feat(ui): three-page console SPA on @unom/ui + the kit's react helpers
Overview (exporter health, Playnite version, counts, sync, live SSE activity, and a first-run panel that names the exact ingest path the .pext writes to), Library (covers + per-game include toggles + why-not-synced), Settings (filters, art delivery, sync cadence, paths). Data layer is AtomHttpApi atoms derived from the shared contract; the whole thing runs with zero host against an in-browser mock transport with four scenarios, and Storybook renders the real pages on the same fixtures. CI now asserts the production bundle carries no fixture strings. Also: CI rebuilt for the workspace layout (one root install, per-package typecheck, publish from plugin/ on a v* tag) with the exporter job and `publish: needs [build, exporter]` unchanged; README rewritten around the three workspaces, with the ingest inbox explained as the load-bearing mechanism it is.
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.stories.tsx"],
|
||||
addons: [],
|
||||
framework: {
|
||||
name: "@storybook/react-vite",
|
||||
options: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,65 @@
|
||||
// Storybook renders the REAL pages on the mock transport: the static import below
|
||||
// registers the fixture HttpClient layer + status ticker (Storybook bundles are never
|
||||
// shipped, so fixtures in them are fine), and the decorator gives every story a fresh
|
||||
// atom registry seeded with mock mode + the story's scenario.
|
||||
import "../src/styles.css";
|
||||
import "../src/mocks/http";
|
||||
import { RegistryProvider } from "@effect/atom-react";
|
||||
import { definePreview } from "@storybook/react-vite";
|
||||
import { useEffect } from "react";
|
||||
import {
|
||||
apiModeAtom,
|
||||
type Scenario,
|
||||
scenarioAtom,
|
||||
} from "../src/mocks/scenario";
|
||||
|
||||
export default definePreview({
|
||||
addons: [],
|
||||
// The console pins dark; default the canvas to dark with a toolbar light switch.
|
||||
initialGlobals: { theme: "dark" },
|
||||
globalTypes: {
|
||||
theme: {
|
||||
description: "Light/dark color scheme",
|
||||
toolbar: {
|
||||
title: "Theme",
|
||||
icon: "circlehollow",
|
||||
items: [
|
||||
{ value: "dark", icon: "moon", title: "Dark" },
|
||||
{ value: "light", icon: "sun", title: "Light" },
|
||||
],
|
||||
dynamicTitle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
decorators: [
|
||||
(Story, context) => {
|
||||
const dark = (context.globals.theme as string) !== "light";
|
||||
const scenario =
|
||||
(context.parameters.scenario as Scenario | undefined) ?? "healthy";
|
||||
const fullscreen = context.parameters.layout === "fullscreen";
|
||||
// Mirror `.dark` onto <html> so portal-mounted content (selects, dialogs,
|
||||
// toasts) picks up the palette — the theme keys everything off `html.dark`.
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
}, [dark]);
|
||||
return (
|
||||
<RegistryProvider
|
||||
key={`${scenario}-${dark}`}
|
||||
initialValues={[
|
||||
[apiModeAtom, "mock"],
|
||||
[scenarioAtom, scenario],
|
||||
]}
|
||||
>
|
||||
<div
|
||||
className={`min-h-screen bg-background text-foreground ${fullscreen ? "" : "p-6"}`}
|
||||
>
|
||||
<Story />
|
||||
</div>
|
||||
</RegistryProvider>
|
||||
);
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<!-- The console pins dark; the standalone dev tab matches it. Removing `dark` yields light. -->
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Playnite — Punktfunk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+4
-2
@@ -2,14 +2,15 @@
|
||||
"name": "punktfunk-plugin-playnite-ui",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "The Playnite plugin SPA — Punktfunk console polish (@unom/ui + @unom/app-ui), an Effect-native data layer derived from the shared HttpApi contract, and a zero-host mock dev loop. Builds into plugin/dist/ui.",
|
||||
"description": "The Playnite plugin SPA \u2014 Punktfunk console polish (@unom/ui + @unom/app-ui), an Effect-native data layer derived from the shared HttpApi contract, and a zero-host mock dev loop. Builds into plugin/dist/ui.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:live": "VITE_API_MODE=live vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"storybook": "storybook dev -p 6014",
|
||||
"build-storybook": "storybook build"
|
||||
"build-storybook": "storybook build",
|
||||
"screenshots": "node tools/screenshots.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/atom-react": "4.0.0-beta.99",
|
||||
@@ -32,6 +33,7 @@
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"playwright": "^1.61.1",
|
||||
"storybook": "^10.4.6",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { App } from "./App";
|
||||
|
||||
const meta = {
|
||||
title: "Shell/App",
|
||||
component: App,
|
||||
parameters: { layout: "fullscreen" },
|
||||
} satisfies Meta<typeof App>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const FirstRun: Story = {
|
||||
parameters: { layout: "fullscreen", scenario: "firstRun" },
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
// 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 { LayoutDashboard, LibraryBig, Settings2 } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { LibraryPage } from "@/pages/library";
|
||||
import { OverviewPage } from "@/pages/overview";
|
||||
import { SettingsPage } from "@/pages/settings";
|
||||
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: LibraryBig },
|
||||
{ id: "settings", label: "Settings", icon: Settings2 },
|
||||
];
|
||||
|
||||
const PAGES: Record<Route, FC<PageProps>> = {
|
||||
overview: OverviewPage,
|
||||
library: LibraryPage,
|
||||
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">
|
||||
<LibraryBig className="size-4 text-primary" />
|
||||
Playnite
|
||||
</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,53 @@
|
||||
// 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 — except the
|
||||
// "exporter hasn't written anything yet" case, which pages can answer with onboarding
|
||||
// via the `unavailable` slot.
|
||||
|
||||
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, isExportUnavailable } 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;
|
||||
/** Rendered instead of the error box when the exporter has produced nothing yet. */
|
||||
readonly unavailable?: ReactNode;
|
||||
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) =>
|
||||
props.unavailable !== undefined && isExportUnavailable(error) ? (
|
||||
props.unavailable
|
||||
) : (
|
||||
<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,150 @@
|
||||
// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the
|
||||
// mutation constants below document which keys each mutation must pass at call time
|
||||
// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`).
|
||||
|
||||
import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@playnite/contract";
|
||||
import { sseAtom } from "@punktfunk/plugin-kit/react";
|
||||
import { AsyncResult, Atom } from "effect/unstable/reactivity";
|
||||
import { installedMockStatusStream } from "@/mocks/register";
|
||||
import { apiModeAtom } from "@/mocks/scenario";
|
||||
import { Api, prefix } from "./client";
|
||||
|
||||
// ── Queries ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export const statusAtom = Api.query("status", "get", {
|
||||
reactivityKeys: ["status"],
|
||||
});
|
||||
|
||||
export const configAtom = Api.query("config", "get", {
|
||||
reactivityKeys: ["config"],
|
||||
});
|
||||
|
||||
export const previewAtom = Api.query("library", "preview", {
|
||||
reactivityKeys: ["preview"],
|
||||
});
|
||||
|
||||
// ── Mutations + the keys they invalidate ───────────────────────────────────────
|
||||
|
||||
export const saveConfig = Api.mutation("config", "put");
|
||||
/** A config save changes the desired state everywhere derived from it. */
|
||||
export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const;
|
||||
|
||||
export const runSync = Api.mutation("sync", "run");
|
||||
export const RUN_SYNC_KEYS = ["status", "preview"] as const;
|
||||
|
||||
// ── Live status (SSE with polling fallback) ────────────────────────────────────
|
||||
|
||||
/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */
|
||||
const liveStatusStream = sseAtom({
|
||||
url: `${prefix}${EVENTS_PATH}`,
|
||||
event: EVENTS_EVENT,
|
||||
schema: EngineStatus,
|
||||
});
|
||||
|
||||
/**
|
||||
* The status frame stream, mode-independent: SSE in live mode, the fixture ticker in
|
||||
* mock mode. `undefined` until the first frame arrives.
|
||||
*/
|
||||
export const statusStreamAtom: Atom.Atom<EngineStatus | undefined> = Atom.make(
|
||||
(get) => {
|
||||
if (get(apiModeAtom) === "mock") {
|
||||
const mock = installedMockStatusStream();
|
||||
return mock === undefined ? undefined : get(mock);
|
||||
}
|
||||
return get(liveStatusStream);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* What pages actually render: the freshest engine status — the latest SSE frame once one
|
||||
* has arrived, the plain GET /api/status result before that (and as error/skeleton
|
||||
* source, since the SSE stream never fails).
|
||||
*/
|
||||
export const liveStatusAtom: Atom.Atom<Atom.Type<typeof statusAtom>> =
|
||||
Atom.make((get) => {
|
||||
const frame = get(statusStreamAtom);
|
||||
if (frame !== undefined) {
|
||||
return AsyncResult.success<EngineStatus, Atom.Failure<typeof statusAtom>>(
|
||||
frame,
|
||||
);
|
||||
}
|
||||
return get(statusAtom);
|
||||
});
|
||||
|
||||
// ── Status transitions → activity feed ─────────────────────────────────────────
|
||||
|
||||
export type StatusEvent = {
|
||||
readonly id: string;
|
||||
readonly at: number;
|
||||
readonly text: string;
|
||||
readonly level: "info" | "warn" | "error";
|
||||
};
|
||||
|
||||
const EVENT_LIMIT = 50;
|
||||
|
||||
/**
|
||||
* Folds the status frame stream into a bounded activity list for the Overview feed —
|
||||
* client-side only, no history endpoint: connect, sync start/finish (with the report's
|
||||
* count), report warnings, export read errors, and a fresh export appearing.
|
||||
*/
|
||||
export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
|
||||
Atom.make((get) => {
|
||||
let prev: EngineStatus | undefined;
|
||||
let items: ReadonlyArray<StatusEvent> = [];
|
||||
let seq = 0;
|
||||
get.subscribe(
|
||||
statusStreamAtom,
|
||||
(frame) => {
|
||||
if (frame === undefined) return;
|
||||
const next: Array<StatusEvent> = [];
|
||||
const push = (text: string, level: StatusEvent["level"] = "info") =>
|
||||
next.push({ id: `ev-${seq++}`, at: Date.now(), text, level });
|
||||
|
||||
if (prev === undefined) {
|
||||
push(
|
||||
frame.location.file === null
|
||||
? "Engine connected — waiting for the Playnite exporter"
|
||||
: `Engine connected — reading ${frame.location.viaIngest ? "the ingest inbox" : "Playnite's ExtensionsData"}`,
|
||||
);
|
||||
if (frame.exportError !== null) push(frame.exportError, "error");
|
||||
if (frame.syncing) push("Sync running");
|
||||
} else {
|
||||
if (!prev.syncing && frame.syncing) push("Sync started");
|
||||
if (prev.syncing && !frame.syncing) {
|
||||
const report = frame.lastReport;
|
||||
if (report === null) {
|
||||
push("Sync finished");
|
||||
} else {
|
||||
push(
|
||||
`Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`,
|
||||
);
|
||||
for (const warning of report.warnings) push(warning, "warn");
|
||||
}
|
||||
}
|
||||
if (
|
||||
frame.exportError !== null &&
|
||||
frame.exportError !== prev.exportError
|
||||
) {
|
||||
push(frame.exportError, "error");
|
||||
}
|
||||
if (prev.exportError !== null && frame.exportError === null) {
|
||||
push("Exporter output is readable again");
|
||||
}
|
||||
if (
|
||||
frame.location.mtime !== null &&
|
||||
frame.location.mtime !== prev.location.mtime
|
||||
) {
|
||||
push("Playnite wrote a new library export");
|
||||
}
|
||||
}
|
||||
|
||||
prev = frame;
|
||||
if (next.length > 0) {
|
||||
items = [...items, ...next].slice(-EVENT_LIMIT);
|
||||
get.setSelf(items);
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
return items;
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// The one place the UI talks HTTP: a typed client + atom factory derived from the SHARED
|
||||
// HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in pages —
|
||||
// endpoints, payloads, and errors all flow from @playnite/contract.
|
||||
|
||||
import { PlayniteApi } from "@playnite/contract";
|
||||
import { resolvePluginBase } from "@punktfunk/plugin-kit/react";
|
||||
import {
|
||||
FetchHttpClient,
|
||||
HttpClient,
|
||||
HttpClientRequest,
|
||||
} from "effect/unstable/http";
|
||||
import { AtomHttpApi } from "effect/unstable/reactivity";
|
||||
import { installedMockHttpLayer } from "@/mocks/register";
|
||||
import { apiModeAtom } from "@/mocks/scenario";
|
||||
|
||||
/**
|
||||
* "/plugin-ui/playnite" behind the console proxy, "" in dev — Vite serves at "/", so
|
||||
* relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode).
|
||||
*/
|
||||
export const prefix = resolvePluginBase();
|
||||
|
||||
export class Api extends AtomHttpApi.Service<Api>()("PlayniteApi", {
|
||||
api: PlayniteApi,
|
||||
// Mock mode swaps the transport UNDER the same typed client, so pages cannot tell
|
||||
// fixtures from a live host. The mock layer arrives via mocks/register (dev-only
|
||||
// import); if it is not installed we fall through to fetch — a loud, honest failure.
|
||||
httpClient: (get) => {
|
||||
if (get(apiModeAtom) === "mock") {
|
||||
const mock = installedMockHttpLayer(get);
|
||||
if (mock !== undefined) return mock;
|
||||
}
|
||||
return FetchHttpClient.layer;
|
||||
},
|
||||
// prependUrl("") breaks URL joining — only install the transform when proxied.
|
||||
...(prefix !== ""
|
||||
? {
|
||||
transformClient: HttpClient.mapRequest(
|
||||
HttpClientRequest.prependUrl(prefix),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}) {}
|
||||
@@ -0,0 +1,97 @@
|
||||
// The config draft: a local raw-config working copy seeded from GET /api/config.
|
||||
// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved`
|
||||
// decodes the draft through the SHARED PlayniteConfigSchema so controls can display
|
||||
// effective values (e.g. pollMinutes 5 when the file omits it).
|
||||
|
||||
import { useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import { type Config, type RawConfig, resolveConfig } from "@playnite/contract";
|
||||
import { toast } from "@unom/ui/toast";
|
||||
import { Cause, Exit } from "effect";
|
||||
import { AsyncResult } from "effect/unstable/reactivity";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { errorText } from "@/lib/errors";
|
||||
import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms";
|
||||
|
||||
export type ConfigDraft = {
|
||||
/** False until GET /api/config has succeeded once (render a skeleton). */
|
||||
readonly loaded: boolean;
|
||||
/** The working copy of the authored (raw) config. `{}` until loaded. */
|
||||
readonly draft: RawConfig;
|
||||
/** The draft decoded through PlayniteConfigSchema (defaults filled), if valid. */
|
||||
readonly resolved: Config | undefined;
|
||||
readonly dirty: boolean;
|
||||
readonly saving: boolean;
|
||||
readonly patch: (patch: Partial<RawConfig>) => void;
|
||||
readonly save: () => void;
|
||||
readonly discard: () => void;
|
||||
};
|
||||
|
||||
type DraftState = {
|
||||
readonly baseline: RawConfig;
|
||||
readonly draft: RawConfig;
|
||||
};
|
||||
|
||||
export const useConfigDraft = (): ConfigDraft => {
|
||||
const result = useAtomValue(configAtom);
|
||||
const saveResult = useAtomValue(saveConfig);
|
||||
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
|
||||
const [state, setState] = useState<DraftState | null>(null);
|
||||
|
||||
// Seed once from the first successful load; later refetches (our own saves) must not
|
||||
// clobber in-progress edits.
|
||||
useEffect(() => {
|
||||
if (state === null && AsyncResult.isSuccess(result)) {
|
||||
const raw = result.value.raw as RawConfig;
|
||||
setState({ baseline: raw, draft: raw });
|
||||
}
|
||||
}, [result, state]);
|
||||
|
||||
const draft = state?.draft ?? {};
|
||||
const dirty =
|
||||
state !== null &&
|
||||
JSON.stringify(state.draft) !== JSON.stringify(state.baseline);
|
||||
|
||||
const resolved = useMemo(() => {
|
||||
try {
|
||||
return resolveConfig(draft);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}, [draft]);
|
||||
|
||||
const patch = useCallback((p: Partial<RawConfig>) => {
|
||||
setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } }));
|
||||
}, []);
|
||||
|
||||
const discard = useCallback(() => {
|
||||
setState((s) => (s === null ? s : { ...s, draft: s.baseline }));
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
if (state === null) return;
|
||||
const raw = state.draft;
|
||||
void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then(
|
||||
(exit) => {
|
||||
if (Exit.isSuccess(exit)) {
|
||||
setState({ baseline: raw, draft: raw });
|
||||
toast.success("Configuration saved");
|
||||
} else {
|
||||
toast.error("Save failed", {
|
||||
description: errorText(Cause.squash(exit.cause)),
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}, [state, write]);
|
||||
|
||||
return {
|
||||
loaded: state !== null,
|
||||
draft,
|
||||
resolved,
|
||||
dirty,
|
||||
saving: AsyncResult.isWaiting(saveResult),
|
||||
patch,
|
||||
save,
|
||||
discard,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
// One place to turn a typed API error (or any transport failure) into a sentence.
|
||||
export const errorText = (error: unknown): string => {
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const e = error as {
|
||||
_tag?: string;
|
||||
issues?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (e._tag === "ConfigInvalid" && typeof e.issues === "string") {
|
||||
return e.issues;
|
||||
}
|
||||
if (e._tag === "SyncInProgress") return "A sync pass is already running";
|
||||
if (typeof e.message === "string" && e.message.length > 0) return e.message;
|
||||
if (typeof e._tag === "string") return e._tag;
|
||||
}
|
||||
return String(error);
|
||||
};
|
||||
|
||||
/** True for the "the Playnite exporter hasn't produced anything yet" failure — the one
|
||||
* error the UI answers with onboarding rather than a red box. */
|
||||
export const isExportUnavailable = (error: unknown): boolean =>
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
(error as { _tag?: string })._tag === "ExportUnavailable";
|
||||
@@ -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,254 @@
|
||||
// Fixture data for the zero-host dev loop + Storybook. Typed AGAINST THE CONTRACT — if
|
||||
// the contract moves, this file fails typecheck, exactly like the real server would.
|
||||
// Never imported by production app code (see mocks/register.ts for the seam).
|
||||
//
|
||||
// Covers are `https://` URLs on purpose: Playnite passes a remote art reference through
|
||||
// verbatim, so this is a shape the real exporter genuinely produces — and it keeps the
|
||||
// fixtures loadable without the plugin's local-file art proxy.
|
||||
import type {
|
||||
EngineStatus,
|
||||
PreviewResult,
|
||||
RawConfig,
|
||||
SyncReport,
|
||||
} from "@playnite/contract";
|
||||
import type { Scenario } from "./scenario";
|
||||
|
||||
type ProviderEntry = PreviewResult["entries"][number];
|
||||
|
||||
// ── Games ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const portrait = (title: string): string =>
|
||||
`https://placehold.co/600x900/1c1530/a79ff8?text=${encodeURIComponent(title).replace(/%20/g, "+")}`;
|
||||
|
||||
const guid = (n: number): string =>
|
||||
`${n.toString(16).padStart(8, "0")}-1111-4111-8111-111111111111`;
|
||||
|
||||
type Fixture = {
|
||||
readonly id: string;
|
||||
readonly title: string;
|
||||
readonly source: string;
|
||||
};
|
||||
|
||||
const FIXTURES: ReadonlyArray<Fixture> = [
|
||||
{ id: guid(1), title: "Hollow Knight", source: "Steam" },
|
||||
{ id: guid(2), title: "Disco Elysium", source: "GOG" },
|
||||
{ id: guid(3), title: "Hades", source: "Steam" },
|
||||
{ id: guid(4), title: "Outer Wilds", source: "Epic" },
|
||||
{ id: guid(5), title: "Return of the Obra Dinn", source: "Steam" },
|
||||
{ id: guid(6), title: "Baldur's Gate 3", source: "GOG" },
|
||||
{ id: guid(7), title: "Slay the Spire", source: "Steam" },
|
||||
{ id: guid(8), title: "Forza Horizon 5", source: "Xbox" },
|
||||
{ id: guid(9), title: "Tunic", source: "Epic" },
|
||||
{ id: guid(10), title: "Celeste", source: "itch.io" },
|
||||
{ id: guid(11), title: "Super Metroid", source: "RetroArch" },
|
||||
{ id: guid(12), title: "Chrono Trigger", source: "RetroArch" },
|
||||
];
|
||||
|
||||
const entryOf = (f: Fixture): ProviderEntry => ({
|
||||
external_id: f.id,
|
||||
title: f.title,
|
||||
art: { portrait: portrait(f.title) },
|
||||
launch: {
|
||||
kind: "command",
|
||||
value: `start "" "playnite://playnite/start/${f.id}"`,
|
||||
},
|
||||
});
|
||||
|
||||
/** The source each fixture game belongs to, for the report's per-source counts. */
|
||||
const SOURCE_OF = new Map(FIXTURES.map((f) => [f.id, f.source]));
|
||||
|
||||
// ── Config ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
const HEALTHY_CONFIG: RawConfig = {
|
||||
filter: { excludeSources: ["Xbox"] },
|
||||
art: { mode: "host" },
|
||||
};
|
||||
|
||||
export const configFor = (scenario: Scenario): RawConfig =>
|
||||
scenario === "firstRun" ? {} : HEALTHY_CONFIG;
|
||||
|
||||
// ── Preview + report (derived from the current raw config, so toggles round-trip) ──
|
||||
|
||||
const SKIPPED = [
|
||||
{
|
||||
id: guid(8),
|
||||
title: "Forza Horizon 5",
|
||||
reason: 'source "Xbox" excluded',
|
||||
},
|
||||
{
|
||||
id: guid(20),
|
||||
title: "Cyberpunk 2077",
|
||||
reason: "not installed",
|
||||
},
|
||||
{
|
||||
id: guid(21),
|
||||
title: "A Hidden Thing",
|
||||
reason: "hidden in Playnite",
|
||||
},
|
||||
];
|
||||
|
||||
const GENERATED_AT = "2026-07-20T09:14:00Z";
|
||||
|
||||
export const previewFor = (
|
||||
scenario: Scenario,
|
||||
raw: RawConfig,
|
||||
): PreviewResult => {
|
||||
if (scenario === "firstRun") {
|
||||
return {
|
||||
entries: [],
|
||||
report: {
|
||||
generatedAt: "",
|
||||
schemaVersion: 1,
|
||||
considered: 0,
|
||||
included: 0,
|
||||
skipped: [],
|
||||
excluded: [],
|
||||
warnings: [],
|
||||
perSource: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
const overrides = raw.gameOverrides ?? {};
|
||||
const excludedIds = new Set(
|
||||
Object.entries(overrides)
|
||||
.filter(([, o]) => o.exclude === true)
|
||||
.map(([id]) => id),
|
||||
);
|
||||
const skippedIds = new Set(SKIPPED.map((s) => s.id));
|
||||
const candidates = FIXTURES.filter((f) => !skippedIds.has(f.id));
|
||||
const kept = candidates.filter((f) => !excludedIds.has(f.id));
|
||||
const excluded = candidates
|
||||
.filter((f) => excludedIds.has(f.id))
|
||||
.map((f) => ({ id: f.id, title: f.title }));
|
||||
|
||||
const perSource: Record<string, number> = {};
|
||||
for (const f of kept) {
|
||||
const source = SOURCE_OF.get(f.id) ?? "(none)";
|
||||
perSource[source] = (perSource[source] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const report: SyncReport = {
|
||||
generatedAt: GENERATED_AT,
|
||||
schemaVersion: 1,
|
||||
considered: FIXTURES.length + 2,
|
||||
included: kept.length,
|
||||
skipped: SKIPPED,
|
||||
excluded,
|
||||
warnings:
|
||||
scenario === "errors"
|
||||
? [
|
||||
'3001 entries with art.mode "dataurl" — inlined covers will exceed the host\'s reconcile body limit. Switch to art.mode "host" (the default), which serves covers by path.',
|
||||
]
|
||||
: [],
|
||||
perSource,
|
||||
};
|
||||
return { entries: kept.map(entryOf), report };
|
||||
};
|
||||
|
||||
export const reportFor = (scenario: Scenario, raw: RawConfig): SyncReport =>
|
||||
previewFor(scenario, raw).report;
|
||||
|
||||
// ── Engine status + the SSE stand-in ticker ────────────────────────────────────
|
||||
|
||||
const INGEST_DIR = "C:\\ProgramData\\punktfunk\\ingest\\playnite";
|
||||
const APPDATA_DIR = "C:\\Users\\enrico\\AppData\\Roaming\\Playnite";
|
||||
|
||||
const PATHS = {
|
||||
dir: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite",
|
||||
config: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite\\config.json",
|
||||
cache: "C:\\ProgramData\\punktfunk\\plugin-state\\playnite\\cache.json",
|
||||
ingest: `${INGEST_DIR}\\punktfunk-library.json`,
|
||||
} as const;
|
||||
|
||||
const CONNECTED_LOCATION: EngineStatus["location"] = {
|
||||
dir: INGEST_DIR,
|
||||
candidates: [INGEST_DIR, APPDATA_DIR],
|
||||
file: `${INGEST_DIR}\\punktfunk-library.json`,
|
||||
mtime: Date.parse(GENERATED_AT),
|
||||
viaIngest: true,
|
||||
};
|
||||
|
||||
const NO_EXPORT_LOCATION: EngineStatus["location"] = {
|
||||
dir: APPDATA_DIR,
|
||||
candidates: [INGEST_DIR, APPDATA_DIR],
|
||||
file: null,
|
||||
mtime: null,
|
||||
viaIngest: false,
|
||||
};
|
||||
|
||||
export const statusFor = (scenario: Scenario): EngineStatus => {
|
||||
const report = reportFor(scenario, configFor(scenario));
|
||||
const base: EngineStatus = {
|
||||
platform: "win32",
|
||||
location: CONNECTED_LOCATION,
|
||||
exportError: null,
|
||||
playnite: { version: "10.36", mode: "Desktop" },
|
||||
artMode: "host",
|
||||
syncing: false,
|
||||
lastSync: {
|
||||
fingerprint: "b3c1a94efd02",
|
||||
count: report.included,
|
||||
at: Date.now() - 8 * 60 * 1000,
|
||||
},
|
||||
lastReport: report,
|
||||
paths: PATHS,
|
||||
};
|
||||
switch (scenario) {
|
||||
case "firstRun":
|
||||
return {
|
||||
...base,
|
||||
location: NO_EXPORT_LOCATION,
|
||||
exportError:
|
||||
"no exporter output under C:\\Users\\enrico\\AppData\\Roaming\\Playnite — is the Punktfunk Sync extension installed in Playnite?",
|
||||
playnite: null,
|
||||
lastSync: null,
|
||||
lastReport: null,
|
||||
};
|
||||
case "syncing":
|
||||
return { ...base, syncing: true };
|
||||
case "errors":
|
||||
return {
|
||||
...base,
|
||||
exportError:
|
||||
'C:\\ProgramData\\punktfunk\\ingest\\playnite\\punktfunk-library.json is not a usable Punktfunk library export: SchemaError(Expected a punktfunk-library.json schema this plugin understands (≤ 1), got 2 at ["schema"])',
|
||||
lastSync: {
|
||||
fingerprint: "b3c1a94efd02",
|
||||
count: report.included,
|
||||
at: Date.now() - 45 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return base;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Frames for the mock status ticker (~2s steps). Multi-frame scenarios loop
|
||||
* idle → syncing → done so the Overview feed and sync button stay alive without a host.
|
||||
*/
|
||||
export const tickerFrames = (
|
||||
scenario: Scenario,
|
||||
): ReadonlyArray<EngineStatus> => {
|
||||
const base = statusFor(scenario);
|
||||
switch (scenario) {
|
||||
case "firstRun":
|
||||
case "syncing":
|
||||
return [base];
|
||||
case "errors":
|
||||
return [base, { ...base, syncing: true }, { ...base, syncing: false }];
|
||||
default:
|
||||
return [
|
||||
base,
|
||||
{ ...base, syncing: true },
|
||||
{
|
||||
...base,
|
||||
syncing: false,
|
||||
lastSync: {
|
||||
fingerprint: "c4d2ba50fe13",
|
||||
count: base.lastReport?.included ?? 0,
|
||||
at: Date.now(),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
// The mock transport: a full in-memory implementation of the PlayniteApi surface as an
|
||||
// HttpClient layer, plus the SSE stand-in ticker. Importing this module REGISTERS both
|
||||
// (see mocks/register.ts); only main.tsx (behind import.meta.env.DEV) and Storybook's
|
||||
// preview import it, so production app chunks never contain fixtures.
|
||||
import type { EngineStatus, RawConfig } from "@playnite/contract";
|
||||
import { Duration, Effect, Layer } from "effect";
|
||||
import {
|
||||
HttpClient,
|
||||
type HttpClientRequest,
|
||||
HttpClientResponse,
|
||||
} from "effect/unstable/http";
|
||||
import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
|
||||
import * as fx from "./fixtures";
|
||||
import { registerMocks } from "./register";
|
||||
import { type Scenario, scenarioAtom } from "./scenario";
|
||||
|
||||
/** PUT /api/config lands here so config edits round-trip within a registry.
|
||||
* keepAlive: written/read outside the reactive graph — see mocks/scenario.ts. */
|
||||
const mockRawConfigAtom = Atom.keepAlive(Atom.make<RawConfig | null>(null));
|
||||
|
||||
const currentRawConfig = (
|
||||
registry: AtomRegistry.AtomRegistry,
|
||||
scenario: Scenario,
|
||||
): RawConfig => registry.get(mockRawConfigAtom) ?? fx.configFor(scenario);
|
||||
|
||||
const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
|
||||
const body = request.body;
|
||||
if (body._tag === "Uint8Array") {
|
||||
return JSON.parse(new TextDecoder().decode(body.body));
|
||||
}
|
||||
if (body._tag === "Raw") return body.body;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/** The 503 the server returns while the Playnite exporter has produced nothing. */
|
||||
const EXPORT_UNAVAILABLE = {
|
||||
_tag: "ExportUnavailable",
|
||||
message:
|
||||
"no exporter output under C:\\Users\\enrico\\AppData\\Roaming\\Playnite — is the Punktfunk Sync extension installed in Playnite?",
|
||||
};
|
||||
|
||||
export const mockHttpClientLayer = (
|
||||
get: Atom.AtomContext,
|
||||
): Layer.Layer<HttpClient.HttpClient> => {
|
||||
const registry = get.registry;
|
||||
return Layer.succeed(HttpClient.HttpClient)(
|
||||
HttpClient.make((request, url) =>
|
||||
Effect.gen(function* () {
|
||||
// Real-ish latency so skeletons/spinners are honest in dev + Storybook.
|
||||
yield* Effect.sleep(Duration.millis(150 + Math.random() * 250));
|
||||
const scenario = registry.get(scenarioAtom);
|
||||
const json = (body: unknown, status = 200) =>
|
||||
HttpClientResponse.fromWeb(request, Response.json(body, { status }));
|
||||
|
||||
switch (`${request.method} ${url.pathname}`) {
|
||||
case "GET /api/status":
|
||||
return json(fx.statusFor(scenario));
|
||||
|
||||
case "GET /api/config":
|
||||
return json({ raw: currentRawConfig(registry, scenario) });
|
||||
|
||||
case "PUT /api/config": {
|
||||
if (scenario === "errors") {
|
||||
return json(
|
||||
{
|
||||
_tag: "ConfigInvalid",
|
||||
issues:
|
||||
'art.mode: expected "host" | "dataurl" | "off", got "inline"',
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
const raw = requestJson(request) as RawConfig;
|
||||
registry.set(mockRawConfigAtom, raw);
|
||||
return json({ raw });
|
||||
}
|
||||
|
||||
case "GET /api/preview": {
|
||||
if (scenario === "firstRun") return json(EXPORT_UNAVAILABLE, 503);
|
||||
return json(
|
||||
fx.previewFor(scenario, currentRawConfig(registry, scenario)),
|
||||
);
|
||||
}
|
||||
|
||||
case "POST /api/sync": {
|
||||
if (scenario === "syncing") {
|
||||
return json({ _tag: "SyncInProgress" }, 409);
|
||||
}
|
||||
if (scenario === "firstRun") return json(EXPORT_UNAVAILABLE, 503);
|
||||
return json(
|
||||
fx.reportFor(scenario, currentRawConfig(registry, scenario)),
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return json(
|
||||
{ error: `mock: unhandled ${request.method} ${url.pathname}` },
|
||||
404,
|
||||
);
|
||||
}
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
/** The SSE stand-in: cycles fixture frames (~2s steps) per the active scenario. */
|
||||
export const mockStatusStreamAtom: Atom.Atom<EngineStatus | undefined> =
|
||||
Atom.make((get) => {
|
||||
const frames = fx.tickerFrames(get(scenarioAtom));
|
||||
if (frames.length > 1) {
|
||||
let i = 0;
|
||||
const timer = setInterval(() => {
|
||||
i += 1;
|
||||
get.setSelf(frames[i % frames.length]);
|
||||
}, 2000);
|
||||
get.addFinalizer(() => clearInterval(timer));
|
||||
}
|
||||
return frames[0];
|
||||
});
|
||||
|
||||
registerMocks({
|
||||
httpLayer: mockHttpClientLayer,
|
||||
statusStream: mockStatusStreamAtom,
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// The dev-only seam between the always-shipped data layer and the mock transport.
|
||||
// client.ts/atoms.ts reference THIS module (tiny, fixture-free); mocks/http.ts calls
|
||||
// `registerMocks` at import time and is only ever imported by main.tsx behind
|
||||
// `import.meta.env.DEV` and by Storybook's preview — so production app chunks stay free
|
||||
// of fixture code without any conditional imports in the data layer.
|
||||
import type { EngineStatus } from "@playnite/contract";
|
||||
import type { Layer } from "effect";
|
||||
import type { HttpClient } from "effect/unstable/http";
|
||||
import type { Atom } from "effect/unstable/reactivity";
|
||||
|
||||
export type MockHttpLayerFactory = (
|
||||
get: Atom.AtomContext,
|
||||
) => Layer.Layer<HttpClient.HttpClient>;
|
||||
|
||||
type Registered = {
|
||||
readonly httpLayer: MockHttpLayerFactory;
|
||||
readonly statusStream: Atom.Atom<EngineStatus | undefined>;
|
||||
};
|
||||
|
||||
let registered: Registered | undefined;
|
||||
|
||||
export const registerMocks = (mocks: Registered): void => {
|
||||
registered = mocks;
|
||||
};
|
||||
|
||||
/** The mock HttpClient layer, when mocks/http.ts has been imported. */
|
||||
export const installedMockHttpLayer = (
|
||||
get: Atom.AtomContext,
|
||||
): Layer.Layer<HttpClient.HttpClient> | undefined => registered?.httpLayer(get);
|
||||
|
||||
/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */
|
||||
export const installedMockStatusStream = ():
|
||||
| Atom.Atom<EngineStatus | undefined>
|
||||
| undefined => registered?.statusStream;
|
||||
@@ -0,0 +1,43 @@
|
||||
// Mock-mode switches. Fixture-free ON PURPOSE: this module sits in the production
|
||||
// import graph (client.ts reads apiModeAtom), so it must never pull in fixtures.
|
||||
import { Atom } from "effect/unstable/reactivity";
|
||||
|
||||
export type ApiMode = "mock" | "live";
|
||||
|
||||
/** The fixture worlds the mock transport can serve (Storybook picks one per story). */
|
||||
export type Scenario = "healthy" | "firstRun" | "syncing" | "errors";
|
||||
|
||||
/**
|
||||
* Dev defaults to the zero-host mock loop; `bun run dev:live` (VITE_API_MODE=live) and
|
||||
* production builds hit the real API. Storybook seeds this atom to "mock" per registry.
|
||||
*/
|
||||
export const defaultApiMode: ApiMode =
|
||||
import.meta.env.DEV && import.meta.env.VITE_API_MODE !== "live"
|
||||
? "mock"
|
||||
: "live";
|
||||
|
||||
// keepAlive: both atoms are read OUTSIDE the reactive graph (the mock transport does a
|
||||
// request-time registry.get) — without it, a registry-seeded value on a page that never
|
||||
// subscribes them is collected and the read falls back to the default.
|
||||
export const apiModeAtom: Atom.Writable<ApiMode> = Atom.keepAlive(
|
||||
Atom.make<ApiMode>(defaultApiMode),
|
||||
);
|
||||
|
||||
const SCENARIOS: ReadonlyArray<Scenario> = [
|
||||
"healthy",
|
||||
"firstRun",
|
||||
"syncing",
|
||||
"errors",
|
||||
];
|
||||
|
||||
const isScenario = (value: unknown): value is Scenario =>
|
||||
SCENARIOS.includes(value as Scenario);
|
||||
|
||||
/** Dev knob: `VITE_MOCK_SCENARIO=firstRun bun run dev` starts in another world. */
|
||||
const defaultScenario: Scenario = isScenario(import.meta.env.VITE_MOCK_SCENARIO)
|
||||
? import.meta.env.VITE_MOCK_SCENARIO
|
||||
: "healthy";
|
||||
|
||||
export const scenarioAtom: Atom.Writable<Scenario> = Atom.keepAlive(
|
||||
Atom.make<Scenario>(defaultScenario),
|
||||
);
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LibraryPage } from "./library";
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Library",
|
||||
component: LibraryPage,
|
||||
args: { navigate: () => {} },
|
||||
} satisfies Meta<typeof LibraryPage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
|
||||
export const Errors: Story = { parameters: { scenario: "errors" } };
|
||||
@@ -0,0 +1,276 @@
|
||||
// Library — subscribes previewAtom (the dry-run desired state) + configAtom (for the
|
||||
// include toggles); mutates saveConfig directly (no draft — a toggle saves immediately).
|
||||
//
|
||||
// Covers: Playnite art is usually a LOCAL file on the host, which the browser cannot
|
||||
// load, so `artUrl` routes those through the plugin's allow-listed /api/art proxy and
|
||||
// leaves already-loadable http/data references alone.
|
||||
|
||||
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import {
|
||||
artUrl,
|
||||
type PreviewResult,
|
||||
type RawConfig,
|
||||
type SyncReport,
|
||||
} from "@playnite/contract";
|
||||
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, LibraryBig, PlugZap } 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 { prefix } from "@/data/client";
|
||||
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>
|
||||
);
|
||||
|
||||
const NoExporter = () => (
|
||||
<EmptyState
|
||||
icon={PlugZap}
|
||||
title="No library yet"
|
||||
description="Install the Punktfunk Sync extension in Playnite and restart it — the games it exports show up here."
|
||||
className="py-16"
|
||||
/>
|
||||
);
|
||||
|
||||
/** 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(
|
||||
(id: 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[id] ?? {};
|
||||
if (included) {
|
||||
if (Object.keys(rest).length === 0) delete overrides[id];
|
||||
else overrides[id] = rest;
|
||||
} else {
|
||||
overrides[id] = { ...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: (id: string, included: boolean) => void;
|
||||
}) => {
|
||||
const [imgState, setImgState] = useState<"loading" | "ok" | "error">(
|
||||
"loading",
|
||||
);
|
||||
const portrait = artUrl(prefix, entry.art?.portrait);
|
||||
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>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
};
|
||||
|
||||
const SkippedCard = ({
|
||||
report,
|
||||
onToggle,
|
||||
}: {
|
||||
report: SyncReport;
|
||||
onToggle: (id: 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>Not in the library ({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.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.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.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={LibraryBig}
|
||||
title="Nothing to show yet"
|
||||
description="Playnite exported a library, but every game was filtered out. Loosen the filters under Settings — for example, turn off “only installed games”."
|
||||
/>
|
||||
);
|
||||
}
|
||||
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}
|
||||
unavailable={<NoExporter />}
|
||||
>
|
||||
{(p) => <LibraryContent preview={p} />}
|
||||
</Gate>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { OverviewPage } from "./overview";
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Overview",
|
||||
component: OverviewPage,
|
||||
args: { navigate: () => {} },
|
||||
} satisfies Meta<typeof OverviewPage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
|
||||
export const Syncing: Story = { parameters: { scenario: "syncing" } };
|
||||
export const Errors: Story = { parameters: { scenario: "errors" } };
|
||||
@@ -0,0 +1,290 @@
|
||||
// Overview — subscribes liveStatusAtom (SSE-fresh engine status) + statusEventsAtom;
|
||||
// mutates runSync. Before the Playnite exporter has written anything (no export file), it
|
||||
// renders the two-step onboarding instead: that is the state every new install starts in.
|
||||
|
||||
import { useAtomRefresh, useAtomSet, useAtomValue } from "@effect/atom-react";
|
||||
import type { EngineStatus } from "@playnite/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 { CodeBlock } from "@unom/ui/code-block";
|
||||
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 {
|
||||
Image,
|
||||
Library,
|
||||
PlugZap,
|
||||
RefreshCw,
|
||||
TriangleAlert,
|
||||
Users,
|
||||
} 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>
|
||||
);
|
||||
|
||||
/** The state a fresh install is in: this plugin runs, the Playnite half does not yet. */
|
||||
const FirstRun = ({ status }: { status: EngineStatus }) => (
|
||||
<div className="space-y-6">
|
||||
<EmptyState
|
||||
icon={PlugZap}
|
||||
title="Waiting for Playnite"
|
||||
description={
|
||||
"This half of the plugin is running. The other half — the Punktfunk Sync extension — lives inside Playnite and writes your library out on every change. Install it in Playnite (double-click punktfunk-sync.pext) and restart Playnite; your games appear here within seconds, no configuration needed."
|
||||
}
|
||||
className="py-16"
|
||||
/>
|
||||
<Card>
|
||||
<h2 className="mb-1 text-lg font-semibold tracking-tight">
|
||||
Where we're looking
|
||||
</h2>
|
||||
<p className="mb-3 text-sm text-muted-foreground">
|
||||
The exporter drops the library into the first path below — the inbox the
|
||||
host's de-privileged plugin runner is allowed to read. The rest are
|
||||
Playnite data directories we also probe.
|
||||
</p>
|
||||
<CodeBlock copy>{status.location.candidates.join("\n")}</CodeBlock>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
const exportAge = (status: EngineStatus): string => {
|
||||
if (status.location.mtime === null) return "no export yet";
|
||||
const minutes = Math.max(
|
||||
0,
|
||||
Math.round((Date.now() - status.location.mtime) / 60_000),
|
||||
);
|
||||
if (minutes === 0) return "written just now";
|
||||
if (minutes < 60) return `written ${minutes} min ago`;
|
||||
if (minutes < 60 * 24) return `written ${Math.round(minutes / 60)} h ago`;
|
||||
return `written ${Math.round(minutes / (60 * 24))} d ago`;
|
||||
};
|
||||
|
||||
const lastSyncHint = (status: EngineStatus): string => {
|
||||
if (status.lastSync === null) 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>
|
||||
);
|
||||
};
|
||||
|
||||
/** A readable export that failed to decode — schema skew, truncation, a foreign file. */
|
||||
const ExportProblem = ({ status }: { status: EngineStatus }) => {
|
||||
if (status.exportError === null) return null;
|
||||
return (
|
||||
<Card className="border-error/40">
|
||||
<div className="flex items-start gap-3">
|
||||
<TriangleAlert className="mt-0.5 size-4 shrink-0 text-error" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">Can't read the Playnite export</p>
|
||||
<p className="text-sm text-muted-foreground">{status.exportError}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const Warnings = ({ status }: { status: EngineStatus }) => {
|
||||
const warnings = status.lastReport?.warnings ?? [];
|
||||
if (warnings.length === 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" />
|
||||
{warnings.length} {warnings.length === 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">
|
||||
{warnings.map((warning) => (
|
||||
<li key={warning} className="flex gap-2">
|
||||
<span aria-hidden className="text-warn">
|
||||
•
|
||||
</span>
|
||||
{warning}
|
||||
</li>
|
||||
))}
|
||||
</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 report = status.lastReport;
|
||||
const inLibrary = status.lastSync?.count ?? report?.included ?? 0;
|
||||
const skipped = report?.skipped.length ?? 0;
|
||||
const sources = Object.keys(report?.perSource ?? {}).length;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-card xl:grid-cols-4">
|
||||
<StatCard
|
||||
label="In library"
|
||||
value={inLibrary}
|
||||
icon={Library}
|
||||
hint={lastSyncHint(status)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Sources"
|
||||
value={sources}
|
||||
icon={Users}
|
||||
hint={
|
||||
sources === 0
|
||||
? "nothing synced yet"
|
||||
: Object.entries(report?.perSource ?? {})
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 3)
|
||||
.map(([source, n]) => `${source} ${n}`)
|
||||
.join(" · ")
|
||||
}
|
||||
/>
|
||||
<StatCard
|
||||
label="Skipped"
|
||||
value={skipped}
|
||||
icon={TriangleAlert}
|
||||
tone={skipped > 0 ? "warn" : "default"}
|
||||
hint={skipped > 0 ? "see Library for reasons" : "nothing skipped"}
|
||||
/>
|
||||
<StatCard
|
||||
label="Cover art"
|
||||
value={status.artMode}
|
||||
icon={Image}
|
||||
tone={status.artMode === "off" ? "default" : "success"}
|
||||
hint={
|
||||
status.artMode === "host"
|
||||
? "served by the host"
|
||||
: status.artMode === "dataurl"
|
||||
? "inlined in the payload"
|
||||
: "covers disabled"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm font-medium">
|
||||
Exporter connected
|
||||
{status.playnite?.version !== null &&
|
||||
status.playnite?.version !== undefined
|
||||
? ` — Playnite ${status.playnite.version}`
|
||||
: ""}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{status.location.viaIngest
|
||||
? "Reading the host ingest inbox"
|
||||
: "Reading Playnite's ExtensionsData"}{" "}
|
||||
· {exportAge(status)}
|
||||
{report !== null ? ` · ${report.considered} games exported` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<SyncButton status={status} />
|
||||
</div>
|
||||
</Card>
|
||||
<ExportProblem status={status} />
|
||||
<Warnings status={status} />
|
||||
<ActivityFeed />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const OverviewPage = (_: PageProps) => {
|
||||
const status = useAtomValue(liveStatusAtom);
|
||||
const retry = useAtomRefresh(statusAtom);
|
||||
return (
|
||||
<Gate result={status} skeleton={<PageSkeleton />} retry={retry}>
|
||||
{(s) =>
|
||||
s.location.file === null ? (
|
||||
<FirstRun status={s} />
|
||||
) : (
|
||||
<Dashboard status={s} />
|
||||
)
|
||||
}
|
||||
</Gate>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { SettingsPage } from "./settings";
|
||||
|
||||
const meta = {
|
||||
title: "Pages/Settings",
|
||||
component: SettingsPage,
|
||||
args: { navigate: () => {} },
|
||||
} satisfies Meta<typeof SettingsPage>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
export const FirstRun: Story = { parameters: { scenario: "firstRun" } };
|
||||
@@ -0,0 +1,301 @@
|
||||
// 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 { ArtMode, RawConfig } from "@playnite/contract";
|
||||
import { PathListEditor } from "@unom/app-ui/path-list-editor";
|
||||
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"]>;
|
||||
type FilterRaw = NonNullable<RawConfig["filter"]>;
|
||||
|
||||
const PageSkeleton = () => (
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-72" />
|
||||
<Skeleton className="h-64" />
|
||||
<Skeleton className="h-52" />
|
||||
<Skeleton className="h-40" />
|
||||
</div>
|
||||
);
|
||||
|
||||
const ART_MODES: ReadonlyArray<{
|
||||
readonly value: ArtMode;
|
||||
readonly label: string;
|
||||
readonly hint: string;
|
||||
}> = [
|
||||
{
|
||||
value: "host",
|
||||
label: "Served by the host",
|
||||
hint: "Send each cover's local path and let the host serve the bytes. Scales to any library size — the recommended mode.",
|
||||
},
|
||||
{
|
||||
value: "dataurl",
|
||||
label: "Inlined in the payload",
|
||||
hint: "Embed each cover in the library payload. Self-contained, but only fit for small libraries.",
|
||||
},
|
||||
{ value: "off", label: "No cover art", hint: "Sync titles with no art." },
|
||||
];
|
||||
|
||||
/** A string list editor over one of the source filters (`sources` / `excludeSources`). */
|
||||
const SourceList = ({
|
||||
values,
|
||||
onChange,
|
||||
placeholder,
|
||||
addLabel,
|
||||
}: {
|
||||
values: ReadonlyArray<string>;
|
||||
onChange: (next: ReadonlyArray<string>) => void;
|
||||
placeholder: string;
|
||||
addLabel: string;
|
||||
}) => (
|
||||
<PathListEditor<string>
|
||||
items={values}
|
||||
onChange={onChange}
|
||||
path={{ get: (s) => s, set: (_, value) => value, placeholder }}
|
||||
makeItem={(s) => s}
|
||||
addLabel={addLabel}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
|
||||
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 patchFilter = (patch: Partial<FilterRaw>) =>
|
||||
draft.patch({ filter: { ...(draft.draft.filter ?? {}), ...patch } });
|
||||
|
||||
const artMode = resolved?.art.mode ?? "host";
|
||||
|
||||
return (
|
||||
<Gate result={config} skeleton={<PageSkeleton />} retry={retry}>
|
||||
{() => (
|
||||
<div className="space-y-6">
|
||||
<SettingsGroup
|
||||
title="Which games sync"
|
||||
description="Playnite exports your whole library; these filters decide what reaches the Punktfunk library."
|
||||
>
|
||||
<SettingsRow
|
||||
label="Only installed games"
|
||||
description="Skip titles Playnite knows about but hasn't installed."
|
||||
>
|
||||
<Switch
|
||||
checked={resolved?.filter.installedOnly ?? true}
|
||||
onCheckedChange={(checked) =>
|
||||
patchFilter({ installedOnly: checked === true })
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Include hidden games"
|
||||
description="Titles you flagged Hidden in Playnite."
|
||||
>
|
||||
<Switch
|
||||
checked={resolved?.filter.includeHidden ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
patchFilter({ includeHidden: checked === true })
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Only these sources"
|
||||
description='Leave empty for every source. Names as Playnite shows them ("Steam", "GOG", "Epic"…); matching ignores case.'
|
||||
vertical
|
||||
>
|
||||
<SourceList
|
||||
values={resolved?.filter.sources ?? []}
|
||||
onChange={(sources) => patchFilter({ sources: [...sources] })}
|
||||
placeholder="Steam"
|
||||
addLabel="Add source"
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Never these sources"
|
||||
description="Applied after the list above."
|
||||
vertical
|
||||
>
|
||||
<SourceList
|
||||
values={resolved?.filter.excludeSources ?? []}
|
||||
onChange={(excludeSources) =>
|
||||
patchFilter({ excludeSources: [...excludeSources] })
|
||||
}
|
||||
placeholder="Xbox"
|
||||
addLabel="Exclude source"
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
title="Cover art"
|
||||
description="Playnite keeps cover art as files on this machine; this decides how it reaches your clients."
|
||||
>
|
||||
<SettingsRow
|
||||
label="Delivery"
|
||||
description={
|
||||
ART_MODES.find((m) => m.value === artMode)?.hint ?? ""
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={artMode}
|
||||
onValueChange={(value) => patchArt({ mode: value as ArtMode })}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-52">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ART_MODES.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value}>
|
||||
{m.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsRow>
|
||||
{artMode !== "off" ? (
|
||||
<SettingsRow
|
||||
label="Also send backgrounds"
|
||||
description="Playnite's background image becomes hero art. Usually large."
|
||||
>
|
||||
<Switch
|
||||
checked={resolved?.art.includeBackground ?? false}
|
||||
onCheckedChange={(checked) =>
|
||||
patchArt({ includeBackground: checked === true })
|
||||
}
|
||||
/>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
{artMode === "dataurl" ? (
|
||||
<SettingsRow
|
||||
label="Size cap per image"
|
||||
description="Bytes. A cover larger than this is dropped rather than inlined."
|
||||
>
|
||||
<InputNumber
|
||||
className="w-36"
|
||||
min={1}
|
||||
step={100_000}
|
||||
value={resolved?.art.maxBytes ?? 1_500_000}
|
||||
onChange={(maxBytes) => patchArt({ maxBytes })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
title="Sync"
|
||||
description="When the plugin re-reads Playnite's export and reconciles the library."
|
||||
>
|
||||
<SettingsRow
|
||||
label="Watch for changes"
|
||||
description="React to a new export 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 re-reads."
|
||||
>
|
||||
<InputNumber
|
||||
className="w-28"
|
||||
min={1}
|
||||
value={resolved?.sync.pollMinutes ?? 5}
|
||||
onChange={(pollMinutes) => patchSync({ pollMinutes })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
<SettingsRow
|
||||
label="Debounce"
|
||||
description="Milliseconds to wait after a change before re-reading."
|
||||
>
|
||||
<InputNumber
|
||||
className="w-28"
|
||||
min={0}
|
||||
step={100}
|
||||
value={resolved?.sync.debounceMs ?? 1500}
|
||||
onChange={(debounceMs) => patchSync({ debounceMs })}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
title="Advanced"
|
||||
description="Only needed when Playnite isn't where the plugin expects it."
|
||||
>
|
||||
<SettingsRow
|
||||
label="Playnite data directory"
|
||||
description="Overrides the auto-detected %APPDATA%\Playnite — for a portable install. The host ingest inbox is still read first."
|
||||
>
|
||||
<InputText
|
||||
className="w-72"
|
||||
spellCheck={false}
|
||||
value={draft.draft.playniteDir ?? ""}
|
||||
placeholder="C:\Playnite"
|
||||
onChange={(event) => {
|
||||
const dir = event.target.value;
|
||||
// `playniteDir` is an optionalKey: undefined is dropped by
|
||||
// JSON.stringify, so the PUT body omits it entirely.
|
||||
draft.patch({
|
||||
playniteDir: dir.length > 0 ? dir : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</SettingsRow>
|
||||
</SettingsGroup>
|
||||
|
||||
<SettingsGroup
|
||||
title="Files"
|
||||
description="Where this plugin keeps its state, and where Playnite drops the library."
|
||||
>
|
||||
<Gate
|
||||
result={status}
|
||||
skeleton={<Skeleton className="h-24" />}
|
||||
retry={retryStatus}
|
||||
>
|
||||
{(s) => (
|
||||
<CodeBlock copy>
|
||||
{`config: ${s.paths.config}\ncache: ${s.paths.cache}\ninbox: ${s.paths.ingest}\nexport: ${s.location.file ?? "(not written yet)"}`}
|
||||
</CodeBlock>
|
||||
)}
|
||||
</Gate>
|
||||
</SettingsGroup>
|
||||
|
||||
<SaveBar
|
||||
dirty={draft.dirty}
|
||||
saving={draft.saving}
|
||||
onSave={draft.save}
|
||||
onDiscard={draft.discard}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Gate>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
// The flat route list, shared by App.tsx and the pages (kept out of App.tsx so pages
|
||||
// can type their `navigate` prop without importing the shell).
|
||||
export const ROUTES = ["overview", "library", "settings"] as const;
|
||||
|
||||
export type Route = (typeof ROUTES)[number];
|
||||
|
||||
export type PageProps = {
|
||||
readonly navigate: (route: Route) => void;
|
||||
};
|
||||
@@ -0,0 +1,19 @@
|
||||
/* The plugin UI's single stylesheet entry. The kit theme.css carries the console's
|
||||
* violet identity (shadcn + @unom token vocabularies, Penner easings, `.dark` palette)
|
||||
* so the embedded iframe is indistinguishable from the console around it. Do NOT import
|
||||
* @unom/ui/styles/theme.css — that is the library's own neutral palette. */
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@import "@punktfunk/plugin-kit/theme.css";
|
||||
@import "@fontsource-variable/geist";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Scan the design system's built output so its class names survive Tailwind's purge.
|
||||
* Bare directory globs on purpose — rebased file globs proved flaky. */
|
||||
@source "../node_modules/@unom/ui/dist";
|
||||
@source "../node_modules/@unom/app-ui/dist";
|
||||
|
||||
body {
|
||||
@apply bg-background font-sans text-foreground antialiased;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Capture page screenshots from the built Storybook — the same harness the console
|
||||
// uses (punktfunk/web/tools/screenshots.mjs): headless Chromium over storybook-static,
|
||||
// every Pages/* + Shell/* story, rendered entirely from fixtures. No host required.
|
||||
//
|
||||
// bun run build-storybook # produce ./storybook-static
|
||||
// bun run screenshots # → ./screenshots/<story-id>.png
|
||||
//
|
||||
// Env knobs: OUT (output dir), STORYBOOK_STATIC (input dir), SETTLE (ms after the
|
||||
// page looks ready, default 600), WIDTH/HEIGHT/SCALE (viewport, default 1440x900@2x),
|
||||
// ONLY (comma-separated story-id substring filter).
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import { extname, join, normalize, resolve } from "node:path";
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const ROOT = resolve(process.env.STORYBOOK_STATIC ?? "storybook-static");
|
||||
const OUT = resolve(process.env.OUT ?? "screenshots");
|
||||
const SETTLE = Number(process.env.SETTLE ?? 600);
|
||||
const WIDTH = Number(process.env.WIDTH ?? 1440);
|
||||
const HEIGHT = Number(process.env.HEIGHT ?? 900);
|
||||
const SCALE = Number(process.env.SCALE ?? 2);
|
||||
const ONLY = (process.env.ONLY ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
// Only the page-level + shell stories make sense as screenshots.
|
||||
const TITLE_PREFIXES = ["Pages/", "Shell/"];
|
||||
|
||||
const MIME = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".mjs": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".jpg": "image/jpeg",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
".ttf": "font/ttf",
|
||||
".map": "application/json",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
|
||||
function staticServer(rootDir) {
|
||||
return createServer(async (req, res) => {
|
||||
try {
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
let path = decodeURIComponent(url.pathname);
|
||||
if (path.endsWith("/")) path += "index.html";
|
||||
// Contain the path to rootDir (no traversal).
|
||||
const filePath = normalize(join(rootDir, path));
|
||||
if (!filePath.startsWith(rootDir)) {
|
||||
res.writeHead(403).end();
|
||||
return;
|
||||
}
|
||||
const body = await readFile(filePath);
|
||||
res.writeHead(200, {
|
||||
"content-type": MIME[extname(filePath)] ?? "application/octet-stream",
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function listStories(rootDir) {
|
||||
const indexPath = join(rootDir, "index.json");
|
||||
if (!existsSync(indexPath)) {
|
||||
throw new Error(
|
||||
`${indexPath} not found — run \`bun run build-storybook\` first`,
|
||||
);
|
||||
}
|
||||
const index = JSON.parse(await readFile(indexPath, "utf8"));
|
||||
const entries = Object.values(index.entries ?? index.stories ?? {});
|
||||
return entries
|
||||
.filter((e) => e.type === "story" || e.type === undefined)
|
||||
.filter((e) => TITLE_PREFIXES.some((p) => (e.title ?? "").startsWith(p)))
|
||||
.filter((e) => ONLY.length === 0 || ONLY.some((f) => e.id.includes(f)))
|
||||
.sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(ROOT)) {
|
||||
throw new Error(
|
||||
`${ROOT} not found — run \`bun run build-storybook\` first`,
|
||||
);
|
||||
}
|
||||
const stories = await listStories(ROOT);
|
||||
if (stories.length === 0)
|
||||
throw new Error("no Pages/* or Shell/* stories found");
|
||||
await mkdir(OUT, { recursive: true });
|
||||
|
||||
const server = staticServer(ROOT);
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
const port = server.address().port;
|
||||
|
||||
const browser = await chromium.launch({
|
||||
args: ["--force-color-profile=srgb"],
|
||||
});
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: WIDTH, height: HEIGHT },
|
||||
deviceScaleFactor: SCALE,
|
||||
colorScheme: "dark",
|
||||
});
|
||||
|
||||
let ok = 0;
|
||||
for (const story of stories) {
|
||||
const page = await context.newPage();
|
||||
const url = `http://127.0.0.1:${port}/iframe.html?id=${encodeURIComponent(
|
||||
story.id,
|
||||
)}&viewMode=story`;
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "networkidle", timeout: 30_000 });
|
||||
// Story root mounted with real content.
|
||||
await page.waitForSelector("#storybook-root > *", { timeout: 20_000 });
|
||||
// Web fonts settled (else text reflows / falls back in the shot).
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
await page.waitForTimeout(SETTLE);
|
||||
const file = join(OUT, `${story.id}.png`);
|
||||
await page.screenshot({ path: file });
|
||||
console.log(`ok ${story.id} -> ${file}`);
|
||||
ok++;
|
||||
} catch (e) {
|
||||
console.warn(`FAIL ${story.id}: ${e.message}`);
|
||||
} finally {
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
await browser.close();
|
||||
await new Promise((r) => server.close(r));
|
||||
console.log(`\n${ok}/${stories.length} stories captured -> ${OUT}`);
|
||||
if (ok === 0) process.exit(1);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src", ".storybook", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
// Vite config for the plugin SPA. `base: "./"` because the console serves the built
|
||||
// bundle from /plugin-ui/playnite/ — asset URLs must stay relative to survive the proxy.
|
||||
// Dev has two modes (see src/mocks/): plain `bun run dev` runs entirely on fixtures,
|
||||
// `bun run dev:live` proxies /api to the plugin's dev server (plugin/src/dev.ts, :5886).
|
||||
|
||||
import { fileURLToPath } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: { "@": fileURLToPath(new URL("./src", import.meta.url)) },
|
||||
},
|
||||
// Scan only the app entry — without this the dev dep-scanner also crawls any
|
||||
// storybook-static/ build output sitting in the workspace and errors out.
|
||||
optimizeDeps: { entries: ["index.html"] },
|
||||
build: {
|
||||
outDir: "../plugin/dist/ui",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5601,
|
||||
...(process.env.VITE_API_MODE === "live"
|
||||
? {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: process.env.PLAYNITE_URL ?? "http://127.0.0.1:5886",
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user