"use client"; import { useVirtualizer } from "@tanstack/react-virtual"; import { ArrowDown, ArrowUp, ArrowUpDown, Check, CircleAlert, CloudDownload, FileJson2, Globe, Languages, Layers, LayoutGrid, List, ListChecks, Loader2, Package, PanelLeftClose, PanelLeftOpen, Pencil, RefreshCw, Search, Sparkles, Trash2, X, } from "lucide-react"; import { motion } from "motion/react"; import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { toast } from "sonner"; import { deleteImportedFurni } from "@/actions/import-furni"; 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 { refreshCatalogTree, useCatalogSync, useLiveCatalog, } from "@/features/catalog/client/use-live-catalog"; import { adminFetch } from "@/lib/admin-fetch"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { readSseStream } from "@/lib/sse-client"; import { cn } from "@/lib/utils"; import { BatchProgress } from "./batch-progress"; import { CatalogRail } from "./catalog-rail"; import { CheckboxDot } from "./checkbox-dot"; import { FurnitureJobHistory } from "./furniture-jobs"; import { filterFurniture } from "./studio-filters"; import { getFurniImageUrl, handleImgError, TYPE_LABELS, } from "./studio-furni-icons"; import { StudioPanelLoading } from "./studio-panel-loading"; import type { BatchItemStatus, FurniItem, FurniStats, Meta, VerificationSummary, } from "./studio-types"; import { useFurnitureJobs } from "./use-furniture-jobs"; import { useStudioTranslations } from "./use-studio-translations"; const NitroEditorDialog = lazy(() => import("@/app/admin/import/furni/nitro-editor-dialog").then((module) => ({ default: module.NitroEditorDialog, })), ); const FurnitureDetailDrawer = lazy(() => import("./furniture-detail-drawer").then((module) => ({ default: module.FurnitureDetailDrawer, })), ); const ImportReview = lazy(() => import("./import-review").then((module) => ({ default: module.ImportReview, })), ); // Grid virtualization geometry (px) — matches the grid-card markup below. const GRID_CARD_W = 150; const GRID_GAP = 12; const GRID_ROW_H = 176; // Persisted UI preferences (survive reloads). const LS_VIEW_MODE = "studio:viewMode"; const LS_SORT_BY = "studio:sortBy"; const SORT_KEYS = [ "default", "name", "classname", "category", "imported-first", "not-imported-first", ] as const; function readLs( key: string, fallback: T, valid: ReadonlyArray, ): T { if (typeof window === "undefined") return fallback; try { const raw = window.localStorage.getItem(key); if (!raw) return fallback; return valid.includes(raw as T) ? (raw as T) : fallback; } catch { return fallback; } } function writeLs(key: string, value: string) { try { window.localStorage.setItem(key, value); } catch { /* private mode / quota — ignore */ } } // Wraps the first case-insensitive match of `query` inside `text` in an accent // mark so users can see where their search hit. function Highlighted({ text, query }: { text: string; query: string }) { if (!query) return text; const index = text.toLowerCase().indexOf(query.toLowerCase()); if (index === -1) return text; return ( <> {text.slice(0, index)} {text.slice(index, index + query.length)} {text.slice(index + query.length)} ); } // Table layout: a shared grid template keeps the sticky header and each // virtualized row in perfect column alignment. const TABLE_COLS = "36px 48px minmax(0,1.4fr) minmax(0,1fr) minmax(0,0.6fr) minmax(0,0.7fr) minmax(0,1fr) 112px"; const TABLE_ROW_H = 40; export function StudioClient({ actorId, source, defaultTranslate, }: { actorId?: number; source: FurniImportSource; defaultTranslate: boolean; }) { const [review, setReview] = useState<{ items: FurniItem[]; single: boolean; cloneAll?: boolean; } | null>(null); const jobs = useFurnitureJobs( () => { void fetchItems(activeSearch, 1, activeSource); void fetchStats(); // A durable job derives its "Imported Furniture" pages server-side, so // the rail cannot predict them either — pull the tree and totals. void refreshCatalogTree(); }, false, actorId, ); const [preparingReview, setPreparingReview] = useState(false); // Catalog tree — read from the live store so pages created by an import show // up without re-rendering the route. const tree = useLiveCatalog().tree; const [selectedPage, setSelectedPage] = useState(null); const [railOpen, setRailOpen] = useState(false); useCatalogSync(); useEffect(() => { setRailOpen(window.matchMedia("(min-width: 1024px)").matches); }, []); // 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(() => readLs(LS_SORT_BY, "default", SORT_KEYS), ); const [viewMode, setViewMode] = useState<"grid" | "list">(() => readLs(LS_VIEW_MODE, "list", ["grid", "list"] as const), ); const changeViewMode = (mode: "grid" | "list") => { setViewMode(mode); writeLs(LS_VIEW_MODE, mode); }; const changeSortBy = (value: string) => { setSortBy(value); writeLs(LS_SORT_BY, value); }; 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); const listRef = useRef(null); // Mirrors `items` synchronously so "select all results" can read the // fully-loaded list right after the last page resolves. const itemsRef = useRef([]); // Bumped when the list query changes (search/source) to cancel a running // bulk "select all" loop. const listGenRef = useRef(0); const detailOrigin = useRef<{ top: number; trigger: HTMLElement | null; } | null>(null); const openDetail = (item: FurniItem) => { detailOrigin.current = { top: listRef.current?.scrollTop ?? 0, trigger: document.activeElement instanceof HTMLElement ? document.activeElement : null, }; setDetail(item); }; useLayoutEffect(() => { if (detail || !detailOrigin.current) return; const origin = detailOrigin.current; detailOrigin.current = null; if (listRef.current) listRef.current.scrollTop = origin.top; if (origin.trigger?.isConnected) origin.trigger.focus({ preventScroll: true }); }, [detail]); const sourceGeneration = useRef(0); const listRequest = useRef(null); useEffect(() => () => listRequest.current?.abort(), []); // Import state const importingId = jobs.busy ? (review?.items[0]?.classname ?? null) : null; const [batchProgress, setBatchProgress] = useState | null>(null); const [batchDone, setBatchDone] = useState(false); const batchDoneRef = useRef(false); const markBatchDone = useCallback(() => { batchDoneRef.current = true; setBatchDone(true); }, []); const [batchSucceeded, setBatchSucceeded] = useState(0); const [batchFailed, setBatchFailed] = useState(0); const [batchCancelling, setBatchCancelling] = useState(false); const [verification, setVerification] = useState( null, ); const batchAbortRef = useRef(null); // How many items the batch endpoints process in parallel. const [concurrency, setConcurrency] = useState(4); // 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(), ); const { ALL_LANGS, translate, setTranslate, translateLangs, setTranslateLangs, langPickerOpen, setLangPickerOpen, buildingLanguages, globalTranslate, buildAllLanguages, toggleLang, onGlobalTranslateChange, } = useStudioTranslations(defaultTranslate); const categories = useMemo(() => { const cats = new Set(items.map((i) => i.category).filter(Boolean)); return ["all", ...Array.from(cats).sort()]; }, [items]); const filteredItems = useMemo( () => filterFurniture(items, { typeFilter, categoryFilter, statusFilter, sortBy, }), [items, typeFilter, categoryFilter, statusFilter, sortBy], ); const allSelected = useMemo( () => filteredItems.length > 0 && filteredItems.every((i) => selected.has(i.classname)), [filteredItems, selected], ); const hasMorePages = meta ? meta.currentPage < meta.lastPage : false; // ── Grid virtualization ───────────────────────────────────────── const [gridCols, setGridCols] = useState(1); useEffect(() => { const el = listRef.current; if (viewMode !== "grid" || !el) return; const update = () => { const width = Math.max(el.clientWidth - 24, 100); const cols = Math.max( 1, Math.floor((width + GRID_GAP) / (GRID_CARD_W + GRID_GAP)), ); setGridCols((previous) => (previous === cols ? previous : cols)); }; update(); const observer = new ResizeObserver(update); observer.observe(el); return () => observer.disconnect(); }, [viewMode]); const gridRowCount = viewMode === "grid" ? Math.ceil(filteredItems.length / gridCols) : 0; const gridVirtualizer = useVirtualizer({ count: gridRowCount, getScrollElement: () => listRef.current, estimateSize: () => GRID_ROW_H, overscan: 5, }); const tableVirtualizer = useVirtualizer({ count: viewMode === "list" ? filteredItems.length : 0, getScrollElement: () => listRef.current, estimateSize: () => TABLE_ROW_H, overscan: 8, }); // Infinite scroll: auto-load the next page when the sentinel enters view. const sentinelRef = useRef(null); // Bulk "select all results" run state; guards the sentinel while pages load. const [loadingAll, setLoadingAll] = useState(false); const loadingAllRef = useRef(false); 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, ): Promise => { listRequest.current?.abort(); const controller = new AbortController(); listRequest.current = controller; 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()}`, { signal: controller.signal }, ); if (controller.signal.aborted) return null; if (!res.ok) { setError(`Request failed (${res.status})`); setLoading(false); return null; } const data = await res.json(); if (controller.signal.aborted) return null; const fetched: FurniItem[] = data.items ?? []; if (page === 1) { itemsRef.current = fetched; setItems(fetched); const available = new Set(fetched.map((item) => item.classname)); setSelected( (previous) => new Set( [...previous].filter((classname) => available.has(classname)), ), ); setDetail((previous) => previous ? (fetched.find( (item) => item.classname === previous.classname, ) ?? null) : null, ); } else { // Pages can arrive out of order (infinite scroll + select-all races); // skip classnames that are already present. const prev = itemsRef.current; const seen = new Set(prev.map((item) => item.classname)); const fresh = fetched.filter((item) => !seen.has(item.classname)); if (fresh.length > 0) { const next = [...prev, ...fresh]; itemsRef.current = next; setItems(next); } } setMeta(data.meta ?? null); return fetched; } catch { if (!controller.signal.aborted) setError("Network error"); return null; } finally { if (!controller.signal.aborted) setLoading(false); } }, [], ); // Infinite scroll: auto-load the next page when the sentinel enters view. useEffect(() => { const el = sentinelRef.current; if (!el || !hasMorePages) return; let cancelled = false; const observer = new IntersectionObserver( (entries) => { if (cancelled) return; if ( entries.some((e) => e.isIntersecting) && !loading && !loadingAll && !batchProgress ) { fetchItems(activeSearch, (meta?.currentPage ?? 1) + 1, activeSource); } }, { root: listRef.current, rootMargin: "700px 0px" }, ); observer.observe(el); return () => { cancelled = true; observer.disconnect(); }; }, [ hasMorePages, meta?.currentPage, loading, loadingAll, batchProgress, activeSearch, activeSource, fetchItems, ]); // Floating "back to top" affordance once the list is long. const [showBackToTop, setShowBackToTop] = useState(false); useEffect(() => { const el = listRef.current; if (!el) return; let raf = 0; const onScroll = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => { setShowBackToTop(el.scrollTop > 600); }); }; el.addEventListener("scroll", onScroll, { passive: true }); onScroll(); return () => { cancelAnimationFrame(raf); el.removeEventListener("scroll", onScroll); }; }, []); const changeSource = (sourceId: string) => { sourceGeneration.current += 1; listGenRef.current += 1; setConfirmDelete(false); setDeleting(null); setEditingClassname(null); listRequest.current?.abort(); detailOrigin.current = null; setDetail(null); setReview(null); 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 prepareCloneReview() { if (!activeSource || preparingReview) return; const generation = sourceGeneration.current; setPreparingReview(true); try { const response = await adminFetch( "/api/admin/import/clone?source=" + encodeURIComponent(activeSource) + "&action=clonable", ); const result = await response.json(); if (generation !== sourceGeneration.current) return; if (!response.ok || !Array.isArray(result.classnames)) throw new Error(result.error || "Could not load missing furniture"); const missing = new Set(result.classnames); if (!missing.size) { toast.info("All items from this source are already imported"); return; } const collected = new Map(); let page = 1, lastPage = 1; do { const res = await adminFetch( "/api/admin/import/furni?source=" + encodeURIComponent(activeSource) + "&page=" + page, ); const data = await res.json(); if (generation !== sourceGeneration.current) return; if ( !res.ok || !Array.isArray(data.items) || !Number.isFinite(data.meta?.lastPage) ) throw new Error(data.error || "Could not prepare the full selection"); for (const item of data.items as FurniItem[]) if (missing.has(item.classname)) collected.set(item.classname, item); lastPage = data.meta.lastPage; page++; } while (page <= lastPage && collected.size < missing.size); if (generation !== sourceGeneration.current) return; if (collected.size !== missing.size) throw new Error( "The source changed while preparing the selection. Refresh and retry.", ); setReview({ items: [...collected.values()], single: false, cloneAll: true, }); } catch (error) { toast.error((error as Error).message); } finally { setPreparingReview(false); } } 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) { listGenRef.current += 1; 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 clearFilters = () => { setTypeFilter("all"); setCategoryFilter("all"); setStatusFilter("all"); setSelectedPage(null); }; const clearSearch = () => { listGenRef.current += 1; setSearchTerm(""); setActiveSearch(""); fetchItems("", 1, activeSource); }; const submitSearch = () => { listGenRef.current += 1; 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]); // Bulk "select every matching result": loads all remaining pages in the // background, then selects whatever matches the active filters. Cancels // itself when the query (search/source) changes or the request is aborted. const selectAllResults = useCallback(async () => { if (loadingAllRef.current) return; const gen = listGenRef.current; const search = activeSearch; const source = activeSource; const first = meta?.currentPage ?? 1; const last = meta?.lastPage ?? first; if (first >= last) { // Everything is already loaded — just select the visible matches. setSelected((prev) => { const next = new Set(prev); for (const item of filteredItems) next.add(item.classname); return next; }); return; } loadingAllRef.current = true; setLoadingAll(true); try { for (let page = first + 1; page <= last; page += 1) { if (listGenRef.current !== gen) break; const got = await fetchItems(search, page, source); if (!got) break; } if (listGenRef.current === gen) { const matching = filterFurniture(itemsRef.current, { typeFilter, categoryFilter, statusFilter, sortBy, }); setSelected((prev) => { const next = new Set(prev); for (const item of matching) next.add(item.classname); return next; }); } } finally { loadingAllRef.current = false; setLoadingAll(false); } }, [ activeSearch, activeSource, meta?.currentPage, meta?.lastPage, filteredItems, typeFilter, categoryFilter, statusFilter, sortBy, fetchItems, ]); // Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear. useEffect(() => { function onKeyDown(e: KeyboardEvent) { if (review || editingClassname || confirmDelete || e.defaultPrevented) return; 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) { if (detail) { e.preventDefault(); setDetail(null); return; } setSelected(new Set()); searchRef.current?.blur(); } } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }, [toggleAll, review, editingClassname, confirmDelete, detail]); async function importBatch( requested?: string[], reviewedItems?: FurniItem[], ) { const toImport = ( reviewedItems ?? (requested ? items : filteredItems) ).filter( (i) => (requested ? requested.includes(i.classname) : selected.has(i.classname)) && (!!reviewedItems || !i.alreadyImported), ); if (toImport.length === 0) { toast.info("Select furni that isn't imported yet"); return; } batchDoneRef.current = false; 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, repairExisting: true, 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, translate, langs: translate && translateLangs.length > 0 ? translateLangs : undefined, }), signal: abort.signal, }); if (!res.ok || !res.body) { toast.error(`Batch import failed (${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("Import cancelled"); } else if ((err as Error).name === "TimeoutError") { toast.error( "Import timed out. Progress was saved — open Import History to resume failed items.", ); } else if (err instanceof TypeError && err.message?.includes("fetch")) { toast.error( "Network error during upload. Progress was saved — open Import History to resume failed items.", ); } else { toast.error( "Unexpected import error: " + (err instanceof Error ? err.message : String(err)), ); } } finally { batchAbortRef.current = null; setBatchCancelling(false); setSelected(new Set()); fetchStats(); // The importers derive their "Imported Furniture" category pages from // furnidata, so the client cannot predict which pages appeared. Pull the // tree over the API instead of re-rendering the route. void refreshCatalogTree(); if (!batchDoneRef.current) markBatchDone(); } } 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 (failed.length === 0) return; if (!activeSource) { await importBatch(failed); return; } // For clone sources, re-run cloneAllMissing with only the failed classnames batchDoneRef.current = false; 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, final: true, }), signal: abort.signal, }); if (!res.ok || !res.body) { toast.error(`Retry failed (${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 imported again`); } fetchItems(activeSearch, 1, activeSource); fetchStats(); fetchCloneStats(activeSource); } catch (err) { if ((err as Error)?.name !== "AbortError") { toast.error("Connection failed while retrying"); 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); const [bulkFurnidata, setBulkFurnidata] = 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); } } async function bulkAddFurnidata() { if (selectedMissingFurnidata.length === 0) return; setBulkFurnidata(true); const wanted = selectedMissingFurnidata.map((i) => i.classname); try { const res = await adminFetch("/api/admin/import/furni/resync", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ classnames: wanted }), }); const data = await res.json(); if (res.ok) { const failedClassnames = new Set( ((data.errors ?? []) as Array<{ classname: string }>).map( (e) => e.classname, ), ); const fixedClassnames = new Set( wanted.filter((cn) => !failedClassnames.has(cn)), ); setItems((prev) => prev.map((i) => fixedClassnames.has(i.classname) ? { ...i, hasFurnidata: true } : i, ), ); const fixed = Number(data.resynced ?? fixedClassnames.size); const failed = Number(data.failed ?? 0); toast.success( `Furnidata added: ${fixed}${failed > 0 ? `, failed: ${failed}` : ""}`, ); } else { toast.error(data.error || "Adding furnidata failed"); } } catch { toast.error("Network error"); } finally { setBulkFurnidata(false); } } 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 missingFurnidataCount = useMemo( () => filteredItems.filter((i) => i.alreadyImported && i.hasFurnidata === false) .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 selectedMissingFurnidata = useMemo( () => selectedImported.filter((i) => i.hasFurnidata === false), [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… (/)" style={{ paddingLeft: 32, paddingRight: 32 }} 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} present {cloneStats.clonable > 0 && ( {cloneStats.clonable} te importeren )} )}
{/* ── Filters + actions ─────────────────────────────── */}
{(typeFilter !== "all" || categoryFilter !== "all" || statusFilter !== "all" || selectedPage !== null) && ( )} {missingNitroCount > 0 && ( )} {missingFurnidataCount > 0 && ( )}
Import options{" "} · Translation {translate ? "on" : "off"}
{" "} {/* ── Import speed (parallel items per batch) ─────────── */}
Speed
{/* ── Translate control (per-import) ─────────────── */}
Translate
Languages
{ALL_LANGS.map((lang) => { const on = translateLangs.includes(lang); return ( ); })}
Default for future imports
{hasMorePages && meta && ( )} {selected.size > 0 && ( { filteredItems.filter((item) => selected.has(item.classname)) .length }{" "} selected in this view )} {selected.size > 0 && ( )}
{cloneStats && cloneStats.clonable > 0 && !batchProgress && ( )} {selectedNonImported > 0 && ( )} {selectedMissingNitro.length > 0 && !batchProgress && ( )} {selectedMissingFurnidata.length > 0 && !batchProgress && ( )} {selectedImported.length > 0 && !batchProgress && ( )}
{selected.size > filteredItems.filter((item) => selected.has(item.classname)).length && (

Some selected furniture is hidden by your filters. Actions apply to the visible selection.

)} {/* ── Body: rail + grid + drawer ────────────────────── */}
{railOpen && (
{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" ? (
{gridVirtualizer.getVirtualItems().map((vi) => { const start = vi.index * gridCols; const rowItems = filteredItems.slice( start, start + gridCols, ); return (
{rowItems.map((item) => { const isSel = selected.has(item.classname); return (
); })}
); })}
) : ( {tableVirtualizer.getVirtualItems().map((vi) => { const item = filteredItems[vi.index]; const isSel = selected.has(item.classname); return ( ); })}
Name Classname Type Category Status Actions
toggleSelect(item.classname) } aria-label={`Select ${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 && ( )}
)}
{hasMorePages ? ( loading ? ( Loading more… ) : ( Scroll for more ) ) : ( End of results )}
)}
{batchProgress ? ( 0 ? failedClassnames : undefined } onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch} onDismiss={() => { setBatchProgress(null); batchDoneRef.current = false; setBatchDone(false); setVerification(null); setFailedClassnames([]); }} onRetry={retryFailed} /> ) : null} {/* ── Detail drawer ─────────────────────────────── */} {detail ? ( setDetail(null)} /> } > j.state === "completed" || j.state === "cancelled" || j.state === "interrupted", ) .map((j) => j.id) .join(",")}`} busy={ jobs.busy || jobs.jobs.some( (j) => (j.state === "queued" || j.state === "running") && j.items.some((i) => i.classname === detail.classname), ) } onClose={() => setDetail(null)} onImport={() => setReview({ items: [detail], single: true })} onEdit={() => setEditingClassname(detail.classname)} onRegenerate={() => void regenNitro(detail)} onDelete={() => { setDeleting(detail.classname); setConfirmDelete(true); }} onRepair={() => jobs.submit([detail], { sourceId: activeSource || undefined, translate: false, mode: "repair", }) } /> ) : null}
{review && ( setReview(null)} />} > entry.id === activeSource)?.name ?? source.label } translation={ translate ? translateLangs.length ? translateLangs.join(", ") : "Default languages" : "Off" } onCancel={() => setReview(null)} busy={jobs.busy} onConfirm={async (accepted) => { if ( await jobs.submit(accepted, { sourceId: activeSource || undefined, translate, langs: translateLangs.length ? translateLangs : undefined, }) ) { setReview(null); setSelected(new Set()); } }} /> )} {editingClassname !== null && ( setEditingClassname(null)} /> } > !open && setEditingClassname(null)} /> )} {confirmDialog} Delete imported furni

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

); }