From 2e496a3f2165fcea97083e3ff360c4428af021b8 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 02:51:15 +0300 Subject: [PATCH 1/4] =?UTF-8?q?=D0=93=D0=B8=D1=82=D0=B8=D0=B3=D0=BD=D0=BE?= =?UTF-8?q?=D1=80:=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20AGENTS.md=20=D0=BD=D0=B5=20=D0=B8=D0=B4=D1=91=D1=82=20?= =?UTF-8?q?=D0=B2=20=D1=80=D0=B5=D0=BF=D0=BE=D0=B7=D0=B8=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AGENTS.md — такой же локальный гайд для ассистента, как CLAUDE.md, только для другого инструмента. Он лежал в рабочем дереве незакоммиченным и висел в git status; теперь он в блоке «AI-ассистенты» рядом с CLAUDE.md и .claude/, так что случайный `git add .` его не подхватит. #84 Co-Authored-By: Claude Opus 5 --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 5620531..dea409e 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ mockups/ # AI-ассистенты (локальные, в репозиторий не идут) CLAUDE.md +AGENTS.md .claude/ # Редактор / ОС -- 2.54.0 From a0a0e522efbeb1a0cf370804bac3f837342dda8a Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 21:20:55 +0300 Subject: [PATCH 2/4] =?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=BC=D0=BE=D0=B4=D0=B5=D0=BB=D1=8C,=20A?= =?UTF-8?q?PI=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0=20=D0=B8=20=D0=B0?= =?UTF-8?q?=D0=B4=D0=BC=D0=B8=D0=BD=D0=BA=D0=B8,=20=D0=BE=D1=87=D0=B8?= =?UTF-8?q?=D1=81=D1=82=D0=BA=D0=B0=20HTML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Объявление администрации показывается игроку окном в свой период, пока игрок не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю увидят объявление снова — ответ помечен updated («обновлено»). Флаг show_to_new_players=false прячет объявление от зарегистрировавшихся после начала показа. Пересекающиеся объявления идут от старого к новому. Текст приходит HTML-ом из редактора админки и сохраняется только после очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты отбрасываются, script/style/svg — вместе с содержимым, текст экранируется заново. Фронт вставляет только этот HTML. API: GET /api/announcements/pending, POST /api/announcements/{id}/ack; админка — список со статусом и счётчиком «закрыли N из M», создание, правка, «снять с показа», удаление, всё в аудит. SSE-событие announcements активным игрокам. Миграция 0015 идемпотентная. Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок, «новые игроки», повторный показ, снятие, удаление, валидация. #84 Co-Authored-By: Claude Opus 5 --- .../alembic/versions/0015_announcements.py | 77 ++++ backend/app/main.py | 4 +- backend/app/models.py | 63 ++++ backend/app/routers/admin.py | 122 +++++++ backend/app/routers/announcements.py | 46 +++ backend/app/schemas/api.py | 55 ++- backend/app/services/announcement_service.py | 336 ++++++++++++++++++ backend/app/services/notify.py | 12 +- backend/tests/test_announcements.py | 286 +++++++++++++++ 9 files changed, 998 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/0015_announcements.py create mode 100644 backend/app/routers/announcements.py create mode 100644 backend/app/services/announcement_service.py create mode 100644 backend/tests/test_announcements.py diff --git a/backend/alembic/versions/0015_announcements.py b/backend/alembic/versions/0015_announcements.py new file mode 100644 index 0000000..39e977d --- /dev/null +++ b/backend/alembic/versions/0015_announcements.py @@ -0,0 +1,77 @@ +"""Объявления администрации (#84): сами объявления и отметки «игрок закрыл». + +Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op; +на существующей БД создаёт недостающие таблицы. + +Revision ID: 0015_announcements +Revises: 0014_rating_inputs +Create Date: 2026-09-18 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from sqlalchemy import inspect + +from alembic import op + +revision: str = "0015_announcements" +down_revision: Union[str, None] = "0014_rating_inputs" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + tables = set(inspect(bind).get_table_names()) + + if "announcements" not in tables: + op.create_table( + "announcements", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("title", sa.String(64), nullable=False), + sa.Column("body_html", sa.Text(), nullable=False), + sa.Column("starts_at", sa.DateTime(), nullable=False), + sa.Column("ends_at", sa.DateTime(), nullable=False), + sa.Column("show_to_new_players", sa.Boolean(), nullable=False, server_default="1"), + sa.Column("revision", sa.Integer(), nullable=False, server_default="1"), + sa.Column( + "created_by", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + ), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.CheckConstraint("ends_at > starts_at", name="ck_announcement_period"), + ) + op.create_index("ix_announcements_period", "announcements", ["starts_at", "ends_at"]) + + if "announcement_views" not in tables: + op.create_table( + "announcement_views", + sa.Column( + "announcement_id", + sa.Integer(), + sa.ForeignKey("announcements.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column( + "user_id", + sa.Integer(), + sa.ForeignKey("users.id", ondelete="CASCADE"), + primary_key=True, + ), + sa.Column("revision", sa.Integer(), nullable=False), + sa.Column("closed_at", sa.DateTime(), nullable=False), + ) + op.create_index( + "ix_announcement_views_user_id", "announcement_views", ["user_id"] + ) + + +def downgrade() -> None: + tables = set(inspect(op.get_bind()).get_table_names()) + if "announcement_views" in tables: + op.drop_table("announcement_views") + if "announcements" in tables: + op.drop_table("announcements") diff --git a/backend/app/main.py b/backend/app/main.py index 856034c..1b43672 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -17,6 +17,7 @@ from app.core.errors import AppError, app_error_handler from app.routers import ( achievements, admin, + announcements, auth, events, groups, @@ -199,7 +200,8 @@ def create_app() -> FastAPI: # API-роутеры под /api. api_routers = [auth.router, users.router, groups.router, invitations.router, matches.router, reference.router, stats.router, achievements.router, - events.router, notifications.router, admin.router] + events.router, notifications.router, announcements.router, + admin.router] for r in api_routers: app.include_router(r, prefix="/api") diff --git a/backend/app/models.py b/backend/app/models.py index f59dc79..83ad391 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -447,6 +447,69 @@ class Notification(SQLModel, table=True): created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True) +# ─── Объявления администрации ──────────────────────────────────────────────── + +class Announcement(SQLModel, table=True): + """Объявление администрации (#84): окно поверх приложения в период показа, каждому + игроку — пока он его не закроет. + + body_html — уже очищенный сервером HTML (announcement_service.sanitize_body), фронт + вставляет его как есть. revision растёт, когда админ сохраняет правку с «показать + заново»: закрывшие прежнюю версию увидят объявление ещё раз с пометкой «обновлено». + show_to_new_players=False — не показывать зарегистрировавшимся после starts_at.""" + + __tablename__ = "announcements" + __table_args__ = ( + CheckConstraint("ends_at > starts_at", name="ck_announcement_period"), + Index("ix_announcements_period", "starts_at", "ends_at"), + ) + + id: int | None = Field(default=None, primary_key=True) + title: str = Field(sa_column=Column(String(64), nullable=False)) + body_html: str = Field(sa_column=Column(Text, nullable=False)) + starts_at: datetime = Field(sa_column=Column(DateTime, nullable=False)) + ends_at: datetime = Field(sa_column=Column(DateTime, nullable=False)) + show_to_new_players: bool = Field( + default=True, + sa_column=Column(Boolean, nullable=False, server_default="1"), + ) + revision: int = Field(default=1, sa_column=Column(Integer, nullable=False, server_default="1")) + created_by: int | None = Field( + default=None, + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ), + ) + created_at: datetime = Field(default_factory=utcnow, nullable=False) + updated_at: datetime = Field( + default_factory=utcnow, + sa_column_kwargs={"onupdate": utcnow}, + nullable=False, + ) + + +class AnnouncementView(SQLModel, table=True): + """Игрок закрыл объявление. revision — какую версию он видел: если админ выпустил + новую, объявление покажется снова.""" + + __tablename__ = "announcement_views" + + announcement_id: int | None = Field( + default=None, + sa_column=Column( + Integer, ForeignKey("announcements.id", ondelete="CASCADE"), primary_key=True + ), + ) + user_id: int | None = Field( + default=None, + sa_column=Column( + Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, index=True + ), + ) + revision: int = Field(sa_column=Column(Integer, nullable=False)) + closed_at: datetime = Field(default_factory=utcnow, nullable=False) + + # ─── Журнал аудита ─────────────────────────────────────────────────────────── class AuditLog(SQLModel, table=True): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 97164dd..d6d968e 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -18,6 +18,7 @@ from app.schemas import api as s from app.services import ( achievement_service, admin_service, + announcement_service, attachment_service, audit_service, faction_service, @@ -463,6 +464,127 @@ def delete_achievement( return s.OkResponse() +# ─── Объявления ────────────────────────────────────────────────────────────── + +def _announcement_read(item: dict) -> s.AdminAnnouncementRead: + a = item["a"] + return s.AdminAnnouncementRead( + id=a.id, + title=a.title, + body_html=a.body_html, + starts_at=iso_utc(a.starts_at), # type: ignore[arg-type] + ends_at=iso_utc(a.ends_at), # type: ignore[arg-type] + show_to_new_players=a.show_to_new_players, + revision=a.revision, + status=item["status"], + seen_count=item["seen"], + audience_count=item["audience"], + created_at=iso_utc(a.created_at), # type: ignore[arg-type] + updated_at=iso_utc(a.updated_at), # type: ignore[arg-type] + ) + + +def _announcement_by_id(session: Session, announcement_id: int) -> s.AdminAnnouncementRead: + return _announcement_read(announcement_service.admin_item(session, announcement_id)) + + +@router.get("/announcements", response_model=list[s.AdminAnnouncementRead]) +def list_announcements( + session: Session = Depends(get_session), + _admin: User = Depends(get_current_admin), +) -> list[s.AdminAnnouncementRead]: + return [_announcement_read(i) for i in announcement_service.list_admin(session)] + + +@router.post("/announcements", response_model=s.AdminAnnouncementRead) +def create_announcement( + body: s.AnnouncementWrite, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.AdminAnnouncementRead: + a = announcement_service.create( + session, + title=body.title, + body_html=body.body_html, + starts_at=body.starts_at, + ends_at=body.ends_at, + show_to_new_players=body.show_to_new_players, + actor_id=admin.id, + ) + audit_service.record( + session, actor_id=admin.id, action="create", entity_type="announcement", + entity_id=a.id, payload={"title": a.title}, ip=client_ip(request), + ) + session.commit() + notify.announcements_changed(session) + return _announcement_by_id(session, a.id) # type: ignore[arg-type] + + +@router.put("/announcements/{announcement_id}", response_model=s.AdminAnnouncementRead) +def update_announcement( + announcement_id: int, + body: s.AnnouncementUpdate, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.AdminAnnouncementRead: + a = announcement_service.update( + session, + announcement_id, + title=body.title, + body_html=body.body_html, + starts_at=body.starts_at, + ends_at=body.ends_at, + show_to_new_players=body.show_to_new_players, + reshow=body.reshow, + ) + audit_service.record( + session, actor_id=admin.id, action="update", entity_type="announcement", + entity_id=announcement_id, + payload={"title": a.title, "reshow": body.reshow, "revision": a.revision}, + ip=client_ip(request), + ) + session.commit() + notify.announcements_changed(session) + return _announcement_by_id(session, announcement_id) + + +@router.post("/announcements/{announcement_id}/stop", response_model=s.AdminAnnouncementRead) +def stop_announcement( + announcement_id: int, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.AdminAnnouncementRead: + """«Снять с показа»: период идущего объявления заканчивается сейчас.""" + announcement_service.stop(session, announcement_id) + audit_service.record( + session, actor_id=admin.id, action="update", entity_type="announcement", + entity_id=announcement_id, payload={"stopped": True}, ip=client_ip(request), + ) + session.commit() + notify.announcements_changed(session) + return _announcement_by_id(session, announcement_id) + + +@router.delete("/announcements/{announcement_id}", response_model=s.OkResponse) +def delete_announcement( + announcement_id: int, + request: Request, + session: Session = Depends(get_session), + admin: User = Depends(get_current_admin), +) -> s.OkResponse: + announcement_service.delete(session, announcement_id) + audit_service.record( + session, actor_id=admin.id, action="delete", entity_type="announcement", + entity_id=announcement_id, ip=client_ip(request), + ) + session.commit() + notify.announcements_changed(session) + return s.OkResponse() + + # ─── Журнал аудита ─────────────────────────────────────────────────────────── @router.get("/audit-logs", response_model=s.AuditLogList) diff --git a/backend/app/routers/announcements.py b/backend/app/routers/announcements.py new file mode 100644 index 0000000..65b457d --- /dev/null +++ b/backend/app/routers/announcements.py @@ -0,0 +1,46 @@ +"""Объявления администрации для игрока: что показать сейчас и «Понятно». + +Появление нового объявления у открытой вкладки обеспечивает SSE-сигнал +`{type:"announcements"}`; объявление с отложенным началом клиент подхватывает +периодическим перезапросом.""" +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlmodel import Session + +from app.auth.deps import get_current_user +from app.db.session import get_session +from app.models import User +from app.schemas import api as s +from app.services import announcement_service + +router = APIRouter(prefix="/announcements", tags=["announcements"]) + + +@router.get("/pending", response_model=list[s.AnnouncementRead]) +def pending( + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> list[s.AnnouncementRead]: + return [ + s.AnnouncementRead( + id=a.id, # type: ignore[arg-type] + title=a.title, + body_html=a.body_html, + revision=a.revision, + updated=updated, + ) + for a, updated in announcement_service.pending_for_user(session, user) + ] + + +@router.post("/{announcement_id}/ack", response_model=s.OkResponse) +def acknowledge( + announcement_id: int, + body: s.AnnouncementAck, + session: Session = Depends(get_session), + user: User = Depends(get_current_user), +) -> s.OkResponse: + announcement_service.acknowledge(session, user.id, announcement_id, body.revision) # type: ignore[arg-type] + session.commit() + return s.OkResponse() diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index 44ece85..5b21bd9 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -1,7 +1,7 @@ """Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" from __future__ import annotations -from datetime import date +from datetime import date, datetime from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field @@ -233,6 +233,23 @@ class NotificationMarkRead(BaseModel): ids: list[int] | None = None +# ─── Объявления (игрок) ────────────────────────────────────────────────────── + +class AnnouncementRead(BaseModel): + id: int + title: str + # HTML, уже очищенный сервером по белому списку — фронт вставляет как есть. + body_html: str + revision: int + # Игрок закрывал прежнюю версию — окно показывает пометку «обновлено». + updated: bool = False + + +class AnnouncementAck(BaseModel): + # Версия, которую игрок видел и закрыл (AnnouncementRead.revision). + revision: int = Field(ge=1) + + # ─── Партии ────────────────────────────────────────────────────────────────── class RandomizeRequest(BaseModel): @@ -627,3 +644,39 @@ class AuditLogList(BaseModel): items: list[AuditLogItem] = [] limit: int offset: int + + +# ─── Объявления (админ) ────────────────────────────────────────────────────── + +AnnouncementStatus = Literal["planned", "live", "finished"] + + +class AnnouncementWrite(BaseModel): + # Точные правила (заголовок до 60 символов, текст до 600 видимых) — в + # announcement_service; здесь только отсечка мегабайтных тел. + title: str = Field(max_length=200) + body_html: str = Field(max_length=20_000) + starts_at: datetime + ends_at: datetime + show_to_new_players: bool = True + + +class AnnouncementUpdate(AnnouncementWrite): + # Показать заново тем, кто уже закрыл (с пометкой «обновлено»). + reshow: bool = False + + +class AdminAnnouncementRead(BaseModel): + id: int + title: str + body_html: str + starts_at: str + ends_at: str + show_to_new_players: bool + revision: int + status: AnnouncementStatus + # Закрыли текущую версию / сколько активных игроков в адресатах. + seen_count: int + audience_count: int + created_at: str + updated_at: str diff --git a/backend/app/services/announcement_service.py b/backend/app/services/announcement_service.py new file mode 100644 index 0000000..9caadc4 --- /dev/null +++ b/backend/app/services/announcement_service.py @@ -0,0 +1,336 @@ +"""Объявления администрации (#84): очистка текста, период показа, кому и что показать. + +Объявление видно игроку, пока идёт его период и игрок не закрыл текущую версию. Закрытие +(«Понятно») пишет отметку с номером версии; правка с «показать заново» поднимает версию — +и закрывшие прежнюю увидят объявление снова, с пометкой «обновлено». + +Текст хранится HTML-ом из редактора админки, но только после очистки по белому списку +(sanitize_body): фронт вставляет его без экранирования, так что это единственный барьер +между полем редактора и страницей игрока. +""" +from __future__ import annotations + +import html +from datetime import datetime, timezone +from html.parser import HTMLParser + +from sqlalchemy import func +from sqlmodel import Session, select + +from app.core.errors import NotFoundError, ValidationError +from app.core.timeutil import utcnow +from app.models import Announcement, AnnouncementView, User + +TITLE_MAX = 60 +TEXT_MAX = 600 # видимых символов, без разметки + +# ─── Очистка HTML ──────────────────────────────────────────────────────────── + +# Что оставляем и во что превращаем. Редактор (contenteditable + execCommand) в разных +# браузерах пишет то , то , абзацы —
или

