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:
@@ -1,18 +1,25 @@
|
||||
<script lang="ts">
|
||||
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 EditCover from './edit-cover.svelte';
|
||||
import EditFiles from './edit-files.svelte';
|
||||
import EditMetadata from './edit-metadata.svelte';
|
||||
|
||||
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>
|
||||
|
||||
<!--
|
||||
This dialog is mounted once, in (root)/(library)/+layout.svelte, and `book`
|
||||
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
|
||||
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.
|
||||
@@ -20,29 +27,41 @@
|
||||
<Dialog.Root bind:open>
|
||||
{#if book}
|
||||
{#key book.id}
|
||||
<Dialog.Content class="sm:max-w-xl">
|
||||
<Tabs.Root value="metadata" class="h-[500px] max-w-xl py-4 md:h-[700px]">
|
||||
<Tabs.List class="grid w-full grid-cols-3">
|
||||
<Tabs.Trigger value="metadata">Metadata</Tabs.Trigger>
|
||||
<Tabs.Trigger value="cover">Cover</Tabs.Trigger>
|
||||
<Tabs.Trigger value="files">Files</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Dialog.Content
|
||||
class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
|
||||
>
|
||||
<Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
|
||||
<Dialog.Title class="truncate font-serif text-base font-normal">{book.title}</Dialog.Title
|
||||
>
|
||||
<Dialog.Description class="truncate text-xs">
|
||||
{book.authors.map((author) => author.name).join(', ') || 'Unknown author'}
|
||||
</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
|
||||
<!-- Metadata form -->
|
||||
<Tabs.Content value="metadata" class="h-full overflow-y-auto pb-1">
|
||||
<EditMetadata {book} {open} />
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Cover form -->
|
||||
<Tabs.Content value="cover">
|
||||
<EditCover {book} {open} />
|
||||
</Tabs.Content>
|
||||
|
||||
<!-- Add files form -->
|
||||
<Tabs.Content value="files">
|
||||
<!-- Rail and form scroll independently so the footer never moves -->
|
||||
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[16rem_1fr]">
|
||||
<aside
|
||||
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} />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</aside>
|
||||
|
||||
<div class="min-h-0 overflow-y-auto p-5">
|
||||
<EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
|
||||
<!-- Cover and file changes hit the server as they happen, while the
|
||||
fields wait for Save. Saying so is the cheapest way to stop
|
||||
Cancel reading as "undo everything". -->
|
||||
<p class="text-xs text-muted-foreground">Cover and file changes apply immediately</p>
|
||||
<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}
|
||||
{/if}
|
||||
|
||||
@@ -1,34 +1,23 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
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 { tick, untrack } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import { updateBookCover } from '$lib/api';
|
||||
import type { Book } from '$lib/schema';
|
||||
import { tick, untrack } from 'svelte';
|
||||
import { X } from '@lucide/svelte';
|
||||
import BookImage from '$lib/components/view/book-image.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>();
|
||||
// Seeded once per mount — see the key in edit-book.svelte
|
||||
let coverImagePreview = $state(untrack(() => `/api/${book.cover_image}`));
|
||||
let autoUploadOnDrop = $state(true);
|
||||
let coverImagePreview = $state<string>(untrack(() => `/api/${book.cover_image}`));
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
updateBookCover.fields.file.set(uploadedFiles[0]);
|
||||
updateCoverPreview();
|
||||
if (autoUploadOnDrop && updateBookCover.fields.file.value()) {
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
function updateCoverPreview() {
|
||||
@@ -36,14 +25,14 @@
|
||||
if (file && file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
coverImagePreview = reader.result;
|
||||
coverImagePreview = reader.result as string;
|
||||
};
|
||||
reader.readAsDataURL(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(() => {
|
||||
@@ -54,65 +43,40 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<section class="flex min-w-0 flex-col gap-2">
|
||||
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Cover</h3>
|
||||
|
||||
<BookImage src={coverImagePreview} class="w-full rounded-md border" />
|
||||
|
||||
<form
|
||||
bind:this={formEl}
|
||||
{...updateBookCover.enhance(async ({ submit, form }) => {
|
||||
try {
|
||||
await submit();
|
||||
form.reset();
|
||||
open = false;
|
||||
toast.success('Updated book cover!');
|
||||
// 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 cover.');
|
||||
toast.error('Failed to update the cover');
|
||||
}
|
||||
})}
|
||||
enctype="multipart/form-data"
|
||||
class="grid grid-cols-[1fr_2fr] gap-4 p-6"
|
||||
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">
|
||||
<BookImage src={coverImagePreview} class="w-64 rounded" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<FileDropZone
|
||||
{onUpload}
|
||||
{onFileRejected}
|
||||
accept=".jpeg,.jpg,.png,.webp,image/*"
|
||||
label="Only JPEG, PNG, and WEBP images supported"
|
||||
label="Replace cover"
|
||||
sublabel="JPEG, PNG or WEBP"
|
||||
maxFiles={1}
|
||||
fileCount={updateBookCover.fields.file.value() ? 1 : 0}
|
||||
/>
|
||||
<input class="hidden" {...updateBookCover.fields.file.as('file')} />
|
||||
<div class="flex flex-col gap-2">
|
||||
{#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>
|
||||
</section>
|
||||
|
||||
@@ -1,98 +1,196 @@
|
||||
<script lang="ts">
|
||||
import { uploadBookFiles } from '$lib/api';
|
||||
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 { tick, untrack } from 'svelte';
|
||||
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 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>();
|
||||
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => {
|
||||
uploadBookFiles.fields.files.set([...Array.from(files), ...uploadedFiles]);
|
||||
if (autoUploadOnDrop && files.length > 0) {
|
||||
const onUpload: FileDropZoneProps['onUpload'] = async (uploaded) => {
|
||||
uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
|
||||
await tick();
|
||||
formEl?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<section class="flex min-w-0 flex-col gap-2">
|
||||
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
|
||||
|
||||
{#if files.length === 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
No files yet. Add one below so this book can be read or downloaded.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<ul class="flex flex-col gap-1.5">
|
||||
{#each files as file (file.id)}
|
||||
<li class="flex items-center gap-2 rounded-md border bg-background p-2">
|
||||
<span
|
||||
class="rounded-sm bg-accent px-1.5 py-0.5 font-mono text-[9px] font-semibold text-accent-foreground"
|
||||
>
|
||||
{getFileType(file.filename)}
|
||||
</span>
|
||||
|
||||
<span class="min-w-0 flex-1">
|
||||
<span class="block truncate text-xs" title={file.filename}>{file.filename}</span>
|
||||
<span class="block font-mono text-[10px] text-muted-foreground tabular-nums">
|
||||
{formatFileSize(file.size)}
|
||||
</span>
|
||||
</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
|
||||
{...uploadBookFiles.enhance(async ({ submit, form }) => {
|
||||
bind:this={formEl}
|
||||
{...uploadBookFiles.enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
// Check if there are any validation issues
|
||||
const issues = uploadBookFiles.fields.allIssues();
|
||||
if (issues && issues.length > 0) {
|
||||
return;
|
||||
}
|
||||
if (issues && issues.length > 0) return;
|
||||
|
||||
// Reset the files field
|
||||
// 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 successfully added!');
|
||||
toast.success('Files added');
|
||||
} catch (error) {
|
||||
console.error('Failed to upload files: ', error);
|
||||
toast.error('Failed to upload files');
|
||||
console.error('Failed to add files: ', error);
|
||||
toast.error('Failed to add files');
|
||||
}
|
||||
})}
|
||||
bind:this={formEl}
|
||||
enctype="multipart/form-data"
|
||||
class="flex w-full flex-col gap-2 p-4"
|
||||
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"
|
||||
sublabel="Only PDF, EPUB, and MOBI files supported"
|
||||
label="Add a file"
|
||||
sublabel="EPUB, PDF or MOBI"
|
||||
/>
|
||||
<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 />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</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>
|
||||
|
||||
<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">Upload</Button>
|
||||
</div>
|
||||
</form>
|
||||
<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">
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
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 { untrack } from 'svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { Minus, Plus } from '@lucide/svelte';
|
||||
|
||||
import { updateBookMetadata } from '$lib/api';
|
||||
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
|
||||
// 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)));
|
||||
|
||||
function handleAddIdentifier() {
|
||||
// Add empty strings to both arrays
|
||||
identifierKeys = [...identifierKeys, ''];
|
||||
identifierValues = [...identifierValues, ''];
|
||||
}
|
||||
@@ -88,9 +92,17 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card.Root class="w-full ">
|
||||
{#snippet groupHeading(label: string)}
|
||||
<h3
|
||||
class="col-span-full mt-2 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
|
||||
>
|
||||
{label}
|
||||
</h3>
|
||||
{/snippet}
|
||||
|
||||
<form
|
||||
{...updateBookMetadata.enhance(async ({ submit, form }) => {
|
||||
id={formId}
|
||||
{...updateBookMetadata.enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
|
||||
@@ -101,28 +113,19 @@
|
||||
}
|
||||
|
||||
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.');
|
||||
}
|
||||
})}
|
||||
class="grid grid-cols-1 items-start gap-x-4 gap-y-3 sm:grid-cols-2"
|
||||
>
|
||||
<Card.Content>
|
||||
<Field.Set>
|
||||
<Field.Group class="flex flex-col">
|
||||
<!-- 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>
|
||||
<input class="hidden" {...updateBookMetadata.fields.book_id.as('text')} />
|
||||
|
||||
<!-- Title field -->
|
||||
<Field.Field>
|
||||
{@render groupHeading('Identity')}
|
||||
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="title">Title</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.title.as('text')} />
|
||||
{#each updateBookMetadata.fields.title.issues() ?? [] as issue}
|
||||
@@ -130,7 +133,6 @@
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Subtitle field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="subtitle">Subtitle</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.subtitle.as('text')} />
|
||||
@@ -139,8 +141,14 @@
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<div class="grid grid-cols-[3fr_1fr] gap-2">
|
||||
<!-- Series field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="edition">Edition</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
||||
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="series">Series</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.series.as('text')} />
|
||||
@@ -149,18 +157,27 @@
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Series position field -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Field.Field>
|
||||
<Field.Label for="series_position">Series position</Field.Label>
|
||||
<Field.Label for="series_position">No.</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>
|
||||
|
||||
<Field.Field>
|
||||
<Field.Label for="language">Language</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.language.as('text')} />
|
||||
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
|
||||
<!-- Authors field -->
|
||||
<Field.Field>
|
||||
{@render groupHeading('People and subjects')}
|
||||
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="authors">Authors</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={authors}
|
||||
@@ -176,8 +193,7 @@
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Tags field -->
|
||||
<Field.Field>
|
||||
<Field.Field class="col-span-full">
|
||||
<Field.Label for="tags">Tags</Field.Label>
|
||||
<TagsInput
|
||||
bind:value={tags}
|
||||
@@ -193,39 +209,8 @@
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Description field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="description">Description</Field.Label>
|
||||
<Textarea {...updateBookMetadata.fields.description.as('text')} />
|
||||
{#each updateBookMetadata.fields.description.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
{@render groupHeading('Publication')}
|
||||
|
||||
<!-- Identifier fields -->
|
||||
<Field.Field>
|
||||
<Field.Label for="identifiers">Identifiers</Field.Label>
|
||||
<div class="grid grid-cols-[5fr_10fr_0.5fr] gap-2">
|
||||
{#each identifierKeys as _, idx}
|
||||
<Input bind:value={identifierKeys[idx]} placeholder="Identifier..." />
|
||||
<Input bind:value={identifierValues[idx]} placeholder="Value..." />
|
||||
|
||||
<Button variant="outline" size="icon" onclick={() => handleRemoveIdentifier(idx)}>
|
||||
<Minus />
|
||||
</Button>
|
||||
{/each}
|
||||
|
||||
<Button variant="outline" onclick={() => handleAddIdentifier()}>
|
||||
<Plus />
|
||||
Add Identifier
|
||||
</Button>
|
||||
|
||||
<input {...updateBookMetadata.fields.identifiers.as('text')} class="hidden" />
|
||||
</div>
|
||||
</Field.Field>
|
||||
|
||||
<!-- Publisher field -->
|
||||
<div class="grid grid-cols-[2fr_1fr] gap-2">
|
||||
<Field.Field>
|
||||
<Field.Label for="publisher">Publisher</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.publisher.as('text')} />
|
||||
@@ -234,18 +219,15 @@
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Published date field -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Field.Field>
|
||||
<Field.Label for="published_date">Date published</Field.Label>
|
||||
<Field.Label for="published_date">Published</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.published_date.as('date')} />
|
||||
{#each updateBookMetadata.fields.published_date.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-[2fr_1fr_1fr] gap-2">
|
||||
<!-- Pages field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="pages">Pages</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.pages.as('number')} />
|
||||
@@ -253,32 +235,44 @@
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Language field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="language">Language</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.language.as('text')} />
|
||||
{#each updateBookMetadata.fields.language.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
|
||||
<!-- Edition field -->
|
||||
<Field.Field>
|
||||
<Field.Label for="edition">Edition</Field.Label>
|
||||
<Input {...updateBookMetadata.fields.edition.as('number')} />
|
||||
{#each updateBookMetadata.fields.edition.issues() ?? [] as issue}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
</Field.Group>
|
||||
</Field.Set>
|
||||
</Card.Content>
|
||||
|
||||
<!-- Submit button -->
|
||||
<Card.Footer class="flex-col gap-2 pt-6">
|
||||
<Button type="submit" class="w-full">Save</Button>
|
||||
</Card.Footer>
|
||||
<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>
|
||||
</Card.Root>
|
||||
|
||||
Reference in New Issue
Block a user