Files
ForbiddenStarsApp/frontend/src/hooks/admin.ts
T
NotBigGhostandClaude Opus 5 ab1fc3f00f Объявления: окно у игрока и редактор в админке
Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди
и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу
уходит из кэша, следующее открывается без ожидания сети. У повторно
показанного объявления под заголовком — красная пометка «обновлено».
AppShell показывает окно на любой странице и только когда пароль задан:
обязательное окно пароля всегда первое. Новые и изменённые объявления
приходят SSE-событием announcements, отложенное начало показа — перезапросом
раз в 5 минут.

Админка — вкладка «Объявления» с редактором R1: contenteditable и панель
(жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления),
вставка и перетаскивание только простым текстом, счётчик символов, период
показа по МСК, переключатели «показывать новым игрокам» и «показать заново».
Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком
«закрыли N из M» и действиями: изменить, снять с показа, дублировать,
удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего.

schema.d.ts пересобран из OpenAPI. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:26:38 +03:00

344 lines
11 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,
AnnouncementUpdate,
AnnouncementWrite,
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 }),
});
}
// Новый пароль игроку — когда он забыл свой (почту приложение не хранит).
export function useAdminSetPassword() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (args: { userId: number; password: string }) =>
unwrap(
await api.PUT("/api/admin/users/{user_id}/password", {
params: { path: { user_id: args.userId } },
body: { new_password: args.password },
}),
),
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 }),
});
}
// ─── Объявления ──────────────────────────────────────────────────────────────
export function useAdminAnnouncements() {
return useQuery({
queryKey: qk.adminAnnouncements,
queryFn: async () => unwrap(await api.GET("/api/admin/announcements")),
});
}
function useAnnouncementMutation<A, R>(fn: (args: A) => Promise<R>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAnnouncements }),
});
}
export function useAdminCreateAnnouncement() {
return useAnnouncementMutation(async (body: AnnouncementWrite) =>
unwrap(await api.POST("/api/admin/announcements", { body })),
);
}
export function useAdminUpdateAnnouncement() {
return useAnnouncementMutation(async (args: { id: number; body: AnnouncementUpdate }) =>
unwrap(
await api.PUT("/api/admin/announcements/{announcement_id}", {
params: { path: { announcement_id: args.id } },
body: args.body,
}),
),
);
}
export function useAdminStopAnnouncement() {
return useAnnouncementMutation(async (id: number) =>
unwrap(
await api.POST("/api/admin/announcements/{announcement_id}/stop", {
params: { path: { announcement_id: id } },
}),
),
);
}
export function useAdminDeleteAnnouncement() {
return useAnnouncementMutation(async (id: number) =>
unwrap(
await api.DELETE("/api/admin/announcements/{announcement_id}", {
params: { path: { announcement_id: id } },
}),
),
);
}