Профиль: выбор любимой фракции и строка «Чаще всего играет на»

В кастомизации профиля появился выбор любимой фракции из полного
справочника (PickerSelect, все 12 фракций): фракция — свойство игрока,
а не группы, поэтому список не зависит от подключённых дополнений.
Дополнение показано в строке опции приглушённым текстом, снятие выбора —
кнопкой «Убрать».

В карточке статистики «Любимая фракция» теперь берётся из профиля, а
«Основная фракция» переименована в «Чаще всего играет на» и печатает
название в предложном падеже. Публичный профиль правок не потребовал —
он использует ту же карточку и ту же схему.

schema.d.ts пересобран с живого бэкенда (npm run gen:api).

#17

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ps52xzuUXnWrJ5SRnEeaLk
This commit is contained in:
2026-09-07 16:17:52 +03:00
co-authored by Claude Opus 5
parent 60b22dd8df
commit befabba752
5 changed files with 92 additions and 5 deletions
+9 -1
View File
@@ -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
+5 -4
View File
@@ -38,16 +38,17 @@ export function ProfileStatsCard({
<div>Очки (рейтинг)</div>
<b className={"lb-score" + (provisional ? " provisional" : "")}>{o.score ?? "—"}</b>
</div>
{stats.most_played_faction && (
{/* Любимая — личный выбор игрока в профиле; ниже — статистика по партиям. */}
{stats.favorite_faction && (
<div className="row-between">
<div>Любимая фракция</div>
<b>{stats.most_played_faction.name_ru}</b>
<b>{stats.favorite_faction.name_ru}</b>
</div>
)}
{stats.main_faction && (
<div className="row-between">
<div>Основная фракция</div>
<b>{stats.main_faction.name_ru}</b>
<div>Чаще всего играет на</div>
<b>{stats.main_faction.name_ru_prepositional}</b>
</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'ом отдельным
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
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) {
return useQuery({
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 { 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() {
</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 }}>
<label className="label">Сменить никнейм</label>
<div className="row">