ci / web (pull_request) Successful in 1m13s
ci / docs-site (pull_request) Successful in 1m23s
apple / swift (pull_request) Successful in 1m40s
ci / bun-nix (pull_request) Successful in 21s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 2m28s
android / android (pull_request) Successful in 4m25s
ci / rust (pull_request) Successful in 6m26s
nix / flake (pull_request) Successful in 15m40s
The library had one visibility control and it was all-or-nothing: turn a SOURCE off
and every one of its games goes. There was no way to drop a single title — a Proton
tool the filter missed, a demo, a game someone doesn't want on the TV — short of
hiding the whole launcher it came from.
**Where the setting lives.** Not on the entry. Only manual custom entries are stored;
a scanner's and a plugin's titles are rebuilt from scratch on every scan and every
reconcile, so a flag written onto one would be erased by the next sync — silently, and
minutes later, which is the worst possible shape for a setting. So `library-hidden.json`
holds the ids, mirroring how `library-scanners.json` holds disabled sources. The id is
stable by construction (D2: a claimed store's entries keep `<store>:<external_id>`
across reconciles), so a hide survives a re-scan, a plugin restart, and a store's
built-in→plugin migration.
**Where it takes effect.** In `all_games`, which is the one place every play surface
already funnels through — the grid on a client, native clients, the GameStream app
list, and launch resolution. Putting it there rather than at each call site is
deliberate: a per-surface filter is a rule someone has to remember, and forgetting one
is precisely the class of bug the `file://` art asymmetry in the previous commit was.
Hiding is curation, not access control — nothing is deleted, and un-hiding is instant.
**The console is the one surface that still sees them**, or a hidden title could never
be brought back. That exception is a TYPE, not a flag: `GET /library` answers
`Vec<GameEntry>` on every lane but the operator's and `Vec<OperatorGameEntry>` on
theirs, so a hidden entry cannot reach a paired streaming client by someone forgetting
a filter — there is no field there to leak. `hidden` is skipped when false, so the
response is byte-identical to today's for a library with nothing hidden.
`PUT /library/hidden/{id}` is operator-only — neither the plugin lane nor a paired cert,
unlike the scanner toggle. A plugin has no business deciding what its operator sees, and
a client must not be able to hide a game on the host it is streaming from. The id is not
validated against the current library on purpose: a title can be legitimately absent at
that moment (launcher closed, plugin mid-sync, drive unmounted), and refusing the
operator's choice in that window is worse than storing an id that matches nothing today.
On the card, the poster dims and a Hidden badge says why — a faded tile with no label
reads as a broken cover. Its controls stay at full contrast and, unlike an ordinary
card's, are not hover-revealed: the un-hide button is the only way out of the state, and
hiding it behind a hover would strand anyone on a touch screen.
Verified on .21 (Linux): 469 host tests pass (5 new), clippy clean under `-D warnings`,
`cargo fmt --all --check` clean. The routing test is the one that earns its keep — every
library id contains a colon and Heroic's contain two, so a router that split on it would
404 the console against ids the host itself produced. Console: tsc clean, production
build clean, i18n 633 messages across en+de, biome clean on the touched files.
272 lines
5.8 KiB
TypeScript
272 lines
5.8 KiB
TypeScript
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
import { GameForm } from "@/sections/Library/GameForm";
|
|
import { LibraryGrid } from "@/sections/Library/LibraryGrid";
|
|
import { MigrationBanner, SourcesCard } from "@/sections/Library/Sources";
|
|
import { library } from "./lib/fixtures";
|
|
|
|
const noop = () => {};
|
|
const idle = { isLoading: false, error: null, refetch: noop };
|
|
const emptyForm = {
|
|
title: "",
|
|
portrait: "",
|
|
hero: "",
|
|
header: "",
|
|
logo: "",
|
|
command: "",
|
|
// The console-password confirmation the form requires alongside a launch command; empty here
|
|
// because the story renders the untouched add form, which has no command yet.
|
|
password: "",
|
|
isLauncher: false,
|
|
platform: "",
|
|
description: "",
|
|
developer: "",
|
|
publisher: "",
|
|
releaseYear: "",
|
|
genres: "",
|
|
tags: "",
|
|
region: "",
|
|
players: "",
|
|
};
|
|
|
|
// The overview grid and the add/edit form are separate components now, so the stories
|
|
// render each on its own (no combined page view).
|
|
const meta = {
|
|
title: "Pages/Library",
|
|
parameters: { layout: "padded" },
|
|
} satisfies Meta;
|
|
|
|
export default meta;
|
|
type Story = StoryObj;
|
|
|
|
export const Populated: Story = {
|
|
render: () => (
|
|
<LibraryGrid
|
|
library={{ data: library, ...idle }}
|
|
onEdit={noop}
|
|
onDelete={noop}
|
|
deletingId={null}
|
|
onToggleHidden={noop}
|
|
hidingId={null}
|
|
/>
|
|
),
|
|
};
|
|
|
|
/** 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}
|
|
onToggleHidden={noop}
|
|
hidingId={null}
|
|
/>
|
|
),
|
|
};
|
|
|
|
/**
|
|
* A hidden title, as only the operator's console ever sees it — every other surface has it filtered
|
|
* out upstream. The poster dims but the badge and the un-hide button stay at full contrast, because
|
|
* this card is the only route back.
|
|
*/
|
|
export const WithHidden: Story = {
|
|
render: () => (
|
|
<LibraryGrid
|
|
library={{
|
|
data: library.map((g, i) => (i === 1 ? { ...g, hidden: true } : g)),
|
|
...idle,
|
|
}}
|
|
onEdit={noop}
|
|
onDelete={noop}
|
|
deletingId={null}
|
|
onToggleHidden={noop}
|
|
hidingId={null}
|
|
/>
|
|
),
|
|
};
|
|
|
|
export const Empty: Story = {
|
|
render: () => (
|
|
<LibraryGrid
|
|
library={{ data: [], ...idle }}
|
|
onEdit={noop}
|
|
onDelete={noop}
|
|
deletingId={null}
|
|
onToggleHidden={noop}
|
|
hidingId={null}
|
|
/>
|
|
),
|
|
};
|
|
|
|
/** 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: () => (
|
|
<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",
|
|
},
|
|
]}
|
|
/>
|
|
),
|
|
};
|
|
|
|
/** 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}
|
|
/>
|
|
),
|
|
};
|
|
|
|
export const AddForm: Story = {
|
|
render: () => (
|
|
<GameForm
|
|
initial={emptyForm}
|
|
mode="add"
|
|
onSubmit={noop}
|
|
onCancel={noop}
|
|
isSaving={false}
|
|
/>
|
|
),
|
|
};
|