История запрашивалась без 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
265 lines
9.3 KiB
TypeScript
265 lines
9.3 KiB
TypeScript
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>
|
||
);
|
||
}
|