Web console - Pairing/Library/Stats refactored into self-contained subsections that each own their own queries + mutations; a shared slot-based layout (view.tsx) is filled by the live page (containers) and Storybook (pure cards + fixtures) so the layout can't drift. - All paired devices in one list on Pairing with a protocol column (punktfunk/1 + Moonlight), routing each unpair to the right endpoint; the redundant Clients page is removed. - Library: overview grid split from the add/edit form into separate files. - Login screen links out to the docs. Docs - "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows) plus a new "Forgot your Password?" troubleshooting page, linked from the login screen. - Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the Bun entry, launchers, systemd units, and packaging. Tooling - Biome now respects .gitignore (stops linting generated code), config migrated to 2.5.1; all lint issues fixed cleanly. Also includes this branch's in-progress host, Apple client, packaging, and CI changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
109 lines
3.1 KiB
TypeScript
109 lines
3.1 KiB
TypeScript
import { Pencil, Trash2 } from "lucide-react";
|
|
import { type FC, useState } from "react";
|
|
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
|
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: GameEntry;
|
|
onEdit: () => void;
|
|
onDelete: () => void;
|
|
deleting: 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.
|
|
*/
|
|
export const GameCard: FC<GameCardProps> = ({
|
|
game,
|
|
onEdit,
|
|
onDelete,
|
|
deleting,
|
|
}) => {
|
|
const isCustom = game.store === "custom";
|
|
// 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">
|
|
{src ? (
|
|
<img
|
|
src={src}
|
|
alt={game.title}
|
|
loading="lazy"
|
|
className="size-full object-cover"
|
|
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">
|
|
{game.title}
|
|
</div>
|
|
)}
|
|
<div className="absolute left-2 top-2">
|
|
<Badge
|
|
variant={isCustom ? "secondary" : "outline"}
|
|
className="bg-background/80 backdrop-blur"
|
|
>
|
|
{storeLabel(game.store)}
|
|
</Badge>
|
|
</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"
|
|
title={game.title}
|
|
>
|
|
{game.title}
|
|
</div>
|
|
</Card>
|
|
);
|
|
};
|