UI: unified design system, light/dark themes, redesigned pages and player UI

Tokens and components in styles.css, shared Ui (dialogs, toasts), icons,
bottom tab bar on mobile, keyboard/gamepad navigation, library shelf with
filters and conversion badges, hero game page, restyled player screens.
Games list now returns per-game version counts. Adds docs/UI_DESIGN.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 03:43:53 +03:00
1 parent 51a7e77e56
commit 9ee3e1ff6e
27 files changed
+2399 -508

No files matched your search

+51 -30
View File
@@ -1,16 +1,22 @@
import { useState, type FormEvent } from "react";
import { api, type Me } from "../api";
import { useAuth } from "../auth";
import { ErrorNote, PageHeader } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
import { ThemeButton } from "../ui/ThemeButton";
export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
const { me, setMe, logout } = useAuth();
const ui = useUi();
const needUsername = !!me?.must_change_username;
const [username, setUsername] = useState(needUsername ? "" : me?.username ?? "");
const [current, setCurrent] = useState("");
const [password, setPassword] = useState("");
const [repeat, setRepeat] = useState("");
const [error, setError] = useState("");
const [done, setDone] = useState(false);
const mismatch = repeat.length > 0 && password !== repeat;
const submit = async (e: FormEvent) => {
e.preventDefault();
@@ -26,7 +32,7 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
new_username: username.trim() || null,
});
setMe(updated);
setDone(true);
if (!forced) ui.toast("Данные сохранены", "ok");
setCurrent("");
setPassword("");
setRepeat("");
@@ -36,39 +42,54 @@ export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
};
const form = (
<form className="card auth-card" onSubmit={submit}>
<h1>{forced ? "Смена учётных данных" : "Аккаунт"}</h1>
{forced && (
<p className="muted">
{needUsername
? "Это первый вход администратора. Задайте собственный логин и пароль."
: "Пароль был выдан администратором. Задайте свой пароль."}
</p>
)}
<label>
Логин{needUsername && " (новый)"}
<form className={forced ? "card auth-card" : "card form"} onSubmit={submit}>
{forced ? (
<div>
<div className="brand"><span className="brand-mark"><Icon name="key" size={20} /></span>Смена данных</div>
<p className="muted" style={{ marginTop: "0.6rem" }}>
{needUsername
? "Это первый вход администратора. Задайте собственный логин и пароль."
: "Пароль был выдан администратором. Задайте свой пароль."}
</p>
</div>
) : null}
<label className="field">
<span>Логин{needUsername && " (новый)"}</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Текущий пароль
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)}
autoComplete="current-password" />
<label className="field">
<span>Текущий пароль</span>
<input type="password" value={current} onChange={(e) => setCurrent(e.target.value)} autoComplete="current-password" />
</label>
<label>
Новый пароль (от 8 символов)
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password" />
<label className="field">
<span>Новый пароль</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="new-password" />
<span className="hint">Минимум 8 символов</span>
</label>
<label>
Повторите пароль
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)}
autoComplete="new-password" />
<label className="field">
<span>Повторите пароль</span>
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)} autoComplete="new-password"
aria-invalid={mismatch} style={mismatch ? { borderColor: "var(--danger)" } : undefined} />
{mismatch && <span className="hint" style={{ color: "var(--danger)" }}>Пароли не совпадают</span>}
</label>
{error && <div className="error">{error}</div>}
{done && !forced && <div className="ok">Сохранено</div>}
<button className="primary" disabled={!current || password.length < 8}>Сохранить</button>
{forced && <button type="button" className="link" onClick={() => void logout()}>Выйти</button>}
{error && <ErrorNote>{error}</ErrorNote>}
<button className="btn btn-primary btn-lg" disabled={!current || password.length < 8 || mismatch}>Сохранить</button>
{forced && <button type="button" className="btn btn-ghost" onClick={() => void logout()}>Выйти</button>}
</form>
);
return forced ? <div className="center">{form}</div> : <div className="page narrow">{form}</div>;
if (forced) {
return (
<div className="auth-wrap">
<ThemeButton className="auth-theme" />
{form}
</div>
);
}
return (
<div className="page narrow">
<PageHeader title="Аккаунт" subtitle={<>Вы вошли как <b>{me?.username}</b> · {me?.role.name}</>} />
{form}
</div>
);
}
+161 -86
View File
@@ -2,19 +2,30 @@ import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { api, formatSize, uploadRaw, type GameDetail, type Version } from "../api";
import { useAuth } from "../auth";
import { getRecent, pushRecent } from "../recent";
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
const STATUS: Record<Version["status"], string> = {
uploading: "загружается",
queued: "в очереди",
converting: "конвертируется",
importing: "импортируется",
ready: "готова",
failed: "ошибка",
uploading: "Загружается",
queued: "В очереди",
converting: "Конвертируется",
importing: "Импортируется",
ready: "Готова",
failed: "Ошибка",
};
const BUSY: Version["status"][] = ["uploading", "queued", "converting", "importing"];
function StatusBadge({ status }: { status: Version["status"] }) {
const cls = status === "ready" ? "badge-ok" : status === "failed" ? "badge-error" : "badge-warn busy";
return <span className={`badge ${cls}`}>{STATUS[status]}</span>;
}
export function GamePage() {
const { gameId } = useParams();
const navigate = useNavigate();
const ui = useUi();
const { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState("");
@@ -33,43 +44,75 @@ export function GamePage() {
}, [load]);
// poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status));
const busy = game?.versions.some((v) => BUSY.includes(v.status));
useEffect(() => {
if (!busy) return;
const t = setInterval(() => void load(), 2000);
return () => clearInterval(t);
}, [busy, load]);
if (error) return <div className="page"><div className="error">{error}</div></div>;
if (!game) return <div className="page muted">Загрузка…</div>;
if (error && !game) return <div className="page"><ErrorNote>{error}</ErrorNote></div>;
if (!game) return <div className="page"><Spinner /></div>;
// the backend also lets uploaders edit only games they created
const canEdit = can("games.edit") || can("games.upload");
const run = async (fn: () => Promise<unknown>) => {
setError("");
const run = async (fn: () => Promise<unknown>, okText?: string) => {
try {
await fn();
if (okText) ui.toast(okText, "ok");
await load();
} catch (e) {
setError((e as Error).message);
ui.toast((e as Error).message, "error");
}
};
const fork = (v: Version) => {
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
const play = (v: Version) => {
pushRecent({ gameId: game.id, versionId: v.id, title: game.title, coverHash: game.cover_hash });
navigate(`/play/${v.id}`);
};
const fork = async (v: Version) => {
const name = await ui.prompt({
title: "Новая версия (форк)",
text: "Форк делит с оригиналом все ассеты и почти не занимает места.",
label: "Название версии", initial: `${v.name} (${me?.username})`, confirmLabel: "Создать форк",
});
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
};
const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
const uploadVersion = (file: File) => {
const name = prompt("Название версии", file.name);
const uploadVersion = async (file: File) => {
const name = await ui.prompt({
title: "Загрузить новую версию", label: "Название версии", initial: file.name.replace(/\.[^.]+$/, ""),
confirmLabel: "Загрузить",
});
if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {}));
file, () => {}), "Архив загружен, началась конвертация");
};
const removeVersion = async (v: Version) => {
if (await ui.confirm({ title: `Удалить версию «${v.name}»?`, text: "Сохранения игроков этой версии тоже будут удалены.", danger: true }))
void run(() => api.del(`/api/versions/${v.id}`), "Версия удалена");
};
const removeGame = async () => {
if (!(await ui.confirm({ title: `Удалить «${game.title}»?`, text: "Игра будет удалена вместе со всеми версиями и сохранениями.", danger: true }))) return;
try {
await api.del(`/api/games/${game.id}`);
ui.toast("Игра удалена", "ok");
navigate("/");
} catch (e) {
ui.toast((e as Error).message, "error");
}
};
const ready = game.versions.filter((v) => v.status === "ready");
const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId;
const playVersion = ready.find((v) => v.id === lastId) ?? ready.find((v) => v.parent_id === null) ?? ready[0];
const byParent = new Map<number | null, Version[]>();
for (const v of game.versions) {
const parent = game.versions.some((p) => p.id === v.parent_id) ? v.parent_id : null;
@@ -77,60 +120,76 @@ export function GamePage() {
}
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id}
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
onDelete={() => void removeVersion(v)} />,
...renderTree(v.id, depth + 1),
]);
return (
<div className="page">
<Link to="/" className="muted">← Библиотека</Link>
<div className="game-head">
<div className="cover big">
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
</div>
<div className="game-info">
{editing ? (
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
) : (
<>
<h1>{game.title}</h1>
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="actions">
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
{canEdit && (
<label className="button">
Обложка
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
</label>
)}
{can("games.upload") && (
<label className="button">
Загрузить ещё архив
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
</label>
)}
{can("games.delete") && (
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
)}
</div>
</>
)}
<div className="hero">
{game.cover_hash && <div className="hero-bg" style={{ backgroundImage: `url(/blobs/${game.cover_hash})` }} />}
<Link to="/" className="back-link"><Icon name="back" size={18} />Библиотека</Link>
<div className="hero-inner">
<Cover hash={game.cover_hash} title={game.title} className="big" />
<div className="hero-info">
{editing ? (
<GameEditor game={game} onDone={(changed) => { setEditing(false); if (changed) { ui.toast("Сохранено", "ok"); void load(); } }} />
) : (
<>
<h1>{game.title}</h1>
{game.tags.length > 0 && <div className="tags">{game.tags.map((t) => <Link key={t} to={`/?tag=${encodeURIComponent(t)}`} className="tag">{t}</Link>)}</div>}
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="hero-actions">
{playVersion ? (
<button className="btn btn-primary btn-lg" onClick={() => play(playVersion)}>
<Icon name="play" size={22} />Играть
</button>
) : (
<span className="badge badge-warn busy">{busy ? "Идёт конвертация…" : "Нет готовых версий"}</span>
)}
{playVersion && game.versions.length > 1 && <span className="muted small">версия «{playVersion.name}»</span>}
</div>
<div className="actions">
{canEdit && <button className="btn btn-sm" onClick={() => setEditing(true)}><Icon name="edit" size={16} />Редактировать</button>}
{canEdit && (
<label className="btn btn-sm">
<Icon name="image" size={16} />Обложка
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
</label>
)}
{can("games.delete") && (
<button className="btn btn-sm btn-danger" onClick={() => void removeGame()}><Icon name="trash" size={16} />Удалить игру</button>
)}
</div>
</>
)}
</div>
</div>
</div>
{error && <div className="error">{error}</div>}
<h2>Версии</h2>
<div className="section-title" style={{ marginTop: "var(--space-5)" }}>
<h2>Версии</h2>
<span className="count">{game.versions.length}</span>
<span className="grow" />
{can("games.upload") && (
<label className="btn btn-sm">
<Icon name="upload" size={16} />Загрузить ещё архив
<input type="file" hidden onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadVersion(f); }} />
</label>
)}
</div>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="versions">{renderTree(null, 0)}</div>
</div>
);
}
function VersionRow(props: {
v: Version; depth: number; canManage: boolean; canFork: boolean;
v: Version; depth: number; current: boolean; canManage: boolean; canFork: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
}) {
const { v } = props;
@@ -139,25 +198,38 @@ function VersionRow(props: {
if (report !== undefined) setReport(undefined);
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
};
const inProgress = BUSY.includes(v.status);
return (
<div className="version" style={{ marginLeft: props.depth * 24 }}>
<div className={`version ${props.current ? "main" : ""}`} style={{ marginLeft: Math.min(props.depth, 4) * 22 }}>
<div className="version-main">
<div>
<b>{props.depth > 0 && "↳ "}{v.name}</b>
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
<div className="muted small">
<div className="version-title">
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
<b>{v.name}</b>
<StatusBadge status={v.status} />
</div>
<div className="muted small version-meta">
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
{v.progress && ` · ${v.progress}`}
</div>
</div>
<div className="actions">
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>}
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
{(v.status === "ready" || v.status === "failed") && (
<button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
<Icon name="file" size={16} />Отчёт
</button>
)}
{v.status === "failed" && props.canManage && <button className="btn btn-sm" onClick={props.onRetry}><Icon name="retry" size={16} />Повторить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" onClick={props.onDelete} title="Удалить версию" aria-label="Удалить версию">
<Icon name="trash" size={16} />
</button>
)}
</div>
</div>
{inProgress && <div className="version-progress progress"><div style={{ width: "100%", opacity: 0.5 }} /></div>}
{report !== undefined && <Report report={report} />}
</div>
);
@@ -165,17 +237,20 @@ function VersionRow(props: {
function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report error">{report.error}</div>;
if (report.error) return <div className="report"><ErrorNote>{report.error}</ErrorNote></div>;
const stat = (value: string | number, label: string) => (
<div className="stat" key={label}><b>{value}</b><span>{label}</span></div>
);
return (
<div className="report">
<div className="report-stats">
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
<span>Меток: {report.labels}</span>
<span>Реплик: {report.stats?.say ?? 0}</span>
<span>Меню: {report.stats?.menu ?? 0}</span>
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
<span>Время: {report.durationSec} с</span>
{stat(`${report.scripts} / ${report.decompiled}`, "скриптов / декомпилировано")}
{stat(report.labels, "меток")}
{stat(report.stats?.say ?? 0, "реплик")}
{stat(report.stats?.menu ?? 0, "меню выбора")}
{stat(`${report.media?.image ?? 0} · ${report.media?.audio ?? 0} · ${report.media?.video ?? 0}`, "картинок · аудио · видео")}
{stat(`${formatSize(report.sourceSize)} → ${formatSize(report.outputSize)}`, "размер")}
{stat(`${report.durationSec} с`, "время конвертации")}
</div>
{report.missingFiles?.length > 0 && (
<details>
@@ -203,7 +278,7 @@ function Report({ report }: { report: Record<string, any> | null }) {
);
}
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
function GameEditor({ game, onDone }: { game: GameDetail; onDone: (changed: boolean) => void }) {
const [title, setTitle] = useState(game.title);
const [description, setDescription] = useState(game.description);
const [tags, setTags] = useState(game.tags.join(", "));
@@ -213,21 +288,21 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void })
await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
onDone();
onDone(true);
} catch (e) {
setError((e as Error).message);
}
};
return (
<div className="editor">
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
{error && <div className="error">{error}</div>}
<form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
<label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label>
<label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="actions">
<button className="primary" onClick={() => void save()}>Сохранить</button>
<button onClick={onDone}>Отмена</button>
<button className="btn btn-primary" disabled={!title.trim()}>Сохранить</button>
<button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button>
</div>
</div>
</form>
);
}
+158 -22
View File
@@ -1,52 +1,180 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { api, type Game } from "../api";
import { useAuth } from "../auth";
import { dropRecent, getRecent, type RecentPlay } from "../recent";
import { Cover, EmptyState, ErrorNote, PageHeader } from "../ui/Bits";
import { Icon } from "../ui/Icon";
type Sort = "new" | "title" | "recent";
const SORTS: [Sort, string][] = [["new", "Новые"], ["title", "По названию"], ["recent", "Недавно играл"]];
function StatusBadge({ g }: { g: Game }) {
if (g.pending_versions) return <span className="badge badge-warn busy">Конвертируется</span>;
if (g.failed_versions && !g.ready_versions) return <span className="badge badge-error">Ошибка</span>;
return null;
}
export function Library() {
const { can } = useAuth();
const [params, setParams] = useSearchParams();
const q = params.get("q") ?? "";
const tag = params.get("tag") ?? "";
const [query, setQuery] = useState(q);
const [sort, setSort] = useState<Sort>(() => (localStorage.getItem("pg-sort") as Sort) || "new");
const [games, setGames] = useState<Game[] | null>(null);
const [error, setError] = useState("");
const [recent, setRecent] = useState<RecentPlay[]>(getRecent);
useEffect(() => {
const t = setTimeout(() => setParams(query ? { q: query } : {}, { replace: true }), 250);
const t = setTimeout(() => {
const next = new URLSearchParams(params);
if (query) next.set("q", query);
else next.delete("q");
setParams(next, { replace: true });
}, 250);
return () => clearTimeout(t);
}, [query, setParams]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const hasPending = games?.some((g) => g.pending_versions) ?? false;
useEffect(() => {
let cancelled = false;
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
.then((g) => !cancelled && setGames(g))
.catch((e) => !cancelled && setError(e.message));
const load = () =>
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
.then((g) => !cancelled && (setGames(g), setError("")))
.catch((e) => !cancelled && setError(e.message));
void load();
// keep conversion badges fresh while something is being converted
const timer = hasPending ? setInterval(() => void load(), 4000) : undefined;
return () => {
cancelled = true;
if (timer) clearInterval(timer);
};
}, [q]);
}, [q, hasPending]);
const changeSort = (s: Sort) => {
setSort(s);
try {
localStorage.setItem("pg-sort", s);
} catch {
/* storage unavailable */
}
};
const setTag = (t: string) => {
const next = new URLSearchParams(params);
if (t && t !== tag) next.set("tag", t);
else next.delete("tag");
setParams(next, { replace: true });
};
const allTags = useMemo(() => {
const counts = new Map<string, number>();
for (const g of games ?? []) for (const t of g.tags) counts.set(t, (counts.get(t) ?? 0) + 1);
return [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 24);
}, [games]);
const shown = useMemo(() => {
const list = (games ?? []).filter((g) => !tag || g.tags.includes(tag));
const playedAt = new Map(recent.map((r) => [r.gameId, r.at]));
return [...list].sort((a, b) => {
if (sort === "title") return a.title.localeCompare(b.title, "ru");
if (sort === "recent") return (playedAt.get(b.id) ?? 0) - (playedAt.get(a.id) ?? 0) || a.title.localeCompare(b.title, "ru");
return b.created_at.localeCompare(a.created_at);
});
}, [games, tag, sort, recent]);
// only show "continue" for games that still exist in the library
const known = new Set((games ?? []).map((g) => g.id));
const shelf = games ? recent.filter((r) => known.has(r.gameId)).slice(0, 8) : [];
const filtering = !!(q || tag);
return (
<div className="page">
<div className="search">
<input placeholder="Поиск по названию, описанию или тегу…" value={query}
onChange={(e) => setQuery(e.target.value)} autoFocus />
<PageHeader
title="Библиотека"
subtitle={games ? `${games.length} ${plural(games.length, "игра", "игры", "игр")}` : undefined}
actions={can("games.upload") && (
<Link to="/games/new" className="btn btn-primary"><Icon name="plus" size={18} />Загрузить игру</Link>
)}
/>
{shelf.length > 0 && !filtering && (
<section aria-label="Продолжить игру">
<div className="section-title"><Icon name="clock" size={20} /><h2>Продолжить</h2></div>
<div className="shelf">
{shelf.map((r) => (
<div key={r.gameId} className="recent-card">
<Link to={`/play/${r.versionId}`} aria-label={`Продолжить: ${r.title}`}>
<Cover hash={r.coverHash} title={r.title} />
<div className="recent-body">
<b>{r.title}</b>
<span className="recent-play"><Icon name="play" size={16} /></span>
</div>
</Link>
<button className="recent-x" title="Убрать из списка" aria-label="Убрать из списка"
onClick={() => { dropRecent(r.gameId); setRecent(getRecent()); }}>
<Icon name="x" size={14} />
</button>
</div>
))}
</div>
</section>
)}
<div className="toolbar" style={{ marginTop: shelf.length && !filtering ? "var(--space-5)" : 0 }}>
<div className="search">
<Icon name="search" size={20} />
<input type="search" placeholder="Поиск по названию, описанию или тегу…" value={query}
onChange={(e) => setQuery(e.target.value)} aria-label="Поиск" />
</div>
<select value={sort} onChange={(e) => changeSort(e.target.value as Sort)} aria-label="Сортировка"
style={{ height: "2.9rem" }}>
{SORTS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
</select>
</div>
{error && <div className="error">{error}</div>}
{games === null ? (
<p className="muted">Загрузка…</p>
) : games.length === 0 ? (
<p className="muted">{q ? "Ничего не найдено." : "В библиотеке пока нет игр."}</p>
{allTags.length > 0 && (
<div className="chips" style={{ marginBottom: "var(--space-4)" }} role="group" aria-label="Теги">
<button className={`chip ${!tag ? "on" : ""}`} onClick={() => setTag("")}>Все</button>
{allTags.map(([t, n]) => (
<button key={t} className={`chip ${tag === t ? "on" : ""}`} onClick={() => setTag(t)}>
{t} <span className="muted small">{n}</span>
</button>
))}
</div>
)}
{error && <ErrorNote>{error}</ErrorNote>}
{games === null && !error ? (
<div className="grid" aria-busy="true">
{Array.from({ length: 8 }, (_, i) => <div key={i} className="skeleton" style={{ aspectRatio: "16 / 13" }} />)}
</div>
) : shown.length === 0 && games ? (
filtering ? (
<EmptyState icon="search" title="Ничего не найдено" text="Попробуйте изменить запрос или сбросить фильтр по тегу."
action={<button className="btn" onClick={() => { setQuery(""); setParams({}, { replace: true }); }}>Сбросить</button>} />
) : (
<EmptyState icon="library" title="В библиотеке пока нет игр"
text={can("games.upload") ? "Загрузите архив с игрой на Ren'Py — сервер сконвертирует её в формат PGVN." : "Попросите администратора добавить игры."}
action={can("games.upload") ? <Link to="/games/new" className="btn btn-primary"><Icon name="upload" size={18} />Загрузить игру</Link> : undefined} />
)
) : (
<div className="grid">
{games.map((g) => (
{shown.map((g) => (
<Link key={g.id} to={`/games/${g.id}`} className="game-card">
<div className="cover">
{g.cover_hash ? <img src={`/blobs/${g.cover_hash}`} alt="" loading="lazy" /> : <span>{g.title[0]}</span>}
<div className="game-card-media">
<Cover hash={g.cover_hash} title={g.title} />
<StatusBadge g={g} />
</div>
<div className="game-card-body">
<b>{g.title}</b>
<div className="tags">
{g.tags.slice(0, 4).map((t) => <span key={t} className="tag">{t}</span>)}
</div>
<span className="game-card-title">{g.title}</span>
{g.tags.length > 0 && (
<div className="tags">{g.tags.slice(0, 3).map((t) => <span key={t} className="tag">{t}</span>)}</div>
)}
{!!g.ready_versions && g.ready_versions > 1 && (
<span className="game-card-meta">{g.ready_versions} {plural(g.ready_versions, "версия", "версии", "версий")}</span>
)}
</div>
</Link>
))}
@@ -55,3 +183,11 @@ export function Library() {
</div>
);
}
export function plural(n: number, one: string, few: string, many: string): string {
const m10 = n % 10;
const m100 = n % 100;
if (m10 === 1 && m100 !== 11) return one;
if (m10 >= 2 && m10 <= 4 && (m100 < 10 || m100 >= 20)) return few;
return many;
}
+17 -9
View File
@@ -1,6 +1,9 @@
import { useState, type FormEvent } from "react";
import { api, type Me } from "../api";
import { useAuth } from "../auth";
import { ErrorNote } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { ThemeButton } from "../ui/ThemeButton";
export function Login() {
const { setMe } = useAuth();
@@ -23,21 +26,26 @@ export function Login() {
};
return (
<div className="center">
<div className="auth-wrap">
<ThemeButton className="auth-theme" />
<form className="card auth-card" onSubmit={submit}>
<h1>PGames</h1>
<p className="muted">Локальная библиотека визуальных новелл</p>
<label>
Логин
<div>
<div className="brand"><span className="brand-mark"><Icon name="play" size={20} /></span>PGames</div>
<p className="muted">Локальная библиотека визуальных новелл</p>
</div>
<label className="field">
<span>Логин</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" />
</label>
<label>
Пароль
<label className="field">
<span>Пароль</span>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password" />
</label>
{error && <div className="error">{error}</div>}
<button className="primary" disabled={busy || !username || !password}>Войти</button>
{error && <ErrorNote>{error}</ErrorNote>}
<button className="btn btn-primary btn-lg btn-block" disabled={busy || !username || !password}>
{busy ? "Входим…" : "Войти"}
</button>
</form>
</div>
);
+74 -29
View File
@@ -1,6 +1,10 @@
import { useState, type FormEvent } from "react";
import { useState, type DragEvent, type FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { api, uploadRaw, type Game } from "../api";
import { api, formatSize, uploadRaw, type Game } from "../api";
import { ErrorNote, PageHeader } from "../ui/Bits";
import { Icon } from "../ui/Icon";
const ARCHIVE = /\.(zip|7z|rar|tar|gz|tgz|bz2|xz)$/i;
export function NewGame() {
const navigate = useNavigate();
@@ -8,9 +12,25 @@ export function NewGame() {
const [description, setDescription] = useState("");
const [tags, setTags] = useState("");
const [file, setFile] = useState<File | null>(null);
const [over, setOver] = useState(false);
const [progress, setProgress] = useState<number | null>(null);
const [error, setError] = useState("");
const pick = (f: File | null | undefined) => {
if (!f) return;
if (!ARCHIVE.test(f.name)) {
setError("Нужен архив: zip, 7z, rar или tar");
return;
}
setError("");
setFile(f);
};
const onDrop = (e: DragEvent) => {
e.preventDefault();
setOver(false);
pick(e.dataTransfer.files[0]);
};
const submit = async (e: FormEvent) => {
e.preventDefault();
if (!file) return;
@@ -34,38 +54,63 @@ export function NewGame() {
}
};
const uploading = progress !== null;
const pct = Math.round((progress ?? 0) * 100);
return (
<div className="page narrow">
<form className="card" onSubmit={submit}>
<h1>Новая игра</h1>
<p className="muted">
Загрузите архив с игрой Ren'Py (zip, 7z, rar, tar). После загрузки сервер распакует его,
сконвертирует скрипты и ассеты в формат PGVN и удалит исходники.
</p>
<label>
Архив
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz"
onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
<div className="page medium">
<PageHeader title="Новая игра"
subtitle="Загрузите архив с игрой на Ren'Py. Сервер распакует его, сконвертирует скрипты и ассеты в формат PGVN и удалит исходники." />
<form className="card form" onSubmit={submit}>
<label className={`dropzone ${over ? "over" : ""} ${file ? "has-file" : ""}`}
onDragOver={(e) => { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)} onDrop={onDrop}>
<input type="file" accept=".zip,.7z,.rar,.tar,.gz,.tgz,.bz2,.xz" disabled={uploading}
onChange={(e) => pick(e.target.files?.[0])} />
<Icon name={file ? "file" : "upload"} size={34} className="dz-icon" />
{file ? (
<>
<b>{file.name}</b>
<span className="muted">{formatSize(file.size)} · нажмите, чтобы выбрать другой</span>
</>
) : (
<>
<b>Перетащите архив сюда или нажмите, чтобы выбрать</b>
<span className="muted small">zip, 7z, rar, tar</span>
</>
)}
</label>
<label>
Название
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
</label>
<label>
Описание
<div className="form-row">
<label className="field">
<span>Название</span>
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
</label>
<label className="field">
<span>Теги</span>
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" />
<span className="hint">Через запятую</span>
</label>
</div>
<label className="field">
<span>Описание</span>
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={4} />
</label>
<label>
Теги через запятую
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="романтика, драма" />
</label>
{error && <div className="error">{error}</div>}
{progress !== null && (
<div className="progress"><div style={{ width: `${Math.round(progress * 100)}%` }} /></div>
{error && <ErrorNote>{error}</ErrorNote>}
{uploading && (
<div>
<div className="progress"><div style={{ width: `${pct}%` }} /></div>
<p className="muted small" style={{ marginTop: "0.4rem" }}>
{pct < 100 ? `Загрузка архива… ${pct}%` : "Архив загружен, передаём на конвертацию…"}
</p>
</div>
)}
<button className="primary" disabled={!file || progress !== null}>
{progress !== null ? `Загрузка ${Math.round(progress * 100)}%` : "Загрузить"}
</button>
<div className="actions">
<button className="btn btn-primary btn-lg" disabled={!file || uploading}>
<Icon name="upload" size={20} />{uploading ? `Загрузка ${pct}%` : "Загрузить"}
</button>
{!uploading && <button type="button" className="btn btn-ghost" onClick={() => navigate(-1)}>Отмена</button>}
</div>
</form>
</div>
);
+13 -2
View File
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import type { Engine } from "../player/engine";
import { Icon } from "../ui/Icon";
export function Play() {
const { versionId } = useParams();
@@ -33,8 +34,18 @@ export function Play() {
<div ref={host} className="play-host" />
{(status || error) && (
<div className="play-status">
{error ? <span className="error">{error}</span> : status}
{error && <button onClick={() => navigate(-1)}>Назад</button>}
{error ? (
<>
<Icon name="alert" size={36} />
<span className="error-text">{error}</span>
<button className="btn" onClick={() => navigate(-1)}><Icon name="back" size={18} />Назад</button>
</>
) : (
<>
<span className="spinner" />
{status}
</>
)}
</div>
)}
</div>
+66 -40
View File
@@ -1,8 +1,12 @@
import { useCallback, useEffect, useState } from "react";
import { api, type Role } from "../api";
import { ErrorNote, PageHeader, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
export function Roles() {
const [roles, setRoles] = useState<Role[]>([]);
const ui = useUi();
const [roles, setRoles] = useState<Role[] | null>(null);
const [perms, setPerms] = useState<Record<string, string>>({});
const [newName, setNewName] = useState("");
const [error, setError] = useState("");
@@ -18,13 +22,13 @@ export function Roles() {
load().catch((e) => setError(e.message));
}, [load]);
const run = async (fn: () => Promise<unknown>) => {
setError("");
const run = async (fn: () => Promise<unknown>, okText?: string) => {
try {
await fn();
if (okText) ui.toast(okText, "ok");
await load();
} catch (e) {
setError((e as Error).message);
ui.toast((e as Error).message, "error");
}
};
@@ -35,48 +39,70 @@ export function Roles() {
void run(() => api.put(`/api/roles/${role.id}`, { name: role.name, permissions: next }));
};
const remove = async (r: Role) => {
if (await ui.confirm({ title: `Удалить роль «${r.name}»?`, danger: true }))
void run(() => api.del(`/api/roles/${r.id}`), "Роль удалена");
};
return (
<div className="page">
<h1>Роли и права</h1>
<p className="muted">Роль — набор прав. Роль admin всегда имеет все права.</p>
{error && <div className="error">{error}</div>}
<div className="table-wrap">
<table className="table roles">
<thead>
<tr>
<th>Право</th>
{roles.map((r) => (
<th key={r.id}>
{r.name}
{!r.is_system && (
<button className="link danger" title="Удалить роль"
onClick={() => confirm(`Удалить роль ${r.name}?`) && void run(() => api.del(`/api/roles/${r.id}`))}>✕</button>
)}
</th>
))}
</tr>
</thead>
<tbody>
{Object.entries(perms).map(([code, label]) => (
<tr key={code}>
<td>{label} <span className="muted small">{code}</span></td>
<div className="page medium">
<PageHeader title="Роли и права" subtitle="Роль — набор прав. Роль admin всегда имеет все права. Изменения применяются сразу." />
{error && <ErrorNote>{error}</ErrorNote>}
{roles === null ? <Spinner /> : (
<div className="table-wrap">
<table className="table roles">
<thead>
<tr>
<th>Право</th>
{roles.map((r) => (
<td key={r.id} className="center-cell">
<input type="checkbox" checked={r.permissions.includes(code)} disabled={r.name === "admin"}
onChange={() => toggle(r, code)} />
</td>
<th key={r.id}>
<span className="roles-th">
{r.name}
{!r.is_system && (
<button className="btn btn-sm btn-ghost btn-icon btn-danger" title={`Удалить роль ${r.name}`}
aria-label={`Удалить роль ${r.name}`} onClick={() => void remove(r)}>
<Icon name="x" size={14} />
</button>
)}
</span>
</th>
))}
</tr>
))}
</tbody>
</table>
</div>
<form className="card inline-form" onSubmit={(e) => {
</thead>
<tbody>
{Object.entries(perms).map(([code, label]) => (
<tr key={code}>
<td>{label}<small>{code}</small></td>
{roles.map((r) => (
<td key={r.id}>
<label className="switch" title={`${r.name}: ${label}`}>
<input type="checkbox" checked={r.permissions.includes(code) || r.name === "admin"}
disabled={r.name === "admin"} onChange={() => toggle(r, code)}
aria-label={`${r.name}: ${label}`} />
<i />
</label>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
<form className="card form" style={{ marginTop: "var(--space-5)" }} onSubmit={(e) => {
e.preventDefault();
void run(() => api.post("/api/roles", { name: newName.trim(), permissions: ["games.play"] })).then(() => setNewName(""));
void run(() => api.post("/api/roles", { name: newName.trim(), permissions: ["games.play"] }), "Роль добавлена")
.then(() => setNewName(""));
}}>
<input placeholder="Название новой роли" value={newName} onChange={(e) => setNewName(e.target.value)} />
<button className="primary" disabled={!newName.trim()}>Добавить роль</button>
<h2>Новая роль</h2>
<div className="form-row">
<label className="field">
<span>Название</span>
<input value={newName} onChange={(e) => setNewName(e.target.value)} />
<span className="hint">Сначала у роли есть только право играть, остальное включите в таблице выше.</span>
</label>
<button className="btn btn-primary" disabled={!newName.trim()}><Icon name="plus" size={18} />Добавить роль</button>
</div>
</form>
</div>
);
+115 -59
View File
@@ -1,6 +1,9 @@
import { useCallback, useEffect, useState, type FormEvent } from "react";
import { api, type Role, type User } from "../api";
import { useAuth } from "../auth";
import { ErrorNote, PageHeader, Spinner } from "../ui/Bits";
import { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
interface Issued {
user: User;
@@ -9,7 +12,8 @@ interface Issued {
export function Users() {
const { me } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const ui = useUi();
const [users, setUsers] = useState<User[] | null>(null);
const [roles, setRoles] = useState<Role[]>([]);
const [username, setUsername] = useState("");
const [roleId, setRoleId] = useState<number | "">("");
@@ -27,13 +31,13 @@ export function Users() {
load().catch((e) => setError(e.message));
}, [load]);
const run = async (fn: () => Promise<unknown>) => {
setError("");
const run = async (fn: () => Promise<unknown>, okText?: string) => {
try {
await fn();
if (okText) ui.toast(okText, "ok");
await load();
} catch (e) {
setError((e as Error).message);
ui.toast((e as Error).message, "error");
}
};
@@ -45,71 +49,123 @@ export function Users() {
});
};
const copy = async () => {
if (!issued) return;
try {
await navigator.clipboard.writeText(`${issued.user.username} / ${issued.password}`);
ui.toast("Логин и пароль скопированы", "ok");
} catch {
ui.toast("Не удалось скопировать — выделите пароль вручную", "error");
}
};
const reset = async (u: User) => {
if (await ui.confirm({ title: `Сбросить пароль для ${u.username}?`, text: "Будет выдан новый пароль, текущий перестанет работать.", confirmLabel: "Сбросить" }))
void run(async () => setIssued(await api.post<Issued>(`/api/users/${u.id}/reset-password`)));
};
const toggle = (u: User) =>
void run(() => api.patch(`/api/users/${u.id}`, { is_active: !u.is_active }), u.is_active ? "Пользователь отключён" : "Пользователь включён");
const remove = async (u: User) => {
if (await ui.confirm({ title: `Удалить ${u.username}?`, text: "Учётная запись и сохранения пользователя будут удалены.", danger: true }))
void run(() => api.del(`/api/users/${u.id}`), "Пользователь удалён");
};
const setRole = (u: User, id: number) => void run(() => api.patch(`/api/users/${u.id}`, { role_id: id }), "Роль изменена");
const statusOf = (u: User) => (
<>
<span className={`badge ${u.is_active ? "badge-ok" : "badge-plain"}`}>{u.is_active ? "Активен" : "Отключён"}</span>
{u.must_change_password && <span className="muted small"> ждёт смены пароля</span>}
</>
);
const roleSelect = (u: User) => (
<select value={u.role.id} disabled={u.id === me?.id} aria-label={`Роль ${u.username}`}
onChange={(e) => setRole(u, Number(e.target.value))}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
);
const actions = (u: User) =>
u.id !== me?.id && (
<div className="row-actions">
<button className="btn btn-sm" onClick={() => void reset(u)}><Icon name="key" size={15} />Сбросить пароль</button>
<button className="btn btn-sm" onClick={() => toggle(u)}>{u.is_active ? "Отключить" : "Включить"}</button>
<button className="btn btn-sm btn-ghost btn-icon btn-danger" onClick={() => void remove(u)} title="Удалить" aria-label={`Удалить ${u.username}`}>
<Icon name="trash" size={16} />
</button>
</div>
);
return (
<div className="page">
<h1>Пользователи</h1>
<form className="card inline-form" onSubmit={create}>
<input placeholder="Логин нового пользователя" value={username} onChange={(e) => setUsername(e.target.value)} />
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
<button className="primary" disabled={username.trim().length < 3}>Создать</button>
<div className="page medium">
<PageHeader title="Пользователи" subtitle="Регистрации нет — пользователей создаёт администратор." />
<form className="card form" onSubmit={create}>
<h2>Новый пользователь</h2>
<div className="form-row">
<label className="field">
<span>Логин</span>
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="off" />
<span className="hint">От 3 символов. Пароль сервер сгенерирует сам.</span>
</label>
<label className="field" style={{ flex: "0 0 12rem" }}>
<span>Роль</span>
<select value={roleId} onChange={(e) => setRoleId(Number(e.target.value))} style={{ width: "100%" }}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</label>
<button className="btn btn-primary" disabled={username.trim().length < 3}><Icon name="plus" size={18} />Создать</button>
</div>
</form>
{issued && (
<div className="card issued">
<div className="card issued" style={{ marginTop: "var(--space-4)" }}>
<p>
Пароль для <b>{issued.user.username}</b> (показывается один раз, передайте его пользователю —
при первом входе он задаст свой):
Пароль для <b>{issued.user.username}</b> показывается один раз. Передайте его пользователю —
при первом входе он задаст свой.
</p>
<div className="password-box">
<code>{issued.password}</code>
<button onClick={() => void navigator.clipboard?.writeText(`${issued.user.username} / ${issued.password}`)}>
Копировать
</button>
<button onClick={() => setIssued(null)}>Скрыть</button>
<button className="btn" onClick={() => void copy()}><Icon name="copy" size={16} />Копировать</button>
<button className="btn btn-ghost" onClick={() => setIssued(null)}>Скрыть</button>
</div>
</div>
)}
{error && <div className="error">{error}</div>}
<table className="table">
<thead>
<tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={u.is_active ? "" : "inactive"}>
<td>
{u.username}
{u.id === me?.id && <span className="muted"> (вы)</span>}
</td>
<td>
<select value={u.role.id} disabled={u.id === me?.id}
onChange={(e) => void run(() => api.patch(`/api/users/${u.id}`, { role_id: Number(e.target.value) }))}>
{roles.map((r) => <option key={r.id} value={r.id}>{r.name}</option>)}
</select>
</td>
<td>
{u.is_active ? "активен" : "отключён"}
{u.must_change_password && <span className="muted"> · ждёт смены пароля</span>}
</td>
<td className="muted">{new Date(u.created_at).toLocaleDateString()}</td>
<td className="actions">
{u.id !== me?.id && (
<>
<button onClick={() => void run(async () =>
setIssued(await api.post<Issued>(`/api/users/${u.id}/reset-password`)))}>Сбросить пароль</button>
<button onClick={() => void run(() => api.patch(`/api/users/${u.id}`, { is_active: !u.is_active }))}>
{u.is_active ? "Отключить" : "Включить"}
</button>
<button className="danger" onClick={() => confirm(`Удалить ${u.username}?`) &&
void run(() => api.del(`/api/users/${u.id}`))}>Удалить</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="section-title"><h2>Все пользователи</h2>{users && <span className="count">{users.length}</span>}</div>
{users === null ? <Spinner /> : (
<>
<div className="table-wrap users-table">
<table className="table">
<thead>
<tr><th>Логин</th><th>Роль</th><th>Статус</th><th>Создан</th><th /></tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className={u.is_active ? "" : "inactive"}>
<td><b>{u.username}</b>{u.id === me?.id && <span className="muted"> (вы)</span>}</td>
<td>{roleSelect(u)}</td>
<td>{statusOf(u)}</td>
<td className="muted">{new Date(u.created_at).toLocaleDateString()}</td>
<td>{actions(u)}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="user-cards">
{users.map((u) => (
<div key={u.id} className={`user-card ${u.is_active ? "" : "inactive"}`}>
<div className="user-card-head">
<b>{u.username}{u.id === me?.id && <span className="muted"> (вы)</span>}</b>
{roleSelect(u)}
</div>
<div>{statusOf(u)}</div>
{actions(u)}
</div>
))}
</div>
</>
)}
</div>
);
}