Initial MVP: local visual novel library with Ren'Py → PGVN converter
- FastAPI backend: admin bootstrap with forced credential change, admin-created users with one-time generated passwords, roles as permission sets - Games and versions with content-addressed blob storage; forks copy only the manifest, so mods share assets with the original - Worker: archive/RPA extraction, unrpyc for .rpyc, own .rpy parser, Python subset translated via ast, media converted to WebP/Opus/WebM - React panel (library search, game page, upload, users, roles) and a framework-free web player with saves, history, skip/auto, transitions - docker compose: postgres, backend, worker, nginx on a single HTTP port Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
364b312064
72 files changed
+9707
No files matched your search
@@ -0,0 +1,74 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, type Me } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
export function ChangeCredentials({ forced = false }: { forced?: boolean }) {
|
||||
const { me, setMe, logout } = useAuth();
|
||||
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 submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (password !== repeat) {
|
||||
setError("Пароли не совпадают");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const updated = await api.post<Me>("/api/auth/change-credentials", {
|
||||
current_password: current,
|
||||
new_password: password,
|
||||
new_username: username.trim() || null,
|
||||
});
|
||||
setMe(updated);
|
||||
setDone(true);
|
||||
setCurrent("");
|
||||
setPassword("");
|
||||
setRepeat("");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const form = (
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h1>{forced ? "Смена учётных данных" : "Аккаунт"}</h1>
|
||||
{forced && (
|
||||
<p className="muted">
|
||||
{needUsername
|
||||
? "Это первый вход администратора. Задайте собственный логин и пароль."
|
||||
: "Пароль был выдан администратором. Задайте свой пароль."}
|
||||
</p>
|
||||
)}
|
||||
<label>
|
||||
Логин{needUsername && " (новый)"}
|
||||
<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>
|
||||
<label>
|
||||
Новый пароль (от 8 символов)
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="new-password" />
|
||||
</label>
|
||||
<label>
|
||||
Повторите пароль
|
||||
<input type="password" value={repeat} onChange={(e) => setRepeat(e.target.value)}
|
||||
autoComplete="new-password" />
|
||||
</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>}
|
||||
</form>
|
||||
);
|
||||
return forced ? <div className="center">{form}</div> : <div className="page narrow">{form}</div>;
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
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";
|
||||
|
||||
const STATUS: Record<Version["status"], string> = {
|
||||
uploading: "загружается",
|
||||
queued: "в очереди",
|
||||
converting: "конвертируется",
|
||||
ready: "готова",
|
||||
failed: "ошибка",
|
||||
};
|
||||
|
||||
export function GamePage() {
|
||||
const { gameId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { me, can } = useAuth();
|
||||
const [game, setGame] = useState<GameDetail | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setGame(await api.get<GameDetail>(`/api/games/${gameId}`));
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}, [gameId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// poll while something is being converted
|
||||
const busy = game?.versions.some((v) => ["uploading", "queued", "converting"].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>;
|
||||
|
||||
// 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("");
|
||||
try {
|
||||
await fn();
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const fork = (v: Version) => {
|
||||
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
|
||||
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, () => {}));
|
||||
|
||||
const uploadVersion = (file: File) => {
|
||||
const name = prompt("Название версии", file.name);
|
||||
if (name)
|
||||
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
|
||||
file, () => {}));
|
||||
};
|
||||
|
||||
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;
|
||||
byParent.set(parent, [...(byParent.get(parent) ?? []), v]);
|
||||
}
|
||||
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)}
|
||||
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
|
||||
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
|
||||
...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>
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
<h2>Версии</h2>
|
||||
<div className="versions">{renderTree(null, 0)}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VersionRow(props: {
|
||||
v: Version; depth: number; canManage: boolean; canFork: boolean;
|
||||
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
|
||||
}) {
|
||||
const { v } = props;
|
||||
const [report, setReport] = useState<Version["report"] | undefined>(undefined);
|
||||
const toggleReport = async () => {
|
||||
if (report !== undefined) setReport(undefined);
|
||||
else setReport((await api.get<Version>(`/api/versions/${v.id}`)).report ?? null);
|
||||
};
|
||||
return (
|
||||
<div className="version" style={{ marginLeft: props.depth * 24 }}>
|
||||
<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">
|
||||
{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 && v.status !== "converting" && <button className="danger" onClick={props.onDelete}>Удалить</button>}
|
||||
</div>
|
||||
</div>
|
||||
{report !== undefined && <Report report={report} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>;
|
||||
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>
|
||||
</div>
|
||||
{report.missingFiles?.length > 0 && (
|
||||
<details>
|
||||
<summary>Не найдены файлы ({report.missingFiles.length})</summary>
|
||||
<ul>{report.missingFiles.map((f: string) => <li key={f}>{f}</li>)}</ul>
|
||||
</details>
|
||||
)}
|
||||
{report.warnings?.length > 0 && (
|
||||
<details>
|
||||
<summary>Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)})</summary>
|
||||
<table>
|
||||
<tbody>
|
||||
{report.warnings.map((w: any) => (
|
||||
<tr key={w.message}>
|
||||
<td>{w.count}×</td>
|
||||
<td>{w.message}</td>
|
||||
<td className="muted small">{w.example}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
|
||||
const [title, setTitle] = useState(game.title);
|
||||
const [description, setDescription] = useState(game.description);
|
||||
const [tags, setTags] = useState(game.tags.join(", "));
|
||||
const [error, setError] = useState("");
|
||||
const save = async () => {
|
||||
try {
|
||||
await api.patch(`/api/games/${game.id}`, {
|
||||
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
onDone();
|
||||
} 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>}
|
||||
<div className="actions">
|
||||
<button className="primary" onClick={() => void save()}>Сохранить</button>
|
||||
<button onClick={onDone}>Отмена</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { api, type Game } from "../api";
|
||||
|
||||
export function Library() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const q = params.get("q") ?? "";
|
||||
const [query, setQuery] = useState(q);
|
||||
const [games, setGames] = useState<Game[] | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setParams(query ? { q: query } : {}, { replace: true }), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [query, setParams]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<Game[]>(`/api/games?q=${encodeURIComponent(q)}`)
|
||||
.then((g) => !cancelled && setGames(g))
|
||||
.catch((e) => !cancelled && setError(e.message));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [q]);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="search">
|
||||
<input placeholder="Поиск по названию, описанию или тегу…" value={query}
|
||||
onChange={(e) => setQuery(e.target.value)} autoFocus />
|
||||
</div>
|
||||
{error && <div className="error">{error}</div>}
|
||||
{games === null ? (
|
||||
<p className="muted">Загрузка…</p>
|
||||
) : games.length === 0 ? (
|
||||
<p className="muted">{q ? "Ничего не найдено." : "В библиотеке пока нет игр."}</p>
|
||||
) : (
|
||||
<div className="grid">
|
||||
{games.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>
|
||||
<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>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { api, type Me } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
export function Login() {
|
||||
const { setMe } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
setMe(await api.post<Me>("/api/auth/login", { username, password }));
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="center">
|
||||
<form className="card auth-card" onSubmit={submit}>
|
||||
<h1>PGames</h1>
|
||||
<p className="muted">Локальная библиотека визуальных новелл</p>
|
||||
<label>
|
||||
Логин
|
||||
<input value={username} onChange={(e) => setUsername(e.target.value)} autoFocus autoComplete="username" />
|
||||
</label>
|
||||
<label>
|
||||
Пароль
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { api, uploadRaw, type Game } from "../api";
|
||||
|
||||
export function NewGame() {
|
||||
const navigate = useNavigate();
|
||||
const [title, setTitle] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [tags, setTags] = useState("");
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [progress, setProgress] = useState<number | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
setError("");
|
||||
setProgress(0);
|
||||
try {
|
||||
const game = await api.post<Game>("/api/games", {
|
||||
title: title.trim() || file.name.replace(/\.(zip|7z|rar|tar.*)$/i, ""),
|
||||
description,
|
||||
tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
|
||||
});
|
||||
await uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}`,
|
||||
file, setProgress);
|
||||
navigate(`/games/${game.id}`);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setProgress(null);
|
||||
}
|
||||
};
|
||||
|
||||
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)} />
|
||||
</label>
|
||||
<label>
|
||||
Название
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="По имени архива" />
|
||||
</label>
|
||||
<label>
|
||||
Описание
|
||||
<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>
|
||||
)}
|
||||
<button className="primary" disabled={!file || progress !== null}>
|
||||
{progress !== null ? `Загрузка ${Math.round(progress * 100)}%` : "Загрузить"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import type { Engine } from "../player/engine";
|
||||
|
||||
export function Play() {
|
||||
const { versionId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const [status, setStatus] = useState("Загрузка…");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let engine: Engine | null = null;
|
||||
let cancelled = false;
|
||||
// the player is a separate chunk: the library UI does not pay for it
|
||||
import("../player").then(({ launch }) =>
|
||||
launch(host.current!, Number(versionId), () => navigate(-1), setStatus),
|
||||
).then((e) => {
|
||||
if (cancelled) e.destroy();
|
||||
else {
|
||||
engine = e;
|
||||
setStatus("");
|
||||
}
|
||||
}).catch((e) => setError((e as Error).message));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
engine?.destroy();
|
||||
};
|
||||
}, [versionId, navigate]);
|
||||
|
||||
return (
|
||||
<div className="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>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api, type Role } from "../api";
|
||||
|
||||
export function Roles() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [perms, setPerms] = useState<Record<string, string>>({});
|
||||
const [newName, setNewName] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [r, p] = await Promise.all([api.get<Role[]>("/api/roles"),
|
||||
api.get<Record<string, string>>("/api/roles/permissions")]);
|
||||
setRoles(r);
|
||||
setPerms(p);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((e) => setError(e.message));
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
try {
|
||||
await fn();
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = (role: Role, perm: string) => {
|
||||
const next = role.permissions.includes(perm)
|
||||
? role.permissions.filter((p) => p !== perm)
|
||||
: [...role.permissions, perm];
|
||||
void run(() => api.put(`/api/roles/${role.id}`, { name: role.name, permissions: next }));
|
||||
};
|
||||
|
||||
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>
|
||||
{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>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<form className="card inline-form" onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
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>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { useCallback, useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type Role, type User } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
|
||||
interface Issued {
|
||||
user: User;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export function Users() {
|
||||
const { me } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [username, setUsername] = useState("");
|
||||
const [roleId, setRoleId] = useState<number | "">("");
|
||||
const [issued, setIssued] = useState<Issued | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const [u, r] = await Promise.all([api.get<User[]>("/api/users"), api.get<Role[]>("/api/roles")]);
|
||||
setUsers(u);
|
||||
setRoles(r);
|
||||
setRoleId((cur) => cur || (r.find((x) => x.name === "player")?.id ?? r[0]?.id ?? ""));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch((e) => setError(e.message));
|
||||
}, [load]);
|
||||
|
||||
const run = async (fn: () => Promise<unknown>) => {
|
||||
setError("");
|
||||
try {
|
||||
await fn();
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
};
|
||||
|
||||
const create = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
void run(async () => {
|
||||
setIssued(await api.post<Issued>("/api/users", { username: username.trim(), role_id: roleId }));
|
||||
setUsername("");
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
</form>
|
||||
{issued && (
|
||||
<div className="card issued">
|
||||
<p>
|
||||
Пароль для <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>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user