From bd383f18202b4297d40a7f7a4c84a0309678b9dc Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Wed, 5 Aug 2026 10:03:24 +0200 Subject: [PATCH] feat(web): one Game sources surface, launcher rail, and the migration nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M4 of design/library-scanner-plugins-implementation-plan.md, plus WP6.2. WP4.1 — SourceToggles and ProvidersCard merge into Library/Sources.tsx. They were two cards because they were two different things: scanners were compiled into the host, plugins were an afterthought. After the extraction they are the same thing — the host reports ONE list of sources whose ids match whether they came from a built-in scanner or the plugin replacing it — so one surface is both simpler and the only honest presentation. Each row carries its toggle, a running/stopped badge for plugin sources, an entry count, filter, settings and an uninstall that offers to remove the games too. An "Add a source" rail lists uncatalogued library plugins with a "Detected" badge; `detected` is deliberately tri-state, so only a POSITIVE probe badges — an entry with no probes for this platform is unknown, and calling that "not installed" would be a lie. The settings drawer (SourceSettings.tsx) renders a generic form from the plugin's own JSON Schema over GET/PUT /__config, through the existing session-gated /plugin-ui// proxy — zero new host surface, and the browser never learns the plugin's port or secret. It flattens allOf branches (effect nests a checked schema's annotations there, so a form reading only the top level silently loses every title and default) and falls back to a JSON editor when any field is a shape it cannot express — partial rendering would be worse than none, because a field missing from the form is a setting the operator cannot change. WP4.2 — uiPlugins() now excludes category "library", which covers both the sidebar and the mobile overflow since they share the selector. The /plugins/$pluginId/$ route still resolves, so existing deep links keep working; library plugins are just not advertised. WP4.3 — LibraryGrid groups role:"launcher" entries into a rail above the grid, and the empty state points at the sources surface rather than leaving a bare grid (after extraction, "no games" is the expected first-run state). WP6.2 — a migration banner offering one install per still-built-in scanner whose plugin is catalogued. One button per scanner, never a single "migrate everything" and never a silent auto-install: installing code stays an explicit operator act, and per-scanner is what makes it safe to repeat (the claim suppresses the built-in idempotently, so a half-finished migration is a valid state). WP4.4 — i18n en+de (kept under the existing "Game sources" label rather than minting a third "Plugins"), Storybook stories for the sources card in three states, the launcher rail and the banner. Gates: orval regen, tsc clean, vite build clean, check-i18n green at 595 messages for both locales. Still owed: the browser click-through (the store's Tabs-theme bug shipped through green types and lint), and an AppShell nav story — that one needs the plugins query mocked, which does not exist in this Storybook setup yet. --- web/messages/de.json | 20 +- web/messages/en.json | 20 +- web/src/api/plugins.ts | 26 +- web/src/api/store.ts | 11 + web/src/sections/Library/LibraryGrid.tsx | 66 ++-- web/src/sections/Library/Providers.tsx | 104 ------ web/src/sections/Library/SourceSettings.tsx | 345 +++++++++++++++++++ web/src/sections/Library/SourceToggles.tsx | 85 ----- web/src/sections/Library/Sources.tsx | 361 ++++++++++++++++++++ web/src/sections/Library/index.tsx | 10 +- web/src/stories/Library.stories.tsx | 174 +++++++++- 11 files changed, 991 insertions(+), 231 deletions(-) delete mode 100644 web/src/sections/Library/Providers.tsx create mode 100644 web/src/sections/Library/SourceSettings.tsx delete mode 100644 web/src/sections/Library/SourceToggles.tsx create mode 100644 web/src/sections/Library/Sources.tsx diff --git a/web/messages/de.json b/web/messages/de.json index c1221d92..1b44cb07 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -285,9 +285,25 @@ "library_field_players": "Spieler", "library_details_legend": "Details (optional)", "library_owned_by": "über {provider}", - "library_providers_title": "Von Plugins synchronisiert", - "library_providers_help": "Diese Einträge gehören einem Plugin und lassen sich deshalb nicht einzeln bearbeiten oder löschen — das Plugin synchronisiert sie neu. Ist das Plugin weg, entferne seine Einträge hier.", "library_provider_count": "{count} Einträge", + "library_launchers_title": "Launcher", + "library_empty_add_source": "Füge unten eine Spielquelle hinzu, damit deine installierten Spiele hier erscheinen.", + "library_add_source": "Quelle hinzufügen", + "library_source_detected": "Erkannt", + "library_source_running": "Läuft", + "library_source_stopped": "Gestoppt", + "library_source_settings": "Einstellungen", + "library_source_settings_title": "Einstellungen für {source}", + "library_source_settings_save": "Einstellungen speichern", + "library_source_settings_saved": "Einstellungen gespeichert.", + "library_source_settings_failed": "Einstellungen konnten nicht gespeichert werden: {issue}", + "library_source_settings_unreachable": "Die Einstellungen dieser Quelle sind nicht erreichbar: {issue}", + "library_source_settings_json_hint": "Die Einstellungen dieser Quelle passen in kein einfaches Formular — bearbeite sie als JSON. Sie werden vor dem Speichern geprüft.", + "library_migrate_title": "Spielquellen werden zu Plugins", + "library_migrate_help": "Jeder Launcher wird ein eigenes Add-on — du installierst nur die, die du nutzt, und jedes bekommt eigene Einstellungen. Installierst du eines, übernimmt es vom eingebauten Scanner; deine Spiele behalten ihre Kacheln. Wenn du nichts tust, ändert sich nichts.", + "library_migrate_install": "Quelle {source} installieren", + "library_source_installing": "{title} wird installiert…", + "library_source_install_failed": "Diese Quelle konnte nicht installiert werden.", "library_provider_filter": "Nur diese zeigen", "library_provider_show_all": "Alle zeigen", "library_provider_purge": "Einträge dieses Anbieters entfernen", diff --git a/web/messages/en.json b/web/messages/en.json index d4e46593..817ff277 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -285,9 +285,25 @@ "library_field_players": "Players", "library_details_legend": "Details (optional)", "library_owned_by": "via {provider}", - "library_providers_title": "Synced by plugins", - "library_providers_help": "These entries are owned by a plugin, so they can't be edited or removed one at a time — the plugin re-syncs them. If the plugin is gone, remove its entries here.", "library_provider_count": "{count} entries", + "library_launchers_title": "Launchers", + "library_empty_add_source": "Add a game source below to see your installed games here.", + "library_add_source": "Add a source", + "library_source_detected": "Detected", + "library_source_running": "Running", + "library_source_stopped": "Stopped", + "library_source_settings": "Settings", + "library_source_settings_title": "{source} settings", + "library_source_settings_save": "Save settings", + "library_source_settings_saved": "Settings saved.", + "library_source_settings_failed": "Could not save the settings: {issue}", + "library_source_settings_unreachable": "Could not reach this source's settings: {issue}", + "library_source_settings_json_hint": "This source's settings don't fit a simple form, so edit them as JSON. They're checked before saving.", + "library_migrate_title": "Game sources are moving to plugins", + "library_migrate_help": "Each launcher is becoming its own add-on, so you only install the ones you use — and each gets its own settings. Install one and it takes over from the built-in scanner; your games keep the same tiles. Nothing changes if you do nothing yet.", + "library_migrate_install": "Install the {source} source", + "library_source_installing": "Installing {title}…", + "library_source_install_failed": "Could not install this source.", "library_provider_filter": "Show only these", "library_provider_show_all": "Show all", "library_provider_purge": "Remove this provider's entries", diff --git a/web/src/api/plugins.ts b/web/src/api/plugins.ts index 017ad062..2c113b9b 100644 --- a/web/src/api/plugins.ts +++ b/web/src/api/plugins.ts @@ -29,8 +29,18 @@ export interface PluginSummary { version?: string; /** Present iff the plugin serves a UI (and thus gets a nav entry). */ ui?: PluginUiSummary; + /** + * What kind of plugin this is. The console knows one value — `"library"` — and keeps those OUT + * of the nav: a scanner's entry point is the Library section's Game sources surface, and six + * installed scanners would otherwise flood the sidebar (design D5). Absent on an older host, and + * absent by choice for a plugin that wants its own page anyway (rom-manager). + */ + category?: string; } +/** The one category the console treats specially. */ +export const LIBRARY_CATEGORY = "library"; + // A curated lucide set for plugin nav icons. Importing lucide's full dynamic icon map would defeat // tree-shaking (U-S4), so a plugin picks a name from here; anything unknown falls back to Puzzle. const ICONS: Record = { @@ -97,6 +107,18 @@ export function usePlugins() { }); } -/** Only the plugins that surface a UI — the ones that get a nav entry. */ +/** + * The plugins that get a **nav entry**: those serving a UI, minus the library-category ones. + * + * A library plugin still serves a UI port (that is how `__config` is reached) and its + * `/plugins/$pluginId/$` route still resolves, so an existing deep link keeps working — it simply + * isn't advertised in the sidebar. + */ export const uiPlugins = (list: PluginSummary[] | undefined): PluginSummary[] => - (list ?? []).filter((p) => p.ui); + (list ?? []).filter((p) => p.ui && p.category !== LIBRARY_CATEGORY); + +/** The installed library-category plugins — the Game sources surface's own list. */ +export const libraryPlugins = ( + list: PluginSummary[] | undefined, +): PluginSummary[] => + (list ?? []).filter((p) => p.category === LIBRARY_CATEGORY); diff --git a/web/src/api/store.ts b/web/src/api/store.ts index e4a34fb4..77de452a 100644 --- a/web/src/api/store.ts +++ b/web/src/api/store.ts @@ -69,6 +69,17 @@ export interface StoreEntry { installed_version?: string; update_available: boolean; blocked?: string; + /** + * What kind of plugin this is. Browse filters on these, and the Library section's "Add a source" + * rail shows exactly the `library` ones (design D5/D6). Absent on an index that predates them. + */ + categories?: string[]; + /** + * Whether the launcher this plugin scans looks installed on this host, from the index's own + * existence probes (design D8). `undefined` = the entry declares no probes for this platform, + * which is "unknown" and must render differently from "not installed". + */ + detected?: boolean; } export interface StoreCatalog { diff --git a/web/src/sections/Library/LibraryGrid.tsx b/web/src/sections/Library/LibraryGrid.tsx index f51fd6d8..690e54fc 100644 --- a/web/src/sections/Library/LibraryGrid.tsx +++ b/web/src/sections/Library/LibraryGrid.tsx @@ -82,14 +82,42 @@ export const LibraryGrid: FC<{ /** Custom id of the card whose delete is in flight, or null — only that card disables. */ deletingId: string | null; }> = ({ library, onEdit, onDelete, deletingId }) => { - const games = library.data ?? []; + const all = library.data ?? []; + // Launcher entries (design D4) open the launcher itself — Steam Big Picture, Heroic — rather than + // a title. They launch and lease exactly like games; grouping them into their own rail is purely + // so a shelf of 400 games doesn't bury the two or three ways to open a launcher. + const launchers = all.filter((g) => g.role === "launcher"); + const games = all.filter((g) => g.role !== "launcher"); + const card = (game: GameEntry) => ( + onEdit(game)} + onDelete={() => onDelete(game)} + deleting={deletingId === customId(game)} + /> + ); return ( - {games.length === 0 ? ( + {launchers.length > 0 && ( +
+

