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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<typeof setTimeout> | undefined;
|
||||
#refreshTimer: ReturnType<typeof setTimeout> | 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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user