Files
ForbiddenStarsApp/frontend/src/pages/MatchDetailPage.tsx
T

305 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}