Ревью кода (#8) #34
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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 }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) }),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) });
|
||||||
|
|||||||
@@ -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;
|
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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user