fixes
This commit is contained in:
@@ -4,52 +4,84 @@
|
||||
/**
|
||||
* Fixed-row virtual window for long scroll lists (Svelte 5).
|
||||
* 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 {
|
||||
items,
|
||||
estimateSize = 80,
|
||||
overscan = 8,
|
||||
maxHeight = 640,
|
||||
class: className = "",
|
||||
getKey,
|
||||
onNearEnd,
|
||||
loadingMore = false,
|
||||
children
|
||||
}: {
|
||||
items: T[];
|
||||
estimateSize?: number;
|
||||
overscan?: number;
|
||||
/** Cap for the scrollport; below this, list is not scrollable. */
|
||||
maxHeight?: number;
|
||||
class?: string;
|
||||
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]>;
|
||||
} = $props();
|
||||
|
||||
let scrollEl = $state<HTMLDivElement | null>(null);
|
||||
let scrollTop = $state(0);
|
||||
let viewportHeight = $state(0);
|
||||
let nearEndArmed = $state(true);
|
||||
|
||||
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(
|
||||
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(
|
||||
Math.min(
|
||||
scrollable
|
||||
? Math.min(
|
||||
items.length,
|
||||
Math.ceil((scrollTop + Math.max(viewportHeight, rowHeight)) / rowHeight) +
|
||||
Math.max(0, overscan)
|
||||
)
|
||||
: items.length
|
||||
);
|
||||
const visible = $derived(items.slice(startIndex, endIndex));
|
||||
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() {
|
||||
if (scrollEl) scrollTop = scrollEl.scrollTop;
|
||||
if (!scrollable || !scrollEl) return;
|
||||
scrollTop = scrollEl.scrollTop;
|
||||
maybeNearEnd();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const el = scrollEl;
|
||||
if (!el) return;
|
||||
if (!el || !scrollable) {
|
||||
viewportHeight = 0;
|
||||
return;
|
||||
}
|
||||
const update = () => {
|
||||
viewportHeight = el.clientHeight;
|
||||
maybeNearEnd();
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
@@ -57,40 +89,73 @@
|
||||
return () => ro.disconnect();
|
||||
});
|
||||
|
||||
// Reset scroll only when the list head changes (new filter/page), not on append.
|
||||
let prevHead: T | undefined = undefined;
|
||||
$effect(() => {
|
||||
// New page / filter result — restart at the top of the window.
|
||||
const first = items[0];
|
||||
const headKey =
|
||||
first !== undefined && getKey ? String(getKey(first, 0)) : String(items.length);
|
||||
void headKey;
|
||||
if (scrollEl) {
|
||||
if (first !== prevHead) {
|
||||
prevHead = first;
|
||||
if (scrollEl && scrollable) {
|
||||
scrollEl.scrollTop = 0;
|
||||
scrollTop = 0;
|
||||
}
|
||||
nearEndArmed = true;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
// Reset scroll window when the item set shrinks below the prior offset.
|
||||
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);
|
||||
scrollTop = scrollEl.scrollTop;
|
||||
}
|
||||
if (!scrollable) scrollTop = 0;
|
||||
queueMicrotask(() => maybeNearEnd());
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
{#if scrollable}
|
||||
<div
|
||||
bind:this={scrollEl}
|
||||
class={className}
|
||||
style="overflow-y: auto;"
|
||||
style="max-height: {cap}px; overflow-y: auto;"
|
||||
onscroll={onScroll}
|
||||
data-virtual-list
|
||||
>
|
||||
>
|
||||
<div style="height: {totalHeight}px; position: relative;" data-virtual-spacer>
|
||||
<div style="transform: translateY({offsetY}px);" data-virtual-window>
|
||||
{#each visible as item, i (getKey ? getKey(item, startIndex + i) : startIndex + i)}
|
||||
{@render children(item, startIndex + i)}
|
||||
{/each}
|
||||
</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>
|
||||
</div>
|
||||
{/if}
|
||||
</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 out: (number | "…")[] = [];
|
||||
if (totalPages <= 7) {
|
||||
@@ -106,7 +111,7 @@
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
disabled={!canGoNext}
|
||||
onclick={goNext}
|
||||
>
|
||||
{i18n.t("catalog.next")}
|
||||
|
||||
@@ -46,19 +46,23 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
|
||||
onInlineRename,
|
||||
onInlineCategory,
|
||||
isLoading = false,
|
||||
activeTab = "processed"
|
||||
activeTab = "processed",
|
||||
loadingMore = false,
|
||||
onNearEnd
|
||||
}: {
|
||||
products: ProductRow[];
|
||||
kind: "processed" | "raw";
|
||||
selectedIds: Array<string | number>;
|
||||
onSelectProduct: (id: string | number, selected: boolean) => void;
|
||||
onSelectAll: () => void;
|
||||
onSelectProduct: (id: string | number, selected: boolean, shiftKey?: boolean) => void;
|
||||
onSelectAll: (selected: boolean) => void;
|
||||
categoryOptions?: CategoryOption[];
|
||||
onEditProduct: (product: ProductRow) => void;
|
||||
onInlineRename?: (product: ProductRow, name: string) => Promise<void>;
|
||||
onInlineCategory?: (product: ProductRow, categoryUniqueId: string) => Promise<void>;
|
||||
onInlineRename?: (product: ProductRow, name: string) => Promise<void> | void;
|
||||
onInlineCategory?: (product: ProductRow, categoryUniqueId: string) => Promise<void> | void;
|
||||
isLoading?: boolean;
|
||||
activeTab?: string;
|
||||
loadingMore?: boolean;
|
||||
onNearEnd?: () => void;
|
||||
} = $props();
|
||||
|
||||
let editingId = $state<string | number | null>(null);
|
||||
@@ -498,10 +502,12 @@ import { Check, Edit, MoreHorizontal, X } from "@lucide/svelte";
|
||||
{:else}
|
||||
<VirtualList
|
||||
items={products}
|
||||
estimateSize={64}
|
||||
estimateSize={72}
|
||||
overscan={6}
|
||||
class="max-h-[min(70vh,640px)]"
|
||||
maxHeight={640}
|
||||
getKey={(product) => String(product.id)}
|
||||
{loadingMore}
|
||||
{onNearEnd}
|
||||
>
|
||||
{#snippet children(product)}
|
||||
{@const selected = isSelected(product.id)}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Best-effort bulk action receipt for process / export / reset toasts. */
|
||||
|
||||
import { goto } from "$app/navigation";
|
||||
import { api } from "$lib/api";
|
||||
import { notifyApiError, notifySuccess } from "$lib/notify";
|
||||
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 });
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -26,7 +30,7 @@ export function bulkReceiptSkipped(requested: number, succeeded: number): number
|
||||
export function processingReceiptAction(): ToastAction {
|
||||
return {
|
||||
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). */
|
||||
export async function undoStartedProcessingJobs(jobIds: string[]): Promise<void> {
|
||||
export async function undoStartedProcessingJobs(
|
||||
jobIds: string[],
|
||||
): Promise<void> {
|
||||
if (jobIds.length === 0) return;
|
||||
try {
|
||||
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(
|
||||
jobIds.length === 1
|
||||
@@ -64,20 +72,20 @@ export async function undoStartedProcessingJobs(jobIds: string[]): Promise<void>
|
||||
{
|
||||
description: i18n.t("processing.receipt.cancelledDesc"),
|
||||
actions: [processingReceiptAction()],
|
||||
duration: 8000
|
||||
}
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
notifyApiError(err, i18n.t("processing.receipt.cancelFailed"), {
|
||||
actions: [processingReceiptAction()],
|
||||
duration: 12000
|
||||
duration: 12000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] {
|
||||
const actions: ToastAction[] = [];
|
||||
@@ -85,9 +93,17 @@ export function processingStartedActions(jobIds: string[]): ToastAction[] {
|
||||
const ids = [...jobIds];
|
||||
actions.push({
|
||||
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());
|
||||
return actions;
|
||||
}
|
||||
|
||||
@@ -32,10 +32,10 @@
|
||||
{#each CTA_DATA.features as feature, index}
|
||||
<div class="flex items-start">
|
||||
<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
|
||||
class="h-3 w-3 text-primary"
|
||||
class="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
@@ -44,7 +44,7 @@
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
stroke-width="3"
|
||||
d="M5 13l4 4L19 7"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -3683,7 +3683,8 @@ export const de: MessageDict = {
|
||||
"processing.option.title": "Titel (KI)",
|
||||
"processing.option.description": "Beschreibungen (KI)",
|
||||
"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.cancelledOne": "Verarbeitung abgebrochen",
|
||||
"processing.receipt.cancelledMany": "Verarbeitungsjobs abgebrochen",
|
||||
|
||||
@@ -4437,6 +4437,7 @@ export const en: MessageDict = {
|
||||
"processing.option.title": "Titles (AI)",
|
||||
"processing.option.description": "Descriptions (AI)",
|
||||
"processing.receipt.view": "View in Processing",
|
||||
"processing.receipt.reviewProducts": "Review products",
|
||||
"processing.receipt.undo": "Undo",
|
||||
"processing.receipt.format":
|
||||
"{succeeded} succeeded · {skipped} skipped · {failed} failed",
|
||||
|
||||
@@ -3683,6 +3683,7 @@ export const es: MessageDict = {
|
||||
"processing.option.title": "TÃÂÂtulos (IA)",
|
||||
"processing.option.description": "Descripciones (IA)",
|
||||
"processing.receipt.view": "Ver en Procesamiento",
|
||||
"processing.receipt.reviewProducts": "Revisar productos",
|
||||
"processing.receipt.undo": "Deshacer",
|
||||
"processing.receipt.format": "{succeeded} correctos · {skipped} omitidos · {failed} fallidos",
|
||||
"processing.receipt.cancelledOne": "Procesamiento cancelado",
|
||||
|
||||
@@ -3683,6 +3683,7 @@ export const fr: MessageDict = {
|
||||
"processing.option.title": "Titres (IA)",
|
||||
"processing.option.description": "Descriptions (IA)",
|
||||
"processing.receipt.view": "Voir dans Traitement",
|
||||
"processing.receipt.reviewProducts": "Revoir les produits",
|
||||
"processing.receipt.undo": "Annuler",
|
||||
"processing.receipt.format": "{succeeded} réussis · {skipped} ignorés · {failed} échoués",
|
||||
"processing.receipt.cancelledOne": "Traitement annulé",
|
||||
|
||||
@@ -3683,6 +3683,7 @@ export const it: MessageDict = {
|
||||
"processing.option.title": "Titoli (IA)",
|
||||
"processing.option.description": "Descrizioni (IA)",
|
||||
"processing.receipt.view": "Vedi in Elaborazione",
|
||||
"processing.receipt.reviewProducts": "Rivedi prodotti",
|
||||
"processing.receipt.undo": "Annulla",
|
||||
"processing.receipt.format": "{succeeded} riusciti · {skipped} saltati · {failed} non riusciti",
|
||||
"processing.receipt.cancelledOne": "Elaborazione annullata",
|
||||
|
||||
@@ -3683,6 +3683,7 @@ export const ja: MessageDict = {
|
||||
"processing.option.title": "タイトル(AI)",
|
||||
"processing.option.description": "説明(AI)",
|
||||
"processing.receipt.view": "処ç†ã§表示",
|
||||
"processing.receipt.reviewProducts": "製品を確認",
|
||||
"processing.receipt.undo": "å…ƒã«戻ãÂÂâ„¢",
|
||||
"processing.receipt.format": "{succeeded} æˆÂÂ功 · {skipped} スã‚ÂÂッãƒ · {failed} 失æ•â€â€",
|
||||
"processing.receipt.cancelledOne": "処ç†をã‚ÂÂャンセルãÂÂâ€â€ÃƒÂ£Ã‚¾ãÂÂâ€â€ÃƒÂ£Ã‚Ÿ",
|
||||
|
||||
@@ -3683,6 +3683,7 @@ export const nl: MessageDict = {
|
||||
"processing.option.title": "Titels (AI)",
|
||||
"processing.option.description": "Beschrijvingen (AI)",
|
||||
"processing.receipt.view": "Bekijken in Verwerking",
|
||||
"processing.receipt.reviewProducts": "Producten controleren",
|
||||
"processing.receipt.undo": "Ongedaan maken",
|
||||
"processing.receipt.format": "{succeeded} geslaagd · {skipped} overgeslagen · {failed} mislukt",
|
||||
"processing.receipt.cancelledOne": "Verwerking geannuleerd",
|
||||
|
||||
@@ -3683,6 +3683,7 @@ export const pl: MessageDict = {
|
||||
"processing.option.title": "Tytuły (AI)",
|
||||
"processing.option.description": "Opisy (AI)",
|
||||
"processing.receipt.view": "Zobacz w Przetwarzaniu",
|
||||
"processing.receipt.reviewProducts": "Przejrzyj produkty",
|
||||
"processing.receipt.undo": "Cofnij",
|
||||
"processing.receipt.format": "{succeeded} powiodło się · {skipped} pominięto · {failed} niepowodzeń",
|
||||
"processing.receipt.cancelledOne": "Przetwarzanie anulowane",
|
||||
|
||||
@@ -3683,6 +3683,7 @@ export const pt: MessageDict = {
|
||||
"processing.option.title": "TÃÂÂtulos (IA)",
|
||||
"processing.option.description": "Descrições (IA)",
|
||||
"processing.receipt.view": "Ver em Processamento",
|
||||
"processing.receipt.reviewProducts": "Rever produtos",
|
||||
"processing.receipt.undo": "Anular",
|
||||
"processing.receipt.format": "{succeeded} com sucesso · {skipped} ignorados · {failed} falhados",
|
||||
"processing.receipt.cancelledOne": "Processamento cancelado",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { i18n } from "$lib/i18n";
|
||||
import { onMount } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import { afterNavigate, goto } from "$app/navigation";
|
||||
import { CircleHelp, ExternalLink, RefreshCw } from "@lucide/svelte";
|
||||
import { api, ApiError, failureMessage, apiDownload } from "$lib/api";
|
||||
import { trackEvent } from "$lib/analytics";
|
||||
@@ -11,6 +11,7 @@
|
||||
unwrapNextCursor,
|
||||
unwrapNextAfterId,
|
||||
DEFAULT_PAGE_SIZE,
|
||||
MAX_PAGE_LIMIT,
|
||||
OPTION_LIST_LIMIT,
|
||||
TREE_LIST_LIMIT,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
@@ -20,6 +21,7 @@
|
||||
bookmarkForPage,
|
||||
recordPageBookmark,
|
||||
resetPageBookmarks,
|
||||
sequentialNextQuery,
|
||||
type PageBookmark,
|
||||
type SequentialNextQuery
|
||||
} from "$lib/list";
|
||||
@@ -73,7 +75,9 @@
|
||||
toggleSelectAllIds
|
||||
} 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 hasExplicitTab = hasExplicitProductTab(initialParams);
|
||||
@@ -106,6 +110,7 @@
|
||||
let upgradeCta = $state(upgradeCtaForRole(false));
|
||||
let loading = $state(true);
|
||||
let fetching = $state(false);
|
||||
let loadingMore = $state(false);
|
||||
let startingJob = $state(false);
|
||||
let saving = $state(false);
|
||||
let reviewBusy = $state(false);
|
||||
@@ -157,6 +162,15 @@
|
||||
const kind = $derived(tabToApiParams(activeTab).kind);
|
||||
const totalPages = $derived(Math.max(1, Math.ceil(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(
|
||||
categoryFilter === "all" ? null : (categories.find((c) => c.uniqueId === categoryFilter) ?? null)
|
||||
);
|
||||
@@ -259,7 +273,7 @@
|
||||
try {
|
||||
const { kind: apiKind, status } = tabToApiParams(activeTab);
|
||||
const params = new URLSearchParams({
|
||||
limit: String(PAGE_SIZE),
|
||||
limit: String(CHUNK_SIZE),
|
||||
kind: apiKind,
|
||||
sort_by: sortBy,
|
||||
sort_order: sortOrder
|
||||
@@ -361,10 +375,75 @@
|
||||
if (gen === productsFetchGen) {
|
||||
loading = 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(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
@@ -433,6 +512,17 @@
|
||||
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) {
|
||||
let target = Math.max(1, Math.floor(Number(next)) || 1);
|
||||
|
||||
@@ -1323,6 +1413,8 @@
|
||||
onInlineCategory={kind === "processed" ? inlineAssignCategory : undefined}
|
||||
isLoading={false}
|
||||
{activeTab}
|
||||
{loadingMore}
|
||||
onNearEnd={() => void loadMoreProducts()}
|
||||
/>
|
||||
</div>
|
||||
<ProductPagination
|
||||
@@ -1330,8 +1422,8 @@
|
||||
{totalPages}
|
||||
onPageChange={changePage}
|
||||
productsLength={products.length}
|
||||
{nextCursor}
|
||||
{nextAfterId}
|
||||
nextCursor={paginationNextCursor}
|
||||
nextAfterId={paginationNextAfterId}
|
||||
{total}
|
||||
pageSize={PAGE_SIZE}
|
||||
{sequentialOnly}
|
||||
|
||||
Reference in New Issue
Block a user