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,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>
|
||||
|
||||
Reference in New Issue
Block a user