Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу уходит из кэша, следующее открывается без ожидания сети. У повторно показанного объявления под заголовком — красная пометка «обновлено». AppShell показывает окно на любой странице и только когда пароль задан: обязательное окно пароля всегда первое. Новые и изменённые объявления приходят SSE-событием announcements, отложенное начало показа — перезапросом раз в 5 минут. Админка — вкладка «Объявления» с редактором R1: contenteditable и панель (жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления), вставка и перетаскивание только простым текстом, счётчик символов, период показа по МСК, переключатели «показывать новым игрокам» и «показать заново». Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком «закрыли N из M» и действиями: изменить, снять с показа, дублировать, удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего. schema.d.ts пересобран из OpenAPI. #84 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
68 lines
2.4 KiB
TypeScript
68 lines
2.4 KiB
TypeScript
import { useState } from "react";
|
|
|
|
import { Spinner } from "../../components/Spinner";
|
|
import { formatDateTime } from "../../domain/format";
|
|
import { useAdminLogs } from "../../hooks/admin";
|
|
|
|
const ACTIONS = ["", "login", "create", "update", "delete"];
|
|
const ENTITIES = ["", "user", "group", "group_member", "match", "admin", "announcement"];
|
|
|
|
export function AdminLogsPage() {
|
|
const [action, setAction] = useState("");
|
|
const [entityType, setEntityType] = useState("");
|
|
const { data, isLoading } = useAdminLogs({
|
|
action: action || undefined,
|
|
entity_type: entityType || undefined,
|
|
});
|
|
|
|
return (
|
|
<div>
|
|
{/* Название раздела уже в активной вкладке админки. */}
|
|
<div className="row" style={{ marginBottom: 10, gap: 8 }}>
|
|
<select style={{ width: "auto" }} value={action} onChange={(e) => setAction(e.target.value)}>
|
|
{ACTIONS.map((a) => (
|
|
<option key={a} value={a}>
|
|
{a || "все действия"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select style={{ width: "auto" }} value={entityType} onChange={(e) => setEntityType(e.target.value)}>
|
|
{ENTITIES.map((en) => (
|
|
<option key={en} value={en}>
|
|
{en || "все сущности"}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{isLoading ? (
|
|
<Spinner />
|
|
) : (
|
|
<div className="stack">
|
|
{(data?.items ?? []).map((log) => (
|
|
<div key={log.id} className="card" style={{ margin: 0 }}>
|
|
<div className="row-between">
|
|
<b>
|
|
{log.action} · {log.entity_type}
|
|
{log.entity_id != null ? ` #${log.entity_id}` : ""}
|
|
</b>
|
|
<span className="muted small">{formatDateTime(log.created_at)}</span>
|
|
</div>
|
|
<div className="muted small">
|
|
actor: {log.actor_id ?? "—"}
|
|
{log.ip ? ` · ${log.ip}` : ""}
|
|
</div>
|
|
{log.payload && (
|
|
<pre className="small muted" style={{ whiteSpace: "pre-wrap", margin: "4px 0 0" }}>
|
|
{JSON.stringify(log.payload)}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
))}
|
|
{(data?.items ?? []).length === 0 && <div className="muted small">Событий пока нет.</div>}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|