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 = { 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 {STATUS[status]}; } 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(null); const [error, setError] = useState(""); const [editing, setEditing] = useState(false); const load = useCallback(async () => { try { setGame(await api.get(`/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
{error}
; if (!game) return
; // the backend also lets uploaders edit only games they created const canEdit = can("games.edit") || can("games.upload"); const run = async (fn: () => Promise, 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(`/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(`/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(); 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) => [ 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 (
{game.cover_hash &&
} Библиотека
{editing ? ( { setEditing(false); if (changed) { ui.toast("Сохранено", "ok"); void load(); } }} /> ) : ( <>

{game.title}

{game.tags.length > 0 &&
{game.tags.map((t) => {t})}
}

{game.description || Нет описания}

{playVersion ? ( ) : ( {busy ? "Идёт конвертация…" : "Нет готовых версий"} )} {playVersion && game.versions.length > 1 && версия «{playVersion.name}»}
{canEdit && } {canEdit && ( )} {can("games.delete") && ( )}
)}

Версии

{game.versions.length} {can("games.upload") && ( )}
{error && {error}}
{renderTree(null, 0)}
); } const KIND: Record = { 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(undefined); const toggleReport = async () => { if (report !== undefined) setReport(undefined); else setReport((await api.get(`/api/versions/${v.id}`)).report ?? null); }; const inProgress = BUSY.includes(v.status); return (
{props.depth > 0 && } {v.name} {props.isMain && главная} {KIND[v.kind] && {KIND[v.kind]}} {v.visibility === "draft" && черновик}
{v.owner?.username ?? "—"} · {new Date(v.created_at).toLocaleDateString()} · {formatSize(v.size_bytes)} {v.progress && ` · ${v.progress}`}
{v.status === "ready" && v.languages?.length > 0 && (
{v.languages.map((l) => ( {l.name} {Math.round(l.progress * 100)}% ))}
)}
{v.status === "ready" && } {v.status === "ready" && props.canFork && } {v.status === "ready" && v.kind === "mod" && Редактор мода} {v.status === "ready" && props.canTranslate && v.kind === "translation" && v.visibility === "draft" && props.canManage ? ( Редактор перевода ) : v.status === "ready" && props.canTranslate && v.visibility === "published" && ( )} {v.status === "ready" && props.canPublish && v.visibility === "draft" && ( )} {v.status === "ready" && props.canPublish && !props.isMain && v.visibility === "published" && v.kind !== "translation" && ( )} {(v.status === "ready" || v.status === "failed") && ( )} {v.status === "failed" && props.canManage && } {props.canManage && !["converting", "importing"].includes(v.status) && ( )}
{inProgress &&
} {report !== undefined && }
); } function Report({ report }: { report: Record | null }) { if (!report) return
Отчёта нет
; if (report.error) return
{report.error} {report.engine &&

Движок: {report.engine}

} {report.issues?.length > 0 &&
Причины остановки ({report.issues.length})
    {report.issues.map((issue: any, index: number) =>
  • {issue.message}{issue.count > 1 ? ` · ${issue.count}×` : ""} {issue.location && — {issue.location}}
  • )}
}
; const stat = (value: string | number, label: string) => (
{value}{label}
); return (
{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} с`, "время конвертации")}
{report.missingFiles?.length > 0 && (
Не найдены файлы ({report.missingFiles.length})
    {report.missingFiles.map((f: string) =>
  • {f}
  • )}
)} {report.warnings?.length > 0 && (
Предупреждения ({report.warnings.reduce((s: number, w: any) => s + w.count, 0)}) {report.warnings.map((w: any) => ( ))}
{w.count}× {w.message} {w.example}
)}
); } 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 (
{ e.preventDefault(); void save(); }}> { setTitle(m.title); setDescription(m.description); setTags(m.tags.join(", ")); }} />