Files
chitai/frontend/src/lib/components/forms/edit-book/edit-files.svelte
T
patrick b124a65d6e feat: report skipped duplicates in the upload tray
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.
2026-08-13 17:23:57 -04:00

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>