diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 951a533..99e8c19 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -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, diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index c5830ac..2b2a03b 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -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; diff --git a/frontend/src/components/MatchHistory.tsx b/frontend/src/components/MatchHistory.tsx new file mode 100644 index 0000000..b03c6d5 --- /dev/null +++ b/frontend/src/components/MatchHistory.tsx @@ -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
Партий пока нет.
; + if (detail === "full") return ; + + return ( +
+ {items.map((m) => { + const mine = m.participants.find((p) => p.user_id === userId); + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 4c36ee8..2982779 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -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 => 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 { diff --git a/frontend/src/hooks/users.ts b/frontend/src/hooks/users.ts index 191a328..4502ecd 100644 --- a/frontend/src/hooks/users.ts +++ b/frontend/src/hooks/users.ts @@ -15,6 +15,21 @@ function useDebounced(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()); diff --git a/frontend/src/pages/AccountPage.tsx b/frontend/src/pages/AccountPage.tsx index 0914290..871d573 100644 --- a/frontend/src/pages/AccountPage.tsx +++ b/frontend/src/pages/AccountPage.tsx @@ -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 && } +
+

История игр

+ {/* Настройки — витрина профиля: в этом же виде историю видят и гости. */} +
+
Только лучшая партия
+ saveHistory({ history_mode: v ? "best" : "all" })} + /> +
+
+
Подробные карточки
+ saveHistory({ history_detail: v ? "full" : "compact" })} + /> +
+
+ {history && ( + + )} +
+
+ diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx index 6b5a843..9c8c0dc 100644 --- a/frontend/src/pages/PublicProfilePage.tsx +++ b/frontend/src/pages/PublicProfilePage.tsx @@ -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() { )} + + {/* История в том виде, который выбрал владелец профиля; контролов здесь нет. */} + {history && history.items.length > 0 && ( +
+

{history.mode === "best" ? "Лучшая партия" : "История игр"}

+ +
+ )} ); }