forked from unom/punktfunk
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:
+18
-2
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user