"use client"; import { ArrowUpDown, Check, CircleAlert, CloudDownload, Download, Globe, Languages, Layers, LayoutGrid, List, Loader2, Package, PanelLeftClose, PanelLeftOpen, Pencil, RefreshCw, Search, Sparkles, Trash2, WandSparkles, X, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { deleteImportedFurni, 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"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Switch } from "@/components/ui/switch"; import { adminFetch } from "@/lib/admin-fetch"; import { type AutoCatalogPreview, 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"; import { CatalogRail } from "./catalog-rail"; import { CheckboxDot } from "./checkbox-dot"; import { getFurniImageUrl, handleImgError, TYPE_LABELS, } from "./studio-furni-icons"; import type { BatchItemStatus, FurniItem, FurniStats, Meta, VerificationSummary, } from "./studio-types"; // Language codes that get a FurnitureData_.json file. Mirrors // FURNIDATA_LANGUAGES in furni-data-i18n (kept client-safe here so we don't pull // server-only fs modules into the browser bundle). const TRANSLATE_LANGS = [ "nl", "en", "de", "fr", "es", "tr", "it", "pt", "fi", "pl", "ru", "ar", "ja", ] as const; export function StudioClient({ source, initialTree, defaultTranslate, }: { source: FurniImportSource; initialTree: TreeNode[]; defaultTranslate: boolean; }) { // Catalog tree const [tree] = useState(initialTree); const [selectedPage, setSelectedPage] = useState(null); const [railOpen, setRailOpen] = useState(true); // Search & list const [searchTerm, setSearchTerm] = useState(""); const [activeSearch, setActiveSearch] = useState(""); const [items, setItems] = useState([]); const [meta, setMeta] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); // Custom clone sources (retro hotels); empty string = official Habbo furnidata const [sources, setSources] = useState>( [], ); const [activeSource, setActiveSource] = useState(""); const [activeSourceName, setActiveSourceName] = useState(""); // Filters const [typeFilter, setTypeFilter] = useState("all"); const [categoryFilter, setCategoryFilter] = useState("all"); const [statusFilter, setStatusFilter] = useState("all"); const [sortBy, setSortBy] = useState("default"); const [viewMode, setViewMode] = useState<"grid" | "list">("grid"); const searchRef = useRef(null); const debounceRef = useRef | null>(null); // Stats const [stats, setStats] = useState(null); // Selection + detail const [selected, setSelected] = useState>(new Set()); const [detail, setDetail] = useState(null); // Import state const [importingId, setImportingId] = useState(null); const [batchProgress, setBatchProgress] = useState | null>(null); const [batchDone, setBatchDone] = useState(false); const batchDoneRef = useRef(false); const markBatchDone = useCallback(() => { batchDoneRef.current = true; markBatchDone(); }, []); const [batchSucceeded, setBatchSucceeded] = useState(0); const [batchFailed, setBatchFailed] = useState(0); const [batchCancelling, setBatchCancelling] = useState(false); const [verification, setVerification] = useState( null, ); const batchAbortRef = useRef(null); // Nitro editor const [editingClassname, setEditingClassname] = useState(null); // Delete confirm const [deleting, setDeleting] = useState(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(null); const batchStartTimeRef = useRef(0); const [failedClassnames, setFailedClassnames] = useState([]); // Nitro regeneration (missing .nitro) const [regeneratingNitro, setRegeneratingNitro] = useState>( new Set(), ); // Translation controls (per-import toggle + language selection, plus a global // default that persists to the site setting and seeds the per-import toggle). const ALL_LANGS = TRANSLATE_LANGS as readonly string[]; const [translate, setTranslate] = useState(defaultTranslate); const [translateLangs, setTranslateLangs] = useState([ ...ALL_LANGS, ]); const [langPickerOpen, setLangPickerOpen] = useState(false); const [buildingLanguages, setBuildingLanguages] = useState(false); const [globalTranslate, setGlobalTranslate] = useState(defaultTranslate); // Build every language for the whole catalog at once (used to translate // everything after importing with translation disabled for speed). const buildAllLanguages = async () => { setBuildingLanguages(true); try { const res = await adminFetch( "/api/admin/import/furni?action=build-languages", ); const data = (await res.json()) as { languages?: Array<{ lang: string; ok: boolean }>; error?: string; }; if (res.ok) { const langs = data.languages ?? []; const okCount = langs.filter((l) => l.ok).length; toast.success( `Alle talen vertaald (${okCount}/${langs.length} gelukt)`, ); } else { toast.error(data.error ?? "Vertaling mislukt"); } } catch { toast.error("Netwerkfout bij het vertalen van alle talen"); } finally { setBuildingLanguages(false); } }; const toggleLang = (lang: string) => setTranslateLangs((prev) => prev.includes(lang) ? prev.filter((l) => l !== lang) : [...prev, lang], ); const onGlobalTranslateChange = async (next: boolean) => { setGlobalTranslate(next); setTranslate(next); try { await setFurnidataTranslateEnabled({ enabled: next }); } catch { toast.error("Kon de globale vertaal-instelling niet opslaan"); } }; const categories = useMemo(() => { const cats = new Set(items.map((i) => i.category).filter(Boolean)); return ["all", ...Array.from(cats).sort()]; }, [items]); const filteredItems = useMemo(() => { let result = items; if (typeFilter !== "all") { result = result.filter((i) => i.type === typeFilter); } if (categoryFilter !== "all") { result = result.filter((i) => i.category === categoryFilter); } 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") sorted.sort((a, b) => a.classname.localeCompare(b.classname)); else if (sortBy === "category") sorted.sort((a, b) => (a.category || "").localeCompare(b.category || "")); else if (sortBy === "imported-first") sorted.sort( (a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0), ); else if (sortBy === "not-imported-first") sorted.sort( (a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0), ); return sorted; }, [items, typeFilter, categoryFilter, statusFilter, sortBy]); const allSelected = useMemo( () => filteredItems.length > 0 && filteredItems.every((i) => selected.has(i.classname)), [filteredItems, selected], ); const fetchStats = useCallback(async () => { try { const res = await adminFetch("/api/admin/import/furni?action=stats"); if (res.ok) { const data = await res.json(); setStats(data); } } catch { /* ignore */ } }, []); const fetchItems = useCallback( async (search: string, page: number, source?: string) => { setLoading(true); setError(""); try { const qs = new URLSearchParams({ page: String(page) }); if (search) qs.set("search", search); if (source) qs.set("source", source); const res = await adminFetch( `/api/admin/import/furni?${qs.toString()}`, ); if (!res.ok) { setError(`Request failed (${res.status})`); setLoading(false); return; } const data = await res.json(); if (page === 1) { setItems(data.items ?? []); } else { setItems((prev) => [...prev, ...(data.items ?? [])]); } setMeta(data.meta ?? null); } catch { setError("Network error"); } finally { setLoading(false); } }, [], ); const changeSource = (sourceId: string) => { setActiveSource(sourceId); setActiveSourceName( sourceId ? (sources.find((s) => s.id === sourceId)?.name ?? "") : "", ); setSelected(new Set()); setItems([]); setActiveSearch(""); setSearchTerm(""); setCloneStats(null); setBatchProgress(null); fetchCloneStats(sourceId); }; const fetchSources = useCallback(async () => { try { const res = await adminFetch("/api/admin/import/clone?action=sources"); if (res.ok) { const data = await res.json(); setSources(data.sources ?? []); } } catch { /* ignore */ } }, []); 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(); for (const cn of classnames) { initial.set(cn, { classname: cn, status: "pending" }); } setBatchProgress(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 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 isLast = i + CHUNK >= classnames.length; 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, final: isLast, }), 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") { 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); markBatchDone(); 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(); fetchSources(); }, [fetchItems, fetchStats, fetchSources, activeSource]); // Debounced auto-search (also triggered by the "/" shortcut focus). useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { if (searchTerm !== activeSearch) { setActiveSearch(searchTerm); fetchItems(searchTerm, 1, activeSource); } }, 400); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchTerm, fetchItems, activeSearch, activeSource]); // Keep detail in sync with the refreshed list. useEffect(() => { if (!detail) return; const updated = items.find((i) => i.classname === detail.classname); if (updated) setDetail(updated); }, [items, detail]); const clearSearch = () => { setSearchTerm(""); setActiveSearch(""); fetchItems("", 1, activeSource); }; const submitSearch = () => { setActiveSearch(searchTerm); fetchItems(searchTerm, 1, activeSource); }; function toggleSelect(classname: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(classname)) next.delete(classname); else next.add(classname); return next; }); } const toggleAll = useCallback(() => { if (allSelected) { const toRemove = new Set(filteredItems.map((i) => i.classname)); setSelected((prev) => { const next = new Set(prev); for (const cn of toRemove) next.delete(cn); return next; }); } else { setSelected((prev) => { const next = new Set(prev); for (const i of filteredItems) next.add(i.classname); return next; }); } }, [allSelected, filteredItems]); // Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear. useEffect(() => { function onKeyDown(e: KeyboardEvent) { const tag = (e.target as HTMLElement)?.tagName; const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) { e.preventDefault(); searchRef.current?.focus(); } if (e.key === "a" && (e.metaKey || e.ctrlKey) && !isInput) { e.preventDefault(); toggleAll(); } if (e.key === "Escape" && !isInput) { setSelected(new Set()); searchRef.current?.blur(); } } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [toggleAll]); async function importSingle(item: FurniItem) { setImportingId(item.classname); try { const res = await adminFetch("/api/admin/import/furni", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: item.id, classname: item.classname, name: item.name, description: item.description, type: item.type, revision: item.revision, category: item.category, sourceId: activeSource || undefined, translate, langs: translate && translateLangs.length > 0 ? translateLangs : undefined, }), }); const data = await res.json(); if (res.ok) { setItems((prev) => prev.map((i) => i.classname === item.classname ? { ...i, alreadyImported: true, nitroExists: true } : i, ), ); if (detail?.classname === item.classname) { setDetail({ ...item, alreadyImported: true, nitroExists: true }); } fetchStats(); const parts = [`${item.classname} imported into the catalog`]; if (typeof data.furniDataFixedIds === "number") { parts.push( `verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`, ); } if (data.furniDataMissing > 0) { parts.push(`${data.furniDataMissing} furnidata missing`); } toast.success(parts.join(" · ")); } else { toast.error(data.error || `Import failed for ${item.classname}`); } } catch { toast.error("Network error"); } finally { setImportingId(null); } } async function importBatch() { const toImport = filteredItems.filter( (i) => selected.has(i.classname) && !i.alreadyImported, ); if (toImport.length === 0) { toast.info("Select furni that isn't imported yet"); return; } setBatchDone(false); setBatchSucceeded(0); setBatchFailed(0); setBatchCancelling(false); setFailedClassnames([]); batchStartTimeRef.current = Date.now(); const initial = new Map(); for (const i of toImport) { initial.set(i.classname, { classname: i.classname, status: "pending" }); } setBatchProgress(initial); const abort = new AbortController(); batchAbortRef.current = abort; try { const res = await adminFetch("/api/admin/import/furni/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ sourceId: activeSource || undefined, items: toImport.map((i) => ({ id: i.id, classname: i.classname, name: i.name, description: i.description, type: i.type, revision: i.revision, category: i.category, })), concurrency: 3, translate, langs: translate && translateLangs.length > 0 ? translateLangs : undefined, }), signal: abort.signal, }); if (!res.ok || !res.body) { toast.error(`Batch import mislukt (${res.status})`); setBatchProgress(null); return; } const reader = res.body.getReader(); const decoder = new TextDecoder(); let buffer = ""; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split("\n"); buffer = lines.pop() || ""; for (const line of lines) { if (!line.startsWith("data: ")) continue; try { const event = JSON.parse(line.slice(6)); if (event.type === "item_progress") { setBatchProgress((prev) => { if (!prev) return prev; const next = new Map(prev); next.set(event.classname, { classname: event.classname, status: event.status, message: event.message, warnings: event.warnings, itemId: event.itemId, }); return next; }); if (event.status === "done") { setItems((prev) => prev.map((i) => i.classname === event.classname ? { ...i, alreadyImported: true, nitroExists: true } : 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); setBatchFailed(event.failed); setVerification({ offerIdsFixed: event.offerIdsFixed ?? 0, furniDataFixedIds: event.furniDataFixedIds ?? 0, furniDataMissing: event.furniDataMissing ?? 0, furniDataConflicts: event.furniDataConflicts ?? 0, nitrosSynced: event.nitrosSynced ?? [], iconsSynced: event.iconsSynced ?? [], ownershipFixed: event.ownershipFixed ?? [], }); markBatchDone(); } } catch { /* skip malformed */ } } } } catch (err) { if ((err as Error).name === "AbortError") { toast.info("Batch import cancelled"); } else { toast.error("Connection lost during batch import"); } } finally { batchAbortRef.current = null; setBatchCancelling(false); setSelected(new Set()); fetchStats(); if (!batchDoneRef.current) setBatchProgress(null); } } const cancelBatch = () => { if (batchAbortRef.current) { setBatchCancelling(true); batchAbortRef.current.abort(); } }; 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 setBatchDone(false); setBatchSucceeded(0); setBatchFailed(0); setFailedClassnames([]); batchStartTimeRef.current = Date.now(); const initial = new Map(); for (const cn of failed) { initial.set(cn, { classname: cn, status: "pending" }); } setBatchProgress(initial); const abort = new AbortController(); cloneAllAbortRef.current = abort; 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, final: true, }), signal: abort.signal, }); if (!res.ok || !res.body) { toast.error(`Retry mislukt (${res.status})`); setBatchProgress(null); 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") { 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); markBatchDone(); 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"); setBatchProgress(null); } } finally { cloneAllAbortRef.current = null; } } async function deleteItem() { if (!deleting) return; try { const result = await deleteImportedFurni({ classname: deleting }); if (result.ok) { const data = result.data as { deletedFiles: string[]; errors?: string[]; }; setItems((prev) => prev.filter((i) => i.classname !== deleting)); setSelected((prev) => { const next = new Set(prev); next.delete(deleting); return next; }); if (detail?.classname === deleting) setDetail(null); fetchStats(); toast.success( `Deleted ${deleting} (${data.deletedFiles?.length ?? 0} files)`, ); } else { toast.error(result.error || "Delete failed"); } } catch { toast.error("Network error"); } finally { setDeleting(null); setConfirmDelete(false); } } async function regenNitro(item: FurniItem) { setRegeneratingNitro((prev) => new Set(prev).add(item.classname)); try { const res = await adminFetch("/api/admin/import/furni", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: [{ classname: item.classname, revision: item.revision }], }), }); const data = await res.json(); if (res.ok && data.succeeded > 0) { setItems((prev) => prev.map((i) => i.classname === item.classname ? { ...i, nitroExists: true } : i, ), ); toast.success(`Nitro file regenerated for ${item.classname}`); } else { toast.error( data.results?.[0]?.warning || data.error || "Regeneration failed", ); } } catch { toast.error("Network error"); } finally { setRegeneratingNitro((prev) => { const next = new Set(prev); next.delete(item.classname); return next; }); } } const [bulkDeleting, setBulkDeleting] = useState(false); const [bulkRegenning, setBulkRegenning] = useState(false); async function bulkDeleteSelected() { if (selectedImported.length === 0) return; const confirmed = await confirm({ title: `${selectedImported.length} items verwijderen?`, description: `Alle ${selectedImported.length} geselecteerde geïmporteerde items worden permanent verwijderd uit de database en het bestandssysteem.`, confirmLabel: "Verwijderen", variant: "danger", }); if (!confirmed) return; setBulkDeleting(true); let deleted = 0; let failed = 0; try { for (const item of selectedImported) { try { const result = await deleteImportedFurni({ classname: item.classname, }); if (result.ok) { deleted++; } else { failed++; } } catch { failed++; } } setItems((prev) => prev.filter((i) => !selected.has(i.classname))); setSelected(new Set()); fetchStats(); toast.success( `Verwijderd: ${deleted}${failed > 0 ? `, mislukt: ${failed}` : ""}`, ); } catch { toast.error("Bulk delete mislukt"); } finally { setBulkDeleting(false); } } async function bulkRegenNitro() { if (selectedMissingNitro.length === 0) return; setBulkRegenning(true); const cns = selectedMissingNitro.map((i) => i.classname); setRegeneratingNitro((prev) => { const next = new Set(prev); for (const cn of cns) next.add(cn); return next; }); try { const res = await adminFetch("/api/admin/import/furni", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: selectedMissingNitro.map((i) => ({ classname: i.classname, revision: i.revision, })), }), }); const data = await res.json(); if (res.ok) { const succeededClassnames = new Set( (data.results ?? []) .filter((r: { ok: boolean }) => r.ok) .map((r: { classname: string }) => r.classname), ); setItems((prev) => prev.map((i) => succeededClassnames.has(i.classname) ? { ...i, nitroExists: true } : i, ), ); const succeeded = Number(data.succeeded ?? 0); const failed = Number(data.failed ?? 0); toast.success( `Nitro geregenereerd: ${succeeded}${failed > 0 ? `, mislukt: ${failed}` : ""}`, ); } else { toast.error(data.error || "Regeneratie mislukt"); } } catch { toast.error("Network error"); } finally { setRegeneratingNitro((prev) => { const next = new Set(prev); for (const cn of cns) next.delete(cn); return next; }); setBulkRegenning(false); } } const detailPreview = useMemo(() => { if (!detail) return null; return previewAutoCatalog( detail.classname, detail.type === "wallitem" ? "i" : "s", ); }, [detail]); const selectedNonImported = useMemo( () => filteredItems.filter( (i) => selected.has(i.classname) && !i.alreadyImported, ).length, [filteredItems, selected], ); const missingNitroCount = useMemo( () => filteredItems.filter((i) => i.alreadyImported && !i.nitroExists).length, [filteredItems], ); const selectedImported = useMemo( () => filteredItems.filter( (i) => selected.has(i.classname) && i.alreadyImported, ), [filteredItems, selected], ); const selectedMissingNitro = useMemo( () => selectedImported.filter((i) => !i.nitroExists), [selectedImported], ); const notImportedCount = useMemo( () => activeSource && cloneStats ? cloneStats.clonable : filteredItems.filter((i) => i.alreadyImported === false).length, [filteredItems, activeSource, cloneStats], ); return (
{/* ── Header ─────────────────────────────────────────── */}

Studio

{activeSourceName || source.label} furnidata

setSearchTerm(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") submitSearch(); if (e.key === "Escape") clearSearch(); }} placeholder="Search furni, classnames, keywords… (/)" className="pl-8 pr-8" /> {searchTerm ? ( ) : null}
{meta && ( {meta.total.toLocaleString()} results )}
{stats && !activeSource && ( <> {stats.totalInDb} in DB {stats.inCatalog} in catalog 0 && "text-[var(--admin-warning)] border-[var(--admin-warning)]/40", )} > {stats.missingNitro} missing nitro )} {cloneStats && activeSource && ( <> {cloneStats.total} total {" "} {cloneStats.present} aanwezig {cloneStats.clonable > 0 && ( {cloneStats.clonable} te importeren )} )}
Vertaling (globaal)
{/* ── Filters + actions ─────────────────────────────── */}
{missingNitroCount > 0 && ( )}
{selected.size > 0 && ( {selected.size} selected )} {selected.size > 0 && ( )} {/* ── Translate control (per-import) ─────────────── */}
Vertalen
Talen
{ALL_LANGS.map((lang) => { const on = translateLangs.includes(lang); return ( ); })}
{cloneStats && cloneStats.clonable > 0 && !batchProgress && ( )} {selectedNonImported > 0 && ( )} {selectedMissingNitro.length > 0 && !batchProgress && ( )} {selectedImported.length > 0 && !batchProgress && ( )}
{/* ── Body: rail + grid + drawer ────────────────────── */}
{railOpen ? ( ) : null}
{loading && items.length === 0 ? (
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
))}
) : error ? (
{error}
) : filteredItems.length === 0 ? (

{activeSearch ? `No furni found for "${activeSearch}"` : "No furni to show yet"}

{activeSearch ? "Try a different search or clear your filters." : "Search a furni library to get started."}

) : ( <> {viewMode === "grid" ? (
{filteredItems.map((item) => { const isSel = selected.has(item.classname); return (
); })}
) : (
{filteredItems.map((item) => { const isSel = selected.has(item.classname); return ( ); })}
Name Classname Type Category Status Actions
toggleSelect(item.classname) } aria-label={`Select ${item.classname}`} /> {item.name} {item.classname} {TYPE_LABELS[item.type] ?? item.type} {item.category || "—"} {item.alreadyImported ? (
{!item.nitroExists && ( No nitro )} {item.hasFurnidata === false && ( No furnidata )} {item.hasCatalogEntry === false && ( No catalog )} {item.nitroExists && item.hasFurnidata !== false && item.hasCatalogEntry !== false && ( Imported )}
) : ( New )}
{!item.alreadyImported && ( )} {item.alreadyImported && item.nitroExists && ( )} {item.alreadyImported && !item.nitroExists && ( )}
)} {meta && meta.currentPage < meta.lastPage && (
)} )}
{batchProgress ? ( 0 ? failedClassnames : undefined } onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch} onDismiss={() => { setBatchProgress(null); setBatchDone(false); setVerification(null); setFailedClassnames([]); }} onRetry={activeSource ? retryFailed : undefined} /> ) : null}
{/* ── Detail drawer ─────────────────────────────── */} {detail ? ( ) : null}
!open && setEditingClassname(null)} /> {confirmDialog} Delete imported furni

This permanently removes{" "} {deleting} {" "} from items_base and the catalog, plus its asset files.

); }