Simplify: общий загрузчик файлов и единые ключи на фронте

- Четыре копии «ресайз → 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 18:52:06 +03:00
co-authored by Claude Opus 5
parent 94e2c09952
commit a9de68f27c
11 changed files with 123 additions and 168 deletions
+11 -1
View File
@@ -1,7 +1,7 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import type { paths } from "./schema"; 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 + "=([^;]*)")); const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
return m ? decodeURIComponent(m[1]) : null; return m ? decodeURIComponent(m[1]) : null;
} }
@@ -53,3 +53,13 @@ export function unwrap<T>(res: FetchResult<T>): T {
} }
return res.data as 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;
+14
View File
@@ -26,3 +26,17 @@ export const qk = {
adminLogs: ["adminLogs"] as const, adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const, adminAchievements: ["adminAchievements"] as const,
}; };
/**
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
* в этом файле тихо разойдётся с местами, где он написан строкой.
*/
export const matchAffectedKeys = [
qk.home,
qk.leaderboard,
qk.myStats,
["userMatches"],
["publicProfile"],
] as const;
+2 -1
View File
@@ -29,8 +29,9 @@ export function RequireGroup({ children }: PropsWithChildren) {
} }
export function RequireAdmin({ children }: PropsWithChildren) { export function RequireAdmin({ children }: PropsWithChildren) {
const { data: admin, isLoading } = useAdminMe(); const { data: admin, isLoading, isError } = useAdminMe();
if (isLoading) return <Spinner />; if (isLoading) return <Spinner />;
if (isError) return <OfflineNotice />;
if (!admin) return <Navigate to="/admin/login" replace />; if (!admin) return <Navigate to="/admin/login" replace />;
return <>{children}</>; return <>{children}</>;
} }
+7
View File
@@ -53,6 +53,7 @@ export function PlaceEditor({
const elimRef = useRef<HTMLDivElement>(null); const elimRef = useRef<HTMLDivElement>(null);
const indRef = useRef<HTMLDivElement>(null); const indRef = useRef<HTMLDivElement>(null);
const dragRef = useRef<DragState | null>(null); const dragRef = useRef<DragState | null>(null);
const hintRef = useRef<string | null>(null);
const byId = new Map(players.map((p) => [p.user_id, p])); const byId = new Map(players.map((p) => [p.user_id, p]));
const placeOf = (i: number) => const placeOf = (i: number) =>
@@ -80,6 +81,7 @@ export function PlaceEditor({
}; };
const clearHints = () => { const clearHints = () => {
hintRef.current = null;
if (indRef.current) indRef.current.style.display = "none"; if (indRef.current) indRef.current.style.display = "none";
listRef.current listRef.current
?.querySelectorAll(".merge-target") ?.querySelectorAll(".merge-target")
@@ -88,7 +90,12 @@ export function PlaceEditor({
}; };
const showHint = (t: Target, d: DragState) => { const showHint = (t: Target, d: DragState) => {
// pointermove срабатывает десятки раз в секунду, а цель меняется намного реже:
// без этой проверки на каждое движение шёл querySelectorAll и перестановка классов.
const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`;
if (key === hintRef.current) return;
clearHints(); clearHints();
hintRef.current = key;
if (t.type === "elim") { if (t.type === "elim") {
elimRef.current?.classList.add("highlight"); elimRef.current?.classList.add("highlight");
return; return;
+7 -2
View File
@@ -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"; import type { PropsWithChildren } from "react";
interface ToastCtx { interface ToastCtx {
@@ -17,8 +17,13 @@ export function ToastProvider({ children }: PropsWithChildren) {
timer.current = setTimeout(() => setMessage(null), 2800); timer.current = setTimeout(() => setMessage(null), 2800);
}, []); }, []);
// Провайдер обёрнут вокруг всего приложения и перерисовывается на каждый тост:
// без useMemo новое значение контекста заставляло бы перерисоваться и всех
// потребителей, к самому тосту отношения не имеющих.
const value = useMemo(() => ({ show }), [show]);
return ( return (
<Ctx.Provider value={{ show }}> <Ctx.Provider value={value}>
{children} {children}
{message && <div className="toast">{message}</div>} {message && <div className="toast">{message}</div>}
</Ctx.Provider> </Ctx.Provider>
+10 -51
View File
@@ -1,14 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types"; import type { AdminMe, MatchUpdate } from "../domain/types";
import { resizeImage } from "../lib/image"; import { uploadFile } from "../lib/upload";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useAdminMe() { export function useAdminMe() {
return useQuery({ return useQuery({
@@ -18,6 +13,7 @@ export function useAdminMe() {
if (r.response.status === 401 || r.response.status === 403) return null; if (r.response.status === 401 || r.response.status === 403) return null;
return unwrap(r); return unwrap(r);
}, },
retry: authProbeRetry,
}); });
} }
@@ -106,29 +102,8 @@ export function useAdminMatchAttachments(matchId: number | null) {
export function useAdminUploadAttachment(matchId: number) { export function useAdminUploadAttachment(matchId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) =>
const blob = await resizeImage(file); uploadFile(`/api/admin/matches/${matchId}/attachments`, 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();
},
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }), onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
}); });
} }
@@ -283,28 +258,12 @@ export function useDeleteAchievement() {
export function useUploadAchievementIcon() { export function useUploadAchievementIcon() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async ({ slug, file }: { slug: string; file: File }) => { // Иконку шлём оригиналом: ресайз в JPEG убил бы прозрачность герба.
const form = new FormData(); mutationFn: async ({ slug, file }: { slug: string; file: File }) =>
form.append("file", file, file.name); uploadFile(`/api/admin/achievements/${slug}/icon`, file, {
const csrf = readCsrfToken();
const base = import.meta.env.VITE_API_BASE_URL || "";
const r = await fetch(`${base}/api/admin/achievements/${slug}/icon`, {
method: "PUT", method: "PUT",
body: form, maxSide: null,
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();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
}); });
} }
+9 -68
View File
@@ -1,7 +1,8 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { qk } from "../api/queryKeys";
import { uploadFile } from "../lib/upload";
import type { AuthConfig, Me } from "../domain/types"; import type { AuthConfig, Me } from "../domain/types";
export function useMe() { export function useMe() {
@@ -12,9 +13,7 @@ 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). Обрыв связи не ответ сервера: retry: authProbeRetry,
// без повторов гварды принимали бы его за разлогин и уводили на /login.
retry: (count, err) => !(err instanceof ApiError) && count < 2,
}); });
} }
@@ -157,74 +156,16 @@ export function useUpdateHistoryPrefs() {
}); });
} }
// Аватар. Картинку уменьшаем на клиенте (≤512px) и грузим multipart'ом отдельным // Аватар: картинку уменьшаем на клиенте (≤512px) и грузим общим загрузчиком.
// 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<Blob> {
try {
const dataUrl = await new Promise<string>((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<HTMLImageElement>((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<Blob | null>((res) =>
canvas.toBlob(res, "image/jpeg", 0.85),
);
return blob ?? file;
} catch {
return file; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
}
}
export function useUploadAvatar() { export function useUploadAvatar() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (file: File): Promise<Me> => { mutationFn: async (file: File): Promise<Me> =>
const blob = await resizeImage(file); uploadFile<Me>("/api/users/me/avatar", 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`, {
method: "PUT", method: "PUT",
body: form, fieldName: "avatar.jpg",
credentials: "include", maxSide: 512,
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;
},
onSuccess: (me) => { onSuccess: (me) => {
qc.setQueryData(qk.me, me); qc.setQueryData(qk.me, me);
qc.invalidateQueries(); qc.invalidateQueries();
+6 -37
View File
@@ -1,14 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ApiError, api, unwrap } from "../api/client"; import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; import { matchAffectedKeys, qk } from "../api/queryKeys";
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types"; import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
import { resizeImage } from "../lib/image"; import { uploadFile } from "../lib/upload";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useMatch(matchId: number | null) { export function useMatch(matchId: number | null) {
return useQuery({ return useQuery({
@@ -62,12 +57,7 @@ export function useFinishMatch() {
qc.invalidateQueries({ queryKey: qk.match(m.id) }); qc.invalidateQueries({ queryKey: qk.match(m.id) });
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
qc.invalidateQueries({ queryKey: qk.leaderboard }); for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
qc.invalidateQueries({ queryKey: qk.home });
qc.invalidateQueries({ queryKey: qk.myStats });
// Завершённая партия попадает в историю игр и в публичные профили участников.
qc.invalidateQueries({ queryKey: ["userMatches"] });
qc.invalidateQueries({ queryKey: ["publicProfile"] });
}, },
}); });
} }
@@ -93,29 +83,8 @@ export function useDeleteMatch() {
export function useUploadMatchAttachment(matchId: number) { export function useUploadMatchAttachment(matchId: number) {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: async (file: File) => { mutationFn: async (file: File) =>
const blob = await resizeImage(file); uploadFile(`/api/matches/${matchId}/attachments`, 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();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }), onSuccess: () => qc.invalidateQueries({ queryKey: qk.match(matchId) }),
}); });
} }
+3 -8
View File
@@ -1,7 +1,7 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect } from "react";
import { qk } from "../api/queryKeys"; import { matchAffectedKeys, qk } from "../api/queryKeys";
interface ServerEvent { interface ServerEvent {
type: "match" | "group" | "invitations" | "notifications"; 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.groupMatches(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
} }
qc.invalidateQueries({ queryKey: qk.home }); // История игр и публичные профили тоже меняются от чужой партии.
qc.invalidateQueries({ queryKey: qk.leaderboard }); for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
// История игр и публичный профиль тоже меняются от чужой партии: без этих
// двух ключей открытый профиль показывал бы состав до завершения.
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) });
+52
View File
@@ -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<T>(
url: string,
file: File,
opts: {
method?: "POST" | "PUT";
fieldName?: string;
maxSide?: number | null;
errorMessage?: string;
} = {},
): Promise<T> {
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;
}
+2
View File
@@ -26,6 +26,8 @@ interface Draft {
let draftSeq = 0; let draftSeq = 0;
function emptyDraft(): Draft { function emptyDraft(): Draft {
// Счётчик монотонный на весь модуль: id нужны лишь уникальные в пределах
// страницы, а сквозная нумерация избавляет от коллизий при перемонтировании.
draftSeq += 1; draftSeq += 1;
return { id: draftSeq, user_id: "", faction_id: "", was_random: false }; return { id: draftSeq, user_id: "", faction_id: "", was_random: false };
} }