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 && ( - )} @@ -206,29 +221,29 @@ export function CreateMatchPage() { 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="— игрок —" renderOption={(m) => playerLabel(m, 28)} renderValue={(m) => playerLabel(m, 24)} - onPick={(m) => update(idx, { user_id: m.id })} + onPick={(m) => update(row.id, { user_id: m.id })} />
- {spin[idx] != null ? ( -
{spin[idx]}
+ {spin[row.id] != null ? ( +
{spin[row.id]}
) : ( 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="— фракция —" 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 })} /> )}