From d4bdb5ed42236b1821188d6e5e8c580988a541cf Mon Sep 17 00:00:00 2001 From: patrick Date: Wed, 12 Aug 2026 12:04:14 -0400 Subject: [PATCH] feat: accept files and folders when uploading books MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The picker could only choose folders: webkitdirectory switches the OS dialog into folder mode rather than filtering, so one input cannot offer both. Adds a drop zone with two browse buttons behind two inputs. Dropping a folder never worked either — dataTransfer.files does not descend into directories, so it arrived as one typeless entry and was rejected. Walks dataTransfer.items instead. Also collects skipped files into one expandable line rather than a toast each, which a folder of covers and notes made unusable, and shows the file count and total size before uploading. --- .../components/forms/book-drop-zone.svelte | 223 ++++++++++++++++++ .../lib/components/forms/books-upload.svelte | 99 ++++++-- 2 files changed, 302 insertions(+), 20 deletions(-) create mode 100644 frontend/src/lib/components/forms/book-drop-zone.svelte diff --git a/frontend/src/lib/components/forms/book-drop-zone.svelte b/frontend/src/lib/components/forms/book-drop-zone.svelte new file mode 100644 index 0000000..befa705 --- /dev/null +++ b/frontend/src/lib/components/forms/book-drop-zone.svelte @@ -0,0 +1,223 @@ + + +
{ + e.preventDefault(); + if (active) dragging = true; + }} + ondragleave={() => (dragging = false)} + ondrop={handleDrop} + class={cn( + 'flex flex-col items-center gap-3 rounded-lg border-2 border-dashed border-border bg-accent/20 p-6 text-center transition-colors', + dragging && 'border-primary bg-accent/50', + !active && 'opacity-50', + className + )} +> +
+ +
+ +
+ + {busy ? 'Reading folder…' : 'Drop books here'} + + A folder keeps its structure +
+ + or + +
+ + +
+ + + + +
diff --git a/frontend/src/lib/components/forms/books-upload.svelte b/frontend/src/lib/components/forms/books-upload.svelte index b68f935..14ba1ed 100644 --- a/frontend/src/lib/components/forms/books-upload.svelte +++ b/frontend/src/lib/components/forms/books-upload.svelte @@ -4,17 +4,14 @@ import * as Field from '$lib/components/ui/field/index.js'; import { Button } from '$lib/components/ui/button'; import * as NativeSelect from '$lib/components/ui/native-select/index.js'; - import { - displaySize, - FileDropZone, - type FileDropZoneProps - } from '$lib/components/ui/file-drop-zone'; + import { displaySize, type FileRejectedReason } from '$lib/components/ui/file-drop-zone'; + import BookDropZone from './book-drop-zone.svelte'; import { X } from '@lucide/svelte'; import { toast } from 'svelte-sonner'; import { Switch } from '$lib/components/ui/switch/index'; - import { tick } from 'svelte'; + import { tick, untrack } from 'svelte'; import { Spinner } from '$lib/components/ui/spinner/index'; import { getLibraryState } from '$lib/state/library.svelte'; import { uploadBooks } from '$lib/api'; @@ -29,14 +26,20 @@ uploadBooks.fields.library_id.set(libraryState.activeLibrary!.id); }); - let files = $derived(uploadBooks.fields.files.value() ?? []); + // Narrowed once here rather than asserted at each use in the markup. + let files = $derived( + (uploadBooks.fields.files.value() ?? []).filter((file): file is File => Boolean(file)) + ); + let totalSize = $derived(files.reduce((sum, file) => sum + file.size, 0)); let autoUploadOnDrop = $state(true); let navigateOnUpload = $state(true); let formEl = $state(); - const onUpload: FileDropZoneProps['onUpload'] = async (uploadedFiles) => { - // Rename files to use webkitRelativePath so directory structure is preserved through form submission + const onUpload = async (uploadedFiles: File[]) => { + // Rename files to use webkitRelativePath so directory structure is preserved + // through form submission. Dropped folders already arrive named that way, + // since the drop zone builds the path while walking them. const renamedFiles = uploadedFiles.map( (f) => new File([f], f.webkitRelativePath || f.name, { type: f.type }) ); @@ -47,10 +50,29 @@ } }; - const onFileRejected: FileDropZoneProps['onFileRejected'] = async ({ reason, file }) => { - toast.error(`${file.name} failed to upload!`, { description: reason }); + /** + * Collected rather than raised one at a time. A folder of a few hundred books + * carries covers and notes alongside them, and a toast per rejected file + * buries the screen. + */ + let rejected = $state<{ name: string; reason: FileRejectedReason }[]>([]); + let showRejected = $state(false); + + const onFileRejected = ({ reason, file }: { reason: FileRejectedReason; file: File }) => { + rejected = [...rejected, { name: file.webkitRelativePath || file.name, reason }]; }; + // Start each visit clean — a skipped-files list left over from last time reads + // as though it applies to what was just chosen. + $effect(() => { + if (open) { + untrack(() => { + rejected = []; + showRejected = false; + }); + } + }); + function navigateToBooks(books: PaginatedResponse) { open = false; let libraryId = books.items[0].library_id; @@ -59,7 +81,9 @@ goto(resolve('/(root)/(library)/book/[bookId]', { bookId: String(books.items[0].id) })); } else { // The path is resolved; the query string is what the rule cannot see past. - const view = resolve('/(root)/(library)/library/[libraryId]/view', { libraryId: String(libraryId) }); + const view = resolve('/(root)/(library)/library/[libraryId]/view', { + libraryId: String(libraryId) + }); // eslint-disable-next-line svelte/no-navigation-without-resolve goto(`${view}?orderBy=created_at&sortOrder=desc`); } @@ -81,7 +105,7 @@
{ + {...uploadBooks.enhance(async ({ submit }) => { try { await submit(); @@ -99,6 +123,7 @@ // Reset the files field uploadBooks.fields.files.set([]); + rejected = []; toast.success('Books successfully uploaded!'); if (navigateOnUpload) { @@ -116,31 +141,40 @@ Select Library - {#each libraryState.libraries as library} + {#each libraryState.libraries as library (library.id)} {library.name} {/each} - + + {#if files.length > 0} +
+ {files.length} ready to upload + + {displaySize(totalSize)} + +
+ {/if} +
- {#each files as file, idx} + {#each files as file, idx (file.name)}
-
- {file.name} +
+ {file.name} {displaySize(file.size)}
{/each}
+ {#if rejected.length > 0} +
+
+ + {rejected.length} + {rejected.length === 1 ? 'file was' : 'files were'} skipped + + +
+ {#if showRejected} +
    + {#each rejected as entry (entry.name)} +
  • + {entry.name} + {entry.reason} +
  • + {/each} +
+ {/if} +
+ {/if} +