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 {
|
||||
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 });
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user