Studio: add clone-all-per-hotel, per-item status detail, retry, ETA, and auto-translate
CI / runtime-diagnostics (push) Skipped
CI / check (push) Successful in 36s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m14s

- 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
This commit is contained in:
openhands committed 2026-09-01 17:29:12 +02:00
1 parent 1610aa1008
commit 1374bd332f
4 files changed
+580 -47

No files matched your search

+73 -1
View File
@@ -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<string>();
const catalogIdSet = new Set<number>();
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<string>();
const catalogIdSet = new Set<number>();
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 });
+66 -9
View File
@@ -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<string, BatchItemStatus>;
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<string | null>(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 (
<div className="border-t border-[var(--admin-border)] p-3">
<div className="mb-2 flex items-center justify-between gap-3">
@@ -81,6 +116,11 @@ export function BatchProgress({
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
{completed}/{total}
</span>
{eta && (
<span className="text-xs font-mono text-[var(--admin-accent)]">
~{eta}
</span>
)}
<Loader2
size={14}
className="animate-spin text-[var(--admin-accent)]"
@@ -90,11 +130,16 @@ export function BatchProgress({
</div>
{!done && (
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
<div className="mb-2 flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
<div
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs font-mono font-bold text-[var(--admin-text)]">
{percent}%
</span>
</div>
)}
@@ -165,10 +210,22 @@ export function BatchProgress({
<div className="mt-2 flex justify-end gap-2">
{done ? (
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
</Button>
<>
{failedClassnames && failedClassnames.length > 0 && onRetry && (
<Button
size="sm"
variant="outline"
onClick={() => onRetry(failedClassnames)}
>
<Loader2 size={14} />
Retry {failedClassnames.length} failed
</Button>
)}
<Button size="sm" variant="secondary" onClick={onDismiss}>
<Check size={14} />
Dismiss
</Button>
</>
) : (
<Button
size="sm"
+437 -37
View File
@@ -30,6 +30,7 @@ import {
setFurnidataTranslateEnabled,
} from "@/actions/import-furni";
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
@@ -60,6 +61,7 @@ import {
previewAutoCatalog,
} from "@/lib/furni/auto-catalog";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
@@ -164,6 +166,20 @@ export function StudioClient({
const [deleting, setDeleting] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
// Confirm dialog for clone all
const { confirm, dialog: confirmDialog } = useConfirmDialog();
// Clone source stats (shown when a clone source is active)
const [cloneStats, setCloneStats] = useState<{
total: number;
present: number;
clonable: number;
} | null>(null);
const [cloneAllCancelling, setCloneAllCancelling] = useState(false);
const cloneAllAbortRef = useRef<AbortController | null>(null);
const batchStartTimeRef = useRef<number>(0);
const [failedClassnames, setFailedClassnames] = useState<string[]>([]);
// Nitro regeneration (missing .nitro)
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
new Set(),
@@ -237,17 +253,22 @@ export function StudioClient({
if (categoryFilter !== "all") {
result = result.filter((i) => i.category === categoryFilter);
}
if (statusFilter !== "all") {
if (statusFilter === "imported")
result = result.filter((i) => i.alreadyImported);
else if (statusFilter === "inCatalog")
result = result.filter((i) => i.alreadyImported && i.nitroExists);
else if (statusFilter === "missingNitro")
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
}
// Not imported filter - works independently (also with statusFilter === "all")
if (statusFilter === "notImported" || statusFilter === "all")
result = result.filter((i) => i.alreadyImported === false);
if (statusFilter === "imported")
result = result.filter((i) => i.alreadyImported);
else if (statusFilter === "inCatalog")
result = result.filter((i) => i.alreadyImported && i.nitroExists);
else if (statusFilter === "missingNitro")
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
else if (statusFilter === "missingFurnidata")
result = result.filter(
(i) => i.alreadyImported && i.hasFurnidata === false,
);
else if (statusFilter === "missingCatalog")
result = result.filter(
(i) => i.alreadyImported && i.hasCatalogEntry === false,
);
else if (statusFilter === "notImported")
result = result.filter((i) => !i.alreadyImported);
const sorted = [...result];
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === "classname")
@@ -325,6 +346,9 @@ export function StudioClient({
setItems([]);
setActiveSearch("");
setSearchTerm("");
setCloneStats(null);
setBatchProgress(null);
fetchCloneStats(sourceId);
};
const fetchSources = useCallback(async () => {
@@ -339,6 +363,183 @@ export function StudioClient({
}
}, []);
const fetchCloneStats = useCallback(async (sourceId: string) => {
if (!sourceId) {
setCloneStats(null);
return;
}
try {
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(sourceId)}`,
);
if (res.ok) {
const data = await res.json();
setCloneStats(data.stats ?? null);
}
} catch {
/* ignore */
}
}, []);
async function cloneAllMissing() {
if (!activeSource) return;
// Fetch all clonable classnames
let classnames: string[] = [];
try {
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
);
const data = (await res.json()) as {
classnames?: string[];
error?: string;
};
if (!res.ok) {
toast.error(data.error || "Failed to fetch clonable items");
return;
}
classnames = data.classnames || [];
} catch {
toast.error("Network error fetching clonable items");
return;
}
if (classnames.length === 0) {
toast.info("Alles is al geïmporteerd voor deze bron");
return;
}
const sourceName =
sources.find((s) => s.id === activeSource)?.name || activeSource;
const confirmed = await confirm({
title: "Alles importeren",
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`,
confirmLabel: "Alles importeren",
});
if (!confirmed) return;
// Initialize progress tracking as a Map for BatchProgress compatibility
const initial = new Map<string, BatchItemStatus>();
for (const cn of classnames) {
initial.set(cn, { classname: cn, status: "pending" });
}
setBatchProgress(new Map(initial));
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setFailedClassnames([]);
setCloneAllCancelling(false);
batchStartTimeRef.current = Date.now();
const abort = new AbortController();
cloneAllAbortRef.current = abort;
const CHUNK = 400;
let done = 0;
let okCount = 0;
let failCount = 0;
try {
for (let i = 0; i < classnames.length; i += CHUNK) {
if (abort.signal.aborted) break;
const chunk = classnames.slice(i, i + CHUNK);
const res = await adminFetch("/api/admin/import/clone/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: activeSource,
classnames: chunk,
concurrency: 10,
}),
signal: abort.signal,
});
if (!res.ok || !res.body) {
toast.error(`Batch chunk mislukt (${res.status})`);
continue;
}
await readSseStream(
res.body,
(evt) => {
if (evt.type === "item_progress") {
const cn = String(evt.classname ?? "");
setBatchProgress((prev) => {
if (!prev) return prev;
const next = new Map(prev);
next.set(cn, {
classname: cn,
status: evt.status as BatchItemStatus["status"],
message: evt.message as string | undefined,
});
return next;
});
if (evt.status === "done" || evt.status === "failed") {
done++;
if (evt.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(cn) ? prev : [...prev, cn],
);
}
}
}
if (evt.type === "batch_complete") {
okCount += Number(evt.succeeded ?? 0);
failCount += Number(evt.failed ?? 0);
}
if (evt.type === "error") {
toast.error(
typeof evt.message === "string" ? evt.message : "Import fout",
);
}
},
abort.signal,
);
}
if (!abort.signal.aborted) {
setBatchSucceeded(okCount);
setBatchFailed(failCount);
setBatchDone(true);
if (okCount > 0) {
toast.success(
`${okCount} items geïmporteerd${
failCount > 0 ? ` (${failCount} mislukt)` : ""
}`,
);
}
if (failCount > 0 && okCount === 0) {
toast.error(`${failCount} items mislukt`);
}
fetchItems(activeSearch, 1, activeSource);
fetchStats();
fetchCloneStats(activeSource);
// Auto-translate after clone all if translation is enabled
if (translate && okCount > 0) {
toast.info("Automatisch vertalen van alle talen...");
buildAllLanguages();
}
} else {
toast.info("Clone all geannuleerd");
}
} catch (err) {
if ((err as Error)?.name === "AbortError") {
toast.info("Clone all geannuleerd");
} else {
toast.error("Verbindingsfout tijdens clone all");
}
} finally {
cloneAllAbortRef.current = null;
setCloneAllCancelling(false);
setSelected(new Set());
}
}
useEffect(() => {
fetchItems("", 1, activeSource);
fetchStats();
@@ -492,6 +693,8 @@ export function StudioClient({
setBatchSucceeded(0);
setBatchFailed(0);
setBatchCancelling(false);
setFailedClassnames([]);
batchStartTimeRef.current = Date.now();
const initial = new Map<string, BatchItemStatus>();
for (const i of toImport) {
@@ -565,6 +768,12 @@ export function StudioClient({
: i,
),
);
} else if (event.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(event.classname)
? prev
: [...prev, event.classname],
);
}
} else if (event.type === "batch_complete") {
setBatchSucceeded(event.succeeded);
@@ -607,6 +816,103 @@ export function StudioClient({
}
};
const cancelCloneAll = () => {
if (cloneAllAbortRef.current) {
setCloneAllCancelling(true);
cloneAllAbortRef.current.abort();
}
};
async function retryFailed(failed: string[]) {
if (!activeSource || failed.length === 0) return;
// For clone sources, re-run cloneAllMissing with only the failed classnames
setBatchProgress(new Map());
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setFailedClassnames([]);
batchStartTimeRef.current = Date.now();
const initial = new Map<string, BatchItemStatus>();
for (const cn of failed) {
initial.set(cn, { classname: cn, status: "pending" });
}
setBatchProgress(new Map(initial));
const abort = new AbortController();
cloneAllAbortRef.current = abort;
let done = 0;
let okCount = 0;
let failCount = 0;
try {
const res = await adminFetch("/api/admin/import/clone/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: activeSource,
classnames: failed,
concurrency: 10,
}),
signal: abort.signal,
});
if (!res.ok || !res.body) {
toast.error(`Retry mislukt (${res.status})`);
return;
}
await readSseStream(
res.body,
(evt) => {
if (evt.type === "item_progress") {
const cn = String(evt.classname ?? "");
setBatchProgress((prev) => {
if (!prev) return prev;
const next = new Map(prev);
next.set(cn, {
classname: cn,
status: evt.status as BatchItemStatus["status"],
message: evt.message as string | undefined,
});
return next;
});
if (evt.status === "done" || evt.status === "failed") {
done++;
if (evt.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(cn) ? prev : [...prev, cn],
);
}
}
}
if (evt.type === "batch_complete") {
okCount += Number(evt.succeeded ?? 0);
failCount += Number(evt.failed ?? 0);
}
},
abort.signal,
);
setBatchSucceeded(okCount);
setBatchFailed(failCount);
setBatchDone(true);
if (okCount > 0) {
toast.success(`${okCount} items opnieuw geïmporteerd`);
}
fetchItems(activeSearch, 1, activeSource);
fetchStats();
fetchCloneStats(activeSource);
} catch (err) {
if ((err as Error)?.name !== "AbortError") {
toast.error("Verbindingsfout tijdens retry");
}
} finally {
cloneAllAbortRef.current = null;
}
}
async function deleteItem() {
if (!deleting) return;
try {
@@ -690,8 +996,11 @@ export function StudioClient({
);
const notImportedCount = useMemo(
() => filteredItems.filter((i) => i.alreadyImported === false).length,
[filteredItems],
() =>
activeSource && cloneStats
? cloneStats.clonable
: filteredItems.filter((i) => i.alreadyImported === false).length,
[filteredItems, activeSource, cloneStats],
);
return (
@@ -748,7 +1057,7 @@ export function StudioClient({
</div>
<div className="flex flex-wrap items-center gap-1.5">
{stats && (
{stats && !activeSource && (
<>
<Badge variant="secondary" className="gap-1">
<Package size={11} /> {stats.totalInDb} in DB
@@ -767,6 +1076,26 @@ export function StudioClient({
</Badge>
</>
)}
{cloneStats && activeSource && (
<>
<Badge variant="secondary" className="gap-1">
<Package size={11} /> {cloneStats.total} total
</Badge>
<Badge variant="secondary" className="gap-1">
<Check size={11} className="text-[var(--admin-success)]" />{" "}
{cloneStats.present} aanwezig
</Badge>
{cloneStats.clonable > 0 && (
<Badge
variant="secondary"
className="gap-1 text-[var(--admin-accent)] border-[var(--admin-accent)]/40"
>
<CloudDownload size={11} /> {cloneStats.clonable} te
importeren
</Badge>
)}
</>
)}
</div>
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
@@ -848,11 +1177,17 @@ export function StudioClient({
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
All statuses {notImportedCount > 0 && notImportedCount}
All statuses{" "}
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
</SelectItem>
<SelectItem value="notImported">Not imported</SelectItem>
<SelectItem value="imported">Imported</SelectItem>
<SelectItem value="inCatalog">In catalog</SelectItem>
<SelectItem value="missingNitro">Missing nitro</SelectItem>
<SelectItem value="missingFurnidata">Missing furnidata</SelectItem>
<SelectItem value="missingCatalog">
Missing catalog entry
</SelectItem>
</SelectContent>
</Select>
@@ -1044,6 +1379,13 @@ export function StudioClient({
<div className="flex-1" />
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
<CloudDownload size={14} />
Alles importeren ({cloneStats.clonable})
</Button>
)}
{selectedNonImported > 0 && (
<Button
size="sm"
@@ -1162,17 +1504,42 @@ export function StudioClient({
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
<>
{!item.nitroExists && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No nitro
</Badge>
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
{item.hasFurnidata === false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No furnidata
</Badge>
)}
{item.hasCatalogEntry === false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No catalog
</Badge>
)}
{item.nitroExists &&
item.hasFurnidata !== false &&
item.hasCatalogEntry !== false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-success)] border-[var(--admin-success)]/40"
>
Imported
</Badge>
)}
</>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
@@ -1286,17 +1653,42 @@ export function StudioClient({
</td>
<td className="px-2 py-1.5">
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
<div className="flex flex-wrap gap-0.5">
{!item.nitroExists && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No nitro
</Badge>
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
{item.hasFurnidata === false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No furnidata
</Badge>
)}
{item.hasCatalogEntry === false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No catalog
</Badge>
)}
{item.nitroExists &&
item.hasFurnidata !== false &&
item.hasCatalogEntry !== false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-success)] border-[var(--admin-success)]/40"
>
Imported
</Badge>
)}
</div>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
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}
</main>
@@ -1586,6 +1984,8 @@ export function StudioClient({
onOpenChange={(open) => !open && setEditingClassname(null)}
/>
{confirmDialog}
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<DialogContent>
<DialogHeader>
@@ -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;
}