fixes
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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é",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "処ç†をã‚ÂÂャンセルãÂÂâ€â€ÃƒÂ£Ã‚¾ãÂÂâ€â€ÃƒÂ£Ã‚Ÿ",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user