203ad8069d
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>
328 lines
9.2 KiB
TypeScript
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>
|
|
);
|
|
};
|