; приводим к одному виду. +_TAGS = { + "b": "b", + "strong": "b", + "i": "em", + "em": "em", + "mark": "mark", + "p": "p", + "div": "p", + "br": "br", +} +# Теги, которые выбрасываются вместе с содержимым: их текст не предназначен для показа. +_DROP_WITH_CONTENT = { + "script", "style", "template", "noscript", "iframe", "object", "embed", + "svg", "math", "head", "title", "textarea", "select", +} +_VOID = {"br"} + + +class _Sanitizer(HTMLParser): + """Пересобирает HTML из разобранных токенов: теги — только из белого списка и без + атрибутов (кроме class="red" у ), весь текст экранируется заново. Всё, что + парсер не распознал как тег из списка, становится текстом или пропадает.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.out: list[str] = [] + self.text: list[str] = [] + self.stack: list[str] = [] + self.drop_depth = 0 + + def _pop(self) -> str: + top = self.stack.pop() + last = self.out[-1] if self.out else "" + # Точное сравнение: startswith(". + if last == f"<{top}>" or last.startswith(f"<{top} "): + self.out.pop() # пустая пара (

от вложенных
) — выбрасываем + else: + self.out.append(f"") + return top + + def _close_to(self, tag: str) -> None: + while self.stack: + if self._pop() == tag: + return + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if tag in _DROP_WITH_CONTENT: + self.drop_depth += 1 + return + if self.drop_depth or tag not in _TAGS: + return + name = _TAGS[tag] + if name in _VOID: + self.out.append(f"<{name}>") + return + if name == "p" and "p" in self.stack: + # Абзац внутри абзаца (вложенные
из contenteditable) — закрываем прежний. + self._close_to("p") + opening = f"<{name}>" + if name == "mark": + classes = next((v or "" for k, v in attrs if k == "class"), "").split() + if "red" in classes: + opening = '' + self.stack.append(name) + self.out.append(opening) + + def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + #
и прочие самозакрытые: содержимого нет, так что drop-теги тут ни при чём. + if not self.drop_depth and _TAGS.get(tag) in _VOID: + self.out.append(f"<{_TAGS[tag]}>") + + def handle_endtag(self, tag: str) -> None: + if tag in _DROP_WITH_CONTENT: + self.drop_depth = max(0, self.drop_depth - 1) + return + if self.drop_depth: + return + name = _TAGS.get(tag) + if name and name not in _VOID and name in self.stack: + self._close_to(name) + + def handle_data(self, data: str) -> None: + if self.drop_depth: + return + self.text.append(data) + self.out.append(html.escape(data, quote=False)) + + def result(self) -> tuple[str, str]: + self.close() + while self.stack: + self._pop() + return "".join(self.out), "".join(self.text) + + +def sanitize_body(raw: str) -> tuple[str, str]: + """(очищенный HTML, видимый текст). Видимый текст нужен для проверки длины.""" + parser = _Sanitizer() + parser.feed(raw) + return parser.result() + + +# ─── Проверки и время ───────────────────────────────────────────────────────── + +def _now() -> datetime: + # В SQLite моменты лежат наивными (UTC) — сравниваем с наивным же «сейчас». + return utcnow().replace(tzinfo=None) + + +def _naive_utc(dt: datetime) -> datetime: + """Момент из запроса → наивный UTC. Время без смещения считаем UTC.""" + if dt.tzinfo is not None: + dt = dt.astimezone(timezone.utc).replace(tzinfo=None) + return dt.replace(second=0, microsecond=0) + + +def _clean_fields( + title: str, body_html: str, starts_at: datetime, ends_at: datetime +) -> tuple[str, str, datetime, datetime]: + title = title.strip() + if not title: + raise ValidationError("Заголовок не может быть пустым.") + if len(title) > TITLE_MAX: + raise ValidationError(f"Заголовок длиннее {TITLE_MAX} символов.") + clean_html, text = sanitize_body(body_html) + text = text.strip() + if not text: + raise ValidationError("Текст объявления не может быть пустым.") + if len(text) > TEXT_MAX: + raise ValidationError(f"Текст длиннее {TEXT_MAX} символов ({len(text)}).") + start, end = _naive_utc(starts_at), _naive_utc(ends_at) + if end <= start: + raise ValidationError("Конец показа должен быть позже начала.") + return title, clean_html, start, end + + +def status_of(a: Announcement, now: datetime | None = None) -> str: + now = now or _now() + if now < a.starts_at: + return "planned" + if now >= a.ends_at: + return "finished" + return "live" + + +def get(session: Session, announcement_id: int) -> Announcement: + a = session.get(Announcement, announcement_id) + if a is None: + raise NotFoundError("Объявление не найдено.") + return a + + +# ─── Админка ───────────────────────────────────────────────────────────────── + +def create( + session: Session, + *, + title: str, + body_html: str, + starts_at: datetime, + ends_at: datetime, + show_to_new_players: bool, + actor_id: int | None, +) -> Announcement: + title, body_html, start, end = _clean_fields(title, body_html, starts_at, ends_at) + if end <= _now(): + raise ValidationError("Период показа уже закончился.") + a = Announcement( + title=title, + body_html=body_html, + starts_at=start, + ends_at=end, + show_to_new_players=show_to_new_players, + created_by=actor_id, + ) + session.add(a) + session.flush() + return a + + +def update( + session: Session, + announcement_id: int, + *, + title: str, + body_html: str, + starts_at: datetime, + ends_at: datetime, + show_to_new_players: bool, + reshow: bool, +) -> Announcement: + a = get(session, announcement_id) + a.title, a.body_html, a.starts_at, a.ends_at = _clean_fields( + title, body_html, starts_at, ends_at + ) + a.show_to_new_players = show_to_new_players + if reshow: + # Новая версия: отметки о закрытии прежней больше не прячут объявление. + a.revision += 1 + a.updated_at = utcnow() + session.add(a) + session.flush() + return a + + +def stop(session: Session, announcement_id: int) -> Announcement: + """«Снять с показа»: период заканчивается сейчас. Только у идущего объявления — + у запланированного конец раньше начала нарушил бы период; его просто удаляют.""" + a = get(session, announcement_id) + now = _now() + if status_of(a, now) != "live" or now <= a.starts_at: + raise ValidationError("Снять с показа можно только идущее объявление.") + a.ends_at = now + a.updated_at = utcnow() + session.add(a) + session.flush() + return a + + +def delete(session: Session, announcement_id: int) -> None: + session.delete(get(session, announcement_id)) + session.flush() + + +def _audience_filter(a: Announcement): + """Условия «игрок — адресат объявления» (для счётчиков и для показа).""" + conds = [User.role == "player", User.is_active.is_(True)] # type: ignore[union-attr] + if not a.show_to_new_players: + conds.append(User.created_at <= a.starts_at) + return conds + + +def _admin_item(session: Session, a: Announcement, now: datetime) -> dict: + """Объявление со статусом и счётчиком «закрыли N из M»: N — закрывшие текущую + версию, M — сколько активных игроков сейчас в адресатах.""" + audience = _audience_filter(a) + audience_count = session.exec(select(func.count()).select_from(User).where(*audience)).one() + seen_count = session.exec( + select(func.count()) + .select_from(AnnouncementView) + .join(User, User.id == AnnouncementView.user_id) + .where( + AnnouncementView.announcement_id == a.id, + AnnouncementView.revision >= a.revision, + *audience, + ) + ).one() + return {"a": a, "status": status_of(a, now), "seen": seen_count, "audience": audience_count} + + +def admin_item(session: Session, announcement_id: int) -> dict: + return _admin_item(session, get(session, announcement_id), _now()) + + +def list_admin(session: Session) -> list[dict]: + """Все объявления, новые сверху. Объявлений единицы, поэтому счётчики — по паре + запросов на объявление.""" + now = _now() + rows = session.exec( + select(Announcement).order_by(Announcement.starts_at.desc(), Announcement.id.desc()) # type: ignore[union-attr] + ).all() + return [_admin_item(session, a, now) for a in rows] + + +# ─── Игрок ─────────────────────────────────────────────────────────────────── + +def pending_for_user(session: Session, user: User) -> list[tuple[Announcement, bool]]: + """Что показать игроку сейчас — от старого к новому. Второй элемент — «обновлено»: + игрок закрывал прежнюю версию этого объявления.""" + now = _now() + registered = user.created_at.replace(tzinfo=None) # только что созданный — aware UTC + rows = session.exec( + select(Announcement, AnnouncementView.revision) + .join( + AnnouncementView, + (AnnouncementView.announcement_id == Announcement.id) + & (AnnouncementView.user_id == user.id), + isouter=True, + ) + .where(Announcement.starts_at <= now, Announcement.ends_at > now) + .order_by(Announcement.starts_at, Announcement.id) + ).all() + result = [] + for a, seen_revision in rows: + if seen_revision is not None and seen_revision >= a.revision: + continue + if not a.show_to_new_players and registered > a.starts_at: + continue + result.append((a, seen_revision is not None)) + return result + + +def acknowledge(session: Session, user_id: int, announcement_id: int, revision: int) -> None: + """Игрок закрыл объявление в версии revision. Версию берём с клиента: если админ + выпустил новую, пока окно было открыто, игрок закрыл старую и новую ещё увидит.""" + a = get(session, announcement_id) + revision = min(revision, a.revision) + view = session.get(AnnouncementView, (announcement_id, user_id)) + if view is None: + session.add( + AnnouncementView(announcement_id=announcement_id, user_id=user_id, revision=revision) + ) + elif revision > view.revision: + view.revision = revision + view.closed_at = utcnow() + session.add(view) + session.flush() diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index 849c3de..2dad33c 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -8,7 +8,7 @@ from __future__ import annotations from sqlmodel import Session, select from app.core.events import hub -from app.models import GroupMember, Match, MatchParticipant +from app.models import GroupMember, Match, MatchParticipant, User def _group_member_ids(session: Session, group_id: int) -> list[int]: @@ -87,3 +87,13 @@ def invitations_changed(user_id: int) -> None: def notifications_changed(user_id: int) -> None: """У пользователя появилось/изменилось уведомление — пусть подтянет список.""" hub.publish([user_id], {"type": "notifications"}) + + +def announcements_changed(session: Session) -> None: + """Админ создал, поправил, снял или удалил объявление — всем активным игрокам: + открытые вкладки перезапросят, что показать. Объявление, чей период начнётся + позже, клиент подхватит сам — периодическим перезапросом.""" + ids = session.exec( + select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr] + ).all() + hub.publish(ids, {"type": "announcements"}) diff --git a/backend/tests/test_announcements.py b/backend/tests/test_announcements.py new file mode 100644 index 0000000..2ab40f6 --- /dev/null +++ b/backend/tests/test_announcements.py @@ -0,0 +1,286 @@ +"""Объявления администрации (#84): очистка HTML, права, период показа, «новые игроки», +повторный показ с пометкой «обновлено», снятие с показа и удаление.""" +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient +from sqlmodel import Session, select + +from app.models import AnnouncementView, User +from app.services import notify +from app.services.announcement_service import sanitize_body +from tests.conftest import csrf_headers, login + + +def _iso(dt: datetime) -> str: + return dt.astimezone(timezone.utc).isoformat() + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _admin_login(client: TestClient, make_admin) -> None: + make_admin("admin", "secret123") + r = client.post( + "/api/admin/auth/login", + json={"username": "admin", "password": "secret123"}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + + +def _create(client: TestClient, **over) -> dict: + body = { + "title": "Турнир", + "body_html": "

