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

+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>
);
}