v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика

This commit is contained in:
2026-06-16 17:41:06 +03:00
parent 6ab74f01aa
commit 56b5d09a4d
123 changed files with 13572 additions and 21 deletions
+212
View File
@@ -0,0 +1,212 @@
import { useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { ApiError } from "../api/client";
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, useFinishMatch, useMatch } from "../hooks/matches";
interface FinishRow {
user_id: number;
nickname: string;
faction_name: string;
place: number;
comment: string;
}
export function MatchDetailPage() {
const { matchId } = useParams();
const id = matchId ? Number(matchId) : null;
const { data: match, isLoading } = useMatch(id);
const finish = useFinishMatch();
const del = useDeleteMatch();
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);
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,
comment: p.comment ?? "",
}));
const upd = (i: number, patch: Partial<FinishRow>) =>
setRows(finishRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
const submitFinish = async () => {
if (!id) return;
setError(null);
try {
await finish.mutateAsync({
matchId: id,
body: {
participants: finishRows.map((r) => ({
user_id: r.user_id,
place: r.place,
comment: r.comment.trim() || null,
})),
win_reason: winReason,
overall_comment: overall.trim() || null,
},
});
toast.show("Партия завершена");
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось завершить партию");
}
};
const remove = async () => {
if (!id) return;
await del.mutateAsync(id).catch(() => {});
toast.show(inProgress ? "Партия отменена" : "Партия удалена");
navigate(-1);
};
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 ? " first" : "")}>{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>
)}
{/* Незавершённая партия — состав + форма завершения */}
{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.place} onChange={(e) => upd(i, { place: Number(e.target.value) })}>
{Array.from({ length: finishRows.length }, (_, k) => k + 1).map((n) => (
<option key={n} value={n}>
{n}
</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} value={overall} onChange={(e) => setOverall(e.target.value)} />
</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={remove}>
{inProgress ? "Отменить партию" : "Удалить партию"}
</button>
)}
</div>
);
}