feat(web): consolidate paired devices, self-contained sections, docs + lint
Web console - Pairing/Library/Stats refactored into self-contained subsections that each own their own queries + mutations; a shared slot-based layout (view.tsx) is filled by the live page (containers) and Storybook (pure cards + fixtures) so the layout can't drift. - All paired devices in one list on Pairing with a protocol column (punktfunk/1 + Moonlight), routing each unpair to the right endpoint; the redundant Clients page is removed. - Library: overview grid split from the add/edit form into separate files. - Login screen links out to the docs. Docs - "Console login password" section on every host page (apt/RPM/Bazzite/SteamOS/Windows) plus a new "Forgot your Password?" troubleshooting page, linked from the login screen. - Console served as HTTP/1.1 over TLS (drop the unusable HTTP/3 advertising) across the Bun entry, launchers, systemd units, and packaging. Tooling - Biome now respects .gitignore (stops linting generated code), config migrated to 2.5.1; all lint issues fixed cleanly. Also includes this branch's in-progress host, Apple client, packaging, and CI changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import { Pencil, Trash2 } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { m } from "@/paraglide/messages";
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
export 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.
|
||||
*/
|
||||
export 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 px-card pb-card pt-4 text-sm font-medium"
|
||||
title={game.title}
|
||||
>
|
||||
{game.title}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { X } from "lucide-react";
|
||||
import { type FC, type FormEvent, useState } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
useCreateCustomGame,
|
||||
useUpdateCustomGame,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { CustomInput } from "@/api/gen/model/customInput";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
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 { m } from "@/paraglide/messages";
|
||||
import { customId } from "./helpers";
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** What the form targets: an existing custom entry to edit, or "new" for a fresh add. */
|
||||
export type FormTarget = GameEntry | "new";
|
||||
|
||||
/**
|
||||
* Container: the add/edit form — owns the create + update mutations and derives the
|
||||
* initial field state from the target. Kept entirely separate from the overview grid
|
||||
* (own file, own queries) so the two concerns don't share a component.
|
||||
*/
|
||||
export const GameFormSection: FC<{
|
||||
target: FormTarget;
|
||||
onClose: () => void;
|
||||
}> = ({ target, onClose }) => {
|
||||
const qc = useQueryClient();
|
||||
const create = useCreateCustomGame();
|
||||
const update = useUpdateCustomGame();
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
|
||||
const onSubmit = async (data: CustomInput) => {
|
||||
if (target === "new") await create.mutateAsync({ data }).then(invalidate);
|
||||
else
|
||||
await update.mutateAsync({ id: customId(target), data }).then(invalidate);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<GameForm
|
||||
initial={target === "new" ? emptyForm : formFrom(target)}
|
||||
mode={target === "new" ? "add" : "edit"}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={onClose}
|
||||
isSaving={create.isPending || update.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The add/edit form card. Owns only its own field state (re-seeded per mount — the
|
||||
* parent keys it by target); reports a ready-to-send `CustomInput` on submit.
|
||||
*/
|
||||
export const GameForm: FC<{
|
||||
initial: FormState;
|
||||
mode: "add" | "edit";
|
||||
onSubmit: (data: CustomInput) => void;
|
||||
onCancel: () => void;
|
||||
isSaving: boolean;
|
||||
}> = ({ initial, mode, onSubmit, onCancel, isSaving }) => {
|
||||
const [form, setForm] = useState<FormState>(initial);
|
||||
|
||||
const handleSubmit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const data = toInput(form);
|
||||
if (!data.title) return;
|
||||
onSubmit(data);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="max-w-xl">
|
||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||
<CardTitle>
|
||||
{mode === "edit" ? m.library_edit_title() : m.library_add_title()}
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={m.library_cancel()}
|
||||
onClick={onCancel}
|
||||
>
|
||||
<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()}>
|
||||
{mode === "edit" ? m.library_save() : m.library_create()}
|
||||
</Button>
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
{m.library_cancel()}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { motion, stagger } from "motion/react";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
useDeleteCustomGame,
|
||||
useGetLibrary,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
import { QueryState } from "@/components/query-state";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { Loadable } from "@/lib/query";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { GameCard } from "./GameCard";
|
||||
import { customId } from "./helpers";
|
||||
|
||||
/**
|
||||
* Container: the library OVERVIEW — owns the listing query and per-card delete.
|
||||
* Editing is escalated to the parent (it opens the separate add/edit form), so
|
||||
* this subsection knows nothing about the form beyond firing `onEdit`.
|
||||
*/
|
||||
export const LibraryGridSection: FC<{ onEdit: (entry: GameEntry) => void }> = ({
|
||||
onEdit,
|
||||
}) => {
|
||||
const qc = useQueryClient();
|
||||
const library = useGetLibrary();
|
||||
const remove = useDeleteCustomGame();
|
||||
|
||||
const onDelete = async (entry: GameEntry) => {
|
||||
if (!confirm(m.library_delete_confirm())) return;
|
||||
await remove
|
||||
.mutateAsync({ id: customId(entry) })
|
||||
.then(() => qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() }));
|
||||
};
|
||||
|
||||
return (
|
||||
<LibraryGrid
|
||||
library={library}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
isDeleting={remove.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/** The poster grid (with empty + loading/error states). */
|
||||
export const LibraryGrid: FC<{
|
||||
library: Loadable<GameEntry[]>;
|
||||
onEdit: (entry: GameEntry) => void;
|
||||
onDelete: (entry: GameEntry) => void;
|
||||
isDeleting: boolean;
|
||||
}> = ({ library, onEdit, onDelete, isDeleting }) => {
|
||||
const games = library.data ?? [];
|
||||
return (
|
||||
<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="@container">
|
||||
<motion.div
|
||||
transition={{ delayChildren: stagger(0.1) }}
|
||||
variants={{ enter: {}, from: {} }}
|
||||
className="grid grid-cols-1 gap-card @sm:grid-cols-2 @md:grid-cols-2 @lg:grid-cols-3 @2xl:grid-cols-4 @4xl:grid-cols-5"
|
||||
>
|
||||
{games.map((game) => (
|
||||
<GameCard
|
||||
key={game.id}
|
||||
game={game}
|
||||
onEdit={() => onEdit(game)}
|
||||
onDelete={() => onDelete(game)}
|
||||
deleting={isDeleting}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</QueryState>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { GameEntry } from "@/api/gen/model/gameEntry";
|
||||
|
||||
/** The custom-CRUD path param is the raw id without the `custom:` prefix. */
|
||||
export function customId(entry: GameEntry): string {
|
||||
return entry.id.startsWith("custom:")
|
||||
? entry.id.slice("custom:".length)
|
||||
: entry.id;
|
||||
}
|
||||
@@ -1,37 +1,44 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { FC } from "react";
|
||||
import {
|
||||
getGetLibraryQueryKey,
|
||||
useCreateCustomGame,
|
||||
useDeleteCustomGame,
|
||||
useGetLibrary,
|
||||
useUpdateCustomGame,
|
||||
} from "@/api/gen/library/library";
|
||||
import type { CustomInput } from "@/api/gen/model/customInput";
|
||||
import Section from "@unom/ui/section";
|
||||
import { Plus } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useLocale } from "@/lib/i18n";
|
||||
import { LibraryView } from "./view";
|
||||
import { m } from "@/paraglide/messages";
|
||||
import { type FormTarget, GameFormSection } from "./GameForm";
|
||||
import { LibraryGridSection } from "./LibraryGrid";
|
||||
|
||||
// Library = an OVERVIEW grid + a SEPARATE add/edit form, deliberately split into their own files
|
||||
// (LibraryGrid / GameForm) so the two concerns never share a component. This container owns only the
|
||||
// shared "is the form open, and for what" UI state; the grid and form each own their own data.
|
||||
export const SectionLibrary: FC = () => {
|
||||
useLocale();
|
||||
const qc = useQueryClient();
|
||||
const library = useGetLibrary();
|
||||
const create = useCreateCustomGame();
|
||||
const update = useUpdateCustomGame();
|
||||
const remove = useDeleteCustomGame();
|
||||
|
||||
const invalidate = () =>
|
||||
qc.invalidateQueries({ queryKey: getGetLibraryQueryKey() });
|
||||
// null = form hidden; "new" = adding; a GameEntry = editing that custom entry. Keying the form
|
||||
// by the target re-seeds its fields when switching add → edit (or between entries).
|
||||
const [target, setTarget] = useState<FormTarget | null>(null);
|
||||
|
||||
return (
|
||||
<LibraryView
|
||||
library={library}
|
||||
onCreate={(data: CustomInput) =>
|
||||
create.mutateAsync({ data }).then(invalidate)
|
||||
}
|
||||
onUpdate={(id, data) => update.mutateAsync({ id, data }).then(invalidate)}
|
||||
onDelete={(id) => remove.mutateAsync({ id }).then(invalidate)}
|
||||
isSaving={create.isPending || update.isPending}
|
||||
isDeleting={remove.isPending}
|
||||
/>
|
||||
<Section maxWidth={false}>
|
||||
<div className="flex flex-col gap-card">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h1 className="text-2xl font-semibold">{m.library_title()}</h1>
|
||||
{target === null && (
|
||||
<Button onClick={() => setTarget("new")}>
|
||||
<Plus className="size-4" />
|
||||
{m.library_add_button()}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{target !== null && (
|
||||
<GameFormSection
|
||||
key={target === "new" ? "new" : target.id}
|
||||
target={target}
|
||||
onClose={() => setTarget(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LibraryGridSection onEdit={(entry) => setTarget(entry)} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user