Объявления: окно у игрока и редактор в админке
Окно — вариант 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:
@@ -0,0 +1,183 @@
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
|
||||
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
|
||||
|
||||
const escapeHtml = (s: string) =>
|
||||
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// Кнопка панели. 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}>
|
||||
✕ формат
|
||||
</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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user