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
+97 -32
View File
@@ -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(
items.length,
Math.ceil((scrollTop + Math.max(viewportHeight, rowHeight)) / rowHeight) +
Math.max(0, overscan)
)
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) {
scrollEl.scrollTop = 0;
scrollTop = 0;
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
bind:this={scrollEl}
class={className}
style="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}
{#if scrollable}
<div
bind:this={scrollEl}
class={className}
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>
{/if}
</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 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,33 +1,37 @@
/** 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";
import { i18n } from "$lib/i18n";
export type BulkReceiptCounts = {
succeeded: number;
skipped?: number;
failed?: number;
succeeded: number;
skipped?: number;
failed?: number;
};
export function formatBulkReceipt(counts: BulkReceiptCounts): string {
const succeeded = Math.max(0, counts.succeeded);
const skipped = Math.max(0, counts.skipped ?? 0);
const failed = Math.max(0, counts.failed ?? 0);
return i18n.t("processing.receipt.format", { succeeded, skipped, failed });
const succeeded = Math.max(0, counts.succeeded);
const skipped = Math.max(0, counts.skipped ?? 0);
const failed = Math.max(0, counts.failed ?? 0);
return i18n.t("processing.receipt.format", { succeeded, skipped, failed });
}
export function bulkReceiptSkipped(requested: number, succeeded: number): number {
return Math.max(0, requested - Math.max(0, succeeded));
export function bulkReceiptSkipped(
requested: number,
succeeded: number,
): number {
return Math.max(0, requested - Math.max(0, succeeded));
}
/** Localized at call time. */
export function processingReceiptAction(): ToastAction {
return {
label: i18n.t("processing.receipt.view"),
href: "/processing"
};
return {
label: i18n.t("processing.receipt.view"),
href: "/processing",
};
}
/** Prefer processingReceiptAction() so the label tracks the active locale. */
@@ -35,59 +39,71 @@ export const PROCESSING_RECEIPT_ACTION: ToastAction = processingReceiptAction();
/** Collect job id(s) from a start-job response (includes auto-split siblings). */
export function collectStartedJobIds(job: {
id?: string | number | null;
sibling_job_ids?: Array<string | number> | null;
jobs?: Array<{ id?: string | number | null }> | null;
id?: string | number | null;
sibling_job_ids?: Array<string | number> | null;
jobs?: Array<{ id?: string | number | null }> | null;
}): string[] {
const ids = new Set<string>();
if (job.id != null && String(job.id)) ids.add(String(job.id));
for (const s of job.sibling_job_ids ?? []) {
if (s != null && String(s)) ids.add(String(s));
}
for (const j of job.jobs ?? []) {
if (j?.id != null && String(j.id)) ids.add(String(j.id));
}
return [...ids];
const ids = new Set<string>();
if (job.id != null && String(job.id)) ids.add(String(job.id));
for (const s of job.sibling_job_ids ?? []) {
if (s != null && String(s)) ids.add(String(s));
}
for (const j of job.jobs ?? []) {
if (j?.id != null && String(j.id)) ids.add(String(j.id));
}
return [...ids];
}
/** Cancel just-started processing jobs via the real cancel API (no fake undo). */
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" }))
);
notifySuccess(
jobIds.length === 1
? i18n.t("processing.receipt.cancelledOne")
: i18n.t("processing.receipt.cancelledMany"),
{
description: i18n.t("processing.receipt.cancelledDesc"),
actions: [processingReceiptAction()],
duration: 8000
}
);
} catch (err) {
notifyApiError(err, i18n.t("processing.receipt.cancelFailed"), {
actions: [processingReceiptAction()],
duration: 12000
});
}
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" }),
),
);
notifySuccess(
jobIds.length === 1
? i18n.t("processing.receipt.cancelledOne")
: i18n.t("processing.receipt.cancelledMany"),
{
description: i18n.t("processing.receipt.cancelledDesc"),
actions: [processingReceiptAction()],
duration: 8000,
},
);
} catch (err) {
notifyApiError(err, i18n.t("processing.receipt.cancelFailed"), {
actions: [processingReceiptAction()],
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[] = [];
if (jobIds.length > 0) {
const ids = [...jobIds];
actions.push({
label: i18n.t("processing.receipt.undo"),
onClick: () => undoStartedProcessingJobs(ids)
});
}
actions.push(processingReceiptAction());
return actions;
const actions: ToastAction[] = [];
if (jobIds.length > 0) {
const ids = [...jobIds];
actions.push({
label: i18n.t("processing.receipt.undo"),
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>
+2 -1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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é",
+1
View File
@@ -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",
+1
View File
@@ -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": "処理をキャンセルしました",
+1
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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",
+97 -5
View File
@@ -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}