From c2bba134051f1e5dd570dccd9e4734881595e0c5 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Sun, 19 Jul 2026 19:58:05 +0200 Subject: [PATCH] feat(host/web): per-scanner library toggles in the console MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every installed-store scanner (Steam; Lutris+Heroic on Linux; Epic/GOG/ Xbox on Windows) was hardwired on. New library-scanners.json persists the operator's disabled set (default all on; absent/malformed = all on); all_games() gates each provider, so disabling one hides its titles from every surface (console grid, native clients, GameStream app list, launch resolve). GET /library/scanners lists this platform's scanners + state; PUT /library/scanners/{id} toggles and emits library.changed — admin lane only (the cert allowlist's exact-path /library match keeps both off the LAN surface). The console's Library page grows a "Game sources" card with one chip per scanner (platform-shaped by the API), EN+DE strings, story. The scanners are slated to become plugins; the stable per-scanner ids are the migration seam. Co-Authored-By: Claude Fable 5 --- api/openapi.json | 149 +++++++++++++++++ crates/punktfunk-host/src/library.rs | 33 +++- crates/punktfunk-host/src/library/scanners.rs | 153 ++++++++++++++++++ crates/punktfunk-host/src/mgmt.rs | 2 + crates/punktfunk-host/src/mgmt/library.rs | 66 ++++++++ crates/punktfunk-host/src/mgmt/tests.rs | 46 ++++++ web/messages/de.json | 3 + web/messages/en.json | 3 + web/src/sections/Library/SourceToggles.tsx | 85 ++++++++++ web/src/sections/Library/index.tsx | 3 + web/src/stories/Library.stories.tsx | 16 ++ 11 files changed, 552 insertions(+), 7 deletions(-) create mode 100644 crates/punktfunk-host/src/library/scanners.rs create mode 100644 web/src/sections/Library/SourceToggles.tsx diff --git a/api/openapi.json b/api/openapi.json index 0ba8e587..467d6443 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1348,6 +1348,117 @@ } } }, + "/api/v1/library/scanners": { + "get": { + "tags": [ + "library" + ], + "summary": "List the library scanners", + "description": "The installed-store scanners this host supports — the list is platform-dependent (Steam\neverywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console\nrenders a toggle only for scanners that can do anything here. Scanners default to enabled;\ndisabling one hides its titles from every library surface from the next read. The user-curated\ncustom store is not a scanner and is always on.", + "operationId": "listLibraryScanners", + "responses": { + "200": { + "description": "This host's scanners with their enable state", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScannerInfo" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, + "/api/v1/library/scanners/{id}": { + "put": { + "tags": [ + "library" + ], + "summary": "Enable or disable a library scanner", + "description": "Persists the toggle and applies it from the next library read (no restart). Disabling a scanner\nhides its titles everywhere — the console grid, native clients, and the GameStream app list —\nand re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits\n`library.changed` with the scanner id as `source` when the state changed.", + "operationId": "setLibraryScanner", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "The scanner id (e.g. `steam`)", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ScannerToggle" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Toggle stored; the full scanner list", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ScannerInfo" + } + } + } + } + }, + "401": { + "description": "Missing or invalid bearer token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "404": { + "description": "No such scanner on this platform", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + }, + "500": { + "description": "Could not persist the settings", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiError" + } + } + } + } + } + } + }, "/api/v1/local/summary": { "get": { "tags": [ @@ -4671,6 +4782,44 @@ } } }, + "ScannerInfo": { + "type": "object", + "description": "One installed-store scanner this host build supports, with its enable state — the unit the\nconsole renders a toggle for. The list is platform-gated at compile time (the scanners are),\nso the console never shows a toggle that cannot do anything on this host.", + "required": [ + "id", + "label", + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether this host runs the scanner (default true)." + }, + "id": { + "type": "string", + "description": "Stable scanner id — the same string the scanner's entries carry in their `store` field.", + "example": "steam" + }, + "label": { + "type": "string", + "description": "Human-facing name for the console toggle.", + "example": "Steam" + } + } + }, + "ScannerToggle": { + "type": "object", + "description": "Request body for `setLibraryScanner`.", + "required": [ + "enabled" + ], + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the scanner should run on this host." + } + } + }, "SessionInfo": { "type": "object", "description": "Client-requested launch parameters (key material is never exposed here).", diff --git a/crates/punktfunk-host/src/library.rs b/crates/punktfunk-host/src/library.rs index b9638035..1438139c 100644 --- a/crates/punktfunk-host/src/library.rs +++ b/crates/punktfunk-host/src/library.rs @@ -31,6 +31,7 @@ mod heroic; mod launch; #[cfg(target_os = "linux")] mod lutris; +mod scanners; mod steam; #[cfg(windows)] mod xbox; @@ -46,6 +47,7 @@ pub use heroic::*; pub use launch::*; #[cfg(target_os = "linux")] pub use lutris::*; +pub use scanners::*; pub use steam::*; #[cfg(windows)] pub use xbox::*; @@ -161,22 +163,39 @@ impl ArtKind { } } -/// The full library: every store's titles merged + the custom entries, sorted by title. +/// The full library: every *enabled* store's titles merged + the custom entries, sorted by title. +/// The operator's scanner toggles (`scanners.rs`) gate each installed-store provider; the custom +/// store is not a scanner and always contributes. pub fn all_games() -> Vec { - let mut games = SteamProvider.list(); + let off = disabled_scanners(); + let on = |id: &str| !off.contains(id); + let mut games = Vec::new(); + if on("steam") { + games.extend(SteamProvider.list()); + } // The Lutris + Heroic providers are Linux-only (their launchers are); on other hosts the library // is Steam + custom. Each provider is best-effort (empty when its store isn't present). #[cfg(target_os = "linux")] { - games.extend(LutrisProvider.list()); - games.extend(HeroicProvider.list()); + if on("lutris") { + games.extend(LutrisProvider.list()); + } + if on("heroic") { + games.extend(HeroicProvider.list()); + } } // Windows store providers (their launchers are Windows-only): Epic + GOG + Xbox/Game Pass. #[cfg(windows)] { - games.extend(EpicProvider.list()); - games.extend(GogProvider.list()); - games.extend(XboxProvider.list()); + if on("epic") { + games.extend(EpicProvider.list()); + } + if on("gog") { + games.extend(GogProvider.list()); + } + if on("xbox") { + games.extend(XboxProvider.list()); + } } games.extend(load_custom().into_iter().map(GameEntry::from)); games.sort_by_key(|g| g.title.to_lowercase()); diff --git a/crates/punktfunk-host/src/library/scanners.rs b/crates/punktfunk-host/src/library/scanners.rs new file mode 100644 index 00000000..3279ebe4 --- /dev/null +++ b/crates/punktfunk-host/src/library/scanners.rs @@ -0,0 +1,153 @@ +//! Library-scanner settings: which installed-store scanners run on this host. Every scanner is +//! **on by default** (the shipped behavior before this existed); the operator can turn one off in +//! the web console, which hides its titles from every library surface (console grid, native +//! clients, the GameStream app list, launch resolution) from the next read. Only the *disabled* +//! set is persisted, so a scanner added in a future build starts enabled without a migration. +//! +//! The user-curated **custom** store is not a scanner (nothing is scanned — the operator typed the +//! entries in) and cannot be disabled here; provider plugins (RFC §8) likewise own their entries +//! through the reconcile API. Down the road the scanners themselves are slated to become plugins — +//! the stable per-scanner ids this module fixes (`steam`, `lutris`, …, matching each entry's +//! `store` field) are the forward seam for that migration. + +use super::*; + +/// One installed-store scanner this host build supports, with its enable state — the unit the +/// console renders a toggle for. The list is platform-gated at compile time (the scanners are), +/// so the console never shows a toggle that cannot do anything on this host. +#[derive(Clone, Debug, Serialize, ToSchema)] +pub struct ScannerInfo { + /// Stable scanner id — the same string the scanner's entries carry in their `store` field. + #[schema(example = "steam")] + pub id: String, + /// Human-facing name for the console toggle. + #[schema(example = "Steam")] + pub label: String, + /// Whether this host runs the scanner (default true). + pub enabled: bool, +} + +/// The scanners compiled into THIS host build: (id, label). Steam is cross-platform; the rest are +/// platform-gated exactly like their provider modules in `library.rs` — keep the two in sync when +/// adding a store. +fn scanner_defs() -> Vec<(&'static str, &'static str)> { + let mut defs = vec![("steam", "Steam")]; + #[cfg(target_os = "linux")] + { + defs.push(("lutris", "Lutris")); + defs.push(("heroic", "Heroic (Epic / GOG / Amazon)")); + } + #[cfg(windows)] + { + defs.push(("epic", "Epic Games Launcher")); + defs.push(("gog", "GOG Galaxy")); + defs.push(("xbox", "Xbox / Game Pass")); + } + defs +} + +/// Persisted shape (`library-scanners.json`): only the ids the operator turned OFF. Absent file = +/// nothing disabled = the pre-existing all-scanners-on behavior. +#[derive(Debug, Default, Serialize, Deserialize)] +struct ScannerSettings { + #[serde(default)] + disabled: Vec, +} + +fn settings_path() -> PathBuf { + // Same hardened config dir as library.json / hooks.json (see `custom_path` for the rationale). + pf_paths::config_dir().join("library-scanners.json") +} + +/// Load the settings (default + non-fatal if the file is absent or malformed). +fn load_settings() -> ScannerSettings { + match std::fs::read_to_string(settings_path()) { + Ok(raw) => serde_json::from_str(&raw).unwrap_or_else(|e| { + tracing::warn!(error = %e, "library-scanners.json malformed — all scanners on"); + ScannerSettings::default() + }), + Err(_) => ScannerSettings::default(), + } +} + +fn save_settings(settings: &ScannerSettings) -> Result<()> { + let dir = pf_paths::config_dir(); + pf_paths::create_private_dir(&dir).with_context(|| format!("create {}", dir.display()))?; + let json = serde_json::to_string_pretty(settings)?; + // Write-then-rename like the catalog, so a crash mid-write never truncates the settings. + let tmp = settings_path().with_extension("json.tmp"); + pf_paths::write_secret_file(&tmp, json.as_bytes()) + .with_context(|| format!("write {}", tmp.display()))?; + std::fs::rename(&tmp, settings_path()).context("rename library-scanners.json")?; + Ok(()) +} + +/// The disabled-scanner ids, loaded once per library read ([`all_games`] consults it per store). +pub(crate) fn disabled_scanners() -> HashSet { + load_settings().disabled.into_iter().collect() +} + +/// The scanners available on this platform with their current enable state, in the fixed +/// definition order (stable for the console). +pub fn list_scanners() -> Vec { + let off = disabled_scanners(); + scanner_defs() + .into_iter() + .map(|(id, label)| ScannerInfo { + id: id.to_string(), + label: label.to_string(), + enabled: !off.contains(id), + }) + .collect() +} + +/// Enable/disable one scanner. `None` when `id` names no scanner available on this platform (the +/// mgmt layer maps that to 404 — the console only ever sees this host's own list). Persists and +/// emits `library.changed` (source = the scanner id) only when the state actually changed, so a +/// repeated PUT is a cheap no-op. +pub fn set_scanner_enabled(id: &str, enabled: bool) -> Result>> { + if !scanner_defs().iter().any(|(sid, _)| *sid == id) { + return Ok(None); + } + let mut settings = load_settings(); + let was_disabled = settings.disabled.iter().any(|d| d == id); + if enabled != was_disabled { + return Ok(Some(list_scanners())); + } + if enabled { + settings.disabled.retain(|d| d != id); + } else { + settings.disabled.push(id.to_string()); + settings.disabled.sort(); + settings.disabled.dedup(); + } + save_settings(&settings)?; + crate::events::emit(crate::events::EventKind::LibraryChanged { + source: id.to_string(), + }); + Ok(Some(list_scanners())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn steam_is_always_a_scanner_and_ids_are_unique() { + let defs = scanner_defs(); + assert!(defs.iter().any(|(id, _)| *id == "steam")); + let ids: HashSet<_> = defs.iter().map(|(id, _)| *id).collect(); + assert_eq!(ids.len(), defs.len(), "scanner ids must be unique"); + // `custom` is a store but never a scanner — the toggle surface must not offer it. + assert!(!ids.contains("custom")); + } + + #[test] + fn absent_or_malformed_settings_mean_all_on() { + // The default (absent-file) settings disable nothing — the pre-feature behavior. + let s = ScannerSettings::default(); + assert!(s.disabled.is_empty()); + let parsed: ScannerSettings = serde_json::from_str("{}").unwrap(); + assert!(parsed.disabled.is_empty(), "missing key defaults to empty"); + } +} diff --git a/crates/punktfunk-host/src/mgmt.rs b/crates/punktfunk-host/src/mgmt.rs index 2f4bc371..daba625d 100644 --- a/crates/punktfunk-host/src/mgmt.rs +++ b/crates/punktfunk-host/src/mgmt.rs @@ -203,6 +203,8 @@ fn api_router_parts() -> (Router>, utoipa::openapi::OpenApi) { .routes(routes!(session::stop_session)) .routes(routes!(session::request_idr)) .routes(routes!(library::get_library)) + .routes(routes!(library::list_library_scanners)) + .routes(routes!(library::set_library_scanner)) .routes(routes!(library::create_custom_game)) .routes(routes!( library::update_custom_game, diff --git a/crates/punktfunk-host/src/mgmt/library.rs b/crates/punktfunk-host/src/mgmt/library.rs index 7f679661..b8d7701c 100644 --- a/crates/punktfunk-host/src/mgmt/library.rs +++ b/crates/punktfunk-host/src/mgmt/library.rs @@ -45,6 +45,72 @@ pub(crate) async fn get_library( Json(games) } +/// Request body for `setLibraryScanner`. +#[derive(Deserialize, ToSchema)] +pub(crate) struct ScannerToggle { + /// Whether the scanner should run on this host. + enabled: bool, +} + +/// List the library scanners +/// +/// The installed-store scanners this host supports — the list is platform-dependent (Steam +/// everywhere; Lutris + Heroic on Linux; Epic, GOG, and Xbox/Game Pass on Windows), so the console +/// renders a toggle only for scanners that can do anything here. Scanners default to enabled; +/// disabling one hides its titles from every library surface from the next read. The user-curated +/// custom store is not a scanner and is always on. +#[utoipa::path( + get, + path = "/library/scanners", + tag = "library", + operation_id = "listLibraryScanners", + responses( + (status = OK, description = "This host's scanners with their enable state", body = [crate::library::ScannerInfo]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + ) +)] +pub(crate) async fn list_library_scanners() -> Json> { + Json(crate::library::list_scanners()) +} + +/// Enable or disable a library scanner +/// +/// Persists the toggle and applies it from the next library read (no restart). Disabling a scanner +/// hides its titles everywhere — the console grid, native clients, and the GameStream app list — +/// and re-enabling brings them straight back (nothing is deleted; the scan just runs again). Emits +/// `library.changed` with the scanner id as `source` when the state changed. +#[utoipa::path( + put, + path = "/library/scanners/{id}", + tag = "library", + operation_id = "setLibraryScanner", + params(("id" = String, Path, description = "The scanner id (e.g. `steam`)")), + request_body = ScannerToggle, + responses( + (status = OK, description = "Toggle stored; the full scanner list", body = [crate::library::ScannerInfo]), + (status = UNAUTHORIZED, description = "Missing or invalid bearer token", body = ApiError), + (status = NOT_FOUND, description = "No such scanner on this platform", body = ApiError), + (status = INTERNAL_SERVER_ERROR, description = "Could not persist the settings", body = ApiError), + ) +)] +pub(crate) async fn set_library_scanner( + Path(id): Path, + ApiJson(toggle): ApiJson, +) -> Response { + match crate::library::set_scanner_enabled(&id, toggle.enabled) { + Ok(Some(scanners)) => { + tracing::info!( + scanner = %id, + enabled = toggle.enabled, + "management API: library scanner toggled" + ); + Json(scanners).into_response() + } + Ok(None) => api_error(StatusCode::NOT_FOUND, "no such scanner on this platform"), + Err(e) => api_error(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string()), + } +} + /// Add a custom library entry /// /// Creates a user-curated title (e.g. a non-Steam game, an emulator, a ROM) with caller-supplied diff --git a/crates/punktfunk-host/src/mgmt/tests.rs b/crates/punktfunk-host/src/mgmt/tests.rs index 5a58df16..1ff39da3 100644 --- a/crates/punktfunk-host/src/mgmt/tests.rs +++ b/crates/punktfunk-host/src/mgmt/tests.rs @@ -138,6 +138,13 @@ async fn cert_auth_is_a_read_only_allowlist() { "the client roster {p} must require the bearer token, not just a paired cert" ); } + // The scanner settings are admin-only in BOTH directions: the exact-path `/api/v1/library` + // cert match must not leak the settings GET, and the toggle PUT is operator configuration. + assert_eq!( + send_cert(&app, get_req("/api/v1/library/scanners"), fp).await, + StatusCode::UNAUTHORIZED, + "the scanner settings must require the bearer token, not just a paired cert" + ); // The plugin directory is admin-only — a paired streaming cert has no business enumerating the // host's running plugins or reaching a plugin UI's proxy credential (plugin-ui-surface §3). for p in [ @@ -1309,6 +1316,45 @@ async fn hooks_get_shape_and_put_validation() { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } +// ------------------------------------------------------------------ library scanners + +/// The scanner list is platform-shaped and read-only-safe; the toggle rejects unknown ids with +/// 404. (A successful toggle PUT would write the developer's real `library-scanners.json`, so the +/// write path is exercised only through the unknown-id rejection here — the settings round-trip +/// itself is unit-tested in `library::scanners` against pure shapes.) +#[tokio::test] +async fn library_scanner_list_and_unknown_toggle() { + let app = test_app(test_state(), None); + + let (s, json) = send(&app, get_req("/api/v1/library/scanners")).await; + assert_eq!(s, StatusCode::OK); + let scanners = json.as_array().expect("a scanner array"); + assert!( + scanners + .iter() + .any(|sc| sc["id"] == "steam" && sc["label"].is_string() && sc["enabled"].is_boolean()), + "steam must be a scanner on every platform: {json}" + ); + // Only platform-available scanners appear (`custom` is a store, never a scanner). + assert!(scanners.iter().all(|sc| sc["id"] != "custom")); + + let (s, json) = send( + &app, + axum::http::Request::put("/api/v1/library/scanners/not-a-store") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({"enabled": false}).to_string(), + )) + .unwrap(), + ) + .await; + assert_eq!( + s, + StatusCode::NOT_FOUND, + "unknown scanner id must 404: {json}" + ); +} + // ------------------------------------------------------------------ library providers /// Provider reconcile validation (the write path itself is unit-tested in `library::custom` diff --git a/web/messages/de.json b/web/messages/de.json index 42413959..1eb25958 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -170,6 +170,9 @@ "pairing_moonlight_title": "Moonlight-Kopplung (GameStream)", "library_title": "Bibliothek", "library_empty": "Noch keine Spiele gefunden.", + "library_sources_title": "Spielequellen", + "library_sources_help": "Launcher, die dieser Host nach installierten Spielen durchsucht. Eine Quelle abschalten blendet ihre Spiele auf allen Geräten aus — nichts wird gelöscht, und beim Wiedereinschalten sind sie sofort zurück.", + "library_sources_failed": "Die Spielequelle konnte nicht aktualisiert werden.", "library_store_steam": "Steam", "library_store_custom": "Eigene", "library_add_title": "Eigenes Spiel hinzufügen", diff --git a/web/messages/en.json b/web/messages/en.json index bbfe68cc..01d3ff16 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -170,6 +170,9 @@ "pairing_moonlight_title": "Moonlight (GameStream) pairing", "library_title": "Library", "library_empty": "No games found yet.", + "library_sources_title": "Game sources", + "library_sources_help": "Launchers this host scans for installed games. Turn one off to hide its games from every device — nothing is deleted, and turning it back on brings them right back.", + "library_sources_failed": "Could not update the game source.", "library_store_steam": "Steam", "library_store_custom": "Custom", "library_add_title": "Add a custom game", diff --git a/web/src/sections/Library/SourceToggles.tsx b/web/src/sections/Library/SourceToggles.tsx new file mode 100644 index 00000000..fd492b01 --- /dev/null +++ b/web/src/sections/Library/SourceToggles.tsx @@ -0,0 +1,85 @@ +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 ( + + ); +}; + +/** 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 }) => ( + + + {m.library_sources_title()} + + +
+ {scanners.map((scanner) => ( + + ))} +
+

+ {m.library_sources_help()} +

+
+
+); diff --git a/web/src/sections/Library/index.tsx b/web/src/sections/Library/index.tsx index 4c5428dc..e8c979f2 100644 --- a/web/src/sections/Library/index.tsx +++ b/web/src/sections/Library/index.tsx @@ -6,6 +6,7 @@ import { useLocale } from "@/lib/i18n"; import { m } from "@/paraglide/messages"; import { type FormTarget, GameFormSection } from "./GameForm"; import { LibraryGridSection } from "./LibraryGrid"; +import { SourceTogglesSection } from "./SourceToggles"; // 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 @@ -37,6 +38,8 @@ export const SectionLibrary: FC = () => { /> )} + + setTarget(entry)} /> diff --git a/web/src/stories/Library.stories.tsx b/web/src/stories/Library.stories.tsx index 391ff75e..418670ed 100644 --- a/web/src/stories/Library.stories.tsx +++ b/web/src/stories/Library.stories.tsx @@ -1,6 +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 { library } from "./lib/fixtures"; const noop = () => {}; @@ -46,6 +47,21 @@ export const Empty: Story = { ), }; +export const Sources: Story = { + render: () => ( + + ), +}; + export const AddForm: Story = { render: () => (