From f3e80f10ecee250b7ce7e34b4518e21f0b5d38ac Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 20 Jul 2026 19:41:49 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20the=20five=20pages=20+=20shell=20?= =?UTF-8?q?=E2=80=94=20console-grade=20@unom/app-ui=20chrome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ui/src/App.tsx | 64 ++++++ ui/src/components/gate.tsx | 45 +++++ ui/src/components/save-bar.tsx | 47 +++++ ui/src/main.tsx | 21 ++ ui/src/pages/emulators.tsx | 350 +++++++++++++++++++++++++++++++++ ui/src/pages/library.tsx | 269 +++++++++++++++++++++++++ ui/src/pages/overview.tsx | 231 ++++++++++++++++++++++ ui/src/pages/settings.tsx | 222 +++++++++++++++++++++ ui/src/pages/sources.tsx | 153 ++++++++++++++ 9 files changed, 1402 insertions(+) create mode 100644 ui/src/App.tsx create mode 100644 ui/src/components/gate.tsx create mode 100644 ui/src/components/save-bar.tsx create mode 100644 ui/src/main.tsx create mode 100644 ui/src/pages/emulators.tsx create mode 100644 ui/src/pages/library.tsx create mode 100644 ui/src/pages/overview.tsx create mode 100644 ui/src/pages/settings.tsx create mode 100644 ui/src/pages/sources.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..0c4d73b --- /dev/null +++ b/ui/src/App.tsx @@ -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 = [ + { 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> = { + 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 = () => ( +
+ + ROM Manager +
+); + +export const App = () => { + const { route, navigate } = usePluginRoute(); + const embedded = useIsEmbedded(); + const Page = PAGES[route]; + return ( + navigate(id as Route)} + standaloneHeader={embedded ? undefined : } + > + + + ); +}; diff --git a/ui/src/components/gate.tsx b/ui/src/components/gate.tsx new file mode 100644 index 0000000..d297b04 --- /dev/null +++ b/ui/src/components/gate.tsx @@ -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 = { + readonly result: AsyncResult.AsyncResult; + /** 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 = (props: GateProps): ReactNode => ( + ( + + Try again + + ) + } + /> + )} + > + {props.children} + +); diff --git a/ui/src/components/save-bar.tsx b/ui/src/components/save-bar.tsx new file mode 100644 index 0000000..3271589 --- /dev/null +++ b/ui/src/components/save-bar.tsx @@ -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) => ( + + {dirty ? ( + +
+ Unsaved changes +
+ + {/* 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. */} + + {saving ? : null} + Save changes + +
+
+
+ ) : null} +
+); diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..20714de --- /dev/null +++ b/ui/src/main.tsx @@ -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( + + + + + , +); diff --git a/ui/src/pages/emulators.tsx b/ui/src/pages/emulators.tsx new file mode 100644 index 0000000..ef3e5fa --- /dev/null +++ b/ui/src/pages/emulators.tsx @@ -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[string]; + +const PageSkeleton = () => ( +
+ + +
+); + +/** Display names for known emulator ids that are not currently detected (id fallback). */ +const EMULATOR_LABELS: Record = { + 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, +): ReadonlyArray => + useMemo(() => { + const detectedById = new Map( + emulators.detected.map((emulator) => [emulator.id, emulator]), + ); + const ids = new Set(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 ( + + ); +}; + +const DetectedList = ({ + emulators, + platforms, +}: { + emulators: EmulatorsPayload; + platforms: ReadonlyArray; +}) => { + const roster = useRoster(emulators, platforms); + return ( + +
+
+

+ Detected emulators +

+

+ {emulators.detectedAt === null + ? "Not scanned yet" + : `Last scan ${new Date(emulators.detectedAt).toLocaleString()}`} +

+
+ +
+
+ {roster.map((row) => ( +
+
+
+ {row.name} + {row.detected !== undefined ? ( + + detected · {row.detected.via} + + ) : ( + + not found + + )} + {row.detected?.contested === true ? ( + + contested + + ) : null} + {row.detected?.cores !== undefined && + row.detected.cores.length > 0 ? ( + + {row.detected.cores.length} cores + + ) : null} +
+ {row.detected?.exePath !== undefined ? ( +

+ {row.detected.exePath} +

+ ) : null} +
+
+ ))} +
+
+ ); +}; + +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 ( + +
+ + {cores.length > 0 ? ( + + ) : null} + { + 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 }, + ); + }} + /> +
+
+ ); +}; + +const EmulatorsContent = ({ + emulators, + platforms, +}: { + emulators: EmulatorsPayload; + platforms: ReadonlyArray; +}) => { + 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 ( +
+ + + {platforms.map((platform) => ( + setLaunch(platform.id, launch)} + /> + ))} + + +
+ ); +}; + +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 ( + } + retry={() => { + retryEmulators(); + retryPlatforms(); + }} + > + {({ emulators: e, platforms: p }) => ( + + )} + + ); +}; diff --git a/ui/src/pages/library.tsx b/ui/src/pages/library.tsx new file mode 100644 index 0000000..f183b4c --- /dev/null +++ b/ui/src/pages/library.tsx @@ -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 = () => ( +
+ {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+); + +/** 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 ( + + +
+ {portrait !== undefined && imgState !== "error" ? ( + <> + {imgState === "loading" ? ( + + ) : null} + {entry.title} setImgState("ok")} + onError={() => setImgState("error")} + className="h-full w-full object-cover" + /> + + ) : ( +
+ +
+ )} +
+
+
+

+ {entry.title} +

+ onToggle(entry.external_id, false)} + aria-label={`Include ${entry.title}`} + /> +
+ + {platform} + + {entry.launch != null ? ( +

+ {entry.launch.value} +

+ ) : null} +
+
+
+ ); +}; + +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 ( + + + {open ? ( +
+ {report.skipped.length > 0 ? ( + + + + Title + Reason + + + + {report.skipped.map((skip) => ( + + {skip.title} + + {skip.reason} + + + ))} + +
+ ) : null} + {report.excluded.length > 0 ? ( +
+

+ Excluded by you +

+ {report.excluded.map((excluded) => ( +
+ {excluded.title} + +
+ ))} +
+ ) : null} +
+ ) : null} +
+ ); +}; + +const LibraryContent = ({ preview }: { preview: PreviewResult }) => { + const setIncluded = useSetIncluded(); + if (preview.entries.length === 0 && preview.report.excluded.length === 0) { + return ( + + ); + } + return ( +
+
+
+ {preview.entries.map((entry, index) => ( + + ))} +
+
+ +
+ ); +}; + +export const LibraryPage = (_: PageProps) => { + const preview = useAtomValue(previewAtom); + const retry = useAtomRefresh(previewAtom); + return ( + } retry={retry}> + {(p) => } + + ); +}; diff --git a/ui/src/pages/overview.tsx b/ui/src/pages/overview.tsx new file mode 100644 index 0000000..56b2fa8 --- /dev/null +++ b/ui/src/pages/overview.tsx @@ -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 = () => ( +
+
+ + + + +
+ + +
+); + +const FirstRun = ({ navigate }: PageProps) => ( + navigate("sources")} + > + Add your first ROM folder + + } + 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 ( + + {pending ? ( + + ) : ( + + )} + {pending ? "Syncing…" : "Sync now"} + + ); +}; + +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 ( + + + + + + + {count} {count === 1 ? "warning" : "warnings"} from the last sync + + + +
    + {report.warnings.map((warning) => ( +
  • + + • + + {warning} +
  • + ))} + {report.overWarn ? ( +
  • + + • + + The library is over the configured warning threshold ( + {report.included} entries) — consider region dedupe or + excludes. +
  • + ) : null} +
+
+
+
+
+ ); +}; + +const ActivityFeed = () => { + const events = useAtomValue(statusEventsAtom); + return ( + +

Activity

+ +
+ ); +}; + +const Dashboard = ({ status }: { status: EngineStatus }) => { + const inLibrary = status.lastSync?.count ?? status.lastReport?.included ?? 0; + const skipped = status.lastReport?.skipped.length ?? 0; + return ( +
+
+ + + 0 ? "warn" : "default"} + hint={skipped > 0 ? "see Library for reasons" : "nothing skipped"} + /> + +
+
+ +
+ + +
+ ); +}; + +export const OverviewPage = ({ navigate }: PageProps) => { + const status = useAtomValue(liveStatusAtom); + const retry = useAtomRefresh(statusAtom); + return ( + } retry={retry}> + {(s) => + s.rootsConfigured === 0 ? ( + + ) : ( + + ) + } + + ); +}; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx new file mode 100644 index 0000000..1461697 --- /dev/null +++ b/ui/src/pages/settings.tsx @@ -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; +type SyncRaw = NonNullable; + +const PageSkeleton = () => ( +
+ + + +
+); + +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) => + draft.patch({ art: { ...(draft.draft.art ?? {}), ...patch } }); + const patchSync = (patch: Partial) => + draft.patch({ sync: { ...(draft.draft.sync ?? {}), ...patch } }); + + const provider = resolved?.art.provider ?? "auto"; + + return ( + } retry={retry}> + {() => ( +
+ + + + patchArt({ enabled: checked === true }) + } + /> + + + + + {provider !== "libretro" ? ( + + { + 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, + }); + }} + /> + + ) : null} + + + + + + patchSync({ watch: checked === true }) + } + /> + + + patchSync({ pollMinutes })} + /> + + + patchSync({ debounceMs })} + /> + + + patchSync({ warnEntries })} + /> + + + patchSync({ maxEntries })} + /> + + + + patchSync({ closeOnEnd: checked === true }) + } + /> + + + + + } + retry={retryStatus} + > + {(s) => ( + + {`dir: ${s.paths.dir}\nconfig: ${s.paths.config}\ncache: ${s.paths.cache}`} + + )} + + + + +
+ )} +
+ ); +}; diff --git a/ui/src/pages/sources.tsx b/ui/src/pages/sources.tsx new file mode 100644 index 0000000..5301493 --- /dev/null +++ b/ui/src/pages/sources.tsx @@ -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[number]; + +const PageSkeleton = () => ( +
+ + +
+); + +const PlatformSelect = ({ + value, + onChange, +}: { + value: string; + onChange: (platform: string) => void; +}) => { + const platforms = useAtomValue(platformsAtom); + const items = AsyncResult.isSuccess(platforms) ? platforms.value : []; + return ( + + ); +}; + +/** + * 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; + onCommit: (globs: ReadonlyArray) => void; +}) => { + const [text, setText] = useState(() => value.join(", ")); + return ( + { + 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 ( + } retry={retry}> + {() => ( + + {draft.saving ? : null} + Save + + } + > + + 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={ + + } + renderExtras={(root, _index, update) => ( + <> + update({ ...root, platform })} + /> + { + // `excludes` is an optionalKey — omit it entirely when empty. + const { excludes: _drop, ...rest } = root; + update( + globs.length > 0 ? { ...rest, excludes: globs } : rest, + ); + }} + /> + + )} + /> + + )} + + ); +};