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:
2026-07-20 19:41:49 +02:00
parent bcff17a718
commit f3e80f10ec
9 changed files with 1402 additions and 0 deletions
+64
View File
@@ -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>
);
};
+45
View File
@@ -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>
);
+47
View File
@@ -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>
);
+21
View File
@@ -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>,
);
+350
View File
@@ -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>
);
};
+269
View File
@@ -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>
);
};
+231
View File
@@ -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>
);
};
+222
View File
@@ -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>
);
};
+153
View File
@@ -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>
);
};