parseCount и граница 99 — в domain/matchCounts.ts вместо двух копий в PlaceEditor и AdminMatchEdit. В MatchDetailPage места по раскладке считает placeRows, из него строятся и предупреждения, и строки для API. Поведение не меняется. #89 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
337 lines
12 KiB
TypeScript
337 lines
12 KiB
TypeScript
import { Scissors } from "lucide-react";
|
|
import { useRef } from "react";
|
|
|
|
import { Avatar } from "./Avatar";
|
|
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
|
|
|
export interface PlacePlayer {
|
|
user_id: number;
|
|
nickname: string;
|
|
faction_name: string;
|
|
avatar_url?: string | null;
|
|
}
|
|
|
|
export type CountField = "objectives" | "worlds";
|
|
/** Цели и миры на конец партии по user_id; отсутствие ключа или null — не указано. */
|
|
export type Counts = Record<number, { objectives: number | null; worlds: number | 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-е место. Бросок между блоками — порядок, на середину чужого блока —
|
|
* слияние в ничью (общая рамка и место, ✂ выносит обратно), в пунктирную зону —
|
|
* выбывший. В каждом блоке — строка комментария об игроке и (если переданы counts)
|
|
* цели и миры на конец партии; у выбывшего миров нет — поле заблокировано нулём.
|
|
*
|
|
* Контролируемый: blocks (упорядоченные группы user_id, длина >1 = ничья) и
|
|
* eliminated живут у родителя. Во время drag DOM двигается напрямую (transform,
|
|
* классы-подсветки) — setState только на отпускании, перерендер сбрасывает transform.
|
|
*/
|
|
export function PlaceEditor({
|
|
players,
|
|
blocks,
|
|
eliminated,
|
|
comments,
|
|
onChange,
|
|
onComment,
|
|
counts,
|
|
onCount,
|
|
}: {
|
|
players: PlacePlayer[];
|
|
blocks: number[][];
|
|
eliminated: number[];
|
|
comments: Record<number, string>;
|
|
onChange: (blocks: number[][], eliminated: number[]) => void;
|
|
onComment: (userId: number, text: string) => void;
|
|
counts?: Counts;
|
|
onCount?: (userId: number, field: CountField, value: number | null) => void;
|
|
}) {
|
|
const listRef = useRef<HTMLDivElement>(null);
|
|
const elimRef = useRef<HTMLDivElement>(null);
|
|
const indRef = useRef<HTMLDivElement>(null);
|
|
const dragRef = useRef<DragState | null>(null);
|
|
const hintRef = useRef<string | 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 = () => {
|
|
hintRef.current = null;
|
|
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) => {
|
|
// pointermove срабатывает десятки раз в секунду, а цель меняется намного реже:
|
|
// без этой проверки на каждое движение шёл querySelectorAll и перестановка классов.
|
|
const key = `${t.type}:${t.type === "elim" ? "" : t.idx}`;
|
|
if (key === hintRef.current) return;
|
|
clearHints();
|
|
hintRef.current = key;
|
|
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,
|
|
// Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого
|
|
// блок остаётся с классом dragging и сдвигом: они выставлены в обход React,
|
|
// и перерендер их не снимет.
|
|
onPointerCancel: 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 countInput = (userId: number, field: CountField, label: string, locked = false) => (
|
|
<label className="rank-count">
|
|
<span>{label}</span>
|
|
<input
|
|
type="number"
|
|
inputMode="numeric"
|
|
min={0}
|
|
max={MAX_COUNT}
|
|
placeholder="—"
|
|
disabled={locked}
|
|
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
|
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
|
/>
|
|
</label>
|
|
);
|
|
|
|
const commentInput = (userId: number) => {
|
|
const comment = (
|
|
<input
|
|
className="rank-comment"
|
|
placeholder="Комментарий об игроке"
|
|
value={comments[userId] ?? ""}
|
|
onChange={(e) => onComment(userId, e.target.value)}
|
|
/>
|
|
);
|
|
if (!counts) return comment;
|
|
return (
|
|
<div className="rank-details">
|
|
{comment}
|
|
{countInput(userId, "objectives", "цели")}
|
|
{countInput(userId, "worlds", "миры", eliminated.includes(userId))}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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>
|
|
);
|
|
}
|