Профиль: блок истории игр с переключателями режима и подробности

В «Аккаунте» появился блок «История игр» с двумя тумблерами — «Только
лучшая партия» и «Подробные карточки»; выбор уходит в профиль, поэтому
переживает перезаход и другое устройство.

Новый компонент MatchHistory: подробный режим переиспользует список партий
группы (MatchListView со всеми участниками), компактный рисует строку с
результатом самого игрока — место, фракция, дата и длительность.

Публичный профиль показывает ту же историю в режиме владельца и без
контролов: страница намеренно read-only.

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

#1

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 19:56:15 +03:00
co-authored by Claude Opus 5
parent be80891449
commit 9dda7de067
7 changed files with 250 additions and 0 deletions
+1
View File
@@ -7,6 +7,7 @@ export const qk = {
factions: ["factions"] as const,
myStats: ["myStats"] as const,
publicProfile: (id: number) => ["publicProfile", id] as const,
userMatches: (id: number) => ["userMatches", id] as const,
userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
groups: ["groups"] as const,
group: (id: number) => ["group", id] as const,
+102
View File
@@ -174,6 +174,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/users/{user_id}/matches": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* User Matches
* @description История партий игрока. Режим — витрина владельца профиля: её видят и гости.
*/
get: operations["user_matches_api_users__user_id__matches_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/users/me/stats": {
parameters: {
query?: never;
@@ -1599,6 +1619,30 @@ export interface components {
/** Faction Id */
faction_id?: number | null;
};
/**
* MatchHistory
* @description История партий игрока плюс настройки витрины его профиля.
*
* Настройки едут вместе со списком, чтобы гость отрисовал историю ровно так,
* как выбрал её владелец, не делая второго запроса за профилем.
*/
MatchHistory: {
/**
* Items
* @default []
*/
items: components["schemas"]["MatchListItem"][];
/** Total */
total: number;
/** Limit */
limit: number;
/** Offset */
offset: number;
/** Mode */
mode: string;
/** Detail */
detail: string;
};
/** MatchList */
MatchList: {
/**
@@ -1765,6 +1809,16 @@ export interface components {
avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/**
* History Mode
* @default all
*/
history_mode: string;
/**
* History Detail
* @default compact
*/
history_detail: string;
/**
* Groups
* @default []
@@ -1904,6 +1958,10 @@ export interface components {
bio?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/** History Mode */
history_mode?: string | null;
/** History Detail */
history_detail?: string | null;
};
/** PublicProfile */
PublicProfile: {
@@ -2012,6 +2070,16 @@ export interface components {
avatar_url?: string | null;
/** Favorite Faction Id */
favorite_faction_id?: number | null;
/**
* History Mode
* @default all
*/
history_mode: string;
/**
* History Detail
* @default compact
*/
history_detail: string;
};
/**
* UserSuggestion
@@ -2354,6 +2422,40 @@ export interface operations {
};
};
};
user_matches_api_users__user_id__matches_get: {
parameters: {
query?: {
limit?: number;
offset?: number;
};
header?: never;
path: {
user_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MatchHistory"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
my_stats_api_users_me_stats_get: {
parameters: {
query?: never;
+60
View File
@@ -0,0 +1,60 @@
import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format";
import type { MatchListItem } from "../domain/types";
import { MatchListView } from "./MatchList";
/**
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
* (MatchListView со всеми участниками); компактный — строка с результатом самого
* владельца профиля, поэтому компонент и принимает его userId.
*/
export function MatchHistory({
items,
userId,
detail,
}: {
items: MatchListItem[];
userId: number;
detail: string;
}) {
const navigate = useNavigate();
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
if (detail === "full") return <MatchListView items={items} />;
return (
<div className="stack">
{items.map((m) => {
const mine = m.participants.find((p) => p.user_id === userId);
return (
<button
key={m.id}
className="card row-between"
style={{ margin: 0, width: "100%", textAlign: "left" }}
onClick={() => navigate(`/match/${m.id}`)}
>
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<span
className={
"place-badge" +
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
(mine?.eliminated ? " eliminated" : "")
}
>
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
</span>
<span
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{mine?.faction_name ?? "—"}
</span>
</div>
<span className="muted small" style={{ flexShrink: 0 }}>
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
</span>
</button>
);
})}
</div>
);
}
+17
View File
@@ -137,6 +137,23 @@ export function useUpdateFavoriteFaction() {
});
}
// Витрина истории партий: режим (all/best) и подробность (compact/full).
// Шлём только изменившееся поле — роутер обновляет лишь пришедшие.
export function useUpdateHistoryPrefs() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (prefs: {
history_mode?: string;
history_detail?: string;
}): Promise<Me> => unwrap(await api.PATCH("/api/users/me/profile", { body: prefs })),
onSuccess: (me) => {
qc.setQueryData(qk.me, me);
// Список истории приходит вместе с режимом — перезапрашиваем его целиком.
qc.invalidateQueries({ queryKey: qk.userMatches(me.id) });
},
});
}
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
function readCsrfToken(): string | null {
+15
View File
@@ -15,6 +15,21 @@ function useDebounced<T>(value: T, ms = 100): T {
return debounced;
}
/** История партий игрока. Режим и подробность приходят вместе со списком:
* это витрина ВЛАДЕЛЬЦА профиля, её видят и гости. */
export function useUserMatches(userId: number | null) {
return useQuery({
queryKey: userId ? qk.userMatches(userId) : ["userMatches", "none"],
enabled: userId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/users/{user_id}/matches", {
params: { path: { user_id: userId as number } },
}),
),
});
}
/** Подсказки по нику среди всех активных игроков (GET /api/users/search). */
export function useUserSearch(q: string, limit = 3) {
const dq = useDebounced(q.trim());
+40
View File
@@ -3,9 +3,11 @@ import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { MatchHistory } from "../components/MatchHistory";
import { PickerSelect } from "../components/PickerSelect";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { Switch } from "../components/Switch";
import { useToast } from "../context/ToastContext";
import {
useDeleteAvatar,
@@ -13,11 +15,13 @@ import {
useMe,
useUpdateBio,
useUpdateFavoriteFaction,
useUpdateHistoryPrefs,
useUpdateNickname,
useUploadAvatar,
} from "../hooks/auth";
import { useExpansions, useFactions } from "../hooks/reference";
import { useMyStats } from "../hooks/stats";
import { useUserMatches } from "../hooks/users";
export function AccountPage() {
const { data: me, isLoading } = useMe();
@@ -25,8 +29,10 @@ export function AccountPage() {
const updateNick = useUpdateNickname();
const updateBio = useUpdateBio();
const updateFavorite = useUpdateFavoriteFaction();
const updateHistory = useUpdateHistoryPrefs();
const { data: factions } = useFactions();
const { data: expansions } = useExpansions();
const { data: history } = useUserMatches(me?.id ?? null);
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const logout = useLogout();
@@ -55,6 +61,14 @@ export function AccountPage() {
}
};
const saveHistory = async (prefs: { history_mode?: string; history_detail?: string }) => {
try {
await updateHistory.mutateAsync(prefs);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveNick = async () => {
const nick = nickname.trim();
if (!nick) return;
@@ -203,6 +217,32 @@ export function AccountPage() {
{stats && <ProfileStatsCard stats={stats} />}
<div className="card">
<h3>История игр</h3>
{/* Настройки — витрина профиля: в этом же виде историю видят и гости. */}
<div className="row-between">
<div>Только лучшая партия</div>
<Switch
checked={me.history_mode === "best"}
disabled={updateHistory.isPending}
onChange={(v) => saveHistory({ history_mode: v ? "best" : "all" })}
/>
</div>
<div className="row-between" style={{ marginTop: 10 }}>
<div>Подробные карточки</div>
<Switch
checked={me.history_detail === "full"}
disabled={updateHistory.isPending}
onChange={(v) => saveHistory({ history_detail: v ? "full" : "compact" })}
/>
</div>
<div style={{ marginTop: 12 }}>
{history && (
<MatchHistory items={history.items} userId={me.id} detail={history.detail} />
)}
</div>
</div>
<button className="btn btn-danger btn-block" onClick={doLogout}>
Выйти
</button>
+15
View File
@@ -3,12 +3,14 @@ import { useParams } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { MatchHistory } from "../components/MatchHistory";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
import { useInviteMember } from "../hooks/groups";
import { usePublicProfile } from "../hooks/stats";
import { useUserMatches } from "../hooks/users";
/**
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
@@ -19,6 +21,7 @@ export function PublicProfilePage() {
const id = Number(userId);
const { data, isLoading, isError } = usePublicProfile(id);
const { data: me } = useMe();
const { data: history } = useUserMatches(Number.isFinite(id) ? id : null);
const invite = useInviteMember();
const toast = useToast();
const [pickOpen, setPickOpen] = useState(false);
@@ -98,6 +101,18 @@ export function PublicProfilePage() {
)}
<ProfileStatsCard stats={data.stats} title="Статистика" />
{/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */}
{history && history.items.length > 0 && (
<div className="card">
<h3>{history.mode === "best" ? "Лучшая партия" : "История игр"}</h3>
<MatchHistory
items={history.items}
userId={data.user_id}
detail={history.detail}
/>
</div>
)}
</div>
);
}