v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Пути в OpenAPI уже содержат префикс /api, поэтому baseUrl пустой.
|
||||
# В dev Vite проксирует /api на бэкенд :8000; в prod — тот же origin.
|
||||
VITE_API_BASE_URL=
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="theme-color" content="#0f1115" />
|
||||
<title>Forbidden Stars — учёт партий</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2205
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "forbidden-stars-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"gen:api": "openapi-typescript http://localhost:8000/api/openapi.json -o src/api/schema.d.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"date-fns": "^3.6.0",
|
||||
"lucide-react": "^0.408.0",
|
||||
"openapi-fetch": "^0.10.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.1",
|
||||
"react-router-dom": "^6.25.1",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"openapi-typescript": "^7.3.0",
|
||||
"typescript": "^5.5.3",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { RouterProvider } from "react-router-dom";
|
||||
|
||||
import { queryClient } from "./app/queryClient";
|
||||
import { router } from "./app/router";
|
||||
import { ToastProvider } from "./context/ToastContext";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ToastProvider>
|
||||
<RouterProvider router={router} />
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import createClient from "openapi-fetch";
|
||||
import type { paths } from "./schema";
|
||||
|
||||
function readCookie(name: string): string | null {
|
||||
const m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
|
||||
return m ? decodeURIComponent(m[1]) : null;
|
||||
}
|
||||
|
||||
// Пути в схеме уже содержат /api; baseUrl — origin (пусто в dev/prod).
|
||||
export const api = createClient<paths>({
|
||||
baseUrl: import.meta.env.VITE_API_BASE_URL || "",
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
// CSRF double-submit: дублируем cookie csrf_token в заголовок на всех запросах.
|
||||
api.use({
|
||||
onRequest({ request }) {
|
||||
const csrf = readCookie("csrf_token");
|
||||
if (csrf) request.headers.set("X-CSRF-Token", csrf);
|
||||
return request;
|
||||
},
|
||||
});
|
||||
|
||||
export class ApiError extends Error {
|
||||
code: string;
|
||||
status: number;
|
||||
details?: unknown;
|
||||
constructor(message: string, code: string, status: number, details?: unknown) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.status = status;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
interface FetchResult<T> {
|
||||
data?: T;
|
||||
error?: unknown;
|
||||
response: Response;
|
||||
}
|
||||
|
||||
/** Разворачивает ответ openapi-fetch: возвращает данные или бросает ApiError. */
|
||||
export function unwrap<T>(res: FetchResult<T>): T {
|
||||
if (res.error || !res.response.ok) {
|
||||
const env = (res.error as { error?: { code?: string; message?: string; details?: unknown } })
|
||||
?.error;
|
||||
throw new ApiError(
|
||||
env?.message || "Ошибка запроса",
|
||||
env?.code || "ERROR",
|
||||
res.response.status,
|
||||
env?.details,
|
||||
);
|
||||
}
|
||||
return res.data as T;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export const qk = {
|
||||
me: ["me"] as const,
|
||||
adminMe: ["adminMe"] as const,
|
||||
home: ["home"] as const,
|
||||
leaderboard: ["leaderboard"] as const,
|
||||
expansions: ["expansions"] as const,
|
||||
factions: ["factions"] as const,
|
||||
myStats: ["myStats"] as const,
|
||||
groups: ["groups"] as const,
|
||||
group: (id: number) => ["group", id] as const,
|
||||
groupFactions: (id: number) => ["group", id, "factions"] as const,
|
||||
groupMembers: (id: number) => ["group", id, "members"] as const,
|
||||
groupMatches: (id: number) => ["group", id, "matches"] as const,
|
||||
groupStats: (id: number) => ["group", id, "stats"] as const,
|
||||
match: (id: number) => ["match", id] as const,
|
||||
authConfig: ["authConfig"] as const,
|
||||
devUsers: ["devUsers"] as const,
|
||||
adminUsers: ["adminUsers"] as const,
|
||||
adminGroups: ["adminGroups"] as const,
|
||||
adminMatches: ["adminMatches"] as const,
|
||||
adminLogs: ["adminLogs"] as const,
|
||||
};
|
||||
Vendored
+2842
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 30_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Navigate, createBrowserRouter } from "react-router-dom";
|
||||
|
||||
import { AppShell } from "../components/AppShell";
|
||||
import { RequireAdmin, RequireAuth, RequireGroup } from "../auth/guards";
|
||||
import { AccountPage } from "../pages/AccountPage";
|
||||
import { CreateMatchPage } from "../pages/CreateMatchPage";
|
||||
import { GroupPage } from "../pages/GroupPage";
|
||||
import { GroupSettingsPage } from "../pages/GroupSettingsPage";
|
||||
import { HomePage } from "../pages/HomePage";
|
||||
import { LoginPage } from "../pages/LoginPage";
|
||||
import { MatchDetailPage } from "../pages/MatchDetailPage";
|
||||
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
|
||||
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
|
||||
import { AdminLayout } from "../pages/admin/AdminLayout";
|
||||
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
|
||||
import { AdminLoginPage } from "../pages/admin/AdminLoginPage";
|
||||
import { AdminRecordsPage } from "../pages/admin/AdminRecordsPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/admin/login", element: <AdminLoginPage /> },
|
||||
{
|
||||
path: "/",
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AppShell />
|
||||
</RequireAuth>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <HomePage /> },
|
||||
{
|
||||
path: "match/new",
|
||||
element: (
|
||||
<RequireGroup>
|
||||
<CreateMatchPage />
|
||||
</RequireGroup>
|
||||
),
|
||||
},
|
||||
{ path: "match/:matchId", element: <MatchDetailPage /> },
|
||||
{ path: "group", element: <GroupPage /> },
|
||||
{
|
||||
path: "group/settings",
|
||||
element: (
|
||||
<RequireGroup>
|
||||
<GroupSettingsPage />
|
||||
</RequireGroup>
|
||||
),
|
||||
},
|
||||
{ path: "account", element: <AccountPage /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: "/admin",
|
||||
element: (
|
||||
<RequireAdmin>
|
||||
<AdminLayout />
|
||||
</RequireAdmin>
|
||||
),
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/admin/records" replace /> },
|
||||
{ path: "records", element: <AdminRecordsPage /> },
|
||||
{ path: "accounts", element: <AdminAccountsPage /> },
|
||||
{ path: "factions", element: <AdminFactionsPage /> },
|
||||
{ path: "logs", element: <AdminLogsPage /> },
|
||||
],
|
||||
},
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { PropsWithChildren } from "react";
|
||||
import { Navigate, useLocation } from "react-router-dom";
|
||||
|
||||
import { Spinner } from "../components/Spinner";
|
||||
import { useMe } from "../hooks/auth";
|
||||
import { useAdminMe } from "../hooks/admin";
|
||||
|
||||
export function RequireAuth({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
const location = useLocation();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!me) return <Navigate to="/login" replace state={{ from: location }} />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireGroup({ children }: PropsWithChildren) {
|
||||
const { data: me, isLoading } = useMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!me) return <Navigate to="/login" replace />;
|
||||
if (me.active_group_id == null) return <Navigate to="/" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export function RequireAdmin({ children }: PropsWithChildren) {
|
||||
const { data: admin, isLoading } = useAdminMe();
|
||||
if (isLoading) return <Spinner />;
|
||||
if (!admin) return <Navigate to="/admin/login" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import { BottomBar } from "./BottomBar";
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
"/": "Forbidden Stars",
|
||||
"/group": "Группа",
|
||||
"/group/settings": "Настройки группы",
|
||||
"/account": "Аккаунт",
|
||||
"/match/new": "Новая партия",
|
||||
};
|
||||
|
||||
export function AppShell() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const title = TITLES[location.pathname] ?? "Forbidden Stars";
|
||||
const isHome = location.pathname === "/";
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
<header className="top-bar">
|
||||
{isHome ? (
|
||||
<span className="title">{title}</span>
|
||||
) : (
|
||||
<button className="btn btn-ghost" onClick={() => navigate(-1)}>
|
||||
← Назад
|
||||
</button>
|
||||
)}
|
||||
<span className="title small muted">{!isHome ? title : ""}</span>
|
||||
</header>
|
||||
|
||||
<main className="app-main">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
<BottomBar />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Plus, Settings, Users } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useMe } from "../hooks/auth";
|
||||
|
||||
export function BottomBar() {
|
||||
const { data: me } = useMe();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const hasGroup = me?.active_group_id != null;
|
||||
const activeGroup = me?.groups.find((g) => g.id === me?.active_group_id);
|
||||
|
||||
const onCreate = () => {
|
||||
if (!hasGroup) {
|
||||
toast.show("Вы не состоите в группе. Создайте или вступите в группу.");
|
||||
navigate("/group");
|
||||
return;
|
||||
}
|
||||
navigate("/match/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<nav className="bottom-bar">
|
||||
<button className="slot" onClick={() => navigate("/group")}>
|
||||
<Users size={20} />
|
||||
<span className="name">{activeGroup ? activeGroup.name : "Группа"}</span>
|
||||
</button>
|
||||
|
||||
<div className="spacer" />
|
||||
|
||||
<button className="slot" onClick={() => navigate("/account")}>
|
||||
<Settings size={20} />
|
||||
<span>Аккаунт</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="fab"
|
||||
aria-disabled={!hasGroup}
|
||||
aria-label="Создать партию"
|
||||
onClick={onCreate}
|
||||
>
|
||||
<Plus size={26} />
|
||||
</button>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import { ApiError } from "../api/client";
|
||||
import { useToast } from "../context/ToastContext";
|
||||
import { useCreateGroup } from "../hooks/groups";
|
||||
import { useExpansions } from "../hooks/reference";
|
||||
|
||||
export function CreateGroupForm({ onCreated }: { onCreated?: () => void }) {
|
||||
const { data: expansions } = useExpansions();
|
||||
const createGroup = useCreateGroup();
|
||||
const toast = useToast();
|
||||
const [name, setName] = useState("");
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const toggle = (id: number) =>
|
||||
setSelected((s) => {
|
||||
const next = new Set(s);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
|
||||
const submit = async () => {
|
||||
setError(null);
|
||||
try {
|
||||
await createGroup.mutateAsync({ name: name.trim(), expansion_ids: [...selected] });
|
||||
toast.show("Группа создана");
|
||||
onCreated?.();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : "Не удалось создать группу");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Новая группа</h3>
|
||||
<div className="field">
|
||||
<label className="label">Название</label>
|
||||
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="Например: Вечер 40k" />
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="label">Дополнения группы</label>
|
||||
<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}
|
||||
checked={e.is_base || selected.has(e.id)}
|
||||
onChange={() => toggle(e.id)}
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{error && <p className="error-text">{error}</p>}
|
||||
<button className="btn btn-primary" style={{ width: "100%" }} onClick={submit} disabled={createGroup.isPending}>
|
||||
Создать
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useMe, useSetActiveGroup } from "../hooks/auth";
|
||||
|
||||
export function GroupSwitcher() {
|
||||
const { data: me } = useMe();
|
||||
const setActive = useSetActiveGroup();
|
||||
if (!me || me.groups.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Мои группы</h3>
|
||||
<div className="stack">
|
||||
{me.groups.map((g) => {
|
||||
const active = g.id === me.active_group_id;
|
||||
return (
|
||||
<button
|
||||
key={g.id}
|
||||
className={"btn" + (active ? " btn-primary" : "")}
|
||||
onClick={() => !active && setActive.mutate(g.id)}
|
||||
>
|
||||
{g.name} {active && "✓"} <span className="muted small">({g.role})</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { formatTime } from "../domain/format";
|
||||
import type { HomeInProgressMatch } from "../domain/types";
|
||||
|
||||
export function InProgressMatches({ items }: { items: HomeInProgressMatch[] }) {
|
||||
const navigate = useNavigate();
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="card" style={{ borderColor: "var(--accent)" }}>
|
||||
<h3 style={{ color: "var(--accent-2)" }}>Незавершённые партии</h3>
|
||||
<div className="stack">
|
||||
{items.map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
className="row-between"
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
style={{
|
||||
width: "100%",
|
||||
textAlign: "left",
|
||||
background: "var(--surface-2)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: "var(--radius-sm)",
|
||||
padding: 12,
|
||||
color: "var(--text)",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 8 }}>
|
||||
<b>{m.group_name}</b>
|
||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
||||
</div>
|
||||
<div className="muted small">
|
||||
{formatTime(m.started_at)} · {m.player_count} игр.
|
||||
</div>
|
||||
<div
|
||||
className="small"
|
||||
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
|
||||
>
|
||||
{m.participants.map((p) => p.nickname).join(", ")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row" style={{ gap: 4, color: "var(--accent-2)", flexShrink: 0 }}>
|
||||
Завершить <ChevronRight size={18} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { LeaderboardEntry } from "../domain/types";
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||
}
|
||||
|
||||
function Row({ entry }: { entry: LeaderboardEntry }) {
|
||||
return (
|
||||
<div className="lb-row">
|
||||
<div className={"lb-rank" + (entry.rank && entry.rank <= 3 ? " top" : "")}>
|
||||
{entry.rank ?? "—"}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600 }}>{entry.nickname}</div>
|
||||
<div className="lb-metrics">
|
||||
<span>Игр: {entry.games}</span>
|
||||
<span>Побед: {entry.wins}</span>
|
||||
<span>WR: {pct(entry.win_rate)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="lb-score">{entry.score ?? "—"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Leaderboard({
|
||||
entries,
|
||||
provisional,
|
||||
}: {
|
||||
entries: LeaderboardEntry[];
|
||||
provisional?: LeaderboardEntry[];
|
||||
}) {
|
||||
if (entries.length === 0 && (!provisional || provisional.length === 0)) {
|
||||
return <div className="muted small">Пока нет сыгранных партий.</div>;
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{entries.map((e) => (
|
||||
<Row key={e.user_id} entry={e} />
|
||||
))}
|
||||
{provisional && provisional.length > 0 && (
|
||||
<>
|
||||
<div className="label" style={{ marginTop: 10 }}>
|
||||
Новички (мало игр)
|
||||
</div>
|
||||
{provisional.map((e) => (
|
||||
<Row key={e.user_id} entry={e} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { formatDuration } from "../domain/format";
|
||||
import { winReasonLabel } from "../domain/winReasons";
|
||||
import type { MatchListItem } from "../domain/types";
|
||||
|
||||
export function MatchListView({ items }: { items: MatchListItem[] }) {
|
||||
const navigate = useNavigate();
|
||||
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
{items.map((m) => {
|
||||
const inProgress = m.status === "in_progress";
|
||||
const sorted = [...m.participants].sort((a, b) => (a.place ?? 99) - (b.place ?? 99));
|
||||
return (
|
||||
<button
|
||||
key={m.id}
|
||||
className="card"
|
||||
style={{ textAlign: "left", width: "100%", margin: 0 }}
|
||||
onClick={() => navigate(`/match/${m.id}`)}
|
||||
>
|
||||
<div className="row-between">
|
||||
<b>{m.played_at}</b>
|
||||
{inProgress ? (
|
||||
<span className="badge" style={{ color: "var(--accent-2)" }}>идёт</span>
|
||||
) : (
|
||||
<span className="muted small">
|
||||
{formatDuration(m.duration_minutes)} · {winReasonLabel(m.win_reason)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="stack" style={{ marginTop: 6, gap: 4 }}>
|
||||
{sorted.map((p) => (
|
||||
<div key={p.user_id} className="row" style={{ gap: 8 }}>
|
||||
<span className={"place-badge" + (p.place === 1 ? " first" : "")}>
|
||||
{p.place ?? "—"}
|
||||
</span>
|
||||
<span>{p.nickname}</span>
|
||||
<span className="muted small">· {p.faction_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { ProfileStats } from "../domain/types";
|
||||
|
||||
function pct(v: number | null | undefined): string {
|
||||
return v == null ? "—" : `${Math.round(v * 100)}%`;
|
||||
}
|
||||
|
||||
export function ProfileStatsCard({ stats }: { stats: ProfileStats }) {
|
||||
const o = stats.overall;
|
||||
return (
|
||||
<div className="card">
|
||||
<h3>Моя статистика</h3>
|
||||
<div className="row-between">
|
||||
<div>Игр</div>
|
||||
<b>{o.games}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Побед</div>
|
||||
<b>{o.wins}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Winrate</div>
|
||||
<b>{pct(o.win_rate)}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Среднее место</div>
|
||||
<b>{o.avg_place ?? "—"}</b>
|
||||
</div>
|
||||
<div className="row-between">
|
||||
<div>Очки (рейтинг)</div>
|
||||
<b className="lb-score">{o.score ?? "—"}</b>
|
||||
</div>
|
||||
{stats.most_played_faction && (
|
||||
<div className="row-between">
|
||||
<div>Любимая фракция</div>
|
||||
<b>{stats.most_played_faction.name_ru}</b>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export function Spinner({ label }: { label?: string }) {
|
||||
return (
|
||||
<div className="center muted" style={{ padding: 40 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 28,
|
||||
height: 28,
|
||||
border: "3px solid var(--border)",
|
||||
borderTopColor: "var(--accent)",
|
||||
borderRadius: "50%",
|
||||
margin: "0 auto 10px",
|
||||
animation: "fs-spin 0.8s linear infinite",
|
||||
}}
|
||||
/>
|
||||
{label ?? "Загрузка…"}
|
||||
<style>{`@keyframes fs-spin { to { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
interface TelegramUser {
|
||||
id: number;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
photo_url?: string;
|
||||
auth_date: number;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__fsTelegramAuth?: (user: TelegramUser) => void;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Официальный Telegram Login Widget. Требует бота (@BotFather) и /setdomain
|
||||
* на текущий HTTPS-домен. botUsername — без «@».
|
||||
*/
|
||||
export function TelegramLoginButton({
|
||||
botUsername,
|
||||
onAuth,
|
||||
}: {
|
||||
botUsername: string;
|
||||
onAuth: (user: TelegramUser) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
window.__fsTelegramAuth = (user) => onAuth(user);
|
||||
const script = document.createElement("script");
|
||||
script.src = "https://telegram.org/js/telegram-widget.js?22";
|
||||
script.async = true;
|
||||
script.setAttribute("data-telegram-login", botUsername);
|
||||
script.setAttribute("data-size", "large");
|
||||
script.setAttribute("data-userpic", "false");
|
||||
script.setAttribute("data-request-access", "write");
|
||||
script.setAttribute("data-onauth", "__fsTelegramAuth(user)");
|
||||
|
||||
const node = ref.current;
|
||||
node?.appendChild(script);
|
||||
return () => {
|
||||
if (node) node.innerHTML = "";
|
||||
delete window.__fsTelegramAuth;
|
||||
};
|
||||
}, [botUsername, onAuth]);
|
||||
|
||||
return <div ref={ref} />;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { createContext, useCallback, useContext, useRef, useState } from "react";
|
||||
import type { PropsWithChildren } from "react";
|
||||
|
||||
interface ToastCtx {
|
||||
show: (message: string) => void;
|
||||
}
|
||||
|
||||
const Ctx = createContext<ToastCtx>({ show: () => {} });
|
||||
|
||||
export function ToastProvider({ children }: PropsWithChildren) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const show = useCallback((msg: string) => {
|
||||
setMessage(msg);
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = setTimeout(() => setMessage(null), 2800);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Ctx.Provider value={{ show }}>
|
||||
{children}
|
||||
{message && <div className="toast">{message}</div>}
|
||||
</Ctx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useToast(): ToastCtx {
|
||||
return useContext(Ctx);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
// Лимиты, зеркалят значения бэкенда (авторитетная проверка — на сервере).
|
||||
export const MAX_MATCH_PLAYERS = 6;
|
||||
export const MAX_GROUP_SIZE = 10;
|
||||
@@ -0,0 +1,39 @@
|
||||
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
|
||||
const APP_TZ_OFFSET_MIN = 3 * 60;
|
||||
|
||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
|
||||
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
|
||||
function shifted(iso: string): Date {
|
||||
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
||||
}
|
||||
|
||||
export function formatDuration(minutes: number | null | undefined): string {
|
||||
if (minutes == null) return "—";
|
||||
const h = Math.floor(minutes / 60);
|
||||
const m = minutes % 60;
|
||||
if (h && m) return `≈ ${h} ч ${m} мин`;
|
||||
if (h) return `≈ ${h} ч`;
|
||||
return `≈ ${m} мин`;
|
||||
}
|
||||
|
||||
export function formatTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = shifted(iso);
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "—";
|
||||
try {
|
||||
const d = shifted(iso);
|
||||
return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
type S = components["schemas"];
|
||||
|
||||
export type Me = S["MeRead"];
|
||||
export type UserRead = S["UserRead"];
|
||||
export type GroupBrief = S["GroupBrief"];
|
||||
export type GroupDetail = S["GroupDetail"];
|
||||
export type ExpansionRead = S["ExpansionRead"];
|
||||
export type FactionRead = S["FactionRead"];
|
||||
export type MemberRead = S["MemberRead"];
|
||||
export type MatchRead = S["MatchRead"];
|
||||
export type MatchList = S["MatchList"];
|
||||
export type MatchListItem = S["MatchListItem"];
|
||||
export type Leaderboard = S["Leaderboard"];
|
||||
export type LeaderboardEntry = S["LeaderboardEntry"];
|
||||
export type ProfileStats = S["ProfileStats"];
|
||||
export type GroupStats = S["GroupStats"];
|
||||
export type HomeResponse = S["HomeResponse"];
|
||||
export type HomeInProgressMatch = S["HomeInProgressMatch"];
|
||||
export type FactionStat = S["FactionStat"];
|
||||
export type AuthConfig = S["AuthConfig"];
|
||||
export type AdminMe = S["AdminMe"];
|
||||
export type AdminUserRead = S["AdminUserRead"];
|
||||
export type AdminGroupRead = S["AdminGroupRead"];
|
||||
export type AdminMatchRead = S["AdminMatchRead"];
|
||||
export type AuditLogList = S["AuditLogList"];
|
||||
export type ParticipantInput = S["ParticipantInput"];
|
||||
export type MatchUpdate = S["MatchUpdate"];
|
||||
export type MatchCreate = S["MatchCreate"];
|
||||
export type MatchFinish = S["MatchFinish"];
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { components } from "../api/schema";
|
||||
|
||||
export type WinReason = components["schemas"]["MatchFinish"]["win_reason"];
|
||||
|
||||
// Фиксированный порядок (сверху вниз).
|
||||
export const WIN_REASONS: { code: WinReason; label: string }[] = [
|
||||
{ code: "objectives", label: "По целям" },
|
||||
{ code: "worlds", label: "По мирам" },
|
||||
{ code: "plastic", label: "По пластику" },
|
||||
{ code: "resources", label: "По ресурсам" },
|
||||
];
|
||||
|
||||
const LABELS: Record<string, string> = Object.fromEntries(
|
||||
WIN_REASONS.map((w) => [w.code, w.label]),
|
||||
);
|
||||
|
||||
export function winReasonLabel(code: string | null | undefined): string {
|
||||
return code ? LABELS[code] ?? code : "—";
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { AdminMe, MatchUpdate } from "../domain/types";
|
||||
|
||||
export function useAdminMe() {
|
||||
return useQuery({
|
||||
queryKey: qk.adminMe,
|
||||
queryFn: async (): Promise<AdminMe | null> => {
|
||||
const r = await api.GET("/api/admin/me");
|
||||
if (r.response.status === 401 || r.response.status === 403) return null;
|
||||
return unwrap(r);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { username: string; password: string }) =>
|
||||
unwrap(await api.POST("/api/admin/auth/login", { body })),
|
||||
onSuccess: (me) => qc.setQueryData(qk.adminMe, me),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => unwrap(await api.POST("/api/admin/auth/logout")),
|
||||
onSuccess: () => qc.setQueryData(qk.adminMe, null),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUsers(query?: string) {
|
||||
return useQuery({
|
||||
queryKey: [...qk.adminUsers, query ?? ""],
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/admin/users", {
|
||||
params: { query: query ? { query } : {} },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminGroups() {
|
||||
return useQuery({
|
||||
queryKey: qk.adminGroups,
|
||||
queryFn: async () => unwrap(await api.GET("/api/admin/groups")),
|
||||
});
|
||||
}
|
||||
|
||||
export interface AdminMatchFilters {
|
||||
group_id?: number | null;
|
||||
user_id?: number | null;
|
||||
faction_id?: number | null;
|
||||
}
|
||||
|
||||
export function useAdminMatches(filters: AdminMatchFilters = {}) {
|
||||
const query: Record<string, number> = {};
|
||||
if (filters.group_id != null) query.group_id = filters.group_id;
|
||||
if (filters.user_id != null) query.user_id = filters.user_id;
|
||||
if (filters.faction_id != null) query.faction_id = filters.faction_id;
|
||||
return useQuery({
|
||||
queryKey: [...qk.adminMatches, query],
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET("/api/admin/matches", { params: { query } })),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminMatch(matchId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: ["adminMatch", matchId],
|
||||
enabled: matchId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/admin/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { matchId: number; body: MatchUpdate }) =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/admin/matches/{match_id}", {
|
||||
params: { path: { match_id: args.matchId } },
|
||||
body: args.body,
|
||||
}),
|
||||
),
|
||||
onSuccess: (_d, args) => {
|
||||
qc.invalidateQueries({ queryKey: qk.adminMatches });
|
||||
qc.invalidateQueries({ queryKey: ["adminMatch", args.matchId] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminFactions() {
|
||||
return useQuery({
|
||||
queryKey: ["adminFactions"],
|
||||
queryFn: async () => unwrap(await api.GET("/api/admin/factions")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminRenameFaction() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { factionId: number; name_ru: string }) =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/admin/factions/{faction_id}", {
|
||||
params: { path: { faction_id: args.factionId } },
|
||||
body: { name_ru: args.name_ru },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["adminFactions"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminLogs(filters?: { action?: string; entity_type?: string }) {
|
||||
return useQuery({
|
||||
queryKey: [...qk.adminLogs, filters?.action ?? "", filters?.entity_type ?? ""],
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/admin/audit-logs", {
|
||||
params: {
|
||||
query: {
|
||||
...(filters?.action ? { action: filters.action } : {}),
|
||||
...(filters?.entity_type ? { entity_type: filters.entity_type } : {}),
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminDeleteMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: number) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/admin/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminMatches }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAdminUpdateUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { userId: number; nickname?: string; is_active?: boolean }) =>
|
||||
unwrap(
|
||||
await api.PATCH("/api/admin/users/{user_id}", {
|
||||
params: { path: { user_id: args.userId } },
|
||||
body: { nickname: args.nickname, is_active: args.is_active },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminUsers }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { AuthConfig, Me } from "../domain/types";
|
||||
|
||||
export function useMe() {
|
||||
return useQuery({
|
||||
queryKey: qk.me,
|
||||
queryFn: async (): Promise<Me | null> => {
|
||||
const r = await api.GET("/api/users/me");
|
||||
if (r.response.status === 401) return null;
|
||||
return unwrap(r);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAuthConfig() {
|
||||
return useQuery({
|
||||
queryKey: qk.authConfig,
|
||||
queryFn: async (): Promise<AuthConfig> => unwrap(await api.GET("/api/auth/config")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDevUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: qk.devUsers,
|
||||
enabled,
|
||||
queryFn: async () => unwrap(await api.GET("/api/auth/dev/users")),
|
||||
});
|
||||
}
|
||||
|
||||
// DEV-вход по нику (только в деве; в прод-бэкенде эндпоинта нет).
|
||||
export function useStubLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(await api.POST("/api/auth/dev/login", { body: { nickname } })),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Вход через Telegram Login Widget: payload виджета проверяется на сервере по HMAC.
|
||||
export function useTelegramLogin() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (payload: Record<string, unknown>) =>
|
||||
unwrap(await api.POST("/api/auth/telegram", { body: payload as never })),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateDevUser() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(await api.POST("/api/auth/dev/users", { body: { nickname } })),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.devUsers }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLogout() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async () => unwrap(await api.POST("/api/auth/logout")),
|
||||
onSuccess: () => {
|
||||
qc.setQueryData(qk.me, null);
|
||||
qc.clear();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateNickname() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(await api.PATCH("/api/users/me", { body: { nickname } })),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.me });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetActiveGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (group_id: number | null) =>
|
||||
unwrap(await api.PUT("/api/users/me/active-group", { body: { group_id } })),
|
||||
onSuccess: (me) => {
|
||||
qc.setQueryData(qk.me, me);
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
export function useMyGroups() {
|
||||
return useQuery({
|
||||
queryKey: qk.groups,
|
||||
queryFn: async () => unwrap(await api.GET("/api/groups")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroup(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.group(groupId) : ["group", "none"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMembers(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.groupMembers(groupId) : ["group", "none", "members"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/members", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupStats(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.groupStats(groupId) : ["group", "none", "stats"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/stats", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupMatches(groupId: number | null, limit = 20, offset = 0) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? [...qk.groupMatches(groupId), limit, offset] : ["group", "none", "matches"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/matches", {
|
||||
params: { path: { group_id: groupId as number }, query: { limit, offset } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateGroup() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: { name: string; expansion_ids: number[] }) =>
|
||||
unwrap(await api.POST("/api/groups", { body })),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.groups });
|
||||
qc.invalidateQueries({ queryKey: qk.me });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetGroupExpansions(groupId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (expansion_ids: number[]) =>
|
||||
unwrap(
|
||||
await api.PUT("/api/groups/{group_id}/expansions", {
|
||||
params: { path: { group_id: groupId } },
|
||||
body: { expansion_ids },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.group(groupId) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupFactions(groupId) });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMember(groupId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (nickname: string) =>
|
||||
unwrap(
|
||||
await api.POST("/api/groups/{group_id}/members", {
|
||||
params: { path: { group_id: groupId } },
|
||||
body: { nickname },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemoveMember(groupId: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (userId: number) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/groups/{group_id}/members/{user_id}", {
|
||||
params: { path: { group_id: groupId, user_id: userId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: qk.groupMembers(groupId) });
|
||||
qc.invalidateQueries({ queryKey: qk.me });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
import type { FactionRead, MatchCreate, MatchFinish, MatchRead } from "../domain/types";
|
||||
|
||||
export function useMatch(matchId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: matchId ? qk.match(matchId) : ["match", "none"],
|
||||
enabled: matchId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRandomizeFaction() {
|
||||
return useMutation({
|
||||
mutationFn: async (args: { group_id: number; exclude_faction_ids: number[] }): Promise<FactionRead> => {
|
||||
const res = unwrap(await api.POST("/api/matches/randomize-faction", { body: args }));
|
||||
return res.faction;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Этап 1: старт партии (игроки + фракции, без мест). */
|
||||
export function useStartMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body: MatchCreate): Promise<MatchRead> =>
|
||||
unwrap(await api.POST("/api/matches", { body })),
|
||||
onSuccess: (m) => {
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Этап 2: завершение партии (места, причина победы). */
|
||||
export function useFinishMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (args: { matchId: number; body: MatchFinish }): Promise<MatchRead> =>
|
||||
unwrap(
|
||||
await api.POST("/api/matches/{match_id}/finish", {
|
||||
params: { path: { match_id: args.matchId } },
|
||||
body: args.body,
|
||||
}),
|
||||
),
|
||||
onSuccess: (m) => {
|
||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
||||
qc.invalidateQueries({ queryKey: qk.home });
|
||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMatch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (matchId: number) =>
|
||||
unwrap(
|
||||
await api.DELETE("/api/matches/{match_id}", {
|
||||
params: { path: { match_id: matchId } },
|
||||
}),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries(),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
export function useExpansions() {
|
||||
return useQuery({
|
||||
queryKey: qk.expansions,
|
||||
queryFn: async () => unwrap(await api.GET("/api/expansions")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useGroupFactions(groupId: number | null) {
|
||||
return useQuery({
|
||||
queryKey: groupId ? qk.groupFactions(groupId) : ["group", "none", "factions"],
|
||||
enabled: groupId != null,
|
||||
queryFn: async () =>
|
||||
unwrap(
|
||||
await api.GET("/api/groups/{group_id}/factions", {
|
||||
params: { path: { group_id: groupId as number } },
|
||||
}),
|
||||
),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { api, unwrap } from "../api/client";
|
||||
import { qk } from "../api/queryKeys";
|
||||
|
||||
export function useHome() {
|
||||
return useQuery({
|
||||
queryKey: qk.home,
|
||||
queryFn: async () => unwrap(await api.GET("/api/home")),
|
||||
});
|
||||
}
|
||||
|
||||
export function useLeaderboard(limit = 50) {
|
||||
return useQuery({
|
||||
queryKey: [...qk.leaderboard, limit],
|
||||
queryFn: async () =>
|
||||
unwrap(await api.GET("/api/stats/leaderboard", { params: { query: { limit } } })),
|
||||
});
|
||||
}
|
||||
|
||||
export function useMyStats() {
|
||||
return useQuery({
|
||||
queryKey: qk.myStats,
|
||||
queryFn: async () => unwrap(await api.GET("/api/users/me/stats")),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import { App } from "./App";
|
||||
import "./styles/global.css";
|
||||
import "./styles/layout.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
@import "./tokens.css";
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica,
|
||||
Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
a { color: var(--accent-2); text-decoration: none; }
|
||||
|
||||
h1, h2, h3 { margin: 0 0 8px; line-height: 1.2; }
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 15px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input, select, textarea {
|
||||
font: inherit;
|
||||
width: 100%;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 12px;
|
||||
outline: none;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
background: var(--surface-2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 10px 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.btn-ghost { background: transparent; }
|
||||
.btn-danger { background: transparent; color: var(--danger); border-color: var(--danger); }
|
||||
.btn[disabled] { opacity: 0.5; pointer-events: none; }
|
||||
|
||||
.row { display: flex; align-items: center; gap: 10px; }
|
||||
.row-between { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.stack { display: flex; flex-direction: column; gap: 10px; }
|
||||
.muted { color: var(--text-muted); }
|
||||
.small { font-size: 13px; }
|
||||
.center { text-align: center; }
|
||||
|
||||
.label { font-size: 13px; color: var(--text-muted); margin-bottom: 4px; display: block; }
|
||||
.field { margin-bottom: 12px; }
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.error-text { color: var(--danger); font-size: 13px; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: calc(var(--bottombar-height) + 80px);
|
||||
transform: translateX(-50%);
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
padding: 10px 16px;
|
||||
border-radius: 999px;
|
||||
z-index: 100;
|
||||
max-width: 90%;
|
||||
text-align: center;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
.app-shell {
|
||||
position: relative;
|
||||
max-width: var(--app-max-w);
|
||||
margin: 0 auto;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg);
|
||||
box-shadow: 0 0 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: calc(var(--safe-top) + 12px) 16px 12px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.top-bar .title { font-weight: 700; font-size: 17px; }
|
||||
|
||||
.app-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 14px;
|
||||
padding-bottom: calc(var(--bottombar-height) + var(--fab-height) + var(--safe-bottom) + 28px);
|
||||
}
|
||||
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
max-width: var(--app-max-w);
|
||||
height: calc(var(--bottombar-height) + var(--safe-bottom));
|
||||
padding-bottom: var(--safe-bottom);
|
||||
background: var(--surface);
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.bottom-bar .slot {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
color: var(--text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
}
|
||||
.bottom-bar .slot .name { color: var(--text); font-weight: 600; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.bottom-bar .spacer { width: var(--fab-width); flex: 0 0 var(--fab-width); }
|
||||
|
||||
.fab {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
bottom: calc(var(--bottombar-height) + var(--safe-bottom) - 2px);
|
||||
width: var(--fab-width);
|
||||
height: var(--fab-height);
|
||||
border: 1px solid var(--accent);
|
||||
border-bottom: none;
|
||||
border-top-left-radius: var(--fab-width);
|
||||
border-top-right-radius: var(--fab-width);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
box-shadow: 0 -4px 14px rgba(224, 83, 61, 0.45);
|
||||
}
|
||||
.fab svg { margin-top: 6px; }
|
||||
.fab[aria-disabled="true"] {
|
||||
background: var(--surface-2);
|
||||
border-color: var(--border);
|
||||
color: var(--text-muted);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* Таблицы / списки лидерборда */
|
||||
.lb-row {
|
||||
display: grid;
|
||||
grid-template-columns: 28px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.lb-row:last-child { border-bottom: none; }
|
||||
.lb-rank { font-weight: 700; color: var(--text-muted); text-align: center; }
|
||||
.lb-rank.top { color: var(--gold); }
|
||||
.lb-metrics { display: flex; gap: 12px; font-size: 13px; color: var(--text-muted); }
|
||||
.lb-score { font-weight: 700; color: var(--accent-2); }
|
||||
|
||||
.participant-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
margin-bottom: 10px;
|
||||
background: var(--surface-2);
|
||||
}
|
||||
.place-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
}
|
||||
.place-badge.first { background: var(--gold); color: #1a1a1a; border-color: var(--gold); }
|
||||
@@ -0,0 +1,27 @@
|
||||
:root {
|
||||
/* Палитра (тёмная тема) */
|
||||
--bg: #0f1115;
|
||||
--surface: #171a21;
|
||||
--surface-2: #1f242e;
|
||||
--border: #2a2f3a;
|
||||
--text: #e8eaed;
|
||||
--text-muted: #9aa0aa;
|
||||
--accent: #e0533d;
|
||||
--accent-2: #f0a04b;
|
||||
--success: #4caf7d;
|
||||
--danger: #e0533d;
|
||||
--gold: #f0c14b;
|
||||
|
||||
/* Размеры */
|
||||
--app-max-w: 480px;
|
||||
--bottombar-height: 64px;
|
||||
--fab-width: 88px;
|
||||
--fab-height: 44px;
|
||||
--radius: 14px;
|
||||
--radius-sm: 10px;
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
|
||||
/* Брейкпоинты (для справки) */
|
||||
--bp-lg: 768px;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// Dev: SPA на :5173 проксирует /api на бэкенд :8000 — пути одинаковы в dev и prod.
|
||||
// Prod: dist отдаётся самим FastAPI (single-origin), прокси не нужен.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
// Слушаем на всех интерфейсах (IPv4 + IPv6), чтобы и localhost, и 127.0.0.1
|
||||
// работали без таймаута IPv4→IPv6 на Windows. Внимание: dev-сервер при этом
|
||||
// доступен и из локальной сети (для разработки на своей машине это нормально).
|
||||
host: true,
|
||||
port: 5173,
|
||||
proxy: {
|
||||
"/api": {
|
||||
// Явный IPv4: на Windows "localhost" может резолвиться в IPv6 ::1,
|
||||
// куда uvicorn не слушает → задержка ~1 с на установку соединения.
|
||||
target: "http://127.0.0.1:8000",
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: "dist",
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user