Кастомизация профиля (о себе, аватар) и просмотр профилей других игроков + общая статистика

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:27:24 +03:00
co-authored by Claude Opus 4.8
parent b302ec769a
commit 96daddae05
22 changed files with 1106 additions and 23 deletions
+92
View File
@@ -0,0 +1,92 @@
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Avatar } from "../components/Avatar";
import { Spinner } from "../components/Spinner";
import type { LeaderboardEntry } from "../domain/types";
import { useLeaderboard } from "../hooks/stats";
type SortKey = "rank" | "games" | "wins" | "win_rate" | "score";
function pct(v: number | null | undefined): string {
return v == null ? "—" : `${Math.round(v * 100)}%`;
}
export function OverallStatsPage() {
const { data, isLoading, isError } = useLeaderboard(200);
const [q, setQ] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("rank");
const [dir, setDir] = useState<1 | -1>(1); // 1 — по возрастанию
const toggleSort = (k: SortKey) => {
if (k === sortKey) setDir((d) => (d === 1 ? -1 : 1));
else {
setSortKey(k);
setDir(k === "rank" ? 1 : -1); // для метрик по умолчанию «лучшие сверху»
}
};
const filterSort = useMemo(() => {
const needle = q.trim().toLowerCase();
const num = (e: LeaderboardEntry) => Number(e[sortKey] ?? 0);
return (list: LeaderboardEntry[]) =>
list
.filter((e) => e.nickname.toLowerCase().includes(needle))
.sort((a, b) => (num(a) - num(b)) * dir);
}, [q, sortKey, dir]);
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить статистику.</div>;
const entries = filterSort(data.entries);
const provisional = filterSort(data.provisional);
const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
const row = (e: LeaderboardEntry) => (
<Link key={e.user_id} to={`/u/${e.user_id}`} className="st-row" style={{ color: "inherit" }}>
<span className={"st-num" + (e.rank && e.rank <= 3 ? " gold" : "")}>{e.rank ?? "—"}</span>
<span className="st-player">
<Avatar url={e.avatar_url} nickname={e.nickname} size={28} />
<span className="nick">{e.nickname}</span>
</span>
<span className="st-num">{e.games}</span>
<span className="st-num">{e.wins}</span>
<span className="st-num">{pct(e.win_rate)}</span>
<span className="st-num lb-score">{e.score ?? "—"}</span>
</Link>
);
return (
<div>
<div className="card">
<h3>Общая статистика</h3>
<input placeholder="Поиск по нику…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="card">
<div className="st-head">
<button onClick={() => toggleSort("rank")}>#{arrow("rank")}</button>
<span>Игрок</span>
<button onClick={() => toggleSort("games")}>Игр{arrow("games")}</button>
<button onClick={() => toggleSort("wins")}>Поб{arrow("wins")}</button>
<button onClick={() => toggleSort("win_rate")}>WR{arrow("win_rate")}</button>
<button onClick={() => toggleSort("score")}>Очки{arrow("score")}</button>
</div>
{entries.length === 0 ? (
<div className="muted small" style={{ paddingTop: 8 }}>
Никого не найдено.
</div>
) : (
entries.map(row)
)}
</div>
{provisional.length > 0 && (
<div className="card">
<div className="label">Новички (меньше {data.min_games} игр)</div>
{provisional.map(row)}
</div>
)}
</div>
);
}