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

Окно — вариант 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,329 @@
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>
);
}
+3
View File
@@ -38,6 +38,9 @@ export function AdminLayout() {
<NavLink to="/admin/logs" className={tab}>
Логи
</NavLink>
<NavLink to="/admin/announcements" className={tab}>
Объявления
</NavLink>
</div>
<main className="app-main" style={{ paddingBottom: 24 }}>
<Outlet />
+1 -1
View File
@@ -5,7 +5,7 @@ import { formatDateTime } from "../../domain/format";
import { useAdminLogs } from "../../hooks/admin";
const ACTIONS = ["", "login", "create", "update", "delete"];
const ENTITIES = ["", "user", "group", "group_member", "match", "admin"];
const ENTITIES = ["", "user", "group", "group_member", "match", "admin", "announcement"];
export function AdminLogsPage() {
const [action, setAction] = useState("");
+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>
);
}