Сменить никнейм
setNickname(e.target.value)} placeholder={me.nickname} />
-
+
ОК
@@ -65,10 +160,7 @@ export function AccountPage() {
- navigate("/admin/login")}
- >
+ navigate("/admin/login")}>
Вход для администратора
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index 98a8d01..89aad8a 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -1,3 +1,5 @@
+import { useNavigate } from "react-router-dom";
+
import { InProgressMatches } from "../components/InProgressMatches";
import { Leaderboard } from "../components/Leaderboard";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
@@ -6,15 +8,23 @@ import { useHome } from "../hooks/stats";
export function HomePage() {
const { data, isLoading, isError } = useHome();
+ const navigate = useNavigate();
if (isLoading) return
;
if (isError || !data) return
Не удалось загрузить данные.
;
return (
-
-
Топ игроков
-
+ {/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
+ ведёт в профиль (Leaderboard останавливает всплытие на ссылках ников). */}
+
navigate("/stats")}>
+
+
Топ игроков
+ Вся статистика →
+
+
+
+
diff --git a/frontend/src/pages/OverallStatsPage.tsx b/frontend/src/pages/OverallStatsPage.tsx
new file mode 100644
index 0000000..75e2134
--- /dev/null
+++ b/frontend/src/pages/OverallStatsPage.tsx
@@ -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
("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 ;
+ if (isError || !data) return Не удалось загрузить статистику.
;
+
+ const entries = filterSort(data.entries);
+ const provisional = filterSort(data.provisional);
+ const arrow = (k: SortKey) => (k === sortKey ? (dir === 1 ? " ↑" : " ↓") : "");
+
+ const row = (e: LeaderboardEntry) => (
+
+ {e.rank ?? "—"}
+
+
+ {e.nickname}
+
+ {e.games}
+ {e.wins}
+ {pct(e.win_rate)}
+ {e.score ?? "—"}
+
+ );
+
+ return (
+
+
+
Общая статистика
+ setQ(e.target.value)} />
+
+
+
+
+ toggleSort("rank")}>#{arrow("rank")}
+ Игрок
+ toggleSort("games")}>Игр{arrow("games")}
+ toggleSort("wins")}>Поб{arrow("wins")}
+ toggleSort("win_rate")}>WR{arrow("win_rate")}
+ toggleSort("score")}>Очки{arrow("score")}
+
+ {entries.length === 0 ? (
+
+ Никого не найдено.
+
+ ) : (
+ entries.map(row)
+ )}
+
+
+ {provisional.length > 0 && (
+
+
Новички (меньше {data.min_games} игр)
+ {provisional.map(row)}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/PublicProfilePage.tsx b/frontend/src/pages/PublicProfilePage.tsx
new file mode 100644
index 0000000..ed986fb
--- /dev/null
+++ b/frontend/src/pages/PublicProfilePage.tsx
@@ -0,0 +1,43 @@
+import { useParams } from "react-router-dom";
+
+import { Avatar } from "../components/Avatar";
+import { ProfileStatsCard } from "../components/ProfileStatsCard";
+import { Spinner } from "../components/Spinner";
+import { usePublicProfile } from "../hooks/stats";
+
+/**
+ * Просмотр ЧУЖОГО профиля — отдельный read-only компонент (намеренно изолирован от
+ * редактируемого AccountPage: здесь нет никаких контролов правки, только просмотр).
+ */
+export function PublicProfilePage() {
+ const { userId } = useParams();
+ const id = Number(userId);
+ const { data, isLoading, isError } = usePublicProfile(id);
+
+ if (isLoading) return ;
+ if (isError || !data) return Профиль не найден.
;
+
+ return (
+
+
+
+
+
+
{data.nickname}
+ {data.bio ? (
+
+ {data.bio}
+
+ ) : (
+
+ Игрок пока не рассказал о себе.
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css
index c3f6487..2c09097 100644
--- a/frontend/src/styles/layout.css
+++ b/frontend/src/styles/layout.css
@@ -90,7 +90,7 @@
/* Таблицы / списки лидерборда */
.lb-row {
display: grid;
- grid-template-columns: 28px 1fr auto;
+ grid-template-columns: 28px 32px 1fr auto;
align-items: center;
gap: 10px;
padding: 8px 4px;
@@ -102,6 +102,26 @@
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
.lb-score { font-weight: 700; color: var(--accent-2); }
+/* Таблица общей статистики (сортируемая, кликабельные строки) */
+.st-row, .st-head {
+ display: grid;
+ grid-template-columns: 26px 1fr 40px 40px 48px 52px;
+ align-items: center;
+ gap: 8px;
+ padding: 8px 2px;
+ border-bottom: 1px solid var(--border);
+}
+.st-head { color: var(--text-muted); font-size: 12px; }
+.st-head button {
+ background: none; border: none; color: inherit; font: inherit;
+ cursor: pointer; padding: 0; text-align: right;
+}
+.st-row:last-child { border-bottom: none; }
+.st-num { text-align: right; font-variant-numeric: tabular-nums; font-size: 14px; }
+.st-num.gold { color: var(--gold); font-weight: 700; }
+.st-player { display: flex; align-items: center; gap: 8px; min-width: 0; font-weight: 600; }
+.st-player .nick { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+
.participant-row {
display: grid;
grid-template-columns: 1fr;