Кастомизация профиля (о себе, аватар) и просмотр профилей других игроков + общая статистика
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user