Files
ForbiddenStarsApp/frontend/src/pages/MatchDetailPage.tsx
T
NotBigGhostandClaude Opus 5 ea743ccac0 Партия: убрать дублирующий тост о чужом завершении
Тот, кто был в форме завершения, но не нажимал «Завершить», получал сразу два
сообщения об одном событии: штатное уведомление сайта сверху (NotificationToaster
по match_finished) и технический тост снизу. Нижний убран — сверху сказано то же
самое, и там на него можно нажать, чтобы перейти к партии.

Вместе с тостом ушли refs, которые обслуживали только его.

#38

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
2026-09-09 20:20:33 +03:00

568 lines
23 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 { 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>
);
}