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">
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,30 +27,42 @@
<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>
<!-- 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} />
</aside>
<!-- Cover form -->
<Tabs.Content value="cover">
<EditCover {book} {open} />
</Tabs.Content>
<div class="min-h-0 overflow-y-auto p-5">
<EditMetadata {book} bind:open formId={METADATA_FORM_ID} />
</div>
</div>
<!-- Add files form -->
<Tabs.Content value="files">
<EditFiles {book} />
</Tabs.Content>
</Tabs.Root>
</Dialog.Content>
<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}
</Dialog.Root>