Профиль: блок истории игр с переключателями режима и подробности
В «Аккаунте» появился блок «История игр» с двумя тумблерами — «Только лучшая партия» и «Подробные карточки»; выбор уходит в профиль, поэтому переживает перезаход и другое устройство. Новый компонент 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,
|
||||
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,
|
||||
|
||||
Vendored
+102
@@ -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;
|
||||
|
||||
@@ -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'ом отдельным
|
||||
// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную.
|
||||
function readCsrfToken(): string | null {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user