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

Окно — вариант 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
+183
View File
@@ -0,0 +1,183 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Кнопка панели. mousedown гасим, чтобы фокус и выделение остались в поле текста.
function Tool({
title,
className,
onClick,
children,
}: {
title: string;
className?: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
className={"rte-tool" + (className ? ` ${className}` : "")}
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
/**
* Редактор текста объявления (вариант R1 из #84): поле contenteditable и панель —
* жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления. Набор узкий
* намеренно: сервер всё равно оставит только b / em / mark / p / br
* (announcement_service.sanitize_body). Вставка и перетаскивание — только простым текстом.
*
* Поле неуправляемое: начальный HTML ставится при монтировании (для другого объявления
* родитель пересоздаёт редактор через key), дальше каждое изменение уходит в onChange
* вместе с длиной видимого текста в символах — так же её считает сервер.
*/
export function RichTextEditor({
initialHtml,
onChange,
}: {
initialHtml: string;
onChange: (html: string, length: number) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const emit = () => {
const el = ref.current;
if (el) onChange(el.innerHTML, [...(el.textContent ?? "").trim()].length);
};
// Только при монтировании: дальше содержимым владеет сам contenteditable.
useEffect(() => {
if (ref.current) ref.current.innerHTML = initialHtml;
emit();
}, []);
// Выделение внутри поля — кнопки панели работают только с ним.
const rangeInEditor = (): Range | null => {
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
return ref.current?.contains(range.commonAncestorContainer) ? range : null;
};
// Курсор в конец поля, если в нём ещё не кликали (эмодзи до первого клика в текст).
const ensureCaret = () => {
const el = ref.current;
if (!el) return;
el.focus();
if (rangeInEditor()) return;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = document.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
};
const run = (fn: () => void) => {
fn();
emit();
};
const format = (cmd: "bold" | "italic") =>
run(() => {
if (rangeInEditor()) document.execCommand(cmd);
});
// Акцент: выделенный текст целиком оборачивается в <mark> (золотой) или
// <mark class="red">. Оформление внутри выделения при этом снимается — для коротких
// акцентов это проще, чем вложенные теги.
const accent = (red: boolean) =>
run(() => {
const range = rangeInEditor();
if (!range || range.collapsed) return;
const inner = range.toString().split("\n").map(escapeHtml).join("<br>");
document.execCommand("insertHTML", false, `<mark${red ? ' class="red"' : ""}>${inner}</mark>`);
});
const clearFormat = () =>
run(() => {
if (!rangeInEditor()) return;
document.execCommand("removeFormat");
// removeFormat не знает про <mark> — акценты в выделении снимаем сами.
const range = rangeInEditor();
ref.current?.querySelectorAll("mark").forEach((m) => {
if (range?.intersectsNode(m)) m.replaceWith(...Array.from(m.childNodes));
});
});
const insertEmoji = (emoji: string) =>
run(() => {
ensureCaret();
document.execCommand("insertText", false, emoji);
});
return (
<div>
<div className="rte-toolbar">
<Tool title="Жирный" onClick={() => format("bold")}>
<b>Ж</b>
</Tool>
<Tool title="Курсив" onClick={() => format("italic")}>
<em>К</em>
</Tool>
<Tool title="Золотой акцент" className="rte-gold" onClick={() => accent(false)}>
A
</Tool>
<Tool title="Красный акцент" className="rte-red" onClick={() => accent(true)}>
A
</Tool>
<span className="rte-sep" />
<Tool title="Эмодзи" onClick={() => setEmojiOpen((v) => !v)}>
😀
</Tool>
<span className="rte-sep" />
<Tool title="Убрать оформление" onClick={clearFormat}>
✕&nbsp;формат
</Tool>
</div>
<div
ref={ref}
className="rte-editor ann-body"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label="Текст объявления"
data-placeholder="Короткий текст объявления…"
onInput={emit}
// Enter создаёт <p>, а не <div> — абзацы сразу в том виде, что хранит сервер.
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")}
onPaste={(e) => {
e.preventDefault();
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
}}
onDrop={(e) => e.preventDefault()}
/>
{emojiOpen && (
<div className="rte-emoji">
{EMOJI.map((emoji) => (
<button
key={emoji}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertEmoji(emoji)}
>
{emoji}
</button>
))}
</div>
)}
</div>
);
}