Суббота, 11:00

", + "starts_at": _iso(_now() - timedelta(hours=1)), + "ends_at": _iso(_now() + timedelta(days=1)), + "show_to_new_players": True, + } + body.update(over) + r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client)) + assert r.status_code == 200, r.text + return r.json() + + +def _update(client: TestClient, ann: dict, **over) -> dict: + body = { + "title": ann["title"], + "body_html": ann["body_html"], + "starts_at": ann["starts_at"], + "ends_at": ann["ends_at"], + "show_to_new_players": ann["show_to_new_players"], + "reshow": False, + } + body.update(over) + r = client.put( + f"/api/admin/announcements/{ann['id']}", json=body, headers=csrf_headers(client) + ) + assert r.status_code == 200, r.text + return r.json() + + +def _pending(client: TestClient) -> list[dict]: + r = client.get("/api/announcements/pending") + assert r.status_code == 200, r.text + return r.json() + + +def _ack(client: TestClient, ann_id: int, revision: int): + return client.post( + f"/api/announcements/{ann_id}/ack", + json={"revision": revision}, + headers=csrf_headers(client), + ) + + +def _admin_item(client: TestClient, ann_id: int) -> dict: + return next(a for a in client.get("/api/admin/announcements").json() if a["id"] == ann_id) + + +# ─── Очистка HTML ──────────────────────────────────────────────────────────── + +def test_sanitize_keeps_allowed_and_strips_everything_else(): + raw = ( + '
Привет мир
' + '

