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 = ({ 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 can step down portrait → header → placeholder. const [failed, setFailed] = useState>({}); const candidates = [game.art.portrait, game.art.header].filter( (u): u is string => !!u && !failed[u], ); const src = candidates[0]; return (
{/* 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 ? ( {game.title} setFailed((prev) => ({ ...prev, [src]: true }))} /> ) : ( )}
{storeLabel(game.store)} {/* 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" && ( {game.platform} )} {/* 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 && ( {m.library_owned_by({ provider: game.provider })} )} {/* 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 && ( {m.library_hidden_badge()} )}
{/* 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. */}
{game.title} {game.release_year != null && ( {game.release_year} )}
); };