+ {m.library_launchers_title()} +

+ + {launchers.map(card)} + +
+ )} + {all.length === 0 ? ( {/* `flush`, not a bare `p-8`: the default `sm:pt-0` would survive the override (tailwind-merge only resolves conflicts within a variant) and eat the top @@ -98,27 +126,25 @@ export const LibraryGrid: FC<{ flush className="p-8 text-center text-sm text-muted-foreground" > - {m.library_empty()} + {/* After extraction a fresh host has NO scanners at all, so "no games" is the + expected first-run state rather than a fault. Point at the fix (design D9) + instead of leaving a bare empty grid. */} +

{m.library_empty()}

+

{m.library_empty_add_source()}

) : ( -
- - {games.map((game) => ( - onEdit(game)} - onDelete={() => onDelete(game)} - deleting={deletingId === customId(game)} - /> - ))} - -
+ games.length > 0 && ( +
+ + {games.map(card)} + +
+ ) )}
); diff --git a/web/src/sections/Library/Providers.tsx b/web/src/sections/Library/Providers.tsx deleted file mode 100644 index b00b6c05..00000000 --- a/web/src/sections/Library/Providers.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { useQueryClient } from "@tanstack/react-query"; -import { toast } from "@unom/ui/toast"; -import { Trash2 } from "lucide-react"; -import type { FC } from "react"; -import { - getGetLibraryQueryKey, - useDeleteProviderEntries, -} from "@/api/gen/library/library"; -import type { GameEntry } from "@/api/gen/model/gameEntry"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { apiErrorMessage } from "@/lib/errors"; -import { m } from "@/paraglide/messages"; - -/** - * Provider-owned entries: who put them there, and how to get rid of them. - * - * A plugin can sync entries into the library (RFC §8) and they are then refused to hand-edit or - * delete individually — the host answers 409 and points at the provider's own reconcile. Which is - * correct, and completely opaque if the plugin is gone: uninstalling it leaves its games in the - * library with no console-side way to remove them. `DELETE /library/provider/{provider}` is the - * documented clean-uninstall path and nothing called it. - * - * Renders nothing when no entry carries a provider, so an ordinary library sees no extra chrome. - */ -export const ProvidersCard: FC<{ - entries: GameEntry[]; - /** The provider currently filtered to, or null for "everything". */ - active: string | null; - onFilter: (provider: string | null) => void; -}> = ({ entries, active, onFilter }) => { - const qc = useQueryClient(); - const purge = useDeleteProviderEntries(); - - // Count per provider, in first-seen order — the list is small and operator-facing. - const counts = new Map(); - for (const e of entries) { - if (e.provider) counts.set(e.provider, (counts.get(e.provider) ?? 0) + 1); - } - if (counts.size === 0) return null; - - const onPurge = async (provider: string, count: number) => { - if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return; - try { - await purge.mutateAsync({ provider }); - // The host emits `library.changed`, but don't wait for the round trip to redraw. - qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }); - if (active === provider) onFilter(null); - toast.success(m.library_provider_purged({ provider })); - } catch (e) { - toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed()); - } - }; - - return ( - - - {m.library_providers_title()} - - -

- {m.library_providers_help()} -

-
- {[...counts.entries()].map(([provider, count]) => ( -
- {provider} - - {m.library_provider_count({ count })} - -
- - -
-
- ))} -
-
-
- ); -}; diff --git a/web/src/sections/Library/SourceSettings.tsx b/web/src/sections/Library/SourceSettings.tsx new file mode 100644 index 00000000..2ff5dd7a --- /dev/null +++ b/web/src/sections/Library/SourceSettings.tsx @@ -0,0 +1,345 @@ +import { toast } from "@unom/ui/toast"; +import { type FC, useEffect, useState } from "react"; +import type { ScannerInfo } from "@/api/gen/model/scannerInfo"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Spinner } from "@/components/ui/spinner"; +import { m } from "@/paraglide/messages"; + +/** + * A library source's settings, rendered as a **generic form** from the plugin's own JSON Schema. + * + * The point (design D7, closing G8): a scanner plugin ships no SPA at all. It serves + * `GET/PUT /__config` from the kit, and the console renders whatever schema comes back. Everything + * goes through the existing session-gated `/plugin-ui//…` proxy, so there is **zero new host + * surface** — the browser never learns the plugin's port or secret. + * + * Fields the derivation can't express fall back to a raw JSON editor. That fallback is what bounds + * the risk of the whole approach: worst case the drawer is a validated textarea, and the PUT still + * validates by decode host-side either way. + */ +export const SourceSettingsDialog: FC<{ + source: ScannerInfo; + onClose: () => void; +}> = ({ source, onClose }) => { + const pluginId = source.provider ?? source.id; + const [state, setState] = useState< + | { tag: "loading" } + | { tag: "error"; message: string } + | { tag: "ready"; schema: JsonSchemaDoc | null; value: JsonObject } + >({ tag: "loading" }); + const [raw, setRaw] = useState(""); + const [saving, setSaving] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + credentials: "same-origin", + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const body = (await res.json()) as { + schema: JsonSchemaDoc | null; + value: JsonObject | null; + }; + if (cancelled) return; + const value = body.value ?? {}; + setState({ tag: "ready", schema: body.schema, value }); + setRaw(JSON.stringify(value, null, 2)); + } catch (e) { + if (!cancelled) { + setState({ tag: "error", message: String(e) }); + } + } + })(); + return () => { + cancelled = true; + }; + }, [pluginId]); + + const save = async (value: JsonObject) => { + setSaving(true); + try { + const res = await fetch(`/plugin-ui/${pluginId}/__config`, { + method: "PUT", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(value), + }); + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { + issue?: string; + } | null; + throw new Error(body?.issue ?? `HTTP ${res.status}`); + } + toast.success(m.library_source_settings_saved()); + onClose(); + } catch (e) { + toast.error(m.library_source_settings_failed({ issue: String(e) })); + } finally { + setSaving(false); + } + }; + + return ( + !open && onClose()}> + + + + {m.library_source_settings_title({ source: source.label })} + + + {state.tag === "loading" && } + {state.tag === "error" && ( +

+ {m.library_source_settings_unreachable({ issue: state.message })} +

+ )} + {state.tag === "ready" && ( + + )} +
+
+ ); +}; + +type JsonObject = Record; + +interface JsonSchemaNode { + type?: string; + title?: string; + description?: string; + default?: unknown; + enum?: string[]; + properties?: Record; + items?: JsonSchemaNode; + allOf?: JsonSchemaNode[]; +} + +interface JsonSchemaDoc { + schema?: JsonSchemaNode; +} + +/** + * Flatten a node's `allOf` branches into it. A *checked* schema (effect's `Schema.Int`, or anything + * with `.check(...)`) nests its annotations and constraints there rather than at the top level, so + * a form that only reads the top level silently loses every title and default on those fields. + */ +const flatten = (node: JsonSchemaNode): JsonSchemaNode => + (node.allOf ?? []).reduce( + (acc, branch) => ({ ...acc, ...branch }), + { ...node }, + ); + +/** Can this field be rendered as a real input? Anything else sends the whole form to the editor. */ +const renderable = (node: JsonSchemaNode): boolean => { + const n = flatten(node); + if (n.enum) return true; + if (n.type === "boolean" || n.type === "string") return true; + if (n.type === "number" || n.type === "integer") return true; + if (n.type === "array" && flatten(n.items ?? {}).type === "string") return true; + if (n.type === "object" && n.properties) { + return Object.values(n.properties).every(renderable); + } + return false; +}; + +const ConfigForm: FC<{ + schema: JsonSchemaDoc | null; + value: JsonObject; + raw: string; + onRaw: (v: string) => void; + saving: boolean; + onSave: (value: JsonObject) => void; +}> = ({ schema, value, raw, onRaw, saving, onSave }) => { + const [draft, setDraft] = useState(value); + const root = schema?.schema ? flatten(schema.schema) : undefined; + const props = root?.properties; + // Fall back to the JSON editor when there is no schema, or any field is a shape the generic + // form can't express (a non-enum union, a $ref). Partial rendering would be worse than none: + // a field silently missing from the form is a setting the operator cannot change. + const canRender = props !== undefined && Object.values(props).every(renderable); + + if (!canRender) { + return ( +
+

+ {m.library_source_settings_json_hint()} +

+