UI: unified design system, light/dark themes, redesigned pages and player UI

Tokens and components in styles.css, shared Ui (dialogs, toasts), icons,
bottom tab bar on mobile, keyboard/gamepad navigation, library shelf with
filters and conversion badges, hero game page, restyled player screens.
Games list now returns per-game version counts. Adds docs/UI_DESIGN.md.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
mrleo1nidandClaude Sonnet 5.5 committed 2026-10-25 03:43:53 +03:00
1 parent 51a7e77e56
commit 9ee3e1ff6e
27 files changed
+2399 -508

No files matched your search

+161 -86
View File
@@ -2,19 +2,30 @@ 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 { Icon } from "../ui/Icon";
import { useUi } from "../ui/Ui";
const STATUS: Record<Version["status"], string> = {
uploading: "загружается",
queued: "в очереди",
converting: "конвертируется",
importing: "импортируется",
ready: "готова",
failed: "ошибка",
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 { me, can } = useAuth();
const [game, setGame] = useState<GameDetail | null>(null);
const [error, setError] = useState("");
@@ -33,43 +44,75 @@ export function GamePage() {
}, [load]);
// poll while something is being converted
const busy = game?.versions.some((v) => ["uploading", "queued", "converting", "importing"].includes(v.status));
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) return <div className="page"><div className="error">{error}</div></div>;
if (!game) return <div className="page muted">Загрузка…</div>;
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>) => {
setError("");
const run = async (fn: () => Promise<unknown>, okText?: string) => {
try {
await fn();
if (okText) ui.toast(okText, "ok");
await load();
} catch (e) {
setError((e as Error).message);
ui.toast((e as Error).message, "error");
}
};
const fork = (v: Version) => {
const name = prompt("Название новой версии", `${v.name} (${me?.username})`);
if (name) void run(() => api.post(`/api/versions/${v.id}/fork`, { name }));
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(() => api.post(`/api/versions/${v.id}/fork`, { name }), "Форк создан");
};
const uploadCover = (file: File) =>
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}));
run(() => uploadRaw("PUT", `/api/games/${game.id}/cover`, file, () => {}), "Обложка обновлена");
const uploadVersion = (file: File) => {
const name = prompt("Название версии", file.name);
const uploadVersion = async (file: File) => {
const name = await ui.prompt({
title: "Загрузить новую версию", label: "Название версии", initial: file.name.replace(/\.[^.]+$/, ""),
confirmLabel: "Загрузить",
});
if (name)
void run(() => uploadRaw("POST", `/api/games/${game.id}/upload?filename=${encodeURIComponent(file.name)}&name=${encodeURIComponent(name)}`,
file, () => {}));
file, () => {}), "Архив загружен, началась конвертация");
};
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 playVersion = ready.find((v) => v.id === lastId) ?? 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;
@@ -77,60 +120,76 @@ export function GamePage() {
}
const renderTree = (parent: number | null, depth: number): JSX.Element[] =>
(byParent.get(parent) ?? []).flatMap((v) => [
<VersionRow key={v.id} v={v} depth={depth} canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => navigate(`/play/${v.id}`)} onFork={() => fork(v)}
<VersionRow key={v.id} v={v} depth={depth} current={v.id === playVersion?.id}
canManage={v.owner?.id === me?.id || can("versions.manage_any") || can("games.delete")}
canFork={can("versions.fork")} onPlay={() => play(v)} onFork={() => void fork(v)}
onRetry={() => void run(() => api.post(`/api/versions/${v.id}/retry`))}
onDelete={() => confirm(`Удалить версию «${v.name}»?`) && void run(() => api.del(`/api/versions/${v.id}`))} />,
onDelete={() => void removeVersion(v)} />,
...renderTree(v.id, depth + 1),
]);
return (
<div className="page">
<Link to="/" className="muted">← Библиотека</Link>
<div className="game-head">
<div className="cover big">
{game.cover_hash ? <img src={`/blobs/${game.cover_hash}`} alt="" /> : <span>{game.title[0]}</span>}
</div>
<div className="game-info">
{editing ? (
<GameEditor game={game} onDone={() => { setEditing(false); void load(); }} />
) : (
<>
<h1>{game.title}</h1>
<div className="tags">{game.tags.map((t) => <span key={t} className="tag">{t}</span>)}</div>
<p className="description">{game.description || <span className="muted">Нет описания</span>}</p>
<div className="actions">
{canEdit && <button onClick={() => setEditing(true)}>Редактировать</button>}
{canEdit && (
<label className="button">
Обложка
<input type="file" accept="image/*" hidden onChange={(e) => e.target.files?.[0] && void uploadCover(e.target.files[0])} />
</label>
)}
{can("games.upload") && (
<label className="button">
Загрузить ещё архив
<input type="file" hidden onChange={(e) => e.target.files?.[0] && uploadVersion(e.target.files[0])} />
</label>
)}
{can("games.delete") && (
<button className="danger" onClick={() => confirm("Удалить игру со всеми версиями?") &&
void api.del(`/api/games/${game.id}`).then(() => navigate("/"))}>Удалить игру</button>
)}
</div>
</>
)}
<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>
{error && <div className="error">{error}</div>}
<h2>Версии</h2>
<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>
);
}
function VersionRow(props: {
v: Version; depth: number; canManage: boolean; canFork: boolean;
v: Version; depth: number; current: boolean; canManage: boolean; canFork: boolean;
onPlay: () => void; onFork: () => void; onRetry: () => void; onDelete: () => void;
}) {
const { v } = props;
@@ -139,25 +198,38 @@ function VersionRow(props: {
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" style={{ marginLeft: props.depth * 24 }}>
<div className={`version ${props.current ? "main" : ""}`} style={{ marginLeft: Math.min(props.depth, 4) * 22 }}>
<div className="version-main">
<div>
<b>{props.depth > 0 && "↳ "}{v.name}</b>
<span className={`status status-${v.status}`}>{STATUS[v.status]}</span>
<div className="muted small">
<div className="version-title">
{props.depth > 0 && <span className="fork-mark" title="Форк"><Icon name="fork" size={16} /></span>}
<b>{v.name}</b>
<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>
</div>
<div className="actions">
{v.status === "ready" && <button className="primary" onClick={props.onPlay}>Играть</button>}
{v.status === "ready" && props.canFork && <button onClick={props.onFork}>Форк</button>}
{(v.status === "ready" || v.status === "failed") && <button onClick={() => void toggleReport()}>Отчёт</button>}
{v.status === "failed" && props.canManage && <button onClick={props.onRetry}>Повторить</button>}
{props.canManage && !["converting", "importing"].includes(v.status) && <button className="danger" onClick={props.onDelete}>Удалить</button>}
{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.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>
);
@@ -165,17 +237,20 @@ function VersionRow(props: {
function Report({ report }: { report: Record<string, any> | null }) {
if (!report) return <div className="report muted">Отчёта нет</div>;
if (report.error) return <div className="report error">{report.error}</div>;
if (report.error) return <div className="report"><ErrorNote>{report.error}</ErrorNote></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">
<span>Скриптов: {report.scripts} (декомпилировано {report.decompiled})</span>
<span>Меток: {report.labels}</span>
<span>Реплик: {report.stats?.say ?? 0}</span>
<span>Меню: {report.stats?.menu ?? 0}</span>
<span>Картинок: {report.media?.image}, аудио: {report.media?.audio}, видео: {report.media?.video}</span>
<span>Размер: {formatSize(report.sourceSize)} → {formatSize(report.outputSize)}</span>
<span>Время: {report.durationSec} с</span>
{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>
@@ -203,7 +278,7 @@ function Report({ report }: { report: Record<string, any> | null }) {
);
}
function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void }) {
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(", "));
@@ -213,21 +288,21 @@ function GameEditor({ game, onDone }: { game: GameDetail; onDone: () => void })
await api.patch(`/api/games/${game.id}`, {
title, description, tags: tags.split(",").map((t) => t.trim()).filter(Boolean),
});
onDone();
onDone(true);
} catch (e) {
setError((e as Error).message);
}
};
return (
<div className="editor">
<input value={title} onChange={(e) => setTitle(e.target.value)} />
<textarea value={description} onChange={(e) => setDescription(e.target.value)} rows={5} />
<input value={tags} onChange={(e) => setTags(e.target.value)} placeholder="теги через запятую" />
{error && <div className="error">{error}</div>}
<form className="editor" onSubmit={(e) => { e.preventDefault(); void save(); }}>
<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="primary" onClick={() => void save()}>Сохранить</button>
<button onClick={onDone}>Отмена</button>
<button className="btn btn-primary" disabled={!title.trim()}>Сохранить</button>
<button type="button" className="btn" onClick={() => onDone(false)}>Отмена</button>
</div>
</div>
</form>
);
}