305 lines
11 KiB
TypeScript
305 lines
11 KiB
TypeScript
import { useState } from "react";
|
||
import { useNavigate, useParams } from "react-router-dom";
|
||
|
||
import { ApiError } from "../api/client";
|
||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||
import { MatchMedia } from "../components/MatchMedia";
|
||
import { Spinner } from "../components/Spinner";
|
||
import { formatDuration, formatTime } from "../domain/format";
|
||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||
import { useToast } from "../context/ToastContext";
|
||
import {
|
||
useDeleteMatch,
|
||
useDeleteMatchAttachment,
|
||
useFinishMatch,
|
||
useMatch,
|
||
useUploadMatchAttachment,
|
||
} from "../hooks/matches";
|
||
|
||
interface FinishRow {
|
||
user_id: number;
|
||
nickname: string;
|
||
faction_name: string;
|
||
place: number;
|
||
eliminated: boolean;
|
||
comment: string;
|
||
}
|
||
|
||
export function MatchDetailPage() {
|
||
const { matchId } = useParams();
|
||
const id = matchId ? Number(matchId) : null;
|
||
const { data: match, isLoading, refetch } = useMatch(id);
|
||
const finish = useFinishMatch();
|
||
const del = useDeleteMatch();
|
||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||
const toast = useToast();
|
||
const navigate = useNavigate();
|
||
|
||
const [rows, setRows] = useState<FinishRow[] | null>(null);
|
||
const [winReason, setWinReason] = useState<WinReason>("objectives");
|
||
const [overall, setOverall] = useState("");
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||
|
||
if (isLoading) return <Spinner />;
|
||
if (!match) return <div className="muted">Партия не найдена.</div>;
|
||
|
||
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 isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE";
|
||
|
||
const submitFinish = async () => {
|
||
if (!id || !match) return;
|
||
setError(null);
|
||
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,
|
||
})),
|
||
win_reason: winReason,
|
||
overall_comment: overall.trim() || null,
|
||
expected_version: match.version,
|
||
},
|
||
});
|
||
toast.show("Партия завершена");
|
||
} catch (e) {
|
||
if (isStale(e)) {
|
||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||
refetch();
|
||
return;
|
||
}
|
||
setError(e instanceof ApiError ? e.message : "Не удалось завершить партию");
|
||
}
|
||
};
|
||
|
||
const remove = async () => {
|
||
if (!id || !match) return;
|
||
try {
|
||
await del.mutateAsync({ matchId: id, expectedVersion: match.version });
|
||
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
|
||
navigate(-1);
|
||
} catch (e) {
|
||
if (isStale(e)) {
|
||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||
refetch();
|
||
return;
|
||
}
|
||
toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить");
|
||
}
|
||
};
|
||
|
||
const sorted = [...match.participants].sort(
|
||
(a, b) => (a.place ?? 99) - (b.place ?? 99),
|
||
);
|
||
|
||
return (
|
||
<div>
|
||
<div className="card">
|
||
<div className="row-between">
|
||
<h2 style={{ margin: 0 }}>{match.played_at}</h2>
|
||
{inProgress ? (
|
||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
||
) : (
|
||
<span className="muted small">{formatDuration(match.duration_minutes)}</span>
|
||
)}
|
||
</div>
|
||
<div className="muted small">
|
||
Начало: {formatTime(match.started_at)}
|
||
{match.finished_at && ` · конец: ${formatTime(match.finished_at)}`}
|
||
</div>
|
||
{!inProgress && (
|
||
<div className="small" style={{ marginTop: 4 }}>
|
||
Победа: <b>{winReasonLabel(match.win_reason)}</b>
|
||
</div>
|
||
)}
|
||
{match.overall_comment && <p className="small" style={{ marginTop: 6 }}>{match.overall_comment}</p>}
|
||
</div>
|
||
|
||
{/* Завершённая партия — результаты */}
|
||
{!inProgress && (
|
||
<div className="card">
|
||
<h3>Результаты</h3>
|
||
<div className="stack">
|
||
{sorted.map((p) => (
|
||
<div key={p.user_id}>
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<span
|
||
className={
|
||
"place-badge" +
|
||
(p.place === 1 && !p.eliminated ? " first" : "") +
|
||
(p.eliminated ? " eliminated" : "")
|
||
}
|
||
>
|
||
{p.eliminated ? "✕" : (p.place ?? "—")}
|
||
</span>
|
||
<b>{p.nickname}</b>
|
||
<span className="muted small">· {p.faction_name}</span>
|
||
{p.was_random && <span className="badge">🎲</span>}
|
||
</div>
|
||
{p.comment && <div className="small muted" style={{ marginLeft: 32 }}>{p.comment}</div>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Медиа (read-only): для завершённых и для зрителей без прав на правку */}
|
||
{!(inProgress && canModify) && match.attachments.length > 0 && (
|
||
<div className="card">
|
||
<h3>Медиа</h3>
|
||
<MatchMedia attachments={match.attachments} editable={false} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Незавершённая партия — состав + форма завершения */}
|
||
{inProgress && (
|
||
<>
|
||
<div className="card">
|
||
<h3>Состав</h3>
|
||
<div className="stack">
|
||
{match.participants.map((p) => (
|
||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||
<b>{p.nickname}</b>
|
||
<span className="muted small">· {p.faction_name}</span>
|
||
{p.was_random && <span className="badge">🎲</span>}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</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>
|
||
|
||
<div className="field">
|
||
<label className="label">Комментарий о партии</label>
|
||
<textarea
|
||
rows={2}
|
||
style={{ resize: "none" }}
|
||
value={overall}
|
||
onChange={(e) => setOverall(e.target.value)}
|
||
/>
|
||
<div style={{ marginTop: 8 }}>
|
||
<MatchMedia
|
||
attachments={match.attachments}
|
||
editable
|
||
onUpload={(f) => uploadAtt.mutateAsync(f)}
|
||
onDelete={(aid) => deleteAtt.mutateAsync(aid)}
|
||
pending={uploadAtt.isPending || deleteAtt.isPending}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{error && <p className="error-text">{error}</p>}
|
||
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ width: "100%" }}
|
||
onClick={submitFinish}
|
||
disabled={finish.isPending}
|
||
>
|
||
Завершить партию
|
||
</button>
|
||
</div>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{canModify && (
|
||
<button
|
||
className="btn btn-danger"
|
||
style={{ width: "100%" }}
|
||
onClick={() => setConfirmRemove(true)}
|
||
>
|
||
{inProgress ? "Отменить партию" : "Удалить партию"}
|
||
</button>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={confirmRemove}
|
||
danger
|
||
title={inProgress ? "Отменить партию?" : "Удалить партию?"}
|
||
message={
|
||
inProgress
|
||
? "Незавершённая партия будет удалена. Действие необратимо."
|
||
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
|
||
}
|
||
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
|
||
cancelLabel="Назад"
|
||
onCancel={() => setConfirmRemove(false)}
|
||
onConfirm={() => {
|
||
setConfirmRemove(false);
|
||
remove();
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|