Files
punktfunk/web/src/sections/Library/LibraryGrid.tsx
T
enricobuehler 6cffe29b13
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
feat(host,console): hide individual library titles
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.
2026-08-08 12:33:54 +02:00

178 lines
6.5 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import { toast } from "@unom/ui/toast";
import { type FC, useEffect, useMemo } from "react";
import {
getGetLibraryQueryKey,
useDeleteCustomGame,
useGetLibrary,
useSetLibraryEntryHidden,
} from "@/api/gen/library/library";
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
import { useDialogs } from "@/components/dialogs";
import { QueryState } from "@/components/query-state";
import { Stagger } from "@/components/stagger";
import { Card, CardContent } from "@/components/ui/card";
import { apiErrorMessage } from "@/lib/errors";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
import { GameCard } from "./GameCard";
import { customId } from "./helpers";
/**
* Container: the library OVERVIEW — owns the listing query and per-card delete.
* Editing is escalated to the parent (it opens the separate add/edit form), so
* this subsection knows nothing about the form beyond firing `onEdit`.
*/
export const LibraryGridSection: FC<{
onEdit: (entry: OperatorGameEntry) => void;
/** Show only entries owned by this provider, or everything when null. */
providerFilter?: string | null;
/** Reports the full (unfiltered) list up, so the providers card can count owners. */
onEntries?: (entries: OperatorGameEntry[]) => void;
}> = ({ onEdit, providerFilter, onEntries }) => {
const qc = useQueryClient();
const { confirm } = useDialogs();
const library = useGetLibrary();
const all = library.data;
useEffect(() => {
if (all) onEntries?.(all);
}, [all, onEntries]);
// Filtering CLIENT-side: `GET /library?provider=` exists, but the page already holds the whole
// list for the grid, and a second parameterised query would just be a second cache entry of the
// same data going stale independently.
const filtered = useMemo(
() =>
providerFilter
? {
...library,
data: all?.filter((e) => e.provider === providerFilter),
}
: library,
[library, all, providerFilter],
);
const remove = useDeleteCustomGame();
// A refused delete has to say so. The host has real reasons to say no (a provider-owned entry
// answers 409 with what to do instead), and an un-caught `mutateAsync` rejection reported none
// of them — the card just stayed put as if nothing had been clicked.
const onDelete = async (entry: OperatorGameEntry) => {
const ok = await confirm({
title: m.library_delete_confirm(),
description: m.library_delete_body(),
confirmLabel: m.library_delete(),
destructive: true,
});
if (!ok) return;
try {
await remove.mutateAsync({ id: customId(entry) });
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_delete_failed());
return;
}
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
const setHidden = useSetLibraryEntryHidden();
// Same error discipline as delete: the host can refuse (it cannot persist the settings file),
// and swallowing that would leave the card looking unchanged with no explanation.
const onToggleHidden = async (entry: OperatorGameEntry) => {
try {
await setHidden.mutateAsync({
id: entry.id,
data: { hidden: entry.hidden !== true },
});
} catch (e) {
toast.error(apiErrorMessage(e) ?? m.library_hide_failed());
return;
}
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
};
return (
<LibraryGrid
library={filtered}
onEdit={onEdit}
onDelete={onDelete}
// The custom id whose delete is in flight (if any), so only that card's button disables.
deletingId={remove.isPending ? (remove.variables?.id ?? null) : null}
onToggleHidden={onToggleHidden}
// Keyed by ENTRY id, not custom id — hiding addresses any store's entry, not just ours.
hidingId={setHidden.isPending ? (setHidden.variables?.id ?? null) : null}
/>
);
};
/** The poster grid (with empty + loading/error states). */
export const LibraryGrid: FC<{
library: Loadable<OperatorGameEntry[]>;
onEdit: (entry: OperatorGameEntry) => void;
onDelete: (entry: OperatorGameEntry) => void;
/** Custom id of the card whose delete is in flight, or null — only that card disables. */
deletingId: string | null;
onToggleHidden: (entry: OperatorGameEntry) => void;
/** Entry id of the card whose hide/un-hide is in flight, or null. */
hidingId: string | null;
}> = ({ library, onEdit, onDelete, deletingId, onToggleHidden, hidingId }) => {
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: OperatorGameEntry) => (
<GameCard
key={game.id}
game={game}
onEdit={() => onEdit(game)}
onDelete={() => onDelete(game)}
deleting={deletingId === customId(game)}
onToggleHidden={() => onToggleHidden(game)}
hiding={hidingId === game.id}
/>
);
return (
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{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>
<Stagger 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)}
</Stagger>
</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
inset at ≥640px — see the CardContent doc comment. */}
<CardContent
flush
className="p-8 text-center text-sm text-muted-foreground"
>
{/* 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>
) : (
games.length > 0 && (
<div className="@container">
<Stagger 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)}
</Stagger>
</div>
)
)}
</QueryState>
);
};