Объявления: окно у игрока и редактор в админке
Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу уходит из кэша, следующее открывается без ожидания сети. У повторно показанного объявления под заголовком — красная пометка «обновлено». AppShell показывает окно на любой странице и только когда пароль задан: обязательное окно пароля всегда первое. Новые и изменённые объявления приходят SSE-событием announcements, отложенное начало показа — перезапросом раз в 5 минут. Админка — вкладка «Объявления» с редактором R1: contenteditable и панель (жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления), вставка и перетаскивание только простым текстом, счётчик символов, период показа по МСК, переключатели «показывать новым игрокам» и «показать заново». Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком «закрыли N из M» и действиями: изменить, снять с показа, дублировать, удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего. schema.d.ts пересобран из OpenAPI. #84 Перенесено в main без рейтинга из ab1fc3f; schema.d.ts сверен с OpenAPI этой ветки. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Vendored
+395
@@ -839,6 +839,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;
|
||||
@@ -1137,6 +1171,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;
|
||||
@@ -1274,6 +1364,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 */
|
||||
@@ -1358,6 +1478,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 */
|
||||
@@ -3918,6 +4108,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;
|
||||
@@ -4614,6 +4859,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?: {
|
||||
|
||||
@@ -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: <AdminFactionsPage /> },
|
||||
{ path: "achievements", element: <AdminAchievementsPage /> },
|
||||
{ path: "logs", element: <AdminLogsPage /> },
|
||||
{ path: "announcements", element: <AdminAnnouncementsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
|
||||
@@ -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 (
|
||||
<AnnouncementWindow
|
||||
key={current.id}
|
||||
title={current.title}
|
||||
bodyHtml={current.body_html}
|
||||
updated={current.updated}
|
||||
index={closed}
|
||||
total={closed + items.length}
|
||||
onOk={() => {
|
||||
setClosed((c) => c + 1);
|
||||
ack.mutate({ id: current.id, revision: current.revision });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal ann-modal" role="dialog" aria-modal="true" aria-labelledby={titleId}>
|
||||
<div className="ann-ribbon">
|
||||
<div className="ann-ribbon-title" id={titleId}>
|
||||
{title}
|
||||
</div>
|
||||
{updated && <div className="ann-updated">обновлено</div>}
|
||||
</div>
|
||||
<div className="ann-body" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
|
||||
<div className="modal-actions ann-actions">
|
||||
<div className="ann-dots" aria-hidden="true">
|
||||
{total > 1 &&
|
||||
Array.from({ length: total }, (_, k) => (
|
||||
<i key={k} className={k <= index ? "on" : undefined} />
|
||||
))}
|
||||
</div>
|
||||
<button className="btn btn-primary" onClick={onOk} autoFocus>
|
||||
Понятно
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 && <SetPasswordDialog nickname={me.nickname} />}
|
||||
{/* Объявления администрации — только после окна пароля, на любой странице. */}
|
||||
{me?.has_password && <AnnouncementDialog />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,21 @@ export function formatDate(iso: string | null | undefined): string {
|
||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
||||
}
|
||||
|
||||
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +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);
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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<A, R>(fn: (args: A) => Promise<R>) {
|
||||
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 } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<AnnouncementRead[]>(qk.announcements, (old) =>
|
||||
old?.filter((x) => x.id !== a.id),
|
||||
);
|
||||
},
|
||||
// Если отметка не записалась, объявление вернётся с перезапросом, а не пропадёт молча.
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.announcements }),
|
||||
});
|
||||
}
|
||||
@@ -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") {
|
||||
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
|
||||
// без лидерборда и историй — иначе группа захлебнётся перезапросами.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -458,3 +458,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); }
|
||||
|
||||
Reference in New Issue
Block a user