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

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

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

Перенесено в main без рейтинга из ab1fc3f; schema.d.ts сверен с OpenAPI этой ветки.

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

330 lines
12 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 { 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<AdminAnnouncementRead["status"], [string, string]> = {
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<HTMLDivElement>(null);
const [draft, setDraft] = useState<Draft>(() => newDraft());
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState(false);
const [toDelete, setToDelete] = useState<AdminAnnouncementRead | null>(null);
const patch = (p: Partial<Draft>) => 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 (
<div>
<div className="card ann-form" ref={formRef}>
{draft.id != null && (
<div className="row-between" style={{ marginBottom: 8 }}>
<span className="muted small">Правка объявления</span>
<button className="btn btn-ghost small" onClick={() => openDraft(newDraft())}>
Отменить правку
</button>
</div>
)}
<div className="field">
<label className="label" htmlFor="ann-title">
Заголовок
</label>
<input
id="ann-title"
maxLength={TITLE_MAX}
value={draft.title}
onChange={(e) => patch({ title: e.target.value })}
/>
</div>
<div className="field">
<span className="label">Текст объявления</span>
<RichTextEditor
key={draft.key}
initialHtml={draft.html}
onChange={(html, length) => patch({ html, length })}
/>
<div className={"rte-counter" + (draft.length > TEXT_MAX ? " over" : "")}>
{draft.length} / {TEXT_MAX}
</div>
</div>
<div className="row" style={{ alignItems: "flex-start" }}>
<div className="field" style={{ flex: 1, minWidth: 0 }}>
<label className="label" htmlFor="ann-starts">
Показывать с (МСК)
</label>
<input
id="ann-starts"
type="datetime-local"
value={draft.starts}
onChange={(e) => patch({ starts: e.target.value })}
/>
</div>
<div className="field" style={{ flex: 1, minWidth: 0 }}>
<label className="label" htmlFor="ann-ends">
по (МСК)
</label>
<input
id="ann-ends"
type="datetime-local"
value={draft.ends}
onChange={(e) => patch({ ends: e.target.value })}
/>
</div>
</div>
<label className="row-between">
<span>Показывать новым игрокам</span>
<Switch checked={draft.showToNew} onChange={(v) => patch({ showToNew: v })} />
</label>
<p className="muted small" style={{ margin: "4px 0 12px" }}>
Выключите, если объявление ни к чему тем, кто зарегистрируется после начала показа.
</p>
{draft.id != null && (
<>
<label className="row-between">
<span>Показать заново тем, кто уже закрыл</span>
<Switch checked={draft.reshow} onChange={(v) => patch({ reshow: v })} />
</label>
<p className="muted small" style={{ margin: "4px 0 12px" }}>
Они увидят объявление ещё раз — с красной пометкой «обновлено» под заголовком.
</p>
</>
)}
{error && <p className="error-text">{error}</p>}
<div className="row">
<button className="btn btn-ghost" onClick={() => setPreview(true)}>
Предпросмотр
</button>
<button className="btn btn-primary" style={{ flex: 1 }} disabled={saving} onClick={save}>
{draft.id == null ? "Опубликовать" : "Сохранить"}
</button>
</div>
</div>
<h3>Созданные объявления</h3>
{isLoading ? (
<Spinner />
) : !items?.length ? (
<p className="muted small">Объявлений пока нет.</p>
) : (
<div className="stack">
{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 (
<div key={a.id} className="card" style={{ margin: 0 }}>
<div className="row-between" style={{ alignItems: "flex-start" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, overflowWrap: "anywhere" }}>{a.title}</div>
<div className="muted small" style={{ marginTop: 2 }}>
{meta.join(" · ")}
</div>
</div>
<span className={badge}>{label}</span>
</div>
<div className="row" style={{ marginTop: 10, flexWrap: "wrap", gap: 8 }}>
<button className="btn btn-ghost small" onClick={() => openDraft(editDraft(a))}>
Изменить
</button>
{a.status === "live" ? (
<button
className="btn btn-ghost small"
disabled={stop.isPending}
onClick={() => doStop(a)}
>
Снять с показа
</button>
) : (
<button className="btn btn-ghost small" onClick={() => openDraft(newDraft(a))}>
Дублировать
</button>
)}
<button className="btn btn-danger small" onClick={() => setToDelete(a)}>
Удалить
</button>
</div>
</div>
);
})}
</div>
)}
{preview && (
<AnnouncementWindow
title={draft.title.trim() || "Без заголовка"}
bodyHtml={draft.html}
onOk={() => setPreview(false)}
/>
)}
<ConfirmDialog
open={toDelete != null}
title="Удалить объявление?"
message={
toDelete
? `«${toDelete.title}» пропадёт у всех, кто его ещё не закрыл, а отметки о прочтении удалятся.`
: undefined
}
confirmLabel="Удалить"
danger
onConfirm={doDelete}
onCancel={() => setToDelete(null)}
/>
</div>
);
}