@@ -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")
|
||||||
@@ -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
|
||||||
+3
-1
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -447,6 +447,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):
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from app.schemas import api as s
|
|||||||
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,
|
||||||
@@ -463,6 +464,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)
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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 Annotated, Literal
|
from typing import Annotated, Literal
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
@@ -233,6 +233,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):
|
||||||
@@ -627,3 +644,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()
|
||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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><b>"
|
||||||
|
)
|
||||||
|
html, text = sanitize_body(raw)
|
||||||
|
assert html == (
|
||||||
|
"<p>Привет <b>мир</b></p>"
|
||||||
|
'<p><em>курсив</em> <mark class="red">красный</mark> <mark>золотой</mark></p>'
|
||||||
|
"ссылка<b>"
|
||||||
|
)
|
||||||
|
# Содержимое 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"]
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Vendored
+395
@@ -842,6 +842,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;
|
||||||
@@ -1140,6 +1174,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;
|
||||||
@@ -1277,6 +1367,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 */
|
||||||
@@ -1361,6 +1481,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 */
|
||||||
@@ -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: {
|
admin_login_api_admin_auth_login_post: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
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: {
|
audit_logs_api_admin_audit_logs_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
|
|||||||
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,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);
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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 } },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { api, unwrap } from "../api/client";
|
||||||
|
import { qk } from "../api/queryKeys";
|
||||||
|
import type { AnnouncementRead } from "../domain/types";
|
||||||
|
|
||||||
|
// Объявления администрации, которые игрок ещё не закрыл, — от старого к новому.
|
||||||
|
export function usePendingAnnouncements(enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: qk.announcements,
|
||||||
|
queryFn: async () => unwrap(await api.GET("/api/announcements/pending")),
|
||||||
|
enabled,
|
||||||
|
// Новое и изменённое объявление приходит SSE-событием, а вот наступление отложенного
|
||||||
|
// начала показа — не событие в данных. Его подхватываем периодическим перезапросом.
|
||||||
|
refetchInterval: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// «Понятно»: объявление помечается закрытым в той версии, которую игрок видел.
|
||||||
|
export function useAckAnnouncement() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (a: { id: number; revision: number }) =>
|
||||||
|
unwrap(
|
||||||
|
await api.POST("/api/announcements/{announcement_id}/ack", {
|
||||||
|
params: { path: { announcement_id: a.id } },
|
||||||
|
body: { revision: a.revision },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
// Закрытое убираем из очереди сразу — следующее открывается, не дожидаясь сети.
|
||||||
|
onMutate: async (a) => {
|
||||||
|
await qc.cancelQueries({ queryKey: qk.announcements });
|
||||||
|
qc.setQueryData<AnnouncementRead[]>(qk.announcements, (old) =>
|
||||||
|
old?.filter((x) => x.id !== a.id),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
// Если отметка не записалась, объявление вернётся с перезапросом, а не пропадёт молча.
|
||||||
|
onSettled: () => qc.invalidateQueries({ queryKey: qk.announcements }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import { matchAffectedKeys, qk } from "../api/queryKeys";
|
|||||||
import { useMe } from "./auth";
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
|
||||||
|
|
||||||
|
const escapeHtml = (s: string) =>
|
||||||
|
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||||
|
|
||||||
|
// Кнопка панели. mousedown гасим, чтобы фокус и выделение остались в поле текста.
|
||||||
|
function Tool({
|
||||||
|
title,
|
||||||
|
className,
|
||||||
|
onClick,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
className?: string;
|
||||||
|
onClick: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={"rte-tool" + (className ? ` ${className}` : "")}
|
||||||
|
title={title}
|
||||||
|
aria-label={title}
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Редактор текста объявления (вариант R1 из #84): поле contenteditable и панель —
|
||||||
|
* жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления. Набор узкий
|
||||||
|
* намеренно: сервер всё равно оставит только b / em / mark / p / br
|
||||||
|
* (announcement_service.sanitize_body). Вставка и перетаскивание — только простым текстом.
|
||||||
|
*
|
||||||
|
* Поле неуправляемое: начальный HTML ставится при монтировании (для другого объявления
|
||||||
|
* родитель пересоздаёт редактор через key), дальше каждое изменение уходит в onChange
|
||||||
|
* вместе с длиной видимого текста в символах — так же её считает сервер.
|
||||||
|
*/
|
||||||
|
export function RichTextEditor({
|
||||||
|
initialHtml,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
initialHtml: string;
|
||||||
|
onChange: (html: string, length: number) => void;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [emojiOpen, setEmojiOpen] = useState(false);
|
||||||
|
|
||||||
|
const emit = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (el) onChange(el.innerHTML, [...(el.textContent ?? "").trim()].length);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Только при монтировании: дальше содержимым владеет сам contenteditable.
|
||||||
|
useEffect(() => {
|
||||||
|
if (ref.current) ref.current.innerHTML = initialHtml;
|
||||||
|
emit();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Выделение внутри поля — кнопки панели работают только с ним.
|
||||||
|
const rangeInEditor = (): Range | null => {
|
||||||
|
const sel = document.getSelection();
|
||||||
|
if (!sel || sel.rangeCount === 0) return null;
|
||||||
|
const range = sel.getRangeAt(0);
|
||||||
|
return ref.current?.contains(range.commonAncestorContainer) ? range : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Курсор в конец поля, если в нём ещё не кликали (эмодзи до первого клика в текст).
|
||||||
|
const ensureCaret = () => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.focus();
|
||||||
|
if (rangeInEditor()) return;
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(el);
|
||||||
|
range.collapse(false);
|
||||||
|
const sel = document.getSelection();
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
};
|
||||||
|
|
||||||
|
const run = (fn: () => void) => {
|
||||||
|
fn();
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
const format = (cmd: "bold" | "italic") =>
|
||||||
|
run(() => {
|
||||||
|
if (rangeInEditor()) document.execCommand(cmd);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Акцент: выделенный текст целиком оборачивается в <mark> (золотой) или
|
||||||
|
// <mark class="red">. Оформление внутри выделения при этом снимается — для коротких
|
||||||
|
// акцентов это проще, чем вложенные теги.
|
||||||
|
const accent = (red: boolean) =>
|
||||||
|
run(() => {
|
||||||
|
const range = rangeInEditor();
|
||||||
|
if (!range || range.collapsed) return;
|
||||||
|
const inner = range.toString().split("\n").map(escapeHtml).join("<br>");
|
||||||
|
document.execCommand("insertHTML", false, `<mark${red ? ' class="red"' : ""}>${inner}</mark>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const clearFormat = () =>
|
||||||
|
run(() => {
|
||||||
|
if (!rangeInEditor()) return;
|
||||||
|
document.execCommand("removeFormat");
|
||||||
|
// removeFormat не знает про <mark> — акценты в выделении снимаем сами.
|
||||||
|
const range = rangeInEditor();
|
||||||
|
ref.current?.querySelectorAll("mark").forEach((m) => {
|
||||||
|
if (range?.intersectsNode(m)) m.replaceWith(...Array.from(m.childNodes));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const insertEmoji = (emoji: string) =>
|
||||||
|
run(() => {
|
||||||
|
ensureCaret();
|
||||||
|
document.execCommand("insertText", false, emoji);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="rte-toolbar">
|
||||||
|
<Tool title="Жирный" onClick={() => format("bold")}>
|
||||||
|
<b>Ж</b>
|
||||||
|
</Tool>
|
||||||
|
<Tool title="Курсив" onClick={() => format("italic")}>
|
||||||
|
<em>К</em>
|
||||||
|
</Tool>
|
||||||
|
<Tool title="Золотой акцент" className="rte-gold" onClick={() => accent(false)}>
|
||||||
|
A
|
||||||
|
</Tool>
|
||||||
|
<Tool title="Красный акцент" className="rte-red" onClick={() => accent(true)}>
|
||||||
|
A
|
||||||
|
</Tool>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<Tool title="Эмодзи" onClick={() => setEmojiOpen((v) => !v)}>
|
||||||
|
😀
|
||||||
|
</Tool>
|
||||||
|
<span className="rte-sep" />
|
||||||
|
<Tool title="Убрать оформление" onClick={clearFormat}>
|
||||||
|
✕ формат
|
||||||
|
</Tool>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className="rte-editor ann-body"
|
||||||
|
contentEditable
|
||||||
|
suppressContentEditableWarning
|
||||||
|
role="textbox"
|
||||||
|
aria-multiline="true"
|
||||||
|
aria-label="Текст объявления"
|
||||||
|
data-placeholder="Короткий текст объявления…"
|
||||||
|
onInput={emit}
|
||||||
|
// Enter создаёт <p>, а не <div> — абзацы сразу в том виде, что хранит сервер.
|
||||||
|
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")}
|
||||||
|
onPaste={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
|
||||||
|
}}
|
||||||
|
onDrop={(e) => e.preventDefault()}
|
||||||
|
/>
|
||||||
|
{emojiOpen && (
|
||||||
|
<div className="rte-emoji">
|
||||||
|
{EMOJI.map((emoji) => (
|
||||||
|
<button
|
||||||
|
key={emoji}
|
||||||
|
type="button"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => insertEmoji(emoji)}
|
||||||
|
>
|
||||||
|
{emoji}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -487,3 +487,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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user