курсив красный ' + 'золотой

' + "" + 'ссылка' + "svg-текст<b>" + ) + html, text = sanitize_body(raw) + assert html == ( + "

Привет мир

" + '

курсив красный золотой

' + "ссылка<b>" + ) + # Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает. + assert text == "Привет миркурсив красный золотойссылка" + + +def test_sanitize_fixes_nesting_and_keeps_line_breaks(): + html, _ = sanitize_body("
один
два
жирный
хвост") + # Вложенные
из contenteditable → плоские абзацы без пустых

; + #
внутри не путается с самим ; незакрытое закрывается. + assert html == "

один

два

жирный
хвост
" + + +# ─── Права ─────────────────────────────────────────────────────────────────── + +def test_admin_endpoints_are_closed_to_players(client: TestClient): + assert client.get("/api/admin/announcements").status_code == 401 + login(client, "Игрок") + assert client.get("/api/admin/announcements").status_code == 401 + r = client.post( + "/api/admin/announcements", + json={ + "title": "x", + "body_html": "y", + "starts_at": _iso(_now()), + "ends_at": _iso(_now() + timedelta(days=1)), + }, + headers=csrf_headers(client), + ) + assert r.status_code == 401 + + +def test_pending_requires_player_session(client: TestClient): + assert client.get("/api/announcements/pending").status_code == 401 + + +# ─── Показ ─────────────────────────────────────────────────────────────────── + +def test_created_announcement_is_shown_once(client: TestClient, make_admin, monkeypatch): + events: list[dict] = [] + monkeypatch.setattr(notify.hub, "publish", lambda ids, ev: events.append(ev)) + login(client, "Игрок") + _admin_login(client, make_admin) + + ann = _create(client, body_html="

