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; logo: string; command: string; // Details — the flattened GameMeta fields; numbers and lists are kept as the raw // text the user typed and only parsed on submit. platform: string; description: string; developer: string; publisher: string; releaseYear: string; genres: string; tags: string; region: string; players: string; } const emptyForm: FormState = { title: "", portrait: "", hero: "", header: "", logo: "", command: "", platform: "", description: "", developer: "", publisher: "", releaseYear: "", genres: "", tags: "", region: "", players: "", }; function formFrom(entry: GameEntry): FormState { return { title: entry.title, portrait: entry.art.portrait ?? "", hero: entry.art.hero ?? "", header: entry.art.header ?? "", logo: entry.art.logo ?? "", command: entry.launch?.kind === "command" ? entry.launch.value : "", platform: entry.platform ?? "", description: entry.description ?? "", developer: entry.developer ?? "", publisher: entry.publisher ?? "", releaseYear: entry.release_year?.toString() ?? "", genres: entry.genres?.join(", ") ?? "", tags: entry.tags?.join(", ") ?? "", region: entry.region ?? "", players: entry.players?.toString() ?? "", }; } /** Map the form to the API body — only attach `launch` when a command was given. `update_custom` * REPLACES the whole entry (art AND the metadata fields), so every field the form knows must * round-trip (else editing a game with a `logo` or a `platform` would silently drop it). */ function toInput(f: FormState): CustomInput { const trim = (s: string) => { const t = s.trim(); return t ? t : undefined; }; // "RPG, Platformer" → ["RPG", "Platformer"]; empty input → omitted entirely. const list = (s: string) => { const items = s .split(",") .map((x) => x.trim()) .filter(Boolean); return items.length ? items : undefined; }; const int = (s: string) => { const n = Number.parseInt(s.trim(), 10); return Number.isFinite(n) ? n : undefined; }; const command = f.command.trim(); return { title: f.title.trim(), art: { portrait: trim(f.portrait), hero: trim(f.hero), header: trim(f.header), logo: trim(f.logo), }, launch: command ? { kind: "command", value: command } : null, platform: trim(f.platform), description: trim(f.description), developer: trim(f.developer), publisher: trim(f.publisher), release_year: int(f.releaseYear), genres: list(f.genres), tags: list(f.tags), region: trim(f.region), players: int(f.players), }; } /** 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 ( ); }; /** One labeled text input bound to a FormState key — the form is a stack of these. */ const Field: FC<{ id: keyof FormState; label: string; value: string; onChange: (value: string) => void; help?: string; type?: string; required?: boolean; }> = ({ id, label, value, onChange, help, type, required }) => (
onChange(e.target.value)} /> {help &&

{help}

}
); /** * 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(initial); const set = (key: keyof FormState) => (value: string) => setForm((f) => ({ ...f, [key]: value })); const handleSubmit = (e: FormEvent) => { e.preventDefault(); const data = toInput(form); if (!data.title) return; onSubmit(data); }; return ( {mode === "edit" ? m.library_edit_title() : m.library_add_title()}
); };