Add immutable revisions, PGVN editor, asset replacement, merge and rebase workflows, authenticated media delivery and reachability GC. Harden the script interpreter, pin unrpyc and remove tracked build metadata. Include current compatibility checks, Naninovel import and regression tests.
398 lines
20 KiB
TypeScript
398 lines
20 KiB
TypeScript
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";
|
||
import { getRecent, pushRecent } from "../recent";
|
||
import { Cover, ErrorNote, Spinner } from "../ui/Bits";
|
||
import { F95Fetch } from "../ui/F95Fetch";
|
||
import { Icon } from "../ui/Icon";
|
||
import { useUi } from "../ui/Ui";
|
||
import { useUploads } from "../uploads";
|
||
|
||
const STATUS: Record<Version["status"], string> = {
|
||
uploading: "Загружается",
|
||
queued: "В очереди",
|
||
converting: "Конвертируется",
|
||
importing: "Импортируется",
|
||
ready: "Готова",
|
||
failed: "Ошибка",
|
||
};
|
||
const BUSY: Version["status"][] = ["uploading", "queued", "converting", "importing"];
|
||
|
||
function StatusBadge({ status }: { status: Version["status"] }) {
|
||
const cls = status === "ready" ? "badge-ok" : status === "failed" ? "badge-error" : "badge-warn busy";
|
||
return <span className={`badge ${cls}`}>{STATUS[status]}</span>;
|
||
}
|
||
|
||
export function GamePage() {
|
||
const { gameId } = useParams();
|
||
const navigate = useNavigate();
|
||
const ui = useUi();
|
||
const { start: startUpload, tasks: uploads } = useUploads();
|
||
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]);
|
||
|
||
const uploadedVersions = uploads.filter((task) => task.gameId === Number(gameId) && task.version).map((task) => task.version!.id).join(",");
|
||
useEffect(() => {
|
||
if (uploadedVersions) void load();
|
||
}, [uploadedVersions, load]);
|
||
|
||
// poll while something is being converted
|
||
const busy = game?.versions.some((v) => BUSY.includes(v.status));
|
||
useEffect(() => {
|
||
if (!busy) return;
|
||
const t = setInterval(() => void load(), 2000);
|
||
return () => clearInterval(t);
|
||
}, [busy, load]);
|
||
|
||
if (error && !game) return <div className="page"><ErrorNote>{error}</ErrorNote></div>;
|
||
if (!game) return <div className="page"><Spinner /></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>, okText?: string) => {
|
||
try {
|
||
await fn();
|
||
if (okText) ui.toast(okText, "ok");
|
||
await load();
|
||
} catch (e) {
|
||
ui.toast((e as Error).message, "error");
|
||
}
|
||
};
|
||
|
||
const play = (v: Version) => {
|
||
pushRecent({ gameId: game.id, versionId: v.id, title: game.title, coverHash: game.cover_hash });
|
||
navigate(`/play/${v.id}`);
|
||
};
|
||
|
||
const fork = async (v: Version) => {
|
||
const name = await ui.prompt({
|
||
title: "Новая версия (форк)",
|
||
text: "Форк делит с оригиналом все ассеты и почти не занимает места.",
|
||
label: "Название версии", initial: `${v.name} (${me?.username})`, confirmLabel: "Создать форк",
|
||
});
|
||
if (name) void run(async () => {
|
||
const fork = await api.post<Version>(`/api/versions/${v.id}/fork`, { name });
|
||
navigate(`/versions/${fork.id}/edit`);
|
||
}, "Форк создан");
|
||
};
|
||
|
||
const translate = async (v: Version) => {
|
||
const name = await ui.prompt({
|
||
title: "Новый перевод",
|
||
text: "Создаётся ваш черновик — копия версии, в которой можно переводить. Игроки увидят перевод после публикации.",
|
||
label: "Название черновика", initial: `Перевод (${me?.username})`, confirmLabel: "Начать перевод",
|
||
});
|
||
if (!name) return;
|
||
try {
|
||
const fork = await api.post<Version>(`/api/versions/${v.id}/fork`, { name, kind: "translation" });
|
||
navigate(`/versions/${fork.id}/translate`);
|
||
} catch (e) {
|
||
ui.toast((e as Error).message, "error");
|
||
}
|
||
};
|
||
|
||
const publish = async (v: Version) => {
|
||
const translationDraft = v.kind === "translation";
|
||
const ok = await ui.confirm({
|
||
title: `Опубликовать «${v.name}»?`,
|
||
text: translationDraft
|
||
? "Изменённые языки будут влиты в главную версию. Сохранения игроков сохранятся, прежнее состояние останется в истории."
|
||
: "Версию увидят все игроки.",
|
||
confirmLabel: "Опубликовать",
|
||
});
|
||
if (ok) void run(() => api.post(`/api/versions/${v.id}/publish`, {}), "Опубликовано");
|
||
};
|
||
|
||
const makeMain = async (v: Version) => {
|
||
if (await ui.confirm({ title: `Сделать «${v.name}» главной версией?`, text: "Кнопка «Играть» будет открывать её.", confirmLabel: "Сделать главной" }))
|
||
void run(() => api.post(`/api/versions/${v.id}/make-main`, {}), "Главная версия изменена");
|
||
};
|
||
|
||
const uploadCover = (file: File) =>
|
||
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
|
||
|
||
const uploadVersion = async (file: File) => {
|
||
const name = await ui.prompt({
|
||
title: "Загрузить новую версию", label: "Название версии", initial: file.name.replace(/\.[^.]+$/, ""),
|
||
confirmLabel: "Загрузить",
|
||
});
|
||
if (name)
|
||
startUpload({ file, gameId: game.id, name });
|
||
};
|
||
|
||
const removeVersion = async (v: Version) => {
|
||
if (await ui.confirm({ title: `Удалить версию «${v.name}»?`, text: "Сохранения игроков этой версии тоже будут удалены.", danger: true }))
|
||
void run(() => api.del(`/api/versions/${v.id}`), "Версия удалена");
|
||
};
|
||
|
||
const removeGame = async () => {
|
||
if (!(await ui.confirm({ title: `Удалить «${game.title}»?`, text: "Игра будет удалена вместе со всеми версиями и сохранениями.", danger: true }))) return;
|
||
try {
|
||
await api.del(`/api/games/${game.id}`);
|
||
ui.toast("Игра удалена", "ok");
|
||
navigate("/");
|
||
} catch (e) {
|
||
ui.toast((e as Error).message, "error");
|
||
}
|
||
};
|
||
|
||
const ready = game.versions.filter((v) => v.status === "ready");
|
||
const lastId = getRecent().find((r) => r.gameId === game.id)?.versionId;
|
||
const mainVersion = ready.find((v) => v.id === game.main_version_id);
|
||
const playVersion = ready.find((v) => v.id === lastId) ?? mainVersion ?? ready.find((v) => v.parent_id === null) ?? ready[0];
|
||
|
||
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} current={v.id === playVersion?.id} isMain={v.id === game.main_version_id}
|
||
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
|
||
canTranslate={can("translations.edit")} canPublish={can("versions.publish")}
|
||
onTranslate={() => void translate(v)} onPublish={() => void publish(v)} onMakeMain={() => void makeMain(v)}
|
||
canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)}
|
||
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
|
||
onDelete={() => void removeVersion(v)} />,
|
||
...renderTree(v.id, depth + 1),
|
||
]);
|
||
|
||
return (
|
||
<div className="page">
|
||
<div className="hero">
|
||
{game.cover_hash && <div className="hero-bg" style={{ backgroundImage: `url(/blobs/${game.cover_hash})` }} />}
|
||
<Link to="/" className="back-link"><Icon name="back" size={18} />Библиотека</Link>
|
||
<div className="hero-inner">
|
||
<Cover hash={game.cover_hash} title={game.title} className="big" />
|
||
<div className="hero-info">
|
||
{editing ? (
|
||
<GameEditor game={game} onDone={(changed) => { setEditing(false); if (changed) { ui.toast("Сохранено", "ok"); void load(); } }} />
|
||
) : (
|
||
<>
|
||
<h1>{game.title}</h1>
|
||
{game.tags.length > 0 && <div className="tags">{game.tags.map((t) => <Link key={t} to={`/?tag=${encodeURIComponent(t)}`} className="tag">{t}</Link>)}</div>}
|
||
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
|
||
<div className="hero-actions">
|
||
{playVersion ? (
|
||
<button className="btn btn-primary btn-lg" onClick={() => play(playVersion)}>
|
||
<Icon name="play" size={22} />Играть
|
||
</button>
|
||
) : (
|
||
<span className="badge badge-warn busy">{busy ? "Идёт конвертация…" : "Нет готовых версий"}</span>
|
||
)}
|
||
{playVersion && game.versions.length > 1 && <span className="muted small">версия «{playVersion.name}»</span>}
|
||
</div>
|
||
<div className="actions">
|
||
{canEdit && <button className="btn btn-sm" onClick={() => setEditing(true)}><Icon name="edit" size={16} />Редактировать</button>}
|
||
{canEdit && (
|
||
<label className="btn btn-sm">
|
||
<Icon name="image" size={16} />Обложка
|
||
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
|
||
</label>
|
||
)}
|
||
{can("games.delete") && (
|
||
<button className="btn btn-sm btn-danger" onClick={() => void removeGame()}><Icon name="trash" size={16} />Удалить игру</button>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="section-title" style={{ marginTop: "var(--space-5)" }}>
|
||
<h2>Версии</h2>
|
||
<span className="count">{game.versions.length}</span>
|
||
<span className="grow" />
|
||
{can("games.upload") && (
|
||
<label className="btn btn-sm">
|
||
<Icon name="upload" size={16} />Загрузить ещё архив
|
||
<input type="file" hidden onChange={(e) => { const f = e.target.files?.[0]; e.target.value = ""; if (f) void uploadVersion(f); }} />
|
||
</label>
|
||
)}
|
||
</div>
|
||
{error && <ErrorNote>{error}</ErrorNote>}
|
||
<div className="versions">{renderTree(null, 0)}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const KIND: Record<Version["kind"], string> = { original: "", translation: "Перевод", mod: "Мод" };
|
||
|
||
function VersionRow(props: {
|
||
v: Version; depth: number; current: boolean; isMain: boolean; canManage: boolean; canFork: boolean;
|
||
canTranslate: boolean; canPublish: boolean;
|
||
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
|
||
onTranslate: () => void; onPublish: () => void; onMakeMain: () => 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);
|
||
};
|
||
const inProgress = BUSY.includes(v.status);
|
||
return (
|
||
<div className={`version ${props.current ? "main" : ""}`} style={{ marginLeft: Math.min(props.depth, 4) * 22 }}>
|
||
<div className="version-main">
|
||
<div>
|
||
<div className="version-title">
|
||
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
|
||
<b>{v.name}</b>
|
||
{props.isMain && <span className="badge badge-ok" title="Её открывает кнопка «Играть»">главная</span>}
|
||
{KIND[v.kind] && <span className="badge badge-plain">{KIND[v.kind]}</span>}
|
||
{v.visibility === "draft" && <span className="badge badge-warn">черновик</span>}
|
||
<StatusBadge status={v.status} />
|
||
</div>
|
||
<div className="muted small version-meta">
|
||
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)}
|
||
{v.progress && ` · ${v.progress}`}
|
||
</div>
|
||
{v.status === "ready" && v.languages?.length > 0 && (
|
||
<div className="tags" style={{ marginTop: "0.35rem" }} title="Языки версии">
|
||
<Icon name="globe" size={14} />
|
||
{v.languages.map((l) => (
|
||
<Link key={l.code} className="tag" to={`/versions/${v.id}/translate?lang=${l.code}`}>
|
||
{l.name} {Math.round(l.progress * 100)}%
|
||
</Link>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="actions">
|
||
{v.status === "ready" && <button className="btn btn-primary btn-sm" onClick={props.onPlay}><Icon name="play" size={16} />Играть</button>}
|
||
{v.status === "ready" && props.canFork && <button className="btn btn-sm" onClick={props.onFork}><Icon name="fork" size={16} />Форк</button>}
|
||
{v.status === "ready" && v.kind === "mod" && <Link className="btn btn-sm" to={`/versions/${v.id}/edit`}>Редактор мода</Link>}
|
||
{v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? (
|
||
<Link className="btn btn-sm" to={`/versions/${v.id}/translate`}><Icon name="globe" size={16} />Редактор перевода</Link>
|
||
) : v.status === "ready" && props.canTranslate && v.visibility === "published" && (
|
||
<button className="btn btn-sm" onClick={props.onTranslate}><Icon name="globe" size={16} />Перевести</button>
|
||
)}
|
||
{v.status === "ready" && props.canPublish && v.visibility === "draft" && (
|
||
<button className="btn btn-sm btn-primary" onClick={props.onPublish}><Icon name="send" size={16} />Опубликовать</button>
|
||
)}
|
||
{v.status === "ready" && props.canPublish && !props.isMain && v.visibility === "published" && v.kind !== "translation" && (
|
||
<button className="btn btn-sm btn-ghost" onClick={props.onMakeMain} title="Сделать главной"><Icon name="star" size={16} />Главная</button>
|
||
)}
|
||
{(v.status === "ready" || v.status === "failed") && (
|
||
<button className="btn btn-sm btn-ghost" onClick={() => void toggleReport()} aria-expanded={report !== undefined}>
|
||
<Icon name="file" size={16} />Отчёт
|
||
</button>
|
||
)}
|
||
{v.status === "failed" && props.canManage && <button className="btn btn-sm" onClick={props.onRetry}><Icon name="retry" size={16} />Повторить</button>}
|
||
{props.canManage && !["converting", "importing"].includes(v.status) && (
|
||
<button className="btn btn-sm btn-ghost btn-icon btn-danger" onClick={props.onDelete} title="Удалить версию" aria-label="Удалить версию">
|
||
<Icon name="trash" size={16} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
{inProgress && <div className="version-progress progress"><div style={{ width: "100%", opacity: 0.5 }} /></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">
|
||
<ErrorNote>{report.error}</ErrorNote>
|
||
{report.engine && <p className="muted">Движок: {report.engine}</p>}
|
||
{report.issues?.length > 0 && <details>
|
||
<summary>Причины остановки ({report.issues.length})</summary>
|
||
<ul>{report.issues.map((issue: any, index: number) => <li key={index}>
|
||
{issue.message}{issue.count > 1 ? ` · ${issue.count}×` : ""}
|
||
{issue.location && <span className="muted"> — {issue.location}</span>}
|
||
</li>)}</ul>
|
||
</details>}
|
||
</div>;
|
||
const stat = (value: string | number, label: string) => (
|
||
<div className="stat" key={label}><b>{value}</b><span>{label}</span></div>
|
||
);
|
||
return (
|
||
<div className="report">
|
||
<div className="report-stats">
|
||
{stat(`${report.scripts} / ${report.decompiled}`, "скриптов / декомпилировано")}
|
||
{stat(report.labels, "меток")}
|
||
{stat(report.stats?.say ?? 0, "реплик")}
|
||
{stat(report.stats?.menu ?? 0, "меню выбора")}
|
||
{stat(`${report.media?.image ?? 0} · ${report.media?.audio ?? 0} · ${report.media?.video ?? 0}`, "картинок · аудио · видео")}
|
||
{stat(`${formatSize(report.sourceSize)} → ${formatSize(report.outputSize)}`, "размер")}
|
||
{stat(`${report.durationSec} с`, "время конвертации")}
|
||
</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: (changed: boolean) => 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(true);
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
}
|
||
};
|
||
return (
|
||
<form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
|
||
<F95Fetch onLoaded={(m) => {
|
||
setTitle(m.title);
|
||
setDescription(m.description);
|
||
setTags(m.tags.join(", "));
|
||
}} />
|
||
<label className="field"><span>Название</span><input value={title} onChange={(e) => setTitle(e.target.value)} /></label>
|
||
<label className="field"><span>Описание</span><textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} /></label>
|
||
<label className="field"><span>Теги</span><input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="через запятую: романтика, драма" /></label>
|
||
{error && <ErrorNote>{error}</ErrorNote>}
|
||
<div className="actions">
|
||
<button className="btn btn-primary" disabled={!title.trim()}>Сохранить</button>
|
||
<button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button>
|
||
</div>
|
||
</form>
|
||
);
|
||
}
|