"use client"; import { ArrowUpDown, Check, CircleAlert, CloudDownload, Download, 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 } from "@/actions/import-furni"; import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { adminFetch } from "@/lib/admin-fetch"; import { type AutoCatalogPreview, previewAutoCatalog, } from "@/lib/furni/auto-catalog"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; 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"; export function StudioClient({ source, initialTree, }: { source: FurniImportSource; initialTree: TreeNode[]; }) { // 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 [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); // Nitro regeneration (missing .nitro) const [regeneratingNitro, setRegeneratingNitro] = useState>( new Set(), ); 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 !== "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); 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(""); }; 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 */ } }, []); 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, }), }); 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; } setBatchProgress(new Map()); setBatchDone(false); setBatchSucceeded(0); setBatchFailed(0); setBatchCancelling(false); const initial = new Map(); for (const i of toImport) { initial.set(i.classname, { classname: i.classname, status: "pending" }); } setBatchProgress(new Map(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, }), signal: abort.signal, }); if (!res.ok || !res.body) { 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.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 ?? [], }); setBatchDone(true); } } 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 (!batchDone) setBatchProgress(null); } } const cancelBatch = () => { if (batchAbortRef.current) { setBatchCancelling(true); batchAbortRef.current.abort(); } }; const [organizing, setOrganizing] = useState(false); async function organizeAll() { if (organizing) return; setOrganizing(true); try { const res = await adminFetch("/api/admin/import/furni/organize", { method: "POST", }); const data = await res.json(); if (res.ok) { const parts = [ `Catalogus georganiseerd`, data.pagesCreated > 0 ? `${data.pagesCreated} pagina's aangemaakt` : null, data.catalogAdded > 0 ? `${data.catalogAdded} nieuw` : null, data.catalogRehomed > 0 ? `${data.catalogRehomed} bijgewerkt` : null, ].filter(Boolean); toast.success(parts.join(" · ")); fetchStats(); } else { toast.error(data.error || "Organiseren mislukt"); } } catch { toast.error("Netwerkfout"); } finally { setOrganizing(false); } } 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)); 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 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 notImportedCount = useMemo( () => filteredItems.filter((i) => i.alreadyImported === false).length, [filteredItems], ); 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 && ( <> {stats.totalInDb} in DB {stats.inCatalog} in catalog 0 && "text-[var(--admin-warning)] border-[var(--admin-warning)]/40", )} > {stats.missingNitro} missing nitro )}
{/* ── Filters + actions ─────────────────────────────── */}
{missingNitroCount > 0 && ( )}
{selected.size > 0 && ( {selected.size} selected )} {selected.size > 0 && ( )}
{selectedNonImported > 0 && ( )}
{/* ── 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 ? "Imported" : "No nitro"} ) : ( New )}
{!item.alreadyImported && ( )} {item.alreadyImported && item.nitroExists && ( )} {item.alreadyImported && !item.nitroExists && ( )}
)} {meta && meta.currentPage < meta.lastPage && (
)} )}
{batchProgress ? ( { setBatchProgress(null); setBatchDone(false); setVerification(null); }} /> ) : null}
{/* ── Detail drawer ─────────────────────────────── */} {detail ? ( ) : null}
!open && setEditingClassname(null)} /> Delete imported furni

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

); }