This commit is contained in:
MatejGhub
2026-09-19 23:39:01 +02:00
parent de9a39d1f1
commit 9b4457d96e
15 changed files with 300 additions and 107 deletions
+79 -14
View File
@@ -4,52 +4,84 @@
/** /**
* Fixed-row virtual window for long scroll lists (Svelte 5). * Fixed-row virtual window for long scroll lists (Svelte 5).
* Prefer with server pagination — only mounts visible rows + overscan. * Prefer with server pagination — only mounts visible rows + overscan.
* Use table-layout/fixed column widths in the row markup to avoid scroll jitter. * Short lists render naturally (no scrollport / spacer) so a single row
* does not leave a scrollable empty area.
*/ */
let { let {
items, items,
estimateSize = 80, estimateSize = 80,
overscan = 8, overscan = 8,
maxHeight = 640,
class: className = "", class: className = "",
getKey, getKey,
onNearEnd,
loadingMore = false,
children children
}: { }: {
items: T[]; items: T[];
estimateSize?: number; estimateSize?: number;
overscan?: number; overscan?: number;
/** Cap for the scrollport; below this, list is not scrollable. */
maxHeight?: number;
class?: string; class?: string;
getKey?: (item: T, index: number) => string | number; getKey?: (item: T, index: number) => string | number;
/** Fired when the user scrolls near the bottom (infinite scroll). */
onNearEnd?: () => void;
loadingMore?: boolean;
children: Snippet<[T, number]>; children: Snippet<[T, number]>;
} = $props(); } = $props();
let scrollEl = $state<HTMLDivElement | null>(null); let scrollEl = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0); let scrollTop = $state(0);
let viewportHeight = $state(0); let viewportHeight = $state(0);
let nearEndArmed = $state(true);
const rowHeight = $derived(Math.max(1, Math.floor(estimateSize) || 80)); const rowHeight = $derived(Math.max(1, Math.floor(estimateSize) || 80));
const totalHeight = $derived(items.length * rowHeight); const loaderHeight = $derived(loadingMore ? 40 : 0);
const totalHeight = $derived(items.length * rowHeight + loaderHeight);
const cap = $derived(Math.max(rowHeight, Math.floor(maxHeight) || 640));
const scrollable = $derived(totalHeight > cap);
const startIndex = $derived( const startIndex = $derived(
Math.max(0, Math.floor(scrollTop / rowHeight) - Math.max(0, overscan)) scrollable
? Math.max(0, Math.floor(scrollTop / rowHeight) - Math.max(0, overscan))
: 0
); );
const endIndex = $derived( const endIndex = $derived(
Math.min( scrollable
? Math.min(
items.length, items.length,
Math.ceil((scrollTop + Math.max(viewportHeight, rowHeight)) / rowHeight) + Math.ceil((scrollTop + Math.max(viewportHeight, rowHeight)) / rowHeight) +
Math.max(0, overscan) Math.max(0, overscan)
) )
: items.length
); );
const visible = $derived(items.slice(startIndex, endIndex)); const visible = $derived(items.slice(startIndex, endIndex));
const offsetY = $derived(startIndex * rowHeight); const offsetY = $derived(startIndex * rowHeight);
function maybeNearEnd() {
if (!onNearEnd || !nearEndArmed || loadingMore || !scrollable || items.length === 0) return;
const threshold = rowHeight * 4;
if (scrollTop + viewportHeight >= totalHeight - threshold) {
nearEndArmed = false;
onNearEnd();
}
}
function onScroll() { function onScroll() {
if (scrollEl) scrollTop = scrollEl.scrollTop; if (!scrollable || !scrollEl) return;
scrollTop = scrollEl.scrollTop;
maybeNearEnd();
} }
$effect(() => { $effect(() => {
const el = scrollEl; const el = scrollEl;
if (!el) return; if (!el || !scrollable) {
viewportHeight = 0;
return;
}
const update = () => { const update = () => {
viewportHeight = el.clientHeight; viewportHeight = el.clientHeight;
maybeNearEnd();
}; };
update(); update();
const ro = new ResizeObserver(update); const ro = new ResizeObserver(update);
@@ -57,32 +89,39 @@
return () => ro.disconnect(); return () => ro.disconnect();
}); });
// Reset scroll only when the list head changes (new filter/page), not on append.
let prevHead: T | undefined = undefined;
$effect(() => { $effect(() => {
// New page / filter result — restart at the top of the window.
const first = items[0]; const first = items[0];
const headKey = if (first !== prevHead) {
first !== undefined && getKey ? String(getKey(first, 0)) : String(items.length); prevHead = first;
void headKey; if (scrollEl && scrollable) {
if (scrollEl) {
scrollEl.scrollTop = 0; scrollEl.scrollTop = 0;
scrollTop = 0; scrollTop = 0;
} }
nearEndArmed = true;
}
}); });
$effect(() => { $effect(() => {
// Reset scroll window when the item set shrinks below the prior offset.
void items.length; void items.length;
if (scrollEl && scrollEl.scrollTop > totalHeight) { void loadingMore;
void scrollable;
nearEndArmed = true;
if (scrollable && scrollEl && totalHeight > 0 && scrollEl.scrollTop > totalHeight) {
scrollEl.scrollTop = Math.max(0, totalHeight - viewportHeight); scrollEl.scrollTop = Math.max(0, totalHeight - viewportHeight);
scrollTop = scrollEl.scrollTop; scrollTop = scrollEl.scrollTop;
} }
if (!scrollable) scrollTop = 0;
queueMicrotask(() => maybeNearEnd());
}); });
</script> </script>
{#if scrollable}
<div <div
bind:this={scrollEl} bind:this={scrollEl}
class={className} class={className}
style="overflow-y: auto;" style="max-height: {cap}px; overflow-y: auto;"
onscroll={onScroll} onscroll={onScroll}
data-virtual-list data-virtual-list
> >
@@ -92,5 +131,31 @@
{@render children(item, startIndex + i)} {@render children(item, startIndex + i)}
{/each} {/each}
</div> </div>
{#if loadingMore}
<div
class="absolute left-0 right-0 flex items-center justify-center"
style="top: {items.length * rowHeight}px; height: {loaderHeight}px;"
role="status"
aria-busy="true"
>
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent text-muted-foreground"
></span>
</div>
{/if}
</div> </div>
</div> </div>
{:else}
<div class={className} data-virtual-list data-virtual-compact>
{#each items as item, i (getKey ? getKey(item, i) : i)}
{@render children(item, i)}
{/each}
{#if loadingMore}
<div class="flex h-10 items-center justify-center" role="status" aria-busy="true">
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent text-muted-foreground"
></span>
</div>
{/if}
</div>
{/if}
@@ -39,6 +39,11 @@
}); });
}); });
const canGoNext = $derived(
currentPage < totalPages &&
(!sequentialOnly || Boolean(sequentialNextQuery(nextCursor, nextAfterId)))
);
const pages = $derived.by(() => { const pages = $derived.by(() => {
const out: (number | "…")[] = []; const out: (number | "…")[] = [];
if (totalPages <= 7) { if (totalPages <= 7) {
@@ -106,7 +111,7 @@
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={currentPage >= totalPages} disabled={!canGoNext}
onclick={goNext} onclick={goNext}
> >
{i18n.t("catalog.next")} {i18n.t("catalog.next")}
@@ -46,19 +46,23 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
onInlineRename, onInlineRename,
onInlineCategory, onInlineCategory,
isLoading = false, isLoading = false,
activeTab = "processed" activeTab = "processed",
loadingMore = false,
onNearEnd
}: { }: {
products: ProductRow[]; products: ProductRow[];
kind: "processed" | "raw"; kind: "processed" | "raw";
selectedIds: Array<string | number>; selectedIds: Array<string | number>;
onSelectProduct: (id: string | number, selected: boolean) => void; onSelectProduct: (id: string | number, selected: boolean, shiftKey?: boolean) => void;
onSelectAll: () => void; onSelectAll: (selected: boolean) => void;
categoryOptions?: CategoryOption[]; categoryOptions?: CategoryOption[];
onEditProduct: (product: ProductRow) => void; onEditProduct: (product: ProductRow) => void;
onInlineRename?: (product: ProductRow, name: string) => Promise<void>; onInlineRename?: (product: ProductRow, name: string) => Promise<void> | void;
onInlineCategory?: (product: ProductRow, categoryUniqueId: string) => Promise<void>; onInlineCategory?: (product: ProductRow, categoryUniqueId: string) => Promise<void> | void;
isLoading?: boolean; isLoading?: boolean;
activeTab?: string; activeTab?: string;
loadingMore?: boolean;
onNearEnd?: () => void;
} = $props(); } = $props();
let editingId = $state<string | number | null>(null); let editingId = $state<string | number | null>(null);
@@ -498,10 +502,12 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
{:else} {:else}
<VirtualList <VirtualList
items={products} items={products}
estimateSize={64} estimateSize={72}
overscan={6} overscan={6}
class="max-h-[min(70vh,640px)]" maxHeight={640}
getKey={(product) => String(product.id)} getKey={(product) => String(product.id)}
{loadingMore}
{onNearEnd}
> >
{#snippet children(product)} {#snippet children(product)}
{@const selected = isSelected(product.id)} {@const selected = isSelected(product.id)}
@@ -1,5 +1,6 @@
/** Best-effort bulk action receipt for process / export / reset toasts. */ /** Best-effort bulk action receipt for process / export / reset toasts. */
import { goto } from "$app/navigation";
import { api } from "$lib/api"; import { api } from "$lib/api";
import { notifyApiError, notifySuccess } from "$lib/notify"; import { notifyApiError, notifySuccess } from "$lib/notify";
import type { ToastAction } from "$lib/components/ui/toast-state"; import type { ToastAction } from "$lib/components/ui/toast-state";
@@ -18,7 +19,10 @@ export function formatBulkReceipt(counts: BulkReceiptCounts): string {
return i18n.t("processing.receipt.format", { succeeded, skipped, failed }); return i18n.t("processing.receipt.format", { succeeded, skipped, failed });
} }
export function bulkReceiptSkipped(requested: number, succeeded: number): number { export function bulkReceiptSkipped(
requested: number,
succeeded: number,
): number {
return Math.max(0, requested - Math.max(0, succeeded)); return Math.max(0, requested - Math.max(0, succeeded));
} }
@@ -26,7 +30,7 @@ export function bulkReceiptSkipped(requested: number, succeeded: number): number
export function processingReceiptAction(): ToastAction { export function processingReceiptAction(): ToastAction {
return { return {
label: i18n.t("processing.receipt.view"), label: i18n.t("processing.receipt.view"),
href: "/processing" href: "/processing",
}; };
} }
@@ -51,11 +55,15 @@ export function collectStartedJobIds(job: {
} }
/** Cancel just-started processing jobs via the real cancel API (no fake undo). */ /** Cancel just-started processing jobs via the real cancel API (no fake undo). */
export async function undoStartedProcessingJobs(jobIds: string[]): Promise<void> { export async function undoStartedProcessingJobs(
jobIds: string[],
): Promise<void> {
if (jobIds.length === 0) return; if (jobIds.length === 0) return;
try { try {
await Promise.all( await Promise.all(
jobIds.map((id) => api(`/api/processing/jobs/${id}/cancel`, { method: "POST" })) jobIds.map((id) =>
api(`/api/processing/jobs/${id}/cancel`, { method: "POST" }),
),
); );
notifySuccess( notifySuccess(
jobIds.length === 1 jobIds.length === 1
@@ -64,20 +72,20 @@ export async function undoStartedProcessingJobs(jobIds: string[]): Promise<void>
{ {
description: i18n.t("processing.receipt.cancelledDesc"), description: i18n.t("processing.receipt.cancelledDesc"),
actions: [processingReceiptAction()], actions: [processingReceiptAction()],
duration: 8000 duration: 8000,
} },
); );
} catch (err) { } catch (err) {
notifyApiError(err, i18n.t("processing.receipt.cancelFailed"), { notifyApiError(err, i18n.t("processing.receipt.cancelFailed"), {
actions: [processingReceiptAction()], actions: [processingReceiptAction()],
duration: 12000 duration: 12000,
}); });
} }
} }
/** /**
* Toast actions after a processing job starts. * Toast actions after a processing job starts.
* Undo only when `jobIds` are known (cancel API). Always includes View in Processing. * Undo when job ids are known; Review products for results; View in Processing for job detail.
*/ */
export function processingStartedActions(jobIds: string[]): ToastAction[] { export function processingStartedActions(jobIds: string[]): ToastAction[] {
const actions: ToastAction[] = []; const actions: ToastAction[] = [];
@@ -85,9 +93,17 @@ export function processingStartedActions(jobIds: string[]): ToastAction[] {
const ids = [...jobIds]; const ids = [...jobIds];
actions.push({ actions.push({
label: i18n.t("processing.receipt.undo"), label: i18n.t("processing.receipt.undo"),
onClick: () => undoStartedProcessingJobs(ids) onClick: () => undoStartedProcessingJobs(ids),
}); });
} }
actions.push({
label: i18n.t("processing.receipt.reviewProducts"),
onClick: () =>
goto("/products?type=processed&status=needs_review", {
noScroll: true,
keepFocus: true,
}),
});
actions.push(processingReceiptAction()); actions.push(processingReceiptAction());
return actions; return actions;
} }
@@ -32,10 +32,10 @@
{#each CTA_DATA.features as feature, index} {#each CTA_DATA.features as feature, index}
<div class="flex items-start"> <div class="flex items-start">
<div <div
class="mt-1 mr-3 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-primary/20" class="mt-1 mr-3 flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-full bg-primary text-primary-foreground"
> >
<svg <svg
class="h-3 w-3 text-primary" class="h-3 w-3"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
stroke="currentColor" stroke="currentColor"
@@ -44,7 +44,7 @@
<path <path
stroke-linecap="round" stroke-linecap="round"
stroke-linejoin="round" stroke-linejoin="round"
stroke-width="2" stroke-width="3"
d="M5 13l4 4L19 7" d="M5 13l4 4L19 7"
/> />
</svg> </svg>
+2 -1
View File
@@ -3683,7 +3683,8 @@ export const de: MessageDict = {
"processing.option.title": "Titel (KI)", "processing.option.title": "Titel (KI)",
"processing.option.description": "Beschreibungen (KI)", "processing.option.description": "Beschreibungen (KI)",
"processing.receipt.view": "In Verarbeitung anzeigen", "processing.receipt.view": "In Verarbeitung anzeigen",
"processing.receipt.undo": "Rückgängig", "processing.receipt.reviewProducts": "Produkte prüfen",
"processing.receipt.undo": "Rückgängig",
"processing.receipt.format": "{succeeded} erfolgreich · {skipped} übersprungen · {failed} fehlgeschlagen", "processing.receipt.format": "{succeeded} erfolgreich · {skipped} übersprungen · {failed} fehlgeschlagen",
"processing.receipt.cancelledOne": "Verarbeitung abgebrochen", "processing.receipt.cancelledOne": "Verarbeitung abgebrochen",
"processing.receipt.cancelledMany": "Verarbeitungsjobs abgebrochen", "processing.receipt.cancelledMany": "Verarbeitungsjobs abgebrochen",
+1
View File
@@ -4437,6 +4437,7 @@ export const en: MessageDict = {
"processing.option.title": "Titles (AI)", "processing.option.title": "Titles (AI)",
"processing.option.description": "Descriptions (AI)", "processing.option.description": "Descriptions (AI)",
"processing.receipt.view": "View in Processing", "processing.receipt.view": "View in Processing",
"processing.receipt.reviewProducts": "Review products",
"processing.receipt.undo": "Undo", "processing.receipt.undo": "Undo",
"processing.receipt.format": "processing.receipt.format":
"{succeeded} succeeded · {skipped} skipped · {failed} failed", "{succeeded} succeeded · {skipped} skipped · {failed} failed",
+1
View File
@@ -3683,6 +3683,7 @@ export const es: MessageDict = {
"processing.option.title": "Títulos (IA)", "processing.option.title": "Títulos (IA)",
"processing.option.description": "Descripciones (IA)", "processing.option.description": "Descripciones (IA)",
"processing.receipt.view": "Ver en Procesamiento", "processing.receipt.view": "Ver en Procesamiento",
"processing.receipt.reviewProducts": "Revisar productos",
"processing.receipt.undo": "Deshacer", "processing.receipt.undo": "Deshacer",
"processing.receipt.format": "{succeeded} correctos · {skipped} omitidos · {failed} fallidos", "processing.receipt.format": "{succeeded} correctos · {skipped} omitidos · {failed} fallidos",
"processing.receipt.cancelledOne": "Procesamiento cancelado", "processing.receipt.cancelledOne": "Procesamiento cancelado",
+1
View File
@@ -3683,6 +3683,7 @@ export const fr: MessageDict = {
"processing.option.title": "Titres (IA)", "processing.option.title": "Titres (IA)",
"processing.option.description": "Descriptions (IA)", "processing.option.description": "Descriptions (IA)",
"processing.receipt.view": "Voir dans Traitement", "processing.receipt.view": "Voir dans Traitement",
"processing.receipt.reviewProducts": "Revoir les produits",
"processing.receipt.undo": "Annuler", "processing.receipt.undo": "Annuler",
"processing.receipt.format": "{succeeded} réussis · {skipped} ignorés · {failed} échoués", "processing.receipt.format": "{succeeded} réussis · {skipped} ignorés · {failed} échoués",
"processing.receipt.cancelledOne": "Traitement annulé", "processing.receipt.cancelledOne": "Traitement annulé",
+1
View File
@@ -3683,6 +3683,7 @@ export const it: MessageDict = {
"processing.option.title": "Titoli (IA)", "processing.option.title": "Titoli (IA)",
"processing.option.description": "Descrizioni (IA)", "processing.option.description": "Descrizioni (IA)",
"processing.receipt.view": "Vedi in Elaborazione", "processing.receipt.view": "Vedi in Elaborazione",
"processing.receipt.reviewProducts": "Rivedi prodotti",
"processing.receipt.undo": "Annulla", "processing.receipt.undo": "Annulla",
"processing.receipt.format": "{succeeded} riusciti · {skipped} saltati · {failed} non riusciti", "processing.receipt.format": "{succeeded} riusciti · {skipped} saltati · {failed} non riusciti",
"processing.receipt.cancelledOne": "Elaborazione annullata", "processing.receipt.cancelledOne": "Elaborazione annullata",
+1
View File
@@ -3683,6 +3683,7 @@ export const ja: MessageDict = {
"processing.option.title": "タイトル(AI)", "processing.option.title": "タイトル(AI)",
"processing.option.description": "説明(AI)", "processing.option.description": "説明(AI)",
"processing.receipt.view": "処理で表示", "processing.receipt.view": "処理で表示",
"processing.receipt.reviewProducts": "製品を確認",
"processing.receipt.undo": "元に戻す", "processing.receipt.undo": "元に戻す",
"processing.receipt.format": "{succeeded} 成功 · {skipped} スキップ · {failed} 失敗", "processing.receipt.format": "{succeeded} 成功 · {skipped} スキップ · {failed} 失敗",
"processing.receipt.cancelledOne": "処理をキャンセルしました", "processing.receipt.cancelledOne": "処理をキャンセルしました",
+1
View File
@@ -3683,6 +3683,7 @@ export const nl: MessageDict = {
"processing.option.title": "Titels (AI)", "processing.option.title": "Titels (AI)",
"processing.option.description": "Beschrijvingen (AI)", "processing.option.description": "Beschrijvingen (AI)",
"processing.receipt.view": "Bekijken in Verwerking", "processing.receipt.view": "Bekijken in Verwerking",
"processing.receipt.reviewProducts": "Producten controleren",
"processing.receipt.undo": "Ongedaan maken", "processing.receipt.undo": "Ongedaan maken",
"processing.receipt.format": "{succeeded} geslaagd · {skipped} overgeslagen · {failed} mislukt", "processing.receipt.format": "{succeeded} geslaagd · {skipped} overgeslagen · {failed} mislukt",
"processing.receipt.cancelledOne": "Verwerking geannuleerd", "processing.receipt.cancelledOne": "Verwerking geannuleerd",
+1
View File
@@ -3683,6 +3683,7 @@ export const pl: MessageDict = {
"processing.option.title": "Tytuły (AI)", "processing.option.title": "Tytuły (AI)",
"processing.option.description": "Opisy (AI)", "processing.option.description": "Opisy (AI)",
"processing.receipt.view": "Zobacz w Przetwarzaniu", "processing.receipt.view": "Zobacz w Przetwarzaniu",
"processing.receipt.reviewProducts": "Przejrzyj produkty",
"processing.receipt.undo": "Cofnij", "processing.receipt.undo": "Cofnij",
"processing.receipt.format": "{succeeded} powiodło się · {skipped} pominięto · {failed} niepowodzeń", "processing.receipt.format": "{succeeded} powiodło się · {skipped} pominięto · {failed} niepowodzeń",
"processing.receipt.cancelledOne": "Przetwarzanie anulowane", "processing.receipt.cancelledOne": "Przetwarzanie anulowane",
+1
View File
@@ -3683,6 +3683,7 @@ export const pt: MessageDict = {
"processing.option.title": "Títulos (IA)", "processing.option.title": "Títulos (IA)",
"processing.option.description": "Descrições (IA)", "processing.option.description": "Descrições (IA)",
"processing.receipt.view": "Ver em Processamento", "processing.receipt.view": "Ver em Processamento",
"processing.receipt.reviewProducts": "Rever produtos",
"processing.receipt.undo": "Anular", "processing.receipt.undo": "Anular",
"processing.receipt.format": "{succeeded} com sucesso · {skipped} ignorados · {failed} falhados", "processing.receipt.format": "{succeeded} com sucesso · {skipped} ignorados · {failed} falhados",
"processing.receipt.cancelledOne": "Processamento cancelado", "processing.receipt.cancelledOne": "Processamento cancelado",
+97 -5
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { i18n } from "$lib/i18n"; import { i18n } from "$lib/i18n";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { goto } from "$app/navigation"; import { afterNavigate, goto } from "$app/navigation";
import { CircleHelp, ExternalLink, RefreshCw } from "@lucide/svelte"; import { CircleHelp, ExternalLink, RefreshCw } from "@lucide/svelte";
import { api, ApiError, failureMessage, apiDownload } from "$lib/api"; import { api, ApiError, failureMessage, apiDownload } from "$lib/api";
import { trackEvent } from "$lib/analytics"; import { trackEvent } from "$lib/analytics";
@@ -11,6 +11,7 @@
unwrapNextCursor, unwrapNextCursor,
unwrapNextAfterId, unwrapNextAfterId,
DEFAULT_PAGE_SIZE, DEFAULT_PAGE_SIZE,
MAX_PAGE_LIMIT,
OPTION_LIST_LIMIT, OPTION_LIST_LIMIT,
TREE_LIST_LIMIT, TREE_LIST_LIMIT,
SEARCH_DEBOUNCE_MS, SEARCH_DEBOUNCE_MS,
@@ -20,6 +21,7 @@
bookmarkForPage, bookmarkForPage,
recordPageBookmark, recordPageBookmark,
resetPageBookmarks, resetPageBookmarks,
sequentialNextQuery,
type PageBookmark, type PageBookmark,
type SequentialNextQuery type SequentialNextQuery
} from "$lib/list"; } from "$lib/list";
@@ -73,7 +75,9 @@
toggleSelectAllIds toggleSelectAllIds
} from "$lib/products-selection"; } from "$lib/products-selection";
const PAGE_SIZE = DEFAULT_PAGE_SIZE; const CHUNK_SIZE = DEFAULT_PAGE_SIZE;
/** One "page" window: infinite-scroll appends in CHUNK_SIZE steps up to this cap. */
const PAGE_SIZE = MAX_PAGE_LIMIT;
const initialParams = appPage.url.searchParams; const initialParams = appPage.url.searchParams;
const hasExplicitTab = hasExplicitProductTab(initialParams); const hasExplicitTab = hasExplicitProductTab(initialParams);
@@ -106,6 +110,7 @@
let upgradeCta = $state(upgradeCtaForRole(false)); let upgradeCta = $state(upgradeCtaForRole(false));
let loading = $state(true); let loading = $state(true);
let fetching = $state(false); let fetching = $state(false);
let loadingMore = $state(false);
let startingJob = $state(false); let startingJob = $state(false);
let saving = $state(false); let saving = $state(false);
let reviewBusy = $state(false); let reviewBusy = $state(false);
@@ -157,6 +162,15 @@
const kind = $derived(tabToApiParams(activeTab).kind); const kind = $derived(tabToApiParams(activeTab).kind);
const totalPages = $derived(Math.max(1, Math.ceil(total / PAGE_SIZE))); const totalPages = $derived(Math.max(1, Math.ceil(total / PAGE_SIZE)));
const sequentialOnly = $derived(preferSequentialPagination(total, PAGE_SIZE)); const sequentialOnly = $derived(preferSequentialPagination(total, PAGE_SIZE));
const canLoadMore = $derived(
products.length > 0 &&
products.length < PAGE_SIZE &&
products.length < total &&
Boolean(sequentialNextQuery(nextCursor, nextAfterId))
);
/** Hide Next until the current window is full (or exhausted) so cursor points at the next page. */
const paginationNextCursor = $derived(canLoadMore ? null : nextCursor);
const paginationNextAfterId = $derived(canLoadMore ? null : nextAfterId);
const selectedCategory = $derived( const selectedCategory = $derived(
categoryFilter === "all" ? null : (categories.find((c) => c.uniqueId === categoryFilter) ?? null) categoryFilter === "all" ? null : (categories.find((c) => c.uniqueId === categoryFilter) ?? null)
); );
@@ -259,7 +273,7 @@
try { try {
const { kind: apiKind, status } = tabToApiParams(activeTab); const { kind: apiKind, status } = tabToApiParams(activeTab);
const params = new URLSearchParams({ const params = new URLSearchParams({
limit: String(PAGE_SIZE), limit: String(CHUNK_SIZE),
kind: apiKind, kind: apiKind,
sort_by: sortBy, sort_by: sortBy,
sort_order: sortOrder sort_order: sortOrder
@@ -361,10 +375,75 @@
if (gen === productsFetchGen) { if (gen === productsFetchGen) {
loading = false; loading = false;
fetching = false; fetching = false;
loadingMore = false;
} }
} }
} }
async function loadMoreProducts() {
if (loadingMore || loading || fetching || !canLoadMore) return;
const seq = sequentialNextQuery(nextCursor, nextAfterId);
if (!seq) return;
const remaining = Math.min(CHUNK_SIZE, PAGE_SIZE - products.length);
if (remaining <= 0) return;
productsAbort?.abort();
const ac = new AbortController();
productsAbort = ac;
const gen = ++productsFetchGen;
loadingMore = true;
error = "";
try {
const { kind: apiKind, status } = tabToApiParams(activeTab);
const params = new URLSearchParams({
limit: String(remaining),
kind: apiKind,
sort_by: sortBy,
sort_order: sortOrder
});
if ("cursor" in seq) params.set("cursor", seq.cursor);
else params.set("after_id", seq.after_id);
if (appliedSearch.trim()) params.set("q", appliedSearch.trim());
if (status) params.set("status", status);
if (categoryFilter !== "all" && apiKind === "processed") {
params.set("category", categoryFilter);
}
if (feedFilter !== "all") {
params.set("feed_id", feedFilter);
}
if (coverageFilter !== "all" && apiKind === "processed") {
params.set("coverage", coverageFilter);
}
if (eprelFilter !== "all" && apiKind === "processed") {
params.set("eprel", eprelFilter);
}
if (syncChangeFilter !== "all") {
params.set("sync_change", syncChangeFilter);
}
const payload = await api<ListResponse<ProductRow> & { total?: number }>(
`/api/products?${params}`,
{ signal: ac.signal }
);
if (gen !== productsFetchGen) return;
const chunk = unwrapList(payload);
const seen = new Set(products.map((p) => String(p.id)));
const appended = chunk.filter((p) => !seen.has(String(p.id)));
products = [...products, ...appended].slice(0, PAGE_SIZE);
total = unwrapTotal(payload) ?? total;
nextCursor = unwrapNextCursor(payload);
nextAfterId = unwrapNextAfterId(payload);
} catch (err) {
if (isAbortError(err) || gen !== productsFetchGen) return;
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
await goto("/login");
return;
}
error = failureMessage(err, i18n.t("products.loadFailed"));
} finally {
if (gen === productsFetchGen) loadingMore = false;
}
}
onMount(() => { onMount(() => {
void (async () => { void (async () => {
try { try {
@@ -433,6 +512,17 @@
void loadProducts({ soft: true }); void loadProducts({ soft: true });
} }
/** Deep links / toast actions change ?type=&status= without remounting — sync the tab. */
afterNavigate(({ to }) => {
if (!to || to.url.pathname !== "/products") return;
const nextTab = tabFromSearchParams(to.url.searchParams);
if (nextTab === activeTab) return;
activeTab = nextTab;
resetListPaging();
selectedIds = [];
void loadProducts({ soft: true });
});
function changePage(next: number, opts?: SequentialNextQuery) { function changePage(next: number, opts?: SequentialNextQuery) {
let target = Math.max(1, Math.floor(Number(next)) || 1); let target = Math.max(1, Math.floor(Number(next)) || 1);
@@ -1323,6 +1413,8 @@
onInlineCategory={kind === "processed" ? inlineAssignCategory : undefined} onInlineCategory={kind === "processed" ? inlineAssignCategory : undefined}
isLoading={false} isLoading={false}
{activeTab} {activeTab}
{loadingMore}
onNearEnd={() => void loadMoreProducts()}
/> />
</div> </div>
<ProductPagination <ProductPagination
@@ -1330,8 +1422,8 @@
{totalPages} {totalPages}
onPageChange={changePage} onPageChange={changePage}
productsLength={products.length} productsLength={products.length}
{nextCursor} nextCursor={paginationNextCursor}
{nextAfterId} nextAfterId={paginationNextAfterId}
{total} {total}
pageSize={PAGE_SIZE} pageSize={PAGE_SIZE}
{sequentialOnly} {sequentialOnly}