Files
ForbiddenStarsApp/frontend/src/hooks/admin.ts
T
NotBigGhostandClaude Opus 5 a9de68f27c 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
2026-09-09 18:52:06 +03:00

270 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, authProbeRetry, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
import { uploadFile } from "../lib/upload";
export function useAdminMe() {
return useQuery({
queryKey: qk.adminMe,
queryFn: async (): Promise<AdminMe | null> => {
const r = await api.GET("/api/admin/me");
if (r.response.status === 401 || r.response.status === 403) return null;
return unwrap(r);
},
retry: authProbeRetry,
});
}
export function useAdminLogin() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (body: { username: string; password: string }) =>
unwrap(await api.POST("/api/admin/auth/login", { body })),
onSuccess: (me) => qc.setQueryData(qk.adminMe, me),
});
}
export function useAdminLogout() {
const qc = useQueryClient();
return useMutation({
mutationFn: async () => unwrap(await api.POST("/api/admin/auth/logout")),
onSuccess: () => qc.setQueryData(qk.adminMe, null),
});
}
export function useAdminUsers(query?: string) {
return useQuery({
queryKey: [...qk.adminUsers, query ?? ""],
queryFn: async () =>
unwrap(
await api.GET("/api/admin/users", {
params: { query: query ? { query } : {} },
}),
),
});
}
export function useAdminGroups() {
return useQuery({
queryKey: qk.adminGroups,
queryFn: async () => unwrap(await api.GET("/api/admin/groups")),
});
}
export interface AdminMatchFilters {
group_id?: number | null;
user_id?: number | null;
faction_id?: number | null;
}
export function useAdminMatches(filters: AdminMatchFilters = {}) {
const query: Record<string, number> = {};
if (filters.group_id != null) query.group_id = filters.group_id;
if (filters.user_id != null) query.user_id = filters.user_id;
if (filters.faction_id != null) query.faction_id = filters.faction_id;
return useQuery({
queryKey: [...qk.adminMatches, query],
queryFn: async () =>
unwrap(await api.GET("/api/admin/matches", { params: { query } })),
});
}
export function useAdminMatch(matchId: number | null) {
return useQuery({
queryKey: ["adminMatch", matchId],
enabled: matchId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/admin/matches/{match_id}", {
params: { path: { match_id: matchId as number } },
}),
),
});
}
// ─── Медиа партии (админ правит в любой момент) ──────────────────────────────
export function useAdminMatchAttachments(matchId: number | null) {
return useQuery({
queryKey: ["adminMatchAttachments", matchId],
enabled: matchId != null,
queryFn: async () =>
unwrap(
await api.GET("/api/admin/matches/{match_id}/attachments", {
params: { path: { match_id: matchId as number } },
}),
),
});
}
export function useAdminUploadAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (file: File) =>
uploadFile(`/api/admin/matches/${matchId}/attachments`, file),
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
});
}
export function useAdminDeleteAttachment(matchId: number) {
const qc = useQueryClient();
return useMutation({
mutationFn: async (attachmentId: number) =>
unwrap(
await api.DELETE("/api/admin/matches/{match_id}/attachments/{attachment_id}", {
params: { path: { match_id: matchId, attachment_id: attachmentId } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminMatchAttachments", matchId] }),
});
}
export function useAdminUpdateMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { matchId: number; body: MatchUpdate }) =>
unwrap(
await api.PATCH("/api/admin/matches/{match_id}", {
params: { path: { match_id: args.matchId } },
body: args.body,
}),
),
onSuccess: (_d, args) => {
qc.invalidateQueries({ queryKey: qk.adminMatches });
qc.invalidateQueries({ queryKey: ["adminMatch", args.matchId] });
},
});
}
export function useAdminFactions() {
return useQuery({
queryKey: ["adminFactions"],
queryFn: async () => unwrap(await api.GET("/api/admin/factions")),
});
}
export function useAdminRenameFaction() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { factionId: number; name_ru: string }) =>
unwrap(
await api.PATCH("/api/admin/factions/{faction_id}", {
params: { path: { faction_id: args.factionId } },
body: { name_ru: args.name_ru },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminFactions"] }),
});
}
export function useAdminLogs(filters?: { action?: string; entity_type?: string }) {
return useQuery({
queryKey: [...qk.adminLogs, filters?.action ?? "", filters?.entity_type ?? ""],
queryFn: async () =>
unwrap(
await api.GET("/api/admin/audit-logs", {
params: {
query: {
...(filters?.action ? { action: filters.action } : {}),
...(filters?.entity_type ? { entity_type: filters.entity_type } : {}),
},
},
}),
),
});
}
export function useAdminDeleteMatch() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (matchId: number) =>
unwrap(
await api.DELETE("/api/admin/matches/{match_id}", {
params: { path: { match_id: matchId } },
}),
),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminMatches }),
});
}
export function useAdminUpdateUser() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { userId: number; nickname?: string; is_active?: boolean }) =>
unwrap(
await api.PATCH("/api/admin/users/{user_id}", {
params: { path: { user_id: args.userId } },
body: { nickname: args.nickname, is_active: args.is_active },
}),
),
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({
// Иконку шлём оригиналом: ресайз в JPEG убил бы прозрачность герба.
mutationFn: async ({ slug, file }: { slug: string; file: File }) =>
uploadFile(`/api/admin/achievements/${slug}/icon`, file, {
method: "PUT",
maxSide: null,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}