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; onCreate: (data: CustomInput) => Promise; onUpdate: (id: string, data: CustomInput) => Promise; onDelete: (id: string) => Promise; 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(null); const [form, setForm] = useState(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 (

{m.library_title()}

{editing === null && ( )}
{editing !== null && ( {editing ? m.library_edit_title() : m.library_add_title()}
setForm((f) => ({ ...f, title: e.target.value })) } />
setForm((f) => ({ ...f, portrait: e.target.value })) } />
setForm((f) => ({ ...f, hero: e.target.value })) } />
setForm((f) => ({ ...f, header: e.target.value })) } />
setForm((f) => ({ ...f, command: e.target.value })) } />

{m.library_field_command_help()}

)} {games.length === 0 ? ( {m.library_empty()} ) : (
{games.map((game) => ( openEdit(game)} onDelete={() => handleDelete(game)} deleting={isDeleting} /> ))}
)}
); }; 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 = ({ game, onEdit, onDelete, deleting }) => { const isCustom = game.store === "custom"; // 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 (
{src ? ( {game.title} setFailed((prev) => ({ ...prev, [src]: true }))} /> ) : (
{game.title}
)}
{storeLabel(game.store)}
{isCustom && (
)}
{game.title}
); };