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