From ab1fc3f00fa5e02b4112f72632b9358bbf77455f Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 21:26:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9E=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F:=20=D0=BE=D0=BA=D0=BD=D0=BE=20=D1=83=20?= =?UTF-8?q?=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D0=B8=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=20=D0=B2=20=D0=B0=D0=B4?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу уходит из кэша, следующее открывается без ожидания сети. У повторно показанного объявления под заголовком — красная пометка «обновлено». AppShell показывает окно на любой странице и только когда пароль задан: обязательное окно пароля всегда первое. Новые и изменённые объявления приходят SSE-событием announcements, отложенное начало показа — перезапросом раз в 5 минут. Админка — вкладка «Объявления» с редактором R1: contenteditable и панель (жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления), вставка и перетаскивание только простым текстом, счётчик символов, период показа по МСК, переключатели «показывать новым игрокам» и «показать заново». Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком «закрыли N из M» и действиями: изменить, снять с показа, дублировать, удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего. schema.d.ts пересобран из OpenAPI. #84 Co-Authored-By: Claude Opus 5 --- frontend/src/api/queryKeys.ts | 2 + frontend/src/api/schema.d.ts | 395 ++++++++++++++++++ frontend/src/app/router.tsx | 2 + .../src/components/AnnouncementDialog.tsx | 40 ++ .../src/components/AnnouncementWindow.tsx | 50 +++ frontend/src/components/AppShell.tsx | 3 + frontend/src/domain/format.ts | 15 + frontend/src/domain/types.ts | 4 + frontend/src/hooks/admin.ts | 61 ++- frontend/src/hooks/announcements.ts | 40 ++ frontend/src/hooks/useServerEvents.ts | 4 +- .../pages/admin/AdminAnnouncementsPage.tsx | 329 +++++++++++++++ frontend/src/pages/admin/AdminLayout.tsx | 3 + frontend/src/pages/admin/AdminLogsPage.tsx | 2 +- frontend/src/pages/admin/RichTextEditor.tsx | 183 ++++++++ frontend/src/styles/global.css | 90 ++++ 16 files changed, 1220 insertions(+), 3 deletions(-) create mode 100644 frontend/src/components/AnnouncementDialog.tsx create mode 100644 frontend/src/components/AnnouncementWindow.tsx create mode 100644 frontend/src/hooks/announcements.ts create mode 100644 frontend/src/pages/admin/AdminAnnouncementsPage.tsx create mode 100644 frontend/src/pages/admin/RichTextEditor.tsx diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index cab916c..eeaa086 100644 --- a/frontend/src/api/queryKeys.ts +++ b/frontend/src/api/queryKeys.ts @@ -19,12 +19,14 @@ export const qk = { authConfig: ["authConfig"] as const, invitations: ["invitations"] as const, notifications: ["notifications"] as const, + announcements: ["announcements"] as const, devUsers: ["devUsers"] as const, adminUsers: ["adminUsers"] as const, adminGroups: ["adminGroups"] as const, adminMatches: ["adminMatches"] as const, adminLogs: ["adminLogs"] as const, adminAchievements: ["adminAchievements"] as const, + adminAnnouncements: ["adminAnnouncements"] as const, }; /** diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index 7a73d40..651211e 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -842,6 +842,40 @@ export interface paths { patch?: never; trace?: never; }; + "/api/announcements/pending": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Pending */ + get: operations["pending_api_announcements_pending_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/announcements/{announcement_id}/ack": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Acknowledge */ + post: operations["acknowledge_api_announcements__announcement_id__ack_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/auth/login": { parameters: { query?: never; @@ -1140,6 +1174,62 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/announcements": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List Announcements */ + get: operations["list_announcements_api_admin_announcements_get"]; + put?: never; + /** Create Announcement */ + post: operations["create_announcement_api_admin_announcements_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/announcements/{announcement_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update Announcement */ + put: operations["update_announcement_api_admin_announcements__announcement_id__put"]; + post?: never; + /** Delete Announcement */ + delete: operations["delete_announcement_api_admin_announcements__announcement_id__delete"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/announcements/{announcement_id}/stop": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Stop Announcement + * @description «Снять с показа»: период идущего объявления заканчивается сейчас. + */ + post: operations["stop_announcement_api_admin_announcements__announcement_id__stop_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/admin/audit-logs": { parameters: { query?: never; @@ -1277,6 +1367,36 @@ export interface components { /** Group Id */ group_id?: number | null; }; + /** AdminAnnouncementRead */ + AdminAnnouncementRead: { + /** Id */ + id: number; + /** Title */ + title: string; + /** Body Html */ + body_html: string; + /** Starts At */ + starts_at: string; + /** Ends At */ + ends_at: string; + /** Show To New Players */ + show_to_new_players: boolean; + /** Revision */ + revision: number; + /** + * Status + * @enum {string} + */ + status: "planned" | "live" | "finished"; + /** Seen Count */ + seen_count: number; + /** Audience Count */ + audience_count: number; + /** Created At */ + created_at: string; + /** Updated At */ + updated_at: string; + }; /** AdminGroupRead */ AdminGroupRead: { /** Id */ @@ -1361,6 +1481,76 @@ export interface components { /** Is Active */ is_active?: boolean | null; }; + /** AnnouncementAck */ + AnnouncementAck: { + /** Revision */ + revision: number; + }; + /** AnnouncementRead */ + AnnouncementRead: { + /** Id */ + id: number; + /** Title */ + title: string; + /** Body Html */ + body_html: string; + /** Revision */ + revision: number; + /** + * Updated + * @default false + */ + updated: boolean; + }; + /** AnnouncementUpdate */ + AnnouncementUpdate: { + /** Title */ + title: string; + /** Body Html */ + body_html: string; + /** + * Starts At + * Format: date-time + */ + starts_at: string; + /** + * Ends At + * Format: date-time + */ + ends_at: string; + /** + * Show To New Players + * @default true + */ + show_to_new_players: boolean; + /** + * Reshow + * @default false + */ + reshow: boolean; + }; + /** AnnouncementWrite */ + AnnouncementWrite: { + /** Title */ + title: string; + /** Body Html */ + body_html: string; + /** + * Starts At + * Format: date-time + */ + starts_at: string; + /** + * Ends At + * Format: date-time + */ + ends_at: string; + /** + * Show To New Players + * @default true + */ + show_to_new_players: boolean; + }; /** AttachmentRead */ AttachmentRead: { /** Id */ @@ -3980,6 +4170,61 @@ export interface operations { }; }; }; + pending_api_announcements_pending_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AnnouncementRead"][]; + }; + }; + }; + }; + acknowledge_api_announcements__announcement_id__ack_post: { + parameters: { + query?: never; + header?: never; + path: { + announcement_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AnnouncementAck"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; admin_login_api_admin_auth_login_post: { parameters: { query?: never; @@ -4676,6 +4921,156 @@ export interface operations { }; }; }; + list_announcements_api_admin_announcements_get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAnnouncementRead"][]; + }; + }; + }; + }; + create_announcement_api_admin_announcements_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AnnouncementWrite"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAnnouncementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_announcement_api_admin_announcements__announcement_id__put: { + parameters: { + query?: never; + header?: never; + path: { + announcement_id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AnnouncementUpdate"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAnnouncementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_announcement_api_admin_announcements__announcement_id__delete: { + parameters: { + query?: never; + header?: never; + path: { + announcement_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OkResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + stop_announcement_api_admin_announcements__announcement_id__stop_post: { + parameters: { + query?: never; + header?: never; + path: { + announcement_id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AdminAnnouncementRead"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; audit_logs_api_admin_audit_logs_get: { parameters: { query?: { diff --git a/frontend/src/app/router.tsx b/frontend/src/app/router.tsx index 871edfe..d6cb4e8 100644 --- a/frontend/src/app/router.tsx +++ b/frontend/src/app/router.tsx @@ -14,6 +14,7 @@ import { OverallStatsPage } from "../pages/OverallStatsPage"; import { PublicProfilePage } from "../pages/PublicProfilePage"; import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage"; +import { AdminAnnouncementsPage } from "../pages/admin/AdminAnnouncementsPage"; import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; import { AdminLayout } from "../pages/admin/AdminLayout"; import { AdminLogsPage } from "../pages/admin/AdminLogsPage"; @@ -70,6 +71,7 @@ export const router = createBrowserRouter([ { path: "factions", element: }, { path: "achievements", element: }, { path: "logs", element: }, + { path: "announcements", element: }, ], }, { path: "*", element: }, diff --git a/frontend/src/components/AnnouncementDialog.tsx b/frontend/src/components/AnnouncementDialog.tsx new file mode 100644 index 0000000..bb6e313 --- /dev/null +++ b/frontend/src/components/AnnouncementDialog.tsx @@ -0,0 +1,40 @@ +import { useEffect, useState } from "react"; + +import { useAckAnnouncement, usePendingAnnouncements } from "../hooks/announcements"; +import { AnnouncementWindow } from "./AnnouncementWindow"; + +/** + * Очередь объявлений администрации поверх любой страницы: по одному, от старого + * к новому, следующее — после «Понятно» на предыдущем. AppShell монтирует её только + * когда пароль задан: обязательное окно пароля всегда первое. + */ +export function AnnouncementDialog() { + const { data } = usePendingAnnouncements(true); + const ack = useAckAnnouncement(); + // Сколько уже закрыто в этой очереди — для точек «2 из 3». Закрытые сразу уходят + // из данных запроса, так что без счётчика точки показывали бы только остаток. + const [closed, setClosed] = useState(0); + const items = data ?? []; + + useEffect(() => { + if (items.length === 0) setClosed(0); + }, [items.length]); + + if (items.length === 0) return null; + const current = items[0]; + + return ( + { + setClosed((c) => c + 1); + ack.mutate({ id: current.id, revision: current.revision }); + }} + /> + ); +} diff --git a/frontend/src/components/AnnouncementWindow.tsx b/frontend/src/components/AnnouncementWindow.tsx new file mode 100644 index 0000000..a6510ec --- /dev/null +++ b/frontend/src/components/AnnouncementWindow.tsx @@ -0,0 +1,50 @@ +import { useId } from "react"; + +/** + * Окно объявления администрации (вариант E из #84): заголовок — в плашке-шапке, ниже текст, + * точки очереди и «Понятно». Крестика нет намеренно: закрытие — только осознанное. + * + * bodyHtml у игрока — HTML, очищенный сервером по белому списку (announcement_service), + * поэтому вставляется как есть. В предпросмотре админки — содержимое его же редактора. + */ +export function AnnouncementWindow({ + title, + bodyHtml, + updated = false, + index = 0, + total = 1, + onOk, +}: { + title: string; + bodyHtml: string; + updated?: boolean; + index?: number; + total?: number; + onOk: () => void; +}) { + const titleId = useId(); + return ( +
+
+
+
+ {title} +
+ {updated &&
обновлено
} +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/AppShell.tsx b/frontend/src/components/AppShell.tsx index 5088d43..dfe474c 100644 --- a/frontend/src/components/AppShell.tsx +++ b/frontend/src/components/AppShell.tsx @@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom"; import { useMe } from "../hooks/auth"; import { useServerEvents } from "../hooks/useServerEvents"; +import { AnnouncementDialog } from "./AnnouncementDialog"; import { BottomBar } from "./BottomBar"; import { NotificationBell } from "./NotificationBell"; import { NotificationToaster } from "./NotificationToaster"; @@ -55,6 +56,8 @@ export function AppShell() { {/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */} {me && !me.has_password && } + {/* Объявления администрации — только после окна пароля, на любой странице. */} + {me?.has_password && }
); } diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts index ecf6e39..4c4ed53 100644 --- a/frontend/src/domain/format.ts +++ b/frontend/src/domain/format.ts @@ -50,6 +50,21 @@ export function formatDate(iso: string | null | undefined): string { return m ? `${m[3]}.${m[2]}.${m[1]}` : iso; } +// Значение — «настенное» время в том же поясе +3, что и всё +// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер. +export function toAppLocalInput(iso: string): string { + const d = shifted(iso); + return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; +} + +// Обратно: «настенное» время +3 из datetime-local → момент ISO (UTC). null — поле пустое. +export function fromAppLocalInput(value: string): string | null { + const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value); + if (!m) return null; + const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - APP_TZ_OFFSET_MIN * 60_000; + return new Date(ms).toISOString(); +} + export function formatDateTime(iso: string | null | undefined): string { if (!iso) return "—"; const d = shifted(iso); diff --git a/frontend/src/domain/types.ts b/frontend/src/domain/types.ts index 3230efb..d4c8dd9 100644 --- a/frontend/src/domain/types.ts +++ b/frontend/src/domain/types.ts @@ -34,3 +34,7 @@ export type MatchUpdate = S["MatchUpdate"]; export type MatchCreate = S["MatchCreate"]; export type MatchFinish = S["MatchFinish"]; export type MatchFinishDraftData = S["MatchFinishDraftData"]; +export type AnnouncementRead = S["AnnouncementRead"]; +export type AdminAnnouncementRead = S["AdminAnnouncementRead"]; +export type AnnouncementWrite = S["AnnouncementWrite"]; +export type AnnouncementUpdate = S["AnnouncementUpdate"]; diff --git a/frontend/src/hooks/admin.ts b/frontend/src/hooks/admin.ts index 4ab7ff8..5d85dad 100644 --- a/frontend/src/hooks/admin.ts +++ b/frontend/src/hooks/admin.ts @@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { api, authProbeRetry, unwrap } from "../api/client"; import { qk } from "../api/queryKeys"; -import type { AdminMe, MatchUpdate } from "../domain/types"; +import type { + AdminMe, + AnnouncementUpdate, + AnnouncementWrite, + MatchUpdate, +} from "../domain/types"; import { uploadFile } from "../lib/upload"; export function useAdminMe() { @@ -282,3 +287,57 @@ export function useUploadAchievementIcon() { onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), }); } + +// ─── Объявления ────────────────────────────────────────────────────────────── + +export function useAdminAnnouncements() { + return useQuery({ + queryKey: qk.adminAnnouncements, + queryFn: async () => unwrap(await api.GET("/api/admin/announcements")), + }); +} + +function useAnnouncementMutation(fn: (args: A) => Promise) { + const qc = useQueryClient(); + return useMutation({ + mutationFn: fn, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAnnouncements }), + }); +} + +export function useAdminCreateAnnouncement() { + return useAnnouncementMutation(async (body: AnnouncementWrite) => + unwrap(await api.POST("/api/admin/announcements", { body })), + ); +} + +export function useAdminUpdateAnnouncement() { + return useAnnouncementMutation(async (args: { id: number; body: AnnouncementUpdate }) => + unwrap( + await api.PUT("/api/admin/announcements/{announcement_id}", { + params: { path: { announcement_id: args.id } }, + body: args.body, + }), + ), + ); +} + +export function useAdminStopAnnouncement() { + return useAnnouncementMutation(async (id: number) => + unwrap( + await api.POST("/api/admin/announcements/{announcement_id}/stop", { + params: { path: { announcement_id: id } }, + }), + ), + ); +} + +export function useAdminDeleteAnnouncement() { + return useAnnouncementMutation(async (id: number) => + unwrap( + await api.DELETE("/api/admin/announcements/{announcement_id}", { + params: { path: { announcement_id: id } }, + }), + ), + ); +} diff --git a/frontend/src/hooks/announcements.ts b/frontend/src/hooks/announcements.ts new file mode 100644 index 0000000..1317e36 --- /dev/null +++ b/frontend/src/hooks/announcements.ts @@ -0,0 +1,40 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { api, unwrap } from "../api/client"; +import { qk } from "../api/queryKeys"; +import type { AnnouncementRead } from "../domain/types"; + +// Объявления администрации, которые игрок ещё не закрыл, — от старого к новому. +export function usePendingAnnouncements(enabled: boolean) { + return useQuery({ + queryKey: qk.announcements, + queryFn: async () => unwrap(await api.GET("/api/announcements/pending")), + enabled, + // Новое и изменённое объявление приходит SSE-событием, а вот наступление отложенного + // начала показа — не событие в данных. Его подхватываем периодическим перезапросом. + refetchInterval: 5 * 60_000, + }); +} + +// «Понятно»: объявление помечается закрытым в той версии, которую игрок видел. +export function useAckAnnouncement() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (a: { id: number; revision: number }) => + unwrap( + await api.POST("/api/announcements/{announcement_id}/ack", { + params: { path: { announcement_id: a.id } }, + body: { revision: a.revision }, + }), + ), + // Закрытое убираем из очереди сразу — следующее открывается, не дожидаясь сети. + onMutate: async (a) => { + await qc.cancelQueries({ queryKey: qk.announcements }); + qc.setQueryData(qk.announcements, (old) => + old?.filter((x) => x.id !== a.id), + ); + }, + // Если отметка не записалась, объявление вернётся с перезапросом, а не пропадёт молча. + onSettled: () => qc.invalidateQueries({ queryKey: qk.announcements }), + }); +} diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index d18b957..e07c63f 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -5,7 +5,7 @@ import { matchAffectedKeys, qk } from "../api/queryKeys"; import { useMe } from "./auth"; interface ServerEvent { - type: "match" | "match_draft" | "group" | "invitations" | "notifications"; + type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements"; match_id?: number; group_id?: number; /** Кто играл в партии: их история и профили протухли, чужие — нет. */ @@ -40,6 +40,8 @@ export function useServerEvents(enabled: boolean) { qc.invalidateQueries({ queryKey: qk.invitations }); } else if (ev.type === "notifications") { qc.invalidateQueries({ queryKey: qk.notifications }); + } else if (ev.type === "announcements") { + qc.invalidateQueries({ queryKey: qk.announcements }); } else if (ev.type === "match_draft") { // Черновик меняется на каждое движение тайла: трогаем только саму партию, // без лидерборда и историй — иначе группа захлебнётся перезапросами. diff --git a/frontend/src/pages/admin/AdminAnnouncementsPage.tsx b/frontend/src/pages/admin/AdminAnnouncementsPage.tsx new file mode 100644 index 0000000..94189f6 --- /dev/null +++ b/frontend/src/pages/admin/AdminAnnouncementsPage.tsx @@ -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 = { + 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(null); + + const [draft, setDraft] = useState(() => newDraft()); + const [error, setError] = useState(null); + const [preview, setPreview] = useState(false); + const [toDelete, setToDelete] = useState(null); + + const patch = (p: Partial) => 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 ( +
+
+ {draft.id != null && ( +
+ Правка объявления + +
+ )} + +
+ + patch({ title: e.target.value })} + /> +
+ +
+ Текст объявления + patch({ html, length })} + /> +
TEXT_MAX ? " over" : "")}> + {draft.length} / {TEXT_MAX} +
+
+ +
+
+ + patch({ starts: e.target.value })} + /> +
+
+ + patch({ ends: e.target.value })} + /> +
+
+ + +

