v1.3 — Глобальное изменение интерфейса завершения партии. Для выбора места теперь используется интуитивное перетаскивание тайлов
This commit is contained in:
+3
-3
@@ -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
|
||||
|
||||
@@ -344,7 +344,8 @@ class Notification(SQLModel, table=True):
|
||||
"""Персистентное уведомление игроку (приглашение, старт/финиш партии, титул и т.д.).
|
||||
|
||||
Текст (`title`/`body`, RU) и ссылку (`link` — относительный SPA-путь) рендерит сервер —
|
||||
фронт лишь отображает. Хранятся 72 часа; протухшие чистятся фоном и при чтении списка."""
|
||||
фронт лишь отображает. Непрочитанные хранятся до прочтения (кап на пользователя),
|
||||
прочитанные чистятся спустя 72 часа — фоном и при чтении списка."""
|
||||
|
||||
__tablename__ = "notifications"
|
||||
__table_args__ = (
|
||||
|
||||
@@ -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]
|
||||
]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Уведомления игрока: список (pull) и отметка прочитанными.
|
||||
|
||||
Появление в реальном времени обеспечивает SSE-сигнал `{type:"notifications"}` — по нему клиент
|
||||
перезапрашивает этот список. Хранение — 72 часа; протухшие чистятся при чтении (и фоном)."""
|
||||
перезапрашивает этот список. Непрочитанные хранятся до прочтения; прочитанные чистятся
|
||||
спустя 72 часа (при чтении и фоном)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
@@ -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):
|
||||
@@ -330,7 +331,9 @@ class ProfileStats(BaseModel):
|
||||
factions: list[FactionStat] = []
|
||||
best_faction: FactionStat | None = None
|
||||
worst_faction: FactionStat | None = None
|
||||
# Любимая — по партиям с ручным выбором фракции; основная — по всем (включая рандом).
|
||||
most_played_faction: FactionStat | None = None
|
||||
main_faction: FactionStat | None = None
|
||||
recent_form: list[RecentFormItem] = []
|
||||
# Порог «Новичков» (MIN_GAMES) — чтобы UI единообразно подсвечивал
|
||||
# неподтверждённый рейтинг, не дублируя константу на фронте.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -34,6 +34,7 @@ scored AS (
|
||||
m.group_id AS group_id,
|
||||
m.played_at AS played_at,
|
||||
mp.place AS place,
|
||||
mp.was_random AS was_random,
|
||||
m.player_count AS player_count,
|
||||
({MATCH_POINTS_SQL}) AS points,
|
||||
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
|
||||
@@ -140,8 +141,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
|
||||
SUM(s.is_win) AS wins,
|
||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
||||
AVG(s.place) AS avg_place,
|
||||
AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
|
||||
AVG(s.points) * 100 AS score, -- фракции: чистое среднее (служебная метрика
|
||||
-- «лучшая/худшая», сглаживание задавило бы её к 50)
|
||||
SUM(CASE WHEN s.was_random THEN 0 ELSE 1 END) AS chosen_games
|
||||
FROM scored s
|
||||
JOIN factions f ON f.id = s.faction_id
|
||||
JOIN expansions e ON e.id = f.expansion_id
|
||||
@@ -166,6 +168,9 @@ def _faction_breakdown(session: Session, user_id: int, group_id: int | None) ->
|
||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
||||
"avg_place": _round(r["avg_place"], 2),
|
||||
"score": _round(r["score"], 1),
|
||||
# Служебное (в API-схему не входит): партии, где фракция выбрана
|
||||
# вручную, — для «любимой фракции» рандомные раздачи не в счёт.
|
||||
"chosen_games": int(r["chosen_games"] or 0),
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -197,7 +202,12 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
|
||||
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
||||
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||
most_played = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
# Любимая — по числу партий, где игрок выбрал фракцию САМ (was_random=false):
|
||||
# рандомная раздача о предпочтениях не говорит. Нет ручных выборов — нет любимой.
|
||||
chosen = [f for f in factions if f["chosen_games"] > 0]
|
||||
most_played = max(chosen, key=lambda f: f["chosen_games"]) if chosen else None
|
||||
# Основная — просто самая игранная, включая рандомные раздачи.
|
||||
main = max(factions, key=lambda f: f["games"]) if factions else None
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"overall": overall,
|
||||
@@ -205,6 +215,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
|
||||
"best_faction": best,
|
||||
"worst_faction": worst,
|
||||
"most_played_faction": most_played,
|
||||
"main_faction": main,
|
||||
"recent_form": _recent_form(session, user_id, group_id),
|
||||
"min_games": MIN_GAMES,
|
||||
}
|
||||
|
||||
@@ -140,8 +140,15 @@ def finish_match(
|
||||
def create_finished_match(
|
||||
client: TestClient, group_id: int, players: list[dict], win_reason: str = "objectives"
|
||||
) -> dict:
|
||||
"""players: [{user_id, faction_id, place, comment?}] → завершённая партия (для статистики)."""
|
||||
roster = [{"user_id": p["user_id"], "faction_id": p["faction_id"]} for p in players]
|
||||
"""players: [{user_id, faction_id, place, comment?, was_random?}] → завершённая партия."""
|
||||
roster = [
|
||||
{
|
||||
"user_id": p["user_id"],
|
||||
"faction_id": p["faction_id"],
|
||||
"was_random": p.get("was_random", False),
|
||||
}
|
||||
for p in players
|
||||
]
|
||||
started = start_match(client, group_id, roster)
|
||||
assert started.status_code == 200, started.text
|
||||
results = [
|
||||
|
||||
@@ -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, "Боб")
|
||||
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(
|
||||
old = datetime.now(timezone.utc).replace(tzinfo=None) - timedelta(
|
||||
hours=notification_service.RETENTION_HOURS + 1
|
||||
)
|
||||
s.add(n)
|
||||
with Session(engine) as s:
|
||||
# Старое непрочитанное, старое давно прочитанное и свежепрочитанное.
|
||||
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.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):
|
||||
|
||||
@@ -124,3 +124,64 @@ def test_leaderboard_includes_avatar_url(client: TestClient, engine, monkeypatch
|
||||
headers=csrf_headers(client),
|
||||
)
|
||||
assert "/avatar?v=" in entry_for(me["id"])["avatar_url"] # после загрузки — ссылка
|
||||
|
||||
|
||||
def test_most_played_faction_ignores_random(client: TestClient, engine):
|
||||
"""Любимая фракция — только по сознательному выбору: рандомные раздачи не в счёт."""
|
||||
me = login(client, "Фракционер")
|
||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
p2 = add_group_member(engine, gid, "Партнёр")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
|
||||
# Дважды играл фракцией A по рандому, один раз фракцией B — по своему выбору.
|
||||
for _ in range(2):
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[2], "place": 1},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2},
|
||||
],
|
||||
)
|
||||
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
assert stats["most_played_faction"]["faction_id"] == fids[2] # B, хоть партий с A больше
|
||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная — по всем партиям
|
||||
|
||||
# У соперника все фракции выбраны вручную — любимая считается как раньше.
|
||||
login(client, "Партнёр")
|
||||
stats2 = client.get("/api/users/me/stats").json()
|
||||
assert stats2["most_played_faction"]["faction_id"] == fids[1]
|
||||
|
||||
|
||||
def test_most_played_faction_absent_when_all_random(client: TestClient, engine):
|
||||
"""Если игрок ни разу не выбирал фракцию сам — любимой нет вовсе."""
|
||||
me = login(client, "Рандомщик")
|
||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||
gid = client.post(
|
||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
||||
).json()["id"]
|
||||
p2 = add_group_member(engine, gid, "Оппонент")
|
||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||
create_finished_match(
|
||||
client,
|
||||
gid,
|
||||
[
|
||||
{"user_id": me["id"], "faction_id": fids[0], "place": 1, "was_random": True},
|
||||
{"user_id": p2, "faction_id": fids[1], "place": 2, "was_random": True},
|
||||
],
|
||||
)
|
||||
stats = client.get("/api/users/me/stats").json()
|
||||
assert stats["most_played_faction"] is None
|
||||
assert stats["main_faction"]["faction_id"] == fids[0] # основная есть и у рандомщика
|
||||
|
||||
Vendored
+3
@@ -1682,6 +1682,8 @@ export interface components {
|
||||
was_random: boolean;
|
||||
/** Comment */
|
||||
comment?: string | null;
|
||||
/** Avatar Url */
|
||||
avatar_url?: string | null;
|
||||
};
|
||||
/** MatchRead */
|
||||
MatchRead: {
|
||||
@@ -1880,6 +1882,7 @@ export interface components {
|
||||
best_faction?: components["schemas"]["FactionStat"] | null;
|
||||
worst_faction?: components["schemas"]["FactionStat"] | null;
|
||||
most_played_faction?: components["schemas"]["FactionStat"] | null;
|
||||
main_faction?: components["schemas"]["FactionStat"] | null;
|
||||
/**
|
||||
* Recent Form
|
||||
* @default []
|
||||
|
||||
@@ -8,7 +8,7 @@ import { type ReactNode, useState } from "react";
|
||||
* selected, найденный в ПОЛНОМ списке: выбранное значение может отсутствовать
|
||||
* в options (например, фракция занята другой строкой после «Рандом всем»).
|
||||
*/
|
||||
export function PickerSelect<T extends { id: number }>({
|
||||
export function PickerSelect<T extends { id: number | string }>({
|
||||
selected,
|
||||
options,
|
||||
placeholder,
|
||||
|
||||
@@ -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<number, string>;
|
||||
onChange: (blocks: number[][], eliminated: number[]) => void;
|
||||
onComment: (userId: number, text: string) => void;
|
||||
}) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const elimRef = useRef<HTMLDivElement>(null);
|
||||
const indRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<DragState | null>(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<HTMLDivElement>,
|
||||
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<HTMLElement>(":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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => {
|
||||
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<HTMLDivElement>) => 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 (
|
||||
<div className="rank-row">
|
||||
{lead}
|
||||
<Avatar url={p.avatar_url} nickname={p.nickname} size={32} />
|
||||
<div className="rank-info">
|
||||
<div className="rank-nick">{p.nickname}</div>
|
||||
<div className="rank-faction">{p.faction_name}</div>
|
||||
</div>
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const commentInput = (userId: number) => (
|
||||
<input
|
||||
className="rank-comment"
|
||||
placeholder="Комментарий об игроке"
|
||||
value={comments[userId] ?? ""}
|
||||
onChange={(e) => onComment(userId, e.target.value)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="rank-list" ref={listRef}>
|
||||
{blocks.map((ids, i) => {
|
||||
const place = placeOf(i);
|
||||
const badge = (
|
||||
<span className={"place-badge" + (place === 1 ? " first" : "")}>{place}</span>
|
||||
);
|
||||
if (ids.length === 1) {
|
||||
return (
|
||||
<div key={ids[0]} className="rank-block" data-idx={i}>
|
||||
{playerRow(ids[0], <div {...gripProps({ blockIdx: i })}>⠿</div>, badge)}
|
||||
{commentInput(ids[0])}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={ids[0]} className="rank-tie" data-idx={i}>
|
||||
<div {...gripProps({ blockIdx: i })}>⠿</div>
|
||||
<div className="rank-tie-col">
|
||||
{ids.map((id) => (
|
||||
<div key={id} className="rank-block">
|
||||
{playerRow(
|
||||
id,
|
||||
undefined,
|
||||
<button
|
||||
type="button"
|
||||
className="rank-unlink"
|
||||
title="Убрать из ничьей"
|
||||
onClick={() => unlink(id)}
|
||||
>
|
||||
<Scissors size={14} />
|
||||
</button>,
|
||||
)}
|
||||
{commentInput(id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="rank-tie-badge">
|
||||
<span className="tag">ничья</span>
|
||||
{badge}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="drop-indicator" ref={indRef} />
|
||||
</div>
|
||||
|
||||
<div className="elim-zone" ref={elimRef}>
|
||||
<div className="zone-title">Выбыли (перетащите сюда) — считаются последним местом</div>
|
||||
{eliminated.map((id) => (
|
||||
<div key={id} className="rank-block">
|
||||
{playerRow(
|
||||
id,
|
||||
<div {...gripProps({ elimId: id })}>⠿</div>,
|
||||
<span className="place-badge eliminated">✕</span>,
|
||||
)}
|
||||
{commentInput(id)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,12 @@ export function ProfileStatsCard({
|
||||
<b>{stats.most_played_faction.name_ru}</b>
|
||||
</div>
|
||||
)}
|
||||
{stats.main_faction && (
|
||||
<div className="row-between">
|
||||
<div>Основная фракция</div>
|
||||
<b>{stats.main_faction.name_ru}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<FinishRow[] | null>(null);
|
||||
// Раскладка мест: упорядоченные блоки user_id (длина >1 = ничья) + выбывшие.
|
||||
const [blocks, setBlocks] = useState<number[][] | null>(null);
|
||||
const [elim, setElim] = useState<number[]>([]);
|
||||
const [comments, setComments] = useState<Record<number, string> | null>(null);
|
||||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||||
const [overall, setOverall] = useState("");
|
||||
const [error, setError] = useState<string | null>(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<FinishRow>) =>
|
||||
setRows(finishRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
|
||||
// Ленивая инициализация раскладки из участников: каждый — отдельным блоком.
|
||||
const finishBlocks: number[][] = blocks ?? match.participants.map((p) => [p.user_id]);
|
||||
const finishComments: Record<number, string> =
|
||||
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() {
|
||||
</div>
|
||||
|
||||
{canModify && (
|
||||
<>
|
||||
<div className="card">
|
||||
<h3>Завершение партии</h3>
|
||||
{finishRows.map((r, i) => (
|
||||
<div className="participant-row" key={r.user_id}>
|
||||
<div className="row-between">
|
||||
<b>{r.nickname}</b>
|
||||
<span className="muted small">{r.faction_name}</span>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div style={{ flex: "0 0 130px" }}>
|
||||
<label className="label">Место</label>
|
||||
<select
|
||||
value={r.eliminated ? "elim" : String(r.place)}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
if (v === "elim") upd(i, { eliminated: true });
|
||||
else upd(i, { eliminated: false, place: Number(v) });
|
||||
<h3>Места</h3>
|
||||
<p className="muted small">
|
||||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||||
игрока, чтобы разделить место (ничья).
|
||||
</p>
|
||||
<PlaceEditor
|
||||
players={match.participants}
|
||||
blocks={finishBlocks}
|
||||
eliminated={elim}
|
||||
comments={finishComments}
|
||||
onChange={(b, e) => {
|
||||
setBlocks(b);
|
||||
setElim(e);
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
<option value="elim">✕</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
placeholder="Комментарий об игроке"
|
||||
value={r.comment}
|
||||
onChange={(e) => upd(i, { comment: e.target.value })}
|
||||
onComment={(uid, text) =>
|
||||
setComments({ ...finishComments, [uid]: text })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Причина победы</label>
|
||||
<select value={winReason} onChange={(e) => setWinReason(e.target.value as WinReason)}>
|
||||
{WIN_REASONS.map((w) => (
|
||||
<option key={w.code} value={w.code}>
|
||||
{w.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="card">
|
||||
<h3>Причина победы</h3>
|
||||
<PickerSelect
|
||||
selected={REASON_OPTIONS.find((o) => o.id === winReason) ?? null}
|
||||
options={REASON_OPTIONS}
|
||||
placeholder="— причина —"
|
||||
renderOption={(o) => o.label}
|
||||
onPick={(o) => setWinReason(o.id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="label">Комментарий о партии</label>
|
||||
<div className="card">
|
||||
<h3>О партии</h3>
|
||||
<textarea
|
||||
rows={2}
|
||||
style={{ resize: "none" }}
|
||||
placeholder="Комментарий о партии"
|
||||
value={overall}
|
||||
onChange={(e) => setOverall(e.target.value)}
|
||||
/>
|
||||
@@ -267,13 +258,17 @@ export function MatchDetailPage() {
|
||||
>
|
||||
Завершить партию
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{canModify && (
|
||||
<button className="btn btn-danger btn-block" onClick={() => setConfirmRemove(true)}>
|
||||
<button
|
||||
className="btn btn-danger btn-block"
|
||||
style={{ marginTop: 12 }}
|
||||
onClick={() => setConfirmRemove(true)}
|
||||
>
|
||||
{inProgress ? "Отменить партию" : "Удалить партию"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -346,6 +346,94 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
.picker-placeholder { color: var(--muted); }
|
||||
.picker-chevron { margin-left: auto; flex: none; color: var(--muted); }
|
||||
|
||||
/* ─── Редактор мест перетаскиванием (PlaceEditor, завершение партии) ─── */
|
||||
.rank-list { position: relative; display: flex; flex-direction: column; gap: 8px; }
|
||||
.rank-block {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.rank-row { display: flex; align-items: center; gap: 10px; }
|
||||
.rank-grip {
|
||||
touch-action: none; /* хват: drag вместо скролла страницы */
|
||||
cursor: grab;
|
||||
color: var(--text-muted);
|
||||
font-size: 18px;
|
||||
padding: 6px 4px;
|
||||
flex: none;
|
||||
}
|
||||
.rank-info { flex: 1; min-width: 0; }
|
||||
.rank-nick { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.rank-faction { font-size: 12px; color: var(--text-muted); }
|
||||
.rank-comment {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.rank-unlink {
|
||||
flex: none;
|
||||
width: 30px; height: 30px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
/* Блок ничьей: общая рамка, общий хват и общее место */
|
||||
.rank-tie {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--accent-2);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px;
|
||||
background: rgba(240, 160, 75, 0.05);
|
||||
}
|
||||
.rank-tie-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
|
||||
.rank-tie-badge {
|
||||
flex: none;
|
||||
display: flex; flex-direction: column; align-items: center; gap: 2px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
.rank-tie-badge .tag {
|
||||
font-size: 10px;
|
||||
color: var(--accent-2);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
.dragging {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
opacity: 0.95;
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.merge-target { outline: 2px solid var(--accent-2); outline-offset: 1px; }
|
||||
.drop-indicator {
|
||||
position: absolute;
|
||||
left: 0; right: 0;
|
||||
height: 3px; border-radius: 2px;
|
||||
background: var(--accent-2);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
.elim-zone {
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.elim-zone.highlight { border-color: var(--accent); background: rgba(224, 83, 61, 0.08); }
|
||||
.elim-zone .zone-title { font-size: 12px; color: var(--text-muted); }
|
||||
.elim-zone .rank-block { opacity: 0.6; margin-top: 8px; }
|
||||
|
||||
/* Всплывашки сверху экрана в момент прихода */
|
||||
.toast-top-wrap {
|
||||
position: fixed;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user