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>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { ToastHost } from "./components.js";
|
||||
import { Emulators } from "./pages/Emulators.js";
|
||||
import { Games } from "./pages/Games.js";
|
||||
import { Setup } from "./pages/Setup.js";
|
||||
import { Sync } from "./pages/Sync.js";
|
||||
|
||||
const TABS = [
|
||||
{ id: "setup", label: "Setup", Page: Setup },
|
||||
{ id: "emulators", label: "Emulators", Page: Emulators },
|
||||
{ id: "games", label: "Games", Page: Games },
|
||||
{ id: "sync", label: "Sync", Page: Sync },
|
||||
] as const;
|
||||
|
||||
type TabId = (typeof TABS)[number]["id"];
|
||||
|
||||
const tabFromHash = (): TabId => {
|
||||
const h = window.location.hash.replace(/^#\/?/, "");
|
||||
return (TABS.find((t) => t.id === h)?.id ?? "setup") as TabId;
|
||||
};
|
||||
|
||||
export const App = () => {
|
||||
const [tab, setTab] = useState<TabId>(tabFromHash);
|
||||
|
||||
useEffect(() => {
|
||||
const onHash = () => setTab(tabFromHash());
|
||||
window.addEventListener("hashchange", onHash);
|
||||
return () => window.removeEventListener("hashchange", onHash);
|
||||
}, []);
|
||||
|
||||
const go = (id: TabId) => {
|
||||
window.location.hash = `/${id}`;
|
||||
setTab(id);
|
||||
// Best-effort deep-link sync with the console shell (plugin-ui-surface §5; ignored elsewhere).
|
||||
try {
|
||||
window.parent?.postMessage({ type: "pf-ui:navigate", path: id }, "*");
|
||||
} catch {
|
||||
// not embedded
|
||||
}
|
||||
};
|
||||
|
||||
const Page = TABS.find((t) => t.id === tab)?.Page ?? Setup;
|
||||
|
||||
return (
|
||||
<ToastHost>
|
||||
<div className="app">
|
||||
<div className="topbar">
|
||||
<span className="logo">🎮</span>
|
||||
<h1>ROM Manager</h1>
|
||||
<span className="subtle">
|
||||
— scan ROMs into your punktfunk library
|
||||
</span>
|
||||
</div>
|
||||
<div className="tabs">
|
||||
{TABS.map((t) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
className={`tab${t.id === tab ? " active" : ""}`}
|
||||
onClick={() => go(t.id)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Page />
|
||||
</div>
|
||||
</ToastHost>
|
||||
);
|
||||
};
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
// Typed client for the plugin-local API (relative paths — the SPA is mounted under the console proxy
|
||||
// prefix, so `api/...` resolves to `/plugin-ui/rom-manager/api/...`). Types mirror the backend.
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export interface Platform {
|
||||
id: string;
|
||||
name: string;
|
||||
extensions: string[];
|
||||
libretroSystem?: string;
|
||||
defaultLaunch: { emulator: string; core?: string; extraArgs?: string };
|
||||
disc?: boolean;
|
||||
}
|
||||
export interface EmulatorDef {
|
||||
id: string;
|
||||
name: string;
|
||||
template: string;
|
||||
supportsArchives: boolean;
|
||||
contested?: boolean;
|
||||
}
|
||||
export interface Detected {
|
||||
id: string;
|
||||
name: string;
|
||||
via: "path" | "file" | "flatpak";
|
||||
exeToken: string;
|
||||
contested?: boolean;
|
||||
coresDir?: string;
|
||||
cores?: string[];
|
||||
}
|
||||
export interface RomRoot {
|
||||
dir: string;
|
||||
platform: string;
|
||||
excludes?: string[];
|
||||
}
|
||||
export interface GameOverride {
|
||||
exclude?: boolean;
|
||||
emulator?: string;
|
||||
core?: string;
|
||||
extraArgs?: string;
|
||||
title?: string;
|
||||
art?: string;
|
||||
}
|
||||
export interface Config {
|
||||
roots: RomRoot[];
|
||||
platformLaunch: Record<
|
||||
string,
|
||||
{ emulator: string; core?: string; extraArgs?: string }
|
||||
>;
|
||||
gameOverrides: Record<string, GameOverride>;
|
||||
sync: {
|
||||
pollMinutes: number;
|
||||
watch: boolean;
|
||||
debounceMs: number;
|
||||
dedupeRegions: string[];
|
||||
regionPriority: string[];
|
||||
warnEntries: number;
|
||||
maxEntries: number;
|
||||
closeOnEnd: boolean;
|
||||
};
|
||||
art: {
|
||||
enabled: boolean;
|
||||
provider: "auto" | "steamgriddb" | "libretro";
|
||||
steamGridDbKey?: string;
|
||||
};
|
||||
ui: {
|
||||
standalone: boolean;
|
||||
port: number;
|
||||
bind: string;
|
||||
passwordHash?: string;
|
||||
};
|
||||
devEntry: boolean;
|
||||
}
|
||||
export interface Artwork {
|
||||
portrait?: string | null;
|
||||
hero?: string | null;
|
||||
logo?: string | null;
|
||||
header?: string | null;
|
||||
}
|
||||
export interface Entry {
|
||||
external_id: string;
|
||||
title: string;
|
||||
launch?: { kind: string; value: string } | null;
|
||||
art?: Artwork;
|
||||
prep?: { do: string; undo?: string | null }[];
|
||||
}
|
||||
export interface Skipped {
|
||||
external_id: string;
|
||||
title: string;
|
||||
reason: string;
|
||||
}
|
||||
export interface Report {
|
||||
considered: number;
|
||||
included: number;
|
||||
skipped: Skipped[];
|
||||
excluded: { external_id: string; title: string }[];
|
||||
warnings: string[];
|
||||
truncated: number;
|
||||
perPlatform: Record<string, number>;
|
||||
overWarn: boolean;
|
||||
}
|
||||
export interface Preview {
|
||||
entries: Entry[];
|
||||
report: Report;
|
||||
detected: Detected[];
|
||||
}
|
||||
export interface Status {
|
||||
rootsConfigured: number;
|
||||
os: "linux" | "windows";
|
||||
artProvider: string | null;
|
||||
lastSync?: { fingerprint: string; count: number; at: number };
|
||||
lastReport?: Report;
|
||||
detected?: { at: number; emulators: Detected[] };
|
||||
paths: { dir: string; config: string; cache: string; relConfig: string };
|
||||
syncing: boolean;
|
||||
}
|
||||
|
||||
const api = async <T>(path: string, opts?: RequestInit): Promise<T> => {
|
||||
const res = await fetch(path, {
|
||||
headers: { "content-type": "application/json" },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(body.error ?? `HTTP ${res.status}`);
|
||||
}
|
||||
return (await res.json()) as T;
|
||||
};
|
||||
|
||||
export const getStatus = () => api<Status>("api/status");
|
||||
export const getConfig = () => api<Config>("api/config");
|
||||
export const putConfig = (config: Config) =>
|
||||
api<Config>("api/config", { method: "PUT", body: JSON.stringify(config) });
|
||||
export const getPreview = () => api<Preview>("api/preview");
|
||||
export const runDetect = () =>
|
||||
api<Detected[]>("api/detect", { method: "POST" });
|
||||
export const runSync = () => api<Report>("api/sync", { method: "POST" });
|
||||
export const getPlatforms = () => api<Platform[]>("api/platforms");
|
||||
export const getEmulators = () =>
|
||||
api<{ defs: EmulatorDef[]; detected: Detected[] }>("api/emulators");
|
||||
|
||||
/** Live engine status via SSE (falls back to a one-shot fetch if EventSource fails). */
|
||||
export const useStatusStream = (): Status | undefined => {
|
||||
const [status, setStatus] = useState<Status>();
|
||||
useEffect(() => {
|
||||
getStatus()
|
||||
.then(setStatus)
|
||||
.catch(() => {});
|
||||
try {
|
||||
const es = new EventSource("api/events");
|
||||
es.addEventListener("status", (e) => {
|
||||
try {
|
||||
setStatus(JSON.parse((e as MessageEvent).data));
|
||||
} catch {
|
||||
// ignore malformed frame
|
||||
}
|
||||
});
|
||||
return () => es.close();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
return status;
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import { createContext, useCallback, useContext, useState } from "react";
|
||||
|
||||
export const Card = ({
|
||||
title,
|
||||
hint,
|
||||
right,
|
||||
children,
|
||||
}: {
|
||||
title?: string;
|
||||
hint?: string;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
}) => (
|
||||
<div className="card">
|
||||
{(title || right) && (
|
||||
<div className="row spread" style={{ marginBottom: hint ? 0 : 8 }}>
|
||||
{title && <h2>{title}</h2>}
|
||||
{right}
|
||||
</div>
|
||||
)}
|
||||
{hint && <p className="hint">{hint}</p>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const Button = ({
|
||||
primary,
|
||||
children,
|
||||
...rest
|
||||
}: ButtonHTMLAttributes<HTMLButtonElement> & { primary?: boolean }) => (
|
||||
<button type="button" className={`btn${primary ? " primary" : ""}`} {...rest}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
export const Badge = ({
|
||||
tone,
|
||||
children,
|
||||
}: {
|
||||
tone?: "ok" | "warn" | "accent";
|
||||
children: ReactNode;
|
||||
}) => <span className={`badge${tone ? ` ${tone}` : ""}`}>{children}</span>;
|
||||
|
||||
// ── Toasts ────────────────────────────────────────────────────────────────────────────────────
|
||||
const ToastCtx = createContext<(msg: string) => void>(() => {});
|
||||
export const useToast = () => useContext(ToastCtx);
|
||||
|
||||
export const ToastHost = ({ children }: { children: ReactNode }) => {
|
||||
const [msg, setMsg] = useState<string>();
|
||||
const show = useCallback((m: string) => {
|
||||
setMsg(m);
|
||||
window.setTimeout(() => setMsg(undefined), 2600);
|
||||
}, []);
|
||||
return (
|
||||
<ToastCtx.Provider value={show}>
|
||||
{children}
|
||||
{msg && <div className="toast">{msg}</div>}
|
||||
</ToastCtx.Provider>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { type Config, getConfig, putConfig } from "./api.js";
|
||||
|
||||
/** Load + save the plugin config, with a local editable copy. */
|
||||
export const useConfig = () => {
|
||||
const [config, setConfig] = useState<Config | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const reload = useCallback(() => {
|
||||
getConfig()
|
||||
.then(setConfig)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
useEffect(reload, [reload]);
|
||||
|
||||
const save = useCallback(async (next: Config): Promise<boolean> => {
|
||||
setSaving(true);
|
||||
setError(undefined);
|
||||
try {
|
||||
setConfig(await putConfig(next));
|
||||
return true;
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { config, setConfig, reload, save, saving, error };
|
||||
};
|
||||
|
||||
/** The platform id out of an `external_id` (`snes/Foo.sfc` → `snes`; `snes/0/Foo.sfc` → `snes`). */
|
||||
export const platformOf = (externalId: string): string =>
|
||||
externalId.split("/")[0] ?? "";
|
||||
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App.js";
|
||||
import "./styles.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
type Detected,
|
||||
type EmulatorDef,
|
||||
getEmulators,
|
||||
getPlatforms,
|
||||
type Platform,
|
||||
runDetect,
|
||||
} from "../api.js";
|
||||
import { Badge, Button, Card, useToast } from "../components.js";
|
||||
import { useConfig } from "../hooks.js";
|
||||
|
||||
export const Emulators = () => {
|
||||
const { config, setConfig, save, saving } = useConfig();
|
||||
const [defs, setDefs] = useState<EmulatorDef[]>([]);
|
||||
const [detected, setDetected] = useState<Detected[]>([]);
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const load = () => {
|
||||
getEmulators().then((e) => {
|
||||
setDefs(e.defs);
|
||||
setDetected(e.detected);
|
||||
});
|
||||
};
|
||||
useEffect(load, []);
|
||||
useEffect(() => {
|
||||
getPlatforms()
|
||||
.then(setPlatforms)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const detectedById = new Map(detected.map((d) => [d.id, d]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="Detected emulators"
|
||||
hint="Best-effort detection: PATH, Flatpak, then known install paths. Missing ones can still be launched via a custom template."
|
||||
right={
|
||||
<Button
|
||||
disabled={detecting}
|
||||
onClick={async () => {
|
||||
setDetecting(true);
|
||||
try {
|
||||
setDetected(await runDetect());
|
||||
toast("Re-detected emulators");
|
||||
} finally {
|
||||
setDetecting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{detecting ? "Detecting…" : "Re-detect"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Emulator</th>
|
||||
<th>Status</th>
|
||||
<th>Cores</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{defs.map((def) => {
|
||||
const d = detectedById.get(def.id);
|
||||
return (
|
||||
<tr key={def.id}>
|
||||
<td>
|
||||
{def.name} <span className="mono">{def.id}</span>
|
||||
</td>
|
||||
<td>
|
||||
{d ? (
|
||||
<Badge tone="ok">detected · {d.via}</Badge>
|
||||
) : (
|
||||
<Badge>not found</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td>{d?.cores?.length ? d.cores.length : "—"}</td>
|
||||
<td>
|
||||
{def.contested && <Badge tone="warn">contested</Badge>}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
|
||||
{config && (
|
||||
<Card
|
||||
title="Per-platform emulator"
|
||||
hint="The default emulator + core for each platform. Override here to prefer a different emulator or RetroArch core."
|
||||
right={
|
||||
<Button
|
||||
primary
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
if (await save(config)) toast("Saved — syncing library");
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving…" : "Save & sync"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="scroll">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Platform</th>
|
||||
<th>Emulator</th>
|
||||
<th>Core (RetroArch)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{platforms.map((p) => {
|
||||
const override = config.platformLaunch[p.id];
|
||||
const emulator =
|
||||
override?.emulator ?? p.defaultLaunch.emulator;
|
||||
const core = override?.core ?? p.defaultLaunch.core ?? "";
|
||||
const detCores = detectedById.get(emulator)?.cores ?? [];
|
||||
const setLaunch = (patch: {
|
||||
emulator?: string;
|
||||
core?: string;
|
||||
}) => {
|
||||
const next = {
|
||||
emulator: patch.emulator ?? emulator,
|
||||
core: patch.core ?? core,
|
||||
};
|
||||
setConfig({
|
||||
...config,
|
||||
platformLaunch: {
|
||||
...config.platformLaunch,
|
||||
[p.id]: next,
|
||||
},
|
||||
});
|
||||
};
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td>{p.name}</td>
|
||||
<td>
|
||||
<select
|
||||
value={emulator}
|
||||
onChange={(e) =>
|
||||
setLaunch({ emulator: e.target.value })
|
||||
}
|
||||
>
|
||||
{defs.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
{detectedById.has(d.id) ? " ✓" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{emulator === "retroarch" ? (
|
||||
detCores.length ? (
|
||||
<select
|
||||
value={core}
|
||||
onChange={(e) =>
|
||||
setLaunch({ core: e.target.value })
|
||||
}
|
||||
>
|
||||
{!detCores.includes(core) && (
|
||||
<option value={core}>{core || "—"}</option>
|
||||
)}
|
||||
{detCores.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
value={core}
|
||||
placeholder="snes9x"
|
||||
onChange={(e) =>
|
||||
setLaunch({ core: e.target.value })
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<span className="subtle">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
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;
|
||||
@@ -0,0 +1,117 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getPlatforms, type Platform, type RomRoot } from "../api.js";
|
||||
import { Button, Card, useToast } from "../components.js";
|
||||
import { useConfig } from "../hooks.js";
|
||||
|
||||
export const Setup = () => {
|
||||
const { config, setConfig, save, saving } = useConfig();
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
const toast = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
getPlatforms()
|
||||
.then(setPlatforms)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (!config) return <Card>Loading…</Card>;
|
||||
|
||||
const roots = config.roots;
|
||||
const setRoots = (next: RomRoot[]) => setConfig({ ...config, roots: next });
|
||||
const update = (i: number, patch: Partial<RomRoot>) =>
|
||||
setRoots(roots.map((r, j) => (j === i ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="ROM roots"
|
||||
hint="Point each folder at the console platform its files belong to. Shared extensions (.iso, .bin) are resolved by the folder's platform."
|
||||
right={
|
||||
<Button
|
||||
primary
|
||||
disabled={saving}
|
||||
onClick={async () => {
|
||||
if (await save(config)) toast("Saved — syncing library");
|
||||
}}
|
||||
>
|
||||
{saving ? "Saving…" : "Save & sync"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{roots.length === 0 && (
|
||||
<p className="subtle" style={{ margin: "8px 0 16px" }}>
|
||||
No roots yet. Add a folder of ROMs to get started.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{roots.length > 0 && (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: "45%" }}>Folder</th>
|
||||
<th style={{ width: "25%" }}>Platform</th>
|
||||
<th>Excludes (comma-separated globs)</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{roots.map((root, i) => (
|
||||
<tr key={i}>
|
||||
<td>
|
||||
<input
|
||||
className="grow"
|
||||
style={{ width: "100%" }}
|
||||
value={root.dir}
|
||||
placeholder="/home/you/roms/snes"
|
||||
onChange={(e) => update(i, { dir: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<select
|
||||
value={root.platform}
|
||||
onChange={(e) => update(i, { platform: e.target.value })}
|
||||
>
|
||||
<option value="">— pick —</option>
|
||||
{platforms.map((p) => (
|
||||
<option key={p.id} value={p.id}>
|
||||
{p.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
style={{ width: "100%" }}
|
||||
value={(root.excludes ?? []).join(", ")}
|
||||
placeholder="*.sav, bios/**"
|
||||
onChange={(e) =>
|
||||
update(i, {
|
||||
excludes: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<Button
|
||||
className="icon"
|
||||
onClick={() => setRoots(roots.filter((_, j) => j !== i))}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Button onClick={() => setRoots([...roots, { dir: "", platform: "" }])}>
|
||||
+ Add root
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,255 @@
|
||||
import { useState } from "react";
|
||||
import { type Config, runSync, useStatusStream } from "../api.js";
|
||||
import { Badge, Button, Card, useToast } from "../components.js";
|
||||
import { useConfig } from "../hooks.js";
|
||||
|
||||
export const Sync = () => {
|
||||
const status = useStatusStream();
|
||||
const { config, setConfig, save, saving } = useConfig();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const toast = useToast();
|
||||
|
||||
const report = status?.lastReport;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
title="Status"
|
||||
right={
|
||||
<Button
|
||||
primary
|
||||
disabled={syncing || status?.syncing}
|
||||
onClick={async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
await runSync();
|
||||
toast("Synced");
|
||||
} catch (e) {
|
||||
toast(String(e));
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{syncing || status?.syncing ? "Syncing…" : "Sync now"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="stats">
|
||||
<div className="stat">
|
||||
<span className="n">{status?.rootsConfigured ?? "—"}</span>
|
||||
<span className="l">ROM roots</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="n">{status?.lastSync?.count ?? "—"}</span>
|
||||
<span className="l">titles in library</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="n">{report?.skipped.length ?? "—"}</span>
|
||||
<span className="l">skipped</span>
|
||||
</div>
|
||||
<div className="stat">
|
||||
<span className="n" style={{ fontSize: 15, paddingTop: 6 }}>
|
||||
{status?.artProvider ? (
|
||||
<Badge tone="accent">{status.artProvider}</Badge>
|
||||
) : (
|
||||
"off"
|
||||
)}
|
||||
</span>
|
||||
<span className="l">art provider</span>
|
||||
</div>
|
||||
</div>
|
||||
{status?.lastSync && (
|
||||
<p className="subtle" style={{ marginTop: 12 }}>
|
||||
Last sync {new Date(status.lastSync.at).toLocaleString()} · OS{" "}
|
||||
{status.os}
|
||||
</p>
|
||||
)}
|
||||
{report?.overWarn && (
|
||||
<p style={{ color: "var(--warn)", marginTop: 8 }}>
|
||||
Large library ({report.included} entries) — reconcile is a single
|
||||
full-body PUT; watch host memory.
|
||||
</p>
|
||||
)}
|
||||
{report && report.warnings.length > 0 && (
|
||||
<details style={{ marginTop: 8 }}>
|
||||
<summary className="subtle">
|
||||
{report.warnings.length} warning(s)
|
||||
</summary>
|
||||
<ul>
|
||||
{report.warnings.slice(0, 20).map((w) => (
|
||||
<li key={w} className="subtle">
|
||||
{w}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{config && (
|
||||
<>
|
||||
<ArtCard
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
save={save}
|
||||
saving={saving}
|
||||
/>
|
||||
<SyncOptionsCard
|
||||
config={config}
|
||||
setConfig={setConfig}
|
||||
save={save}
|
||||
saving={saving}
|
||||
/>
|
||||
{status && (
|
||||
<Card title="Files">
|
||||
<p className="mono">{status.paths.config}</p>
|
||||
<p className="mono">{status.paths.cache}</p>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface CardProps {
|
||||
config: Config;
|
||||
setConfig: (c: Config) => void;
|
||||
save: (c: Config) => Promise<boolean>;
|
||||
saving: boolean;
|
||||
}
|
||||
|
||||
const ArtCard = ({ config, setConfig, save, saving }: CardProps) => {
|
||||
const toast = useToast();
|
||||
const art = config.art;
|
||||
const set = (patch: Partial<Config["art"]>) =>
|
||||
setConfig({ ...config, art: { ...art, ...patch } });
|
||||
return (
|
||||
<Card
|
||||
title="Box art"
|
||||
hint="SteamGridDB (like Steam ROM Manager) gives portrait + hero + logo + header with a free API key. Without a key, the keyless libretro-thumbnails fallback provides box art only."
|
||||
right={
|
||||
<Button
|
||||
primary
|
||||
disabled={saving}
|
||||
onClick={async () => (await save(config)) && toast("Saved")}
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="row wrap" style={{ gap: 16 }}>
|
||||
<label className="field">
|
||||
<span>Enabled</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={art.enabled}
|
||||
onChange={(e) => set({ enabled: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Provider</span>
|
||||
<select
|
||||
value={art.provider}
|
||||
onChange={(e) =>
|
||||
set({ provider: e.target.value as Config["art"]["provider"] })
|
||||
}
|
||||
>
|
||||
<option value="auto">auto (SteamGridDB if key set)</option>
|
||||
<option value="steamgriddb">SteamGridDB</option>
|
||||
<option value="libretro">libretro-thumbnails</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field grow">
|
||||
<span>SteamGridDB API key</span>
|
||||
<input
|
||||
type="password"
|
||||
value={art.steamGridDbKey ?? ""}
|
||||
placeholder="from steamgriddb.com › profile › preferences"
|
||||
onChange={(e) =>
|
||||
set({ steamGridDbKey: e.target.value || undefined })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const SyncOptionsCard = ({ config, setConfig, save, saving }: CardProps) => {
|
||||
const toast = useToast();
|
||||
const sync = config.sync;
|
||||
const set = (patch: Partial<Config["sync"]>) =>
|
||||
setConfig({ ...config, sync: { ...sync, ...patch } });
|
||||
return (
|
||||
<Card
|
||||
title="Sync options"
|
||||
right={
|
||||
<Button
|
||||
primary
|
||||
disabled={saving}
|
||||
onClick={async () => (await save(config)) && toast("Saved — syncing")}
|
||||
>
|
||||
{saving ? "Saving…" : "Save & sync"}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="row wrap" style={{ gap: 16 }}>
|
||||
<label className="field">
|
||||
<span>Poll (minutes)</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
style={{ width: 90 }}
|
||||
value={sync.pollMinutes}
|
||||
onChange={(e) => set({ pollMinutes: Number(e.target.value) || 15 })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Watch filesystem</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sync.watch}
|
||||
onChange={(e) => set({ watch: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Max entries</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
style={{ width: 110 }}
|
||||
value={sync.maxEntries}
|
||||
onChange={(e) =>
|
||||
set({ maxEntries: Number(e.target.value) || 5000 })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Close emulator on stream end</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={sync.closeOnEnd}
|
||||
onChange={(e) => set({ closeOnEnd: e.target.checked })}
|
||||
/>
|
||||
</label>
|
||||
<label className="field grow">
|
||||
<span>Region-dedupe platforms (comma-separated ids)</span>
|
||||
<input
|
||||
value={sync.dedupeRegions.join(", ")}
|
||||
placeholder="snes, genesis"
|
||||
onChange={(e) =>
|
||||
set({
|
||||
dedupeRegions: e.target.value
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,267 @@
|
||||
/* A self-contained dark theme (the console renders plugin UIs on a dark canvas — plugin-ui-surface
|
||||
§5). Purple accent to sit alongside the punktfunk console; no external UI library so the SPA builds
|
||||
anywhere. */
|
||||
:root {
|
||||
--bg: #0b0b0f;
|
||||
--panel: #14141b;
|
||||
--panel-2: #1b1b24;
|
||||
--border: #2a2a36;
|
||||
--text: #e5e7eb;
|
||||
--muted: #9ca3af;
|
||||
--accent: #7c3aed;
|
||||
--accent-hover: #6d28d9;
|
||||
--danger: #ef4444;
|
||||
--ok: #22c55e;
|
||||
--warn: #f59e0b;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
height: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font:
|
||||
14px / 1.5 system-ui,
|
||||
-apple-system,
|
||||
"Segoe UI",
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
a {
|
||||
color: var(--accent);
|
||||
}
|
||||
code {
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px 64px;
|
||||
}
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.topbar .logo {
|
||||
font-size: 20px;
|
||||
}
|
||||
.subtle {
|
||||
color: var(--muted);
|
||||
}
|
||||
.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 18px 0 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tab {
|
||||
padding: 8px 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
font-size: 14px;
|
||||
}
|
||||
.tab:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
.tab.active {
|
||||
color: var(--text);
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 16px 18px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.card h2 {
|
||||
font-size: 15px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.card .hint {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
margin: 2px 0 12px;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.row.wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.spread {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.grow {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
button.btn {
|
||||
padding: 7px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--panel-2);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
button.btn:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
button.btn.primary {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
button.btn.primary:hover {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
button.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
button.btn.icon {
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
padding: 7px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: #0f0f16;
|
||||
color: var(--text);
|
||||
font-size: 13px;
|
||||
}
|
||||
input:focus,
|
||||
select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
label.field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--muted);
|
||||
}
|
||||
.badge.ok {
|
||||
color: var(--ok);
|
||||
border-color: color-mix(in srgb, var(--ok) 40%, var(--border));
|
||||
}
|
||||
.badge.warn {
|
||||
color: var(--warn);
|
||||
border-color: color-mix(in srgb, var(--warn) 40%, var(--border));
|
||||
}
|
||||
.badge.accent {
|
||||
color: #c4b5fd;
|
||||
border-color: color-mix(in srgb, var(--accent) 50%, var(--border));
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 42px;
|
||||
height: 60px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
.cover.empty {
|
||||
display: inline-block;
|
||||
}
|
||||
.mono {
|
||||
font-family: ui-monospace, Menlo, monospace;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
word-break: break-all;
|
||||
}
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
padding: 32px;
|
||||
}
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 10px 16px;
|
||||
font-size: 13px;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.stat {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.stat .n {
|
||||
font-size: 22px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.stat .l {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.scroll {
|
||||
max-height: 420px;
|
||||
overflow: auto;
|
||||
}
|
||||
Reference in New Issue
Block a user