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:
+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 }),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user