diff --git a/client_v2/src/lib/api/externalSearch.svelte.ts b/client_v2/src/lib/api/externalSearch.svelte.ts new file mode 100644 index 00000000..cf99676c --- /dev/null +++ b/client_v2/src/lib/api/externalSearch.svelte.ts @@ -0,0 +1,94 @@ +import { spoolSource } from './spoolSource'; +import type { ExternalFilament } from './external'; + +/** Rows per request. Enough to overflow a picker's result list, so there is something to scroll. */ +const PAGE = 20; +/** Distance from the bottom of the result list at which the next page is fetched. */ +const SCROLL_MARGIN = 120; + +/** + * The external-catalog half of a filament picker's search (#1136): `search` loads the + * first page of matches, and scrolling the result list towards its end appends the next. + * Shared by the add-spools and change-filament dialogs. + */ +export class ExternalSearch { + items = $state([]); + error = $state(false); + loadingMore = $state(false); + #total = 0; + #query = ''; + #loading = false; + // Bumped by every new search, so a page that lands after the query changed is dropped. + #req = 0; + #ctrl: AbortController | undefined; + // The result list, remembered from its first scroll event. + #list: HTMLElement | undefined; + + async search(query: string): Promise { + const req = this.#restart(); + this.#query = query.trim(); + this.#loading = true; + this.error = false; + try { + const page = await spoolSource.searchExternalFilaments(this.#query, PAGE, 0, this.#ctrl!.signal); + if (req !== this.#req) return; + this.items = page.items; + this.#total = page.total; + // A new query starts at the top: left scrolled down, the list would fetch + // its second page straight away. + this.#list?.scrollTo(0, 0); + } catch { + if (req !== this.#req) return; + this.items = []; + this.#total = 0; + this.error = true; + } finally { + if (req === this.#req) this.#loading = false; + } + } + + async more(): Promise { + if (this.#loading || this.loadingMore || this.error || this.items.length >= this.#total) return; + const req = this.#req; + this.loadingMore = true; + try { + const page = await spoolSource.searchExternalFilaments( + this.#query, + PAGE, + this.items.length, + this.#ctrl!.signal + ); + if (req !== this.#req) return; + // A catalog sync between two pages shifts the offsets and can hand back a row + // that is already listed; a repeated id would break the keyed list. + const fresh = page.items.filter((f) => !this.items.some((e) => e.id === f.id)); + this.items = [...this.items, ...fresh]; + this.#total = page.total; + } catch (e) { + if (req === this.#req) console.error('Failed to load more external filaments', e); + } finally { + if (req === this.#req) this.loadingMore = false; + } + } + + /** Forget the results, dropping any request still in flight. */ + clear(): void { + this.#restart(); + this.items = []; + this.#total = 0; + } + + /** Scroll handler for the result list. An arrow function so it can be passed as-is. */ + onscroll = (e: Event) => { + const el = (this.#list = e.currentTarget as HTMLElement); + if (el.scrollTop + el.clientHeight >= el.scrollHeight - SCROLL_MARGIN) void this.more(); + }; + + #restart(): number { + this.#ctrl?.abort(); + this.#ctrl = new AbortController(); + this.#loading = false; + this.loadingMore = false; + return ++this.#req; + } +} diff --git a/client_v2/src/lib/api/spoolSource.ts b/client_v2/src/lib/api/spoolSource.ts index 946f6b07..02e146b3 100644 --- a/client_v2/src/lib/api/spoolSource.ts +++ b/client_v2/src/lib/api/spoolSource.ts @@ -234,12 +234,18 @@ class HttpSpoolSource { return filaments.map((m) => m.entity).slice(0, limit); } - async searchExternalFilaments(query: string, limit = 8): Promise { + async searchExternalFilaments( + query: string, + limit: number, + offset = 0, + signal?: AbortSignal + ): Promise> { // Filtering happens server-side (/external/filament/search) so the whole catalog // — thousands of entries — never has to be downloaded to the client. const q = query.trim(); - if (!q) return []; - return getJson('/external/filament/search', { query: q, limit }); + if (!q) return { items: [], total: 0 }; + const { items, total } = await getList('/external/filament/search', { query: q, limit, offset }, signal); + return { items: items as ExternalFilament[], total }; } /** diff --git a/client_v2/src/lib/components/AddSpoolModal.svelte b/client_v2/src/lib/components/AddSpoolModal.svelte index 71f9bfcc..6487bd9d 100644 --- a/client_v2/src/lib/components/AddSpoolModal.svelte +++ b/client_v2/src/lib/components/AddSpoolModal.svelte @@ -17,6 +17,7 @@ import { fields } from '$lib/stores/fields.svelte'; import type { EntityType } from '$lib/api/fields'; import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external'; + import { ExternalSearch } from '$lib/api/externalSearch.svelte'; import { roundGrams, weightAuto } from '$lib/utils/format'; import { numErr } from '$lib/utils/validate'; import { @@ -49,9 +50,8 @@ let query = $state(''); let searchInput = $state(); let localResults = $state([]); - let externalResults = $state([]); + const extSearch = new ExternalSearch(); let searching = $state(false); - let extError = $state(false); let chosen = $state(null); let creating = $state(false); let submitting = $state(false); @@ -123,17 +123,11 @@ } async function runSearch() { searching = true; - extError = false; - const [local, external] = await Promise.allSettled([ + const [local] = await Promise.allSettled([ spoolSource.searchFilaments(query.trim()), - spoolSource.searchExternalFilaments(query.trim()) + extSearch.search(query) ]); localResults = local.status === 'fulfilled' ? local.value : []; - if (external.status === 'fulfilled') externalResults = external.value; - else { - externalResults = []; - extError = true; - } searching = false; } @@ -339,7 +333,7 @@ step = 1; query = ''; localResults = []; - externalResults = []; + extSearch.clear(); chosen = null; creating = false; cloneSource = null; @@ -536,7 +530,7 @@ oninput={(e) => onSearch(e.currentTarget.value)} placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })} /> -
+
{m['add.yourCatalog']()}
{#if searching && localResults.length === 0}
{m['add.searching']()}
@@ -562,16 +556,16 @@ {/if}
{serverInfo.externalDbName}
- {#if extError} + {#if extSearch.error}
{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}
- {:else if searching && externalResults.length === 0} + {:else if searching && extSearch.items.length === 0}
{m['add.searching']()}
- {:else if externalResults.length === 0} + {:else if extSearch.items.length === 0}
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
{:else} - {#each externalResults as ext (ext.id)} + {#each extSearch.items as ext (ext.id)} {/each} + {#if extSearch.loadingMore} +
{m['add.searching']()}
+ {/if} {/if}
diff --git a/client_v2/src/lib/components/library/ChangeFilamentModal.svelte b/client_v2/src/lib/components/library/ChangeFilamentModal.svelte index 23bbcbe0..0330ff05 100644 --- a/client_v2/src/lib/components/library/ChangeFilamentModal.svelte +++ b/client_v2/src/lib/components/library/ChangeFilamentModal.svelte @@ -20,6 +20,7 @@ import { spoolSource } from '$lib/api/spoolSource'; import { fields } from '$lib/stores/fields.svelte'; import { externalColors, externalDirection, type ExternalFilament } from '$lib/api/external'; + import { ExternalSearch } from '$lib/api/externalSearch.svelte'; import { toasts } from '$lib/stores/toasts.svelte'; import { weightAuto } from '$lib/utils/format'; import { parseDecimal } from '$lib/utils/numeric'; @@ -53,9 +54,8 @@ let query = $state(''); let searchInput = $state(); let localResults = $state([]); - let externalResults = $state([]); + const extSearch = new ExternalSearch(); let searching = $state(false); - let extError = $state(false); let chosen = $state(null); let busy = $state(false); @@ -187,17 +187,11 @@ } async function runSearch() { searching = true; - extError = false; - const [local, external] = await Promise.allSettled([ + const [local] = await Promise.allSettled([ spoolSource.searchFilaments(query.trim()), - spoolSource.searchExternalFilaments(query.trim()) + extSearch.search(query) ]); localResults = local.status === 'fulfilled' ? local.value : []; - if (external.status === 'fulfilled') externalResults = external.value; - else { - externalResults = []; - extError = true; - } searching = false; } @@ -434,7 +428,7 @@ placeholder={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })} aria-label={m['add.searchPlaceholder']({ name: serverInfo.externalDbName })} /> -
+
{m['add.yourCatalog']()}
{#if searching && localResults.length === 0}
{m['add.searching']()}
@@ -465,16 +459,16 @@ {/if}
{serverInfo.externalDbName}
- {#if extError} + {#if extSearch.error}
{m['add.dbUnavailable']({ name: serverInfo.externalDbName })}
- {:else if searching && externalResults.length === 0} + {:else if searching && extSearch.items.length === 0}
{m['add.searching']()}
- {:else if externalResults.length === 0} + {:else if extSearch.items.length === 0}
{query.trim() ? m['add.typeToSearchMatches']() : m['add.typeToSearchAll']()}
{:else} - {#each externalResults as ext (ext.id)} + {#each extSearch.items as ext (ext.id)} {/each} + {#if extSearch.loadingMore} +
{m['add.searching']()}
+ {/if} {/if}