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
+170
View File
@@ -0,0 +1,170 @@
import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { Spinner } from "../../components/Spinner";
import { WIN_REASONS, type WinReason } from "../../domain/winReasons";
import { useToast } from "../../context/ToastContext";
import { useAdminFactions, useAdminMatch, useAdminUpdateMatch } from "../../hooks/admin";
interface Row {
user_id: number;
nickname: string;
faction_id: number;
place: number;
was_random: boolean;
comment: string;
}
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
const { data: match, isLoading } = useAdminMatch(matchId);
const { data: factions } = useAdminFactions();
const update = useAdminUpdateMatch();
const toast = useToast();
const [rows, setRows] = useState<Row[]>([]);
const [playedAt, setPlayedAt] = useState("");
const [overall, setOverall] = useState("");
const [winReason, setWinReason] = useState<WinReason>("objectives");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (match) {
setRows(
match.participants.map((p) => ({
user_id: p.user_id,
nickname: p.nickname,
faction_id: p.faction_id,
place: p.place ?? 1,
was_random: p.was_random,
comment: p.comment ?? "",
})),
);
setPlayedAt(match.played_at);
setOverall(match.overall_comment ?? "");
if (match.win_reason) setWinReason(match.win_reason);
}
}, [match]);
const upd = (i: number, patch: Partial<Row>) =>
setRows((rs) => rs.map((r, idx) => (idx === i ? { ...r, ...patch } : r)));
const save = async () => {
setError(null);
try {
await update.mutateAsync({
matchId,
body: {
played_at: playedAt,
overall_comment: overall.trim() || null,
win_reason: winReason,
participants: rows.map((r) => ({
user_id: r.user_id,
faction_id: r.faction_id,
place: r.place,
was_random: r.was_random,
comment: r.comment.trim() || null,
})),
},
});
toast.show("Партия обновлена");
onClose();
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось сохранить");
}
};
return (
<div
style={{
position: "fixed",
inset: 0,
background: "rgba(0,0,0,0.6)",
zIndex: 200,
display: "flex",
alignItems: "flex-end",
justifyContent: "center",
}}
onClick={onClose}
>
<div
className="app-shell"
style={{ maxHeight: "92vh", overflowY: "auto", boxShadow: "none" }}
onClick={(e) => e.stopPropagation()}
>
<div className="app-main" style={{ paddingBottom: 24 }}>
<div className="row-between">
<h2 style={{ margin: 0 }}>Правка партии #{matchId}</h2>
<button className="btn btn-ghost" onClick={onClose}>
✕
</button>
</div>
{isLoading || !match ? (
<Spinner />
) : (
<>
<div className="field">
<label className="label">Дата</label>
<input type="date" value={playedAt} onChange={(e) => setPlayedAt(e.target.value)} />
</div>
{rows.map((r, i) => (
<div className="participant-row" key={r.user_id}>
<b>{r.nickname}</b>
<div className="row">
<select
style={{ flex: 1 }}
value={r.faction_id}
onChange={(e) => upd(i, { faction_id: Number(e.target.value) })}
>
{(factions ?? []).map((f) => (
<option key={f.id} value={f.id}>
{f.name_ru}
</option>
))}
</select>
<div style={{ flex: "0 0 90px" }}>
<input
type="number"
min={1}
value={r.place}
onChange={(e) => upd(i, { place: Math.max(1, Number(e.target.value) || 1) })}
/>
</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={save} disabled={update.isPending}>
Сохранить изменения
</button>
</>
)}
</div>
</div>
</div>
);
}