Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу уходит из кэша, следующее открывается без ожидания сети. У повторно показанного объявления под заголовком — красная пометка «обновлено». AppShell показывает окно на любой странице и только когда пароль задан: обязательное окно пароля всегда первое. Новые и изменённые объявления приходят SSE-событием announcements, отложенное начало показа — перезапросом раз в 5 минут. Админка — вкладка «Объявления» с редактором R1: contenteditable и панель (жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления), вставка и перетаскивание только простым текстом, счётчик символов, период показа по МСК, переключатели «показывать новым игрокам» и «показать заново». Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком «закрыли N из M» и действиями: изменить, снять с показа, дублировать, удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего. schema.d.ts пересобран из OpenAPI. #84 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
344 lines
11 KiB
TypeScript
344 lines
11 KiB
TypeScript
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 } },
|
||
}),
|
||
),
|
||
);
|
||
}
|