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
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { GroupSwitcher } from "../components/GroupSwitcher";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { useToast } from "../context/ToastContext";
import { useLogout, useMe, useUpdateNickname } from "../hooks/auth";
import { useMyStats } from "../hooks/stats";
export function AccountPage() {
const { data: me, isLoading } = useMe();
const { data: stats } = useMyStats();
const updateNick = useUpdateNickname();
const logout = useLogout();
const toast = useToast();
const navigate = useNavigate();
const [nickname, setNickname] = useState("");
if (isLoading || !me) return <Spinner />;
const save = async () => {
const nick = nickname.trim();
if (!nick) return;
try {
await updateNick.mutateAsync(nick);
setNickname("");
toast.show("Никнейм обновлён");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const doLogout = async () => {
await logout.mutateAsync().catch(() => {});
navigate("/login", { replace: true });
};
return (
<div>
<div className="card">
<h3>Профиль</h3>
<div className="row-between">
<span className="muted">Никнейм</span>
<b>{me.nickname}</b>
</div>
<div className="field" style={{ marginTop: 10 }}>
<label className="label">Сменить никнейм</label>
<div className="row">
<input value={nickname} onChange={(e) => setNickname(e.target.value)} placeholder={me.nickname} />
<button className="btn btn-primary" onClick={save} disabled={updateNick.isPending}>
ОК
</button>
</div>
</div>
</div>
{stats && <ProfileStatsCard stats={stats} />}
<GroupSwitcher />
<button className="btn btn-danger" style={{ width: "100%" }} onClick={doLogout}>
Выйти
</button>
<p className="center" style={{ marginTop: 24 }}>
<button
className="btn btn-ghost small muted"
onClick={() => navigate("/admin/login")}
>
Вход для администратора
</button>
</p>
</div>
);
}
+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>
);
}
+65
View File
@@ -0,0 +1,65 @@
import { useNavigate } from "react-router-dom";
import { CreateGroupForm } from "../components/CreateGroupForm";
import { GroupSwitcher } from "../components/GroupSwitcher";
import { Leaderboard } from "../components/Leaderboard";
import { MatchListView } from "../components/MatchList";
import { Spinner } from "../components/Spinner";
import { useMe } from "../hooks/auth";
import { useGroup, useGroupMatches, useGroupStats } from "../hooks/groups";
export function GroupPage() {
const { data: me, isLoading } = useMe();
const navigate = useNavigate();
const groupId = me?.active_group_id ?? null;
const { data: group } = useGroup(groupId);
const { data: stats } = useGroupStats(groupId);
const { data: matches } = useGroupMatches(groupId);
if (isLoading) return <Spinner />;
if (!groupId) {
return (
<div>
<div className="card">
<h3>Активная группа не выбрана</h3>
<p className="muted small">
Создайте группу или выберите существующую, чтобы вести учёт партий.
</p>
</div>
<GroupSwitcher />
<CreateGroupForm />
</div>
);
}
return (
<div>
<div className="card">
<div className="row-between">
<h2 style={{ margin: 0 }}>{group?.name ?? "Группа"}</h2>
{group && (
<button className="btn btn-ghost small" onClick={() => navigate("/group/settings")}>
Настройки
</button>
)}
</div>
<p className="muted small">
Партий: {stats?.total_matches ?? 0}
{stats?.last_match_at ? ` · последняя: ${stats.last_match_at}` : ""}
</p>
</div>
<div className="card">
<h3>Топ в группе</h3>
{stats && <Leaderboard entries={stats.leaderboard} provisional={stats.provisional} />}
</div>
<GroupSwitcher />
<h3 style={{ marginTop: 8 }}>Партии</h3>
{matches && <MatchListView items={matches.items} />}
</div>
);
}
+128
View File
@@ -0,0 +1,128 @@
import { Trash2, UserPlus } from "lucide-react";
import { useEffect, useState } from "react";
import { ApiError } from "../api/client";
import { Spinner } from "../components/Spinner";
import { MAX_GROUP_SIZE } from "../domain/constants";
import { useToast } from "../context/ToastContext";
import { useMe } from "../hooks/auth";
import { useAddMember, useGroup, useGroupMembers, useRemoveMember, useSetGroupExpansions } from "../hooks/groups";
import { useExpansions } from "../hooks/reference";
export function GroupSettingsPage() {
const { data: me } = useMe();
const groupId = me?.active_group_id ?? null;
const { data: group, isLoading } = useGroup(groupId);
const { data: expansions } = useExpansions();
const { data: members } = useGroupMembers(groupId);
const setExpansions = useSetGroupExpansions(groupId ?? 0);
const addMember = useAddMember(groupId ?? 0);
const removeMember = useRemoveMember(groupId ?? 0);
const toast = useToast();
const [selected, setSelected] = useState<Set<number>>(new Set());
const [newNick, setNewNick] = useState("");
useEffect(() => {
if (group) setSelected(new Set(group.expansion_ids));
}, [group]);
if (isLoading || !group) return <Spinner />;
// Управление группой доступно всем участникам (создатель лишь создаёт и приглашает).
const isOwner = true;
const toggle = (id: number) =>
setSelected((s) => {
const next = new Set(s);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
const saveExpansions = async () => {
try {
await setExpansions.mutateAsync([...selected]);
toast.show("Дополнения сохранены");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const add = async () => {
const nick = newNick.trim();
if (!nick) return;
try {
await addMember.mutateAsync(nick);
setNewNick("");
toast.show("Игрок добавлен");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
const remove = async (userId: number) => {
try {
await removeMember.mutateAsync(userId);
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div>
<div className="card">
<h3>Дополнения</h3>
<div className="stack">
{(expansions ?? []).map((e) => (
<label key={e.id} className="row" style={{ justifyContent: "space-between" }}>
<span>
{e.name_ru}
{e.is_base && <span className="muted small"> (всегда включено)</span>}
</span>
<input
type="checkbox"
style={{ width: "auto" }}
disabled={e.is_base || !isOwner}
checked={e.is_base || selected.has(e.id)}
onChange={() => toggle(e.id)}
/>
</label>
))}
</div>
{isOwner && (
<button className="btn btn-primary" style={{ marginTop: 10 }} onClick={saveExpansions}>
Сохранить дополнения
</button>
)}
</div>
<div className="card">
<h3>Участники ({(members ?? []).length}/{MAX_GROUP_SIZE})</h3>
{isOwner && (members ?? []).length >= MAX_GROUP_SIZE && (
<p className="muted small">Достигнут максимум участников ({MAX_GROUP_SIZE}).</p>
)}
{isOwner && (members ?? []).length < MAX_GROUP_SIZE && (
<div className="row" style={{ marginBottom: 10 }}>
<input placeholder="Никнейм игрока" value={newNick} onChange={(e) => setNewNick(e.target.value)} />
<button className="btn btn-primary" onClick={add}>
<UserPlus size={18} />
</button>
</div>
)}
<div className="stack">
{(members ?? []).map((m) => (
<div key={m.user_id} className="row-between">
<span>
{m.nickname} <span className="muted small">· {m.role}</span>
</span>
{isOwner && m.role !== "owner" && (
<button className="btn btn-ghost btn-danger small" onClick={() => remove(m.user_id)}>
<Trash2 size={16} />
</button>
)}
</div>
))}
</div>
</div>
</div>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { InProgressMatches } from "../components/InProgressMatches";
import { Leaderboard } from "../components/Leaderboard";
import { ProfileStatsCard } from "../components/ProfileStatsCard";
import { Spinner } from "../components/Spinner";
import { useHome } from "../hooks/stats";
export function HomePage() {
const { data, isLoading, isError } = useHome();
if (isLoading) return <Spinner />;
if (isError || !data) return <div className="muted">Не удалось загрузить данные.</div>;
return (
<div>
<div className="card">
<h3>Топ игроков</h3>
<Leaderboard entries={data.leaderboard} provisional={data.provisional} />
</div>
<ProfileStatsCard stats={data.profile} />
<InProgressMatches items={data.in_progress} />
{data.active_group && (
<div className="card">
<h3>Активная группа</h3>
<div className="row-between">
<b>{data.active_group.name}</b>
<span className="muted small">
игр: {data.active_group.games}, побед: {data.active_group.wins}
</span>
</div>
</div>
)}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { useState } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { ApiError } from "../api/client";
import { Spinner } from "../components/Spinner";
import { TelegramLoginButton } from "../components/TelegramLoginButton";
import {
useAuthConfig,
useCreateDevUser,
useDevUsers,
useMe,
useStubLogin,
useTelegramLogin,
} from "../hooks/auth";
export function LoginPage() {
const { data: me, isLoading } = useMe();
const { data: config } = useAuthConfig();
const navigate = useNavigate();
const [error, setError] = useState<string | null>(null);
const methods = config?.methods ?? [];
const hasTelegram = methods.includes("telegram");
// Stub-вход показываем только в dev-сборке И если бэкенд его отдаёт (в проде
// import.meta.env.DEV === false → блок вырезается из бандла).
const showStub = import.meta.env.DEV && methods.includes("stub");
const telegram = useTelegramLogin();
if (isLoading) return <Spinner />;
if (me) return <Navigate to="/" replace />;
const onTelegram = async (user: Record<string, unknown>) => {
setError(null);
try {
await telegram.mutateAsync(user);
navigate("/", { replace: true });
} catch (e) {
setError(e instanceof ApiError ? e.message : "Не удалось войти через Telegram");
}
};
return (
<div className="app-shell">
<div className="app-main" style={{ paddingBottom: 24 }}>
<h1 className="center" style={{ marginTop: 24 }}>
Forbidden Stars
</h1>
<p className="muted center">Учёт партий</p>
{hasTelegram && (
<div className="card">
<h3>Вход через Telegram</h3>
{config?.telegram_bot_username ? (
<TelegramLoginButton
botUsername={config.telegram_bot_username}
onAuth={(u) => onTelegram(u as unknown as Record<string, unknown>)}
/>
) : (
<p className="muted small">
Telegram не настроен — укажите TELEGRAM_BOT_USERNAME и TELEGRAM_BOT_TOKEN
в .env (бот от @BotFather + /setdomain на ваш домен).
</p>
)}
</div>
)}
{showStub && <DevLogin onError={setError} />}
{error && <p className="error-text center">{error}</p>}
<p className="center" style={{ marginTop: 40 }}>
<button
className="btn btn-ghost small"
onClick={() => navigate("/admin/login")}
style={{ opacity: 0.4 }}
>
·
</button>
</p>
</div>
</div>
);
}
// DEV-вход по нику. Вынесен в отдельный компонент; импортируется только в dev-ветке
// LoginPage, поэтому в прод-сборке (import.meta.env.DEV=false) удаляется тришейкингом.
function DevLogin({ onError }: { onError: (msg: string | null) => void }) {
const { data: devUsers } = useDevUsers(true);
const stubLogin = useStubLogin();
const createUser = useCreateDevUser();
const navigate = useNavigate();
const [nickname, setNickname] = useState("");
const doLogin = async (nick: string) => {
onError(null);
try {
await stubLogin.mutateAsync(nick);
navigate("/", { replace: true });
} catch (e) {
onError(e instanceof ApiError ? e.message : "Не удалось войти");
}
};
const onCreate = async () => {
const nick = nickname.trim();
if (!nick) return;
try {
await createUser.mutateAsync(nick);
await doLogin(nick);
} catch (e) {
onError(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div className="card">
<h3>Тестовый вход (dev)</h3>
<div className="field">
<label className="label">Новый игрок</label>
<div className="row">
<input placeholder="Ваш никнейм" value={nickname} onChange={(e) => setNickname(e.target.value)} />
<button className="btn btn-primary" onClick={onCreate} disabled={createUser.isPending}>
Войти
</button>
</div>
</div>
{devUsers && devUsers.length > 0 && (
<>
<div className="label">Или выберите существующего:</div>
<div className="stack">
{devUsers.map((u) => (
<button
key={u.id}
className="btn"
disabled={!u.is_active}
title={u.is_active ? undefined : "Аккаунт отключён администратором"}
style={u.is_active ? undefined : { opacity: 0.45, color: "var(--text-muted)" }}
onClick={() => doLogin(u.nickname)}
>
{u.nickname}
{!u.is_active && <span className="small"> · отключён</span>}
</button>
))}
</div>
</>
)}
</div>
);
}
+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>
);
}
@@ -0,0 +1,44 @@
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import { useAdminUpdateUser, useAdminUsers } from "../../hooks/admin";
export function AdminAccountsPage() {
const { data: users, isLoading } = useAdminUsers();
const update = useAdminUpdateUser();
const toast = useToast();
if (isLoading) return <Spinner />;
return (
<div>
<h3>Аккаунты</h3>
<div className="stack">
{(users ?? []).map((u) => (
<div key={u.id} className="card" style={{ margin: 0 }}>
<div className="row-between">
<div>
<b>{u.nickname}</b> {u.role === "admin" && <span className="badge">admin</span>}
<div className="muted small">
#{u.id} · {u.auth_provider} · {u.is_active ? "активен" : "отключён"}
</div>
</div>
{u.role !== "admin" && (
<button
className="btn small"
onClick={async () => {
await update
.mutateAsync({ userId: u.id, is_active: !u.is_active })
.catch(() => {});
toast.show("Сохранено");
}}
>
{u.is_active ? "Отключить" : "Включить"}
</button>
)}
</div>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,59 @@
import { useEffect, useState } from "react";
import { ApiError } from "../../api/client";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import { useAdminFactions, useAdminRenameFaction } from "../../hooks/admin";
export function AdminFactionsPage() {
const { data: factions, isLoading } = useAdminFactions();
const rename = useAdminRenameFaction();
const toast = useToast();
const [names, setNames] = useState<Record<number, string>>({});
useEffect(() => {
if (factions) {
setNames(Object.fromEntries(factions.map((f) => [f.id, f.name_ru])));
}
}, [factions]);
if (isLoading) return <Spinner />;
const save = async (id: number) => {
try {
await rename.mutateAsync({ factionId: id, name_ru: names[id] });
toast.show("Название обновлено во всей системе");
} catch (e) {
toast.show(e instanceof ApiError ? e.message : "Ошибка");
}
};
return (
<div>
<h3>Фракции</h3>
<p className="muted small">
Переименование меняет название фракции во всех партиях и статистике.
</p>
<div className="stack">
{(factions ?? []).map((f) => (
<div key={f.id} className="card" style={{ margin: 0 }}>
<div className="muted small">{f.code}</div>
<div className="row">
<input
value={names[f.id] ?? ""}
onChange={(e) => setNames((n) => ({ ...n, [f.id]: e.target.value }))}
/>
<button
className="btn btn-primary"
disabled={!names[f.id] || names[f.id] === f.name_ru}
onClick={() => save(f.id)}
>
ОК
</button>
</div>
</div>
))}
</div>
</div>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { useAdminLogout } from "../../hooks/admin";
export function AdminLayout() {
const logout = useAdminLogout();
const navigate = useNavigate();
const doLogout = async () => {
await logout.mutateAsync().catch(() => {});
navigate("/admin/login", { replace: true });
};
const tab = ({ isActive }: { isActive: boolean }) =>
"btn small" + (isActive ? " btn-primary" : " btn-ghost");
return (
<div className="app-shell">
<header className="top-bar">
<span className="title">Админ-панель</span>
<button className="btn btn-ghost small" onClick={doLogout}>
Выйти
</button>
</header>
<div className="row" style={{ padding: "10px 14px", gap: 8 }}>
<NavLink to="/admin/records" className={tab}>
Записи
</NavLink>
<NavLink to="/admin/accounts" className={tab}>
Аккаунты
</NavLink>
<NavLink to="/admin/factions" className={tab}>
Фракции
</NavLink>
<NavLink to="/admin/logs" className={tab}>
Логи
</NavLink>
</div>
<main className="app-main" style={{ paddingBottom: 24 }}>
<Outlet />
</main>
</div>
);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import { Navigate, useNavigate } from "react-router-dom";
import { ApiError } from "../../api/client";
import { useAdminLogin, useAdminMe } from "../../hooks/admin";
export function AdminLoginPage() {
const { data: admin } = useAdminMe();
const login = useAdminLogin();
const navigate = useNavigate();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
if (admin) return <Navigate to="/admin" replace />;
const submit = async () => {
setError(null);
try {
await login.mutateAsync({ username, password });
navigate("/admin", { replace: true });
} catch (e) {
setError(e instanceof ApiError ? e.message : "Ошибка входа");
}
};
return (
<div className="app-shell">
<div className="app-main" style={{ paddingBottom: 24 }}>
<h1 className="center" style={{ marginTop: 24 }}>
Администратор
</h1>
<div className="card">
<div className="field">
<label className="label">Логин</label>
<input value={username} onChange={(e) => setUsername(e.target.value)} />
</div>
<div className="field">
<label className="label">Пароль</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} />
</div>
{error && <p className="error-text">{error}</p>}
<button className="btn btn-primary" style={{ width: "100%" }} onClick={submit} disabled={login.isPending}>
Войти
</button>
</div>
<p className="center">
<button className="btn btn-ghost small muted" onClick={() => navigate("/")}>
← К приложению
</button>
</p>
</div>
</div>
);
}
@@ -0,0 +1,67 @@
import { useState } from "react";
import { Spinner } from "../../components/Spinner";
import { formatDateTime } from "../../domain/format";
import { useAdminLogs } from "../../hooks/admin";
const ACTIONS = ["", "login", "create", "update", "delete"];
const ENTITIES = ["", "user", "group", "group_member", "match", "admin"];
export function AdminLogsPage() {
const [action, setAction] = useState("");
const [entityType, setEntityType] = useState("");
const { data, isLoading } = useAdminLogs({
action: action || undefined,
entity_type: entityType || undefined,
});
return (
<div>
<h3>Журнал событий</h3>
<div className="row" style={{ marginBottom: 10, gap: 8 }}>
<select style={{ width: "auto" }} value={action} onChange={(e) => setAction(e.target.value)}>
{ACTIONS.map((a) => (
<option key={a} value={a}>
{a || "все действия"}
</option>
))}
</select>
<select style={{ width: "auto" }} value={entityType} onChange={(e) => setEntityType(e.target.value)}>
{ENTITIES.map((en) => (
<option key={en} value={en}>
{en || "все сущности"}
</option>
))}
</select>
</div>
{isLoading ? (
<Spinner />
) : (
<div className="stack">
{(data?.items ?? []).map((log) => (
<div key={log.id} className="card" style={{ margin: 0 }}>
<div className="row-between">
<b>
{log.action} · {log.entity_type}
{log.entity_id != null ? ` #${log.entity_id}` : ""}
</b>
<span className="muted small">{formatDateTime(log.created_at)}</span>
</div>
<div className="muted small">
actor: {log.actor_id ?? "—"}
{log.ip ? ` · ${log.ip}` : ""}
</div>
{log.payload && (
<pre className="small muted" style={{ whiteSpace: "pre-wrap", margin: "4px 0 0" }}>
{JSON.stringify(log.payload)}
</pre>
)}
</div>
))}
{(data?.items ?? []).length === 0 && <div className="muted small">Событий нет.</div>}
</div>
)}
</div>
);
}
+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>
);
}
@@ -0,0 +1,146 @@
import { Pencil, Trash2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Spinner } from "../../components/Spinner";
import { useToast } from "../../context/ToastContext";
import {
useAdminDeleteMatch,
useAdminFactions,
useAdminGroups,
useAdminMatches,
useAdminUsers,
} from "../../hooks/admin";
import type { AdminMatchRead } from "../../domain/types";
import { AdminMatchEdit } from "./AdminMatchEdit";
export function AdminRecordsPage() {
const [groupFilter, setGroupFilter] = useState<number | null>(null);
const [userFilter, setUserFilter] = useState<number | null>(null);
const [factionFilter, setFactionFilter] = useState<number | null>(null);
const [grouped, setGrouped] = useState(true);
const [editId, setEditId] = useState<number | null>(null);
const { data: groups } = useAdminGroups();
const { data: users } = useAdminUsers();
const { data: factions } = useAdminFactions();
const { data: matches, isLoading } = useAdminMatches({
group_id: groupFilter,
user_id: userFilter,
faction_id: factionFilter,
});
const del = useAdminDeleteMatch();
const toast = useToast();
const byGroup = useMemo(() => {
const map = new Map<number, { name: string; items: AdminMatchRead[] }>();
for (const m of matches ?? []) {
const g = map.get(m.group_id) ?? { name: m.group_name ?? `Группа ${m.group_id}`, items: [] };
g.items.push(m);
map.set(m.group_id, g);
}
return [...map.entries()];
}, [matches]);
const flat = useMemo(
() => [...(matches ?? [])].sort((a, b) => b.id - a.id),
[matches],
);
const card = (m: AdminMatchRead) => (
<div key={m.id} className="card" style={{ margin: 0 }}>
<div className="row-between">
<div>
<b>#{m.id}</b> · {m.played_at}
{!grouped && <span className="muted small"> · {m.group_name}</span>}
<div className="muted small">
{m.player_count} игр. · автор {m.created_by}
</div>
</div>
<div className="row" style={{ gap: 4 }}>
<button className="btn btn-ghost small" onClick={() => setEditId(m.id)}>
<Pencil size={16} />
</button>
<button
className="btn btn-ghost btn-danger small"
onClick={async () => {
await del.mutateAsync(m.id).catch(() => {});
toast.show("Удалено");
}}
>
<Trash2 size={16} />
</button>
</div>
</div>
</div>
);
return (
<div>
<div className="card">
<div className="stack" style={{ gap: 8 }}>
<select
value={groupFilter ?? ""}
onChange={(e) => setGroupFilter(e.target.value ? Number(e.target.value) : null)}
>
<option value="">Все группы</option>
{(groups ?? []).map((g) => (
<option key={g.id} value={g.id}>
{g.name}
</option>
))}
</select>
<select
value={userFilter ?? ""}
onChange={(e) => setUserFilter(e.target.value ? Number(e.target.value) : null)}
>
<option value="">Все игроки</option>
{(users ?? []).filter((u) => u.role !== "admin").map((u) => (
<option key={u.id} value={u.id}>
{u.nickname}
</option>
))}
</select>
<select
value={factionFilter ?? ""}
onChange={(e) => setFactionFilter(e.target.value ? Number(e.target.value) : null)}
>
<option value="">Все фракции</option>
{(factions ?? []).map((f) => (
<option key={f.id} value={f.id}>
{f.name_ru}
</option>
))}
</select>
<label className="row" style={{ gap: 8 }}>
<input
type="checkbox"
style={{ width: "auto" }}
checked={grouped}
onChange={(e) => setGrouped(e.target.checked)}
/>
Группировать по группам
</label>
</div>
</div>
{isLoading ? (
<Spinner />
) : (matches ?? []).length === 0 ? (
<div className="muted small">Записей нет.</div>
) : grouped ? (
byGroup.map(([gid, { name, items }]) => (
<div key={gid} style={{ marginBottom: 16 }}>
<div className="label" style={{ fontSize: 14, color: "var(--accent-2)" }}>
{name} · {items.length}
</div>
<div className="stack">{items.map(card)}</div>
</div>
))
) : (
<div className="stack">{flat.map(card)}</div>
)}
{editId != null && <AdminMatchEdit matchId={editId} onClose={() => setEditId(null)} />}
</div>
);
}