diff --git a/web/messages/de.json b/web/messages/de.json index 8cade95..85ac3bc 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -6,6 +6,7 @@ "nav_host": "Host", "nav_clients": "Gekoppelte Geräte", "nav_pairing": "Kopplung", + "nav_library": "Bibliothek", "nav_settings": "Einstellungen", "status_title": "Live-Status", "status_video": "Video", @@ -67,6 +68,25 @@ "pairing_pending_age_secs": "vor {s}s", "pairing_pending_age_mins": "vor {min} min", "pairing_moonlight_title": "Moonlight-Kopplung (GameStream)", + "library_title": "Bibliothek", + "library_empty": "Noch keine Spiele gefunden.", + "library_store_steam": "Steam", + "library_store_custom": "Eigene", + "library_add_title": "Eigenes Spiel hinzufügen", + "library_edit_title": "Eigenes Spiel bearbeiten", + "library_add_button": "Eigenes Spiel hinzufügen", + "library_field_title": "Titel", + "library_field_portrait": "Portrait-Bild-URL", + "library_field_hero": "Hero-Bild-URL", + "library_field_header": "Header-Bild-URL", + "library_field_command": "Startbefehl", + "library_field_command_help": "Optional. Der Befehl, mit dem der Host diesen Titel startet.", + "library_save": "Speichern", + "library_create": "Hinzufügen", + "library_cancel": "Abbrechen", + "library_edit": "Bearbeiten", + "library_delete": "Löschen", + "library_delete_confirm": "Dieses eigene Spiel löschen? Das kann nicht rückgängig gemacht werden.", "settings_title": "Einstellungen", "settings_token_label": "API-Token", "settings_token_help": "Bearer-Token für die Verwaltungs-API. Bei einem Loopback-Host ohne Token leer lassen.", diff --git a/web/messages/en.json b/web/messages/en.json index d1d50bf..40f5f32 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -6,6 +6,7 @@ "nav_host": "Host", "nav_clients": "Paired clients", "nav_pairing": "Pairing", + "nav_library": "Library", "nav_settings": "Settings", "status_title": "Live status", "status_video": "Video", @@ -67,6 +68,25 @@ "pairing_pending_age_secs": "{s}s ago", "pairing_pending_age_mins": "{min} min ago", "pairing_moonlight_title": "Moonlight (GameStream) pairing", + "library_title": "Library", + "library_empty": "No games found yet.", + "library_store_steam": "Steam", + "library_store_custom": "Custom", + "library_add_title": "Add a custom game", + "library_edit_title": "Edit custom game", + "library_add_button": "Add custom game", + "library_field_title": "Title", + "library_field_portrait": "Portrait art URL", + "library_field_hero": "Hero art URL", + "library_field_header": "Header art URL", + "library_field_command": "Launch command", + "library_field_command_help": "Optional. The command the host runs to launch this title.", + "library_save": "Save", + "library_create": "Add", + "library_cancel": "Cancel", + "library_edit": "Edit", + "library_delete": "Delete", + "library_delete_confirm": "Delete this custom game? This can't be undone.", "settings_title": "Settings", "settings_token_label": "API token", "settings_token_help": "Bearer token for the management API. Leave empty for a loopback host with no token.", diff --git a/web/src/components/app-shell.tsx b/web/src/components/app-shell.tsx index ccdc9e4..79425f8 100644 --- a/web/src/components/app-shell.tsx +++ b/web/src/components/app-shell.tsx @@ -1,6 +1,6 @@ import type { ReactNode } from 'react' import { Link } from '@tanstack/react-router' -import { Activity, Server, Users, KeyRound, Settings, Radio } from 'lucide-react' +import { Activity, Server, Users, KeyRound, LibraryBig, Settings, Radio } from 'lucide-react' import { m } from '@/paraglide/messages' import { useLocale, changeLocale, locales, type Locale } from '@/lib/i18n' import { cn } from '@/lib/utils' @@ -8,6 +8,7 @@ import { cn } from '@/lib/utils' const NAV = [ { to: '/', icon: Activity, label: () => m.nav_dashboard() }, { to: '/host', icon: Server, label: () => m.nav_host() }, + { to: '/library', icon: LibraryBig, label: () => m.nav_library() }, { to: '/clients', icon: Users, label: () => m.nav_clients() }, { to: '/pairing', icon: KeyRound, label: () => m.nav_pairing() }, { to: '/settings', icon: Settings, label: () => m.nav_settings() }, diff --git a/web/src/routes/library.tsx b/web/src/routes/library.tsx new file mode 100644 index 0000000..fc71ab7 --- /dev/null +++ b/web/src/routes/library.tsx @@ -0,0 +1,292 @@ +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, + } +} + +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} +
+
+ ) +}