+ Выключите, если объявление ни к чему тем, кто зарегистрируется после начала показа. +

+ + {draft.id != null && ( + <> + +

+ Они увидят объявление ещё раз — с красной пометкой «обновлено» под заголовком. +

+ + )} + + {error &&

{error}

} + +
+ + +
+
+ +

Созданные объявления

+ {isLoading ? ( + + ) : !items?.length ? ( +

Объявлений пока нет.

+ ) : ( +
+ {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 ( +
+
+
+
{a.title}
+
+ {meta.join(" · ")} +
+
+ {label} +
+
+ + {a.status === "live" ? ( + + ) : ( + + )} + +
+
+ ); + })} +
+ )} + + {preview && ( + setPreview(false)} + /> + )} + + setToDelete(null)} + /> +
+ ); +} diff --git a/frontend/src/pages/admin/AdminLayout.tsx b/frontend/src/pages/admin/AdminLayout.tsx index c26f6cc..c760f67 100644 --- a/frontend/src/pages/admin/AdminLayout.tsx +++ b/frontend/src/pages/admin/AdminLayout.tsx @@ -38,6 +38,9 @@ export function AdminLayout() { Логи + + Объявления +
diff --git a/frontend/src/pages/admin/AdminLogsPage.tsx b/frontend/src/pages/admin/AdminLogsPage.tsx index 4f14852..66d6e7a 100644 --- a/frontend/src/pages/admin/AdminLogsPage.tsx +++ b/frontend/src/pages/admin/AdminLogsPage.tsx @@ -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(""); diff --git a/frontend/src/pages/admin/RichTextEditor.tsx b/frontend/src/pages/admin/RichTextEditor.tsx new file mode 100644 index 0000000..69abc3f --- /dev/null +++ b/frontend/src/pages/admin/RichTextEditor.tsx @@ -0,0 +1,183 @@ +import { type ReactNode, useEffect, useRef, useState } from "react"; + +const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"]; + +const escapeHtml = (s: string) => + s.replace(/&/g, "&").replace(//g, ">"); + +// Кнопка панели. mousedown гасим, чтобы фокус и выделение остались в поле текста. +function Tool({ + title, + className, + onClick, + children, +}: { + title: string; + className?: string; + onClick: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +/** + * Редактор текста объявления (вариант 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(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); + }); + + // Акцент: выделенный текст целиком оборачивается в (золотой) или + // . Оформление внутри выделения при этом снимается — для коротких + // акцентов это проще, чем вложенные теги. + const accent = (red: boolean) => + run(() => { + const range = rangeInEditor(); + if (!range || range.collapsed) return; + const inner = range.toString().split("\n").map(escapeHtml).join("
"); + document.execCommand("insertHTML", false, `${inner}
`); + }); + + const clearFormat = () => + run(() => { + if (!rangeInEditor()) return; + document.execCommand("removeFormat"); + // removeFormat не знает про — акценты в выделении снимаем сами. + 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 ( +
+
+ format("bold")}> + Ж + + format("italic")}> + К + + accent(false)}> + A + + accent(true)}> + A + + + setEmojiOpen((v) => !v)}> + 😀 + + + + ✕ формат + +
+
, а не
— абзацы сразу в том виде, что хранит сервер. + onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")} + onPaste={(e) => { + e.preventDefault(); + document.execCommand("insertText", false, e.clipboardData.getData("text/plain")); + }} + onDrop={(e) => e.preventDefault()} + /> + {emojiOpen && ( +
+ {EMOJI.map((emoji) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 8d9cac1..6314612 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -487,3 +487,93 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); } .toast-top-error { border-left-color: var(--danger); } .toast-top-title { font-weight: 700; } .toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); } + +/* ─── Объявления администрации (#84) ─── */ + +/* Окно (вариант E): заголовок — в плашке-шапке на всю ширину модалки. */ +.ann-modal { max-height: calc(100dvh - 48px); overflow-y: auto; } +.ann-ribbon { + margin: -18px -18px 14px; + padding: 12px 18px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + border-radius: var(--radius) var(--radius) 0 0; +} +.ann-ribbon-title { font-weight: 700; font-size: 16px; line-height: 1.3; overflow-wrap: anywhere; } +/* Пометка повторного показа — мелкой строкой под заголовком, красным. */ +.ann-updated { margin-top: 2px; font-size: 12px; color: var(--danger); } +.ann-actions { margin-top: 16px; justify-content: space-between; align-items: center; } +.ann-dots { display: flex; gap: 6px; align-items: center; } +.ann-dots i { display: block; width: 6px; height: 6px; border-radius: 50%; background: var(--border); } +.ann-dots i.on { background: var(--accent-2); } + +/* Текст объявления — и в окне, и в редакторе админки (там он выглядит так же). */ +.ann-body { font-size: 14px; line-height: 1.5; color: var(--text); overflow-wrap: anywhere; } +.ann-body p { margin: 0; } +.ann-body p + p { margin-top: 10px; } +/* Акценты: золотой — цвет ссылок и бейджа «идёт», красный — для предупреждений. */ +.ann-body mark { background: none; color: var(--accent-2); font-weight: 600; } +.ann-body mark.red { color: var(--danger); } + +/* Редактор (вариант R1) */ +.rte-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 8px; + background: var(--surface-2); + border: 1px solid var(--border); + border-bottom: none; + border-radius: var(--radius-sm) var(--radius-sm) 0 0; +} +.rte-tool { + min-width: 32px; + height: 32px; + padding: 0 8px; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); + font-size: 14px; + display: inline-flex; + align-items: center; + justify-content: center; +} +.rte-tool:hover { border-color: var(--accent-2); } +.rte-gold { color: var(--accent-2); font-weight: 700; } +.rte-red { color: var(--danger); font-weight: 700; } +.rte-sep { width: 1px; height: 22px; background: var(--border); } +.rte-editor { + min-height: 130px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + padding: 10px 12px; + outline: none; +} +.rte-editor:focus { border-color: var(--accent); } +.rte-editor:empty::before { content: attr(data-placeholder); color: var(--text-muted); } +.rte-emoji { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + padding: 8px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); +} +.rte-emoji button { + width: 34px; + height: 34px; + border: none; + background: none; + font-size: 18px; + border-radius: 8px; +} +.rte-emoji button:hover { background: var(--surface-2); } +.rte-counter { font-size: 12px; color: var(--text-muted); margin-top: 4px; text-align: right; } +.rte-counter.over { color: var(--danger); } +/* Выделение текста в форме объявления — полупрозрачный --accent-2 вместо системного синего. */ +.ann-form ::selection { background: rgba(240, 160, 75, 0.35); }