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

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
+103 -11
View File
@@ -1,26 +1,42 @@
import { useState } from "react";
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { GroupSwitcher } from "../components/GroupSwitcher";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext";
import { useLogout, useMe, useUpdateNickname } from "../hooks/auth";
import {
useDeleteAvatar,
useLogout,
useMe,
useUpdateBio,
useUpdateNickname,
useUploadAvatar,
} from "../hooks/auth";
import { useMyStats } from "../hooks/stats";
export function AccountPage() {
const { data: me, isLoading } = useMe();
const { data: stats } = useMyStats();
const updateNick = useUpdateNickname();
const updateBio = useUpdateBio();
const uploadAvatar = useUploadAvatar();
const deleteAvatar = useDeleteAvatar();
const logout = useLogout();
const toast = useToast();
const navigate = useNavigate();
const fileRef = useRef<HTMLInputElement>(null);
const [nickname, setNickname] = useState("");
const [bio, setBio] = useState<string | null>(null);
if (isLoading || !me) return <Spinner />;
const save = async () => {
// bio редактируется локально; до первого ввода берём значение из профиля.
const bioValue = bio ?? me.bio ?? "";
const saveNick = async () => {
const nick = nickname.trim();
if (!nick) return;
try {
@@ -32,6 +48,36 @@ export function AccountPage() {
}
};
const saveBio = async () => {
try {
await updateBio.mutateAsync(bioValue.trim());
toast.show("Сохранено");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const onPickFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = ""; // позволяем выбрать тот же файл повторно
if (!file) return;
try {
await uploadAvatar.mutateAsync(file);
toast.show("Аватар обновлён");
} catch (err) {
toast.show(err instanceof ApiError ? err.message : "Не удалось загрузить");
}
};
const removeAvatar = async () => {
try {
await deleteAvatar.mutateAsync();
toast.show("Аватар удалён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const doLogout = async () => {
await logout.mutateAsync().catch(() => {});
navigate("/login", { replace: true });
@@ -41,15 +87,64 @@ export function AccountPage() {
<div>
<div className="card">
<h3>Профиль</h3>
<div className="row-between">
<span className="muted">Никнейм</span>
<b>{me.nickname}</b>
<div className="row" style={{ gap: 14 }}>
<Avatar url={me.avatar_url} nickname={me.nickname} size={64} />
<div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{me.nickname}</b>
<div className="row" style={{ gap: 8, marginTop: 8 }}>
<button
className="btn small"
onClick={() => fileRef.current?.click()}
disabled={uploadAvatar.isPending}
>
{me.avatar_url ? "Сменить аватар" : "Загрузить аватар"}
</button>
{me.avatar_url && (
<button
className="btn btn-ghost btn-danger small"
onClick={removeAvatar}
disabled={deleteAvatar.isPending}
>
Удалить
</button>
)}
</div>
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
style={{ display: "none" }}
onChange={onPickFile}
/>
</div>
</div>
<div className="field" style={{ marginTop: 14 }}>
<label className="label">О себе</label>
<textarea
rows={3}
maxLength={500}
placeholder="Пара слов о себе…"
value={bioValue}
onChange={(e) => setBio(e.target.value)}
/>
<div className="row-between" style={{ marginTop: 6 }}>
<span className="muted small">{bioValue.length}/500</span>
<button
className="btn btn-primary small"
onClick={saveBio}
disabled={updateBio.isPending || bioValue.trim() === (me.bio ?? "")}
>
Сохранить
</button>
</div>
</div>
<div className="field" style={{ marginTop: 10 }}>
<label className="label">Сменить никнейм</label>
<div className="row">
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
<button className="btn btn-primary" onClick={save} disabled={updateNick.isPending}>
<button className="btn btn-primary" onClick={saveNick} disabled={updateNick.isPending}>
ОК
</button>
</div>
@@ -65,10 +160,7 @@ export function AccountPage() {
</button>
<p className="center" style={{ marginTop: 24 }}>
<button
className="btn btn-ghost small muted"
onClick={() => navigate("/admin/login")}
>
<button className="btn btn-ghost small muted" onClick={() => navigate("/admin/login")}>
Вход для администратора
</button>
</p>
+13 -3
View File
@@ -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 <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>;
return (
<div>
<div className="card">
<h3>Топ игроков</h3>
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
{/* Карточка кликабельна целиком → общая статистика; клик по нику внутри
ведёт в профиль (Leaderboard останавливает всплытие на ссылках ников). */}
<div className="card" style={{ cursor: "pointer" }} onClick={() => navigate("/stats")}>
<div className="row-between">
<h3 style={{ margin: 0 }}>Топ игроков</h3>
<span className="muted small">Вся статистика →</span>
</div>
<div style={{ marginTop: 10 }}>
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
</div>
</div>
<ProfileStatsCard stats={data.profile} />
+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>
);
}
+43
View File
@@ -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 <Spinner />;
if (isError || !data) return <div className="muted">Профиль не найден.</div>;
return (
<div>
<div className="card">
<div className="row" style={{ gap: 14 }}>
<Avatar url={data.avatar_url} nickname={data.nickname} size={64} />
<div style={{ flex: 1 }}>
<b style={{ fontSize: 18 }}>{data.nickname}</b>
{data.bio ? (
<div className="muted" style={{ marginTop: 6, whiteSpace: "pre-wrap" }}>
{data.bio}
</div>
) : (
<div className="muted small" style={{ marginTop: 6 }}>
Игрок пока не рассказал о себе.
</div>
)}
</div>
</div>
</div>
<ProfileStatsCard stats={data.stats} title="Статистика" />
</div>
);
}