feat: rebuild the edit dialog around a cover and files rail

Drops the three tabs for a wide dialog: cover and files in a fixed rail,
all fourteen metadata fields grouped beside them, Save in the footer.

Adds file management, which the Files tab never had — it could only upload,
never list or remove. Files now show size and format with download and
delete, the latter asking whether to remove it from disk too.
This commit is contained in:
2026-08-12 11:40:49 -04:00
parent d6207b5743
commit 5f2d68694d
4 changed files with 437 additions and 362 deletions
@@ -1,18 +1,25 @@
<script lang="ts"> <script lang="ts">
import * as Dialog from '$lib/components/ui/dialog/index.js'; import * as Dialog from '$lib/components/ui/dialog/index.js';
import * as Tabs from '$lib/components/ui/tabs/index.js'; import { Button } from '$lib/components/ui/button/index.js';
import { type Book } from '$lib/schema'; import { type Book } from '$lib/schema';
import EditCover from './edit-cover.svelte'; import EditCover from './edit-cover.svelte';
import EditFiles from './edit-files.svelte'; import EditFiles from './edit-files.svelte';
import EditMetadata from './edit-metadata.svelte'; import EditMetadata from './edit-metadata.svelte';
let { book, open = $bindable() }: { book?: Book; open: boolean } = $props(); let { book, open = $bindable() }: { book?: Book; open: boolean } = $props();
/**
* The metadata form lives in a child, but Save belongs in the dialog footer —
* a submit button reaches it by id rather than the footer having to duplicate
* the submit logic.
*/
const METADATA_FORM_ID = 'edit-book-metadata';
</script> </script>
<!-- <!--
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book` This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
changes underneath it as different books are edited. bookToEdit is never changes underneath it as different books are edited. bookToEdit is never
cleared, so {#if book} stays true and the tab forms never unmount — they seed cleared, so {#if book} stays true and the forms never unmount — they seed
local state from `book` on mount, so without this key you would open Edit on a local state from `book` on mount, so without this key you would open Edit on a
second book and see the first book's authors, tags and cover, then save them second book and see the first book's authors, tags and cover, then save them
onto the wrong record. Keying on the id remounts the forms per book. onto the wrong record. Keying on the id remounts the forms per book.
@@ -20,30 +27,42 @@
<Dialog.Root bind:open> <Dialog.Root bind:open>
{#if book} {#if book}
{#key book.id} {#key book.id}
<Dialog.Content class="sm:max-w-xl"> <Dialog.Content
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]"> class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
<Tabs.List class="grid w-full grid-cols-3"> >
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger> <Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
<Tabs.Trigger value="cover">Cover</Tabs.Trigger> <Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
<Tabs.Trigger value="files">Files</Tabs.Trigger> >
</Tabs.List> <Dialog.Description class="truncate text-xs">
{book.authors.map((author) => author.name).join(', ') || 'Unknown author'}
</Dialog.Description>
</Dialog.Header>
<!-- Metadata form --> <!-- Rail and form scroll independently so the footer never moves -->
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1"> <div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_1fr]">
<EditMetadata {book} {open} /> <aside
</Tabs.Content> class="flex min-w-0 flex-col gap-5 overflow-y-auto border-b bg-sidebar p-4 md:border-r md:border-b-0"
>
<EditCover {book} />
<EditFiles {book} />
</aside>
<!-- Cover form --> <div class="min-h-0 overflow-y-auto p-5">
<Tabs.Content value="cover"> <EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
<EditCover {book} {open} /> </div>
</Tabs.Content> </div>
<!-- Add files form --> <Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
<Tabs.Content value="files"> <!-- Cover and file changes hit the server as they happen, while the
<EditFiles {book} /> fields wait for Save. Saying so is the cheapest way to stop
</Tabs.Content> Cancel reading as "undo everything". -->
</Tabs.Root> <p class="text-xs text-muted-foreground">Cover and file changes apply immediately</p>
</Dialog.Content> <div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button type="submit" form={METADATA_FORM_ID}>Save changes</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
{/key} {/key}
{/if} {/if}
</Dialog.Root> </Dialog.Root>
@@ -1,34 +1,23 @@
<script lang="ts"> <script lang="ts">
import { import { tick, untrack } from 'svelte';
FileDropZone,
type FileDropZoneProps,
displaySize
} from '$lib/components/ui/file-drop-zone/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Switch } from '$lib/components/ui/switch/index';
import BookImage from '$lib/components/view/book-image.svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { updateBookCover } from '$lib/api'; import { updateBookCover } from '$lib/api';
import type { Book } from '$lib/schema'; import type { Book } from '$lib/schema';
import { tick, untrack } from 'svelte'; import BookImage from '$lib/components/view/book-image.svelte';
import { X } from '@lucide/svelte'; import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone/index.js';
let { book, open = $bindable() }: { book: Book; open: boolean } = $props(); let { book }: { book: Book } = $props();
let formEl = $state<HTMLFormElement>(); let formEl = $state<HTMLFormElement>();
// Seeded once per mount — see the key in edit-book.svelte // Seeded once per mount — see the key in edit-book.svelte
let coverImagePreview = $state(untrack(() => `/api/${book.cover_image}`)); let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
let autoUploadOnDrop = $state(true);
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => { const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
updateBookCover.fields.file.set(uploadedFiles[0]); updateBookCover.fields.file.set(uploadedFiles[0]);
updateCoverPreview(); updateCoverPreview();
if (autoUploadOnDrop && updateBookCover.fields.file.value()) { await tick();
await tick(); formEl?.requestSubmit();
formEl?.requestSubmit();
}
}; };
function updateCoverPreview() { function updateCoverPreview() {
@@ -36,14 +25,14 @@
if (file && file.type.startsWith('image/')) { if (file && file.type.startsWith('image/')) {
const reader = new FileReader(); const reader = new FileReader();
reader.onloadend = () => { reader.onloadend = () => {
coverImagePreview = reader.result; coverImagePreview = reader.result as string;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
} }
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => { const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
toast.error(`${file.name} failed to upload!`, { description: reason }); toast.error(`${file.name} was not used`, { description: reason });
}; };
$effect(() => { $effect(() => {
@@ -54,65 +43,40 @@
}); });
</script> </script>
<form <section class="flex min-w-0 flex-col gap-2">
bind:this={formEl} <h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Cover</h3>
{...updateBookCover.enhance(async ({ submit, form }) => {
try {
await submit();
form.reset();
open = false;
toast.success('Updated book cover!');
} catch (error) {
console.error('Failed to update book cover: ', error);
toast.error('Failed to update cover.');
}
})}
enctype="multipart/form-data"
class="grid grid-cols-[1fr_2fr] gap-4 p-6"
>
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
<div class="flex flex-col gap-2"> <BookImage src={coverImagePreview} class="w-full rounded-md border" />
<BookImage src={coverImagePreview} class="w-64 rounded" />
</div> <form
bind:this={formEl}
{...updateBookCover.enhance(async ({ submit, form }) => {
try {
await submit();
form.reset();
// Deliberately does not close the dialog. The cover is one panel of a
// larger form now, and closing here would throw away metadata edits
// the reader has not saved yet.
toast.success('Cover updated');
} catch (error) {
console.error('Failed to update book cover: ', error);
toast.error('Failed to update the cover');
}
})}
enctype="multipart/form-data"
class="flex flex-col gap-2"
>
<input class="hidden" {...updateBookCover.fields.book_id.as('text')} />
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
<div class="flex flex-col gap-2">
<FileDropZone <FileDropZone
{onUpload} {onUpload}
{onFileRejected} {onFileRejected}
accept=".jpeg,.jpg,.png,.webp,image/*" accept=".jpeg,.jpg,.png,.webp,image/*"
label="Only JPEG, PNG, and WEBP images supported" label="Replace cover"
sublabel="JPEG, PNG or WEBP"
maxFiles={1} maxFiles={1}
fileCount={updateBookCover.fields.file.value() ? 1 : 0} fileCount={updateBookCover.fields.file.value() ? 1 : 0}
/> />
<input class="hidden" {...updateBookCover.fields.file.as('file')} /> </form>
<div class="flex flex-col gap-2"> </section>
{#if updateBookCover.fields.file.value()}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{updateBookCover.fields.file.value().name}</span>
<span class="text-xs text-muted-foreground"
>{displaySize(updateBookCover.fields.file.value().size)}</span
>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
updateBookCover.fields.file.set(undefined);
coverImagePreview = `/api/${book.cover_image}`;
}}
>
<X />
</Button>
</div>
{/if}
</div>
<div class="flex flex-row items-center space-x-2">
<Switch bind:checked={autoUploadOnDrop} />
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label>
<Button type="submit" class="ml-auto w-fit" disabled={!updateBookCover.fields.file.value()}>Upload</Button>
</div>
</div>
</form>
@@ -1,98 +1,196 @@
<script lang="ts"> <script lang="ts">
import { uploadBookFiles } from '$lib/api'; import { tick, untrack } from 'svelte';
import {
displaySize,
FileDropZone,
type FileDropZoneProps
} from '$lib/components/ui/file-drop-zone';
import { Button } from '$lib/components/ui/button';
import * as Field from '$lib/components/ui/field/index.js';
import { Switch } from '$lib/components/ui/switch/index';
import { X } from '@lucide/svelte';
import type { Book } from '$lib/schema';
import { tick } from 'svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Download, Trash2 } from '@lucide/svelte';
import { uploadBookFiles } from '$lib/api';
import type { Book, BookFile } from '$lib/schema';
import { formatFileSize, getFileType } from '$lib/utils';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import * as AlertDialog from '$lib/components/ui/alert-dialog/index.js';
import * as Field from '$lib/components/ui/field/index.js';
import { Button, buttonVariants } from '$lib/components/ui/button/index.js';
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
import { FileDropZone, type FileDropZoneProps } from '$lib/components/ui/file-drop-zone';
import { Spinner } from '$lib/components/ui/spinner/index';
let { book }: { book: Book } = $props(); let { book }: { book: Book } = $props();
let files = $derived(uploadBookFiles.fields.files.value() ?? []); const bookOps = getBookOperationsState();
let autoUploadOnDrop = $state(true); /**
* The book's files, owned locally.
*
* `book` is a snapshot handed down from bookOperations rather than a live
* query, so invalidating after a delete does not reach it. Keeping the list
* here lets the rail reflect an add or a remove straight away. Seeded once per
* mount — edit-book.svelte keys the dialog on book.id.
*/
let files = $state<BookFile[]>(untrack(() => [...book.files]));
// The field's value is a sparse-ish list until the form settles, so narrow it
// before rendering rather than asserting at each use.
let pending = $derived(
(uploadBookFiles.fields.files.value() ?? []).filter((file): file is File => Boolean(file))
);
let fileToDelete = $state<BookFile>();
let deleteFromDisk = $state(true);
let confirmOpen = $state(false);
let formEl = $state<HTMLFormElement>(); let formEl = $state<HTMLFormElement>();
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => { const onUpload: FileDropZoneProps['onUpload'] = async (uploaded) => {
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]); uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
if (autoUploadOnDrop && files.length > 0) { await tick();
await tick(); formEl?.requestSubmit();
formEl?.requestSubmit();
}
}; };
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => { const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
toast.error(`${file.name} failed to upload!`, { description: reason }); toast.error(`${file.name} was not added`, { description: reason });
}; };
function confirmDelete(file: BookFile) {
fileToDelete = file;
confirmOpen = true;
}
async function removeFile() {
if (!fileToDelete) return;
const target = fileToDelete;
confirmOpen = false;
// Drop it from the list first: the request invalidates the books query, but
// this dialog holds its own copy of the book and would not see that.
files = files.filter((file) => file.id !== target.id);
await bookOps.deleteBookFiles(book.id, [target.id], deleteFromDisk);
fileToDelete = undefined;
}
</script> </script>
<form <section class="flex min-w-0 flex-col gap-2">
{...uploadBookFiles.enhance(async ({ submit, form }) => { <h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
try {
await submit();
// Check if there are any validation issues {#if files.length === 0}
const issues = uploadBookFiles.fields.allIssues(); <p class="text-xs text-muted-foreground">
if (issues && issues.length > 0) { No files yet. Add one below so this book can be read or downloaded.
return; </p>
} {/if}
// Reset the files field <ul class="flex flex-col gap-1.5">
uploadBookFiles.fields.files.set([]); {#each files as file (file.id)}
toast.success('Files successfully added!'); <li class="flex items-center gap-2 rounded-md border bg-background p-2">
} catch (error) { <span
console.error('Failed to upload files: ', error); class="rounded-sm bg-accent px-1.5 py-0.5 font-mono text-[9px] font-semibold text-accent-foreground"
toast.error('Failed to upload files');
}
})}
bind:this={formEl}
enctype="multipart/form-data"
class="flex w-full flex-col gap-2 p-4"
>
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
<FileDropZone
{onUpload}
{onFileRejected}
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
sublabel="Only PDF, EPUB, and MOBI files supported"
/>
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
<div class="flex flex-col gap-2">
{#each files as file, idx}
<div class="flex place-items-center justify-between gap-2">
<div class="flex flex-col">
<span>{file.name}</span>
<span class="text-xs text-muted-foreground">{displaySize(file.size)}</span>
</div>
<Button
variant="outline"
size="icon"
onclick={() => {
uploadBookFiles.fields.files.set([
...Array.from(files).slice(0, idx),
...Array.from(files).slice(idx + 1)
]);
}}
> >
<X /> {getFileType(file.filename)}
</Button> </span>
</div>
{/each}
</div>
<div class="flex flex-row items-center space-x-2"> <span class="min-w-0 flex-1">
<Switch bind:checked={autoUploadOnDrop} /> <span class="block truncate text-xs" title={file.filename}>{file.filename}</span>
<Field.Label for="auto-upload-on-drop">Auto upload on file drop</Field.Label> <span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
<Button type="submit" class="ml-auto w-fit">Upload</Button> {formatFileSize(file.size)}
</div> </span>
</form> </span>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0"
title="Download {file.filename}"
onclick={() => bookOps.downloadBookFile(book.id, file.id, file.filename)}
>
<Download class="size-3.5" />
<span class="sr-only">Download {file.filename}</span>
</Button>
<Button
variant="ghost"
size="icon"
class="size-7 shrink-0 text-destructive hover:text-destructive"
title="Remove {file.filename}"
onclick={() => confirmDelete(file)}
>
<Trash2 class="size-3.5" />
<span class="sr-only">Remove {file.filename}</span>
</Button>
</li>
{/each}
{#each pending as file (file.name)}
<li
class="flex items-center gap-2 rounded-md border border-dashed bg-background p-2 text-muted-foreground"
>
<Spinner class="size-3.5 shrink-0" />
<span class="min-w-0 flex-1">
<span class="block truncate text-xs">{file.name}</span>
<span class="block font-mono text-[10px] tabular-nums">Uploading…</span>
</span>
</li>
{/each}
</ul>
<form
bind:this={formEl}
{...uploadBookFiles.enhance(async ({ submit }) => {
try {
await submit();
const issues = uploadBookFiles.fields.allIssues();
if (issues && issues.length > 0) return;
// The endpoint answers with the updated book, so the new files come
// back with their ids rather than having to be guessed at.
files = uploadBookFiles.result?.files ?? files;
uploadBookFiles.fields.files.set([]);
toast.success('Files added');
} catch (error) {
console.error('Failed to add files: ', error);
toast.error('Failed to add files');
}
})}
enctype="multipart/form-data"
class="flex flex-col gap-2"
>
<input {...uploadBookFiles.fields.book_id.as('hidden', book.id)} />
<input class="hidden" {...uploadBookFiles.fields.files.as('file multiple')} />
<FileDropZone
{onUpload}
{onFileRejected}
accept=".pdf,.epub,.mobi,application/pdf,application/epub+zip,application/x-mobipocket-ebook"
label="Add a file"
sublabel="EPUB, PDF or MOBI"
/>
</form>
</section>
<AlertDialog.Root bind:open={confirmOpen}>
<AlertDialog.Content>
<AlertDialog.Header>
<AlertDialog.Title>Remove {fileToDelete?.filename}?</AlertDialog.Title>
<AlertDialog.Description>
This cannot be undone. The other files on this book are not affected.
</AlertDialog.Description>
</AlertDialog.Header>
<!-- The API takes these as separate outcomes: drop the record, or drop the
record and the file on disk. Leaving it implicit would mean deleting
someone's only copy without saying so. -->
<div class="flex items-center gap-2">
<Checkbox id="delete-from-disk" bind:checked={deleteFromDisk} />
<Field.Label for="delete-from-disk" class="font-normal">
Also delete the file from the filesystem
</Field.Label>
</div>
<AlertDialog.Footer>
<AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
<AlertDialog.Action class={buttonVariants({ variant: 'destructive' })} onclick={removeFile}>
Remove
</AlertDialog.Action>
</AlertDialog.Footer>
</AlertDialog.Content>
</AlertDialog.Root>
@@ -1,18 +1,23 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card/index.js'; import { untrack } from 'svelte';
import * as Field from '$lib/components/ui/field/index.js';
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { Minus, Plus } from '@lucide/svelte';
import { updateBookMetadata } from '$lib/api'; import { updateBookMetadata } from '$lib/api';
import type { Book } from '$lib/schema'; import type { Book } from '$lib/schema';
import { untrack } from 'svelte';
import { Minus, Plus } from '@lucide/svelte';
let { book, open = $bindable() }: { book: Book; open: boolean } = $props(); import * as Field from '$lib/components/ui/field/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { TagsInput, type TagsInputProps } from '$lib/components/ui/tags-input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
let {
book,
open = $bindable(),
/** Lets the dialog footer own the submit button via the `form` attribute. */
formId
}: { book: Book; open: boolean; formId: string } = $props();
// Seeded once per mount; edit-book.svelte keys this form on book.id so a // Seeded once per mount; edit-book.svelte keys this form on book.id so a
// different book gets a fresh form rather than the previous book's values. // different book gets a fresh form rather than the previous book's values.
@@ -22,7 +27,6 @@
let identifierValues = $state(untrack(() => Object.values(book.identifiers))); let identifierValues = $state(untrack(() => Object.values(book.identifiers)));
function handleAddIdentifier() { function handleAddIdentifier() {
// Add empty strings to both arrays
identifierKeys = [...identifierKeys, '']; identifierKeys = [...identifierKeys, ''];
identifierValues = [...identifierValues, '']; identifierValues = [...identifierValues, ''];
} }
@@ -88,197 +92,187 @@
}); });
</script> </script>
<Card.Root class="w-full "> {#snippet groupHeading(label: string)}
<form <h3
{...updateBookMetadata.enhance(async ({ submit, form }) => { class="col-span-full mt-2 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
try {
await submit();
// Check if there are any validation issues
const issues = updateBookMetadata.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
open = false;
book = book;
toast.success('Updated book metadata!');
} catch (error) {
console.error('Error occurred updating book metadata: ', error);
toast.error('Failed to update book metadata.');
}
})}
> >
<Card.Content> {label}
<Field.Set> </h3>
<Field.Group class="flex flex-col"> {/snippet}
<!-- Book ID field -->
<Field.Field class="hidden">
<Field.Label for="book_id">Book ID</Field.Label>
<Input {...updateBookMetadata.fields.book_id.as('text')} />
{#each updateBookMetadata.fields.book_id.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Title field --> <form
<Field.Field> id={formId}
<Field.Label for="title">Title</Field.Label> {...updateBookMetadata.enhance(async ({ submit }) => {
<Input {...updateBookMetadata.fields.title.as('text')} /> try {
{#each updateBookMetadata.fields.title.issues() ?? [] as issue} await submit();
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Subtitle field --> // Check if there are any validation issues
<Field.Field> const issues = updateBookMetadata.fields.allIssues();
<Field.Label for="subtitle">Subtitle</Field.Label> if (issues && issues.length > 0) {
<Input {...updateBookMetadata.fields.subtitle.as('text')} /> return;
{#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue} }
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<div class="grid grid-cols-[3fr_1fr] gap-2"> open = false;
<!-- Series field --> toast.success('Updated book metadata!');
<Field.Field> } catch (error) {
<Field.Label for="series">Series</Field.Label> console.error('Error occurred updating book metadata: ', error);
<Input {...updateBookMetadata.fields.series.as('text')} /> toast.error('Failed to update book metadata.');
{#each updateBookMetadata.fields.series.issues() ?? [] as issue} }
<Field.Error>{issue.message}</Field.Error> })}
{/each} class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
</Field.Field> >
<input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
<!-- Series position field --> {@render groupHeading('Identity')}
<Field.Field>
<Field.Label for="series_position">Series position</Field.Label>
<Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<!-- Authors field --> <Field.Field class="col-span-full">
<Field.Field> <Field.Label for="title">Title</Field.Label>
<Field.Label for="authors">Authors</Field.Label> <Input {...updateBookMetadata.fields.title.as('text')} />
<TagsInput {#each updateBookMetadata.fields.title.issues() ?? [] as issue}
bind:value={authors} <Field.Error>{issue.message}</Field.Error>
validate={validateTagsInput} {/each}
placeholder="Add an author" </Field.Field>
class="min-h-10 p-2 text-sm"
/>
{#each authors as author}
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
{/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Tags field --> <Field.Field>
<Field.Field> <Field.Label for="subtitle">Subtitle</Field.Label>
<Field.Label for="tags">Tags</Field.Label> <Input {...updateBookMetadata.fields.subtitle.as('text')} />
<TagsInput {#each updateBookMetadata.fields.subtitle.issues() ?? [] as issue}
bind:value={tags} <Field.Error>{issue.message}</Field.Error>
validate={validateTagsInput} {/each}
placeholder="Add a tag" </Field.Field>
class="min-h-10 p-2 text-sm"
/>
{#each tags as tag}
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
{/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Description field --> <Field.Field>
<Field.Field> <Field.Label for="edition">Edition</Field.Label>
<Field.Label for="description">Description</Field.Label> <Input {...updateBookMetadata.fields.edition.as('number')} />
<Textarea {...updateBookMetadata.fields.description.as('text')} /> {#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
{#each updateBookMetadata.fields.description.issues() ?? [] as issue} <Field.Error>{issue.message}</Field.Error>
<Field.Error>{issue.message}</Field.Error> {/each}
{/each} </Field.Field>
</Field.Field>
<!-- Identifier fields --> <Field.Field>
<Field.Field> <Field.Label for="series">Series</Field.Label>
<Field.Label for="identifiers">Identifiers</Field.Label> <Input {...updateBookMetadata.fields.series.as('text')} />
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2"> {#each updateBookMetadata.fields.series.issues() ?? [] as issue}
{#each identifierKeys as _, idx} <Field.Error>{issue.message}</Field.Error>
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." /> {/each}
<Input bind:value={identifierValues[idx]} placeholder="Value..." /> </Field.Field>
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}> <div class="grid grid-cols-2 gap-2">
<Minus /> <Field.Field>
</Button> <Field.Label for="series_position">No.</Field.Label>
{/each} <Input {...updateBookMetadata.fields.series_position.as('text')} />
{#each updateBookMetadata.fields.series_position.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<Button variant="outline" onclick={() => handleAddIdentifier()}> <Field.Field>
<Plus /> <Field.Label for="language">Language</Field.Label>
Add Identifier <Input {...updateBookMetadata.fields.language.as('text')} />
</Button> {#each updateBookMetadata.fields.language.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</div>
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" /> {@render groupHeading('People and subjects')}
</div>
</Field.Field>
<!-- Publisher field --> <Field.Field class="col-span-full">
<div class="grid grid-cols-[2fr_1fr] gap-2"> <Field.Label for="authors">Authors</Field.Label>
<Field.Field> <TagsInput
<Field.Label for="publisher">Publisher</Field.Label> bind:value={authors}
<Input {...updateBookMetadata.fields.publisher.as('text')} /> validate={validateTagsInput}
{#each updateBookMetadata.fields.publisher.issues() ?? [] as issue} placeholder="Add an author"
<Field.Error>{issue.message}</Field.Error> class="min-h-10 p-2 text-sm"
{/each} />
</Field.Field> {#each authors as author}
<input class="hidden" {...updateBookMetadata.fields.authors.as('checkbox', author)} />
{/each}
{#each updateBookMetadata.fields.authors.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Published date field --> <Field.Field class="col-span-full">
<Field.Field> <Field.Label for="tags">Tags</Field.Label>
<Field.Label for="published_date">Date published</Field.Label> <TagsInput
<Input {...updateBookMetadata.fields.published_date.as('date')} /> bind:value={tags}
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue} validate={validateTagsInput}
<Field.Error>{issue.message}</Field.Error> placeholder="Add a tag"
{/each} class="min-h-10 p-2 text-sm"
</Field.Field> />
</div> {#each tags as tag}
<input class="hidden" {...updateBookMetadata.fields.tags.as('checkbox', tag)} />
{/each}
{#each updateBookMetadata.fields.tags.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2"> {@render groupHeading('Publication')}
<!-- Pages field -->
<Field.Field>
<Field.Label for="pages">Pages</Field.Label>
<Input {...updateBookMetadata.fields.pages.as('number')} />
{#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
<!-- Language field --> <Field.Field>
<Field.Field> <Field.Label for="publisher">Publisher</Field.Label>
<Field.Label for="language">Language</Field.Label> <Input {...updateBookMetadata.fields.publisher.as('text')} />
<Input {...updateBookMetadata.fields.language.as('text')} /> {#each updateBookMetadata.fields.publisher.issues() ?? [] as issue}
{#each updateBookMetadata.fields.language.issues() ?? [] as issue} <Field.Error>{issue.message}</Field.Error>
<Field.Error>{issue.message}</Field.Error> {/each}
{/each} </Field.Field>
</Field.Field>
<!-- Edition field --> <div class="grid grid-cols-2 gap-2">
<Field.Field> <Field.Field>
<Field.Label for="edition">Edition</Field.Label> <Field.Label for="published_date">Published</Field.Label>
<Input {...updateBookMetadata.fields.edition.as('number')} /> <Input {...updateBookMetadata.fields.published_date.as('date')} />
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue} {#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error> <Field.Error>{issue.message}</Field.Error>
{/each} {/each}
</Field.Field> </Field.Field>
</div>
</Field.Group>
</Field.Set>
</Card.Content>
<!-- Submit button --> <Field.Field>
<Card.Footer class="flex-col gap-2 pt-6"> <Field.Label for="pages">Pages</Field.Label>
<Button type="submit" class="w-full">Save</Button> <Input {...updateBookMetadata.fields.pages.as('number')} />
</Card.Footer> {#each updateBookMetadata.fields.pages.issues() ?? [] as issue}
</form> <Field.Error>{issue.message}</Field.Error>
</Card.Root> {/each}
</Field.Field>
</div>
<Field.Field class="col-span-full">
<Field.Label for="identifiers">Identifiers</Field.Label>
<div class="flex flex-col gap-2">
{#each identifierKeys as _, idx}
<div class="grid grid-cols-[1fr_1.6fr_auto] gap-2">
<Input bind:value={identifierKeys[idx]} placeholder="ISBN, DOI…" />
<Input bind:value={identifierValues[idx]} placeholder="Value" />
<Button
type="button"
variant="outline"
size="icon"
title="Remove identifier"
onclick={() => handleRemoveIdentifier(idx)}
>
<Minus />
<span class="sr-only">Remove identifier</span>
</Button>
</div>
{/each}
<Button type="button" variant="outline" class="w-fit" onclick={handleAddIdentifier}>
<Plus />
Add identifier
</Button>
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
</div>
</Field.Field>
{@render groupHeading('Description')}
<Field.Field class="col-span-full">
<Field.Label for="description" class="sr-only">Description</Field.Label>
<Textarea {...updateBookMetadata.fields.description.as('text')} rows={6} />
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
<Field.Error>{issue.message}</Field.Error>
{/each}
</Field.Field>
</form>