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

Окно — вариант 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
@@ -0,0 +1,50 @@
import { useId } from "react";
/**
* Окно объявления администрации (вариант E из #84): заголовок — в плашке-шапке, ниже текст,
* точки очереди и «Понятно». Крестика нет намеренно: закрытие — только осознанное.
*
* bodyHtml у игрока — HTML, очищенный сервером по белому списку (announcement_service),
* поэтому вставляется как есть. В предпросмотре админки — содержимое его же редактора.
*/
export function AnnouncementWindow({
title,
bodyHtml,
updated = false,
index = 0,
total = 1,
onOk,
}: {
title: string;
bodyHtml: string;
updated?: boolean;
index?: number;
total?: number;
onOk: () => void;
}) {
const titleId = useId();
return (
<div className="modal-overlay">
<div className="modal ann-modal" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<div className="ann-ribbon">
<div className="ann-ribbon-title" id={titleId}>
{title}
</div>
{updated && <div className="ann-updated">обновлено</div>}
</div>
<div className="ann-body" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
<div className="modal-actions ann-actions">
<div className="ann-dots" aria-hidden="true">
{total > 1 &&
Array.from({ length: total }, (_, k) => (
<i key={k} className={k <= index ? "on" : undefined} />
))}
</div>
<button className="btn btn-primary" onClick={onOk} autoFocus>
Понятно
</button>
</div>
</div>
</div>
);
}