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

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>