From 1374bd332f0e24c3ec890131ef4adefcf6211ea8 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 1 Sep 2026 17:29:12 +0200 Subject: [PATCH] Studio: add clone-all-per-hotel, per-item status detail, retry, ETA, and auto-translate - Add 'Clone all missing' button for clone sources with confirmation dialog - Show per-item status badges: nitro, furnidata, catalog entry - Add status filters: missing furnidata, missing catalog entry - Add ETA and percentage to batch progress bar - Add retry button for failed items after batch import - Auto-translate all languages after clone-all completes - Fix statusFilter 'all' bug that incorrectly filtered imported items - Bulk-load FurnitureData + catalog references for O(1) per-item checks --- src/app/api/admin/import/furni/route.ts | 74 ++- .../admin/studio/batch-progress.tsx | 75 ++- src/components/admin/studio/studio-client.tsx | 474 ++++++++++++++++-- src/components/admin/studio/studio-types.ts | 4 + 4 files changed, 580 insertions(+), 47 deletions(-) diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index 3443210d..5c66de76 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -11,6 +11,7 @@ import { getSource } from "@/lib/services/clone-sources"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { appendFurniEntriesBulk, + readFurniData, rebuildCatalogOfferIds, reconcileFurniDataWithItemsBase, verifyAndFixSpriteIds, @@ -311,6 +312,38 @@ export const GET = withAdmin( }); const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, ""); + + // Bulk-load FurnitureData and catalog references for status checks + const furniDataSet = new Set(); + const catalogIdSet = new Set(); + try { + const fd = (await readFurniData()) as { + roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; + wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; + }; + for (const e of [ + ...(fd.roomitemtypes?.furnitype ?? []), + ...(fd.wallitemtypes?.furnitype ?? []), + ]) { + if (e.classname) furniDataSet.add(e.classname); + } + } catch { + /* best effort */ + } + try { + const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`); + for (const row of ( + rows as unknown as [Array<{ item_ids: string | null }>, unknown] + )[0]) { + for (const part of String(row.item_ids ?? "").split(/[;,]/)) { + const n = Number(part.trim()); + if (Number.isFinite(n) && n > 0) catalogIdSet.add(n); + } + } + } catch { + /* best effort */ + } + const enriched = items.map((item) => { const imported = item.present; let nitroExists = false; @@ -336,6 +369,8 @@ export const GET = withAdmin( category: String(item.category ?? ""), alreadyImported: imported, nitroExists, + hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined, + hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined, iconUrl: sourceIconBase ? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png` : undefined, @@ -364,6 +399,37 @@ export const GET = withAdmin( allImported.map((e) => e.itemName).filter((n): n is string => !!n), ); + // Bulk-load FurnitureData and catalog references for status checks + const furniDataSet = new Set(); + const catalogIdSet = new Set(); + try { + const fd = (await readFurniData()) as { + roomitemtypes?: { furnitype?: Array<{ classname?: string }> }; + wallitemtypes?: { furnitype?: Array<{ classname?: string }> }; + }; + for (const e of [ + ...(fd.roomitemtypes?.furnitype ?? []), + ...(fd.wallitemtypes?.furnitype ?? []), + ]) { + if (e.classname) furniDataSet.add(e.classname); + } + } catch { + /* best effort */ + } + try { + const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`); + for (const row of ( + rows as unknown as [Array<{ item_ids: string | null }>, unknown] + )[0]) { + for (const part of String(row.item_ids ?? "").split(/[;,]/)) { + const n = Number(part.trim()); + if (Number.isFinite(n) && n > 0) catalogIdSet.add(n); + } + } + } catch { + /* best effort */ + } + const result = await listFurniture({ search: search || undefined, page, @@ -386,7 +452,13 @@ export const GET = withAdmin( ); nitroExists = existsSync(/*turbopackIgnore: true*/ p); } - return { ...item, alreadyImported: imported, nitroExists }; + return { + ...item, + alreadyImported: imported, + nitroExists, + hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined, + hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined, + }; }); return apiOk({ items: enriched, meta: result.meta }); diff --git a/src/components/admin/studio/batch-progress.tsx b/src/components/admin/studio/batch-progress.tsx index 8f3ee4bd..699c0962 100644 --- a/src/components/admin/studio/batch-progress.tsx +++ b/src/components/admin/studio/batch-progress.tsx @@ -1,6 +1,7 @@ "use client"; import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react"; +import { useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; @@ -32,6 +33,13 @@ function SummaryRow({ ); } +function formatEta(ms: number): string { + if (ms < 60_000) return `${Math.ceil(ms / 1000)}s`; + const min = Math.floor(ms / 60_000); + const sec = Math.ceil((ms % 60_000) / 1000); + return `${min}m ${sec}s`; +} + export function BatchProgress({ progress, done, @@ -39,8 +47,11 @@ export function BatchProgress({ failed, cancelling, verification, + startTime, + failedClassnames, onCancel, onDismiss, + onRetry, }: { progress: Map; done: boolean; @@ -48,8 +59,11 @@ export function BatchProgress({ failed: number; cancelling?: boolean; verification?: VerificationSummary | null; + startTime?: number; + failedClassnames?: string[]; onCancel?: () => void; onDismiss?: () => void; + onRetry?: (classnames: string[]) => void; }) { const entries = [...progress.entries()]; const total = entries.length; @@ -58,6 +72,27 @@ export function BatchProgress({ ).length; const percent = total > 0 ? Math.round((completed / total) * 100) : 0; + // Live ETA calculation + const [eta, setEta] = useState(null); + useEffect(() => { + if (done || !startTime || completed === 0) { + setEta(null); + return; + } + const elapsed = Date.now() - startTime; + const avgPerItem = elapsed / completed; + const remaining = (total - completed) * avgPerItem; + setEta(formatEta(remaining)); + + const interval = setInterval(() => { + const elapsed = Date.now() - startTime; + const avgPerItem = elapsed / completed; + const remaining = (total - completed) * avgPerItem; + setEta(formatEta(remaining)); + }, 2000); + return () => clearInterval(interval); + }, [done, startTime, completed, total]); + return (
@@ -81,6 +116,11 @@ export function BatchProgress({ {completed}/{total} + {eta && ( + + ~{eta} + + )} {!done && ( -
-
+
+
+
+
+ + {percent}% +
)} @@ -165,10 +210,22 @@ export function BatchProgress({
{done ? ( - + <> + {failedClassnames && failedClassnames.length > 0 && onRetry && ( + + )} + + ) : (
- {stats && ( + {stats && !activeSource && ( <> {stats.totalInDb} in DB @@ -767,6 +1076,26 @@ export function StudioClient({ )} + {cloneStats && activeSource && ( + <> + + {cloneStats.total} total + + + {" "} + {cloneStats.present} aanwezig + + {cloneStats.clonable > 0 && ( + + {cloneStats.clonable} te + importeren + + )} + + )}
@@ -848,11 +1177,17 @@ export function StudioClient({ - All statuses {notImportedCount > 0 && notImportedCount} + All statuses{" "} + {notImportedCount > 0 && `(${notImportedCount} te importeren)`} + Not imported Imported In catalog Missing nitro + Missing furnidata + + Missing catalog entry + @@ -1044,6 +1379,13 @@ export function StudioClient({
+ {cloneStats && cloneStats.clonable > 0 && !batchProgress && ( + + )} + {selectedNonImported > 0 && (
) : ( New @@ -1401,14 +1793,20 @@ export function StudioClient({ done={batchDone} succeeded={batchSucceeded} failed={batchFailed} - cancelling={batchCancelling} + cancelling={batchCancelling || cloneAllCancelling} verification={verification} - onCancel={cancelBatch} + startTime={batchStartTimeRef.current || undefined} + failedClassnames={ + failedClassnames.length > 0 ? failedClassnames : undefined + } + onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch} onDismiss={() => { setBatchProgress(null); setBatchDone(false); setVerification(null); + setFailedClassnames([]); }} + onRetry={activeSource ? retryFailed : undefined} /> ) : null} @@ -1586,6 +1984,8 @@ export function StudioClient({ onOpenChange={(open) => !open && setEditingClassname(null)} /> + {confirmDialog} + diff --git a/src/components/admin/studio/studio-types.ts b/src/components/admin/studio/studio-types.ts index 746ff8fc..dadfb130 100644 --- a/src/components/admin/studio/studio-types.ts +++ b/src/components/admin/studio/studio-types.ts @@ -8,6 +8,10 @@ export interface FurniItem { category: string; alreadyImported: boolean; nitroExists: boolean; + /** Has an entry in FurnitureData.json */ + hasFurnidata?: boolean; + /** Has an entry in catalog_items (SQL) */ + hasCatalogEntry?: boolean; /** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */ iconUrl?: string; }