- 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>
58 lines
2.0 KiB
TypeScript
58 lines
2.0 KiB
TypeScript
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>
|
|
);
|
|
}
|