feat(ui): the five pages + shell — console-grade @unom/app-ui chrome
PluginShell over the kit router (flat routes, console deep-link bridge), standalone header only outside the iframe. Every subscription renders through a shared Gate (kit ResultGate + colocated PageSkeleton + retry error EmptyState). Overview: first-run hero, tone-coded StatCards, sync button that tracks engine state, warnings accordion, live EventFeed. Library: @container-responsive cover grid with whileInView stagger, include Switches saving gameOverrides immediately, skipped/excluded card with re-include. Sources: PathListEditor over draft.roots with platform Select + excludes globs. Emulators: detected roster with via/ contested/cores badges + re-detect, per-platform launch overrides. Settings: artwork/sync/files groups over resolved defaults, sticky SaveBar (motion slide-up on dirty; entry variants neutralized inside the object-form motion wrapper so the button isn't stuck at opacity 0). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
// The app frame: kit router (flat routes, console deep-link bridge) inside the shared
|
||||||
|
// PluginShell chrome. Pages own their data; the shell owns navigation + the toaster.
|
||||||
|
|
||||||
|
import { createPluginRouter, useIsEmbedded } from "@punktfunk/plugin-kit/react";
|
||||||
|
import {
|
||||||
|
PluginShell,
|
||||||
|
type PluginShellNavItem,
|
||||||
|
} from "@unom/app-ui/plugin-shell";
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
Gamepad2,
|
||||||
|
LayoutDashboard,
|
||||||
|
Library,
|
||||||
|
Settings2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { FC } from "react";
|
||||||
|
import { EmulatorsPage } from "@/pages/emulators";
|
||||||
|
import { LibraryPage } from "@/pages/library";
|
||||||
|
import { OverviewPage } from "@/pages/overview";
|
||||||
|
import { SettingsPage } from "@/pages/settings";
|
||||||
|
import { SourcesPage } from "@/pages/sources";
|
||||||
|
import { type PageProps, ROUTES, type Route } from "@/routes";
|
||||||
|
|
||||||
|
const { usePluginRoute } = createPluginRouter(ROUTES, "overview");
|
||||||
|
|
||||||
|
const NAV: ReadonlyArray<PluginShellNavItem> = [
|
||||||
|
{ id: "overview", label: "Overview", icon: LayoutDashboard },
|
||||||
|
{ id: "library", label: "Library", icon: Library },
|
||||||
|
{ id: "sources", label: "Sources", icon: FolderOpen },
|
||||||
|
{ id: "emulators", label: "Emulators", icon: Gamepad2 },
|
||||||
|
{ id: "settings", label: "Settings", icon: Settings2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PAGES: Record<Route, FC<PageProps>> = {
|
||||||
|
overview: OverviewPage,
|
||||||
|
library: LibraryPage,
|
||||||
|
sources: SourcesPage,
|
||||||
|
emulators: EmulatorsPage,
|
||||||
|
settings: SettingsPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Only shown in a standalone tab — embedded in the console, the console is the chrome. */
|
||||||
|
const StandaloneHeader = () => (
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<Gamepad2 className="size-4 text-primary" />
|
||||||
|
ROM Manager
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const App = () => {
|
||||||
|
const { route, navigate } = usePluginRoute();
|
||||||
|
const embedded = useIsEmbedded();
|
||||||
|
const Page = PAGES[route];
|
||||||
|
return (
|
||||||
|
<PluginShell
|
||||||
|
nav={NAV}
|
||||||
|
active={route}
|
||||||
|
onNavigate={(id) => navigate(id as Route)}
|
||||||
|
standaloneHeader={embedded ? undefined : <StandaloneHeader />}
|
||||||
|
>
|
||||||
|
<Page navigate={navigate} />
|
||||||
|
</PluginShell>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
// The one loading/error convention for every subscription on every page: the kit's
|
||||||
|
// ResultGate wired to this plugin's skeleton + error visuals. Pages pass their own
|
||||||
|
// colocated PageSkeleton; failures render a retryable error EmptyState.
|
||||||
|
|
||||||
|
import { ResultGate } from "@punktfunk/plugin-kit/react";
|
||||||
|
import { Button } from "@unom/ui/button";
|
||||||
|
import { EmptyState } from "@unom/ui/empty-state";
|
||||||
|
import type { AsyncResult } from "effect/unstable/reactivity";
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { errorText } from "@/lib/errors";
|
||||||
|
|
||||||
|
export type GateProps<A, E> = {
|
||||||
|
readonly result: AsyncResult.AsyncResult<A, E>;
|
||||||
|
/** The page's colocated skeleton, shown while the first value loads. */
|
||||||
|
readonly skeleton: ReactNode;
|
||||||
|
/** Usually `useAtomRefresh(theAtom)`. */
|
||||||
|
readonly retry?: () => void;
|
||||||
|
readonly children: (value: A) => ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Gate = <A, E>(props: GateProps<A, E>): ReactNode => (
|
||||||
|
<ResultGate
|
||||||
|
result={props.result}
|
||||||
|
waiting={props.skeleton}
|
||||||
|
retry={props.retry}
|
||||||
|
failure={(error, retry) => (
|
||||||
|
<EmptyState
|
||||||
|
variant="error"
|
||||||
|
icon={TriangleAlert}
|
||||||
|
title="Something went wrong"
|
||||||
|
description={errorText(error)}
|
||||||
|
action={
|
||||||
|
retry === undefined ? undefined : (
|
||||||
|
<Button variant="outline" onClick={retry}>
|
||||||
|
Try again
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</ResultGate>
|
||||||
|
);
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// The sticky save bar every draft-editing page shares: slides up when the draft goes
|
||||||
|
// dirty, pins to the viewport bottom, offers Save + Discard.
|
||||||
|
import { AnimatedButton, Button } from "@unom/ui/button";
|
||||||
|
import { Spinner } from "@unom/ui/spinner";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
|
||||||
|
export type SaveBarProps = {
|
||||||
|
readonly dirty: boolean;
|
||||||
|
readonly saving: boolean;
|
||||||
|
readonly onSave: () => void;
|
||||||
|
readonly onDiscard: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SaveBar = ({ dirty, saving, onSave, onDiscard }: SaveBarProps) => (
|
||||||
|
<AnimatePresence>
|
||||||
|
{dirty ? (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 24 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: 24 }}
|
||||||
|
transition={{ duration: 0.3, ease: [0.165, 0.84, 0.44, 1] }}
|
||||||
|
className="sticky bottom-4 z-20 mt-6"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-card border border-border bg-popover/90 px-5 py-3 shadow-lg backdrop-blur">
|
||||||
|
<span className="text-sm text-muted-foreground">Unsaved changes</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="ghost" onClick={onDiscard} disabled={saving}>
|
||||||
|
Discard
|
||||||
|
</Button>
|
||||||
|
{/* variants={{}} neutralizes the button's Section-stagger entry variants —
|
||||||
|
inside this object-form motion wrapper the `enter` label never
|
||||||
|
propagates, which would leave the button stuck at opacity 0. */}
|
||||||
|
<AnimatedButton
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving}
|
||||||
|
variants={{}}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
>
|
||||||
|
{saving ? <Spinner className="size-4" /> : null}
|
||||||
|
Save changes
|
||||||
|
</AnimatedButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
) : null}
|
||||||
|
</AnimatePresence>
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import "./styles.css";
|
||||||
|
import { RegistryProvider } from "@effect/atom-react";
|
||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { App } from "./App";
|
||||||
|
import { defaultApiMode } from "./mocks/scenario";
|
||||||
|
|
||||||
|
// Zero-host dev loop: install the mock transport BEFORE the first render so the very
|
||||||
|
// first atom reads hit fixtures. The whole branch is dead-code-eliminated from
|
||||||
|
// production builds (import.meta.env.DEV is false), so no fixture code ships.
|
||||||
|
if (import.meta.env.DEV && defaultApiMode === "mock") {
|
||||||
|
await import("./mocks/http");
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<RegistryProvider>
|
||||||
|
<App />
|
||||||
|
</RegistryProvider>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
@@ -0,0 +1,350 @@
|
|||||||
|
// Emulators — subscribes emulatorsAtom + platformsAtom + the config draft; mutates
|
||||||
|
// runDetect (re-detect) and saveConfig (per-platform launch overrides, via the draft).
|
||||||
|
|
||||||
|
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||||
|
import type {
|
||||||
|
DetectedEmulator,
|
||||||
|
EmulatorsPayload,
|
||||||
|
Platform,
|
||||||
|
RawConfig,
|
||||||
|
} from "@rom-manager/contract";
|
||||||
|
import { SettingsGroup, SettingsRow } from "@unom/app-ui/settings";
|
||||||
|
import { Badge } from "@unom/ui/badge";
|
||||||
|
import { Button } from "@unom/ui/button";
|
||||||
|
import Card from "@unom/ui/card";
|
||||||
|
import { InputText } from "@unom/ui/form/input-text";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@unom/ui/form/select";
|
||||||
|
import { Skeleton } from "@unom/ui/skeleton";
|
||||||
|
import { Spinner } from "@unom/ui/spinner";
|
||||||
|
import { toast } from "@unom/ui/toast";
|
||||||
|
import { Cause, Exit } from "effect";
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { Gate } from "@/components/gate";
|
||||||
|
import { SaveBar } from "@/components/save-bar";
|
||||||
|
import {
|
||||||
|
emulatorsAtom,
|
||||||
|
platformsAtom,
|
||||||
|
RUN_DETECT_KEYS,
|
||||||
|
runDetect,
|
||||||
|
} from "@/data/atoms";
|
||||||
|
import { useConfigDraft } from "@/data/drafts";
|
||||||
|
import { errorText } from "@/lib/errors";
|
||||||
|
import type { PageProps } from "@/routes";
|
||||||
|
|
||||||
|
type PlatformLaunchRaw = NonNullable<RawConfig["platformLaunch"]>[string];
|
||||||
|
|
||||||
|
const PageSkeleton = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
<Skeleton className="h-80" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Display names for known emulator ids that are not currently detected (id fallback). */
|
||||||
|
const EMULATOR_LABELS: Record<string, string> = {
|
||||||
|
retroarch: "RetroArch",
|
||||||
|
dolphin: "Dolphin",
|
||||||
|
pcsx2: "PCSX2",
|
||||||
|
duckstation: "DuckStation",
|
||||||
|
ppsspp: "PPSSPP",
|
||||||
|
mgba: "mGBA",
|
||||||
|
melonds: "melonDS",
|
||||||
|
flycast: "Flycast",
|
||||||
|
xemu: "xemu",
|
||||||
|
azahar: "Azahar",
|
||||||
|
ryujinx: "Ryujinx",
|
||||||
|
};
|
||||||
|
|
||||||
|
const emulatorLabel = (id: string, detected?: DetectedEmulator): string =>
|
||||||
|
detected?.name ?? EMULATOR_LABELS[id] ?? id;
|
||||||
|
|
||||||
|
type RosterRow = {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly detected: DetectedEmulator | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The emulator roster the page renders: every detected emulator plus every emulator
|
||||||
|
* any platform's default launch references (the API only ships detected ones — the
|
||||||
|
* full definition catalog is plugin-internal).
|
||||||
|
*/
|
||||||
|
const useRoster = (
|
||||||
|
emulators: EmulatorsPayload,
|
||||||
|
platforms: ReadonlyArray<Platform>,
|
||||||
|
): ReadonlyArray<RosterRow> =>
|
||||||
|
useMemo(() => {
|
||||||
|
const detectedById = new Map(
|
||||||
|
emulators.detected.map((emulator) => [emulator.id, emulator]),
|
||||||
|
);
|
||||||
|
const ids = new Set<string>(detectedById.keys());
|
||||||
|
for (const platform of platforms) ids.add(platform.defaultLaunch.emulator);
|
||||||
|
return [...ids]
|
||||||
|
.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: emulatorLabel(id, detectedById.get(id)),
|
||||||
|
detected: detectedById.get(id),
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
a.detected === b.detected || (a.detected && b.detected)
|
||||||
|
? a.name.localeCompare(b.name)
|
||||||
|
: a.detected
|
||||||
|
? -1
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
}, [emulators, platforms]);
|
||||||
|
|
||||||
|
const DetectButton = () => {
|
||||||
|
const detectResult = useAtomValue(runDetect);
|
||||||
|
const detect = useAtomSet(runDetect, { mode: "promiseExit" });
|
||||||
|
const pending = AsyncResult.isWaiting(detectResult);
|
||||||
|
const onDetect = () => {
|
||||||
|
void detect({ reactivityKeys: [...RUN_DETECT_KEYS] }).then((exit) => {
|
||||||
|
if (Exit.isSuccess(exit)) {
|
||||||
|
toast.success(
|
||||||
|
`Detection finished — ${exit.value.detected.length} ${exit.value.detected.length === 1 ? "emulator" : "emulators"} found`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error("Detection failed", {
|
||||||
|
description: errorText(Cause.squash(exit.cause)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Button variant="outline" onClick={onDetect} disabled={pending}>
|
||||||
|
{pending ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
)}
|
||||||
|
Re-detect
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const DetectedList = ({
|
||||||
|
emulators,
|
||||||
|
platforms,
|
||||||
|
}: {
|
||||||
|
emulators: EmulatorsPayload;
|
||||||
|
platforms: ReadonlyArray<Platform>;
|
||||||
|
}) => {
|
||||||
|
const roster = useRoster(emulators, platforms);
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight">
|
||||||
|
Detected emulators
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{emulators.detectedAt === null
|
||||||
|
? "Not scanned yet"
|
||||||
|
: `Last scan ${new Date(emulators.detectedAt).toLocaleString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<DetectButton />
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-border">
|
||||||
|
{roster.map((row) => (
|
||||||
|
<div key={row.id} className="flex items-center gap-3 py-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="font-medium">{row.name}</span>
|
||||||
|
{row.detected !== undefined ? (
|
||||||
|
<Badge variant="success" size="sm" dot>
|
||||||
|
detected · {row.detected.via}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
not found
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{row.detected?.contested === true ? (
|
||||||
|
<Badge variant="warn" size="sm">
|
||||||
|
contested
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{row.detected?.cores !== undefined &&
|
||||||
|
row.detected.cores.length > 0 ? (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{row.detected.cores.length} cores
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{row.detected?.exePath !== undefined ? (
|
||||||
|
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">
|
||||||
|
{row.detected.exePath}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORM_DEFAULT = "__default";
|
||||||
|
|
||||||
|
const LaunchRow = ({
|
||||||
|
platform,
|
||||||
|
emulators,
|
||||||
|
launch,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
platform: Platform;
|
||||||
|
emulators: EmulatorsPayload;
|
||||||
|
launch: PlatformLaunchRaw | undefined;
|
||||||
|
onChange: (launch: PlatformLaunchRaw | undefined) => void;
|
||||||
|
}) => {
|
||||||
|
const effectiveEmulator = launch?.emulator ?? platform.defaultLaunch.emulator;
|
||||||
|
const detected = emulators.detected.find(
|
||||||
|
(emulator) => emulator.id === effectiveEmulator,
|
||||||
|
);
|
||||||
|
const cores = detected?.cores ?? [];
|
||||||
|
const defaultCore =
|
||||||
|
launch?.emulator === undefined ||
|
||||||
|
launch.emulator === platform.defaultLaunch.emulator
|
||||||
|
? platform.defaultLaunch.core
|
||||||
|
: undefined;
|
||||||
|
const description =
|
||||||
|
platform.defaultLaunch.core === undefined
|
||||||
|
? `default: ${emulatorLabel(platform.defaultLaunch.emulator)}`
|
||||||
|
: `default: ${emulatorLabel(platform.defaultLaunch.emulator)} · ${platform.defaultLaunch.core}`;
|
||||||
|
return (
|
||||||
|
<SettingsRow label={platform.name} description={description}>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<Select
|
||||||
|
value={launch?.emulator ?? PLATFORM_DEFAULT}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
onChange(
|
||||||
|
value === PLATFORM_DEFAULT ? undefined : { emulator: value },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={PLATFORM_DEFAULT}>Platform default</SelectItem>
|
||||||
|
{emulators.detected.map((emulator) => (
|
||||||
|
<SelectItem key={emulator.id} value={emulator.id}>
|
||||||
|
{emulator.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{cores.length > 0 ? (
|
||||||
|
<Select
|
||||||
|
value={launch?.core ?? defaultCore}
|
||||||
|
onValueChange={(core) =>
|
||||||
|
onChange({ emulator: effectiveEmulator, ...launch, core })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-44">
|
||||||
|
<SelectValue placeholder="Core" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{cores.map((core) => (
|
||||||
|
<SelectItem key={core} value={core}>
|
||||||
|
{core}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : null}
|
||||||
|
<InputText
|
||||||
|
value={launch?.extraArgs ?? ""}
|
||||||
|
placeholder="extra args"
|
||||||
|
className="w-36"
|
||||||
|
onChange={(event) => {
|
||||||
|
const extraArgs = event.target.value;
|
||||||
|
// `extraArgs` is an optionalKey — omit it when cleared.
|
||||||
|
const { extraArgs: _drop, ...rest } = launch ?? {
|
||||||
|
emulator: effectiveEmulator,
|
||||||
|
};
|
||||||
|
onChange(
|
||||||
|
extraArgs.length > 0 ? { ...rest, extraArgs } : { ...rest },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</SettingsRow>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const EmulatorsContent = ({
|
||||||
|
emulators,
|
||||||
|
platforms,
|
||||||
|
}: {
|
||||||
|
emulators: EmulatorsPayload;
|
||||||
|
platforms: ReadonlyArray<Platform>;
|
||||||
|
}) => {
|
||||||
|
const draft = useConfigDraft();
|
||||||
|
const setLaunch = (
|
||||||
|
platformId: string,
|
||||||
|
launch: PlatformLaunchRaw | undefined,
|
||||||
|
) => {
|
||||||
|
const platformLaunch = { ...(draft.draft.platformLaunch ?? {}) };
|
||||||
|
if (launch === undefined) delete platformLaunch[platformId];
|
||||||
|
else platformLaunch[platformId] = launch;
|
||||||
|
draft.patch({ platformLaunch });
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<DetectedList emulators={emulators} platforms={platforms} />
|
||||||
|
<SettingsGroup
|
||||||
|
title="Per-platform launch"
|
||||||
|
description="Override which emulator (and core) launches each platform's games."
|
||||||
|
>
|
||||||
|
{platforms.map((platform) => (
|
||||||
|
<LaunchRow
|
||||||
|
key={platform.id}
|
||||||
|
platform={platform}
|
||||||
|
emulators={emulators}
|
||||||
|
launch={draft.draft.platformLaunch?.[platform.id]}
|
||||||
|
onChange={(launch) => setLaunch(platform.id, launch)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsGroup>
|
||||||
|
<SaveBar
|
||||||
|
dirty={draft.dirty}
|
||||||
|
saving={draft.saving}
|
||||||
|
onSave={draft.save}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const EmulatorsPage = (_: PageProps) => {
|
||||||
|
const emulators = useAtomValue(emulatorsAtom);
|
||||||
|
const platforms = useAtomValue(platformsAtom);
|
||||||
|
const retryEmulators = useAtomRefresh(emulatorsAtom);
|
||||||
|
const retryPlatforms = useAtomRefresh(platformsAtom);
|
||||||
|
const combined = AsyncResult.all({ emulators, platforms });
|
||||||
|
return (
|
||||||
|
<Gate
|
||||||
|
result={combined}
|
||||||
|
skeleton={<PageSkeleton />}
|
||||||
|
retry={() => {
|
||||||
|
retryEmulators();
|
||||||
|
retryPlatforms();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ emulators: e, platforms: p }) => (
|
||||||
|
<EmulatorsContent emulators={e} platforms={p} />
|
||||||
|
)}
|
||||||
|
</Gate>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// Library — subscribes previewAtom (the dry-run desired state) + configAtom (for the
|
||||||
|
// include toggles); mutates saveConfig directly (no draft — a toggle saves immediately).
|
||||||
|
|
||||||
|
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||||
|
import type {
|
||||||
|
PreviewResult,
|
||||||
|
RawConfig,
|
||||||
|
SyncReport,
|
||||||
|
} from "@rom-manager/contract";
|
||||||
|
import { Badge } from "@unom/ui/badge";
|
||||||
|
import { Button } from "@unom/ui/button";
|
||||||
|
import Card from "@unom/ui/card";
|
||||||
|
import { EmptyState } from "@unom/ui/empty-state";
|
||||||
|
import { Switch } from "@unom/ui/form/switch";
|
||||||
|
import { Skeleton } from "@unom/ui/skeleton";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@unom/ui/table";
|
||||||
|
import { toast } from "@unom/ui/toast";
|
||||||
|
import { Cause, Exit } from "effect";
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity";
|
||||||
|
import { Gamepad2, Library as LibraryIcon } from "lucide-react";
|
||||||
|
import { motion } from "motion/react";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { Gate } from "@/components/gate";
|
||||||
|
import {
|
||||||
|
configAtom,
|
||||||
|
previewAtom,
|
||||||
|
SAVE_CONFIG_KEYS,
|
||||||
|
saveConfig,
|
||||||
|
} from "@/data/atoms";
|
||||||
|
import { errorText } from "@/lib/errors";
|
||||||
|
import type { PageProps } from "@/routes";
|
||||||
|
|
||||||
|
type Entry = PreviewResult["entries"][number];
|
||||||
|
|
||||||
|
const PageSkeleton = () => (
|
||||||
|
<div className="grid grid-cols-2 gap-card md:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{Array.from({ length: 8 }, (_, i) => (
|
||||||
|
<Skeleton key={i} className="aspect-[2/3] w-full rounded-card" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Toggle helper: rewrites gameOverrides on the CURRENT raw config and saves. */
|
||||||
|
const useSetIncluded = () => {
|
||||||
|
const config = useAtomValue(configAtom);
|
||||||
|
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
|
||||||
|
return useCallback(
|
||||||
|
(externalId: string, included: boolean) => {
|
||||||
|
if (!AsyncResult.isSuccess(config)) return;
|
||||||
|
const raw = config.value.raw as RawConfig;
|
||||||
|
const overrides = { ...(raw.gameOverrides ?? {}) };
|
||||||
|
// `exclude` is an optionalKey — the key must be OMITTED, never set undefined.
|
||||||
|
const { exclude: _drop, ...rest } = overrides[externalId] ?? {};
|
||||||
|
if (included) {
|
||||||
|
if (Object.keys(rest).length === 0) delete overrides[externalId];
|
||||||
|
else overrides[externalId] = rest;
|
||||||
|
} else {
|
||||||
|
overrides[externalId] = { ...rest, exclude: true };
|
||||||
|
}
|
||||||
|
void write({
|
||||||
|
payload: { ...raw, gameOverrides: overrides },
|
||||||
|
reactivityKeys: [...SAVE_CONFIG_KEYS],
|
||||||
|
}).then((exit) => {
|
||||||
|
if (!Exit.isSuccess(exit)) {
|
||||||
|
toast.error("Couldn't update the game", {
|
||||||
|
description: errorText(Cause.squash(exit.cause)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[config, write],
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const GameCard = ({
|
||||||
|
entry,
|
||||||
|
index,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
entry: Entry;
|
||||||
|
index: number;
|
||||||
|
onToggle: (externalId: string, included: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const [imgState, setImgState] = useState<"loading" | "ok" | "error">(
|
||||||
|
"loading",
|
||||||
|
);
|
||||||
|
const platform = entry.external_id.split("/")[0] ?? "unknown";
|
||||||
|
const portrait = entry.art?.portrait ?? undefined;
|
||||||
|
return (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 12 }}
|
||||||
|
whileInView={{ opacity: 1, y: 0 }}
|
||||||
|
viewport={{ once: true, margin: "-40px" }}
|
||||||
|
whileHover={{ scale: 1.02 }}
|
||||||
|
transition={{
|
||||||
|
duration: 0.4,
|
||||||
|
ease: [0.165, 0.84, 0.44, 1],
|
||||||
|
delay: Math.min(index, 24) * 0.04,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
padding={false}
|
||||||
|
className="overflow-hidden ring-0 transition-shadow duration-300 hover:ring-2 hover:ring-accent/40"
|
||||||
|
>
|
||||||
|
<div className="relative aspect-[2/3] w-full overflow-hidden bg-gradient-to-br from-secondary to-muted">
|
||||||
|
{portrait !== undefined && imgState !== "error" ? (
|
||||||
|
<>
|
||||||
|
{imgState === "loading" ? (
|
||||||
|
<Skeleton className="absolute inset-0 rounded-none" />
|
||||||
|
) : null}
|
||||||
|
<img
|
||||||
|
src={portrait}
|
||||||
|
alt={entry.title}
|
||||||
|
loading="lazy"
|
||||||
|
onLoad={() => setImgState("ok")}
|
||||||
|
onError={() => setImgState("error")}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center">
|
||||||
|
<Gamepad2 className="size-8 text-muted-foreground/50" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 p-3">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<p className="line-clamp-2 text-sm font-medium leading-tight">
|
||||||
|
{entry.title}
|
||||||
|
</p>
|
||||||
|
<Switch
|
||||||
|
checked
|
||||||
|
onCheckedChange={() => onToggle(entry.external_id, false)}
|
||||||
|
aria-label={`Include ${entry.title}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{platform}
|
||||||
|
</Badge>
|
||||||
|
{entry.launch != null ? (
|
||||||
|
<p
|
||||||
|
title={entry.launch.value}
|
||||||
|
className="line-clamp-1 font-mono text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{entry.launch.value}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</motion.div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const SkippedCard = ({
|
||||||
|
report,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
report: SyncReport;
|
||||||
|
onToggle: (externalId: string, included: boolean) => void;
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const total = report.skipped.length + report.excluded.length;
|
||||||
|
if (total === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card padding={false} className="overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="flex w-full items-center justify-between px-5 py-4 text-left text-sm font-medium hover:bg-secondary/40"
|
||||||
|
>
|
||||||
|
<span>Skipped ({total})</span>
|
||||||
|
<span className="text-muted-foreground">{open ? "Hide" : "Show"}</span>
|
||||||
|
</button>
|
||||||
|
{open ? (
|
||||||
|
<div className="space-y-5 px-5 pb-5">
|
||||||
|
{report.skipped.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>Title</TableHead>
|
||||||
|
<TableHead>Reason</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{report.skipped.map((skip) => (
|
||||||
|
<TableRow key={skip.external_id}>
|
||||||
|
<TableCell>{skip.title}</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground">
|
||||||
|
{skip.reason}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : null}
|
||||||
|
{report.excluded.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">
|
||||||
|
Excluded by you
|
||||||
|
</p>
|
||||||
|
{report.excluded.map((excluded) => (
|
||||||
|
<div
|
||||||
|
key={excluded.external_id}
|
||||||
|
className="flex items-center justify-between gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{excluded.title}</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onToggle(excluded.external_id, true)}
|
||||||
|
>
|
||||||
|
Re-include
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const LibraryContent = ({ preview }: { preview: PreviewResult }) => {
|
||||||
|
const setIncluded = useSetIncluded();
|
||||||
|
if (preview.entries.length === 0 && preview.report.excluded.length === 0) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
icon={LibraryIcon}
|
||||||
|
title="Nothing to show yet"
|
||||||
|
description="Add a ROM folder under Sources and the scan preview appears here."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="@container">
|
||||||
|
<div className="grid grid-cols-2 gap-card @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5">
|
||||||
|
{preview.entries.map((entry, index) => (
|
||||||
|
<GameCard
|
||||||
|
key={entry.external_id}
|
||||||
|
entry={entry}
|
||||||
|
index={index}
|
||||||
|
onToggle={setIncluded}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<SkippedCard report={preview.report} onToggle={setIncluded} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LibraryPage = (_: PageProps) => {
|
||||||
|
const preview = useAtomValue(previewAtom);
|
||||||
|
const retry = useAtomRefresh(previewAtom);
|
||||||
|
return (
|
||||||
|
<Gate result={preview} skeleton={<PageSkeleton />} retry={retry}>
|
||||||
|
{(p) => <LibraryContent preview={p} />}
|
||||||
|
</Gate>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
// Overview — subscribes liveStatusAtom (SSE-fresh engine status) + statusEventsAtom;
|
||||||
|
// mutates runSync. First run (no roots) renders the onboarding hero instead.
|
||||||
|
|
||||||
|
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||||
|
import type { EngineStatus } from "@rom-manager/contract";
|
||||||
|
import { EventFeed } from "@unom/app-ui/event-feed";
|
||||||
|
import { StatCard } from "@unom/app-ui/stat-card";
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from "@unom/ui/accordion";
|
||||||
|
import { AnimatedButton } from "@unom/ui/button";
|
||||||
|
import Card from "@unom/ui/card";
|
||||||
|
import { EmptyState } from "@unom/ui/empty-state";
|
||||||
|
import { Skeleton } from "@unom/ui/skeleton";
|
||||||
|
import { Spinner } from "@unom/ui/spinner";
|
||||||
|
import { toast } from "@unom/ui/toast";
|
||||||
|
import { Cause, Exit } from "effect";
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity";
|
||||||
|
import {
|
||||||
|
FolderOpen,
|
||||||
|
FolderSearch,
|
||||||
|
Image,
|
||||||
|
Library,
|
||||||
|
RefreshCw,
|
||||||
|
TriangleAlert,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Gate } from "@/components/gate";
|
||||||
|
import {
|
||||||
|
liveStatusAtom,
|
||||||
|
RUN_SYNC_KEYS,
|
||||||
|
runSync,
|
||||||
|
statusAtom,
|
||||||
|
statusEventsAtom,
|
||||||
|
} from "@/data/atoms";
|
||||||
|
import { errorText } from "@/lib/errors";
|
||||||
|
import type { PageProps } from "@/routes";
|
||||||
|
|
||||||
|
const PageSkeleton = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
|
||||||
|
<Skeleton className="h-28" />
|
||||||
|
<Skeleton className="h-28" />
|
||||||
|
<Skeleton className="h-28" />
|
||||||
|
<Skeleton className="h-28" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-10 w-40" />
|
||||||
|
<Skeleton className="h-56" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const FirstRun = ({ navigate }: PageProps) => (
|
||||||
|
<EmptyState
|
||||||
|
icon={FolderSearch}
|
||||||
|
title="No ROM sources yet"
|
||||||
|
description={
|
||||||
|
"Three steps to a streaming retro library: add a ROM folder, pick the platform its files belong to, then sync — every game lands in your Punktfunk library with art and a launch command."
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<AnimatedButton
|
||||||
|
whileHover={{ scale: 1.03 }}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
onClick={() => navigate("sources")}
|
||||||
|
>
|
||||||
|
Add your first ROM folder
|
||||||
|
</AnimatedButton>
|
||||||
|
}
|
||||||
|
className="py-20"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const lastSyncHint = (status: EngineStatus): string => {
|
||||||
|
if (status.lastSync === undefined) return "never synced";
|
||||||
|
const minutes = Math.max(
|
||||||
|
0,
|
||||||
|
Math.round((Date.now() - status.lastSync.at) / 60_000),
|
||||||
|
);
|
||||||
|
if (minutes === 0) return "synced just now";
|
||||||
|
if (minutes < 60) return `synced ${minutes} min ago`;
|
||||||
|
return `synced ${Math.round(minutes / 60)} h ago`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SyncButton = ({ status }: { status: EngineStatus }) => {
|
||||||
|
const syncResult = useAtomValue(runSync);
|
||||||
|
const sync = useAtomSet(runSync, { mode: "promiseExit" });
|
||||||
|
const pending = status.syncing || AsyncResult.isWaiting(syncResult);
|
||||||
|
const onSync = () => {
|
||||||
|
void sync({ reactivityKeys: [...RUN_SYNC_KEYS] }).then((exit) => {
|
||||||
|
if (Exit.isSuccess(exit)) {
|
||||||
|
toast.success(
|
||||||
|
`Sync complete — ${exit.value.included} ${exit.value.included === 1 ? "title" : "titles"} in the library`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error("Sync failed", {
|
||||||
|
description: errorText(Cause.squash(exit.cause)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<AnimatedButton
|
||||||
|
onClick={onSync}
|
||||||
|
disabled={pending}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Spinner className="size-4" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
)}
|
||||||
|
{pending ? "Syncing…" : "Sync now"}
|
||||||
|
</AnimatedButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Warnings = ({ status }: { status: EngineStatus }) => {
|
||||||
|
const report = status.lastReport;
|
||||||
|
if (report === undefined) return null;
|
||||||
|
const count = report.warnings.length + (report.overWarn ? 1 : 0);
|
||||||
|
if (count === 0) return null;
|
||||||
|
return (
|
||||||
|
<Card padding={false} className="overflow-hidden">
|
||||||
|
<Accordion type="single" collapsible>
|
||||||
|
<AccordionItem value="warnings" className="border-b-0">
|
||||||
|
<AccordionTrigger className="px-5 py-4 text-warn">
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<TriangleAlert className="size-4" />
|
||||||
|
{count} {count === 1 ? "warning" : "warnings"} from the last sync
|
||||||
|
</span>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-5 pb-4">
|
||||||
|
<ul className="space-y-1.5 text-sm text-muted-foreground">
|
||||||
|
{report.warnings.map((warning) => (
|
||||||
|
<li key={warning} className="flex gap-2">
|
||||||
|
<span aria-hidden className="text-warn">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
{warning}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{report.overWarn ? (
|
||||||
|
<li className="flex gap-2">
|
||||||
|
<span aria-hidden className="text-warn">
|
||||||
|
•
|
||||||
|
</span>
|
||||||
|
The library is over the configured warning threshold (
|
||||||
|
{report.included} entries) — consider region dedupe or
|
||||||
|
excludes.
|
||||||
|
</li>
|
||||||
|
) : null}
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const ActivityFeed = () => {
|
||||||
|
const events = useAtomValue(statusEventsAtom);
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<h2 className="mb-3 text-lg font-semibold tracking-tight">Activity</h2>
|
||||||
|
<EventFeed
|
||||||
|
items={events}
|
||||||
|
maxHeight="16rem"
|
||||||
|
empty="Waiting for engine events…"
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Dashboard = ({ status }: { status: EngineStatus }) => {
|
||||||
|
const inLibrary = status.lastSync?.count ?? status.lastReport?.included ?? 0;
|
||||||
|
const skipped = status.lastReport?.skipped.length ?? 0;
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
|
||||||
|
<StatCard
|
||||||
|
label="ROM sources"
|
||||||
|
value={status.rootsConfigured}
|
||||||
|
icon={FolderOpen}
|
||||||
|
hint={`on ${status.os}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="In library"
|
||||||
|
value={inLibrary}
|
||||||
|
icon={Library}
|
||||||
|
hint={lastSyncHint(status)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Skipped"
|
||||||
|
value={skipped}
|
||||||
|
icon={TriangleAlert}
|
||||||
|
tone={skipped > 0 ? "warn" : "default"}
|
||||||
|
hint={skipped > 0 ? "see Library for reasons" : "nothing skipped"}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Art provider"
|
||||||
|
value={status.artProvider ?? "off"}
|
||||||
|
icon={Image}
|
||||||
|
tone={status.artProvider === null ? "default" : "success"}
|
||||||
|
hint={status.artProvider === null ? "covers disabled" : "covers on"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<SyncButton status={status} />
|
||||||
|
</div>
|
||||||
|
<Warnings status={status} />
|
||||||
|
<ActivityFeed />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OverviewPage = ({ navigate }: PageProps) => {
|
||||||
|
const status = useAtomValue(liveStatusAtom);
|
||||||
|
const retry = useAtomRefresh(statusAtom);
|
||||||
|
return (
|
||||||
|
<Gate result={status} skeleton={<PageSkeleton />} retry={retry}>
|
||||||
|
{(s) =>
|
||||||
|
s.rootsConfigured === 0 ? (
|
||||||
|
<FirstRun navigate={navigate} />
|
||||||
|
) : (
|
||||||
|
<Dashboard status={s} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Gate>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
// Settings — subscribes the config draft (raw edits, resolved display values) +
|
||||||
|
// liveStatusAtom (the Files paths); mutates saveConfig via the sticky SaveBar.
|
||||||
|
|
||||||
|
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||||
|
import type { RawConfig } from "@rom-manager/contract";
|
||||||
|
import { SettingsGroup, SettingsRow } from "@unom/app-ui/settings";
|
||||||
|
import { CodeBlock } from "@unom/ui/code-block";
|
||||||
|
import { InputNumber } from "@unom/ui/form/input-number";
|
||||||
|
import { InputText } from "@unom/ui/form/input-text";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@unom/ui/form/select";
|
||||||
|
import { Switch } from "@unom/ui/form/switch";
|
||||||
|
import { Skeleton } from "@unom/ui/skeleton";
|
||||||
|
import { Gate } from "@/components/gate";
|
||||||
|
import { SaveBar } from "@/components/save-bar";
|
||||||
|
import { configAtom, liveStatusAtom, statusAtom } from "@/data/atoms";
|
||||||
|
import { useConfigDraft } from "@/data/drafts";
|
||||||
|
import type { PageProps } from "@/routes";
|
||||||
|
|
||||||
|
type ArtRaw = NonNullable<RawConfig["art"]>;
|
||||||
|
type SyncRaw = NonNullable<RawConfig["sync"]>;
|
||||||
|
|
||||||
|
const PageSkeleton = () => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Skeleton className="h-52" />
|
||||||
|
<Skeleton className="h-72" />
|
||||||
|
<Skeleton className="h-40" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PROVIDERS = ["auto", "steamgriddb", "libretro"] as const;
|
||||||
|
|
||||||
|
export const SettingsPage = (_: PageProps) => {
|
||||||
|
const config = useAtomValue(configAtom);
|
||||||
|
const retry = useAtomRefresh(configAtom);
|
||||||
|
const status = useAtomValue(liveStatusAtom);
|
||||||
|
const retryStatus = useAtomRefresh(statusAtom);
|
||||||
|
const draft = useConfigDraft();
|
||||||
|
const { resolved } = draft;
|
||||||
|
|
||||||
|
const patchArt = (patch: Partial<ArtRaw>) =>
|
||||||
|
draft.patch({ art: { ...(draft.draft.art ?? {}), ...patch } });
|
||||||
|
const patchSync = (patch: Partial<SyncRaw>) =>
|
||||||
|
draft.patch({ sync: { ...(draft.draft.sync ?? {}), ...patch } });
|
||||||
|
|
||||||
|
const provider = resolved?.art.provider ?? "auto";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
|
||||||
|
{() => (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsGroup
|
||||||
|
title="Artwork"
|
||||||
|
description="Cover art for library entries — fetched at sync time, cached on disk."
|
||||||
|
>
|
||||||
|
<SettingsRow
|
||||||
|
label="Fetch artwork"
|
||||||
|
description="Look up portraits, heroes, and logos for scanned games."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={resolved?.art.enabled ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patchArt({ enabled: checked === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
label="Provider"
|
||||||
|
description="auto prefers SteamGridDB when a key is set, libretro otherwise."
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={provider}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
patchArt({ provider: value as ArtRaw["provider"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-44">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVIDERS.map((p) => (
|
||||||
|
<SelectItem key={p} value={p}>
|
||||||
|
{p}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingsRow>
|
||||||
|
{provider !== "libretro" ? (
|
||||||
|
<SettingsRow
|
||||||
|
label="SteamGridDB API key"
|
||||||
|
description="From steamgriddb.com — stored in the plugin's config file."
|
||||||
|
>
|
||||||
|
<InputText
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-64"
|
||||||
|
value={draft.draft.art?.steamGridDbKey ?? ""}
|
||||||
|
placeholder="API key"
|
||||||
|
onChange={(event) => {
|
||||||
|
const key = event.target.value;
|
||||||
|
// optionalKey — omit when cleared.
|
||||||
|
const { steamGridDbKey: _drop, ...rest } =
|
||||||
|
draft.draft.art ?? {};
|
||||||
|
draft.patch({
|
||||||
|
art:
|
||||||
|
key.length > 0
|
||||||
|
? { ...rest, steamGridDbKey: key }
|
||||||
|
: rest,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
) : null}
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup
|
||||||
|
title="Sync"
|
||||||
|
description="When and how the scanner reconciles your folders into the library."
|
||||||
|
>
|
||||||
|
<SettingsRow
|
||||||
|
label="Watch folders"
|
||||||
|
description="React to file changes immediately (polling stays on as the fallback)."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={resolved?.sync.watch ?? true}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patchSync({ watch: checked === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
label="Poll interval"
|
||||||
|
description="Minutes between full re-scans."
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
className="w-28"
|
||||||
|
min={1}
|
||||||
|
value={resolved?.sync.pollMinutes ?? 15}
|
||||||
|
onChange={(pollMinutes) => patchSync({ pollMinutes })}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
label="Debounce"
|
||||||
|
description="Milliseconds to wait after a file event before re-scanning."
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
className="w-28"
|
||||||
|
min={0}
|
||||||
|
step={100}
|
||||||
|
value={resolved?.sync.debounceMs ?? 2000}
|
||||||
|
onChange={(debounceMs) => patchSync({ debounceMs })}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
label="Warn threshold"
|
||||||
|
description="Warn when the library grows past this many entries."
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
className="w-28"
|
||||||
|
min={1}
|
||||||
|
value={resolved?.sync.warnEntries ?? 2000}
|
||||||
|
onChange={(warnEntries) => patchSync({ warnEntries })}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
label="Max entries"
|
||||||
|
description="Hard cap — entries beyond it are truncated."
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
className="w-28"
|
||||||
|
min={1}
|
||||||
|
value={resolved?.sync.maxEntries ?? 5000}
|
||||||
|
onChange={(maxEntries) => patchSync({ maxEntries })}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
<SettingsRow
|
||||||
|
label="Close game on stream end"
|
||||||
|
description="Stop the emulator when the streaming session ends."
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={resolved?.sync.closeOnEnd ?? false}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patchSync({ closeOnEnd: checked === true })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SettingsRow>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SettingsGroup
|
||||||
|
title="Files"
|
||||||
|
description="Where this plugin keeps its state on the host."
|
||||||
|
>
|
||||||
|
<Gate
|
||||||
|
result={status}
|
||||||
|
skeleton={<Skeleton className="h-20" />}
|
||||||
|
retry={retryStatus}
|
||||||
|
>
|
||||||
|
{(s) => (
|
||||||
|
<CodeBlock copy>
|
||||||
|
{`dir: ${s.paths.dir}\nconfig: ${s.paths.config}\ncache: ${s.paths.cache}`}
|
||||||
|
</CodeBlock>
|
||||||
|
)}
|
||||||
|
</Gate>
|
||||||
|
</SettingsGroup>
|
||||||
|
|
||||||
|
<SaveBar
|
||||||
|
dirty={draft.dirty}
|
||||||
|
saving={draft.saving}
|
||||||
|
onSave={draft.save}
|
||||||
|
onDiscard={draft.discard}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Gate>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Sources — subscribes configAtom (via the draft) + platformsAtom; mutates saveConfig
|
||||||
|
// through the draft's save(). PathListEditor edits draft.roots in place.
|
||||||
|
|
||||||
|
import { useAtomRefresh, useAtomValue } from "@effect/atom-react";
|
||||||
|
import type { RawConfig } from "@rom-manager/contract";
|
||||||
|
import { PathListEditor } from "@unom/app-ui/path-list-editor";
|
||||||
|
import { SettingsGroup } from "@unom/app-ui/settings";
|
||||||
|
import { AnimatedButton } from "@unom/ui/button";
|
||||||
|
import { EmptyState } from "@unom/ui/empty-state";
|
||||||
|
import { InputText } from "@unom/ui/form/input-text";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@unom/ui/form/select";
|
||||||
|
import { Skeleton } from "@unom/ui/skeleton";
|
||||||
|
import { Spinner } from "@unom/ui/spinner";
|
||||||
|
import { AsyncResult } from "effect/unstable/reactivity";
|
||||||
|
import { FolderOpen } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Gate } from "@/components/gate";
|
||||||
|
import { configAtom, platformsAtom } from "@/data/atoms";
|
||||||
|
import { useConfigDraft } from "@/data/drafts";
|
||||||
|
import type { PageProps } from "@/routes";
|
||||||
|
|
||||||
|
type RomRootRaw = NonNullable<RawConfig["roots"]>[number];
|
||||||
|
|
||||||
|
const PageSkeleton = () => (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-64" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const PlatformSelect = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (platform: string) => void;
|
||||||
|
}) => {
|
||||||
|
const platforms = useAtomValue(platformsAtom);
|
||||||
|
const items = AsyncResult.isSuccess(platforms) ? platforms.value : [];
|
||||||
|
return (
|
||||||
|
<Select value={value === "" ? undefined : value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger size="sm" className="w-44 shrink-0">
|
||||||
|
<SelectValue placeholder="Platform" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{items.map((platform) => (
|
||||||
|
<SelectItem key={platform.id} value={platform.id}>
|
||||||
|
{platform.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Comma-separated glob editor. Local text state so typing "a, b" isn't normalized
|
||||||
|
* under the cursor; every keystroke still commits the parsed globs to the draft.
|
||||||
|
*/
|
||||||
|
const ExcludesInput = ({
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
}: {
|
||||||
|
value: ReadonlyArray<string>;
|
||||||
|
onCommit: (globs: ReadonlyArray<string>) => void;
|
||||||
|
}) => {
|
||||||
|
const [text, setText] = useState(() => value.join(", "));
|
||||||
|
return (
|
||||||
|
<InputText
|
||||||
|
value={text}
|
||||||
|
placeholder="exclude globs, comma-separated"
|
||||||
|
className="w-56"
|
||||||
|
onChange={(event) => {
|
||||||
|
setText(event.target.value);
|
||||||
|
onCommit(
|
||||||
|
event.target.value
|
||||||
|
.split(",")
|
||||||
|
.map((glob) => glob.trim())
|
||||||
|
.filter((glob) => glob.length > 0),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SourcesPage = (_: PageProps) => {
|
||||||
|
const config = useAtomValue(configAtom);
|
||||||
|
const retry = useAtomRefresh(configAtom);
|
||||||
|
const draft = useConfigDraft();
|
||||||
|
return (
|
||||||
|
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
|
||||||
|
{() => (
|
||||||
|
<SettingsGroup
|
||||||
|
title="ROM folders"
|
||||||
|
description="Each folder is scanned for one platform's ROMs. Exclude globs skip files inside it."
|
||||||
|
footer={
|
||||||
|
<AnimatedButton
|
||||||
|
disabled={!draft.dirty || draft.saving}
|
||||||
|
onClick={draft.save}
|
||||||
|
whileTap={{ scale: 0.97 }}
|
||||||
|
>
|
||||||
|
{draft.saving ? <Spinner className="size-4" /> : null}
|
||||||
|
Save
|
||||||
|
</AnimatedButton>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<PathListEditor<RomRootRaw>
|
||||||
|
items={draft.draft.roots ?? []}
|
||||||
|
onChange={(roots) => draft.patch({ roots })}
|
||||||
|
path={{
|
||||||
|
get: (root) => root.dir,
|
||||||
|
set: (root, dir) => ({ ...root, dir }),
|
||||||
|
placeholder: "/path/to/roms",
|
||||||
|
}}
|
||||||
|
makeItem={(dir) => ({ dir, platform: "" })}
|
||||||
|
addLabel="Add ROM folder"
|
||||||
|
empty={
|
||||||
|
<EmptyState
|
||||||
|
icon={FolderOpen}
|
||||||
|
title="No ROM folders"
|
||||||
|
description="Add the folder your ROMs live in — one folder per platform."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderExtras={(root, _index, update) => (
|
||||||
|
<>
|
||||||
|
<PlatformSelect
|
||||||
|
value={root.platform}
|
||||||
|
onChange={(platform) => update({ ...root, platform })}
|
||||||
|
/>
|
||||||
|
<ExcludesInput
|
||||||
|
value={root.excludes ?? []}
|
||||||
|
onCommit={(globs) => {
|
||||||
|
// `excludes` is an optionalKey — omit it entirely when empty.
|
||||||
|
const { excludes: _drop, ...rest } = root;
|
||||||
|
update(
|
||||||
|
globs.length > 0 ? { ...rest, excludes: globs } : rest,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SettingsGroup>
|
||||||
|
)}
|
||||||
|
</Gate>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user