import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' import { useQueryClient } from '@tanstack/react-query' import { Pencil, Plus, Trash2, X } from 'lucide-react' import { useGetLibrary, useCreateCustomGame, useUpdateCustomGame, useDeleteCustomGame, getGetLibraryQueryKey, } from '@/api/gen/library/library' import type { GameEntry } from '@/api/gen/model/gameEntry' import type { CustomInput } from '@/api/gen/model/customInput' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { QueryState } from '@/components/query-state' import { m } from '@/paraglide/messages' import { useLocale } from '@/lib/i18n' export const Route = createFileRoute('/library')({ component: LibraryPage }) /** 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 } /** Editable form state for the add/edit custom-game form. */ 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, } } // Exported for Storybook (see src/stories) — harmless alongside `Route`. export function LibraryPage() { useLocale() const qc = useQueryClient() const library = useGetLibrary() const create = useCreateCustomGame() const update = useUpdateCustomGame() const remove = useDeleteCustomGame() // 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 invalidate = () => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }) const openAdd = () => { setForm(emptyForm) setEditing('') } const openEdit = (entry: GameEntry) => { setForm(formFrom(entry)) setEditing(customId(entry)) } const closeForm = () => setEditing(null) const onSubmit = (e: React.FormEvent) => { e.preventDefault() const data = toInput(form) if (!data.title) return if (editing) { update.mutate({ id: editing, data }, { onSuccess: () => { invalidate(); closeForm() } }) } else { create.mutate({ data }, { onSuccess: () => { invalidate(); closeForm() } }) } } const onDelete = (entry: GameEntry) => { if (!confirm(m.library_delete_confirm())) return remove.mutate({ id: customId(entry) }, { onSuccess: invalidate }) } const saving = create.isPending || update.isPending 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={() => onDelete(game)} deleting={remove.isPending} /> ))}
)}
) } 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. */ function GameCard({ game, onEdit, onDelete, deleting }: GameCardProps) { 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}
)}
{isCustom ? m.library_store_custom() : m.library_store_steam()}
{isCustom && (
)}
{game.title}
) }