Сбор в 11:00

") + assert ann["body_html"] == "

Сбор в 11:00

" + assert ann["status"] == "live" + assert (ann["seen_count"], ann["audience_count"]) == (0, 1) + assert {"type": "announcements"} in events + + items = _pending(client) + assert [(a["id"], a["revision"], a["updated"]) for a in items] == [(ann["id"], 1, False)] + assert _ack(client, ann["id"], 1).status_code == 200 + assert _pending(client) == [] + assert _admin_item(client, ann["id"])["seen_count"] == 1 + + logs = client.get("/api/admin/audit-logs?entity_type=announcement").json()["items"] + assert [(l["action"], l["entity_id"]) for l in logs] == [("create", ann["id"])] + + +def test_pending_respects_period_and_order(client: TestClient, make_admin): + login(client, "Игрок") + _admin_login(client, make_admin) + later = _create(client, title="Позже", starts_at=_iso(_now() - timedelta(minutes=30))) + earlier = _create(client, title="Раньше", starts_at=_iso(_now() - timedelta(hours=3))) + planned = _create( + client, + title="Завтра", + starts_at=_iso(_now() + timedelta(days=1)), + ends_at=_iso(_now() + timedelta(days=2)), + ) + assert planned["status"] == "planned" + # Пересекающиеся периоды — от старого к новому; запланированного пока нет. + assert [a["title"] for a in _pending(client)] == ["Раньше", "Позже"] + assert earlier["id"] != later["id"] + + # Период, который уже закончился, создать нельзя. + r = client.post( + "/api/admin/announcements", + json={ + "title": "Прошлое", + "body_html": "текст", + "starts_at": _iso(_now() - timedelta(days=2)), + "ends_at": _iso(_now() - timedelta(days=1)), + }, + headers=csrf_headers(client), + ) + assert r.status_code == 422 + + +def test_hidden_from_players_registered_after_start(client: TestClient, make_admin, engine): + login(client, "Старожил") + with Session(engine) as s: + old = s.exec(select(User).where(User.nickname == "Старожил")).one() + old.created_at = datetime.now(timezone.utc) - timedelta(days=2) + s.add(old) + s.commit() + _admin_login(client, make_admin) + ann = _create(client, show_to_new_players=False) + assert ann["audience_count"] == 1 + + assert [a["id"] for a in _pending(client)] == [ann["id"]] + login(client, "Новичок") # зарегистрирован уже после начала показа + assert _pending(client) == [] + # Адресаты — по-прежнему только старожил. + assert _admin_item(client, ann["id"])["audience_count"] == 1 + + +def test_reshow_brings_announcement_back_marked_updated(client: TestClient, make_admin): + login(client, "Игрок") + _admin_login(client, make_admin) + ann = _create(client) + assert _ack(client, ann["id"], 1).status_code == 200 + + # Правка без «показать заново» — закрывшие её не видят. + ann = _update(client, ann, body_html="

