Files
ForbiddenStarsApp/frontend/src/pages/OverallStatsPage.tsx
T
NotBigGhostandClaude Fable 5 45bdadece9 UI: прочерк рейтинга у ещё не игравших — серебристый, не золотой
Класс .provisional (серебро) применяется и при score = null: строки
«Ещё не играли» в топах и прочерк «Очки (рейтинг)» в карточке профиля.
Золотым остаётся только подтверждённый рейтинг.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-17 03:07:55 +03:00

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>
);
}