feat(host,console): hide individual library titles
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
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.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { Eye, EyeOff, Pencil, Trash2 } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
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";
|
||||
@@ -23,23 +23,33 @@ function storeLabel(store: string): string {
|
||||
}
|
||||
|
||||
export interface GameCardProps {
|
||||
game: GameEntry;
|
||||
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.
|
||||
* 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
|
||||
@@ -57,16 +67,23 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
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"
|
||||
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">
|
||||
<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>
|
||||
)}
|
||||
@@ -91,30 +108,67 @@ export const GameCard: FC<GameCardProps> = ({
|
||||
{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>
|
||||
{isCustom && (
|
||||
<div className="absolute right-2 top-2 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
|
||||
<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"
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
getGetLibraryQueryKey,
|
||||
useDeleteCustomGame,
|
||||
useGetLibrary,
|
||||
useSetLibraryEntryHidden,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import type { OperatorGameEntry } from "@/api/gen/model/operatorGameEntry";
|
||||
import { useDialogs } from "@/components/dialogs";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Stagger } from "@/components/stagger";
|
||||
@@ -23,11 +24,11 @@ import { customId } from "./helpers";
|
||||
* this subsection knows nothing about the form beyond firing `onEdit`.
|
||||
*/
|
||||
export const LibraryGridSection: FC<{
|
||||
onEdit: (entry: GameEntry) => void;
|
||||
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: GameEntry[]) => void;
|
||||
onEntries?: (entries: OperatorGameEntry[]) => void;
|
||||
}> = ({ onEdit, providerFilter, onEntries }) => {
|
||||
const qc = useQueryClient();
|
||||
const { confirm } = useDialogs();
|
||||
@@ -54,7 +55,7 @@ export const LibraryGridSection: FC<{
|
||||
// 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: GameEntry) => {
|
||||
const onDelete = async (entry: OperatorGameEntry) => {
|
||||
const ok = await confirm({
|
||||
title: m.library_delete_confirm(),
|
||||
description: m.library_delete_body(),
|
||||
@@ -71,6 +72,23 @@ export const LibraryGridSection: FC<{
|
||||
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}
|
||||
@@ -78,31 +96,39 @@ export const LibraryGridSection: FC<{
|
||||
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<GameEntry[]>;
|
||||
onEdit: (entry: GameEntry) => void;
|
||||
onDelete: (entry: GameEntry) => void;
|
||||
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;
|
||||
}> = ({ library, onEdit, onDelete, deletingId }) => {
|
||||
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: GameEntry) => (
|
||||
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 (
|
||||
|
||||
@@ -45,6 +45,8 @@ export const Populated: Story = {
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
deletingId={null}
|
||||
onToggleHidden={noop}
|
||||
hidingId={null}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -70,6 +72,29 @@ export const WithLaunchers: Story = {
|
||||
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}
|
||||
/>
|
||||
),
|
||||
};
|
||||
@@ -81,6 +106,8 @@ export const Empty: Story = {
|
||||
onEdit={noop}
|
||||
onDelete={noop}
|
||||
deletingId={null}
|
||||
onToggleHidden={noop}
|
||||
hidingId={null}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user