"use client"; import { AlertCircle, ArrowUpDown, BarChart3, Check, CheckCheck, DatabaseZap, Download, ExternalLink, FileWarning, FolderSync, Globe2, 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 { type RecentFurniResyncResult, requestRecentFurniResync, } from "@/lib/admin/recent-furni-resync"; import { adminFetch } from "@/lib/admin-fetch"; import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel"; import { getFurniIconUrl, getFurniImageUrl, handleImgError, STATUS_LABELS, } from "./import-furni-icons"; import type { BatchItemStatus, BatchSummary, FurniItem, FurniStats, Meta, } from "./import-furni-types"; import { NitroEditorDialog } from "./nitro-editor-dialog"; import { Pagination } from "./pagination"; export function ImportFurniClient({ source }: { source: FurniImportSource }) { 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 [confirmRecentResync, setConfirmRecentResync] = useState(false); const [recentResyncing, setRecentResyncing] = useState(false); const [recentResyncResult, setRecentResyncResult] = useState(null); const searchRef = useRef(null); const batchAbortRef = 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 adminFetch("/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); // biome-ignore lint/correctness/useExhaustiveDependencies: toggleSelectAll is a state setter, stable }, [toggleSelectAll]); // 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 adminFetch(`/api/admin/import/furni?${params}`); const data = await res.json(); if (!res.ok) { setError(data.error || "Failed to load"); return; } const loadedItems = data.items || []; 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); 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, fetchItems, activeSearch, statusFilter]); // 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 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 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, }), }); 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)); 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({ 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) { 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) { if ((err as Error).name === "AbortError") { toast.info("Batch import cancelled"); } else { console.error("[Import] Batch import error:", err); toast.error( "Connection lost during batch import. Check results above.", ); } } finally { setBatchImporting(false); batchAbortRef.current = null; 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 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 { 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 adminFetch("/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 adminFetch("/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 resyncRecentImports() { setConfirmRecentResync(false); setRecentResyncing(true); setRecentResyncResult(null); try { const result = await requestRecentFurniResync(adminFetch); setRecentResyncResult(result); if (result.examined === 0) { toast.info("No imported furni found in the last 7 days"); } else if (result.failed > 0 || !result.rconOk) { toast.warning("Furni resync completed with warnings"); } else { toast.success(`Updated ${result.resynced} imported furni`); } fetchStats(); } catch (error) { toast.error( error instanceof Error ? error.message : "Furni resync failed", ); } finally { setRecentResyncing(false); } } async function applyReorganize() { setReorgPreview(null); setReorganizing(true); try { const res = await adminFetch("/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]); return (

Official furnidata source

{source.label}

Furniture assets: {source.assetCdnHost}

{source.host}
{/* Dashboard Statistics */} {!stats && (
{Array.from({ length: 5 }, (_, i) => i).map((n) => (
))}
)} {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}
)} {recentResyncResult && (

Imported Furni Update

{recentResyncResult.examined} examined {recentResyncResult.resynced} updated {recentResyncResult.failed} failed RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
{recentResyncResult.errors.length > 0 && (
View errors
{recentResyncResult.errors.map((error) => (

{error.classname}:{" "} {error.message}

))}
)}
)} {/* 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) => ({ w, n: i })) .map(({ w, n }) => (

{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 && ( )} {batchImporting && ( )} {hasImported && ( )} ); })()} {missingNitroItems.length > 0 && ( )} setConfirmRecentResync(true)} disabled={recentResyncing} > {recentResyncing ? ( ) : ( )} {recentResyncing ? "Updating imported furni..." : "Update imported furni"} {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 }, (_, i) => i).map((n) => (
))}
)} {!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 */} {/* 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. Update imported furni? This updates FurnitureData for furni imported during the last 7 days, then refreshes the emulator catalog and item caches. No database rows or asset files are deleted. {/* 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} ))}
)}
)}
); }