feat(web): one Game sources surface, launcher rail, and the migration nudge

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/<id>/ 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.
This commit is contained in:
2026-08-05 10:03:24 +02:00
parent 8728d90e01
commit bd383f1820
11 changed files with 991 additions and 231 deletions
+18 -2
View File
@@ -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",
+18 -2
View File
@@ -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",
+24 -2
View File
@@ -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<string, LucideIcon> = {
@@ -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);
+11
View File
@@ -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 {
+46 -20
View File
@@ -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) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={deletingId === customId(game)}
/>
);
return (
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{games.length === 0 ? (
{launchers.length > 0 && (
<div className="@container mb-card">
<p className="pb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground/70">
{m.library_launchers_title()}
</p>
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{launchers.map(card)}
</motion.div>
</div>
)}
{all.length === 0 ? (
<Card>
{/* `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. */}
<p>{m.library_empty()}</p>
<p className="mt-2">{m.library_empty_add_source()}</p>
</CardContent>
</Card>
) : (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={deletingId === customId(game)}
/>
))}
</motion.div>
</div>
games.length > 0 && (
<div className="@container">
<motion.div
transition={{ delayChildren: stagger(0.1) }}
variants={{ enter: {}, from: {} }}
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
>
{games.map(card)}
</motion.div>
</div>
)
)}
</QueryState>
);
-104
View File
@@ -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<string, number>();
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 (
<Card>
<CardHeader>
<CardTitle>{m.library_providers_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{m.library_providers_help()}
</p>
<div className="flex flex-col gap-2">
{[...counts.entries()].map(([provider, count]) => (
<div
key={provider}
className="flex flex-wrap items-center gap-3 rounded-lg border p-3"
>
<span className="font-medium">{provider}</span>
<Badge variant="secondary">
{m.library_provider_count({ count })}
</Badge>
<div className="ml-auto flex gap-2">
<Button
size="sm"
variant={active === provider ? "default" : "outline"}
aria-pressed={active === provider}
onClick={() =>
onFilter(active === provider ? null : provider)
}
>
{active === provider
? m.library_provider_show_all()
: m.library_provider_filter()}
</Button>
<Button
size="sm"
variant="outline"
disabled={purge.isPending}
aria-label={m.library_provider_purge()}
onClick={() => onPurge(provider, count)}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
};
+345
View File
@@ -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/<id>/…` 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 (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{m.library_source_settings_title({ source: source.label })}
</DialogTitle>
</DialogHeader>
{state.tag === "loading" && <Spinner />}
{state.tag === "error" && (
<p className="text-sm text-destructive">
{m.library_source_settings_unreachable({ issue: state.message })}
</p>
)}
{state.tag === "ready" && (
<ConfigForm
schema={state.schema}
value={state.value}
raw={raw}
onRaw={setRaw}
saving={saving}
onSave={save}
/>
)}
</DialogContent>
</Dialog>
);
};
type JsonObject = Record<string, unknown>;
interface JsonSchemaNode {
type?: string;
title?: string;
description?: string;
default?: unknown;
enum?: string[];
properties?: Record<string, JsonSchemaNode>;
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<JsonSchemaNode>(
(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<JsonObject>(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 (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
{m.library_source_settings_json_hint()}
</p>
<textarea
className="h-64 w-full rounded-md border bg-background p-2 font-mono text-xs"
value={raw}
onChange={(e) => onRaw(e.target.value)}
spellCheck={false}
/>
<Button
disabled={saving}
onClick={() => {
try {
onSave(JSON.parse(raw) as JsonObject);
} catch (e) {
toast.error(
m.library_source_settings_failed({ issue: String(e) }),
);
}
}}
>
{m.library_source_settings_save()}
</Button>
</div>
);
}
return (
<div className="space-y-4">
{Object.entries(props).map(([key, rawNode]) => (
<Field
key={key}
name={key}
node={flatten(rawNode)}
value={draft[key]}
onChange={(v) => setDraft((d) => ({ ...d, [key]: v }))}
/>
))}
<Button disabled={saving} onClick={() => onSave(draft)}>
{m.library_source_settings_save()}
</Button>
</div>
);
};
/** One schema field. `undefined` in the draft means "unset" — the file keeps its default out. */
const Field: FC<{
name: string;
node: JsonSchemaNode;
value: unknown;
onChange: (v: unknown) => void;
}> = ({ name, node, value, onChange }) => {
const label = node.title ?? name;
const id = `cfg-${name}`;
if (node.type === "object" && node.properties) {
const nested = (value ?? {}) as JsonObject;
return (
<fieldset className="space-y-3 rounded-lg border p-3">
<legend className="px-1 text-sm font-medium">{label}</legend>
{Object.entries(node.properties).map(([k, n]) => (
<Field
key={k}
name={`${name}.${k}`}
node={flatten(n)}
value={nested[k]}
onChange={(v) => onChange({ ...nested, [k]: v })}
/>
))}
</fieldset>
);
}
if (node.enum) {
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<select
id={id}
className="h-9 w-full rounded-md border bg-background px-2 text-sm"
value={String(value ?? node.default ?? node.enum[0])}
onChange={(e) => onChange(e.target.value)}
>
{node.enum.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
}
if (node.type === "boolean") {
const checked = (value ?? node.default ?? false) as boolean;
return (
<div className="space-y-1">
<div className="flex items-center gap-2">
<input
id={id}
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<Label htmlFor={id}>{label}</Label>
</div>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
}
if (node.type === "array") {
// One absolute path per line — the shape every "extra library folders" setting wants.
const list = (value ?? node.default ?? []) as string[];
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<textarea
id={id}
className="h-24 w-full rounded-md border bg-background p-2 font-mono text-xs"
value={list.join("\n")}
onChange={(e) =>
onChange(
e.target.value
.split("\n")
.map((s) => s.trim())
.filter((s) => s !== ""),
)
}
/>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
}
const numeric = node.type === "number" || node.type === "integer";
return (
<div className="space-y-1">
<Label htmlFor={id}>{label}</Label>
<Input
id={id}
type={numeric ? "number" : "text"}
value={String(value ?? "")}
placeholder={node.default != null ? String(node.default) : undefined}
onChange={(e) => {
const v = e.target.value;
// An emptied field means "unset", which is NOT the same as zero or "" — it is what
// keeps the operator's file free of a value they never chose.
if (v === "") return onChange(undefined);
onChange(numeric ? Number(v) : v);
}}
/>
{node.description && (
<p className="text-xs text-muted-foreground">{node.description}</p>
)}
</div>
);
};
@@ -1,85 +0,0 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Check } from "lucide-react";
import type { FC } from "react";
import {
getGetLibraryQueryKey,
getListLibraryScannersQueryKey,
useListLibraryScanners,
useSetLibraryScanner,
} from "@/api/gen/library/library";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* Container: the game-source (library scanner) toggles — owns the scanner query and the toggle
* mutation. The host only reports the scanners its platform actually has (Steam everywhere,
* Lutris/Heroic on Linux, Epic/GOG/Xbox on Windows), so whatever arrives is renderable as-is.
* Rendered only once the list is loaded: this is a secondary control, and when the API is down
* the grid's own QueryState already tells the story — no second error banner.
*/
export const SourceTogglesSection: FC = () => {
const qc = useQueryClient();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const onToggle = async (scanner: ScannerInfo) => {
try {
// The PUT answers with the full updated list — seed the query cache with it directly,
// then refetch the library so the grid reflects the new source set.
const list = await toggle.mutateAsync({
id: scanner.id,
data: { enabled: !scanner.enabled },
});
qc.setQueryData(getListLibraryScannersQueryKey(), list);
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
} catch {
toast.error(m.library_sources_failed());
}
};
if (!scanners.data) return null;
return (
<SourceToggles
scanners={scanners.data}
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
onToggle={onToggle}
/>
);
};
/** The sources card: one pressed/unpressed chip per scanner (pressed = the host scans it). */
export const SourceToggles: FC<{
scanners: ScannerInfo[];
/** Scanner id whose toggle is in flight, or null — only that chip disables. */
busyId: string | null;
onToggle: (scanner: ScannerInfo) => void;
}> = ({ scanners, busyId, onToggle }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex flex-wrap gap-2">
{scanners.map((scanner) => (
<Button
key={scanner.id}
size="sm"
variant={scanner.enabled ? "default" : "outline"}
aria-pressed={scanner.enabled}
disabled={busyId === scanner.id}
onClick={() => onToggle(scanner)}
>
{scanner.enabled && <Check className="size-4" />}
{scanner.label}
</Button>
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">
{m.library_sources_help()}
</p>
</CardContent>
</Card>
);
+361
View File
@@ -0,0 +1,361 @@
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { Check, Download, Settings2, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import {
getGetLibraryQueryKey,
getListLibraryScannersQueryKey,
useDeleteProviderEntries,
useListLibraryScanners,
useSetLibraryScanner,
} from "@/api/gen/library/library";
import type { ScannerInfo } from "@/api/gen/model/scannerInfo";
import { libraryPlugins, usePlugins } from "@/api/plugins";
import {
type StoreEntry,
useInstallPlugin,
useStoreCatalog,
} from "@/api/store";
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";
import { SourceSettingsDialog } from "./SourceSettings";
/**
* **Game sources** — the single surface for "where do my games come from", merging what used to be
* two cards (the scanner toggles and the "synced by plugins" list).
*
* They were split because they were different things: scanners were compiled into the host and
* 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 that
* replaced it — so one surface is both simpler and the only honest presentation (design D6).
*
* Deliberately kept under the existing "Game sources" label rather than a new "Plugins" heading:
* `store_title` and `nav_plugins` are both already "Plugins", and a third would be worse than the
* merge is good.
*/
export const SourcesSection: FC<{
/** The provider currently filtered to in the grid, or null for "everything". */
activeFilter: string | null;
onFilter: (provider: string | null) => void;
}> = ({ activeFilter, onFilter }) => {
const qc = useQueryClient();
const scanners = useListLibraryScanners();
const toggle = useSetLibraryScanner();
const purge = useDeleteProviderEntries();
const plugins = usePlugins();
const catalog = useStoreCatalog();
const install = useInstallPlugin();
const [settingsFor, setSettingsFor] = useState<ScannerInfo | null>(null);
const onToggle = async (source: ScannerInfo) => {
try {
// The PUT answers with the full updated list — seed the query cache with it directly,
// then refetch the library so the grid reflects the new source set.
const list = await toggle.mutateAsync({
id: source.id,
data: { enabled: !source.enabled },
});
qc.setQueryData(getListLibraryScannersQueryKey(), list);
await qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
} catch {
toast.error(m.library_sources_failed());
}
};
const onPurge = async (source: ScannerInfo) => {
const provider = source.provider ?? source.id;
const count = source.entries ?? 0;
if (!confirm(m.library_provider_purge_confirm({ provider, count }))) return;
try {
await purge.mutateAsync({ provider });
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
qc.invalidateQueries({ queryKey: getListLibraryScannersQueryKey() });
if (activeFilter === provider) onFilter(null);
toast.success(m.library_provider_purged({ provider }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_provider_purge_failed());
}
};
const onInstall = async (entry: StoreEntry) => {
try {
// Install by (source, id) — the catalogued, integrity-pinned path. The raw-spec form is
// for unverified installs and must never be reachable from a one-click rail.
await install.mutateAsync({ source: entry.source, id: entry.id });
toast.success(m.library_source_installing({ title: entry.title }));
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_source_install_failed());
}
};
// This is a secondary control: when the API is down the grid's own QueryState already tells the
// story, so render nothing rather than a second error banner.
if (!scanners.data) return null;
// Catalog rows that are library sources and not already installed — the "Add a source" rail.
const installedPkgs = new Set(
(catalog.data?.plugins ?? [])
.filter((p) => p.installed_version)
.map((p) => p.pkg),
);
const available = (catalog.data?.plugins ?? []).filter(
(p) => p.categories?.includes("library") && !installedPkgs.has(p.pkg),
);
const running = new Set(libraryPlugins(plugins.data).map((p) => p.id));
// The bridge-release nudge (design D9): a built-in scanner still doing the work, with its
// replacement plugin sitting uninstalled in the catalog. One click per scanner, and NEVER a
// silent auto-install — installing code stays an explicit operator act.
const migratable = scanners.data
.filter((s) => s.origin === "builtin" && s.enabled)
.map((s) => ({
source: s,
entry: available.find((p) => p.id === s.id && p.compatible),
}))
.filter((r): r is { source: ScannerInfo; entry: StoreEntry } => !!r.entry);
return (
<>
{migratable.length > 0 && (
<MigrationBanner
rows={migratable}
busy={catalog.data?.busy === true || install.isPending}
onInstall={onInstall}
/>
)}
<SourcesCard
sources={scanners.data}
available={available}
running={running}
busyId={toggle.isPending ? (toggle.variables?.id ?? null) : null}
installBusy={catalog.data?.busy === true || install.isPending}
activeFilter={activeFilter}
onToggle={onToggle}
onFilter={onFilter}
onSettings={setSettingsFor}
onPurge={onPurge}
onInstall={onInstall}
/>
{settingsFor && (
<SourceSettingsDialog
source={settingsFor}
onClose={() => setSettingsFor(null)}
/>
)}
</>
);
};
/**
* "Game sources are moving to plugins" — shown only while a built-in scanner is still doing a job a
* catalogued plugin could take over.
*
* One button per scanner rather than a single "migrate everything": installing a plugin is an
* explicit operator act under the store's consent model, and per-scanner is also what makes it safe
* to repeat — the claim suppresses the built-in idempotently (design D2), so a half-finished
* migration is a valid state rather than a mess.
*/
export const MigrationBanner: FC<{
rows: ReadonlyArray<{ source: ScannerInfo; entry: StoreEntry }>;
busy: boolean;
onInstall: (entry: StoreEntry) => void;
}> = ({ rows, busy, onInstall }) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{m.library_migrate_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="max-w-prose text-sm text-muted-foreground">
{m.library_migrate_help()}
</p>
<div className="flex flex-wrap gap-2">
{rows.map(({ source, entry }) => (
<Button
key={source.id}
size="sm"
variant="outline"
disabled={busy}
onClick={() => onInstall(entry)}
>
<Download className="size-4" />
{m.library_migrate_install({ source: source.label })}
</Button>
))}
</div>
</CardContent>
</Card>
);
/** The sources card itself — presentational, so Storybook can drive every state. */
export const SourcesCard: FC<{
sources: ScannerInfo[];
/** Catalog rows offering a library source that isn't installed yet. */
available: StoreEntry[];
/** Ids of library plugins whose lease is currently live. */
running: Set<string>;
/** Source id whose toggle is in flight, or null — only that row disables. */
busyId: string | null;
installBusy: boolean;
activeFilter: string | null;
onToggle: (source: ScannerInfo) => void;
onFilter: (provider: string | null) => void;
onSettings: (source: ScannerInfo) => void;
onPurge: (source: ScannerInfo) => void;
onInstall: (entry: StoreEntry) => void;
}> = ({
sources,
available,
running,
busyId,
installBusy,
activeFilter,
onToggle,
onFilter,
onSettings,
onPurge,
onInstall,
}) => (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{m.library_sources_title()}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-col gap-2">
{sources.map((source) => (
<SourceRow
key={source.id}
source={source}
running={running.has(source.id)}
busy={busyId === source.id}
filtered={
activeFilter !== null &&
activeFilter === (source.provider ?? source.id)
}
onToggle={() => onToggle(source)}
onFilter={() => {
const p = source.provider ?? source.id;
onFilter(activeFilter === p ? null : p);
}}
onSettings={() => onSettings(source)}
onPurge={() => onPurge(source)}
/>
))}
</div>
<p className="max-w-prose text-xs text-muted-foreground">
{m.library_sources_help()}
</p>
{available.length > 0 && (
<div className="space-y-2 border-t pt-4">
<p className="text-sm font-medium">{m.library_add_source()}</p>
<div className="flex flex-wrap gap-2">
{available.map((entry) => (
<Button
key={entry.pkg}
size="sm"
variant="outline"
disabled={!entry.compatible || installBusy}
title={entry.incompatible_reason ?? entry.description}
onClick={() => onInstall(entry)}
>
<Download className="size-4" />
{entry.title}
{/* `detected` is tri-state: only badge a POSITIVE probe. An entry with
no probes for this platform is "unknown", and labelling that "not
installed" would be a lie. */}
{entry.detected === true && (
<Badge variant="secondary">
{m.library_source_detected()}
</Badge>
)}
</Button>
))}
</div>
</div>
)}
</CardContent>
</Card>
);
/** One source row: enable toggle, provenance, counts, and its per-source actions. */
const SourceRow: FC<{
source: ScannerInfo;
/** The plugin backing this source is currently registered (its lease is live). */
running: boolean;
busy: boolean;
filtered: boolean;
onToggle: () => void;
onFilter: () => void;
onSettings: () => void;
onPurge: () => void;
}> = ({
source,
running,
busy,
filtered,
onToggle,
onFilter,
onSettings,
onPurge,
}) => {
const isPlugin = source.origin === "plugin";
return (
<div className="flex flex-wrap items-center gap-3 rounded-lg border p-3">
<Button
size="sm"
variant={source.enabled ? "default" : "outline"}
aria-pressed={source.enabled}
disabled={busy}
onClick={onToggle}
>
{source.enabled && <Check className="size-4" />}
{source.label}
</Button>
{isPlugin && (
<Badge variant={running ? "secondary" : "outline"}>
{running ? m.library_source_running() : m.library_source_stopped()}
</Badge>
)}
{source.entries != null && (
<Badge variant="secondary">
{m.library_provider_count({ count: source.entries })}
</Badge>
)}
<div className="ml-auto flex gap-2">
{isPlugin && (
<>
<Button
size="sm"
variant={filtered ? "default" : "outline"}
aria-pressed={filtered}
onClick={onFilter}
>
{filtered
? m.library_provider_show_all()
: m.library_provider_filter()}
</Button>
<Button
size="sm"
variant="outline"
aria-label={m.library_source_settings()}
onClick={onSettings}
>
<Settings2 className="size-4" />
</Button>
<Button
size="sm"
variant="outline"
aria-label={m.library_provider_purge()}
onClick={onPurge}
>
<Trash2 className="size-4 text-destructive" />
</Button>
</>
)}
</div>
</div>
);
};
+3 -7
View File
@@ -7,8 +7,7 @@ import { useLocale } from "@/lib/i18n";
import { m } from "@/paraglide/messages";
import { type FormTarget, GameFormSection } from "./GameForm";
import { LibraryGridSection } from "./LibraryGrid";
import { ProvidersCard } from "./Providers";
import { SourceTogglesSection } from "./SourceToggles";
import { SourcesSection } from "./Sources";
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
@@ -44,11 +43,8 @@ export const SectionLibrary: FC = () => {
/>
)}
<SourceTogglesSection />
<ProvidersCard
entries={entries}
active={providerFilter}
<SourcesSection
activeFilter={providerFilter}
onFilter={setProviderFilter}
/>
+165 -9
View File
@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { GameForm } from "@/sections/Library/GameForm";
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
import { SourceToggles } from "@/sections/Library/SourceToggles";
import { MigrationBanner, SourcesCard } from "@/sections/Library/Sources";
import { library } from "./lib/fixtures";
const noop = () => {};
@@ -45,6 +45,31 @@ export const Populated: Story = {
),
};
/** Launcher entries (design D4) get their own rail above the grid. */
export const WithLaunchers: Story = {
render: () => (
<LibraryGrid
library={{
data: [
{
id: "steam:bigpicture",
store: "steam",
title: "Steam Big Picture",
art: { portrait: null, hero: null, logo: null, header: null },
role: "launcher",
launch: { kind: "steam_ui", value: "bigpicture" },
},
...library,
],
...idle,
}}
onEdit={noop}
onDelete={noop}
deletingId={null}
/>
),
};
export const Empty: Story = {
render: () => (
<LibraryGrid
@@ -56,17 +81,148 @@ export const Empty: Story = {
),
};
/** A catalog row for the "Add a source" rail — only the fields the card actually reads. */
const catalogEntry = (
over: Partial<Parameters<typeof SourcesCard>[0]["available"][number]>,
) =>
({
id: "steam",
pkg: "@punktfunk/plugin-steam",
title: "Steam",
description: "Steam library scanner",
author: "unom",
version: "0.1.0",
source: "unom",
tier: "verified",
platforms: [],
compatible: true,
update_available: false,
categories: ["library"],
...over,
}) as Parameters<typeof SourcesCard>[0]["available"][number];
const sourcesArgs = {
available: [],
running: new Set<string>(),
busyId: null,
installBusy: false,
activeFilter: null,
onToggle: noop,
onFilter: noop,
onSettings: noop,
onPurge: noop,
onInstall: noop,
};
/** The bridge-release shape: built-in scanners only, one turned off. */
export const Sources: Story = {
render: () => (
<SourceToggles
// A Linux host's scanner set, one turned off — the widest built-in list.
scanners={[
{ id: "steam", label: "Steam", enabled: true },
{ id: "lutris", label: "Lutris", enabled: false },
{ id: "heroic", label: "Heroic (Epic / GOG / Amazon)", enabled: true },
<SourcesCard
{...sourcesArgs}
sources={[
{ id: "steam", label: "Steam", enabled: true, origin: "builtin" },
{ id: "lutris", label: "Lutris", enabled: false, origin: "builtin" },
{
id: "heroic",
label: "Heroic (Epic / GOG / Amazon)",
enabled: true,
origin: "builtin",
},
]}
busyId={null}
onToggle={noop}
/>
),
};
/** Mid-migration: a claimed plugin source beside the remaining built-ins, one plugin stopped. */
export const SourcesWithPlugins: Story = {
render: () => (
<SourcesCard
{...sourcesArgs}
sources={[
{
id: "steam",
label: "Steam",
enabled: true,
origin: "plugin",
provider: "steam",
entries: 214,
},
{
id: "lutris",
label: "Lutris",
enabled: false,
origin: "plugin",
provider: "lutris",
entries: 12,
},
{
id: "heroic",
label: "Heroic (Epic / GOG / Amazon)",
enabled: true,
origin: "builtin",
},
]}
running={new Set(["steam"])}
available={[
catalogEntry({ pkg: "@punktfunk/plugin-heroic", title: "Heroic" }),
]}
/>
),
};
/** A fresh host after extraction: nothing installed, two launchers detected on this box. */
export const SourcesEmptyWithDetected: Story = {
render: () => (
<SourcesCard
{...sourcesArgs}
sources={[]}
available={[
catalogEntry({ detected: true }),
catalogEntry({
pkg: "@punktfunk/plugin-lutris",
title: "Lutris",
detected: true,
}),
catalogEntry({
pkg: "@punktfunk/plugin-heroic",
title: "Heroic",
detected: false,
}),
]}
/>
),
};
/** The bridge-release nudge — one button per still-built-in scanner, never an auto-install. */
export const Migration: Story = {
render: () => (
<MigrationBanner
rows={[
{
source: {
id: "steam",
label: "Steam",
enabled: true,
origin: "builtin",
},
entry: catalogEntry({}),
},
{
source: {
id: "lutris",
label: "Lutris",
enabled: true,
origin: "builtin",
},
entry: catalogEntry({
pkg: "@punktfunk/plugin-lutris",
id: "lutris",
title: "Lutris",
}),
},
]}
busy={false}
onInstall={noop}
/>
),
};