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:
1 parent
51a7e77e56
commit
9ee3e1ff6e
27 files changed
+2399
-508
No files matched your search
+161
-86
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user