Профиль: блок истории игр с переключателями режима и подробности
В «Аккаунте» появился блок «История игр» с двумя тумблерами — «Только лучшая партия» и «Подробные карточки»; выбор уходит в профиль, поэтому переживает перезаход и другое устройство. Новый компонент 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:
@@ -7,6 +7,7 @@ export const qk = {
|
|||||||
factions: ["factions"] as const,
|
factions: ["factions"] as const,
|
||||||
myStats: ["myStats"] as const,
|
myStats: ["myStats"] as const,
|
||||||
publicProfile: (id: number) => ["publicProfile", id] 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,
|
userSearch: (q: string, limit: number) => ["userSearch", q, limit] as const,
|
||||||
groups: ["groups"] as const,
|
groups: ["groups"] as const,
|
||||||
group: (id: number) => ["group", id] as const,
|
group: (id: number) => ["group", id] as const,
|
||||||
|
|||||||
Vendored
+102
@@ -174,6 +174,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/users/me/stats": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1599,6 +1619,30 @@ export interface components {
|
|||||||
/** Faction Id */
|
/** Faction Id */
|
||||||
faction_id?: number | null;
|
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 */
|
||||||
MatchList: {
|
MatchList: {
|
||||||
/**
|
/**
|
||||||
@@ -1765,6 +1809,16 @@ export interface components {
|
|||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
/** Favorite Faction Id */
|
/** Favorite Faction Id */
|
||||||
favorite_faction_id?: number | null;
|
favorite_faction_id?: number | null;
|
||||||
|
/**
|
||||||
|
* History Mode
|
||||||
|
* @default all
|
||||||
|
*/
|
||||||
|
history_mode: string;
|
||||||
|
/**
|
||||||
|
* History Detail
|
||||||
|
* @default compact
|
||||||
|
*/
|
||||||
|
history_detail: string;
|
||||||
/**
|
/**
|
||||||
* Groups
|
* Groups
|
||||||
* @default []
|
* @default []
|
||||||
@@ -1904,6 +1958,10 @@ export interface components {
|
|||||||
bio?: string | null;
|
bio?: string | null;
|
||||||
/** Favorite Faction Id */
|
/** Favorite Faction Id */
|
||||||
favorite_faction_id?: number | null;
|
favorite_faction_id?: number | null;
|
||||||
|
/** History Mode */
|
||||||
|
history_mode?: string | null;
|
||||||
|
/** History Detail */
|
||||||
|
history_detail?: string | null;
|
||||||
};
|
};
|
||||||
/** PublicProfile */
|
/** PublicProfile */
|
||||||
PublicProfile: {
|
PublicProfile: {
|
||||||
@@ -2012,6 +2070,16 @@ export interface components {
|
|||||||
avatar_url?: string | null;
|
avatar_url?: string | null;
|
||||||
/** Favorite Faction Id */
|
/** Favorite Faction Id */
|
||||||
favorite_faction_id?: number | null;
|
favorite_faction_id?: number | null;
|
||||||
|
/**
|
||||||
|
* History Mode
|
||||||
|
* @default all
|
||||||
|
*/
|
||||||
|
history_mode: string;
|
||||||
|
/**
|
||||||
|
* History Detail
|
||||||
|
* @default compact
|
||||||
|
*/
|
||||||
|
history_detail: string;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* UserSuggestion
|
* 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: {
|
my_stats_api_users_me_stats_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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'ом отдельным
|
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным
|
||||||
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
|
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
|
||||||
function readCsrfToken(): string | null {
|
function readCsrfToken(): string | null {
|
||||||
|
|||||||
@@ -15,6 +15,21 @@ function useDebounced<T>(value: T, ms = 100): T {
|
|||||||
return debounced;
|
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). */
|
/** Подсказки по нику среди всех активных игроков (GET /api/users/search). */
|
||||||
export function useUserSearch(q: string, limit = 3) {
|
export function useUserSearch(q: string, limit = 3) {
|
||||||
const dq = useDebounced(q.trim());
|
const dq = useDebounced(q.trim());
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ 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 { MatchHistory } from "../components/MatchHistory";
|
||||||
import { PickerSelect } from "../components/PickerSelect";
|
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 { Switch } from "../components/Switch";
|
||||||
import { useToast } from "../context/ToastContext";
|
import { useToast } from "../context/ToastContext";
|
||||||
import {
|
import {
|
||||||
useDeleteAvatar,
|
useDeleteAvatar,
|
||||||
@@ -13,11 +15,13 @@ import {
|
|||||||
useMe,
|
useMe,
|
||||||
useUpdateBio,
|
useUpdateBio,
|
||||||
useUpdateFavoriteFaction,
|
useUpdateFavoriteFaction,
|
||||||
|
useUpdateHistoryPrefs,
|
||||||
useUpdateNickname,
|
useUpdateNickname,
|
||||||
useUploadAvatar,
|
useUploadAvatar,
|
||||||
} from "../hooks/auth";
|
} from "../hooks/auth";
|
||||||
import { useExpansions, useFactions } from "../hooks/reference";
|
import { useExpansions, useFactions } from "../hooks/reference";
|
||||||
import { useMyStats } from "../hooks/stats";
|
import { useMyStats } from "../hooks/stats";
|
||||||
|
import { useUserMatches } from "../hooks/users";
|
||||||
|
|
||||||
export function AccountPage() {
|
export function AccountPage() {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading } = useMe();
|
||||||
@@ -25,8 +29,10 @@ export function AccountPage() {
|
|||||||
const updateNick = useUpdateNickname();
|
const updateNick = useUpdateNickname();
|
||||||
const updateBio = useUpdateBio();
|
const updateBio = useUpdateBio();
|
||||||
const updateFavorite = useUpdateFavoriteFaction();
|
const updateFavorite = useUpdateFavoriteFaction();
|
||||||
|
const updateHistory = useUpdateHistoryPrefs();
|
||||||
const { data: factions } = useFactions();
|
const { data: factions } = useFactions();
|
||||||
const { data: expansions } = useExpansions();
|
const { data: expansions } = useExpansions();
|
||||||
|
const { data: history } = useUserMatches(me?.id ?? null);
|
||||||
const uploadAvatar = useUploadAvatar();
|
const uploadAvatar = useUploadAvatar();
|
||||||
const deleteAvatar = useDeleteAvatar();
|
const deleteAvatar = useDeleteAvatar();
|
||||||
const logout = useLogout();
|
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 saveNick = async () => {
|
||||||
const nick = nickname.trim();
|
const nick = nickname.trim();
|
||||||
if (!nick) return;
|
if (!nick) return;
|
||||||
@@ -203,6 +217,32 @@ export function AccountPage() {
|
|||||||
|
|
||||||
{stats && <ProfileStatsCard stats={stats} />}
|
{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 className="btn btn-danger btn-block" onClick={doLogout}>
|
||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { useParams } 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 { MatchHistory } from "../components/MatchHistory";
|
||||||
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";
|
||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useInviteMember } from "../hooks/groups";
|
import { useInviteMember } from "../hooks/groups";
|
||||||
import { usePublicProfile } from "../hooks/stats";
|
import { usePublicProfile } from "../hooks/stats";
|
||||||
|
import { useUserMatches } from "../hooks/users";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
|
* Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
|
||||||
@@ -19,6 +21,7 @@ export function PublicProfilePage() {
|
|||||||
const id = Number(userId);
|
const id = Number(userId);
|
||||||
const { data, isLoading, isError } = usePublicProfile(id);
|
const { data, isLoading, isError } = usePublicProfile(id);
|
||||||
const { data: me } = useMe();
|
const { data: me } = useMe();
|
||||||
|
const { data: history } = useUserMatches(Number.isFinite(id) ? id : null);
|
||||||
const invite = useInviteMember();
|
const invite = useInviteMember();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [pickOpen, setPickOpen] = useState(false);
|
const [pickOpen, setPickOpen] = useState(false);
|
||||||
@@ -98,6 +101,18 @@ export function PublicProfilePage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<ProfileStatsCard stats={data.stats} title="Статистика" />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user