Сбор в 12:00

") + assert ann["revision"] == 1 + assert _pending(client) == [] + + ann = _update(client, ann, body_html="

Сбор в 13:00

", reshow=True) + assert ann["revision"] == 2 + assert ann["seen_count"] == 0 # закрывших новую версию ещё нет + items = _pending(client) + assert [(a["revision"], a["updated"], a["body_html"]) for a in items] == [ + (2, True, "

Сбор в 13:00

") + ] + + # Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью. + ann = _update(client, ann, reshow=True) + assert _ack(client, ann["id"], 2).status_code == 200 + assert [(a["revision"], a["updated"]) for a in _pending(client)] == [(3, True)] + assert _ack(client, ann["id"], 3).status_code == 200 + assert _pending(client) == [] + + +def test_stop_and_delete(client: TestClient, make_admin, engine): + login(client, "Игрок") + _admin_login(client, make_admin) + live = _create(client) + planned = _create( + client, + starts_at=_iso(_now() + timedelta(days=1)), + ends_at=_iso(_now() + timedelta(days=2)), + ) + + r = client.post(f"/api/admin/announcements/{planned['id']}/stop", headers=csrf_headers(client)) + assert r.status_code == 422 # снять можно только идущее + + r = client.post(f"/api/admin/announcements/{live['id']}/stop", headers=csrf_headers(client)) + assert r.status_code == 200, r.text + assert r.json()["status"] == "finished" + assert _pending(client) == [] + + assert _ack(client, live["id"], 1).status_code == 200 # закрыть можно и снятое + r = client.delete(f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client)) + assert r.status_code == 200 + with Session(engine) as s: + assert s.exec(select(AnnouncementView)).all() == [] # отметки ушли каскадом + assert client.delete( + f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client) + ).status_code == 404 + assert _ack(client, live["id"], 1).status_code == 404 + + +@pytest.mark.parametrize( + ("over", "message_part"), + [ + ({"title": " "}, "Заголовок"), + ({"title": "Я" * 61}, "Заголовок"), + ({"body_html": "

