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
This commit is contained in:
1 parent
1610aa1008
commit
1374bd332f
4 files changed
+580
-47
No files matched your search
@@ -11,6 +11,7 @@ import { getSource } from "@/lib/services/clone-sources";
|
|||||||
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
|
||||||
import {
|
import {
|
||||||
appendFurniEntriesBulk,
|
appendFurniEntriesBulk,
|
||||||
|
readFurniData,
|
||||||
rebuildCatalogOfferIds,
|
rebuildCatalogOfferIds,
|
||||||
reconcileFurniDataWithItemsBase,
|
reconcileFurniDataWithItemsBase,
|
||||||
verifyAndFixSpriteIds,
|
verifyAndFixSpriteIds,
|
||||||
@@ -311,6 +312,38 @@ export const GET = withAdmin(
|
|||||||
});
|
});
|
||||||
|
|
||||||
const sourceIconBase = source.iconBaseUrl?.trim().replace(/\/+$/, "");
|
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 enriched = items.map((item) => {
|
||||||
const imported = item.present;
|
const imported = item.present;
|
||||||
let nitroExists = false;
|
let nitroExists = false;
|
||||||
@@ -336,6 +369,8 @@ export const GET = withAdmin(
|
|||||||
category: String(item.category ?? ""),
|
category: String(item.category ?? ""),
|
||||||
alreadyImported: imported,
|
alreadyImported: imported,
|
||||||
nitroExists,
|
nitroExists,
|
||||||
|
hasFurnidata: imported ? furniDataSet.has(item.classname) : undefined,
|
||||||
|
hasCatalogEntry: imported ? catalogIdSet.has(item.id) : undefined,
|
||||||
iconUrl: sourceIconBase
|
iconUrl: sourceIconBase
|
||||||
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
|
? `${sourceIconBase}/${encodeURIComponent(item.classname)}_icon.png`
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -364,6 +399,37 @@ export const GET = withAdmin(
|
|||||||
allImported.map((e) => e.itemName).filter((n): n is string => !!n),
|
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({
|
const result = await listFurniture({
|
||||||
search: search || undefined,
|
search: search || undefined,
|
||||||
page,
|
page,
|
||||||
@@ -386,7 +452,13 @@ export const GET = withAdmin(
|
|||||||
);
|
);
|
||||||
nitroExists = existsSync(/*turbopackIgnore: true*/ p);
|
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 });
|
return apiOk({ items: enriched, meta: result.meta });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
import { Ban, Check, Loader2, ShieldCheck, X } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { cn } from "@/lib/utils";
|
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({
|
export function BatchProgress({
|
||||||
progress,
|
progress,
|
||||||
done,
|
done,
|
||||||
@@ -39,8 +47,11 @@ export function BatchProgress({
|
|||||||
failed,
|
failed,
|
||||||
cancelling,
|
cancelling,
|
||||||
verification,
|
verification,
|
||||||
|
startTime,
|
||||||
|
failedClassnames,
|
||||||
onCancel,
|
onCancel,
|
||||||
onDismiss,
|
onDismiss,
|
||||||
|
onRetry,
|
||||||
}: {
|
}: {
|
||||||
progress: Map<string, BatchItemStatus>;
|
progress: Map<string, BatchItemStatus>;
|
||||||
done: boolean;
|
done: boolean;
|
||||||
@@ -48,8 +59,11 @@ export function BatchProgress({
|
|||||||
failed: number;
|
failed: number;
|
||||||
cancelling?: boolean;
|
cancelling?: boolean;
|
||||||
verification?: VerificationSummary | null;
|
verification?: VerificationSummary | null;
|
||||||
|
startTime?: number;
|
||||||
|
failedClassnames?: string[];
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
onDismiss?: () => void;
|
onDismiss?: () => void;
|
||||||
|
onRetry?: (classnames: string[]) => void;
|
||||||
}) {
|
}) {
|
||||||
const entries = [...progress.entries()];
|
const entries = [...progress.entries()];
|
||||||
const total = entries.length;
|
const total = entries.length;
|
||||||
@@ -58,6 +72,27 @@ export function BatchProgress({
|
|||||||
).length;
|
).length;
|
||||||
const percent = total > 0 ? Math.round((completed / total) * 100) : 0;
|
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 (
|
return (
|
||||||
<div className="border-t border-[var(--admin-border)] p-3">
|
<div className="border-t border-[var(--admin-border)] p-3">
|
||||||
<div className="mb-2 flex items-center justify-between gap-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)]">
|
<span className="text-xs font-mono text-[var(--admin-text-muted)]">
|
||||||
{completed}/{total}
|
{completed}/{total}
|
||||||
</span>
|
</span>
|
||||||
|
{eta && (
|
||||||
|
<span className="text-xs font-mono text-[var(--admin-accent)]">
|
||||||
|
~{eta}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<Loader2
|
<Loader2
|
||||||
size={14}
|
size={14}
|
||||||
className="animate-spin text-[var(--admin-accent)]"
|
className="animate-spin text-[var(--admin-accent)]"
|
||||||
@@ -90,11 +130,16 @@ export function BatchProgress({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!done && (
|
{!done && (
|
||||||
<div className="mb-2 h-1.5 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
|
<div className="mb-2 flex items-center gap-2">
|
||||||
<div
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--admin-accent)]/10">
|
||||||
className="h-full rounded-full bg-[var(--admin-accent)] transition-all duration-300"
|
<div
|
||||||
style={{ width: `${percent}%` }}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -165,10 +210,22 @@ export function BatchProgress({
|
|||||||
|
|
||||||
<div className="mt-2 flex justify-end gap-2">
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
{done ? (
|
{done ? (
|
||||||
<Button size="sm" variant="secondary" onClick={onDismiss}>
|
<>
|
||||||
<Check size={14} />
|
{failedClassnames && failedClassnames.length > 0 && onRetry && (
|
||||||
Dismiss
|
<Button
|
||||||
</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
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
setFurnidataTranslateEnabled,
|
setFurnidataTranslateEnabled,
|
||||||
} from "@/actions/import-furni";
|
} from "@/actions/import-furni";
|
||||||
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
|
||||||
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
@@ -60,6 +61,7 @@ import {
|
|||||||
previewAutoCatalog,
|
previewAutoCatalog,
|
||||||
} from "@/lib/furni/auto-catalog";
|
} from "@/lib/furni/auto-catalog";
|
||||||
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
|
||||||
|
import { readSseStream } from "@/lib/sse-client";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
import type { TreeNode } from "@/types/catalog";
|
||||||
import { BatchProgress } from "./batch-progress";
|
import { BatchProgress } from "./batch-progress";
|
||||||
@@ -164,6 +166,20 @@ export function StudioClient({
|
|||||||
const [deleting, setDeleting] = useState<string | null>(null);
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
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)
|
// Nitro regeneration (missing .nitro)
|
||||||
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
|
||||||
new Set(),
|
new Set(),
|
||||||
@@ -237,17 +253,22 @@ export function StudioClient({
|
|||||||
if (categoryFilter !== "all") {
|
if (categoryFilter !== "all") {
|
||||||
result = result.filter((i) => i.category === categoryFilter);
|
result = result.filter((i) => i.category === categoryFilter);
|
||||||
}
|
}
|
||||||
if (statusFilter !== "all") {
|
if (statusFilter === "imported")
|
||||||
if (statusFilter === "imported")
|
result = result.filter((i) => i.alreadyImported);
|
||||||
result = result.filter((i) => i.alreadyImported);
|
else if (statusFilter === "inCatalog")
|
||||||
else if (statusFilter === "inCatalog")
|
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
||||||
result = result.filter((i) => i.alreadyImported && i.nitroExists);
|
else if (statusFilter === "missingNitro")
|
||||||
else if (statusFilter === "missingNitro")
|
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
||||||
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
|
else if (statusFilter === "missingFurnidata")
|
||||||
}
|
result = result.filter(
|
||||||
// Not imported filter - works independently (also with statusFilter === "all")
|
(i) => i.alreadyImported && i.hasFurnidata === false,
|
||||||
if (statusFilter === "notImported" || statusFilter === "all")
|
);
|
||||||
result = result.filter((i) => i.alreadyImported === 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];
|
const sorted = [...result];
|
||||||
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
else if (sortBy === "classname")
|
else if (sortBy === "classname")
|
||||||
@@ -325,6 +346,9 @@ export function StudioClient({
|
|||||||
setItems([]);
|
setItems([]);
|
||||||
setActiveSearch("");
|
setActiveSearch("");
|
||||||
setSearchTerm("");
|
setSearchTerm("");
|
||||||
|
setCloneStats(null);
|
||||||
|
setBatchProgress(null);
|
||||||
|
fetchCloneStats(sourceId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchSources = useCallback(async () => {
|
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(() => {
|
useEffect(() => {
|
||||||
fetchItems("", 1, activeSource);
|
fetchItems("", 1, activeSource);
|
||||||
fetchStats();
|
fetchStats();
|
||||||
@@ -492,6 +693,8 @@ export function StudioClient({
|
|||||||
setBatchSucceeded(0);
|
setBatchSucceeded(0);
|
||||||
setBatchFailed(0);
|
setBatchFailed(0);
|
||||||
setBatchCancelling(false);
|
setBatchCancelling(false);
|
||||||
|
setFailedClassnames([]);
|
||||||
|
batchStartTimeRef.current = Date.now();
|
||||||
|
|
||||||
const initial = new Map<string, BatchItemStatus>();
|
const initial = new Map<string, BatchItemStatus>();
|
||||||
for (const i of toImport) {
|
for (const i of toImport) {
|
||||||
@@ -565,6 +768,12 @@ export function StudioClient({
|
|||||||
: i,
|
: i,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
} else if (event.status === "failed") {
|
||||||
|
setFailedClassnames((prev) =>
|
||||||
|
prev.includes(event.classname)
|
||||||
|
? prev
|
||||||
|
: [...prev, event.classname],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else if (event.type === "batch_complete") {
|
} else if (event.type === "batch_complete") {
|
||||||
setBatchSucceeded(event.succeeded);
|
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() {
|
async function deleteItem() {
|
||||||
if (!deleting) return;
|
if (!deleting) return;
|
||||||
try {
|
try {
|
||||||
@@ -690,8 +996,11 @@ export function StudioClient({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const notImportedCount = useMemo(
|
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 (
|
return (
|
||||||
@@ -748,7 +1057,7 @@ export function StudioClient({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
{stats && (
|
{stats && !activeSource && (
|
||||||
<>
|
<>
|
||||||
<Badge variant="secondary" className="gap-1">
|
<Badge variant="secondary" className="gap-1">
|
||||||
<Package size={11} /> {stats.totalInDb} in DB
|
<Package size={11} /> {stats.totalInDb} in DB
|
||||||
@@ -767,6 +1076,26 @@ export function StudioClient({
|
|||||||
</Badge>
|
</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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
|
<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>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">
|
||||||
All statuses {notImportedCount > 0 && notImportedCount}
|
All statuses{" "}
|
||||||
|
{notImportedCount > 0 && `(${notImportedCount} te importeren)`}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
|
<SelectItem value="notImported">Not imported</SelectItem>
|
||||||
<SelectItem value="imported">Imported</SelectItem>
|
<SelectItem value="imported">Imported</SelectItem>
|
||||||
<SelectItem value="inCatalog">In catalog</SelectItem>
|
<SelectItem value="inCatalog">In catalog</SelectItem>
|
||||||
<SelectItem value="missingNitro">Missing nitro</SelectItem>
|
<SelectItem value="missingNitro">Missing nitro</SelectItem>
|
||||||
|
<SelectItem value="missingFurnidata">Missing furnidata</SelectItem>
|
||||||
|
<SelectItem value="missingCatalog">
|
||||||
|
Missing catalog entry
|
||||||
|
</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
@@ -1044,6 +1379,13 @@ export function StudioClient({
|
|||||||
|
|
||||||
<div className="flex-1" />
|
<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 && (
|
{selectedNonImported > 0 && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1162,17 +1504,42 @@ export function StudioClient({
|
|||||||
{TYPE_LABELS[item.type] ?? item.type}
|
{TYPE_LABELS[item.type] ?? item.type}
|
||||||
</Badge>
|
</Badge>
|
||||||
{item.alreadyImported ? (
|
{item.alreadyImported ? (
|
||||||
<Badge
|
<>
|
||||||
variant="secondary"
|
{!item.nitroExists && (
|
||||||
className={cn(
|
<Badge
|
||||||
"h-4",
|
variant="secondary"
|
||||||
item.nitroExists
|
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
>
|
||||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
No nitro
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
>
|
{item.hasFurnidata === false && (
|
||||||
{item.nitroExists ? "Imported" : "No nitro"}
|
<Badge
|
||||||
</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)]">
|
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||||
New
|
New
|
||||||
@@ -1286,17 +1653,42 @@ export function StudioClient({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1.5">
|
<td className="px-2 py-1.5">
|
||||||
{item.alreadyImported ? (
|
{item.alreadyImported ? (
|
||||||
<Badge
|
<div className="flex flex-wrap gap-0.5">
|
||||||
variant="secondary"
|
{!item.nitroExists && (
|
||||||
className={cn(
|
<Badge
|
||||||
"h-4",
|
variant="secondary"
|
||||||
item.nitroExists
|
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
|
||||||
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
|
>
|
||||||
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
|
No nitro
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
>
|
{item.hasFurnidata === false && (
|
||||||
{item.nitroExists ? "Imported" : "No nitro"}
|
<Badge
|
||||||
</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)]">
|
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
|
||||||
New
|
New
|
||||||
@@ -1401,14 +1793,20 @@ export function StudioClient({
|
|||||||
done={batchDone}
|
done={batchDone}
|
||||||
succeeded={batchSucceeded}
|
succeeded={batchSucceeded}
|
||||||
failed={batchFailed}
|
failed={batchFailed}
|
||||||
cancelling={batchCancelling}
|
cancelling={batchCancelling || cloneAllCancelling}
|
||||||
verification={verification}
|
verification={verification}
|
||||||
onCancel={cancelBatch}
|
startTime={batchStartTimeRef.current || undefined}
|
||||||
|
failedClassnames={
|
||||||
|
failedClassnames.length > 0 ? failedClassnames : undefined
|
||||||
|
}
|
||||||
|
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
||||||
onDismiss={() => {
|
onDismiss={() => {
|
||||||
setBatchProgress(null);
|
setBatchProgress(null);
|
||||||
setBatchDone(false);
|
setBatchDone(false);
|
||||||
setVerification(null);
|
setVerification(null);
|
||||||
|
setFailedClassnames([]);
|
||||||
}}
|
}}
|
||||||
|
onRetry={activeSource ? retryFailed : undefined}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
@@ -1586,6 +1984,8 @@ export function StudioClient({
|
|||||||
onOpenChange={(open) => !open && setEditingClassname(null)}
|
onOpenChange={(open) => !open && setEditingClassname(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{confirmDialog}
|
||||||
|
|
||||||
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ export interface FurniItem {
|
|||||||
category: string;
|
category: string;
|
||||||
alreadyImported: boolean;
|
alreadyImported: boolean;
|
||||||
nitroExists: 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. */
|
/** Custom-source preview icon (retro hotel CDN). Absent for official Habbo items. */
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user