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