Ревью: корректность фронтенда
Находки прохода /code-review high по frontend/src: - CreateMatchPage: барабан рандома, таймеры и строки адресовались по индексу, а удаление строки индексы сдвигает — фракция могла записаться соседу или потеряться. Строки получили стабильный id. - AdminAccountsPage: переключение «активен» глотало ошибку и всё равно показывало «Сохранено». - useServerEvents/useFinishMatch: завершение чужой партии не инвалидировало историю игр и публичные профили — открытый профиль показывал состав до завершения. - guards/useMe: обрыв связи не отличался от «нет сессии», и мигание сети выкидывало авторизованного пользователя на /login. Транспортные ошибки повторяем, гварды показывают сообщение вместо редиректа. - format: fallback на нераспознанную дату не работал (new Date не бросает исключение), и в интерфейс попадало «NaN.NaN NaN:NaN». - PlaceEditor: без onPointerCancel прерванный перенос оставлял блок с классом dragging и сдвигом, которые React не снимает — они выставлены в обход него. - AdminFactionsPage: refetch после сохранения одной фракции затирал несохранённый ввод в остальных. - MatchDetailPage: nav(-1) после удаления уводил из приложения при открытии партии по прямой ссылке; id из URL мог быть NaN и уходил в запрос. #8 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
This commit is contained in:
@@ -5,17 +5,24 @@ import { Spinner } from "../components/Spinner";
|
|||||||
import { useMe } from "../hooks/auth";
|
import { useMe } from "../hooks/auth";
|
||||||
import { useAdminMe } from "../hooks/admin";
|
import { useAdminMe } from "../hooks/admin";
|
||||||
|
|
||||||
|
/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */
|
||||||
|
function OfflineNotice() {
|
||||||
|
return <div className="muted">Нет связи с сервером. Проверьте подключение и обновите страницу.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
export function RequireAuth({ children }: PropsWithChildren) {
|
export function RequireAuth({ children }: PropsWithChildren) {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading, isError } = useMe();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <OfflineNotice />;
|
||||||
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RequireGroup({ children }: PropsWithChildren) {
|
export function RequireGroup({ children }: PropsWithChildren) {
|
||||||
const { data: me, isLoading } = useMe();
|
const { data: me, isLoading, isError } = useMe();
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
if (isError) return <OfflineNotice />;
|
||||||
if (!me) return <Navigate to="/login" replace />;
|
if (!me) return <Navigate to="/login" replace />;
|
||||||
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|||||||
@@ -182,6 +182,10 @@ export function PlaceEditor({
|
|||||||
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts),
|
onPointerDown: (e: React.PointerEvent<HTMLDivElement>) => startDrag(e, opts),
|
||||||
onPointerMove: moveDrag,
|
onPointerMove: moveDrag,
|
||||||
onPointerUp: endDrag,
|
onPointerUp: endDrag,
|
||||||
|
// Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого
|
||||||
|
// блок остаётся с классом dragging и сдвигом: они выставлены в обход React,
|
||||||
|
// и перерендер их не снимет.
|
||||||
|
onPointerCancel: endDrag,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
|
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
|
||||||
|
|||||||
@@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string {
|
|||||||
|
|
||||||
export function formatTime(iso: string | null | undefined): string {
|
export function formatTime(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
try {
|
|
||||||
const d = shifted(iso);
|
const d = shifted(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
|
// «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части,
|
||||||
@@ -47,10 +44,7 @@ export function formatDate(iso: string | null | undefined): string {
|
|||||||
|
|
||||||
export function formatDateTime(iso: string | null | undefined): string {
|
export function formatDateTime(iso: string | null | undefined): string {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
try {
|
|
||||||
const d = shifted(iso);
|
const d = shifted(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return iso;
|
||||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||||
} catch {
|
|
||||||
return iso;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export function useMe() {
|
|||||||
if (r.response.status === 401) return null;
|
if (r.response.status === 401) return null;
|
||||||
return unwrap(r);
|
return unwrap(r);
|
||||||
},
|
},
|
||||||
|
// «Нет сессии» — это только 401 (выше, null). Обрыв связи не ответ сервера:
|
||||||
|
// без повторов гварды принимали бы его за разлогин и уводили на /login.
|
||||||
|
retry: (count, err) => !(err instanceof ApiError) && count < 2,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ function readCsrfToken(): string | null {
|
|||||||
|
|
||||||
export function useMatch(matchId: number | null) {
|
export function useMatch(matchId: number | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: matchId ? qk.match(matchId) : ["match", "none"],
|
// Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы
|
||||||
|
// свою ошибку под общим ключом-заглушкой.
|
||||||
|
queryKey: matchId != null ? qk.match(matchId) : ["match", "none"],
|
||||||
enabled: matchId != null,
|
enabled: matchId != null,
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
unwrap(
|
unwrap(
|
||||||
@@ -63,6 +65,9 @@ export function useFinishMatch() {
|
|||||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||||
qc.invalidateQueries({ queryKey: qk.home });
|
qc.invalidateQueries({ queryKey: qk.home });
|
||||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||||
|
// Завершённая партия попадает в историю игр и в публичные профили участников.
|
||||||
|
qc.invalidateQueries({ queryKey: ["userMatches"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["publicProfile"] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export function useServerEvents(enabled: boolean) {
|
|||||||
}
|
}
|
||||||
qc.invalidateQueries({ queryKey: qk.home });
|
qc.invalidateQueries({ queryKey: qk.home });
|
||||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||||
|
// История игр и публичный профиль тоже меняются от чужой партии: без этих
|
||||||
|
// двух ключей открытый профиль показывал бы состав до завершения.
|
||||||
|
qc.invalidateQueries({ queryKey: ["userMatches"] });
|
||||||
|
qc.invalidateQueries({ queryKey: ["publicProfile"] });
|
||||||
|
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||||
} else if (ev.type === "group") {
|
} else if (ev.type === "group") {
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||||
|
|||||||
@@ -15,13 +15,19 @@ import { useGroupMembers } from "../hooks/groups";
|
|||||||
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
|
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
|
||||||
|
|
||||||
interface Draft {
|
interface Draft {
|
||||||
|
// Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома
|
||||||
|
// отложенно дописывает результат в свою строку и промахнулся бы по соседа.
|
||||||
|
id: number;
|
||||||
user_id: number | "";
|
user_id: number | "";
|
||||||
faction_id: number | "";
|
faction_id: number | "";
|
||||||
was_random: boolean;
|
was_random: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let draftSeq = 0;
|
||||||
|
|
||||||
function emptyDraft(): Draft {
|
function emptyDraft(): Draft {
|
||||||
return { user_id: "", faction_id: "", was_random: false };
|
draftSeq += 1;
|
||||||
|
return { id: draftSeq, user_id: "", faction_id: "", was_random: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CreateMatchPage() {
|
export function CreateMatchPage() {
|
||||||
@@ -51,30 +57,39 @@ export function CreateMatchPage() {
|
|||||||
if (lm || lf) return <Spinner />;
|
if (lm || lf) return <Spinner />;
|
||||||
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
if (!groupId) return <div className="muted">Нет активной группы.</div>;
|
||||||
|
|
||||||
const takenFactionIds = (exceptIdx: number) =>
|
const takenFactionIds = (exceptId: number) =>
|
||||||
rows
|
rows
|
||||||
.filter((_, i) => i !== exceptIdx)
|
.filter((r) => r.id !== exceptId)
|
||||||
.map((r) => r.faction_id)
|
.map((r) => r.faction_id)
|
||||||
.filter((x): x is number => typeof x === "number");
|
.filter((x): x is number => typeof x === "number");
|
||||||
|
|
||||||
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
|
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
|
||||||
const takenUserIds = (exceptIdx: number) =>
|
const takenUserIds = (exceptId: number) =>
|
||||||
rows
|
rows
|
||||||
.filter((_, i) => i !== exceptIdx)
|
.filter((r) => r.id !== exceptId)
|
||||||
.map((r) => r.user_id)
|
.map((r) => r.user_id)
|
||||||
.filter((x): x is number => typeof x === "number");
|
.filter((x): x is number => typeof x === "number");
|
||||||
|
|
||||||
const update = (idx: number, patch: Partial<Draft>) =>
|
const update = (id: number, patch: Partial<Draft>) =>
|
||||||
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
|
setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
|
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
|
||||||
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
|
const removeRow = (id: number) => {
|
||||||
|
const timer = reelTimers.current[id];
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
delete reelTimers.current[id];
|
||||||
|
setSpin((sp) => {
|
||||||
|
const { [id]: _dropped, ...rest } = sp;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
setRows((rs) => rs.filter((r) => r.id !== id));
|
||||||
|
};
|
||||||
|
|
||||||
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
|
// «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление),
|
||||||
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
|
// затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только
|
||||||
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
|
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
|
||||||
const animateReel = (
|
const animateReel = (
|
||||||
idx: number,
|
id: number,
|
||||||
names: string[],
|
names: string[],
|
||||||
finalName: string,
|
finalName: string,
|
||||||
onReveal: () => void,
|
onReveal: () => void,
|
||||||
@@ -82,16 +97,16 @@ export function CreateMatchPage() {
|
|||||||
new Promise((resolve) => {
|
new Promise((resolve) => {
|
||||||
let delay = 50;
|
let delay = 50;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] }));
|
setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] }));
|
||||||
delay *= 1.18;
|
delay *= 1.18;
|
||||||
if (delay < 300) {
|
if (delay < 300) {
|
||||||
reelTimers.current[idx] = setTimeout(tick, delay);
|
reelTimers.current[id] = setTimeout(tick, delay);
|
||||||
} else {
|
} else {
|
||||||
reelTimers.current[idx] = setTimeout(() => {
|
reelTimers.current[id] = setTimeout(() => {
|
||||||
setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной
|
setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной
|
||||||
reelTimers.current[idx] = setTimeout(() => {
|
reelTimers.current[id] = setTimeout(() => {
|
||||||
onReveal();
|
onReveal();
|
||||||
setSpin((s) => ({ ...s, [idx]: null }));
|
setSpin((s) => ({ ...s, [id]: null }));
|
||||||
resolve();
|
resolve();
|
||||||
}, 500); // пауза перед «выбором»
|
}, 500); // пауза перед «выбором»
|
||||||
}, delay);
|
}, delay);
|
||||||
@@ -100,20 +115,20 @@ export function CreateMatchPage() {
|
|||||||
tick();
|
tick();
|
||||||
});
|
});
|
||||||
|
|
||||||
const randomizeRow = async (idx: number) => {
|
const randomizeRow = async (id: number) => {
|
||||||
const pool = factions ?? [];
|
const pool = factions ?? [];
|
||||||
if (pool.length === 0 || spin[idx] != null) return;
|
if (pool.length === 0 || spin[id] != null) return;
|
||||||
try {
|
try {
|
||||||
const faction = await randomize.mutateAsync({
|
const faction = await randomize.mutateAsync({
|
||||||
group_id: groupId,
|
group_id: groupId,
|
||||||
exclude_faction_ids: takenFactionIds(idx),
|
exclude_faction_ids: takenFactionIds(id),
|
||||||
});
|
});
|
||||||
await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () =>
|
await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () =>
|
||||||
update(idx, { faction_id: faction.id, was_random: true }),
|
update(id, { faction_id: faction.id, was_random: true }),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
|
if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
|
||||||
setSpin((s) => ({ ...s, [idx]: null }));
|
setSpin((s) => ({ ...s, [id]: null }));
|
||||||
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -130,10 +145,10 @@ export function CreateMatchPage() {
|
|||||||
const names = all.map((f) => f.name_ru);
|
const names = all.map((f) => f.name_ru);
|
||||||
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
|
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
rows.map((_, idx) => {
|
rows.map((row, idx) => {
|
||||||
const f = pool[idx % pool.length];
|
const f = pool[idx % pool.length];
|
||||||
return animateReel(idx, names, f.name_ru, () =>
|
return animateReel(row.id, names, f.name_ru, () =>
|
||||||
update(idx, { faction_id: f.id, was_random: true }),
|
update(row.id, { faction_id: f.id, was_random: true }),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -194,11 +209,11 @@ export function CreateMatchPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{rows.map((row, idx) => (
|
{rows.map((row, idx) => (
|
||||||
<div className="participant-row" key={idx}>
|
<div className="participant-row" key={row.id}>
|
||||||
<div className="row-between">
|
<div className="row-between">
|
||||||
<b>Игрок {idx + 1}</b>
|
<b>Игрок {idx + 1}</b>
|
||||||
{rows.length > 2 && (
|
{rows.length > 2 && (
|
||||||
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}>
|
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(row.id)}>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
@@ -206,29 +221,29 @@ export function CreateMatchPage() {
|
|||||||
|
|
||||||
<PickerSelect
|
<PickerSelect
|
||||||
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
|
selected={playerOptions.find((m) => m.id === row.user_id) ?? null}
|
||||||
options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))}
|
options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))}
|
||||||
placeholder="— игрок —"
|
placeholder="— игрок —"
|
||||||
renderOption={(m) => playerLabel(m, 28)}
|
renderOption={(m) => playerLabel(m, 28)}
|
||||||
renderValue={(m) => playerLabel(m, 24)}
|
renderValue={(m) => playerLabel(m, 24)}
|
||||||
onPick={(m) => update(idx, { user_id: m.id })}
|
onPick={(m) => update(row.id, { user_id: m.id })}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="row">
|
<div className="row">
|
||||||
{spin[idx] != null ? (
|
{spin[row.id] != null ? (
|
||||||
<div className="faction-spin" style={{ flex: 1 }}>{spin[idx]}</div>
|
<div className="faction-spin" style={{ flex: 1 }}>{spin[row.id]}</div>
|
||||||
) : (
|
) : (
|
||||||
<PickerSelect
|
<PickerSelect
|
||||||
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
|
selected={(factions ?? []).find((f) => f.id === row.faction_id) ?? null}
|
||||||
options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))}
|
options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))}
|
||||||
placeholder="— фракция —"
|
placeholder="— фракция —"
|
||||||
renderOption={(f) => f.name_ru}
|
renderOption={(f) => f.name_ru}
|
||||||
onPick={(f) => update(idx, { faction_id: f.id, was_random: false })}
|
onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
onClick={() => randomizeRow(idx)}
|
onClick={() => randomizeRow(row.id)}
|
||||||
disabled={spin[idx] != null}
|
disabled={spin[row.id] != null}
|
||||||
title="Случайная фракция"
|
title="Случайная фракция"
|
||||||
>
|
>
|
||||||
<Dices size={18} />
|
<Dices size={18} />
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
|||||||
|
|
||||||
export function MatchDetailPage() {
|
export function MatchDetailPage() {
|
||||||
const { matchId } = useParams();
|
const { matchId } = useParams();
|
||||||
const id = matchId ? Number(matchId) : null;
|
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||||
|
const parsed = matchId ? Number(matchId) : NaN;
|
||||||
|
const id = Number.isInteger(parsed) ? parsed : null;
|
||||||
const { data: match, isLoading, refetch } = useMatch(id);
|
const { data: match, isLoading, refetch } = useMatch(id);
|
||||||
const finish = useFinishMatch();
|
const finish = useFinishMatch();
|
||||||
const del = useDeleteMatch();
|
const del = useDeleteMatch();
|
||||||
@@ -105,7 +107,9 @@ export function MatchDetailPage() {
|
|||||||
try {
|
try {
|
||||||
await del.mutateAsync({ matchId: id, expectedVersion: match.version });
|
await del.mutateAsync({ matchId: id, expectedVersion: match.version });
|
||||||
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
|
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
|
||||||
navigate(-1);
|
// Не navigate(-1): партию часто открывают по прямой ссылке, и «назад»
|
||||||
|
// уводит из приложения вместо возврата к группе.
|
||||||
|
navigate("/group", { replace: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (isStale(e)) {
|
if (isStale(e)) {
|
||||||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||||||
|
|||||||
@@ -36,8 +36,12 @@ export function AdminAccountsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const toggleActive = async (id: number, isActive: boolean) => {
|
const toggleActive = async (id: number, isActive: boolean) => {
|
||||||
await update.mutateAsync({ userId: id, is_active: !isActive }).catch(() => {});
|
try {
|
||||||
|
await update.mutateAsync({ userId: id, is_active: !isActive });
|
||||||
toast.show("Сохранено");
|
toast.show("Сохранено");
|
||||||
|
} catch (e) {
|
||||||
|
toast.show(e instanceof ApiError ? e.message : "Не удалось сохранить");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ export function AdminFactionsPage() {
|
|||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
const [names, setNames] = useState<Record<number, string>>({});
|
const [names, setNames] = useState<Record<number, string>>({});
|
||||||
|
|
||||||
|
// Подставляем серверные названия только там, где поле ещё не трогали: refetch после
|
||||||
|
// сохранения одной фракции иначе затирал бы несохранённый ввод в остальных.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (factions) {
|
if (!factions) return;
|
||||||
setNames(Object.fromEntries(factions.map((f) => [f.id, f.name_ru])));
|
setNames((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const f of factions) {
|
||||||
|
if (next[f.id] === undefined) next[f.id] = f.name_ru;
|
||||||
}
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
}, [factions]);
|
}, [factions]);
|
||||||
|
|
||||||
if (isLoading) return <Spinner />;
|
if (isLoading) return <Spinner />;
|
||||||
|
|||||||
Reference in New Issue
Block a user