Merge pull request 'Система объявлений в main без рейтинга (#84)' (#86) from issue-84-announcements-main into main

Reviewed-on: #86
This commit was merged in pull request #86.
This commit is contained in:
2026-09-18 22:06:49 +03:00
26 changed files with 2222 additions and 6 deletions
+1
View File
@@ -43,6 +43,7 @@ mockups/
# AI-ассистенты (локальные, в репозиторий не идут) # AI-ассистенты (локальные, в репозиторий не идут)
CLAUDE.md CLAUDE.md
AGENTS.md
.claude/ .claude/
# Редактор / ОС # Редактор / ОС
@@ -0,0 +1,80 @@
"""Объявления администрации (#84): сами объявления и отметки «игрок закрыл».
Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД создаёт недостающие таблицы.
Отходит от 0013, а не от 0014: объявления выпущены в main раньше рейтинга. Ветки рейтинга
(0014) и объявлений (0015) сводит пустая миграция 0016.
Revision ID: 0015_announcements
Revises: 0013_user_token_version
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] = "0013_user_token_version"
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")
+3 -1
View File
@@ -17,6 +17,7 @@ from app.core.errors import AppError, app_error_handler
from app.routers import ( from app.routers import (
achievements, achievements,
admin, admin,
announcements,
auth, auth,
events, events,
groups, groups,
@@ -199,7 +200,8 @@ def create_app() -> FastAPI:
# API-роутеры под /api. # API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router, api_routers = [auth.router, users.router, groups.router, invitations.router,
matches.router, reference.router, stats.router, achievements.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: for r in api_routers:
app.include_router(r, prefix="/api") app.include_router(r, prefix="/api")
+63
View File
@@ -428,6 +428,69 @@ class Notification(SQLModel, table=True):
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=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): class AuditLog(SQLModel, table=True):
+122
View File
@@ -19,6 +19,7 @@ from app.services.match_service import ParticipantInput
from app.services import ( from app.services import (
achievement_service, achievement_service,
admin_service, admin_service,
announcement_service,
attachment_service, attachment_service,
audit_service, audit_service,
faction_service, faction_service,
@@ -475,6 +476,127 @@ def delete_achievement(
return s.OkResponse() 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) @router.get("/audit-logs", response_model=s.AuditLogList)
+46
View File
@@ -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()
+54 -1
View File
@@ -1,7 +1,7 @@
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта.""" """Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
from __future__ import annotations from __future__ import annotations
from datetime import date from datetime import date, datetime
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
@@ -222,6 +222,23 @@ class NotificationMarkRead(BaseModel):
ids: list[int] | None = None 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): class RandomizeRequest(BaseModel):
@@ -588,3 +605,39 @@ class AuditLogList(BaseModel):
items: list[AuditLogItem] = [] items: list[AuditLogItem] = []
limit: int limit: int
offset: 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
@@ -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) в разных
# браузерах пишет то <b>, то <strong>, абзацы — <div> или <p>; приводим к одному виду.
_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" у <mark>), весь текст экранируется заново. Всё, что
парсер не распознал как тег из списка, становится текстом или пропадает."""
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("<b") поймал бы и <br>.
if last == f"<{top}>" or last.startswith(f"<{top} "):
self.out.pop() # пустая пара (<p></p> от вложенных <div>) — выбрасываем
else:
self.out.append(f"</{top}>")
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:
# Абзац внутри абзаца (вложенные <div> из 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 = '<mark class="red">'
self.stack.append(name)
self.out.append(opening)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
# <br/> и прочие самозакрытые: содержимого нет, так что 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()
+11 -1
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.events import hub 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]: 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: def notifications_changed(user_id: int) -> None:
"""У пользователя появилось/изменилось уведомление — пусть подтянет список.""" """У пользователя появилось/изменилось уведомление — пусть подтянет список."""
hub.publish([user_id], {"type": "notifications"}) 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"})
+286
View File
@@ -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": "<p>Суббота, <b>11:00</b></p>",
"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 = (
'<div>Привет <strong onclick="steal()">мир</strong></div>'
'<p style="color:red"><i>курсив</i> <mark class="red big">красный</mark> '
'<mark class="gold" onmouseover="x()">золотой</mark></p>'
"<script>alert(1)</script><style>p{display:none}</style>"
'<img src=x onerror="alert(1)"><a href="javascript:alert(1)">ссылка</a>'
"<svg><text>svg-текст</text></svg>&lt;b&gt;"
)
html, text = sanitize_body(raw)
assert html == (
"<p>Привет <b>мир</b></p>"
'<p><em>курсив</em> <mark class="red">красный</mark> <mark>золотой</mark></p>'
"ссылка&lt;b&gt;"
)
# Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает.
assert text == "Привет миркурсив красный золотойссылка<b>"
def test_sanitize_fixes_nesting_and_keeps_line_breaks():
html, _ = sanitize_body("<div><div>один</div><div>два<br></div></div><b>жирный<br/>хвост")
# Вложенные <div> из contenteditable → плоские абзацы без пустых <p></p>;
# <br> внутри <b> не путается с самим <b>; незакрытое закрывается.
assert html == "<p>один</p><p>два<br></p><b>жирный<br>хвост</b>"
# ─── Права ───────────────────────────────────────────────────────────────────
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="<p>Сбор <b>в 11:00</b></p><script>alert(1)</script>")
assert ann["body_html"] == "<p>Сбор <b>в 11:00</b></p>"
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="<p>Сбор в 12:00</p>")
assert ann["revision"] == 1
assert _pending(client) == []
ann = _update(client, ann, body_html="<p>Сбор в 13:00</p>", 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, "<p>Сбор в 13:00</p>")
]
# Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью.
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": "<p> </p><script>текст</script>"}, "пустым"),
({"body_html": "<p>" + "а" * 601 + "</p>"}, "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": "<p>текст</p>",
"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
View File
@@ -19,12 +19,14 @@ export const qk = {
authConfig: ["authConfig"] as const, authConfig: ["authConfig"] as const,
invitations: ["invitations"] as const, invitations: ["invitations"] as const,
notifications: ["notifications"] as const, notifications: ["notifications"] as const,
announcements: ["announcements"] as const,
devUsers: ["devUsers"] as const, devUsers: ["devUsers"] as const,
adminUsers: ["adminUsers"] as const, adminUsers: ["adminUsers"] as const,
adminGroups: ["adminGroups"] as const, adminGroups: ["adminGroups"] as const,
adminMatches: ["adminMatches"] as const, adminMatches: ["adminMatches"] as const,
adminLogs: ["adminLogs"] as const, adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const, adminAchievements: ["adminAchievements"] as const,
adminAnnouncements: ["adminAnnouncements"] as const,
}; };
/** /**
+395
View File
@@ -839,6 +839,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/admin/auth/login": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1137,6 +1171,62 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/admin/audit-logs": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1274,6 +1364,36 @@ export interface components {
/** Group Id */ /** Group Id */
group_id?: number | null; 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 */
AdminGroupRead: { AdminGroupRead: {
/** Id */ /** Id */
@@ -1358,6 +1478,76 @@ export interface components {
/** Is Active */ /** Is Active */
is_active?: boolean | null; 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 */
AttachmentRead: { AttachmentRead: {
/** Id */ /** 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: { admin_login_api_admin_auth_login_post: {
parameters: { parameters: {
query?: never; 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: { audit_logs_api_admin_audit_logs_get: {
parameters: { parameters: {
query?: { query?: {
+2
View File
@@ -14,6 +14,7 @@ import { OverallStatsPage } from "../pages/OverallStatsPage";
import { PublicProfilePage } from "../pages/PublicProfilePage"; import { PublicProfilePage } from "../pages/PublicProfilePage";
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage"; import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage"; import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
import { AdminAnnouncementsPage } from "../pages/admin/AdminAnnouncementsPage";
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage"; import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
import { AdminLayout } from "../pages/admin/AdminLayout"; import { AdminLayout } from "../pages/admin/AdminLayout";
import { AdminLogsPage } from "../pages/admin/AdminLogsPage"; import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
@@ -70,6 +71,7 @@ export const router = createBrowserRouter([
{ path: "factions", element: <AdminFactionsPage /> }, { path: "factions", element: <AdminFactionsPage /> },
{ path: "achievements", element: <AdminAchievementsPage /> }, { path: "achievements", element: <AdminAchievementsPage /> },
{ path: "logs", element: <AdminLogsPage /> }, { path: "logs", element: <AdminLogsPage /> },
{ path: "announcements", element: <AdminAnnouncementsPage /> },
], ],
}, },
{ path: "*", element: <Navigate to="/" replace /> }, { 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
View File
@@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMe } from "../hooks/auth"; import { useMe } from "../hooks/auth";
import { useServerEvents } from "../hooks/useServerEvents"; import { useServerEvents } from "../hooks/useServerEvents";
import { AnnouncementDialog } from "./AnnouncementDialog";
import { BottomBar } from "./BottomBar"; import { BottomBar } from "./BottomBar";
import { NotificationBell } from "./NotificationBell"; import { NotificationBell } from "./NotificationBell";
import { NotificationToaster } from "./NotificationToaster"; import { NotificationToaster } from "./NotificationToaster";
@@ -55,6 +56,8 @@ export function AppShell() {
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */} {/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />} {me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
{/* Объявления администрации — только после окна пароля, на любой странице. */}
{me?.has_password && <AnnouncementDialog />}
</div> </div>
); );
} }
+15
View File
@@ -42,6 +42,21 @@ export function formatDate(iso: string | null | undefined): string {
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso; 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 { export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—"; if (!iso) return "—";
const d = shifted(iso); const d = shifted(iso);
+4
View File
@@ -34,3 +34,7 @@ export type MatchUpdate = S["MatchUpdate"];
export type MatchCreate = S["MatchCreate"]; export type MatchCreate = S["MatchCreate"];
export type MatchFinish = S["MatchFinish"]; export type MatchFinish = S["MatchFinish"];
export type MatchFinishDraftData = S["MatchFinishDraftData"]; 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"];
+60 -1
View File
@@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, authProbeRetry, unwrap } from "../api/client"; import { api, authProbeRetry, unwrap } from "../api/client";
import { qk } from "../api/queryKeys"; 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"; import { uploadFile } from "../lib/upload";
export function useAdminMe() { export function useAdminMe() {
@@ -282,3 +287,57 @@ export function useUploadAchievementIcon() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }), 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 } },
}),
),
);
}
+40
View File
@@ -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 }),
});
}
+3 -1
View File
@@ -5,7 +5,7 @@ import { matchAffectedKeys, qk } from "../api/queryKeys";
import { useMe } from "./auth"; import { useMe } from "./auth";
interface ServerEvent { interface ServerEvent {
type: "match" | "match_draft" | "group" | "invitations" | "notifications"; type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements";
match_id?: number; match_id?: number;
group_id?: number; group_id?: number;
/** Кто играл в партии: их история и профили протухли, чужие — нет. */ /** Кто играл в партии: их история и профили протухли, чужие — нет. */
@@ -40,6 +40,8 @@ export function useServerEvents(enabled: boolean) {
qc.invalidateQueries({ queryKey: qk.invitations }); qc.invalidateQueries({ queryKey: qk.invitations });
} else if (ev.type === "notifications") { } else if (ev.type === "notifications") {
qc.invalidateQueries({ queryKey: qk.notifications }); qc.invalidateQueries({ queryKey: qk.notifications });
} else if (ev.type === "announcements") {
qc.invalidateQueries({ queryKey: qk.announcements });
} else if (ev.type === "match_draft") { } 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>
);
}
+3
View File
@@ -38,6 +38,9 @@ export function AdminLayout() {
<NavLink to="/admin/logs" className={tab}> <NavLink to="/admin/logs" className={tab}>
Логи Логи
</NavLink> </NavLink>
<NavLink to="/admin/announcements" className={tab}>
Объявления
</NavLink>
</div> </div>
<main className="app-main" style={{ paddingBottom: 24 }}> <main className="app-main" style={{ paddingBottom: 24 }}>
<Outlet /> <Outlet />
+1 -1
View File
@@ -5,7 +5,7 @@ import { formatDateTime } from "../../domain/format";
import { useAdminLogs } from "../../hooks/admin"; import { useAdminLogs } from "../../hooks/admin";
const ACTIONS = ["", "login", "create", "update", "delete"]; 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() { export function AdminLogsPage() {
const [action, setAction] = useState(""); const [action, setAction] = useState("");
+183
View File
@@ -0,0 +1,183 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Кнопка панели. 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}>
✕&nbsp;формат
</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>
);
}
+90
View File
@@ -458,3 +458,93 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.toast-top-error { border-left-color: var(--danger); } .toast-top-error { border-left-color: var(--danger); }
.toast-top-title { font-weight: 700; } .toast-top-title { font-weight: 700; }
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); } .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); }