Files
ForbiddenStarsApp/frontend/src/pages/admin/AdminLogsPage.tsx
T
NotBigGhostandClaude Opus 5 ab1fc3f00f Объявления: окно у игрока и редактор в админке
Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди
и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу
уходит из кэша, следующее открывается без ожидания сети. У повторно
показанного объявления под заголовком — красная пометка «обновлено».
AppShell показывает окно на любой странице и только когда пароль задан:
обязательное окно пароля всегда первое. Новые и изменённые объявления
приходят SSE-событием announcements, отложенное начало показа — перезапросом
раз в 5 минут.

Админка — вкладка «Объявления» с редактором R1: contenteditable и панель
(жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления),
вставка и перетаскивание только простым текстом, счётчик символов, период
показа по МСК, переключатели «показывать новым игрокам» и «показать заново».
Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком
«закрыли N из M» и действиями: изменить, снять с показа, дублировать,
удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего.

schema.d.ts пересобран из OpenAPI. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:26:38 +03:00

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>
);
}