"use client"; import { AlertCircle, ArrowUpDown, BarChart3, Check, CheckCheck, Download, FileWarning, FolderSync, LayoutGrid, List, Loader2, MoreHorizontal, Package, Pencil, RefreshCw, Search, ShoppingBag, Trash2, X, XCircle, } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { cleanSwfFiles, deleteImportedFurni } from "@/actions/import-furni"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { NitroEditorDialog } from "./nitro-editor-dialog"; interface FurniItem { id: number; classname: string; type: string; name: string; description: string; revision: number; category: string; alreadyImported: boolean; nitroExists: boolean; } interface Meta { currentPage: number; lastPage: number; total: number; perPage: number; } interface BatchItemStatus { classname: string; status: | "pending" | "started" | "downloading" | "converting" | "writing_db" | "done" | "failed"; message?: string; warnings?: string[]; itemId?: number; } interface BatchSummary { succeeded: number; failed: number; warnings: number; duration: number; } interface FurniStats { totalInDb: number; inCatalog: number; missingNitro: number; notInCatalog: number; } function getFurniImageUrl(classname: string, revision: number) { // Use official Habbo CDN for furniture icons const base = classname.includes("*") ? classname.substring(0, classname.indexOf("*")) : classname; return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(base)}_icon.png`; } function getFurniIconUrl(classname: string, revision: number) { const iconName = classname.replace(/\*/g, "_"); return `https://images.habbo.com/dcr/hof_furni/${revision}/${encodeURIComponent(iconName)}_icon.png`; } function handleImgError( e: React.SyntheticEvent, classname: string, revision: number, ) { const img = e.target as HTMLImageElement; const iconName = classname.replace(/\*/g, "_"); if (!img.dataset.fallback) { img.dataset.fallback = "1"; img.src = getFurniIconUrl(classname, revision); } else if (img.dataset.fallback === "1") { img.dataset.fallback = "2"; img.src = `/swf/dcr/hof_furni/icons/${encodeURIComponent(iconName)}_icon.png`; } else { // Show placeholder instead of invisible image img.style.opacity = "0.3"; img.src = 'data:image/svg+xml,'; } } const STATUS_LABELS: Record = { pending: "Pending", started: "Starting", downloading: "Downloading", converting: "Converting", writing_db: "Writing DB", done: "Done", failed: "Failed", }; export function ImportFurniClient() { const [searchTerm, setSearchTerm] = useState(""); const [activeSearch, setActiveSearch] = useState(""); const [items, setItems] = useState([]); const [meta, setMeta] = useState(null); const [_page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [importingId, setImportingId] = useState(null); const [batchImporting, setBatchImporting] = useState(false); const [error, setError] = useState(""); const [cleaningSwf, setCleaningSwf] = useState(false); const [selected, setSelected] = useState>(new Set()); // Filters & sort 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"); // Batch regen SSE const [regenProgress, setRegenProgress] = useState | null>(null); const [regenSummary, setRegenSummary] = useState<{ succeeded: number; failed: number; skipped: number; duration: number; } | null>(null); const [batchRegenerating, setBatchRegenerating] = useState(false); // Recently imported (this session) const [_recentImports, setRecentImports] = useState([]); // Bulk delete const [confirmBulkDelete, setConfirmBulkDelete] = useState(false); // Dashboard stats const [stats, setStats] = useState(null); // Batch progress (SSE) const [batchProgress, setBatchProgress] = useState | null>(null); const [batchSummary, setBatchSummary] = useState(null); // Nitro regeneration const [regeneratingNitro, setRegeneratingNitro] = useState>( new Set(), ); // Item deletion const [deletingItem, setDeletingItem] = useState(null); // Nitro editor const [editingClassname, setEditingClassname] = useState(null); // Reorganize catalog const [reorganizing, setReorganizing] = useState(false); const searchRef = useRef(null); // Confirmation dialogs const [confirmBatch, setConfirmBatch] = useState(false); const [confirmDelete, setConfirmDelete] = useState(null); const [reorgPreview, setReorgPreview] = useState<{ moved: number; repriced: number; skipped: number; total: number; categoryCounts: Record; } | null>(null); const fetchStats = useCallback(async () => { try { const res = await fetch("/api/admin/import/furni?action=stats"); const data = await res.json(); if (res.ok) { setStats({ totalInDb: data.totalInDb ?? 0, inCatalog: data.inCatalog ?? 0, missingNitro: data.missingNitro ?? 0, notInCatalog: data.notInCatalog ?? 0, }); } } catch { console.error("[Import] Failed to load stats"); } }, []); useEffect(() => { fetchStats(); }, [fetchStats]); // Keyboard shortcuts: "/" focus search, Ctrl+A select all, Escape deselect 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(); toggleSelectAll(); } if (e.key === "Escape" && !isInput) { setSelected(new Set()); } } document.addEventListener("keydown", onKeyDown); return () => document.removeEventListener("keydown", onKeyDown); }); // Derived: unique categories from loaded items const categories = useMemo(() => { const cats = new Set(items.map((i) => i.category).filter(Boolean)); return ["all", ...Array.from(cats).sort()]; }, [items]); // Derived: filtered + sorted items const filteredItems = useMemo(() => { let result: FurniItem[]; if (statusFilter === "missing-nitro") { result = items.filter((i) => { if (typeFilter !== "all" && i.type !== typeFilter) return false; if (categoryFilter !== "all" && i.category !== categoryFilter) return false; return true; }); } else { result = items.filter((i) => { if (typeFilter !== "all" && i.type !== typeFilter) return false; if (categoryFilter !== "all" && i.category !== categoryFilter) return false; if (statusFilter === "not-imported" && i.alreadyImported) return false; if (statusFilter === "imported" && !i.alreadyImported) return false; if (statusFilter === "ok" && !(i.alreadyImported && i.nitroExists)) return false; return true; }); } // Sort if (sortBy === "name") result.sort((a, b) => a.name.localeCompare(b.name)); else if (sortBy === "classname") result.sort((a, b) => a.classname.localeCompare(b.classname)); else if (sortBy === "category") result.sort((a, b) => (a.category || "").localeCompare(b.category || "")); else if (sortBy === "imported-first") result.sort( (a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0), ); else if (sortBy === "not-imported-first") result.sort( (a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0), ); return result; }, [items, typeFilter, categoryFilter, statusFilter, sortBy]); // Derived: importable (not yet imported) count in filtered set const importableCount = useMemo( () => filteredItems.filter((i) => !i.alreadyImported).length, [filteredItems], ); const fetchItems = useCallback( async (search: string, p: number, status?: string) => { setLoading(true); setError(""); try { const params = new URLSearchParams({ page: String(p) }); if (search) params.set("search", search); // Server-side filter for missing-nitro — much faster than client-side if (status === "missing-nitro") { params.set("action", "missing-nitro"); } const res = await fetch(`/api/admin/import/furni?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); return; } const loadedItems = data.item || []; setItems(loadedItems); setMeta(data.meta || null); setSelected(new Set()); } catch { setError("Network error"); } finally { setLoading(false); } }, [], ); // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { fetchItems("", 1, statusFilter); }, [fetchItems]); // Auto-search with debounce (500ms) const debounceRef = useRef | null>(null); // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { if (debounceRef.current) clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { if (searchTerm !== activeSearch) { setActiveSearch(searchTerm); setPage(1); fetchItems(searchTerm, 1, statusFilter); } }, 500); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [searchTerm]); // Re-fetch when status filter changes to/from missing-nitro const prevStatusRef = useRef(statusFilter); useEffect(() => { if (prevStatusRef.current !== statusFilter) { const wasMissing = prevStatusRef.current === "missing-nitro"; const isMissing = statusFilter === "missing-nitro"; prevStatusRef.current = statusFilter; if (wasMissing || isMissing) { setPage(1); fetchItems(activeSearch, 1, statusFilter); } } }, [statusFilter, activeSearch, fetchItems]); function _doSearch(e?: React.FormEvent) { e?.preventDefault(); setActiveSearch(searchTerm); setPage(1); fetchItems(searchTerm, 1, statusFilter); } function goPage(p: number) { setPage(p); fetchItems(activeSearch, p, statusFilter); } function toggleSelect(classname: string) { setSelected((prev) => { const next = new Set(prev); if (next.has(classname)) next.delete(classname); else next.add(classname); return next; }); } function toggleSelectAll() { const allSelected = filteredItems.length > 0 && filteredItems.every((i) => selected.has(i.classname)); if (allSelected) { setSelected(new Set()); } else { setSelected(new Set(filteredItems.map((i) => i.classname))); } } async function doImport(item: FurniItem) { setImportingId(item.classname); try { const res = await fetch("/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, }), }); const data = await res.json(); if (data.ok) { toast.success(`${item.name} imported`); setRecentImports((prev) => [item.classname, ...prev.filter((c) => c !== item.classname)].slice( 0, 20, ), ); setItems((prev) => prev.map((i) => i.classname === item.classname ? { ...i, alreadyImported: true, nitroExists: true } : i, ), ); setSelected((prev) => { const n = new Set(prev); n.delete(item.classname); return n; }); fetchStats(); } else { toast.error(data.error || `Failed to import ${item.name}`); } } catch (err) { console.error("[Import] Single import failed:", err); toast.error(`Network error importing ${item.name}`); } finally { setImportingId(null); } } async function doBatchImport() { const toImport = filteredItems.filter( (i) => selected.has(i.classname) && !i.alreadyImported, ); if (toImport.length === 0) return; setBatchImporting(true); setBatchSummary(null); // Initialize progress map const initialProgress = new Map(); for (const i of toImport) { initialProgress.set(i.classname, { classname: i.classname, status: "pending", }); } setBatchProgress(new Map(initialProgress)); try { const res = await fetch("/api/admin/import/furni/batch", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ 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, }), }); if (!res.ok || !res.body) { setBatchImporting(false); 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") { setBatchSummary({ succeeded: event.succeeded, failed: event.failed, warnings: event.warnings, duration: event.duration, }); } } catch { /* skip malformed events */ } } } } catch (err) { console.error("[Import] Batch import error:", err); toast.error("Connection lost during batch import. Check results above."); } finally { setBatchImporting(false); setSelected(new Set()); fetchStats(); } } async function cleanSwf() { setCleaningSwf(true); try { const result = await cleanSwfFiles(); if (result.ok) { const data = result.data as Record; toast.success( `Deleted ${data.deleted} SWF files (${data.remaining} remaining)`, ); } else { toast.error(result.error || "Failed to clean SWF files"); } } catch { toast.error("Network error"); } finally { setCleaningSwf(false); } } async function regenNitro(item: FurniItem) { setRegeneratingNitro((prev) => new Set(prev).add(item.classname)); try { const res = await fetch("/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 { const warning = data.results?.[0]?.warning || data.error || "Unknown error"; toast.error(`Failed: ${warning}`); } } catch { toast.error("Network error"); } finally { setRegeneratingNitro((prev) => { const next = new Set(prev); next.delete(item.classname); return next; }); } } const missingNitroItems = useMemo(() => { return filteredItems.filter((i) => i.alreadyImported && !i.nitroExists); }, [filteredItems]); async function regenAllMissing() { if (missingNitroItems.length === 0) return; setBatchRegenerating(true); setRegenSummary(null); const initialProgress = new Map< string, { status: string; message?: string } >(); for (const i of missingNitroItems) { initialProgress.set(i.classname, { status: "pending" }); } setRegenProgress(new Map(initialProgress)); try { const res = await fetch("/api/admin/import/furni/batch-regen", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ items: missingNitroItems.map((i) => ({ classname: i.classname, revision: i.revision, })), concurrency: 3, }), }); if (!res.ok || !res.body) { toast.error("Failed to start batch regen"); setBatchRegenerating(false); 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 === "regen_progress") { setRegenProgress((prev) => { if (!prev) return prev; const next = new Map(prev); next.set(event.classname, { status: event.status, message: event.message, }); return next; }); if (event.status === "done") { setItems((prev) => prev.map((i) => i.classname === event.classname ? { ...i, nitroExists: true } : i, ), ); } } else if (event.type === "regen_complete") { setRegenSummary({ succeeded: event.succeeded, failed: event.failed, skipped: event.skipped, duration: event.duration, }); } } catch { /* skip */ } } } } catch { toast.error("Connection lost during regen"); } finally { setBatchRegenerating(false); setRegeneratingNitro(new Set()); fetchStats(); } } // Bulk delete selected imported items async function doBulkDelete() { const toDelete = filteredItems.filter( (i) => selected.has(i.classname) && i.alreadyImported, ); if (toDelete.length === 0) return; setConfirmBulkDelete(false); let ok = 0, fail = 0; for (const item of toDelete) { setDeletingItem(item.classname); try { const result = await deleteImportedFurni({ classname: item.classname }); if (result.ok) { ok++; setItems((prev) => prev.map((i) => i.classname === item.classname ? { ...i, alreadyImported: false, nitroExists: false } : i, ), ); } else { fail++; } } catch { fail++; } } setDeletingItem(null); setSelected(new Set()); if (ok > 0) toast.success(`${ok} item(s) deleted`); if (fail > 0) toast.error(`${fail} deletion(s) failed`); fetchStats(); } async function startReorganize() { setReorganizing(true); try { const previewRes = await fetch("/api/admin/import/furni?dryrun=1", { method: "PUT", }); const previewData = await previewRes.json(); if (!previewRes.ok) { toast.error(previewData.error || "Preview failed"); return; } if (previewData.moved === 0 && previewData.repriced === 0) { toast.info( previewData.message || "Everything is already correctly organized", ); return; } setReorgPreview(previewData); } catch { toast.error("Network error"); } finally { setReorganizing(false); } } async function applyReorganize() { setReorgPreview(null); setReorganizing(true); try { const res = await fetch("/api/admin/import/furni", { method: "PUT" }); const d = await res.json(); if (res.ok) { const parts: string[] = []; if (d.moved > 0) parts.push(`${d.moved} moved`); if (d.repriced > 0) parts.push(`${d.repriced} repriced`); if (d.skipped > 0) parts.push(`${d.skipped} already ok`); if (d.pagesDeleted > 0) parts.push(`${d.pagesDeleted} empty pages cleaned`); toast.success(`Done! ${parts.join(", ")} (${d.total} total)`); } else { toast.error(d.error || "Failed"); } } catch { toast.error("Network error"); } finally { setReorganizing(false); } } async function doDeleteItem(item: FurniItem) { setConfirmDelete(null); setDeletingItem(item.classname); try { const result = await deleteImportedFurni({ classname: item.classname }); if (result.ok) { const data = result.data as Record; setItems((prev) => prev.map((i) => i.classname === item.classname ? { ...i, alreadyImported: false, nitroExists: false } : i, ), ); const files = (data.deletedFiles as string[])?.join(", ") || "none"; toast.success(`Deleted ${item.classname} (files: ${files})`); if ((data.errors as string[])?.length > 0) { toast.warning(`Some errors: ${(data.errors as string[]).join(", ")}`); } } else { toast.error(result.error || "Delete failed"); } } catch { toast.error("Network error"); } finally { setDeletingItem(null); } } // ── Batch progress helpers ────────────────────────────────────────── const progressStats = useMemo(() => { if (!batchProgress) return null; const arr = Array.from(batchProgress.values()); const done = arr.filter((s) => s.status === "done").length; const failed = arr.filter((s) => s.status === "failed").length; const completed = done + failed; const percent = arr.length > 0 ? Math.round((completed / arr.length) * 100) : 0; return { completed, done, failed, total: arr.length, percent }; }, [batchProgress]); function renderPagination() { if (!meta || meta.lastPage <= 1) return null; const nodes: React.ReactNode[] = []; if (meta.currentPage > 1) { nodes.push( , ); } for (let p = 1; p <= meta.lastPage; p++) { if (p === meta.currentPage) { nodes.push( , ); } else if ( Math.abs(p - meta.currentPage) <= 2 || p === 1 || p === meta.lastPage ) { nodes.push( , ); } else if (Math.abs(p - meta.currentPage) === 3) { nodes.push( … , ); } } if (meta.currentPage < meta.lastPage) { nodes.push( , ); } return (
{nodes}
); } return (
{/* Dashboard Statistics */} {!stats && (
{Array.from({ length: 5 }).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
))}
)} {stats && (
Total in DB
{stats.totalInDb.toLocaleString()}
In Catalog
{stats.inCatalog.toLocaleString()}
Imported
{items.filter((i) => i.alreadyImported).length.toLocaleString()}
on this page
Missing .nitro
{stats.missingNitro.toLocaleString()}
Not in Catalog
{stats.notInCatalog.toLocaleString()}
)} {error && (
{error}
)} {/* Batch Summary */} {batchSummary && (

Import Complete

{batchSummary.succeeded} succeeded {batchSummary.failed > 0 && ( {batchSummary.failed} failed )} {batchSummary.warnings > 0 && ( {batchSummary.warnings} with warnings )} {(batchSummary.duration / 1000).toFixed(1)}s
{batchProgress && (
View details
{Array.from(batchProgress.values()) .filter( (s) => s.status === "failed" || (s.warnings && s.warnings.length > 0), ) .map((item) => (
{item.status === "failed" ? ( ) : ( )}
{item.classname} {item.message && (

{item.message}

)} {item.warnings?.map((w, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: warnings list is append-only and not reordered

{w}

))}
))}
)}
)} {/* Batch Progress (during import) */} {batchProgress && batchImporting && progressStats && (
{progressStats.percent}%
{progressStats.completed}/{progressStats.total} completed {progressStats.done > 0 && ( {progressStats.done} OK )} {progressStats.failed > 0 && ( {progressStats.failed} failed )}
{Array.from(batchProgress.values()).map((item) => (
{item.status === "done" && ( )} {item.status === "failed" && ( )} {[ "downloading", "converting", "writing_db", "started", ].includes(item.status) && ( )} {item.status === "pending" && (
)} {item.classname} {STATUS_LABELS[item.status] || item.status} {item.message && ( {item.message} )}
))}
)} {/* Regen SSE Progress */} {regenProgress && batchRegenerating && (() => { const arr = Array.from(regenProgress.values()); const done = arr.filter((s) => s.status === "done").length; const failed = arr.filter((s) => s.status === "failed").length; const completed = done + failed + arr.filter((s) => s.status === "skipped").length; const percent = arr.length > 0 ? Math.round((completed / arr.length) * 100) : 0; return (
Regenerating .nitro files...
{percent}%
{completed}/{arr.length} {done > 0 && ( {done} OK )} {failed > 0 && ( {failed} failed )}
); })()} {/* Regen Summary */} {regenSummary && !batchRegenerating && (

Regeneration Complete

{regenSummary.succeeded > 0 && ( {regenSummary.succeeded} regenerated )} {regenSummary.failed > 0 && ( {regenSummary.failed} failed )} {regenSummary.skipped > 0 && ( {regenSummary.skipped} skipped )} {(regenSummary.duration / 1000).toFixed(1)}s
{/* Show failed items details */} {regenProgress && (() => { const failedItems = Array.from(regenProgress.entries()).filter( ([, v]) => v.status === "failed", ); if (failedItems.length === 0) return null; return (
View {failedItems.length} failed item {failedItems.length > 1 ? "s" : ""}
{failedItems.map(([classname, info]) => (
{classname} {info.message && (

{info.message}

)}
))}
); })()}
)} {/* Toolbar: Search + View toggle */}
setSearchTerm(e.target.value)} onKeyDown={(e) => { if (e.key === "Escape") { setSearchTerm(""); setActiveSearch(""); searchRef.current?.blur(); } }} className="pl-9 pr-8" /> {searchTerm && ( )}
{meta && ( {meta.total.toLocaleString()} furni )} {/* View toggle */}
{/* Filters + Sort row */}
{filteredItems.length} / {items.length}
{/* Actions row */}
{importableCount > 0 && ( )} {selected.size > 0 && (() => { const hasImportable = filteredItems.some( (i) => selected.has(i.classname) && !i.alreadyImported, ); const hasImported = filteredItems.some( (i) => selected.has(i.classname) && i.alreadyImported, ); return ( <> {hasImportable && ( )} {hasImported && ( )} ); })()} {missingNitroItems.length > 0 && ( )} {reorganizing ? "Reorganizing..." : "Reorganize Catalog"} {cleaningSwf ? "Cleaning..." : "Clean SWF Files"}
{/* Keyboard shortcuts hint */} {selected.size === 0 && !loading && items.length > 0 && (
/{" "} Search Ctrl+A{" "} Select all Esc{" "} Deselect
)} {/* Furni grid */} {loading && items.length === 0 && (
{Array.from({ length: 12 }).map((_, i) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static skeleton placeholders
))}
)} {!loading && items.length === 0 && !error && (

No furniture found{activeSearch ? ` for "${activeSearch}"` : ""}.

)} {filteredItems.length > 0 && viewMode === "list" && (
{filteredItems.map((item) => ( ))}
0 && selected.size === importableCount } onCheckedChange={toggleSelectAll} /> Name Classname Type Category Status Actions
{!item.alreadyImported && ( toggleSelect(item.classname)} /> )} handleImgError(e, item.classname, item.revision) } /> {item.name} {item.classname} {item.type === "wallitem" ? "Wall" : "Floor"} {item.category || "-"} {item.alreadyImported ? ( item.nitroExists ? ( OK ) : ( No .nitro ) ) : ( — )}
{!item.alreadyImported && ( )} {item.alreadyImported && item.nitroExists && ( )} {item.alreadyImported && !item.nitroExists && ( )} {item.alreadyImported && ( )}
)} {filteredItems.length > 0 && viewMode === "grid" && (
{filteredItems.map((item) => (
{/* Preview area with furni floor bg */}
{/* Checkbox top-left (for non-imported AND imported) */}
toggleSelect(item.classname)} />
{/* Import button top-right on hover */} {!item.alreadyImported && (
)} {/* Hover detail tooltip */}
{item.description && (

{item.description}

)}
Rev: {item.revision} {item.type === "wallitem" ? "Wall" : "Floor"} {item.category || "credit"}
{/* Furni image */} {item.name} handleImgError(e, item.classname, item.revision) } />
{/* Info bar */}
{item.name} {item.alreadyImported && ( <> {item.nitroExists ? ( Imported ) : ( No .nitro )} {item.nitroExists ? ( ) : ( )} )}
{item.type === "wallitem" ? "Wall" : "Room"} -{" "} {item.category || "credit"}
{/* Small icon */}
handleImgError(e, item.classname, item.revision) } />
))}
)} {items.length > 0 && filteredItems.length === 0 && (

No furniture matches the selected filters.

)} {/* Pagination */} {renderPagination()} {/* Nitro Editor Dialog */} { if (!open) setEditingClassname(null); }} /> {/* Batch Import Confirmation */} Confirm Batch Import Import {selected.size} furniture items? This will download SWFs, convert to .nitro, and add to the catalog. {/* Delete Confirmation */} setConfirmDelete(null)} > Delete Imported Item Delete {confirmDelete?.name} ( {confirmDelete?.classname})? This removes database records, FurnitureData entry, .nitro, .swf, and icon files. {/* Bulk Delete Confirmation */} Bulk Delete Delete{" "} { filteredItems.filter( (i) => selected.has(i.classname) && i.alreadyImported, ).length } {" "} imported item(s)? This removes database records, FurnitureData entries, .nitro, .swf, and icon files for each. {/* Reorganize Preview */} setReorgPreview(null)}> Reorganize Catalog Preview of changes to {reorgPreview?.total} imported items: {reorgPreview && (
{reorgPreview.moved > 0 && (

{reorgPreview.moved}

Items to move

)} {reorgPreview.repriced > 0 && (

{reorgPreview.repriced}

Prices to update

)} {reorgPreview.skipped > 0 && (

{reorgPreview.skipped}

Already correct

)}
{Object.keys(reorgPreview.categoryCounts || {}).length > 0 && (

Categories:

{Object.entries(reorgPreview.categoryCounts) .sort(([, a], [, b]) => b - a) .map(([cat, count]) => ( {cat}: {count} ))}
)}
)}
); }