From e8acf922de4b8c4618258e9a67058de94ee84d4d Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 20 Jul 2026 21:05:04 +0200 Subject: [PATCH] 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. --- .gitea/workflows/ci.yml | 45 +++-- README.md | 110 ++++++++--- biome.json | 9 +- bun.lock | 15 +- exporter/README.md | 2 +- ui/.storybook/main.ts | 12 ++ ui/.storybook/preview.tsx | 65 +++++++ ui/index.html | 13 ++ ui/package.json | 6 +- ui/src/App.stories.tsx | 16 ++ ui/src/App.tsx | 52 ++++++ ui/src/components/gate.tsx | 53 ++++++ ui/src/components/save-bar.tsx | 47 +++++ ui/src/data/atoms.ts | 150 +++++++++++++++ ui/src/data/client.ts | 42 +++++ ui/src/data/drafts.ts | 97 ++++++++++ ui/src/lib/errors.ts | 24 +++ ui/src/main.tsx | 21 +++ ui/src/mocks/fixtures.ts | 254 +++++++++++++++++++++++++ ui/src/mocks/http.ts | 124 ++++++++++++ ui/src/mocks/register.ts | 34 ++++ ui/src/mocks/scenario.ts | 43 +++++ ui/src/pages/library.stories.tsx | 15 ++ ui/src/pages/library.tsx | 276 +++++++++++++++++++++++++++ ui/src/pages/overview.stories.tsx | 16 ++ ui/src/pages/overview.tsx | 290 ++++++++++++++++++++++++++++ ui/src/pages/settings.stories.tsx | 14 ++ ui/src/pages/settings.tsx | 301 ++++++++++++++++++++++++++++++ ui/src/routes.ts | 9 + ui/src/styles.css | 19 ++ ui/tools/screenshots.mjs | 144 ++++++++++++++ ui/tsconfig.json | 20 ++ ui/vite.config.ts | 36 ++++ 33 files changed, 2328 insertions(+), 46 deletions(-) create mode 100644 ui/.storybook/main.ts create mode 100644 ui/.storybook/preview.tsx create mode 100644 ui/index.html create mode 100644 ui/src/App.stories.tsx 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/data/atoms.ts create mode 100644 ui/src/data/client.ts create mode 100644 ui/src/data/drafts.ts create mode 100644 ui/src/lib/errors.ts create mode 100644 ui/src/main.tsx create mode 100644 ui/src/mocks/fixtures.ts create mode 100644 ui/src/mocks/http.ts create mode 100644 ui/src/mocks/register.ts create mode 100644 ui/src/mocks/scenario.ts create mode 100644 ui/src/pages/library.stories.tsx create mode 100644 ui/src/pages/library.tsx create mode 100644 ui/src/pages/overview.stories.tsx create mode 100644 ui/src/pages/overview.tsx create mode 100644 ui/src/pages/settings.stories.tsx create mode 100644 ui/src/pages/settings.tsx create mode 100644 ui/src/routes.ts create mode 100644 ui/src/styles.css create mode 100644 ui/tools/screenshots.mjs create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.ts diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 23dc4fa..5ffa146 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,10 +1,11 @@ -# CI for @punktfunk/plugin-playnite (Gitea Actions). -# build — the TS plugin + SPA: lint, typecheck, test, bundle (mirrors the rom-manager plugin CI). -# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on Linux (no -# Windows/MSBuild needed) and uploaded as a workflow artifact. -# publish — npm publish to the Gitea registry on a `v*` tag. -# Installs resolve @punktfunk/* from the Gitea registry via the bunfig scope map + REGISTRY_TOKEN; -# everything else (effect, react, tailwind…) comes from npm. +# CI for the playnite workspace (Gitea Actions). +# build — the three bun workspaces (contract + plugin + ui): one root install, lint, +# per-package typecheck, domain tests, bundle + SPA. Mirrors rom-manager's. +# exporter — the C# Playnite extension → a `.pext`, built net462 with the .NET SDK on +# Linux (no Windows/MSBuild needed) and uploaded as a workflow artifact. +# publish — npm publish to the Gitea registry on a `v*` tag, from plugin/. +# @punktfunk/* and @unom/* resolve from the Gitea registry via the root bunfig scope maps +# + REGISTRY_TOKEN; everything else (effect, react, tailwind…) comes from npm. name: CI on: @@ -31,22 +32,38 @@ jobs: run: | test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" - - name: Install + - name: Install (workspace) run: bun install --frozen-lockfile - name: Lint & format run: bunx biome check - - name: Typecheck (backend) + - name: Typecheck (contract) + working-directory: contract run: bunx tsc --noEmit - - name: Test (engine) + - name: Typecheck (plugin) + working-directory: plugin + run: bunx tsc --noEmit + - name: Test (domain) + working-directory: plugin run: bun test - - name: Build backend + SPA + - name: Build backend bundle + SPA + working-directory: plugin run: bun run build:all - name: Typecheck (UI) working-directory: ui run: bunx tsc --noEmit - name: Sanity — plugin default export is a valid PluginDef + working-directory: plugin run: | - bun -e 'import p from "./dist/index.js"; const d = p.default ?? p; if (d?.name !== "playnite" || typeof d?.main !== "function") { console.error("bad default export", d); process.exit(1); } console.log("ok:", d.name)' + bun -e 'import plugin from "./dist/index.js"; if (plugin?.name !== "playnite" || typeof plugin?.main !== "function") { console.error("bad default export", plugin); process.exit(1); } console.log("ok:", plugin.name)' + - name: Sanity — no fixtures in the production SPA bundle + working-directory: plugin + run: | + for needle in placehold.co mockHttpClientLayer tickerFrames; do + if grep -rq "$needle" dist/ui/assets/*.js; then + echo "fixture string '$needle' leaked into the production bundle"; exit 1 + fi + done + echo "ok: production bundle is fixture-free" exporter: runs-on: ubuntu-24.04 @@ -90,12 +107,14 @@ jobs: run: | test -n "$TOKEN" || { echo "REGISTRY_TOKEN secret is empty"; exit 1; } printf '//git.unom.io/api/packages/unom/npm/:_authToken=%s\n' "$TOKEN" > "$HOME/.npmrc" - - name: Install + - name: Install (workspace) run: bun install --frozen-lockfile - name: Tag matches package version + working-directory: plugin run: | TAG="${GITHUB_REF_NAME#v}" PKG="$(node -p "require('./package.json').version")" test "$TAG" = "$PKG" || { echo "tag $GITHUB_REF_NAME != package version $PKG"; exit 1; } - name: Publish (prepublishOnly builds backend + SPA) + working-directory: plugin run: bun publish diff --git a/README.md b/README.md index c80f623..c88e741 100644 --- a/README.md +++ b/README.md @@ -3,24 +3,25 @@ Sync your **[Playnite](https://playnite.link)** library into the Punktfunk host game library. Every store and emulator Playnite manages — Steam, GOG, Epic, Xbox, itch, RetroArch, standalone emulators, manually-added games — shows up in Punktfunk's grid on every client, and launching a title hands it -straight back to Playnite, which performs the real launch. +straight back to Playnite, which performs the real launch. It ships a web UI that lives **inside the +Punktfunk console** (no second password, no second port). It has two halves, both on the **same Windows box** as the Punktfunk host: ``` -┌─────────────────────────────┐ ┌──────────────────────────────────────────┐ +┌─────────────────────────────┐ ┌────────────────────────────────────────────┐ │ Playnite │ │ Punktfunk host (this plugin, in the │ │ └ Punktfunk Sync extension │ JSON │ scripting runner) │ -│ writes │ ─────▶ │ reads punktfunk-library.json, embeds art, │ -│ punktfunk-library.json │ │ PUT /library/provider/playnite │ -└─────────────────────────────┘ └──────────────────────────────────────────┘ +│ writes │ ─────▶ │ reads punktfunk-library.json, maps it to │ +│ punktfunk-library.json │ │ entries, PUT /library/provider/playnite │ +└─────────────────────────────┘ └────────────────────────────────────────────┘ ``` - **The Playnite exporter** (`exporter/`, C#) is a tiny [GenericPlugin](https://api.playnite.link/docs/tutorials/extensions/genericPlugins.html) that writes your library to a JSON file whenever it changes. Playnite locks its library database while running, so reading it from *inside* Playnite is the only robust way — this is that adapter, and nothing more. -- **This plugin** (TypeScript, on [`@punktfunk/host`](https://git.unom.io/unom/punktfunk/src/branch/main/sdk)) +- **This plugin** (TypeScript, on [`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit)) watches that file and reconciles it into the host library as the `playnite` provider, with a console-hosted web UI. Zero-config auth via the runner. @@ -37,8 +38,8 @@ bun add @punktfunk/plugin-playnite Enable-ScheduledTask PunktfunkScripting # enable the scripting runner if it isn't already ``` -Open the Punktfunk console — a **Playnite** entry appears in the nav. (Headless box without the -console? The engine is fully functional from a `config.json` alone — see [Headless](#headless--cli).) +Open the Punktfunk console — a **Playnite** entry appears in the nav. It will say it's waiting for +Playnite until you do step 2. ### 2 · The Punktfunk Sync extension (in Playnite) @@ -46,13 +47,23 @@ Download **`punktfunk-sync.pext`** from the [latest CI build](https://git.unom.i (the `exporter` job's artifact) and **double-click it** — Playnite installs it like any add-on. Restart Playnite once. -That's it. The console's **Playnite** page shows "Exporter connected" and your games sync within -seconds of any library change. +That's it. The console's **Playnite** page flips to "Exporter connected" and your games sync within +seconds of any library change. No configuration needed. -> The console page can also **deploy the exporter for you** if you'd rather not handle the `.pext` — -> it drops the extension into `%APPDATA%\Playnite\Extensions\` and asks you to restart Playnite. -> (Best-effort: it needs read/write access to the interactive user's `%APPDATA%`; the `.pext` is the -> reliable fallback.) +> **Headless box** without the console? The engine is fully functional from a `config.json` alone — +> see [Headless / CLI](#headless--cli). + +## The ingest inbox + +Punktfunk's plugin runner is **de-privileged** on Windows (it runs as `NT AUTHORITY\LocalService`), +so it cannot read the interactive user's `%APPDATA%\Playnite\ExtensionsData\…` — which is where a +Playnite extension normally writes. So the exporter drops a second copy into the host's **ingest +inbox**, `\ingest\playnite\punktfunk-library.json`, which `punktfunk-host plugins enable` +makes user-writable. The plugin reads that inbox **first**, and falls back to scanning Playnite's own +`ExtensionsData` (which works for a same-user/SYSTEM runner, or on Linux under Wine). + +This is why the plugin works at all under the security tiers; if the console says it's reading the +ingest inbox, that's the healthy path. ## Launching @@ -64,17 +75,21 @@ maintain. ## Box art Playnite stores cover art as **local files** on the host. Rather than shipping image bytes in the -reconcile (which doesn't scale — a large library blows past the host's request-body limit), the plugin -sends the local file **path** and the host serves the cover through its art proxy +reconcile (which doesn't scale — a large library blows past the host's request-body limit), the +default is to send the local file **path** and let the host serve the cover through its art proxy (`/library/art/…`, exactly like Steam art). The payload stays tiny at any library size. | `art.mode` | Behaviour | |--------------|-----------------------------------------------------------------| | `host` | **Default.** Send the cover's local path; the host serves the bytes. Scales to thousands of titles. Requires a host with the provider-art proxy. | -| `dataurl` | Inline the cover as a size-capped `data:` URL (`maxBytes` caps one image). Self-contained, but for **small** libraries only — a big one exceeds the host's body limit. | +| `dataurl` | Inline the cover as a size-capped `data:` URL (`maxBytes` caps one image). Self-contained, but for **small** libraries only. | | `off` | Sync titles with no art (lightest payload). | -`art.includeBackground` additionally serves the Playnite background as `hero` art (off by default). +`art.includeBackground` additionally sends the Playnite background as `hero` art (off by default). + +The plugin's own SPA can't load a `C:\…\cover.jpg` either, so it fetches covers through +`GET /api/art?path=…` — which serves **only** paths the currently-ingested export references. It is +an allow-list lookup, not an arbitrary file read. ## Filters @@ -84,13 +99,15 @@ sends the local file **path** and the host serves the cover through its art prox | `filter.includeHidden` | `false` | Include games flagged Hidden in Playnite. | | `filter.sources` | `[]` | If non-empty, keep only these sources (`"Steam"`, `"GOG"`…). Case-insensitive. | | `filter.excludeSources` | `[]` | Drop these sources. | +| `gameOverrides` | `{}` | Per-game `{ "": { "exclude": true } }` — the Library page's toggle. | -Edit them in the console's **Playnite** page, or in `config.json` directly. +Edit them in the console's **Playnite** page, or in `config.json` directly. Only the keys you author +are stored: defaults live in the schema (`contract/src/config.ts`) and are never baked into your file. ## Headless / CLI -The plugin owns `/playnite/config.json` (see [`config.example.json`](./config.example.json)). -It's fully functional from that file alone. A debug CLI mirrors the engine: +The plugin owns `/plugin-state/playnite/config.json` and is fully functional from that +file alone. A debug CLI mirrors the engine: ```sh bunx punktfunk-plugin-playnite where # where the exporter output was found (+ probed paths) @@ -99,23 +116,45 @@ bunx punktfunk-plugin-playnite sync # reconcile into the live library bunx punktfunk-plugin-playnite uninstall # remove every entry this plugin owns ``` -For a host without the console, set `ui.standalone: true` and a password -(`bunx punktfunk-plugin-playnite set-password `); the UI then serves on `ui.port` (default 47994). +Provider entries are deliberately **left** in the library when the plugin stops, so your games survive +restarts and reboots; `uninstall` is the explicit way to clear them. ## Requirements - A **Windows** Punktfunk host running the scripting runner (`punktfunk-scripting`). - **Playnite** on the same box, with the Punktfunk Sync extension installed. -- The mgmt-API provider reconcile is loopback + token; the runner supplies both automatically. +- `@punktfunk/host` **≥ 0.1.2** + `@punktfunk/plugin-kit` **≥ 0.1.4** (shared with the runner, not + bundled). -## Build from source +## Development — three bun workspaces + +This plugin is the second consumer of +[`@punktfunk/plugin-kit`](https://git.unom.io/unom/punktfunk/src/branch/main/plugin-kit), built on the +same blueprint as [`rom-manager`](https://git.unom.io/unom/punktfunk-plugin-rom-manager) — copy either +repo's structure to start a new plugin. + +| Workspace | What it is | +|---|---| +| `contract/` | **The single source of truth**: the cross-process export Schema (the C# exporter's other half, with the schema-version guard as a decode check), the config schema (raw↔resolved in one schema), domain DTOs, the `HttpApi` contract, API errors. Never published — bundled into the plugin, imported source-level by the UI. No hand-mirrored types anywhere. | +| `plugin/` | The published package. `src/domain` is the pure, injectable, unit-tested core (locate/read, art, reconcile); `src/services` wires it into the kit (ConfigService, CacheStore, SyncEngine, ProviderClient, HttpApi handlers + SSE + the art proxy); `src/index.ts` is the `definePluginKit` entry (async at the runner boundary, Effect inside). | +| `ui/` | The SPA: React 19 + `@unom/ui` + `@unom/app-ui` + the kit's `/react` helpers + `/theme.css`, with an Effect-native data layer (`AtomHttpApi` atoms derived from the contract). Three pages — **Overview**, **Library**, **Settings**. Builds into `plugin/dist/ui`. | ```sh -bun install -bun run build:all # backend (dist/) + SPA (dist/ui/) -bun test +bun install # one workspace install (Gitea registry for @punktfunk/@unom scopes) +bun test # domain tests (plugin/) +bun run typecheck # contract → plugin → ui +bun run build # backend bundle + SPA → plugin/dist + +cd ui && bun run dev # UI with ZERO host running (in-browser mock layer + scenarios) +cd ui && bun run storybook # per-page stories on the same fixtures (port 6014) +cd ui && bun run screenshots # headless captures of every page story +cd plugin && bun run dev # auth-free dev API on :5886 … +cd ui && bun run dev:live # … and the UI proxied against it ``` +`@unom/*` + `@effect/atom-react` are build-time only — the SPA ships as static assets, so the +published plugin carries no UI runtime dependencies. + The exporter (needs the .NET SDK; builds net462 on any OS via reference assemblies): ```sh @@ -124,6 +163,21 @@ dotnet build exporter/PunktfunkSync.csproj -c Release ( cd exporter/bin/Release && zip -j ../../../punktfunk-sync.pext extension.yaml PunktfunkSync.dll ) ``` +**The export shape is a contract with the C# side.** `contract/src/export.ts` and +`exporter/Model.cs` describe the same document; change one and you must change the other, and bump +`SCHEMA` if the change is breaking. The reader refuses a document stamped with a higher `SCHEMA` than +it understands rather than mis-reading it. + +## Security + +- The **ingest inbox is writable by any local user** — treat what it contains as untrusted. Game ids + are validated as .NET Guids before they reach a launch command; the `playnite://` URI is the only + thing ever interpolated, and Playnite performs the launch. +- `config.json` lives in the hardened `/plugin-state`; the plugin refuses a + group/world-writable one. +- `GET /api/art` serves only paths referenced by the current export, and only image extensions. +- No outbound network at all. No telemetry. + ## License MIT OR Apache-2.0. diff --git a/biome.json b/biome.json index 612391b..843a37c 100644 --- a/biome.json +++ b/biome.json @@ -7,7 +7,14 @@ }, "files": { "ignoreUnknown": false, - "includes": ["**", "!dist", "!ui/dist", "!**/node_modules", "!exporter"] + "includes": [ + "**", + "!**/dist", + "!**/node_modules", + "!exporter", + "!ui/storybook-static", + "!ui/screenshots" + ] }, "formatter": { "enabled": true, diff --git a/bun.lock b/bun.lock index 5b8eb0a..09257cd 100644 --- a/bun.lock +++ b/bun.lock @@ -62,6 +62,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", @@ -1109,7 +1110,7 @@ "framer-motion": ["framer-motion@12.42.2", "", { "dependencies": { "motion-dom": "^12.42.2", "motion-utils": "^12.39.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5XY9luDiu0oHfHBjpDthFMh0ES+122w6p/papSJBweMkO8Sn+PW2QaEgRblQBpWFnuvZS5qvarpt/hO2pjGmnw=="], - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], @@ -1413,6 +1414,10 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "playwright": ["playwright@1.61.1", "", { "dependencies": { "playwright-core": "1.61.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ=="], + + "playwright-core": ["playwright-core@1.61.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg=="], + "pluralize": ["pluralize@8.0.0", "", {}, "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA=="], "postcss": ["postcss@8.5.20", "", { "dependencies": { "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug=="], @@ -1713,6 +1718,8 @@ "anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], + "chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "cosmiconfig/yaml": ["yaml@1.10.3", "", {}, "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA=="], "hoist-non-react-statics/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], @@ -1741,8 +1748,14 @@ "rolldown-plugin-dts/get-tsconfig": ["get-tsconfig@5.0.0-beta.5", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-/6gFNr0N04nob252sTQxyFLi3eKFRqIg1I87YcqAMT1i6SQrSF6KujUEQrtrjMV0H/eejTCltLdDSTEMzHbnsQ=="], + "rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "unplugin/acorn": ["acorn@8.17.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "@oxc-resolver/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], "@rolldown/binding-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="], diff --git a/exporter/README.md b/exporter/README.md index 176eaa6..e982540 100644 --- a/exporter/README.md +++ b/exporter/README.md @@ -17,7 +17,7 @@ On startup, on every `OnLibraryUpdated`, and (debounced) on any per-game add/upd %APPDATA%\Playnite\ExtensionsData\\punktfunk-library.json ``` -Shape (kept in lockstep with `../src/export-schema.ts`): +Shape (kept in lockstep with `../contract/src/export.ts`): ```jsonc { diff --git a/ui/.storybook/main.ts b/ui/.storybook/main.ts new file mode 100644 index 0000000..b5d49d3 --- /dev/null +++ b/ui/.storybook/main.ts @@ -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; diff --git a/ui/.storybook/preview.tsx b/ui/.storybook/preview.tsx new file mode 100644 index 0000000..b4a0a23 --- /dev/null +++ b/ui/.storybook/preview.tsx @@ -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 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 ( + +
+ +
+
+ ); + }, + ], + parameters: { + layout: "padded", + }, +}); diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..dd95061 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + + Playnite — Punktfunk + + +
+ + + diff --git a/ui/package.json b/ui/package.json index e5de0a2..31a1f2d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -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", diff --git a/ui/src/App.stories.tsx b/ui/src/App.stories.tsx new file mode 100644 index 0000000..876b3be --- /dev/null +++ b/ui/src/App.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { + parameters: { layout: "fullscreen", scenario: "firstRun" }, +}; diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..726b796 --- /dev/null +++ b/ui/src/App.tsx @@ -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 = [ + { id: "overview", label: "Overview", icon: LayoutDashboard }, + { id: "library", label: "Library", icon: LibraryBig }, + { id: "settings", label: "Settings", icon: Settings2 }, +]; + +const PAGES: Record> = { + overview: OverviewPage, + library: LibraryPage, + settings: SettingsPage, +}; + +/** Only shown in a standalone tab — embedded in the console, the console is the chrome. */ +const StandaloneHeader = () => ( +
+ + Playnite +
+); + +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..6cea84e --- /dev/null +++ b/ui/src/components/gate.tsx @@ -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 = { + readonly result: AsyncResult.AsyncResult; + /** 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 = (props: GateProps): ReactNode => ( + + props.unavailable !== undefined && isExportUnavailable(error) ? ( + props.unavailable + ) : ( + + 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/data/atoms.ts b/ui/src/data/atoms.ts new file mode 100644 index 0000000..71a67fb --- /dev/null +++ b/ui/src/data/atoms.ts @@ -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 = 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.make((get) => { + const frame = get(statusStreamAtom); + if (frame !== undefined) { + return AsyncResult.success>( + 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> = + Atom.make((get) => { + let prev: EngineStatus | undefined; + let items: ReadonlyArray = []; + let seq = 0; + get.subscribe( + statusStreamAtom, + (frame) => { + if (frame === undefined) return; + const next: Array = []; + 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; + }); diff --git a/ui/src/data/client.ts b/ui/src/data/client.ts new file mode 100644 index 0000000..d471eae --- /dev/null +++ b/ui/src/data/client.ts @@ -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()("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), + ), + } + : {}), +}) {} diff --git a/ui/src/data/drafts.ts b/ui/src/data/drafts.ts new file mode 100644 index 0000000..7c1dafe --- /dev/null +++ b/ui/src/data/drafts.ts @@ -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) => 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(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) => { + 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, + }; +}; diff --git a/ui/src/lib/errors.ts b/ui/src/lib/errors.ts new file mode 100644 index 0000000..b3db97d --- /dev/null +++ b/ui/src/lib/errors.ts @@ -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"; diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..639e8c0 --- /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/mocks/fixtures.ts b/ui/src/mocks/fixtures.ts new file mode 100644 index 0000000..29862b9 --- /dev/null +++ b/ui/src/mocks/fixtures.ts @@ -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 = [ + { 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 = {}; + 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 => { + 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(), + }, + }, + ]; + } +}; diff --git a/ui/src/mocks/http.ts b/ui/src/mocks/http.ts new file mode 100644 index 0000000..a726ded --- /dev/null +++ b/ui/src/mocks/http.ts @@ -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(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 => { + 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 = + 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, +}); diff --git a/ui/src/mocks/register.ts b/ui/src/mocks/register.ts new file mode 100644 index 0000000..f249c95 --- /dev/null +++ b/ui/src/mocks/register.ts @@ -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; + +type Registered = { + readonly httpLayer: MockHttpLayerFactory; + readonly statusStream: Atom.Atom; +}; + +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 | undefined => registered?.httpLayer(get); + +/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */ +export const installedMockStatusStream = (): + | Atom.Atom + | undefined => registered?.statusStream; diff --git a/ui/src/mocks/scenario.ts b/ui/src/mocks/scenario.ts new file mode 100644 index 0000000..8967d08 --- /dev/null +++ b/ui/src/mocks/scenario.ts @@ -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 = Atom.keepAlive( + Atom.make(defaultApiMode), +); + +const SCENARIOS: ReadonlyArray = [ + "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 = Atom.keepAlive( + Atom.make(defaultScenario), +); diff --git a/ui/src/pages/library.stories.tsx b/ui/src/pages/library.stories.tsx new file mode 100644 index 0000000..860a1b2 --- /dev/null +++ b/ui/src/pages/library.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { parameters: { scenario: "firstRun" } }; +export const Errors: Story = { parameters: { scenario: "errors" } }; diff --git a/ui/src/pages/library.tsx b/ui/src/pages/library.tsx new file mode 100644 index 0000000..0f2f989 --- /dev/null +++ b/ui/src/pages/library.tsx @@ -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 = () => ( +
+ {Array.from({ length: 8 }, (_, i) => ( + + ))} +
+); + +const NoExporter = () => ( + +); + +/** 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 ( + + +
+ {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}`} + /> +
+
+
+
+ ); +}; + +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 ( + + + {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} + unavailable={} + > + {(p) => } + + ); +}; diff --git a/ui/src/pages/overview.stories.tsx b/ui/src/pages/overview.stories.tsx new file mode 100644 index 0000000..7765031 --- /dev/null +++ b/ui/src/pages/overview.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +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" } }; diff --git a/ui/src/pages/overview.tsx b/ui/src/pages/overview.tsx new file mode 100644 index 0000000..72f6ec0 --- /dev/null +++ b/ui/src/pages/overview.tsx @@ -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 = () => ( +
+
+ + + + +
+ + +
+); + +/** The state a fresh install is in: this plugin runs, the Playnite half does not yet. */ +const FirstRun = ({ status }: { status: EngineStatus }) => ( +
+ + +

+ Where we're looking +

+

+ 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. +

+ {status.location.candidates.join("\n")} +
+
+); + +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 ( + + {pending ? ( + + ) : ( + + )} + {pending ? "Syncing…" : "Sync now"} + + ); +}; + +/** 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 ( + +
+ +
+

Can't read the Playnite export

+

{status.exportError}

+
+
+
+ ); +}; + +const Warnings = ({ status }: { status: EngineStatus }) => { + const warnings = status.lastReport?.warnings ?? []; + if (warnings.length === 0) return null; + return ( + + + + + + + {warnings.length} {warnings.length === 1 ? "warning" : "warnings"}{" "} + from the last sync + + + +
    + {warnings.map((warning) => ( +
  • + + • + + {warning} +
  • + ))} +
+
+
+
+
+ ); +}; + +const ActivityFeed = () => { + const events = useAtomValue(statusEventsAtom); + return ( + +

Activity

+ +
+ ); +}; + +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 ( +
+
+ + b[1] - a[1]) + .slice(0, 3) + .map(([source, n]) => `${source} ${n}`) + .join(" · ") + } + /> + 0 ? "warn" : "default"} + hint={skipped > 0 ? "see Library for reasons" : "nothing skipped"} + /> + +
+ +
+
+

+ Exporter connected + {status.playnite?.version !== null && + status.playnite?.version !== undefined + ? ` — Playnite ${status.playnite.version}` + : ""} +

+

+ {status.location.viaIngest + ? "Reading the host ingest inbox" + : "Reading Playnite's ExtensionsData"}{" "} + · {exportAge(status)} + {report !== null ? ` · ${report.considered} games exported` : ""} +

+
+ +
+
+ + + +
+ ); +}; + +export const OverviewPage = (_: PageProps) => { + const status = useAtomValue(liveStatusAtom); + const retry = useAtomRefresh(statusAtom); + return ( + } retry={retry}> + {(s) => + s.location.file === null ? ( + + ) : ( + + ) + } + + ); +}; diff --git a/ui/src/pages/settings.stories.tsx b/ui/src/pages/settings.stories.tsx new file mode 100644 index 0000000..594a6e0 --- /dev/null +++ b/ui/src/pages/settings.stories.tsx @@ -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; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +export const FirstRun: Story = { parameters: { scenario: "firstRun" } }; diff --git a/ui/src/pages/settings.tsx b/ui/src/pages/settings.tsx new file mode 100644 index 0000000..f135f95 --- /dev/null +++ b/ui/src/pages/settings.tsx @@ -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; +type SyncRaw = NonNullable; +type FilterRaw = NonNullable; + +const PageSkeleton = () => ( +
+ + + + +
+); + +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; + onChange: (next: ReadonlyArray) => void; + placeholder: string; + addLabel: 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) => + draft.patch({ art: { ...(draft.draft.art ?? {}), ...patch } }); + const patchSync = (patch: Partial) => + draft.patch({ sync: { ...(draft.draft.sync ?? {}), ...patch } }); + const patchFilter = (patch: Partial) => + draft.patch({ filter: { ...(draft.draft.filter ?? {}), ...patch } }); + + const artMode = resolved?.art.mode ?? "host"; + + return ( + } retry={retry}> + {() => ( +
+ + + + patchFilter({ installedOnly: checked === true }) + } + /> + + + + patchFilter({ includeHidden: checked === true }) + } + /> + + + patchFilter({ sources: [...sources] })} + placeholder="Steam" + addLabel="Add source" + /> + + + + patchFilter({ excludeSources: [...excludeSources] }) + } + placeholder="Xbox" + addLabel="Exclude source" + /> + + + + + m.value === artMode)?.hint ?? "" + } + > + + + {artMode !== "off" ? ( + + + patchArt({ includeBackground: checked === true }) + } + /> + + ) : null} + {artMode === "dataurl" ? ( + + patchArt({ maxBytes })} + /> + + ) : null} + + + + + + patchSync({ watch: checked === true }) + } + /> + + + patchSync({ pollMinutes })} + /> + + + patchSync({ debounceMs })} + /> + + + + + + { + 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, + }); + }} + /> + + + + + } + retry={retryStatus} + > + {(s) => ( + + {`config: ${s.paths.config}\ncache: ${s.paths.cache}\ninbox: ${s.paths.ingest}\nexport: ${s.location.file ?? "(not written yet)"}`} + + )} + + + + +
+ )} +
+ ); +}; diff --git a/ui/src/routes.ts b/ui/src/routes.ts new file mode 100644 index 0000000..7bd7d7b --- /dev/null +++ b/ui/src/routes.ts @@ -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; +}; diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..4e0b440 --- /dev/null +++ b/ui/src/styles.css @@ -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; +} diff --git a/ui/tools/screenshots.mjs b/ui/tools/screenshots.mjs new file mode 100644 index 0000000..aae123f --- /dev/null +++ b/ui/tools/screenshots.mjs @@ -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/.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); +}); diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..72348f3 --- /dev/null +++ b/ui/tsconfig.json @@ -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"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..b3ce6b3 --- /dev/null +++ b/ui/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", + }, + }, + } + : {}), + }, +});