"}, "пустым"), + ({"body_html": "

" + "а" * 601 + "

"}, "600"), + ({"ends_at": _iso(_now() - timedelta(hours=2))}, "позже начала"), + ], +) +def test_validation(client: TestClient, make_admin, over: dict, message_part: str): + _admin_login(client, make_admin) + body = { + "title": "Заголовок", + "body_html": "

текст

", + "starts_at": _iso(_now() - timedelta(hours=1)), + "ends_at": _iso(_now() + timedelta(days=1)), + } + body.update(over) + r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client)) + assert r.status_code == 422, r.text + err = r.json()["error"] + assert err["code"] == "VALIDATION_ERROR" + assert message_part in err["message"] -- 2.54.0 From ab1fc3f00fa5e02b4112f72632b9358bbf77455f Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 21:26:38 +0300 Subject: [PATCH 3/4] =?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); } -- 2.54.0 From ec0445fbcb9bc543313f6dcd05cb0e093d994aa7 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Fri, 18 Sep 2026 21:55:43 +0300 Subject: [PATCH 4/4] =?UTF-8?q?=D0=9C=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D0=B8:=20=D0=BE=D0=B1=D1=8A=D1=8F=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=BE=D1=82=D1=85=D0=BE=D0=B4=D1=8F=D1=82=20?= =?UTF-8?q?=D0=BE=D1=82=200013,=20=D1=81=D0=BB=D0=B8=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20=D1=81=20=D1=80=D0=B5=D0=B9=D1=82=D0=B8=D0=BD=D0=B3?= =?UTF-8?q?=D0=BE=D0=BC=20=D0=B2=200016?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Объявления выпускаются в main раньше рейтинга, а в main последняя миграция — 0013. Если бы 0015 оставалась после 0014, прод, получивший объявления, встал бы на 0015, и при будущем переходе на dev alembic счёл бы базу актуальной — рейтинговая 0014 не применилась бы никогда. Теперь 0015 отходит от 0013 (как и 0014), а пустая 0016 сводит обе ветки в одну голову: прод на 0015 докатит 0014 и 0016, база на 0014 — 0015 и 0016. Базы, уже прогнанные прежней версией (0015 после 0014), повторно получат идемпотентную 0014 — проверено на копии dev-БД, на пустой базе и на такой базе. #84 Co-Authored-By: Claude Opus 5 --- .../alembic/versions/0015_announcements.py | 7 ++++-- .../0016_merge_rating_announcements.py | 25 +++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/0016_merge_rating_announcements.py diff --git a/backend/alembic/versions/0015_announcements.py b/backend/alembic/versions/0015_announcements.py index 39e977d..4f03dc5 100644 --- a/backend/alembic/versions/0015_announcements.py +++ b/backend/alembic/versions/0015_announcements.py @@ -3,8 +3,11 @@ Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op; на существующей БД создаёт недостающие таблицы. +Отходит от 0013, а не от 0014: объявления выпущены в main раньше рейтинга. Ветки рейтинга +(0014) и объявлений (0015) сводит пустая миграция 0016. + Revision ID: 0015_announcements -Revises: 0014_rating_inputs +Revises: 0013_user_token_version Create Date: 2026-09-18 """ from typing import Sequence, Union @@ -15,7 +18,7 @@ from sqlalchemy import inspect from alembic import op revision: str = "0015_announcements" -down_revision: Union[str, None] = "0014_rating_inputs" +down_revision: Union[str, None] = "0013_user_token_version" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None diff --git a/backend/alembic/versions/0016_merge_rating_announcements.py b/backend/alembic/versions/0016_merge_rating_announcements.py new file mode 100644 index 0000000..5442441 --- /dev/null +++ b/backend/alembic/versions/0016_merge_rating_announcements.py @@ -0,0 +1,25 @@ +"""Слияние веток миграций: рейтинг (0014) и объявления (0015). + +Объявления (#84) вышли в main раньше рейтинга, поэтому 0015 отходит от 0013, а не от 0014, +и у графа миграций две ветки. Эта миграция сводит их в одну голову и сама ничего не меняет: +прод, стоящий на 0015 (релиз без рейтинга), при переходе на эту версию получит 0014 и её; +база на 0014 (dev) — 0015 и её. + +Revision ID: 0016_merge_rating_announcements +Revises: 0014_rating_inputs, 0015_announcements +Create Date: 2026-09-18 +""" +from typing import Sequence, Union + +revision: str = "0016_merge_rating_announcements" +down_revision: Union[str, Sequence[str], None] = ("0014_rating_inputs", "0015_announcements") +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass -- 2.54.0