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);