From a9de68f27cdd9967333ff73407e38870b515dffd Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 18:52:06 +0300 Subject: [PATCH] =?UTF-8?q?Simplify:=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D1=87=D0=B8=D0=BA=20=D1=84?= =?UTF-8?q?=D0=B0=D0=B9=D0=BB=D0=BE=D0=B2=20=D0=B8=20=D0=B5=D0=B4=D0=B8?= =?UTF-8?q?=D0=BD=D1=8B=D0=B5=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B8=20=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Четыре копии «ресайз → FormData → CSRF из cookie → fetch → разбор конверта ошибки» (аватар, фото партии, фото из админки, иконка ачивки) сведены в lib/upload.ts. Иконка по-прежнему уходит оригиналом: ресайз в JPEG убил бы прозрачность герба. - Три копии readCsrfToken и вторая копия resizeImage удалены — берём readCookie из api/client.ts и resizeImage из lib/image.ts с параметром размера. - Ключи, протухающие от партии, собраны в matchAffectedKeys: раньше они были написаны строками мимо реестра qk в двух местах, и переименование ключа сломало бы инвалидацию молча. - Различение «нет сессии» и «нет связи» вынесено в authProbeRetry и применено к обеим пробам. Прошлый заход чинил только игроцкие гварды, и RequireAdmin по-прежнему выкидывал админа на страницу входа при обрыве связи. - ToastContext больше не пересоздаёт значение контекста: провайдер обёрнут вокруг всего приложения, и каждый тост перерисовывал всех потребителей. - PlaceEditor не трогает DOM, пока цель подсветки не изменилась (было querySelectorAll на каждый pointermove). #8 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- frontend/src/api/client.ts | 12 +++- frontend/src/api/queryKeys.ts | 14 +++++ frontend/src/auth/guards.tsx | 3 +- frontend/src/components/PlaceEditor.tsx | 7 +++ frontend/src/context/ToastContext.tsx | 9 ++- frontend/src/hooks/admin.ts | 61 ++++---------------- frontend/src/hooks/auth.ts | 77 +++---------------------- frontend/src/hooks/matches.ts | 43 ++------------ frontend/src/hooks/useServerEvents.ts | 11 +--- frontend/src/lib/upload.ts | 52 +++++++++++++++++ frontend/src/pages/CreateMatchPage.tsx | 2 + 11 files changed, 123 insertions(+), 168 deletions(-) create mode 100644 frontend/src/lib/upload.ts diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 9baa563..f7a546b 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -1,7 +1,7 @@ import createClient from "openapi-fetch"; import type { paths } from "./schema"; -function readCookie(name: string): string | null { +export function readCookie(name: string): string | null { const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)")); return m ? decodeURIComponent(m[1]) : null; } @@ -53,3 +53,13 @@ export function unwrap(res: FetchResult): T { } return res.data as T; } + +/** + * Повторы для проб сессии (`/users/me`, `/admin/me`). «Не авторизован» — это + * только ответ сервера (401/403, обрабатывается в самих хуках); обрыв связи + * ответом не является, и без повторов гварды приняли бы его за разлогин и + * увели на страницу входа. + */ +export const authProbeRetry = (count: number, err: unknown): boolean => + !(err instanceof ApiError) && count < 2; + diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 99e8c19..cab916c 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -26,3 +26,17 @@ export const qk = { adminLogs: ["adminLogs"] as const, adminAchievements: ["adminAchievements"] as const, }; + +/** + * Ключи, которые протухают от любой партии: конкретных участников мы не знаем + * (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один + * список на SSE-обработчик и на завершение партии — иначе переименование ключа + * в этом файле тихо разойдётся с местами, где он написан строкой. + */ +export const matchAffectedKeys = [ + qk.home, + qk.leaderboard, + qk.myStats, + ["userMatches"], + ["publicProfile"], +] as const; diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx index 79bb2c9..c5f24c9 100644 --- a/frontend/src/auth/guards.tsx +++ b/frontend/src/auth/guards.tsx @@ -29,8 +29,9 @@ export function RequireGroup({ children }: PropsWithChildren) { } export function RequireAdmin({ children }: PropsWithChildren) { - const { data: admin, isLoading } = useAdminMe(); + const { data: admin, isLoading, isError } = useAdminMe(); if (isLoading) return ; + if (isError) return ; if (!admin) return ; return <>{children}; } diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx index aa7a9cd..ebee8ba 100644 --- a/frontend/src/components/PlaceEditor.tsx +++ b/frontend/src/components/PlaceEditor.tsx @@ -53,6 +53,7 @@ export function PlaceEditor({ const elimRef = useRef(null); const indRef = useRef(null); const dragRef = useRef(null); + const hintRef = useRef(null); const byId = new Map(players.map((p) => [p.user_id, p])); const placeOf = (i: number) => @@ -80,6 +81,7 @@ export function PlaceEditor({ }; const clearHints = () => { + hintRef.current = null; if (indRef.current) indRef.current.style.display = "none"; listRef.current ?.querySelectorAll(".merge-target") @@ -88,7 +90,12 @@ export function PlaceEditor({ }; const showHint = (t: Target, d: DragState) => { + // pointermove срабатывает десятки раз в секунду, а цель меняется намного реже: + // без этой проверки на каждое движение шёл querySelectorAll и перестановка классов. + const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`; + if (key === hintRef.current) return; clearHints(); + hintRef.current = key; if (t.type === "elim") { elimRef.current?.classList.add("highlight"); return; diff --git a/frontend/src/context/ToastContext.tsx b/frontend/src/context/ToastContext.tsx index ccb0969..819c647 100644 --- a/frontend/src/context/ToastContext.tsx +++ b/frontend/src/context/ToastContext.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useRef, useState } from "react"; +import { createContext, useCallback, useContext, useMemo, useRef, useState } from "react"; import type { PropsWithChildren } from "react"; interface ToastCtx { @@ -17,8 +17,13 @@ export function ToastProvider({ children }: PropsWithChildren) { timer.current = setTimeout(() => setMessage(null), 2800); }, []); + // Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост: + // без useMemo новое значение контекста заставляло бы перерисоваться и всех + // потребителей, к самому тосту отношения не имеющих. + const value = useMemo(() => ({ show }), [show]); + return ( - + {children} {message &&
{message}
}
diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index 8c4fa7a..8828c15 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -1,14 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError, api, unwrap } from "../api/client"; +import { api, authProbeRetry, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; import type { AdminMe, MatchUpdate } from "../domain/types"; -import { resizeImage } from "../lib/image"; - -function readCsrfToken(): string | null { - const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); - return m ? decodeURIComponent(m[1]) : null; -} +import { uploadFile } from "../lib/upload"; export function useAdminMe() { return useQuery({ @@ -18,6 +13,7 @@ export function useAdminMe() { if (r.response.status === 401 || r.response.status === 403) return null; return unwrap(r); }, + retry: authProbeRetry, }); } @@ -106,29 +102,8 @@ export function useAdminMatchAttachments(matchId: number | null) { export function useAdminUploadAttachment(matchId: number) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (file: File) => { - const blob = await resizeImage(file); - const form = new FormData(); - form.append("file", blob, "photo.jpg"); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/admin/matches/${matchId}/attachments`, { - method: "POST", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); - } - return await r.json(); - }, + mutationFn: async (file: File) => + uploadFile(`/api/admin/matches/${matchId}/attachments`, file), onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }), }); } @@ -283,28 +258,12 @@ export function useDeleteAchievement() { export function useUploadAchievementIcon() { const qc = useQueryClient(); return useMutation({ - mutationFn: async ({ slug, file }: { slug: string; file: File }) => { - const form = new FormData(); - form.append("file", file, file.name); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, { + // Иконку шлём оригиналом: ресайз в JPEG убил бы прозрачность герба. + mutationFn: async ({ slug, file }: { slug: string; file: File }) => + uploadFile(`/api/admin/achievements/${slug}/icon`, file, { method: "PUT", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); - } - return await r.json(); - }, + maxSide: null, + }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), }); } diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index f7e03ed..9442c2d 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -1,7 +1,8 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError, api, unwrap } from "../api/client"; +import { api, authProbeRetry, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; +import { uploadFile } from "../lib/upload"; import type { AuthConfig, Me } from "../domain/types"; export function useMe() { @@ -12,9 +13,7 @@ export function useMe() { if (r.response.status === 401) return null; return unwrap(r); }, - // «Нет сессии» — это только 401 (выше, null). Обрыв связи не ответ сервера: - // без повторов гварды принимали бы его за разлогин и уводили на /login. - retry: (count, err) => !(err instanceof ApiError) && count < 2, + retry: authProbeRetry, }); } @@ -157,74 +156,16 @@ export function useUpdateHistoryPrefs() { }); } -// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным -// fetch (openapi-fetch неудобен для файлов); CSRF-токен из cookie ставим вручную. -function readCsrfToken(): string | null { - const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); - return m ? decodeURIComponent(m[1]) : null; -} - -async function resizeImage(file: File, max = 512): Promise { - try { - const dataUrl = await new Promise((res, rej) => { - const fr = new FileReader(); - fr.onload = () => res(fr.result as string); - fr.onerror = () => rej(fr.error); - fr.readAsDataURL(file); - }); - const img = await new Promise((res, rej) => { - const i = new Image(); - i.onload = () => res(i); - i.onerror = () => rej(new Error("image load failed")); - i.src = dataUrl; - }); - let { width, height } = img; - if (width > max || height > max) { - const scale = Math.min(max / width, max / height); - width = Math.round(width * scale); - height = Math.round(height * scale); - } - const canvas = document.createElement("canvas"); - canvas.width = width; - canvas.height = height; - const ctx = canvas.getContext("2d"); - if (!ctx) return file; - ctx.drawImage(img, 0, 0, width, height); - const blob = await new Promise((res) => - canvas.toBlob(res, "image/jpeg", 0.85), - ); - return blob ?? file; - } catch { - return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер) - } -} - +// Аватар: картинку уменьшаем на клиенте (≤512px) и грузим общим загрузчиком. export function useUploadAvatar() { const qc = useQueryClient(); return useMutation({ - mutationFn: async (file: File): Promise => { - const blob = await resizeImage(file); - const form = new FormData(); - form.append("file", blob, "avatar.jpg"); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/users/me/avatar`, { + mutationFn: async (file: File): Promise => + uploadFile("/api/users/me/avatar", file, { method: "PUT", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string; details?: unknown } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status, env?.details); - } - return (await r.json()) as Me; - }, + fieldName: "avatar.jpg", + maxSide: 512, + }), onSuccess: (me) => { qc.setQueryData(qk.me, me); qc.invalidateQueries(); diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 564b28c..fd8fc74 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -1,14 +1,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { ApiError, api, unwrap } from "../api/client"; -import { qk } from "../api/queryKeys"; +import { api, unwrap } from "../api/client"; +import { matchAffectedKeys, qk } from "../api/queryKeys"; import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types"; -import { resizeImage } from "../lib/image"; - -function readCsrfToken(): string | null { - const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/); - return m ? decodeURIComponent(m[1]) : null; -} +import { uploadFile } from "../lib/upload"; export function useMatch(matchId: number | null) { return useQuery({ @@ -62,12 +57,7 @@ export function useFinishMatch() { qc.invalidateQueries({ queryKey: qk.match(m.id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); - qc.invalidateQueries({ queryKey: qk.leaderboard }); - qc.invalidateQueries({ queryKey: qk.home }); - qc.invalidateQueries({ queryKey: qk.myStats }); - // Завершённая партия попадает в историю игр и в публичные профили участников. - qc.invalidateQueries({ queryKey: ["userMatches"] }); - qc.invalidateQueries({ queryKey: ["publicProfile"] }); + for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); }, }); } @@ -93,29 +83,8 @@ export function useDeleteMatch() { export function useUploadMatchAttachment(matchId: number) { const qc = useQueryClient(); return useMutation({ - mutationFn: async (file: File) => { - const blob = await resizeImage(file); - const form = new FormData(); - form.append("file", blob, "photo.jpg"); - const csrf = readCsrfToken(); - const base = import.meta.env.VITE_API_BASE_URL || ""; - const r = await fetch(`${base}/api/matches/${matchId}/attachments`, { - method: "POST", - body: form, - credentials: "include", - headers: csrf ? { "X-CSRF-Token": csrf } : {}, - }); - if (!r.ok) { - let env: { code?: string; message?: string } | undefined; - try { - env = ((await r.json()) as { error?: typeof env }).error; - } catch { - /* тело без JSON */ - } - throw new ApiError(env?.message || "Не удалось загрузить", env?.code || "ERROR", r.status); - } - return await r.json(); - }, + mutationFn: async (file: File) => + uploadFile(`/api/matches/${matchId}/attachments`, file), onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }), }); } diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index 163c34e..3b853c4 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -1,7 +1,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useEffect } from "react"; -import { qk } from "../api/queryKeys"; +import { matchAffectedKeys, qk } from "../api/queryKeys"; interface ServerEvent { type: "match" | "group" | "invitations" | "notifications"; @@ -38,13 +38,8 @@ export function useServerEvents(enabled: boolean) { qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); } - qc.invalidateQueries({ queryKey: qk.home }); - qc.invalidateQueries({ queryKey: qk.leaderboard }); - // История игр и публичный профиль тоже меняются от чужой партии: без этих - // двух ключей открытый профиль показывал бы состав до завершения. - qc.invalidateQueries({ queryKey: ["userMatches"] }); - qc.invalidateQueries({ queryKey: ["publicProfile"] }); - qc.invalidateQueries({ queryKey: qk.myStats }); + // История игр и публичные профили тоже меняются от чужой партии. + for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); } else if (ev.type === "group") { if (ev.group_id != null) { qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); diff --git a/frontend/src/lib/upload.ts b/frontend/src/lib/upload.ts new file mode 100644 index 0000000..1179d49 --- /dev/null +++ b/frontend/src/lib/upload.ts @@ -0,0 +1,52 @@ +import { ApiError, readCookie } from "../api/client"; + +import { resizeImage } from "./image"; + +/** + * Загрузка файла multipart'ом. openapi-fetch неудобен для файлов, поэтому идём + * обычным fetch — а значит CSRF-заголовок, базовый URL и разбор конверта ошибки + * приходится ставить руками. Один хелпер на все четыре загрузки (аватар, фото + * партии, фото партии из админки, иконка ачивки): иначе правка вроде таймаута + * или прогресса расходится по копиям. + * + * maxSide задаёт ресайз перед отправкой; `null` отправляет файл как есть + * (иконки ачивок грузятся оригиналом, у них свой лимит на сервере). + */ +export async function uploadFile( + url: string, + file: File, + opts: { + method?: "POST" | "PUT"; + fieldName?: string; + maxSide?: number | null; + errorMessage?: string; + } = {}, +): Promise { + const { + method = "POST", + maxSide = 1600, + fieldName = maxSide == null ? file.name : "photo.jpg", + errorMessage = "Не удалось загрузить", + } = opts; + const body = maxSide == null ? file : await resizeImage(file, maxSide); + const form = new FormData(); + form.append("file", body, fieldName); + const csrf = readCookie("csrf_token"); + const base = import.meta.env.VITE_API_BASE_URL || ""; + const r = await fetch(`${base}${url}`, { + method, + body: form, + credentials: "include", + headers: csrf ? { "X-CSRF-Token": csrf } : {}, + }); + if (!r.ok) { + let env: { code?: string; message?: string; details?: unknown } | undefined; + try { + env = ((await r.json()) as { error?: typeof env }).error; + } catch { + /* тело без JSON */ + } + throw new ApiError(env?.message || errorMessage, env?.code || "ERROR", r.status, env?.details); + } + return (await r.json()) as T; +} diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index f897e86..d50afeb 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -26,6 +26,8 @@ interface Draft { let draftSeq = 0; function emptyDraft(): Draft { + // Счётчик монотонный на весь модуль: id нужны лишь уникальные в пределах + // страницы, а сквозная нумерация избавляет от коллизий при перемонтировании. draftSeq += 1; return { id: draftSeq, user_id: "", faction_id: "", was_random: false }; }