Окно — вариант 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>
330 lines
12 KiB
TypeScript
330 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|