Объявления: окно у игрока и редактор в админке
Окно — вариант 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:
@@ -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 } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
});
|
||||
}
|
||||
@@ -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") {
|
||||
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
|
||||
// без лидерборда и историй — иначе группа захлебнётся перезапросами.
|
||||
|
||||
Reference in New Issue
Block a user