Класс .provisional (серебро) применяется и при score = null: строки «Ещё не играли» в топах и прочерк «Очки (рейтинг)» в карточке профиля. Золотым остаётся только подтверждённый рейтинг. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
95 lines
3.6 KiB
TypeScript
95 lines
3.6 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
|
|
import { Avatar } from "../components/Avatar";
|
|
import { PlayerLink } from "../components/PlayerLink";
|
|
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, isProvisional = false) => (
|
|
<PlayerLink key={e.user_id} userId={e.user_id} className="st-row">
|
|
<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" + (isProvisional || e.score == null ? " provisional" : "")}>
|
|
{e.score ?? "—"}
|
|
</span>
|
|
</PlayerLink>
|
|
);
|
|
|
|
return (
|
|
<div>
|
|
{/* Название страницы уже в верхней панели — в теле сразу поиск. */}
|
|
<div className="card">
|
|
<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((e) => row(e))
|
|
)}
|
|
</div>
|
|
|
|
{provisional.length > 0 && (
|
|
<div className="card">
|
|
<div className="label">Новички (меньше {data.min_games} игр)</div>
|
|
{provisional.map((e) => row(e, true))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|