v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user