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" ? "Лучшая партия" : "История игр"}
+
+
+ )}
);
}