From befabba752c107a0fa38c6bb2e1bad4236fe7a74 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Mon, 7 Sep 2026 16:17:52 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=BE=D1=84=D0=B8=D0=BB=D1=8C:=20?= =?UTF-8?q?=D0=B2=D1=8B=D0=B1=D0=BE=D1=80=20=D0=BB=D1=8E=D0=B1=D0=B8=D0=BC?= =?UTF-8?q?=D0=BE=D0=B9=20=D1=84=D1=80=D0=B0=D0=BA=D1=86=D0=B8=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B0=20=C2=AB=D0=A7?= =?UTF-8?q?=D0=B0=D1=89=D0=B5=20=D0=B2=D1=81=D0=B5=D0=B3=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=B3=D1=80=D0=B0=D0=B5=D1=82=20=D0=BD=D0=B0=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit В кастомизации профиля появился выбор любимой фракции из полного справочника (PickerSelect, все 12 фракций): фракция — свойство игрока, а не группы, поэтому список не зависит от подключённых дополнений. Дополнение показано в строке опции приглушённым текстом, снятие выбора — кнопкой «Убрать». В карточке статистики «Любимая фракция» теперь берётся из профиля, а «Основная фракция» переименована в «Чаще всего играет на» и печатает название в предложном падеже. Публичный профиль правок не потребовал — он использует ту же карточку и ту же схему. schema.d.ts пересобран с живого бэкенда (npm run gen:api). #17 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk --- frontend/src/api/schema.d.ts | 10 +++- frontend/src/components/ProfileStatsCard.tsx | 9 ++-- frontend/src/hooks/auth.ts | 20 ++++++++ frontend/src/hooks/reference.ts | 8 ++++ frontend/src/pages/AccountPage.tsx | 50 ++++++++++++++++++++ 5 files changed, 92 insertions(+), 5 deletions(-) diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index ff8fb94..c5830ac 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1357,6 +1357,8 @@ export interface components { code: string; /** Name Ru */ name_ru: string; + /** Name Ru Prepositional */ + name_ru_prepositional: string; /** Expansion Code */ expansion_code: string; /** Games */ @@ -1761,6 +1763,8 @@ export interface components { bio?: string | null; /** Avatar Url */ avatar_url?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; /** * Groups * @default [] @@ -1881,7 +1885,7 @@ export interface components { factions: components["schemas"]["FactionStat"][]; best_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; /** * Recent Form @@ -1898,6 +1902,8 @@ export interface components { ProfileUpdate: { /** Bio */ bio?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; }; /** PublicProfile */ PublicProfile: { @@ -2004,6 +2010,8 @@ export interface components { bio?: string | null; /** Avatar Url */ avatar_url?: string | null; + /** Favorite Faction Id */ + favorite_faction_id?: number | null; }; /** * UserSuggestion diff --git a/frontend/src/components/ProfileStatsCard.tsx b/frontend/src/components/ProfileStatsCard.tsx index adc1de7..496d9e2 100644 --- a/frontend/src/components/ProfileStatsCard.tsx +++ b/frontend/src/components/ProfileStatsCard.tsx @@ -38,16 +38,17 @@ export function ProfileStatsCard({
Очки (рейтинг)
{o.score ?? "—"} - {stats.most_played_faction && ( + {/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */} + {stats.favorite_faction && (
Любимая фракция
- {stats.most_played_faction.name_ru} + {stats.favorite_faction.name_ru}
)} {stats.main_faction && (
-
Основная фракция
- {stats.main_faction.name_ru} +
Чаще всего играет на
+ {stats.main_faction.name_ru_prepositional}
)} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index aef11de..4c36ee8 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -117,6 +117,26 @@ export function useUpdateBio() { }); } +// Любимая фракция — личный выбор игрока (в статистике не вычисляется). null снимает выбор. +// Шлём только это поле: роутер обновляет лишь пришедшие, «о себе» не затирается. +export function useUpdateFavoriteFaction() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (factionId: number | null): Promise => + 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'ом отдельным // fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. function readCsrfToken(): string | null { diff --git a/frontend/src/hooks/reference.ts b/frontend/src/hooks/reference.ts index 7f5133d..0b8abca 100644 --- a/frontend/src/hooks/reference.ts +++ b/frontend/src/hooks/reference.ts @@ -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) { return useQuery({ queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"], diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 14aac89..0914290 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { ApiError } from "../api/client"; import { Avatar } from "../components/Avatar"; +import { PickerSelect } from "../components/PickerSelect"; import { ProfileStatsCard } from "../components/ProfileStatsCard"; import { Spinner } from "../components/Spinner"; import { useToast } from "../context/ToastContext"; @@ -11,9 +12,11 @@ import { useLogout, useMe, useUpdateBio, + useUpdateFavoriteFaction, useUpdateNickname, useUploadAvatar, } from "../hooks/auth"; +import { useExpansions, useFactions } from "../hooks/reference"; import { useMyStats } from "../hooks/stats"; export function AccountPage() { @@ -21,6 +24,9 @@ export function AccountPage() { const { data: stats } = useMyStats(); const updateNick = useUpdateNickname(); const updateBio = useUpdateBio(); + const updateFavorite = useUpdateFavoriteFaction(); + const { data: factions } = useFactions(); + const { data: expansions } = useExpansions(); const uploadAvatar = useUploadAvatar(); const deleteAvatar = useDeleteAvatar(); const logout = useLogout(); @@ -35,6 +41,20 @@ export function AccountPage() { // 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 nick = nickname.trim(); if (!nick) return; @@ -140,6 +160,36 @@ export function AccountPage() { +
+ +
+ ( + <> + {f.name_ru} + + {expansionName(f.expansion_id)} + + + )} + renderValue={(f) => {f.name_ru}} + onPick={(f) => saveFavorite(f.id)} + /> + {favorite && ( + + )} +
+
+