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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user