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:
mrleo1nidandClaude Opus 5.5 committed 2026-10-25 02:41:32 +03:00
commit 364b312064
72 files changed
+9707

No files matched your search

+74
View File
@@ -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>;
}
+232
View File
@@ -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>
);
}
+57
View File
@@ -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>
);
}
+44
View File
@@ -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>
);
}
+69
View File
@@ -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>
);
}
+42
View File
@@ -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>
);
}
+83
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}