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