UI: места при завершении партии — перетаскиванием блоков (макет A2)

Вместо select-ов мест — PlaceEditor: вертикальный список блоков игроков
(верхний — 1-е место), бросок на другого игрока сливает в ничью с общей
рамкой и местом (✂ выносит обратно), выбывшие — перетаскиванием в
пунктирную зону (сервер сам ставит им последнее место). Комментарий об
игроке — строка в каждом блоке. Причина победы — PickerSelect (дженерик
расширен на строковые id), общий комментарий и медиа — карточкой,
кнопка завершения отдельно. Места считаются по блокам и всегда дают
валидный competition ranking (1,2,2,4).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-18 17:07:26 +03:00
co-authored by Claude Fable 5
parent 376605f554
commit 3023e9f515
4 changed files with 446 additions and 78 deletions
+1 -1
View File
@@ -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,
+289
View File
@@ -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>
);
}
+68 -77
View File
@@ -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) });
}}
>
{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 })}
/>
</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>
<p className="muted small">
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
игрока, чтобы разделить место (ничья).
</p>
<PlaceEditor
players={match.participants}
blocks={finishBlocks}
eliminated={elim}
comments={finishComments}
onChange={(b, e) => {
setBlocks(b);
setElim(e);
}}
onComment={(uid, text) =>
setComments({ ...finishComments, [uid]: text })
}
/>
</div>
<div className="field">
<label className="label">Комментарий о партии</label>
<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="card">
<h3>О партии</h3>
<textarea
rows={2}
style={{ resize: "none" }}
placeholder="Комментарий о партии"
value={overall}
onChange={(e) => setOverall(e.target.value)}
/>
@@ -267,7 +258,7 @@ export function MatchDetailPage() {
>
Завершить партию
</button>
</div>
</>
)}
</>
)}
+88
View File
@@ -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;