Изменение логики любимых и основных фракций (#17) #20

Merged
NotBigGhost merged 2 commits from issue-17-faction-favorite-and-most-played into dev 2026-09-07 19:03:06 +03:00
5 changed files with 92 additions and 5 deletions
Showing only changes of commit befabba752 - Show all commits
+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">