From 5c3aa6ba693e3026de2187a9e5c0b007f04d6dbb Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 04:08:00 +0300 Subject: [PATCH 1/7] =?UTF-8?q?=D0=A3=D0=B2=D0=B5=D0=B4=D0=BE=D0=BC=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F:=20=D0=BD=D0=B5=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D0=B0=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B5?= =?UTF-8?q?=20=D0=BF=D1=80=D0=BE=D1=82=D1=83=D1=85=D0=B0=D1=8E=D1=82=20?= =?UTF-8?q?=E2=80=94=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=20=D1=83=D0=B2=D0=B8?= =?UTF-8?q?=D0=B4=D0=B8=D1=82=20=D0=B8=D1=85=20=D0=BF=D1=80=D0=B8=20=D0=BB?= =?UTF-8?q?=D1=8E=D0=B1=D0=BE=D0=BC=20=D0=B7=D0=B0=D1=85=D0=BE=D0=B4=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше уведомления удалялись через 72 часа после создания независимо от прочтения: кто не зашёл за 3 дня, не видел их вовсе. Теперь по времени чистятся только прочитанные (72ч после прочтения), а от роста у незаходящих защищает кап MAX_PER_USER=100 — при создании удаляются старейшие сверх лимита. Механика доставки не менялась: запись в БД + pull при заходе, SSE лишь ускоряет обновление открытых вкладок. Co-Authored-By: Claude Fable 5 --- backend/app/main.py | 6 +-- backend/app/models.py | 3 +- backend/app/routers/notifications.py | 3 +- backend/app/services/notification_service.py | 33 +++++++++++--- backend/tests/test_notifications.py | 46 +++++++++++++++----- 5 files changed, 71 insertions(+), 20 deletions(-) diff --git a/backend/app/main.py b/backend/app/main.py index 6b53aaa..46bccc4 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -72,12 +72,12 @@ class CSRFMiddleware: await self.app(scope, receive, send) -_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим протухшие уведомления (>72ч) +_NOTIFICATIONS_PURGE_INTERVAL = 3600 # раз в час чистим давно прочитанные уведомления async def _notifications_purge_loop() -> None: - """Фоновая чистка протухших уведомлений (single-worker безопасно). Чтобы удалялись - «отовсюду» даже у неактивных пользователей (помимо очистки при чтении списка).""" + """Фоновая чистка давно прочитанных уведомлений (single-worker безопасно). Чтобы + удалялись «отовсюду» даже у неактивных пользователей (помимо очистки при чтении).""" import asyncio from app.db.session import Session, engine diff --git a/backend/app/models.py b/backend/app/models.py index adb9f01..478d619 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -344,7 +344,8 @@ class Notification(SQLModel, table=True): """Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.). Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер — - фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка.""" + фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя), + прочитанные чистятся спустя 72 часа — фоном и при чтении списка.""" __tablename__ = "notifications" __table_args__ = ( diff --git a/backend/app/routers/notifications.py b/backend/app/routers/notifications.py index 080ce2a..1f46b28 100644 --- a/backend/app/routers/notifications.py +++ b/backend/app/routers/notifications.py @@ -1,7 +1,8 @@ """Уведомления игрока: список (pull) и отметка прочитанными. Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент -перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном).""" +перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся +спустя 72 часа (при чтении и фоном).""" from __future__ import annotations from fastapi import APIRouter, Depends diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index 2c646b1..cd3c80d 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -2,7 +2,9 @@ Запись в БД + живой сигнал по SSE (`notify.notifications_changed`) — клиент по сигналу подтягивает список (`GET /api/notifications`). Текст (RU) и ссылку рендерим здесь, на сервере. -Хранение — 72 часа; протухшие удаляются при чтении списка и фоновой задачей. +Хранение: непрочитанные живут до прочтения (игрок должен увидеть их, когда бы ни зашёл), +прочитанные удаляются спустя RETENTION_HOURS после прочтения — при чтении списка и фоновой +задачей. От роста у незаходящих защищает кап MAX_PER_USER при создании. """ from __future__ import annotations @@ -14,7 +16,8 @@ from app.core.timeutil import iso_utc from app.models import Group, Match, MatchParticipant, Notification from app.services import notify -RETENTION_HOURS = 72 +RETENTION_HOURS = 72 # сколько живёт ПРОЧИТАННОЕ уведомление после прочтения +MAX_PER_USER = 100 # кап на пользователя: старейшие сверх него удаляются при создании def _now() -> datetime: @@ -45,6 +48,9 @@ def create_for( Notification(user_id=uid, type=type, title=title, body=body, link=link) for uid in ids ] session.add_all(rows) + session.flush() + for uid in ids: + _cap_user(session, uid) session.commit() for uid in ids: notify.notifications_changed(uid) @@ -64,10 +70,27 @@ def create( return rows[0] if rows else None +def _cap_user(session: Session, user_id: int) -> None: + """Удалить старейшие уведомления пользователя сверх MAX_PER_USER (без commit).""" + extra = session.exec( + select(Notification) + .where(Notification.user_id == user_id) + .order_by(Notification.created_at.desc(), Notification.id.desc()) + .offset(MAX_PER_USER) + ).all() + for row in extra: + session.delete(row) + + def purge_expired(session: Session) -> int: - """Удалить уведомления старше RETENTION_HOURS. Возвращает число удалённых.""" + """Удалить ПРОЧИТАННЫЕ уведомления спустя RETENTION_HOURS после прочтения. + + Непрочитанные по времени не протухают — иначе игрок, не заходивший дольше окна + хранения, не увидел бы их вовсе. Возвращает число удалённых.""" rows = session.exec( - select(Notification).where(Notification.created_at < _cutoff()) + select(Notification).where( + Notification.read_at.is_not(None), Notification.read_at < _cutoff() + ) ).all() for row in rows: session.delete(row) @@ -77,7 +100,7 @@ def purge_expired(session: Session) -> int: def list_for_user(session: Session, user_id: int) -> dict: - """Свежие (<72ч) уведомления пользователя + число непрочитанных. Чистит протухшие.""" + """Уведомления пользователя + число непрочитанных. Попутно чистит давно прочитанные.""" purge_expired(session) rows = session.exec( select(Notification) diff --git a/backend/tests/test_notifications.py b/backend/tests/test_notifications.py index f76b119..fc10232 100644 --- a/backend/tests/test_notifications.py +++ b/backend/tests/test_notifications.py @@ -56,23 +56,49 @@ def test_create_list_mark_read(engine): assert after["items"][0]["read_at"] is not None -def test_purge_expired(engine): +def test_purge_removes_only_long_read(engine): + """Протухают лишь ПРОЧИТАННЫЕ (спустя 72ч после прочтения): непрочитанное должно + дождаться игрока, сколько бы тот ни отсутствовал.""" uid = _make_user(engine, "Боб") + old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( + hours=notification_service.RETENTION_HOURS + 1 + ) with Session(engine) as s: - n = Notification(user_id=uid, type="test", title="Старое") - s.add(n) - s.commit() - s.refresh(n) - # Состарим запись за пределы окна хранения. - n.created_at = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta( - hours=notification_service.RETENTION_HOURS + 1 + # Старое непрочитанное, старое давно прочитанное и свежепрочитанное. + s.add(Notification(user_id=uid, type="test", title="Непрочитанное", created_at=old)) + s.add( + Notification( + user_id=uid, type="test", title="Давно прочитанное", created_at=old, read_at=old + ) + ) + s.add( + Notification( + user_id=uid, + type="test", + title="Свежепрочитанное", + created_at=old, + read_at=datetime.now(timezone.utc).replace(tzinfo=None), + ) ) - s.add(n) s.commit() removed = notification_service.purge_expired(s) assert removed == 1 - assert notification_service.list_for_user(s, uid)["items"] == [] + titles = {n["title"] for n in notification_service.list_for_user(s, uid)["items"]} + assert titles == {"Непрочитанное", "Свежепрочитанное"} + + +def test_cap_per_user(engine): + """Кап MAX_PER_USER: при создании сверх лимита удаляются старейшие.""" + uid = _make_user(engine, "Копитель") + with Session(engine) as s: + for i in range(notification_service.MAX_PER_USER + 5): + notification_service.create(s, uid, type="test", title=f"n{i}") + data = notification_service.list_for_user(s, uid) + assert len(data["items"]) == notification_service.MAX_PER_USER + titles = {n["title"] for n in data["items"]} + assert "n0" not in titles # старейшие срезаны + assert f"n{notification_service.MAX_PER_USER + 4}" in titles # новейшее на месте def test_notifications_require_auth(client: TestClient): From 4b7d8678cee11f4d92073b00c6ee386f3c72a65e Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 16:50:43 +0300 Subject: [PATCH 2/7] =?UTF-8?q?UI:=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B8=20=E2=80=94=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BB=D0=B8=D1=82=D1=80=D0=B0=20=D0=BA=D0=B0=D1=80=D1=82?= =?UTF-8?q?=D0=BE=D1=87=D0=B5=D0=BA=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=B3=D1=80=D1=83=D0=BF=D0=BF=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Фон .participant-row: surface-2 → surface; surface-2 остаётся только у интерактивных элементов внутри (поля, кнопки, выпадашки). Затрагивает старт партии, форму завершения и админ-правку — везде единообразно. Co-Authored-By: Claude Fable 5 --- frontend/src/styles/layout.css | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/styles/layout.css b/frontend/src/styles/layout.css index 6084ee5..f0597ae 100644 --- a/frontend/src/styles/layout.css +++ b/frontend/src/styles/layout.css @@ -181,7 +181,9 @@ border: 1px solid var(--border); border-radius: var(--radius-sm); margin-bottom: 10px; - background: var(--surface-2); + /* Палитра как у карточек страницы группы: блок — surface, а surface-2 — только + интерактивные элементы внутри (поля, кнопки, выпадашки). */ + background: var(--surface); } .place-badge { display: inline-flex; From 376605f554bd8bc20d46284de5f93ea5ffb46ec5 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 17:02:10 +0300 Subject: [PATCH 3/7] =?UTF-8?q?API:=20avatar=5Furl=20=D1=83=20=D1=83=D1=87?= =?UTF-8?q?=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=BF=D0=B0?= =?UTF-8?q?=D1=80=D1=82=D0=B8=D0=B8=20(=D0=B4=D0=BB=D1=8F=20=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=BA=D0=BE=D0=B2=20=D0=BD=D0=BE=D0=B2=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B0=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=82)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- backend/app/routers/matches.py | 1 + backend/app/schemas/api.py | 1 + frontend/src/api/schema.d.ts | 2 ++ 3 files changed, 4 insertions(+) diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index 719efd6..ef4cb4d 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -56,6 +56,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False eliminated=p.eliminated, was_random=p.was_random, comment=p.comment, + avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type] ) for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type] ] diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index b8cc7eb..f5b8b86 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -254,6 +254,7 @@ class MatchParticipantRead(BaseModel): eliminated: bool = False was_random: bool comment: str | None = None + avatar_url: str | None = None class AttachmentRead(BaseModel): diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index dbc5301..09f298d 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1682,6 +1682,8 @@ export interface components { was_random: boolean; /** Comment */ comment?: string | null; + /** Avatar Url */ + avatar_url?: string | null; }; /** MatchRead */ MatchRead: { From 3023e9f515e33b9093889b258d7cd62902c01277 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 17:07:26 +0300 Subject: [PATCH 4/7] =?UTF-8?q?UI:=20=D0=BC=D0=B5=D1=81=D1=82=D0=B0=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B8=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B8=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B8=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B5=D1=80=D0=B5=D1=82=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D0=B8=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B1=D0=BB=D0=BE?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2=20(=D0=BC=D0=B0=D0=BA=D0=B5=D1=82=20A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Вместо select-ов мест — PlaceEditor: вертикальный список блоков игроков (верхний — 1-е место), бросок на другого игрока сливает в ничью с общей рамкой и местом (✂ выносит обратно), выбывшие — перетаскиванием в пунктирную зону (сервер сам ставит им последнее место). Комментарий об игроке — строка в каждом блоке. Причина победы — PickerSelect (дженерик расширен на строковые id), общий комментарий и медиа — карточкой, кнопка завершения отдельно. Места считаются по блокам и всегда дают валидный competition ranking (1,2,2,4). Co-Authored-By: Claude Fable 5 --- frontend/src/components/PickerSelect.tsx | 2 +- frontend/src/components/PlaceEditor.tsx | 289 +++++++++++++++++++++++ frontend/src/pages/MatchDetailPage.tsx | 145 ++++++------ frontend/src/styles/global.css | 88 +++++++ 4 files changed, 446 insertions(+), 78 deletions(-) create mode 100644 frontend/src/components/PlaceEditor.tsx diff --git a/frontend/src/components/PickerSelect.tsx b/frontend/src/components/PickerSelect.tsx index a042c00..3a5554a 100644 --- a/frontend/src/components/PickerSelect.tsx +++ b/frontend/src/components/PickerSelect.tsx @@ -8,7 +8,7 @@ import { type ReactNode, useState } from "react"; * selected, найденный в ПОЛНОМ списке: выбранное значение может отсутствовать * в options (например, фракция занята другой строкой после «Рандом всем»). */ -export function PickerSelect({ +export function PickerSelect({ selected, options, placeholder, diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx new file mode 100644 index 0000000..7deb09d --- /dev/null +++ b/frontend/src/components/PlaceEditor.tsx @@ -0,0 +1,289 @@ +import { Scissors } from "lucide-react"; +import { useRef } from "react"; + +import { Avatar } from "./Avatar"; + +export interface PlacePlayer { + user_id: number; + nickname: string; + faction_name: string; + avatar_url?: string | null; +} + +type Target = + | { type: "merge"; idx: number } + | { type: "insert"; idx: number } + | { type: "elim" }; + +interface DragState { + blockIdx: number | null; // null — тянем блок из зоны выбывших + elimId: number | null; + el: HTMLElement; + startY: number; + rects: { idx: number; el: HTMLElement; rect: DOMRect }[]; + elimRect: DOMRect | null; +} + +/** + * Редактор мест перетаскиванием (макет A2): вертикальный список блоков игроков, + * верхний — 1-е место. Бросок между блоками — порядок, на середину чужого блока — + * слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону — + * выбывший. В каждом блоке — строка комментария об игроке. + * + * Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и + * eliminated живут у родителя. Во время drag DOM двигается напрямую (transform, + * классы-подсветки) — setState только на отпускании, перерендер сбрасывает transform. + */ +export function PlaceEditor({ + players, + blocks, + eliminated, + comments, + onChange, + onComment, +}: { + players: PlacePlayer[]; + blocks: number[][]; + eliminated: number[]; + comments: Record; + onChange: (blocks: number[][], eliminated: number[]) => void; + onComment: (userId: number, text: string) => void; +}) { + const listRef = useRef(null); + const elimRef = useRef(null); + const indRef = useRef(null); + const dragRef = useRef(null); + + const byId = new Map(players.map((p) => [p.user_id, p])); + const placeOf = (i: number) => + 1 + blocks.slice(0, i).reduce((sum, b) => sum + b.length, 0); + + // ─── Drag ─── + + const computeTarget = (pointerY: number, d: DragState): Target => { + if (d.elimRect && pointerY > d.elimRect.top && pointerY < d.elimRect.bottom) { + return { type: "elim" }; + } + for (const r of d.rects) { + if (r.el === d.el) continue; + const h = r.rect.height; + if (pointerY > r.rect.top + h * 0.3 && pointerY < r.rect.bottom - h * 0.3) { + return { type: "merge", idx: r.idx }; + } + } + let idx = 0; + for (const r of d.rects) { + if (r.el === d.el) continue; + if (pointerY > r.rect.top + r.rect.height / 2) idx = r.idx + 1; + } + return { type: "insert", idx }; + }; + + const clearHints = () => { + if (indRef.current) indRef.current.style.display = "none"; + listRef.current + ?.querySelectorAll(".merge-target") + .forEach((n) => n.classList.remove("merge-target")); + elimRef.current?.classList.remove("highlight"); + }; + + const showHint = (t: Target, d: DragState) => { + clearHints(); + if (t.type === "elim") { + elimRef.current?.classList.add("highlight"); + return; + } + if (t.type === "merge") { + d.rects.find((r) => r.idx === t.idx)?.el.classList.add("merge-target"); + return; + } + const ind = indRef.current; + const list = listRef.current; + if (!ind || !list || d.rects.length === 0) return; + const listTop = list.getBoundingClientRect().top; + const others = d.rects.filter((r) => r.el !== d.el); + if (others.length === 0) return; + const y = + t.idx === 0 + ? others[0].rect.top - listTop - 5 + : (d.rects.find((r) => r.idx === t.idx - 1)?.rect.bottom ?? 0) - listTop + 2; + ind.style.top = `${y}px`; + ind.style.display = "block"; + }; + + const startDrag = ( + e: React.PointerEvent, + opts: { blockIdx?: number; elimId?: number }, + ) => { + e.preventDefault(); + const grip = e.currentTarget; + const el = (grip.closest("[data-idx]") ?? grip.closest(".rank-block")) as HTMLElement | null; + const list = listRef.current; + if (!el || !list) return; + dragRef.current = { + blockIdx: opts.blockIdx ?? null, + elimId: opts.elimId ?? null, + el, + startY: e.clientY, + rects: [...list.querySelectorAll(":scope > [data-idx]")].map((n) => ({ + idx: Number(n.dataset.idx), + el: n, + rect: n.getBoundingClientRect(), + })), + elimRect: elimRef.current?.getBoundingClientRect() ?? null, + }; + el.classList.add("dragging"); + grip.setPointerCapture(e.pointerId); + }; + + const moveDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + d.el.style.transform = `translateY(${e.clientY - d.startY}px)`; + showHint(computeTarget(e.clientY, d), d); + }; + + const endDrag = (e: React.PointerEvent) => { + const d = dragRef.current; + if (!d) return; + dragRef.current = null; + clearHints(); + d.el.classList.remove("dragging"); + d.el.style.transform = ""; + + const t = computeTarget(e.clientY, d); + const moved = d.elimId != null ? [d.elimId] : [...blocks[d.blockIdx!]]; + const nextBlocks = blocks.map((b) => [...b]); + let nextElim = [...eliminated]; + const takeMoved = () => { + if (d.elimId != null) nextElim = nextElim.filter((x) => x !== d.elimId); + else nextBlocks.splice(d.blockIdx!, 1); + }; + + if (t.type === "elim") { + if (d.elimId != null) return; // уже там + takeMoved(); + nextElim.push(...moved); + } else if (t.type === "merge") { + const target = nextBlocks[t.idx]; // ссылка ДО удаления: индексы сместятся + takeMoved(); + target.push(...moved); + } else { + let idx = t.idx; + if (d.elimId == null && d.blockIdx! < idx) idx -= 1; + takeMoved(); + nextBlocks.splice(idx, 0, moved); + } + onChange(nextBlocks, nextElim); + }; + + const gripProps = (opts: { blockIdx?: number; elimId?: number }) => ({ + className: "rank-grip", + onPointerDown: (e: React.PointerEvent) => startDrag(e, opts), + onPointerMove: moveDrag, + onPointerUp: endDrag, + }); + + // ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё. + const unlink = (userId: number) => { + const bi = blocks.findIndex((b) => b.includes(userId)); + if (bi < 0) return; + const rest = blocks[bi].filter((x) => x !== userId); + const next = blocks.map((b) => [...b]); + next[bi] = rest; + next.splice(bi + 1, 0, [userId]); + if (rest.length === 0) next.splice(bi, 1); + onChange(next, eliminated); + }; + + // ─── Разметка ─── + + const playerRow = (userId: number, lead?: React.ReactNode, extra?: React.ReactNode) => { + const p = byId.get(userId); + if (!p) return null; + return ( +
+ {lead} + +
+
{p.nickname}
+
{p.faction_name}
+
+ {extra} +
+ ); + }; + + const commentInput = (userId: number) => ( + onComment(userId, e.target.value)} + /> + ); + + return ( +
+
+ {blocks.map((ids, i) => { + const place = placeOf(i); + const badge = ( + {place} + ); + if (ids.length === 1) { + return ( +
+ {playerRow(ids[0],
⠿
, badge)} + {commentInput(ids[0])} +
+ ); + } + return ( +
+
⠿
+
+ {ids.map((id) => ( +
+ {playerRow( + id, + undefined, + , + )} + {commentInput(id)} +
+ ))} +
+
+ ничья + {badge} +
+
+ ); + })} +
+
+ +
+
Выбыли (перетащите сюда) — считаются последним местом
+ {eliminated.map((id) => ( +
+ {playerRow( + id, +
⠿
, + ✕, + )} + {commentInput(id)} +
+ ))} +
+
+ ); +} diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index f452107..c64fe1a 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -4,6 +4,8 @@ import { useNavigate, useParams } from "react-router-dom"; import { ApiError } from "../api/client"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { MatchMedia } from "../components/MatchMedia"; +import { PickerSelect } from "../components/PickerSelect"; +import { PlaceEditor } from "../components/PlaceEditor"; import { PlayerLink } from "../components/PlayerLink"; import { Spinner } from "../components/Spinner"; import { formatDate, formatDuration, formatTime } from "../domain/format"; @@ -17,14 +19,7 @@ import { useUploadMatchAttachment, } from "../hooks/matches"; -interface FinishRow { - user_id: number; - nickname: string; - faction_name: string; - place: number; - eliminated: boolean; - comment: string; -} +const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label })); export function MatchDetailPage() { const { matchId } = useParams(); @@ -37,7 +32,10 @@ export function MatchDetailPage() { const toast = useToast(); const navigate = useNavigate(); - const [rows, setRows] = useState(null); + // Раскладка мест: упорядоченные блоки user_id (длина >1 = ничья) + выбывшие. + const [blocks, setBlocks] = useState(null); + const [elim, setElim] = useState([]); + const [comments, setComments] = useState | null>(null); const [winReason, setWinReason] = useState("objectives"); const [overall, setOverall] = useState(""); const [error, setError] = useState(null); @@ -49,20 +47,11 @@ export function MatchDetailPage() { const canModify = !!match.can_modify; // авторитетный флаг с бэкенда (создатель/owner/admin) const inProgress = match.status === "in_progress"; - // Ленивая инициализация строк завершения из участников. - const finishRows: FinishRow[] = - rows ?? - match.participants.map((p, i) => ({ - user_id: p.user_id, - nickname: p.nickname, - faction_name: p.faction_name, - place: i + 1, - eliminated: false, - comment: p.comment ?? "", - })); - - const upd = (i: number, patch: Partial) => - setRows(finishRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + // Ленивая инициализация раскладки из участников: каждый — отдельным блоком. + const finishBlocks: number[][] = blocks ?? match.participants.map((p) => [p.user_id]); + const finishComments: Record = + comments ?? + Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])); // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; @@ -70,16 +59,31 @@ export function MatchDetailPage() { const submitFinish = async () => { if (!id || !match) return; setError(null); + const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null; + let place = 1; + const survivors = finishBlocks.flatMap((ids) => { + const rows = ids.map((uid) => ({ + user_id: uid, + place, + eliminated: false, + comment: commentOf(uid), + })); + place += ids.length; // competition ranking: ничья съедает следующие места + return rows; + }); try { await finish.mutateAsync({ matchId: id, body: { - participants: finishRows.map((r) => ({ - user_id: r.user_id, - place: r.eliminated ? null : r.place, - eliminated: r.eliminated, - comment: r.comment.trim() || null, - })), + participants: [ + ...survivors, + ...elim.map((uid) => ({ + user_id: uid, + place: null, + eliminated: true, + comment: commentOf(uid), + })), + ], win_reason: winReason, overall_comment: overall.trim() || null, expected_version: match.version, @@ -192,58 +196,45 @@ export function MatchDetailPage() {
{canModify && ( -
-

Завершение партии

- {finishRows.map((r, i) => ( -
-
- {r.nickname} - {r.faction_name} -
-
-
- - -
-
- upd(i, { comment: e.target.value })} - /> -
- ))} - -
- - + <> +
+

Места

+

+ Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого + игрока, чтобы разделить место (ничья). +

+ { + setBlocks(b); + setElim(e); + }} + onComment={(uid, text) => + setComments({ ...finishComments, [uid]: text }) + } + />
-
- +
+

Причина победы

+ o.id === winReason) ?? null} + options={REASON_OPTIONS} + placeholder="— причина —" + renderOption={(o) => o.label} + onPick={(o) => setWinReason(o.id)} + /> +
+ +
+

О партии