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,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) {
await tick();
formEl?.requestSubmit();
}
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>
<form
{...uploadBookFiles.enhance(async ({ submit, form }) => {
try {
await submit();
<section class="flex min-w-0 flex-col gap-2">
<h3 class="font-mono text-[10px] tracking-widest text-muted-foreground uppercase">Files</h3>
// Check if there are any validation issues
const issues = uploadBookFiles.fields.allIssues();
if (issues && issues.length > 0) {
return;
}
{#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}
// Reset the files field
uploadBookFiles.fields.files.set([]);
toast.success('Files successfully added!');
} catch (error) {
console.error('Failed to upload files: ', error);
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)
]);
}}
<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"
>
<X />
</Button>
</div>
{/each}
</div>
{getFileType(file.filename)}
</span>
<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>
<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
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>