Объявления: окно у игрока и редактор в админке

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 21:26:38 +03:00
co-authored by Claude Opus 5
parent a0a0e522ef
commit ab1fc3f00f
16 changed files with 1220 additions and 3 deletions
+60 -1
View File
@@ -2,7 +2,12 @@ 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 type {
AdminMe,
AnnouncementUpdate,
AnnouncementWrite,
MatchUpdate,
} from "../domain/types";
import { uploadFile } from "../lib/upload";
export function useAdminMe() {
@@ -282,3 +287,57 @@ export function useUploadAchievementIcon() {
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 } },
}),
),
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AnnouncementRead } from "../domain/types";
// Объявления администрации, которые игрок ещё не закрыл, — от старого к новому.
export function usePendingAnnouncements(enabled: boolean) {
return useQuery({
queryKey: qk.announcements,
queryFn: async () => unwrap(await api.GET("/api/announcements/pending")),
enabled,
// Новое и изменённое объявление приходит SSE-событием, а вот наступление отложенного
// начала показа — не событие в данных. Его подхватываем периодическим перезапросом.
refetchInterval: 5 * 60_000,
});
}
// «Понятно»: объявление помечается закрытым в той версии, которую игрок видел.
export function useAckAnnouncement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (a: { id: number; revision: number }) =>
unwrap(
await api.POST("/api/announcements/{announcement_id}/ack", {
params: { path: { announcement_id: a.id } },
body: { revision: a.revision },
}),
),
// Закрытое убираем из очереди сразу — следующее открывается, не дожидаясь сети.
onMutate: async (a) => {
await qc.cancelQueries({ queryKey: qk.announcements });
qc.setQueryData<AnnouncementRead[]>(qk.announcements, (old) =>
old?.filter((x) => x.id !== a.id),
);
},
// Если отметка не записалась, объявление вернётся с перезапросом, а не пропадёт молча.
onSettled: () => qc.invalidateQueries({ queryKey: qk.announcements }),
});
}
+3 -1
View File
@@ -5,7 +5,7 @@ import { matchAffectedKeys, qk } from "../api/queryKeys";
import { useMe } from "./auth";
interface ServerEvent {
type: "match" | "match_draft" | "group" | "invitations" | "notifications";
type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements";
match_id?: number;
group_id?: number;
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
@@ -40,6 +40,8 @@ export function useServerEvents(enabled: boolean) {
qc.invalidateQueries({ queryKey: qk.invitations });
} else if (ev.type === "notifications") {
qc.invalidateQueries({ queryKey: qk.notifications });
} else if (ev.type === "announcements") {
qc.invalidateQueries({ queryKey: qk.announcements });
} else if (ev.type === "match_draft") {
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
// без лидерборда и историй — иначе группа захлебнётся перезапросами.