A book whose files were all already stored settles as skipped rather than done, naming the book that holds them and offering to add it anyway.
214 lines
7.1 KiB
Svelte
214 lines
7.1 KiB
Svelte
<script lang="ts">
|
|
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();
|
|
|
|
const bookOps = getBookOperationsState();
|
|
|
|
/**
|
|
* 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 (uploaded) => {
|
|
uploadBookFiles.fields.files.set([...Array.from(pending), ...uploaded]);
|
|
await tick();
|
|
formEl?.requestSubmit();
|
|
};
|
|
|
|
const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => {
|
|
toast.error(`${file.name} was not added`, { description: reason });
|
|
};
|
|
|
|
/**
|
|
* The API's own words, when it has any.
|
|
*
|
|
* Adding a file the library already holds under another book is refused with a
|
|
* 409 naming it — far more use than "failed to add files". SvelteKit hands an
|
|
* `error()` back as an HttpError on the client, so the message sits on `body`.
|
|
*/
|
|
function apiMessage(error: unknown): string | undefined {
|
|
if (typeof error !== 'object' || error === null) return undefined;
|
|
|
|
const body = (error as { body?: { message?: string } }).body;
|
|
if (typeof body?.message === 'string') return body.message;
|
|
|
|
return error instanceof Error ? error.message : undefined;
|
|
}
|
|
|
|
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
|
|
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(apiMessage(error) ?? 'Failed to add files');
|
|
uploadBookFiles.fields.files.set([]);
|
|
}
|
|
})}
|
|
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>
|