Files
ForbiddenStarsApp/frontend/src/pages/AccountPage.tsx
T
NotBigGhostandClaude Opus 5 f2aaa2a15e Профиль: догрузка истории и счётчик партий в шапке
История запрашивалась без limit/offset, поэтому бэкенд отдавал первые 20 партий, а
страницы рисовали их и молча игнорировали total: у игрока с 40 партиями половина
истории просто не существовала — ни кнопки, ни намёка.

Теперь история грузится страницами по 20, под списком — «Показать ещё». Постранично,
а не одним большим запросом: у эндпоинта потолок limit=100, и на 101-й партии
обрезка вернулась бы. Кнопка живёт в самом компоненте истории, поэтому появилась
сразу и в своём профиле, и в чужом, и в обоих режимах подробности.

Общее число партий вынесено в шапку профиля, к нику. Берём его из статистики, а не
из total истории: в режиме «только лучшая партия» total равен единице, и счётчик
показывал бы «Партий: 1».

#30

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
2026-09-09 20:27:38 +03:00

265 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Avatar } from "../components/Avatar";
import { MatchHistory } from "../components/MatchHistory";
import { PickerSelect } from "../components/PickerSelect";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { Switch } from "../components/Switch";
import { useToast } from "../context/ToastContext";
import {
useDeleteAvatar,
useLogout,
useMe,
useUpdateBio,
useUpdateFavoriteFaction,
useUpdateHistoryPrefs,
useUpdateNickname,
useUploadAvatar,
} from "../hooks/auth";
import { useExpansions, useFactions } from "../hooks/reference";
import { useMyStats } from "../hooks/stats";
import { useUserMatches } from "../hooks/users";
export function AccountPage() {
const { data: me, isLoading } = useMe();
const { data: stats } = useMyStats();
const updateNick = useUpdateNickname();
const updateBio = useUpdateBio();
const updateFavorite = useUpdateFavoriteFaction();
const updateHistory = useUpdateHistoryPrefs();
const { data: factions } = useFactions();
const { data: expansions } = useExpansions();
const {
data: history,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useUserMatches(me?.id ?? null);
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 />;
// bio редактируется локально; до первого ввода берём значение из профиля.
const bioValue = bio ?? me.bio ?? "";
// Любимая фракция выбирается из ПОЛНОГО справочника: это свойство игрока, а не
// группы, и не должно зависеть от подключённых у неё дополнений.
const favorite = factions?.find((f) => f.id === me.favorite_faction_id) ?? null;
const expansionName = (id: number) => expansions?.find((e) => e.id === id)?.name_ru ?? "";
const saveFavorite = async (factionId: number | null) => {
try {
await updateFavorite.mutateAsync(factionId);
toast.show(factionId == null ? "Любимая фракция убрана" : "Любимая фракция сохранена");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveHistory = async (prefs: { history_mode?: string; history_detail?: string }) => {
try {
await updateHistory.mutateAsync(prefs);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saveNick = async () => {
const nick = nickname.trim();
if (!nick) return;
try {
await updateNick.mutateAsync(nick);
setNickname("");
toast.show("Никнейм обновлён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
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 });
};
return (
<div>
<div className="card">
<h3>Профиль</h3>
<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>
{stats && <div className="muted small">Партий: {stats.overall.games}</div>}
<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="Пара слов о себе…"
style={{ resize: "none" }}
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">
<PickerSelect
selected={favorite}
options={factions ?? []}
placeholder="Не выбрана"
renderOption={(f) => (
<>
<span className="nick">{f.name_ru}</span>
<span className="muted small" style={{ marginLeft: "auto" }}>
{expansionName(f.expansion_id)}
</span>
</>
)}
renderValue={(f) => <span className="nick">{f.name_ru}</span>}
onPick={(f) => saveFavorite(f.id)}
/>
{favorite && (
<button
className="btn btn-ghost small"
onClick={() => saveFavorite(null)}
disabled={updateFavorite.isPending}
>
Убрать
</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={saveNick} disabled={updateNick.isPending}>
Сохранить
</button>
</div>
</div>
</div>
{stats && <ProfileStatsCard stats={stats} />}
<div className="card">
<h3>История игр</h3>
{/* Настройки — витрина профиля: в этом же виде историю видят и гости. */}
<div className="row-between">
<div>Только лучшая партия</div>
<Switch
checked={me.history_mode === "best"}
disabled={updateHistory.isPending}
onChange={(v) => saveHistory({ history_mode: v ? "best" : "all" })}
/>
</div>
<div className="row-between" style={{ marginTop: 10 }}>
<div>Подробные карточки</div>
<Switch
checked={me.history_detail === "full"}
disabled={updateHistory.isPending}
onChange={(v) => saveHistory({ history_detail: v ? "full" : "compact" })}
/>
</div>
<div style={{ marginTop: 12 }}>
{history && (
<MatchHistory
items={history.pages.flatMap((p) => p.items)}
userId={me.id}
detail={history.pages[0].detail}
hasMore={hasNextPage}
loadingMore={isFetchingNextPage}
onMore={() => fetchNextPage()}
/>
)}
</div>
</div>
<button className="btn btn-danger btn-block" onClick={doLogout}>
Выйти
</button>
</div>
);
}