Files
enricobuehler 203ad8069d fix(web): library badge shows the actual store, not always "Steam"
The GameCard badge hard-coded steam-vs-custom, so any non-Steam non-custom store
rendered with the "Steam" label. Add storeLabel(store): steam/custom keep their
localized strings, every other store is shown as a capitalized proper noun — so the
new Lutris/Heroic providers (and future ones) surface correctly with no per-store
translation. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 07:22:28 +00:00

328 lines
9.2 KiB
TypeScript

import { Pencil, Plus, Trash2, X } from "lucide-react";
import { type FC, useState } from "react";
import type { CustomInput } from "@/api/gen/model/customInput";
import type { GameEntry } from "@/api/gen/model/gameEntry";
import { QueryState } from "@/components/query-state";
import { Section } from "@/components/section";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { Loadable } from "@/lib/query";
import { m } from "@/paraglide/messages";
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
function customId(entry: GameEntry): string {
return entry.id.startsWith("custom:")
? entry.id.slice("custom:".length)
: entry.id;
}
/**
* 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);
}
}
interface FormState {
title: string;
portrait: string;
hero: string;
header: string;
command: string;
}
const emptyForm: FormState = {
title: "",
portrait: "",
hero: "",
header: "",
command: "",
};
function formFrom(entry: GameEntry): FormState {
return {
title: entry.title,
portrait: entry.art.portrait ?? "",
hero: entry.art.hero ?? "",
header: entry.art.header ?? "",
command: entry.launch?.kind === "command" ? entry.launch.value : "",
};
}
/** Map the form to the API body — only attach `launch` when a command was given. */
function toInput(f: FormState): CustomInput {
const trim = (s: string) => {
const t = s.trim();
return t ? t : undefined;
};
const command = f.command.trim();
return {
title: f.title.trim(),
art: {
portrait: trim(f.portrait),
hero: trim(f.hero),
header: trim(f.header),
},
launch: command ? { kind: "command", value: command } : null,
};
}
export const LibraryView: FC<{
library: Loadable<GameEntry[]>;
onCreate: (data: CustomInput) => Promise<unknown>;
onUpdate: (id: string, data: CustomInput) => Promise<unknown>;
onDelete: (id: string) => Promise<unknown>;
isSaving: boolean;
isDeleting: boolean;
}> = ({ library, onCreate, onUpdate, onDelete, isSaving, isDeleting }) => {
// null = form hidden; "" = adding a new entry; an id = editing that custom entry.
const [editing, setEditing] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const games = library.data ?? [];
const openAdd = () => {
setForm(emptyForm);
setEditing("");
};
const openEdit = (entry: GameEntry) => {
setForm(formFrom(entry));
setEditing(customId(entry));
};
const closeForm = () => setEditing(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const data = toInput(form);
if (!data.title) return;
await (editing ? onUpdate(editing, data) : onCreate(data));
closeForm();
};
const handleDelete = async (entry: GameEntry) => {
if (!confirm(m.library_delete_confirm())) return;
await onDelete(customId(entry));
};
return (
<Section>
<div className="flex items-center justify-between gap-4">
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
{editing === null && (
<Button onClick={openAdd}>
<Plus className="size-4" />
{m.library_add_button()}
</Button>
)}
</div>
{editing !== null && (
<Card className="max-w-xl">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle>
{editing ? m.library_edit_title() : m.library_add_title()}
</CardTitle>
<Button
variant="ghost"
size="icon"
aria-label={m.library_cancel()}
onClick={closeForm}
>
<X className="size-4" />
</Button>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="lib-title">{m.library_field_title()}</Label>
<Input
id="lib-title"
required
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-portrait">
{m.library_field_portrait()}
</Label>
<Input
id="lib-portrait"
type="url"
inputMode="url"
value={form.portrait}
onChange={(e) =>
setForm((f) => ({ ...f, portrait: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-hero">{m.library_field_hero()}</Label>
<Input
id="lib-hero"
type="url"
inputMode="url"
value={form.hero}
onChange={(e) =>
setForm((f) => ({ ...f, hero: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-header">{m.library_field_header()}</Label>
<Input
id="lib-header"
type="url"
inputMode="url"
value={form.header}
onChange={(e) =>
setForm((f) => ({ ...f, header: e.target.value }))
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="lib-command">{m.library_field_command()}</Label>
<Input
id="lib-command"
value={form.command}
onChange={(e) =>
setForm((f) => ({ ...f, command: e.target.value }))
}
/>
<p className="text-xs text-muted-foreground">
{m.library_field_command_help()}
</p>
</div>
<div className="flex gap-2">
<Button type="submit" disabled={isSaving || !form.title.trim()}>
{editing ? m.library_save() : m.library_create()}
</Button>
<Button type="button" variant="outline" onClick={closeForm}>
{m.library_cancel()}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
<QueryState
isLoading={library.isLoading}
error={library.error}
refetch={library.refetch}
>
{games.length === 0 ? (
<Card>
<CardContent className="p-8 text-center text-sm text-muted-foreground">
{m.library_empty()}
</CardContent>
</Card>
) : (
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5">
{games.map((game) => (
<GameCard
key={game.id}
game={game}
onEdit={() => openEdit(game)}
onDelete={() => handleDelete(game)}
deleting={isDeleting}
/>
))}
</div>
)}
</QueryState>
</Section>
);
};
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.
*/
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 p-2 text-sm font-medium" title={game.title}>
{game.title}
</div>
</Card>
);
};