import { useRef, useState } from "react"; import { ApiError } from "../../api/client"; import { AnnouncementWindow } from "../../components/AnnouncementWindow"; import { ConfirmDialog } from "../../components/ConfirmDialog"; import { Spinner } from "../../components/Spinner"; import { Switch } from "../../components/Switch"; import { useToast } from "../../context/ToastContext"; import { formatDateTime, fromAppLocalInput, toAppLocalInput } from "../../domain/format"; import type { AdminAnnouncementRead } from "../../domain/types"; import { useAdminAnnouncements, useAdminCreateAnnouncement, useAdminDeleteAnnouncement, useAdminStopAnnouncement, useAdminUpdateAnnouncement, } from "../../hooks/admin"; import { RichTextEditor } from "./RichTextEditor"; // Те же пределы, что проверяет сервер (announcement_service). const TITLE_MAX = 60; const TEXT_MAX = 600; const WEEK_MS = 7 * 24 * 3600_000; const STATUS: Record = { planned: ["Запланировано", "badge"], live: ["Идёт", "badge badge-live"], finished: ["Завершено", "badge muted"], }; interface Draft { id: number | null; // null — новое объявление key: number; // смена key пересоздаёт редактор с новым начальным текстом title: string; html: string; length: number; starts: string; // datetime-local, МСК ends: string; showToNew: boolean; reshow: boolean; } let draftSeq = 0; // Новое объявление: показ с текущей минуты на неделю. from — «Дублировать». function newDraft(from?: AdminAnnouncementRead): Draft { const now = Date.now(); return { id: null, key: ++draftSeq, title: from?.title ?? "", html: from?.body_html ?? "", length: 0, starts: toAppLocalInput(new Date(now).toISOString()), ends: toAppLocalInput(new Date(now + WEEK_MS).toISOString()), showToNew: from?.show_to_new_players ?? true, reshow: false, }; } function editDraft(a: AdminAnnouncementRead): Draft { return { id: a.id, key: ++draftSeq, title: a.title, html: a.body_html, length: 0, starts: toAppLocalInput(a.starts_at), ends: toAppLocalInput(a.ends_at), showToNew: a.show_to_new_players, reshow: false, }; } export function AdminAnnouncementsPage() { const { data: items, isLoading } = useAdminAnnouncements(); const create = useAdminCreateAnnouncement(); const update = useAdminUpdateAnnouncement(); const stop = useAdminStopAnnouncement(); const remove = useAdminDeleteAnnouncement(); const toast = useToast(); const formRef = useRef(null); const [draft, setDraft] = useState(() => newDraft()); const [error, setError] = useState(null); const [preview, setPreview] = useState(false); const [toDelete, setToDelete] = useState(null); const patch = (p: Partial) => setDraft((d) => ({ ...d, ...p })); const openDraft = (d: Draft) => { setDraft(d); setError(null); formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }); }; const validate = (): { starts: string; ends: string } | string => { if (!draft.title.trim()) return "Введите заголовок."; if (draft.length === 0) return "Введите текст объявления."; if (draft.length > TEXT_MAX) return `Текст длиннее ${TEXT_MAX} символов.`; const starts = fromAppLocalInput(draft.starts); const ends = fromAppLocalInput(draft.ends); if (!starts || !ends) return "Укажите начало и конец показа."; if (ends <= starts) return "Конец показа должен быть позже начала."; return { starts, ends }; }; const save = async () => { const period = validate(); if (typeof period === "string") { setError(period); return; } setError(null); const body = { title: draft.title.trim(), body_html: draft.html, starts_at: period.starts, ends_at: period.ends, show_to_new_players: draft.showToNew, }; try { if (draft.id == null) { const a = await create.mutateAsync(body); toast.show(a.status === "planned" ? "Объявление запланировано" : "Объявление опубликовано"); } else { await update.mutateAsync({ id: draft.id, body: { ...body, reshow: draft.reshow } }); toast.show(draft.reshow ? "Сохранено — игроки увидят обновление" : "Сохранено"); } setDraft(newDraft()); } catch (e) { toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; const doStop = async (a: AdminAnnouncementRead) => { try { await stop.mutateAsync(a.id); toast.show("Снято с показа"); } catch (e) { toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; const doDelete = async () => { const a = toDelete; setToDelete(null); if (!a) return; try { await remove.mutateAsync(a.id); if (draft.id === a.id) setDraft(newDraft()); toast.show("Объявление удалено"); } catch (e) { toast.error(e instanceof ApiError ? e.message : "Ошибка"); } }; const saving = create.isPending || update.isPending; return (
{draft.id != null && (
Правка объявления
)}
patch({ title: e.target.value })} />
Текст объявления patch({ html, length })} />
TEXT_MAX ? " over" : "")}> {draft.length} / {TEXT_MAX}
patch({ starts: e.target.value })} />
patch({ ends: e.target.value })} />

Выключите, если объявление ни к чему тем, кто зарегистрируется после начала показа.

{draft.id != null && ( <>

Они увидят объявление ещё раз — с красной пометкой «обновлено» под заголовком.

)} {error &&

{error}

}

Созданные объявления

{isLoading ? ( ) : !items?.length ? (

Объявлений пока нет.

) : (
{items.map((a) => { const [label, badge] = STATUS[a.status]; const meta = [ `${formatDateTime(a.starts_at)} — ${formatDateTime(a.ends_at)}`, `закрыли ${a.seen_count} из ${a.audience_count}`, ...(a.show_to_new_players ? [] : ["без новых игроков"]), ...(a.revision > 1 ? [`версия ${a.revision}`] : []), ]; return (
{a.title}
{meta.join(" · ")}
{label}
{a.status === "live" ? ( ) : ( )}
); })}
)} {preview && ( setPreview(false)} /> )} setToDelete(null)} />
); }