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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user