From 85367daf7e7900a400865b7a35c71afdee7f6b22 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 17 Aug 2026 11:21:49 -0400 Subject: [PATCH] feat: show books as an import adds them, not once it ends Refresh the list every couple of seconds while the queue runs, holding off while the reader has scrolled past the first page. --- .../src/lib/state/bookCollection.svelte.ts | 11 +++++ frontend/src/lib/state/upload-queue.svelte.ts | 43 +++++++++++++++++++ .../library/[libraryId]/view/+page.svelte | 11 +++++ 3 files changed, 65 insertions(+) diff --git a/frontend/src/lib/state/bookCollection.svelte.ts b/frontend/src/lib/state/bookCollection.svelte.ts index cc39799..5fd0e6e 100644 --- a/frontend/src/lib/state/bookCollection.svelte.ts +++ b/frontend/src/lib/state/bookCollection.svelte.ts @@ -29,6 +29,12 @@ export class BookCollectionState { private currentBookPage = $state(1); public loading = $state(false); + /** + * Whether the reader has loaded past the first page. Anything that refetches + * gets page one back, which is a jump to the top from here. + */ + readonly pastFirstPage = $derived(this.currentBookPage > 1); + public filterOptions: FilterOption[] = $state([]); private ops: BookOperationsState; @@ -187,6 +193,11 @@ export class BookCollectionState { this.moreBooks = books.total > books.items.length; this.filterOptions = this.buildFilterOptions(filterData); + // The loader hands back the first page, so the counter has to say so — + // otherwise loadMoreBooks resumes from wherever the reader had scrolled to + // and skips every page in between. + this.currentBookPage = 1; + const urlParams = new URLSearchParams(window.location.search); // Re-read sort and filter state from URL diff --git a/frontend/src/lib/state/upload-queue.svelte.ts b/frontend/src/lib/state/upload-queue.svelte.ts index 6c394f3..225f0f8 100644 --- a/frontend/src/lib/state/upload-queue.svelte.ts +++ b/frontend/src/lib/state/upload-queue.svelte.ts @@ -68,6 +68,9 @@ function warnBeforeUnload(event: BeforeUnloadEvent) { /** How long a clean run stays on screen before clearing itself. */ const DISMISS_AFTER_MS = 6000; +/** How often a run that is still going refreshes the list behind it. */ +const REFRESH_EVERY_MS = 2000; + /** * Uploads books one request at a time and keeps the result on screen. * @@ -108,8 +111,15 @@ export class UploadQueueState { ) ); + /** + * Set by a list that has loaded past its first page, to keep a mid-run refresh + * from throwing the reader back to the top. See #scheduleRefresh. + */ + holdRefresh = false; + #running = false; #dismissTimer: ReturnType | undefined; + #refreshTimer: ReturnType | undefined; #held = false; /** Adds one job per book and starts the runner if it is not already going. */ @@ -177,6 +187,31 @@ export class UploadQueueState { }, DISMISS_AFTER_MS); } + /** + * Shows the books added so far, without waiting for the rest of the run. + * + * A refetch rather than inserting the created book into the list by hand: the + * server owns the sort, the filters and the paging, so asking it again is what + * makes a part-finished import look the same as a finished one. + * + * Trailing, and at most one refresh per REFRESH_EVERY_MS — a folder of small + * books lands faster than the list can usefully redraw. + */ + #scheduleRefresh() { + if (this.#refreshTimer) return; + + this.#refreshTimer = setTimeout(() => { + this.#refreshTimer = undefined; + + // The loaders only ever return the first page, so refreshing under a + // reader who has scrolled past it would drop them back to the top. Their + // list catches up when the run ends. + if (this.holdRefresh) return; + + void invalidate('app:books'); + }, REFRESH_EVERY_MS); + } + /** Re-queues everything that failed, so one bad book is not a reason to start over. */ retryFailed() { this.jobs = this.jobs.map((job) => @@ -229,6 +264,8 @@ export class UploadQueueState { // One job is one book, so it has at most one set of candidates. possibleDuplicates: result.possible_duplicates?.[0]?.candidates }); + + if (result.created.length > 0) this.#scheduleRefresh(); } catch (error) { // One bad book must not take the rest of the queue with it. console.error(`Failed to upload ${this.jobs[index].label}`, error); @@ -243,6 +280,12 @@ export class UploadQueueState { window.removeEventListener('beforeunload', warnBeforeUnload); } + // The run is over, so the refresh below is the one that counts — a pending + // mid-run one would only repeat it a moment later, and it is the one that + // honours holdRefresh. + clearTimeout(this.#refreshTimer); + this.#refreshTimer = undefined; + if (created > 0) await invalidate('app:books'); if (!this.needsAttention) this.#scheduleDismiss(); diff --git a/frontend/src/routes/(root)/(library)/library/[libraryId]/view/+page.svelte b/frontend/src/routes/(root)/(library)/library/[libraryId]/view/+page.svelte index c2a28ab..a54a1e6 100644 --- a/frontend/src/routes/(root)/(library)/library/[libraryId]/view/+page.svelte +++ b/frontend/src/routes/(root)/(library)/library/[libraryId]/view/+page.svelte @@ -5,6 +5,7 @@ import type { PaginatedResponse } from '$lib/schema/common'; import { setBookCollectionState } from '$lib/state/bookCollection.svelte'; import { getBookOperationsState } from '$lib/state/bookOperations.svelte'; + import { getUploadQueueState } from '$lib/state/upload-queue.svelte'; import { untrack } from 'svelte'; let { @@ -23,6 +24,16 @@ const bookOps = getBookOperationsState(); const bookCollection = setBookCollectionState(bookOps, books, filterData); + const queue = getUploadQueueState(); + + // An upload refreshes this list every couple of seconds so books show up as + // they land. That refresh returns the first page, so it waits while the reader + // is reading further down. Cleared on the way out: this list is gone, and a + // stale hold would stop the next one refreshing at all. + $effect(() => { + queue.holdRefresh = bookCollection.pastFirstPage; + return () => (queue.holdRefresh = false); + }); let skip = $state(true);