Ревью кода (#8) #34
@@ -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<T>(res: FetchResult<T>): 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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <Spinner />;
|
||||
if (isError) return <OfflineNotice />;
|
||||
if (!admin) return <Navigate to="/admin/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export function PlaceEditor({
|
||||
const elimRef = useRef<HTMLDivElement>(null);
|
||||
const indRef = useRef<HTMLDivElement>(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 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;
|
||||
|
||||
@@ -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 (
|
||||
<Ctx.Provider value={{ show }}>
|
||||
<Ctx.Provider value={value}>
|
||||
{children}
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</Ctx.Provider>
|
||||
|
||||
+10
-51
@@ -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 }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<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; // не вышло уменьшить — отправим как есть (сервер проверит тип/размер)
|
||||
}
|
||||
}
|
||||
|
||||
// Аватар: картинку уменьшаем на клиенте (≤512px) и грузим общим загрузчиком.
|
||||
export function useUploadAvatar() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (file: File): Promise<Me> => {
|
||||
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<Me> =>
|
||||
uploadFile<Me>("/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();
|
||||
|
||||
@@ -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) }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user