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
+183
View File
@@ -0,0 +1,183 @@
import { Dices, Trash2 } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Spinner } from "../components/Spinner";
import { MAX_MATCH_PLAYERS } from "../domain/constants";
import { useToast } from "../context/ToastContext";
import type { MemberRead } from "../domain/types";
import { useMe } from "../hooks/auth";
import { useGroupFactions } from "../hooks/reference";
import { useGroupMembers } from "../hooks/groups";
import { useRandomizeFaction, useStartMatch } from "../hooks/matches";
interface Draft {
user_id: number | "";
faction_id: number | "";
was_random: boolean;
}
function emptyDraft(): Draft {
return { user_id: "", faction_id: "", was_random: false };
}
export function CreateMatchPage() {
const { data: me } = useMe();
const groupId = me?.active_group_id ?? null;
const { data: members, isLoading: lm } = useGroupMembers(groupId);
const { data: factions, isLoading: lf } = useGroupFactions(groupId);
const startMatch = useStartMatch();
const randomize = useRandomizeFaction();
const toast = useToast();
const navigate = useNavigate();
const [rows, setRows] = useState<Draft[]>([emptyDraft(), emptyDraft()]);
const [error, setError] = useState<string | null>(null);
if (lm || lf) return <Spinner />;
if (!groupId) return <div className="muted">Нет активной группы.</div>;
const takenFactionIds = (exceptIdx: number) =>
rows
.filter((_, i) => i !== exceptIdx)
.map((r) => r.faction_id)
.filter((x): x is number => typeof x === "number");
const update = (idx: number, patch: Partial<Draft>) =>
setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r)));
const addRow = () => setRows((rs) => [...rs, emptyDraft()]);
const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx));
const randomizeRow = async (idx: number) => {
try {
const faction = await randomize.mutateAsync({
group_id: groupId,
exclude_faction_ids: takenFactionIds(idx),
});
update(idx, { faction_id: faction.id, was_random: true });
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка рандома");
}
};
const randomizeAll = () => {
const pool = [...(factions ?? [])];
for (let i = pool.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[pool[i], pool[j]] = [pool[j], pool[i]];
}
if (pool.length < rows.length) toast.show("Фракций меньше, чем игроков — возможны повторы.");
setRows((rs) =>
rs.map((r, i) => ({ ...r, faction_id: pool[i % pool.length]?.id ?? "", was_random: true })),
);
};
const submit = async () => {
setError(null);
const filled = rows.filter((r) => r.user_id !== "" && r.faction_id !== "");
if (filled.length < 2) {
setError("Нужно минимум 2 участника с выбранным игроком и фракцией.");
return;
}
if (filled.length > MAX_MATCH_PLAYERS) {
setError(`Максимум ${MAX_MATCH_PLAYERS} игроков в партии.`);
return;
}
try {
const match = await startMatch.mutateAsync({
group_id: groupId,
participants: filled.map((r) => ({
user_id: r.user_id as number,
faction_id: r.faction_id as number,
was_random: r.was_random,
})),
});
toast.show("Партия начата");
navigate(`/match/${match.id}`, { replace: true });
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось начать партию");
}
};
const memberOptions = (members ?? []) as MemberRead[];
return (
<div>
<div className="card">
<h3>Этап 1 — старт партии</h3>
<p className="muted small">Выберите игроков и фракции. Места и итоги укажете при завершении.</p>
<button className="btn" onClick={randomizeAll}>
<Dices size={18} /> Рандом всем
</button>
</div>
{rows.map((row, idx) => (
<div className="participant-row" key={idx}>
<div className="row-between">
<b>Игрок {idx + 1}</b>
{rows.length > 2 && (
<button className="btn btn-ghost btn-danger small" onClick={() => removeRow(idx)}>
<Trash2 size={16} />
</button>
)}
</div>
<select
value={row.user_id}
onChange={(e) => update(idx, { user_id: e.target.value ? Number(e.target.value) : "" })}
>
<option value="">— игрок —</option>
{memberOptions.map((m) => (
<option key={m.user_id} value={m.user_id}>
{m.nickname}
</option>
))}
</select>
<div className="row">
<select
value={row.faction_id}
onChange={(e) =>
update(idx, {
faction_id: e.target.value ? Number(e.target.value) : "",
was_random: false,
})
}
style={{ flex: 1 }}
>
<option value="">— фракция —</option>
{(factions ?? []).map((f) => (
<option key={f.id} value={f.id}>
{f.name_ru}
</option>
))}
</select>
<button className="btn" onClick={() => randomizeRow(idx)} title="Случайная фракция">
<Dices size={18} />
</button>
</div>
{row.was_random && row.faction_id !== "" && <span className="badge">🎲 случайная</span>}
</div>
))}
{rows.length < Math.min(MAX_MATCH_PLAYERS, memberOptions.length) && (
<button className="btn btn-ghost" onClick={addRow}>
+ Добавить игрока
</button>
)}
{error && <p className="error-text">{error}</p>}
<button
className="btn btn-primary"
style={{ width: "100%", marginTop: 12 }}
onClick={submit}
disabled={startMatch.isPending}
>
Начать партию
</button>
</div>
);
}