Files
punktfunk/web/src/sections/Library/GameCard.tsx
T
enricobuehler 6cffe29b13 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

187 lines
6.4 KiB
TypeScript

import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
import { type FC, useState } from "react";
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { m } from "@/paraglide/messages";
/**
* Display label for a store badge. Steam and custom keep their localized strings; every other store
* (lutris, heroic, epic, …) is a proper noun shown capitalized, so new providers surface correctly
* without a translation per store.
*/
function storeLabel(store: string): string {
switch (store) {
case "custom":
return m.library_store_custom();
case "steam":
return m.library_store_steam();
default:
return store.charAt(0).toUpperCase() + store.slice(1);
}
}
export interface GameCardProps {
game: OperatorGameEntry;
onEdit: () => void;
onDelete: () => void;
deleting: boolean;
/** Hide this title from every play surface, or bring it back. */
onToggleHidden: () => void;
/** This card's hide/un-hide is in flight — only this one disables. */
hiding: boolean;
}
/**
* A poster tile. The cover prefers the 2:3 portrait capsule; on a load error it
* falls back to the wide header, then to a text placeholder. Custom entries get
* edit/delete affordances; every entry can be hidden.
*/
export const GameCard: FC<GameCardProps> = ({
game,
onEdit,
onDelete,
deleting,
onToggleHidden,
hiding,
}) => {
// Hiding is available for EVERY store, unlike edit/delete: the titles most worth hiding are the
// ones the operator cannot edit — a launcher's own scanned entries, a Proton tool, a demo. The
// host keys the setting by the entry id and never needs to own the entry.
const hidden = game.hidden === true;
// Editable only if the operator actually owns this entry. A custom-store entry SYNCED by a
// provider plugin also has `store === "custom"`, but the host refuses to hand-edit or delete it
// (409 CONFLICT, "owned by provider … — update it through its reconcile"), so offering the
// buttons produced a failure the card never surfaced. Provider-owned entries are attributed
// instead.
const isCustom = game.store === "custom" && !game.provider;
// Track which sources have failed so the <img> can step down portrait → header → placeholder.
const [failed, setFailed] = useState<Record<string, boolean>>({});
const candidates = [game.art.portrait, game.art.header].filter(
(u): u is string => !!u && !failed[u],
);
const src = candidates[0];
return (
<Card className="group relative overflow-hidden">
<div className="relative aspect-[2/3] bg-muted">
{/* Dim the ARTWORK only — never the badges or the buttons layered over it. A hidden
card is the sole place the title can be brought back, so its controls have to stay
at full contrast while the poster reads as "not in play". */}
{src ? (
<img
src={src}
alt={game.title}
loading="lazy"
className={`size-full object-cover${hidden ? " opacity-30" : ""}`}
onError={() => setFailed((prev) => ({ ...prev, [src]: true }))}
/>
) : (
<div
className={`flex size-full items-center justify-center p-3 text-center text-sm font-medium text-muted-foreground${
hidden ? " opacity-30" : ""
}`}
>
{game.title}
</div>
)}
<div className="absolute left-2 top-2 flex flex-wrap gap-1">
<Badge
variant={isCustom ? "secondary" : "outline"}
className="bg-background/80 backdrop-blur"
>
{storeLabel(game.store)}
</Badge>
{/* Platform badge — "PC" is implied by every installed store, so only
non-PC platforms (the emulation case) earn a second badge. */}
{game.platform && game.platform.toUpperCase() !== "PC" && (
<Badge variant="outline" className="bg-background/80 backdrop-blur">
{game.platform}
</Badge>
)}
{/* Who owns this entry, when it isn't the operator — the reason the edit/delete
buttons are absent here and present on the card next to it. */}
{game.provider && (
<Badge variant="outline" className="bg-background/80 backdrop-blur">
{m.library_owned_by({ provider: game.provider })}
</Badge>
)}
{/* Says WHY this poster is faded. Without it a dimmed tile reads as a broken cover
or a still-loading image rather than a deliberate setting. */}
{hidden && (
<Badge
variant="secondary"
className="bg-background/90 backdrop-blur"
>
{m.library_hidden_badge()}
</Badge>
)}
</div>
{/* A hidden card keeps its controls VISIBLE rather than hover-revealed. Hover-to-reveal
is fine for an ordinary tile, but the un-hide button is the only way out of the
hidden state — requiring a hover to discover it would strand anyone on a touch
screen, which is exactly where the console's pointer work landed. */}
<div
className={`absolute right-2 top-2 flex gap-1 transition-opacity focus-within:opacity-100 group-hover:opacity-100${
hidden ? "" : " opacity-0"
}`}
>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={
hidden ? m.library_unhide_action() : m.library_hide_action()
}
aria-pressed={hidden}
disabled={hiding}
onClick={onToggleHidden}
>
{hidden ? (
<Eye className="size-3.5" />
) : (
<EyeOff className="size-3.5" />
)}
</Button>
{isCustom && (
<>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_edit()}
onClick={onEdit}
>
<Pencil className="size-3.5" />
</Button>
<Button
variant="secondary"
size="icon"
className="size-7 bg-background/80 backdrop-blur"
aria-label={m.library_delete()}
disabled={deleting}
onClick={onDelete}
>
<Trash2 className="size-3.5 text-destructive" />
</Button>
</>
)}
</div>
</div>
<div
className="truncate px-card pb-card pt-4 text-sm font-medium"
title={game.title}
>
{game.title}
{game.release_year != null && (
<span className="ml-1.5 font-normal text-muted-foreground">
{game.release_year}
</span>
)}
</div>
</Card>
);
};