From 3023e9f515e33b9093889b258d7cd62902c01277 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sat, 18 Jul 2026 17:07:26 +0300 Subject: [PATCH] =?UTF-8?q?UI:=20=D0=BC=D0=B5=D1=81=D1=82=D0=B0=20=D0=BF?= =?UTF-8?q?=D1=80=D0=B8=20=D0=B7=D0=B0=D0=B2=D0=B5=D1=80=D1=88=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D0=B8=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B8=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=B5=D1=80=D0=B5=D1=82=D0=B0=D1=81=D0=BA=D0=B8=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=D0=BC=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE?= =?UTF-8?q?=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)} + /> +
+ +
+

О партии