Merge pull request 'Изменение логики любимых и основных фракций (#17)' (#20) from issue-17-faction-favorite-and-most-played into dev

Reviewed-on: #20
This commit was merged in pull request #20.
This commit is contained in:
2026-09-07 19:03:05 +03:00
13 changed files with 299 additions and 38 deletions
@@ -0,0 +1,46 @@
"""Пользователь: любимая фракция (личный выбор в профиле).
Идемпотентна: на свежей БД столбец создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД добавляет столбец. render_as_batch включён в env.py (для SQLite).
Revision ID: 0010_user_favorite_faction
Revises: 0009_participant_eliminated
Create Date: 2026-09-07
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0010_user_favorite_faction"
down_revision: Union[str, None] = "0009_participant_eliminated"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
if "favorite_faction_id" not in cols:
with op.batch_alter_table("users") as b:
b.add_column(sa.Column("favorite_faction_id", sa.Integer(), nullable=True))
b.create_foreign_key(
"fk_users_favorite_faction_id",
"factions",
["favorite_faction_id"],
["id"],
ondelete="SET NULL",
)
def downgrade() -> None:
bind = op.get_bind()
insp = inspect(bind)
cols = {c["name"] for c in insp.get_columns("users")}
if "favorite_faction_id" in cols:
with op.batch_alter_table("users") as b:
b.drop_constraint("fk_users_favorite_faction_id", type_="foreignkey")
b.drop_column("favorite_faction_id")
+10
View File
@@ -97,6 +97,16 @@ class User(SQLModel, table=True):
index=True, index=True,
) )
) )
# Любимая фракция — личный выбор игрока в кастомизации профиля, а НЕ вычисление
# по истории партий (её считает «Чаще всего играет на»). NULL — выбор не сделан.
favorite_faction_id: int | None = Field(
default=None,
sa_column=Column(
Integer,
ForeignKey("factions.id", ondelete="SET NULL"),
nullable=True,
),
)
is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1")) is_active: bool = Field(sa_column=Column(Boolean, nullable=False, server_default="1"))
# Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL # Выбранный титул (slug ачивки), отображаемый под ником. Задел: пока всегда NULL
# (выдача ачивок игрокам — следующий этап). # (выдача ачивок игрокам — следующий этап).
+10 -2
View File
@@ -31,6 +31,7 @@ def build_me(session: Session, user: User) -> s.MeRead:
active_group_id=user.active_group_id, active_group_id=user.active_group_id,
bio=user.bio, bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
groups=groups, groups=groups,
) )
@@ -71,6 +72,7 @@ def update_me(
active_group_id=user.active_group_id, active_group_id=user.active_group_id,
bio=user.bio, bio=user.bio,
avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type] avatar_url=user_service.avatar_url_for(user.id, user.avatar_path, user.updated_at), # type: ignore[arg-type]
favorite_faction_id=user.favorite_faction_id,
) )
@@ -81,14 +83,20 @@ def update_my_profile(
session: Session = Depends(get_session), session: Session = Depends(get_session),
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> s.MeRead: ) -> s.MeRead:
user_service.update_bio(session, user, body.bio) # Обновляем только реально переданные поля: «О себе» и любимая фракция правятся
# по отдельности, и частичный запрос не должен обнулять соседнее поле.
changed = body.model_dump(exclude_unset=True)
if "bio" in changed:
user_service.update_bio(session, user, changed["bio"])
if "favorite_faction_id" in changed:
user_service.update_favorite_faction(session, user, changed["favorite_faction_id"])
audit_service.record( audit_service.record(
session, session,
actor_id=user.id, actor_id=user.id,
action="update", action="update",
entity_type="user", entity_type="user",
entity_id=user.id, entity_id=user.id,
payload={"bio": True}, payload={key: True for key in changed},
ip=request.client.host if request.client else None, ip=request.client.host if request.client else None,
) )
session.commit() session.commit()
+10 -2
View File
@@ -90,6 +90,8 @@ class UserRead(BaseModel):
active_group_id: int | None = None active_group_id: int | None = None
bio: str | None = None bio: str | None = None
avatar_url: str | None = None avatar_url: str | None = None
# Любимая фракция — выбор игрока (id справочника); None — не выбрана.
favorite_faction_id: int | None = None
class MeRead(UserRead): class MeRead(UserRead):
@@ -101,7 +103,10 @@ class NicknameUpdate(BaseModel):
class ProfileUpdate(BaseModel): class ProfileUpdate(BaseModel):
# Оба поля необязательны и обновляются, только если реально переданы
# (роутер смотрит exclude_unset): правка «О себе» не трогает фракцию.
bio: str | None = None bio: str | None = None
favorite_faction_id: int | None = None
class ActiveGroupUpdate(BaseModel): class ActiveGroupUpdate(BaseModel):
@@ -311,6 +316,8 @@ class FactionStat(BaseModel):
faction_id: int faction_id: int
code: str code: str
name_ru: str name_ru: str
# Название в предложном падеже — для строки «Чаще всего играет на …».
name_ru_prepositional: str
expansion_code: str expansion_code: str
games: int games: int
wins: int wins: int
@@ -331,8 +338,9 @@ class ProfileStats(BaseModel):
factions: list[FactionStat] = [] factions: list[FactionStat] = []
best_faction: FactionStat | None = None best_faction: FactionStat | None = None
worst_faction: FactionStat | None = None worst_faction: FactionStat | None = None
# Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом). # Любимая — личный выбор игрока в профиле (не статистика).
most_played_faction: FactionStat | None = None favorite_faction: FactionRead | None = None
# «Чаще всего играет на» — самая игранная по всем партиям, включая рандомные раздачи.
main_faction: FactionStat | None = None main_faction: FactionStat | None = None
recent_form: list[RecentFormItem] = [] recent_form: list[RecentFormItem] = []
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал # Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
+24
View File
@@ -18,3 +18,27 @@ def list_factions(session: Session) -> list[Faction]:
select(Faction).order_by(Faction.expansion_id, Faction.sort_order) select(Faction).order_by(Faction.expansion_id, Faction.sort_order)
).all() ).all()
) )
# Предложный падеж названий фракций для фразы «Чаще всего играет на …».
# Словарь в коде, а не колонка в БД: набор фракций задан игрой и не меняется.
# Фракция, добавленная админом мимо словаря, выводится в именительном падеже.
_PREPOSITIONAL: dict[str, str] = {
"orks": "Орках",
"ultramarines": "Ультрамаринах",
"eldar": "Эльдарах",
"chaos": "Хаоситах",
"astra_militarum": "Имперской гвардии",
"tau": "Тау",
"necrons": "Некронах",
"tyranids": "Тиранидах",
"inquisition": "Инквизиции",
"sisters_of_battle": "Сёстрах битвы",
"drukhari": "Друкхари",
"adeptus_mechanicus": "Адептус Механикус",
}
def prepositional(code: str, name_ru: str) -> str:
"""Название фракции в предложном падеже; вне словаря — именительный как есть."""
return _PREPOSITIONAL.get(code, name_ru)
+26 -14
View File
@@ -7,8 +7,8 @@ from sqlalchemy import text
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.timeutil import iso_utc from app.core.timeutil import iso_utc
from app.models import Group, GroupMember, Match from app.models import Faction, Group, GroupMember, Match, User
from app.services import group_service, membership_service, user_service from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import ( from app.services.scoring import (
FACTION_MIN_GAMES, FACTION_MIN_GAMES,
MATCH_POINTS_SQL, MATCH_POINTS_SQL,
@@ -141,9 +141,8 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
SUM(s.is_win) AS wins, SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate, AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place, AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
-- «лучшая/худшая», сглаживание задавило бы её к 50) -- «лучшая/худшая», сглаживание задавило бы её к 50)
SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games
FROM scored s FROM scored s
JOIN factions f ON f.id = s.faction_id JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id JOIN expansions e ON e.id = f.expansion_id
@@ -168,9 +167,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
"win_rate": _round(r["win_rate"] or 0.0, 4), "win_rate": _round(r["win_rate"] or 0.0, 4),
"avg_place": _round(r["avg_place"], 2), "avg_place": _round(r["avg_place"], 2),
"score": _round(r["score"], 1), "score": _round(r["score"], 1),
# Служебное (в API-схему не входит): партии, где фракция выбрана "name_ru_prepositional": faction_service.prepositional(
# вручную, — для «любимой фракции» рандомные раздачи не в счёт. r["code"], r["name_ru"]
"chosen_games": int(r["chosen_games"] or 0), ),
} }
) )
return out return out
@@ -196,17 +195,30 @@ def _recent_form(session: Session, user_id: int, group_id: int | None, limit: in
] ]
def _favorite_faction(session: Session, user_id: int) -> dict | None:
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
user = session.get(User, user_id)
if user is None or user.favorite_faction_id is None:
return None
faction = session.get(Faction, user.favorite_faction_id)
if faction is None:
return None
return {
"id": faction.id,
"code": faction.code,
"name_ru": faction.name_ru,
"expansion_id": faction.expansion_id,
}
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict: def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
overall = _overall_for_user(session, user_id, group_id) overall = _overall_for_user(session, user_id, group_id)
factions = _faction_breakdown(session, user_id, group_id) factions = _faction_breakdown(session, user_id, group_id)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES] qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
# Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false): # «Чаще всего играет на» — самая игранная фракция по всей истории, включая
# рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой. # рандомные раздачи.
chosen = [f for f in factions if f["chosen_games"] > 0]
most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None
# Основная — просто самая игранная, включая рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None main = max(factions, key=lambda f: f["games"]) if factions else None
return { return {
"user_id": user_id, "user_id": user_id,
@@ -214,7 +226,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
"factions": factions, "factions": factions,
"best_faction": best, "best_faction": best,
"worst_faction": worst, "worst_faction": worst,
"most_played_faction": most_played, "favorite_faction": _favorite_faction(session, user_id),
"main_faction": main, "main_faction": main,
"recent_form": _recent_form(session, user_id, group_id), "recent_form": _recent_form(session, user_id, group_id),
"min_games": MIN_GAMES, "min_games": MIN_GAMES,
+12 -1
View File
@@ -11,7 +11,7 @@ from sqlmodel import Session, select
from app.auth.provider import ExternalIdentity from app.auth.provider import ExternalIdentity
from app.core.config import settings from app.core.config import settings
from app.core.errors import NicknameTakenError, NotFoundError, ValidationError from app.core.errors import NicknameTakenError, NotFoundError, ValidationError
from app.models import AuthIdentity, GroupMember, User from app.models import AuthIdentity, Faction, GroupMember, User
_NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE)
_BIO_MAX = 500 _BIO_MAX = 500
@@ -185,6 +185,17 @@ def update_bio(session: Session, user: User, bio: str | None) -> User:
return user return user
def update_favorite_faction(session: Session, user: User, faction_id: int | None) -> User:
"""Любимая фракция — личный выбор игрока; None очищает выбор."""
if faction_id is not None and session.get(Faction, faction_id) is None:
raise ValidationError("Такой фракции не существует.")
user.favorite_faction_id = faction_id
session.add(user)
session.commit()
session.refresh(user)
return user
def sniff_image_ext(content: bytes) -> str | None: def sniff_image_ext(content: bytes) -> str | None:
"""Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка.""" """Расширение по магическим байтам (PNG/JPEG/WebP), без Pillow. None — не картинка."""
if content.startswith(b"\x89PNG\r\n\x1a\n"): if content.startswith(b"\x89PNG\r\n\x1a\n"):
+69 -14
View File
@@ -1,4 +1,5 @@
"""Профиль: «о себе» (bio), аватар (загрузка/отдача/удаление), публичный профиль.""" """Профиль: «о себе» (bio), любимая фракция, аватар (загрузка/отдача/удаление),
публичный профиль."""
from __future__ import annotations from __future__ import annotations
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@@ -126,8 +127,8 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
def test_most_played_faction_ignores_random(client: TestClient, engine): def test_main_faction_counts_random_games(client: TestClient, engine):
"""Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт.""" """«Чаще всего играет на» — по всей истории, включая рандомные раздачи."""
me = login(client, "Фракционер") me = login(client, "Фракционер")
exps = [e["id"] for e in client.get("/api/expansions").json()] exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post( gid = client.post(
@@ -156,17 +157,15 @@ def test_most_played_faction_ignores_random(client: TestClient, engine):
) )
stats = client.get("/api/users/me/stats").json() stats = client.get("/api/users/me/stats").json()
assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше # Условия выбора не важны: A сыграна чаще, пусть и по рандому.
assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям assert stats["main_faction"]["faction_id"] == fids[0]
assert stats["main_faction"]["name_ru_prepositional"] # падежная форма проставлена
# У соперника все фракции выбраны вручную — любимая считается как раньше. # Любимая больше не выводится из истории — она задаётся игроком вручную.
login(client, "Партнёр") assert stats["favorite_faction"] is None
stats2 = client.get("/api/users/me/stats").json()
assert stats2["most_played_faction"]["faction_id"] == fids[1]
def test_most_played_faction_absent_when_all_random(client: TestClient, engine): def test_main_faction_present_for_random_only_player(client: TestClient, engine):
"""Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе.""" """Игроку, который всегда играет рандомом, «чаще всего играет на» всё равно считается."""
me = login(client, "Рандомщик") me = login(client, "Рандомщик")
exps = [e["id"] for e in client.get("/api/expansions").json()] exps = [e["id"] for e in client.get("/api/expansions").json()]
gid = client.post( gid = client.post(
@@ -183,5 +182,61 @@ def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
], ],
) )
stats = client.get("/api/users/me/stats").json() stats = client.get("/api/users/me/stats").json()
assert stats["most_played_faction"] is None assert stats["main_faction"]["faction_id"] == fids[0]
assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика
def test_favorite_faction_set_and_clear(client: TestClient):
"""Любимая фракция — личный выбор игрока: сохраняется и снимается."""
login(client, "Выбирающий")
fid = client.get("/api/factions").json()[0]["id"]
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
assert r.json()["favorite_faction_id"] == fid
assert client.get("/api/users/me").json()["favorite_faction_id"] == fid
# Партий нет вовсе, а любимая фракция всё равно показывается — она не из статистики.
assert client.get("/api/users/me/stats").json()["favorite_faction"]["id"] == fid
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": None}, headers=csrf_headers(client)
)
assert r.status_code == 200
assert r.json()["favorite_faction_id"] is None
assert client.get("/api/users/me/stats").json()["favorite_faction"] is None
def test_favorite_faction_rejects_unknown(client: TestClient):
login(client, "Фантазёр")
r = client.patch(
"/api/users/me/profile", json={"favorite_faction_id": 99999}, headers=csrf_headers(client)
)
assert r.status_code == 422
def test_bio_update_keeps_favorite_faction(client: TestClient):
"""Частичный PATCH: правка «о себе» не должна обнулять выбранную фракцию."""
login(client, "Аккуратный")
fid = client.get("/api/factions").json()[0]["id"]
client.patch(
"/api/users/me/profile", json={"favorite_faction_id": fid}, headers=csrf_headers(client)
)
r = client.patch("/api/users/me/profile", json={"bio": "Только био"}, headers=csrf_headers(client))
assert r.status_code == 200
assert r.json()["bio"] == "Только био"
assert r.json()["favorite_faction_id"] == fid
def test_prepositional_dictionary_and_fallback():
"""Падежная форма берётся из словаря; незнакомый код — именительный как есть."""
from app.services import faction_service
assert faction_service.prepositional("orks", "Орки") == "Орках"
assert (
faction_service.prepositional("astra_militarum", "Имперская гвардия")
== "Имперской гвардии"
)
# Фракция, заведённая админом мимо словаря, не роняет вывод.
assert faction_service.prepositional("custom_xeno", "Ксеносы") == "Ксеносы"
+9 -1
View File
@@ -1357,6 +1357,8 @@ export interface components {
code: string; code: string;
/** Name Ru */ /** Name Ru */
name_ru: string; name_ru: string;
/** Name Ru Prepositional */
name_ru_prepositional: string;
/** Expansion Code */ /** Expansion Code */
expansion_code: string; expansion_code: string;
/** Games */ /** Games */
@@ -1761,6 +1763,8 @@ export interface components {
bio?: string | null; bio?: string | null;
/** Avatar Url */ /** Avatar Url */
avatar_url?: string | null; avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/** /**
* Groups * Groups
* @default [] * @default []
@@ -1881,7 +1885,7 @@ export interface components {
factions: components["schemas"]["FactionStat"][]; factions: components["schemas"]["FactionStat"][];
best_faction?: components["schemas"]["FactionStat"] | null; best_faction?: components["schemas"]["FactionStat"] | null;
worst_faction?: components["schemas"]["FactionStat"] | null; worst_faction?: components["schemas"]["FactionStat"] | null;
most_played_faction?: components["schemas"]["FactionStat"] | null; favorite_faction?: components["schemas"]["FactionRead"] | null;
main_faction?: components["schemas"]["FactionStat"] | null; main_faction?: components["schemas"]["FactionStat"] | null;
/** /**
* Recent Form * Recent Form
@@ -1898,6 +1902,8 @@ export interface components {
ProfileUpdate: { ProfileUpdate: {
/** Bio */ /** Bio */
bio?: string | null; bio?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
}; };
/** PublicProfile */ /** PublicProfile */
PublicProfile: { PublicProfile: {
@@ -2004,6 +2010,8 @@ export interface components {
bio?: string | null; bio?: string | null;
/** Avatar Url */ /** Avatar Url */
avatar_url?: string | null; avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
}; };
/** /**
* UserSuggestion * UserSuggestion
+5 -4
View File
@@ -38,16 +38,17 @@ export function ProfileStatsCard({
<div>Очки (рейтинг)</div> <div>Очки (рейтинг)</div>
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b> <b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
</div> </div>
{stats.most_played_faction && ( {/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
{stats.favorite_faction && (
<div className="row-between"> <div className="row-between">
<div>Любимая фракция</div> <div>Любимая фракция</div>
<b>{stats.most_played_faction.name_ru}</b> <b>{stats.favorite_faction.name_ru}</b>
</div> </div>
)} )}
{stats.main_faction && ( {stats.main_faction && (
<div className="row-between"> <div className="row-between">
<div>Основная фракция</div> <div>Чаще всего играет на</div>
<b>{stats.main_faction.name_ru}</b> <b>{stats.main_faction.name_ru_prepositional}</b>
</div> </div>
)} )}
</div> </div>
+20
View File
@@ -117,6 +117,26 @@ export function useUpdateBio() {
}); });
} }
// Любимая фракция — личный выбор игрока (в статистике не вычисляется). null снимает выбор.
// Шлём только это поле: роутер обновляет лишь пришедшие, «о себе» не затирается.
export function useUpdateFavoriteFaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (factionId: number | null): Promise<Me> =>
unwrap(
await api.PATCH("/api/users/me/profile", {
body: { favorite_faction_id: factionId },
}),
),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
// Карточка статистики показывает любимую фракцию — её тоже перезапрашиваем.
qc.invalidateQueries({ queryKey: qk.myStats });
qc.invalidateQueries({ queryKey: qk.home });
},
});
}
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным // Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. // fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
function readCsrfToken(): string | null { function readCsrfToken(): string | null {
+8
View File
@@ -10,6 +10,14 @@ export function useExpansions() {
}); });
} }
// Весь справочник фракций (для выбора любимой в профиле — он не зависит от группы).
export function useFactions() {
return useQuery({
queryKey: qk.factions,
queryFn: async () => unwrap(await api.GET("/api/factions")),
});
}
export function useGroupFactions(groupId: number | null) { export function useGroupFactions(groupId: number | null) {
return useQuery({ return useQuery({
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"], queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
+50
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client"; import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar"; import { Avatar } from "../components/Avatar";
import { PickerSelect } from "../components/PickerSelect";
import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner"; import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext"; import { useToast } from "../context/ToastContext";
@@ -11,9 +12,11 @@ import {
useLogout, useLogout,
useMe, useMe,
useUpdateBio, useUpdateBio,
useUpdateFavoriteFaction,
useUpdateNickname, useUpdateNickname,
useUploadAvatar, useUploadAvatar,
} from "../hooks/auth"; } from "../hooks/auth";
import { useExpansions, useFactions } from "../hooks/reference";
import { useMyStats } from "../hooks/stats"; import { useMyStats } from "../hooks/stats";
export function AccountPage() { export function AccountPage() {
@@ -21,6 +24,9 @@ export function AccountPage() {
const { data: stats } = useMyStats(); const { data: stats } = useMyStats();
const updateNick = useUpdateNickname(); const updateNick = useUpdateNickname();
const updateBio = useUpdateBio(); const updateBio = useUpdateBio();
const updateFavorite = useUpdateFavoriteFaction();
const { data: factions } = useFactions();
const { data: expansions } = useExpansions();
const uploadAvatar = useUploadAvatar(); const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar(); const deleteAvatar = useDeleteAvatar();
const logout = useLogout(); const logout = useLogout();
@@ -35,6 +41,20 @@ export function AccountPage() {
// bio редактируется локально; до первого ввода берём значение из профиля. // bio редактируется локально; до первого ввода берём значение из профиля.
const bioValue = bio ?? me.bio ?? ""; const bioValue = bio ?? me.bio ?? "";
// Любимая фракция выбирается из ПОЛНОГО справочника: это свойство игрока, а не
// группы, и не должно зависеть от подключённых у неё дополнений.
const favorite = factions?.find((f) => f.id === me.favorite_faction_id) ?? null;
const expansionName = (id: number) => expansions?.find((e) => e.id === id)?.name_ru ?? "";
const saveFavorite = async (factionId: number | null) => {
try {
await updateFavorite.mutateAsync(factionId);
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveNick = async () => { const saveNick = async () => {
const nick = nickname.trim(); const nick = nickname.trim();
if (!nick) return; if (!nick) return;
@@ -140,6 +160,36 @@ export function AccountPage() {
</div> </div>
</div> </div>
<div className="field" style={{ marginTop: 10 }}>
<label className="label">Любимая фракция</label>
<div className="row">
<PickerSelect
selected={favorite}
options={factions ?? []}
placeholder="Не выбрана"
renderOption={(f) => (
<>
<span className="nick">{f.name_ru}</span>
<span className="muted small" style={{ marginLeft: "auto" }}>
{expansionName(f.expansion_id)}
</span>
</>
)}
renderValue={(f) => <span className="nick">{f.name_ru}</span>}
onPick={(f) => saveFavorite(f.id)}
/>
{favorite && (
<button
className="btn btn-ghost small"
onClick={() => saveFavorite(null)}
disabled={updateFavorite.isPending}
>
Убрать
</button>
)}
</div>
</div>
<div className="field" style={{ marginTop: 10 }}> <div className="field" style={{ marginTop: 10 }}>
<label className="label">Сменить никнейм</label> <label className="label">Сменить никнейм</label>
<div className="row"> <div className="row">