diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx
index a468f42..79bb2c9 100644
--- a/frontend/src/auth/guards.tsx
+++ b/frontend/src/auth/guards.tsx
@@ -5,17 +5,24 @@ import { Spinner } from "../components/Spinner";
import { useMe } from "../hooks/auth";
import { useAdminMe } from "../hooks/admin";
+/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */
+function OfflineNotice() {
+ return
Нет связи с сервером. Проверьте подключение и обновите страницу.
;
+}
+
export function RequireAuth({ children }: PropsWithChildren) {
- const { data: me, isLoading } = useMe();
+ const { data: me, isLoading, isError } = useMe();
const location = useLocation();
if (isLoading) return ;
+ if (isError) return ;
if (!me) return ;
return <>{children}>;
}
export function RequireGroup({ children }: PropsWithChildren) {
- const { data: me, isLoading } = useMe();
+ const { data: me, isLoading, isError } = useMe();
if (isLoading) return ;
+ if (isError) return ;
if (!me) return ;
if (me.active_group_id == null) return ;
return <>{children}>;
diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx
index 7deb09d..aa7a9cd 100644
--- a/frontend/src/components/PlaceEditor.tsx
+++ b/frontend/src/components/PlaceEditor.tsx
@@ -182,6 +182,10 @@ export function PlaceEditor({
onPointerDown: (e: React.PointerEvent) => startDrag(e, opts),
onPointerMove: moveDrag,
onPointerUp: endDrag,
+ // Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого
+ // блок остаётся с классом dragging и сдвигом: они выставлены в обход React,
+ // и перерендер их не снимет.
+ onPointerCancel: endDrag,
});
// ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё.
diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts
index 5885c61..f8cecd9 100644
--- a/frontend/src/domain/format.ts
+++ b/frontend/src/domain/format.ts
@@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string {
export function formatTime(iso: string | null | undefined): string {
if (!iso) return "—";
- try {
- const d = shifted(iso);
- return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
- } catch {
- return 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())}`;
}
// «Дата игры» приходит 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 {
if (!iso) return "—";
- try {
- const d = shifted(iso);
- return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
- } catch {
- return 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())}`;
}
diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts
index 2982779..f7e03ed 100644
--- a/frontend/src/hooks/auth.ts
+++ b/frontend/src/hooks/auth.ts
@@ -12,6 +12,9 @@ export function useMe() {
if (r.response.status === 401) return null;
return unwrap(r);
},
+ // «Нет сессии» — это только 401 (выше, null). Обрыв связи не ответ сервера:
+ // без повторов гварды принимали бы его за разлогин и уводили на /login.
+ retry: (count, err) => !(err instanceof ApiError) && count < 2,
});
}
diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts
index 90e2b94..564b28c 100644
--- a/frontend/src/hooks/matches.ts
+++ b/frontend/src/hooks/matches.ts
@@ -12,7 +12,9 @@ function readCsrfToken(): string | null {
export function useMatch(matchId: number | null) {
return useQuery({
- queryKey: matchId ? qk.match(matchId) : ["match", "none"],
+ // Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы
+ // свою ошибку под общим ключом-заглушкой.
+ queryKey: matchId != null ? qk.match(matchId) : ["match", "none"],
enabled: matchId != null,
queryFn: async () =>
unwrap(
@@ -63,6 +65,9 @@ export function useFinishMatch() {
qc.invalidateQueries({ queryKey: qk.leaderboard });
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.myStats });
+ // Завершённая партия попадает в историю игр и в публичные профили участников.
+ qc.invalidateQueries({ queryKey: ["userMatches"] });
+ qc.invalidateQueries({ queryKey: ["publicProfile"] });
},
});
}
diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts
index 8efddda..163c34e 100644
--- a/frontend/src/hooks/useServerEvents.ts
+++ b/frontend/src/hooks/useServerEvents.ts
@@ -40,6 +40,11 @@ export function useServerEvents(enabled: boolean) {
}
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.leaderboard });
+ // История игр и публичный профиль тоже меняются от чужой партии: без этих
+ // двух ключей открытый профиль показывал бы состав до завершения.
+ qc.invalidateQueries({ queryKey: ["userMatches"] });
+ qc.invalidateQueries({ queryKey: ["publicProfile"] });
+ qc.invalidateQueries({ queryKey: qk.myStats });
} else if (ev.type === "group") {
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx
index e6aa1e9..f897e86 100644
--- a/frontend/src/pages/CreateMatchPage.tsx
+++ b/frontend/src/pages/CreateMatchPage.tsx
@@ -15,13 +15,19 @@ import { useGroupMembers } from "../hooks/groups";
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
interface Draft {
+ // Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома
+ // отложенно дописывает результат в свою строку и промахнулся бы по соседа.
+ id: number;
user_id: number | "";
faction_id: number | "";
was_random: boolean;
}
+let draftSeq = 0;
+
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() {
@@ -51,30 +57,39 @@ export function CreateMatchPage() {
if (lm || lf) return ;
if (!groupId) return Нет активной группы.
;
- const takenFactionIds = (exceptIdx: number) =>
+ const takenFactionIds = (exceptId: number) =>
rows
- .filter((_, i) => i !== exceptIdx)
+ .filter((r) => r.id !== exceptId)
.map((r) => r.faction_id)
.filter((x): x is number => typeof x === "number");
// Игроки, уже выбранные в других строках, — в списке не предлагаются.
- const takenUserIds = (exceptIdx: number) =>
+ const takenUserIds = (exceptId: number) =>
rows
- .filter((_, i) => i !== exceptIdx)
+ .filter((r) => r.id !== exceptId)
.map((r) => r.user_id)
.filter((x): x is number => typeof x === "number");
- const update = (idx: number, patch: Partial) =>
- setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
+ const update = (id: number, patch: Partial) =>
+ setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r)));
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с — и только
// после этого фракция «выбирается» (onReveal: показываем select с результатом).
const animateReel = (
- idx: number,
+ id: number,
names: string[],
finalName: string,
onReveal: () => void,
@@ -82,16 +97,16 @@ export function CreateMatchPage() {
new Promise((resolve) => {
let delay = 50;
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;
if (delay < 300) {
- reelTimers.current[idx] = setTimeout(tick, delay);
+ reelTimers.current[id] = setTimeout(tick, delay);
} else {
- reelTimers.current[idx] = setTimeout(() => {
- setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной
- reelTimers.current[idx] = setTimeout(() => {
+ reelTimers.current[id] = setTimeout(() => {
+ setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной
+ reelTimers.current[id] = setTimeout(() => {
onReveal();
- setSpin((s) => ({ ...s, [idx]: null }));
+ setSpin((s) => ({ ...s, [id]: null }));
resolve();
}, 500); // пауза перед «выбором»
}, delay);
@@ -100,20 +115,20 @@ export function CreateMatchPage() {
tick();
});
- const randomizeRow = async (idx: number) => {
+ const randomizeRow = async (id: number) => {
const pool = factions ?? [];
- if (pool.length === 0 || spin[idx] != null) return;
+ if (pool.length === 0 || spin[id] != null) return;
try {
const faction = await randomize.mutateAsync({
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, () =>
- update(idx, { faction_id: faction.id, was_random: true }),
+ await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () =>
+ update(id, { faction_id: faction.id, was_random: true }),
);
} catch (e) {
- if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]);
- setSpin((s) => ({ ...s, [idx]: null }));
+ if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]);
+ setSpin((s) => ({ ...s, [id]: null }));
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
}
};
@@ -130,10 +145,10 @@ export function CreateMatchPage() {
const names = all.map((f) => f.name_ru);
// Барабан крутится во всех строках одновременно, каждая садится на свою фракцию.
await Promise.all(
- rows.map((_, idx) => {
+ rows.map((row, idx) => {
const f = pool[idx % pool.length];
- return animateReel(idx, names, f.name_ru, () =>
- update(idx, { faction_id: f.id, was_random: true }),
+ return animateReel(row.id, names, f.name_ru, () =>
+ update(row.id, { faction_id: f.id, was_random: true }),
);
}),
);
@@ -194,11 +209,11 @@ export function CreateMatchPage() {
{rows.map((row, idx) => (
-
+
Игрок {idx + 1}
{rows.length > 2 && (
-