feat(host/web): per-scanner library toggles in the console
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Failing after 24s
ci / bench (push) Successful in 5m31s
deb / build-publish (push) Successful in 9m21s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
android / android (push) Successful in 19m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-host (push) Successful in 9m44s
arch / build-publish (push) Successful in 18m17s
ci / rust (push) Successful in 19m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
windows-host / package (push) Successful in 17m47s
docker / deploy-docs (push) Successful in 10s
apple / swift (push) Successful in 1m16s
apple / screenshots (push) Successful in 6m29s
ci / web (push) Successful in 1m1s
ci / docs-site (push) Failing after 24s
ci / bench (push) Successful in 5m31s
deb / build-publish (push) Successful in 9m21s
decky / build-publish (push) Successful in 24s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 13s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 43s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 9s
android / android (push) Successful in 19m41s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
deb / build-publish-host (push) Successful in 9m44s
arch / build-publish (push) Successful in 18m17s
ci / rust (push) Successful in 19m0s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9m14s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 25m20s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 25m10s
windows-host / package (push) Successful in 17m47s
docker / deploy-docs (push) Successful in 10s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
<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>
|
||||
);
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
<SourceTogglesSection />
|
||||
|
||||
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
@@ -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: () => (
|
||||
<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 },
|
||||
]}
|
||||
busyId={null}
|
||||
onToggle={noop}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
export const AddForm: Story = {
|
||||
render: () => (
|
||||
<GameForm
|
||||
|
||||
Reference in New Issue
Block a user