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

+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>