Редактор ачивок в админке + файловое хранилище ачивок (задел под титулы)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 17:41:20 +03:00
co-authored by Claude Opus 4.8
parent 96daddae05
commit c4065962e2
19 changed files with 1165 additions and 6 deletions
+87 -1
View File
@@ -1,9 +1,14 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { ApiError, api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
function readCsrfToken(): string | null {
const m = document.cookie.match(/(?:^|; )csrf_token=([^;]*)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function useAdminMe() {
return useQuery({
queryKey: qk.adminMe,
@@ -163,3 +168,84 @@ export function useAdminUpdateUser() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
});
}
// ─── Ачивки (редактор; выдача игрокам — на будущее) ──────────────────────────
interface AchievementInput {
name?: string;
description?: string;
condition?: string;
}
export function useAdminAchievements() {
return useQuery({
queryKey: qk.adminAchievements,
queryFn: async () => unwrap(await api.GET("/api/admin/achievements")),
});
}
export function useCreateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: AchievementInput & { name: string }) =>
unwrap(await api.POST("/api/admin/achievements", { body })),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useUpdateAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { slug: string; body: AchievementInput }) =>
unwrap(
await api.PATCH("/api/admin/achievements/{slug}", {
params: { path: { slug: args.slug } },
body: args.body,
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
export function useDeleteAchievement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (slug: string) =>
unwrap(
await api.DELETE("/api/admin/achievements/{slug}", {
params: { path: { slug } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
// Иконка ачивки — multipart отдельным fetch (как аватар), с CSRF из cookie.
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`, {
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();
},
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}