Files
punktfunk-plugin-rom-manager/ui/src/pages/Games.tsx
T
enricobuehler 3a6c80558d
CI / build (push) Has been cancelled
CI / publish (push) Has been cancelled
feat: initial ROM & emulator manager plugin (M0–M5)
A punktfunk-plugin-* package that scans ROM directories, maps them to
emulators, fetches box art, and reconciles them into the host game library
under provider id `rom-manager` — with a console-hosted web UI.

Engine (pure, unit-tested core):
- Table-driven platform registry (~25 consoles) + emulator registry with
  best-effort per-OS detection (PATH / Flatpak / known paths) and RetroArch
  core discovery.
- Scanner with disc folding (m3u/cue/gdi), archive gating, excludes.
- No-Intro title parsing + optional per-platform region dedupe.
- Security-critical quoting seam: POSIX single-quote + Windows double-quote
  with hostile-name refusal; ROM filenames never reach a shell un-quoted.
- Pure desired-state reconcile (stable external_ids, scale guard, fingerprint
  skip) → full-replace PUT /library/provider/rom-manager.

Box art (like Steam ROM Manager): SteamGridDB primary (portrait/hero/logo/
header, fuzzy match, operator API key) behind a provider seam, with keyless
libretro-thumbnails as the zero-setup fallback (`auto` default).

UI: console-hosted via the SDK `servePluginUi` (zero plugin-side auth) with a
plugin-local REST/SSE API and a self-contained React SPA (Setup / Emulators /
Games / Sync). Standalone password-gated fallback for host-only installs.

CLI: scan / detect / preview / sync / uninstall / set-password.

48 engine tests, typecheck + biome clean, SPA builds. Verified end-to-end:
scan → detect → reconcile PUT, fingerprint idempotence, and the standalone
UI serving SPA + REST.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 10:09:18 +02:00

157 lines
4.3 KiB
TypeScript

import { useCallback, useEffect, useState } from "react";
import { getConfig, getPreview, type Preview, putConfig } from "../api.js";
import { Badge, Button, Card, useToast } from "../components.js";
import { platformOf } from "../hooks.js";
export const Games = () => {
const [preview, setPreview] = useState<Preview>();
const [loading, setLoading] = useState(false);
const [showSkipped, setShowSkipped] = useState(false);
const toast = useToast();
const refresh = useCallback(() => {
setLoading(true);
getPreview()
.then(setPreview)
.catch((e) => toast(String(e)))
.finally(() => setLoading(false));
}, [toast]);
useEffect(refresh, [refresh]);
// Toggle a title's exclude override, persist, and refresh the preview.
const setExcluded = async (externalId: string, exclude: boolean) => {
const config = await getConfig();
const overrides = { ...config.gameOverrides };
const current = overrides[externalId] ?? {};
if (exclude) overrides[externalId] = { ...current, exclude: true };
else {
const { exclude: _drop, ...rest } = current;
if (Object.keys(rest).length) overrides[externalId] = rest;
else delete overrides[externalId];
}
await putConfig({ ...config, gameOverrides: overrides });
toast(exclude ? "Excluded" : "Included");
refresh();
};
if (!preview) return <Card>{loading ? "Scanning…" : "Loading…"}</Card>;
const { entries, report } = preview;
const excluded = report.excluded ?? [];
return (
<>
<Card
title={`Games (${entries.length})`}
hint="What will be reconciled into the library. Untick a title to exclude it; covers come from your art provider."
right={
<Button disabled={loading} onClick={refresh}>
{loading ? "Scanning…" : "Rescan"}
</Button>
}
>
{entries.length === 0 && excluded.length === 0 ? (
<div className="empty-state">
No games found. Add ROM roots in <strong>Setup</strong>, then
rescan.
</div>
) : (
<div className="scroll">
<table>
<thead>
<tr>
<th style={{ width: 52 }} />
<th>Title</th>
<th style={{ width: 90 }}>Platform</th>
<th>Launch command</th>
<th style={{ width: 60 }}>Include</th>
</tr>
</thead>
<tbody>
{entries.map((e) => (
<tr key={e.external_id}>
<td>
{e.art?.portrait ? (
<img
className="cover"
src={e.art.portrait}
alt={`${e.title} cover`}
loading="lazy"
/>
) : (
<span className="cover empty" />
)}
</td>
<td>{e.title}</td>
<td>
<Badge>{platformOf(e.external_id)}</Badge>
</td>
<td className="mono" title={e.launch?.value}>
{truncate(e.launch?.value ?? "", 70)}
</td>
<td>
<input
type="checkbox"
checked
onChange={() => setExcluded(e.external_id, true)}
/>
</td>
</tr>
))}
{excluded.map((x) => (
<tr key={x.external_id} style={{ opacity: 0.55 }}>
<td>
<span className="cover empty" />
</td>
<td>{x.title}</td>
<td>
<Badge>{platformOf(x.external_id)}</Badge>
</td>
<td className="subtle">excluded</td>
<td>
<input
type="checkbox"
checked={false}
onChange={() => setExcluded(x.external_id, false)}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
{report.skipped.length > 0 && (
<Card
title={`Skipped (${report.skipped.length})`}
right={
<Button onClick={() => setShowSkipped((s) => !s)}>
{showSkipped ? "Hide" : "Show"}
</Button>
}
>
{showSkipped && (
<div className="scroll">
<table>
<tbody>
{report.skipped.map((s) => (
<tr key={s.external_id}>
<td>{s.title}</td>
<td className="subtle">{s.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</Card>
)}
</>
);
};
const truncate = (s: string, n: number): string =>
s.length > n ? `${s.slice(0, n)}…` : s;