Тот, кто был в форме завершения, но не нажимал «Завершить», получал сразу два сообщения об одном событии: штатное уведомление сайта сверху (NotificationToaster по match_finished) и технический тост снизу. Нижний убран — сверху сказано то же самое, и там на него можно нажать, чтобы перейти к партии. Вместе с тостом ушли refs, которые обслуживали только его. #38 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
568 lines
23 KiB
TypeScript
568 lines
23 KiB
TypeScript
import { useEffect, useRef, 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 { 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";
|
||
import type { MatchFinishDraftData } from "../domain/types";
|
||
import { WIN_REASONS, type WinReason, winReasonLabel } from "../domain/winReasons";
|
||
import { useToast } from "../context/ToastContext";
|
||
import { useMe } from "../hooks/auth";
|
||
import {
|
||
useDeleteMatch,
|
||
useDeleteMatchAttachment,
|
||
useFinishMatch,
|
||
useMatch,
|
||
useSaveFinishDraft,
|
||
useUpdateMatch,
|
||
useUploadMatchAttachment,
|
||
} from "../hooks/matches";
|
||
import { useGroupFactions } from "../hooks/reference";
|
||
|
||
const REASON_OPTIONS = WIN_REASONS.map((w) => ({ id: w.code, label: w.label }));
|
||
|
||
export function MatchDetailPage() {
|
||
const { matchId } = useParams();
|
||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||
const parsed = matchId ? Number(matchId) : NaN;
|
||
const id = Number.isInteger(parsed) ? parsed : null;
|
||
const { data: match, isLoading, refetch } = useMatch(id);
|
||
const { data: me } = useMe();
|
||
const { data: groupFactions } = useGroupFactions(match?.group_id ?? null);
|
||
const finish = useFinishMatch();
|
||
const saveDraft = useSaveFinishDraft(id);
|
||
const updateMatch = useUpdateMatch();
|
||
const del = useDeleteMatch();
|
||
const uploadAtt = useUploadMatchAttachment(id ?? 0);
|
||
const deleteAtt = useDeleteMatchAttachment(id ?? 0);
|
||
const toast = useToast();
|
||
const navigate = useNavigate();
|
||
|
||
// Раскладка мест: упорядоченные блоки 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);
|
||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||
// Правка завершённой партии: тот же редактор мест, что и при завершении.
|
||
const [editing, setEditing] = useState(false);
|
||
const [editFactions, setEditFactions] = useState<Record<number, number>>({});
|
||
|
||
// ─── Совместное заполнение формы ───
|
||
// Отложенная запись черновика: он меняется на каждое движение тайла, слать
|
||
// каждое было бы перебором. Актуальное состояние держим в ref — таймер иначе
|
||
// отправил бы то, что было на момент его постановки.
|
||
const draftTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
const draftPayload = useRef<MatchFinishDraftData | null>(null);
|
||
const lastLocalEdit = useRef(0);
|
||
const appliedDraftAt = useRef<string | null>(null);
|
||
|
||
const sendDraft = () => {
|
||
draftTimer.current = null;
|
||
if (draftPayload.current && id) saveDraft.mutate(draftPayload.current);
|
||
};
|
||
|
||
const queueDraft = (payload: MatchFinishDraftData) => {
|
||
draftPayload.current = payload;
|
||
lastLocalEdit.current = Date.now();
|
||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||
draftTimer.current = setTimeout(sendDraft, 600);
|
||
};
|
||
|
||
useEffect(
|
||
() => () => {
|
||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||
},
|
||
[],
|
||
);
|
||
|
||
// Чужой черновик применяем, только если человек сейчас ничего не двигает:
|
||
// иначе правка соседа перетёрла бы тайл прямо под рукой.
|
||
const incoming = match?.finish_draft;
|
||
useEffect(() => {
|
||
if (!incoming || match?.status !== "in_progress") return;
|
||
if (incoming.updated_by === me?.id) return;
|
||
if (incoming.updated_at === appliedDraftAt.current) return;
|
||
if (Date.now() - lastLocalEdit.current < 1500) return;
|
||
setBlocks(incoming.data.blocks);
|
||
setElim(incoming.data.eliminated);
|
||
setComments(incoming.data.comments);
|
||
setWinReason((incoming.data.win_reason ?? "objectives") as WinReason);
|
||
setOverall(incoming.data.overall_comment ?? "");
|
||
appliedDraftAt.current = incoming.updated_at;
|
||
}, [incoming, match?.status, me?.id]);
|
||
|
||
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 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";
|
||
|
||
// Снимок формы для общего черновика: то, что увидят остальные заполняющие.
|
||
const draftOf = (patch: Partial<MatchFinishDraftData> = {}): MatchFinishDraftData => ({
|
||
blocks: finishBlocks,
|
||
eliminated: elim,
|
||
comments: Object.fromEntries(
|
||
Object.entries(finishComments).map(([uid, text]) => [String(uid), text]),
|
||
),
|
||
win_reason: winReason,
|
||
overall_comment: overall.trim() || null,
|
||
...patch,
|
||
});
|
||
|
||
const submitFinish = async () => {
|
||
if (!id || !match) return;
|
||
setError(null);
|
||
// Дожимаем отложенную запись: иначе последняя правка ушла бы в результаты,
|
||
// но не в черновик, и второй участник увидел бы не то, что записалось.
|
||
if (draftTimer.current) {
|
||
clearTimeout(draftTimer.current);
|
||
sendDraft();
|
||
}
|
||
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: [
|
||
...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,
|
||
},
|
||
});
|
||
toast.show("Партия завершена");
|
||
} catch (e) {
|
||
if (isStale(e)) {
|
||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||
refetch();
|
||
return;
|
||
}
|
||
setError(e instanceof ApiError ? e.message : "Не удалось завершить партию");
|
||
}
|
||
};
|
||
|
||
// Раскладка для правки восстанавливается из сохранённых результатов: одинаковое
|
||
// место = ничья, выбывшие уходят отдельным списком.
|
||
const startEditing = () => {
|
||
const byPlace = new Map<number, number[]>();
|
||
for (const p of match.participants.filter((x) => !x.eliminated)) {
|
||
const key = p.place ?? 99;
|
||
byPlace.set(key, [...(byPlace.get(key) ?? []), p.user_id]);
|
||
}
|
||
setBlocks([...byPlace.entries()].sort((a, b) => a[0] - b[0]).map(([, ids]) => ids));
|
||
setElim(match.participants.filter((p) => p.eliminated).map((p) => p.user_id));
|
||
setComments(
|
||
Object.fromEntries(match.participants.map((p) => [p.user_id, p.comment ?? ""])),
|
||
);
|
||
setWinReason((match.win_reason ?? "objectives") as WinReason);
|
||
setOverall(match.overall_comment ?? "");
|
||
setEditFactions(Object.fromEntries(match.participants.map((p) => [p.user_id, p.faction_id])));
|
||
setError(null);
|
||
setEditing(true);
|
||
};
|
||
|
||
const cancelEditing = () => {
|
||
setEditing(false);
|
||
setBlocks(null);
|
||
setComments(null);
|
||
setError(null);
|
||
};
|
||
|
||
const submitEdit = async () => {
|
||
if (!id || !match) return;
|
||
setError(null);
|
||
const wasRandom = Object.fromEntries(
|
||
match.participants.map((p) => [p.user_id, p.was_random]),
|
||
);
|
||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
||
const rowOf = (uid: number, place: number | null, eliminated: boolean) => ({
|
||
user_id: uid,
|
||
faction_id: editFactions[uid],
|
||
place,
|
||
eliminated,
|
||
was_random: wasRandom[uid] ?? false,
|
||
comment: commentOf(uid),
|
||
});
|
||
let place = 1;
|
||
const survivors = finishBlocks.flatMap((ids) => {
|
||
const rows = ids.map((uid) => rowOf(uid, place, false));
|
||
place += ids.length; // competition ranking: ничья съедает следующие места
|
||
return rows;
|
||
});
|
||
try {
|
||
await updateMatch.mutateAsync({
|
||
matchId: id,
|
||
body: {
|
||
participants: [...survivors, ...elim.map((uid) => rowOf(uid, null, true))],
|
||
win_reason: winReason,
|
||
overall_comment: overall.trim() || null,
|
||
expected_version: match.version,
|
||
},
|
||
});
|
||
toast.show("Партия обновлена");
|
||
cancelEditing();
|
||
} catch (e) {
|
||
if (isStale(e)) {
|
||
toast.show("Партия изменилась на другом устройстве — обновлено");
|
||
refetch();
|
||
cancelEditing();
|
||
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): партию часто открывают по прямой ссылке, и «назад»
|
||
// уводит из приложения вместо возврата к группе.
|
||
navigate("/group", { replace: true });
|
||
} 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),
|
||
);
|
||
|
||
// К доступным группе фракциям добавляем те, что уже стоят в партии: дополнение
|
||
// могли отключить после игры, но менять её фракцию это мешать не должно.
|
||
const factionOptions = [
|
||
...(groupFactions ?? []),
|
||
...match.participants
|
||
.filter((p) => !(groupFactions ?? []).some((f) => f.id === p.faction_id))
|
||
.map((p) => ({ id: p.faction_id, code: "", name_ru: p.faction_name, expansion_id: 0 })),
|
||
];
|
||
|
||
return (
|
||
<div>
|
||
<div className="card">
|
||
<div className="row-between">
|
||
<h2 style={{ margin: 0 }}>{formatDate(match.played_at)}</h2>
|
||
{inProgress ? (
|
||
<span className="badge badge-live">идёт</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 && editing && (
|
||
<>
|
||
<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="card">
|
||
<h3>Фракции</h3>
|
||
<div className="stack">
|
||
{match.participants.map((p) => (
|
||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||
<b style={{ flex: "0 0 40%" }}>{p.nickname}</b>
|
||
<div style={{ flex: 1 }}>
|
||
<PickerSelect
|
||
selected={factionOptions.find((f) => f.id === editFactions[p.user_id]) ?? null}
|
||
options={factionOptions.filter(
|
||
(f) =>
|
||
f.id === editFactions[p.user_id] ||
|
||
!Object.values(editFactions).includes(f.id),
|
||
)}
|
||
placeholder="— фракция —"
|
||
renderOption={(f) => f.name_ru}
|
||
onPick={(f) => setEditFactions({ ...editFactions, [p.user_id]: f.id })}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<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)}
|
||
/>
|
||
</div>
|
||
|
||
{error && <p className="error-text">{error}</p>}
|
||
|
||
<div className="row" style={{ gap: 8 }}>
|
||
<button
|
||
className="btn btn-primary"
|
||
style={{ flex: 1 }}
|
||
onClick={submitEdit}
|
||
disabled={updateMatch.isPending}
|
||
>
|
||
Сохранить
|
||
</button>
|
||
<button className="btn btn-ghost" style={{ flex: 1 }} onClick={cancelEditing}>
|
||
Отмена
|
||
</button>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* Завершённая партия — результаты */}
|
||
{!inProgress && !editing && (
|
||
<div className="card">
|
||
<div className="row-between">
|
||
<h3 style={{ margin: 0 }}>Результаты</h3>
|
||
{canModify && (
|
||
<button className="btn btn-ghost small" onClick={startEditing}>
|
||
Редактировать
|
||
</button>
|
||
)}
|
||
</div>
|
||
<div className="stack" style={{ marginTop: 10 }}>
|
||
{sorted.map((p) => (
|
||
<div key={p.user_id}>
|
||
<PlayerLink userId={p.user_id} 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>}
|
||
</PlayerLink>
|
||
{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) => (
|
||
<PlayerLink key={p.user_id} userId={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>}
|
||
</PlayerLink>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{canModify && (
|
||
<>
|
||
<div className="card">
|
||
<h3>Места</h3>
|
||
<p className="muted small">
|
||
Перетаскивайте игроков за ⠿: верхний — 1-е место. Бросьте на другого
|
||
игрока, чтобы разделить место (ничья).
|
||
</p>
|
||
{match.finish_draft && match.finish_draft.updated_by !== me?.id && (
|
||
<p className="small" style={{ color: "var(--accent-2)" }}>
|
||
Результаты заполняет также {match.finish_draft.updated_by_nickname ?? "другой игрок"}
|
||
{" · "}
|
||
{formatTime(match.finish_draft.updated_at)}
|
||
</p>
|
||
)}
|
||
<PlaceEditor
|
||
players={match.participants}
|
||
blocks={finishBlocks}
|
||
eliminated={elim}
|
||
comments={finishComments}
|
||
onChange={(b, e) => {
|
||
setBlocks(b);
|
||
setElim(e);
|
||
queueDraft(draftOf({ blocks: b, eliminated: e }));
|
||
}}
|
||
onComment={(uid, text) => {
|
||
const next = { ...finishComments, [uid]: text };
|
||
setComments(next);
|
||
queueDraft(
|
||
draftOf({
|
||
comments: Object.fromEntries(
|
||
Object.entries(next).map(([k, v]) => [String(k), v]),
|
||
),
|
||
}),
|
||
);
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<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);
|
||
queueDraft(draftOf({ win_reason: o.id }));
|
||
}}
|
||
/>
|
||
</div>
|
||
|
||
<div className="card">
|
||
<h3>О партии</h3>
|
||
<textarea
|
||
rows={2}
|
||
style={{ resize: "none" }}
|
||
placeholder="Комментарий о партии"
|
||
value={overall}
|
||
onChange={(e) => {
|
||
setOverall(e.target.value);
|
||
queueDraft(draftOf({ overall_comment: e.target.value.trim() || null }));
|
||
}}
|
||
/>
|
||
<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 btn-block"
|
||
onClick={submitFinish}
|
||
disabled={finish.isPending}
|
||
>
|
||
Завершить партию
|
||
</button>
|
||
</>
|
||
)}
|
||
</>
|
||
)}
|
||
|
||
{canModify && (
|
||
<button
|
||
className="btn btn-danger btn-block"
|
||
style={{ marginTop: 12 }}
|
||
onClick={() => setConfirmRemove(true)}
|
||
>
|
||
{inProgress ? "Отменить партию" : "Удалить партию"}
|
||
</button>
|
||
)}
|
||
|
||
<ConfirmDialog
|
||
open={confirmRemove}
|
||
danger
|
||
title={inProgress ? "Отменить партию?" : "Удалить партию?"}
|
||
message={
|
||
inProgress
|
||
? "Незавершённая партия будет удалена. Действие необратимо."
|
||
: "Сыгранная партия и её результаты будут удалены. Действие необратимо."
|
||
}
|
||
confirmLabel={inProgress ? "Отменить партию" : "Удалить"}
|
||
onCancel={() => setConfirmRemove(false)}
|
||
onConfirm={() => {
|
||
setConfirmRemove(false);
|
||
remove();
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|