diff --git a/src/app/api/admin/import/furni/batch/route.ts b/src/app/api/admin/import/furni/batch/route.ts
index d74b8461..dc571216 100644
--- a/src/app/api/admin/import/furni/batch/route.ts
+++ b/src/app/api/admin/import/furni/batch/route.ts
@@ -117,6 +117,11 @@ export const POST = withAdmin(
});
try {
+ // Coalesce micro-step progress events to at most one per
+ // ~120ms per item so large imports don't flood the client
+ // (terminal states are always emitted by importSingleFurni
+ // and sent below).
+ let lastProgressSent = 0;
const result: ImportSingleResult = await importSingleFurni({
id: item.id ?? 0,
classname: item.classname,
@@ -131,6 +136,9 @@ export const POST = withAdmin(
nitroBaseUrl: source?.nitroBaseUrl,
iconBaseUrl: source?.iconBaseUrl,
onProgress: (status: string) => {
+ const now = Date.now();
+ if (now - lastProgressSent < 120) return;
+ lastProgressSent = now;
send({
type: "item_progress",
classname: item.classname,
diff --git a/src/components/admin/studio/batch-progress.tsx b/src/components/admin/studio/batch-progress.tsx
index e7e7677c..8489fbf5 100644
--- a/src/components/admin/studio/batch-progress.tsx
+++ b/src/components/admin/studio/batch-progress.tsx
@@ -73,7 +73,13 @@ export const BatchProgress = memo(function BatchProgress({
return () => clearInterval(id);
}, [done]);
- const entries = useMemo(() => [...progress.values()], [progress]);
+ // Relevance-first list: newest activity on top, capped so high-volume
+ // imports don't render hundreds of DOM rows on every progress event.
+ const VISIBLE_ITEMS = 60;
+ const entries = useMemo(
+ () => [...progress.values()].slice().reverse(),
+ [progress],
+ );
const completed = useMemo(
() =>
entries.filter((i) => i.status === "done" || i.status === "failed")
@@ -239,7 +245,12 @@ export const BatchProgress = memo(function BatchProgress({
View item details ({total})
- {entries.map((item) => (
+ {entries.length > VISIBLE_ITEMS && (
+
+ Showing {VISIBLE_ITEMS} of {entries.length} items
+
+ )}
+ {entries.slice(0, VISIBLE_ITEMS).map((item) => (
GRID_ROW_H,
overscan: 5,
});
+ const tableVirtualizer = useVirtualizer({
+ count: viewMode === "list" ? filteredItems.length : 0,
+ getScrollElement: () => listRef.current,
+ estimateSize: () => TABLE_ROW_H,
+ overscan: 8,
+ });
// Infinite scroll: auto-load the next page when the sentinel enters view.
const sentinelRef = useRef
(null);
@@ -1771,9 +1783,9 @@ export function StudioClient({
{item.classname}
-
- {item.category || "—"}
-
+
+ {item.category || "—"}
+
@@ -1855,212 +1867,246 @@ export function StudioClient({
})}
) : (
-
-
-
-
- |
-
- |
- |
-
- Name
- |
-
- Classname
- |
-
- Type
- |
-
- Category
- |
-
- Status
- |
-
- Actions
- |
-
-
-
- {filteredItems.map((item) => {
- const isSel = selected.has(item.classname);
- return (
-
-
-
- toggleSelect(item.classname)
- }
- aria-label={`Select ${item.classname}`}
+
+
+
+ |
+
+ |
+ |
+
+ Name
+ |
+
+ Classname
+ |
+
+ Type
+ |
+
+ Category
+ |
+
+ Status
+ |
+
+ Actions
+ |
+
+
+
+ {tableVirtualizer.getVirtualItems().map((vi) => {
+ const item = filteredItems[vi.index];
+ const isSel = selected.has(item.classname);
+ return (
+
+ |
+
+ toggleSelect(item.classname)
+ }
+ aria-label={`Select ${item.classname}`}
+ />
+ |
+
+ |
-
-
- |
-
- {item.name}
- |
-
- {item.classname}
- |
-
- {TYPE_LABELS[item.type] ?? item.type}
- |
-
- {item.category || "—"}
- |
-
- {item.alreadyImported ? (
-
- {!item.nitroExists && (
-
- No nitro
-
- )}
- {item.hasFurnidata === false && (
-
- No furnidata
-
- )}
- {item.hasCatalogEntry === false && (
-
- No catalog
-
- )}
- {item.nitroExists &&
- item.hasFurnidata !== false &&
- item.hasCatalogEntry !== false && (
-
- Imported
-
- )}
-
- ) : (
-
- New
-
- )}
- |
-
-
- {!item.alreadyImported && (
-
+ |
+
+ {item.name}
+ |
+
+ {item.classname}
+ |
+
+ {TYPE_LABELS[item.type] ?? item.type}
+ |
+
+ {item.category || "—"}
+ |
+
+ {item.alreadyImported ? (
+
+ {!item.nitroExists && (
+
- {importingId === item.classname ? (
-
- ) : (
-
- )}
-
+ No nitro
+
)}
- {item.alreadyImported && item.nitroExists && (
-
+ No furnidata
+
)}
- {item.alreadyImported &&
- !item.nitroExists && (
-
+ Imported
+
)}
- |
-
- );
- })}
-
-
-
+ ) : (
+
+ New
+
+ )}
+ |
+
+
+ {!item.alreadyImported && (
+
+ )}
+ {item.alreadyImported && item.nitroExists && (
+
+ )}
+ {item.alreadyImported && !item.nitroExists && (
+
+ )}
+
+ |
+
+ );
+ })}
+
+
)}
(opts: RunSseBatchOptions): Response {
index,
});
try {
- const result = await worker(item, index, (status) =>
- send({ type: "item_progress", classname, status, index }),
- );
+ // Coalesce micro-step progress ('downloading', 'converting',
+ // ...) to at most one per 120ms per item so large imports
+ // don't flood the client. Terminal states are emitted
+ // separately below.
+ let lastReportSent = 0;
+ const result = await worker(item, index, (status) => {
+ const now = Date.now();
+ if (now - lastReportSent < 120) return;
+ lastReportSent = now;
+ send({
+ type: "item_progress",
+ classname,
+ status,
+ index,
+ });
+ });
if (ac.signal.aborted) return;
if (result.ok) {
succeeded++;