Files
EpicNext-Cms/src/app/admin/import/furni/import-furni-client.tsx
T
openhands f285a7cd98
CI / check (push) Successful in 34s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m23s
Add performance optimizations and component refactors
- Cache read-heavy public API routes via redisCache (leaderboard, values,
  shop, articles, photos, guilds, teams, staff, users, home, radio, badges)
- Add single-flight and bounded-memory cache layer with unit tests
- Parallelize independent DB queries on search, rares, shop, staff, polls
  and profile pages
- Push radio points leaderboard aggregation to SQL with a LIMIT
- Split studio-client and import-furni-client into focused modules
- Clean up next.config.ts
2026-08-17 22:02:21 +02:00

2033 lines
62 KiB
TypeScript

"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<FurniItem[]>([]);
const [meta, setMeta] = useState<Meta | null>(null);
const [_page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [importingId, setImportingId] = useState<string | null>(null);
const [batchImporting, setBatchImporting] = useState(false);
const [error, setError] = useState("");
const [cleaningSwf, setCleaningSwf] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
// Filters & sort
const [typeFilter, setTypeFilter] = useState<string>("all");
const [categoryFilter, setCategoryFilter] = useState<string>("all");
const [statusFilter, setStatusFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<string>("default");
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
// Batch regen SSE
const [regenProgress, setRegenProgress] = useState<Map<
string,
{ status: string; message?: string }
> | 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<string[]>([]);
// Bulk delete
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false);
// Dashboard stats
const [stats, setStats] = useState<FurniStats | null>(null);
// Batch progress (SSE)
const [batchProgress, setBatchProgress] = useState<Map<
string,
BatchItemStatus
> | null>(null);
const [batchSummary, setBatchSummary] = useState<BatchSummary | null>(null);
// Nitro regeneration
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
new Set(),
);
// Item deletion
const [deletingItem, setDeletingItem] = useState<string | null>(null);
// Nitro editor
const [editingClassname, setEditingClassname] = useState<string | null>(null);
// Reorganize catalog
const [reorganizing, setReorganizing] = useState(false);
const [confirmRecentResync, setConfirmRecentResync] = useState(false);
const [recentResyncing, setRecentResyncing] = useState(false);
const [recentResyncResult, setRecentResyncResult] =
useState<RecentFurniResyncResult | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
const batchAbortRef = useRef<AbortController | null>(null);
// Confirmation dialogs
const [confirmBatch, setConfirmBatch] = useState(false);
const [confirmDelete, setConfirmDelete] = useState<FurniItem | null>(null);
const [reorgPreview, setReorgPreview] = useState<{
moved: number;
repriced: number;
skipped: number;
total: number;
categoryCounts: Record<string, number>;
} | 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<ReturnType<typeof setTimeout> | 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<string, BatchItemStatus>();
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<string, unknown>;
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<string, unknown>;
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 (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-card px-4 py-3">
<div className="flex items-center gap-3">
<div className="rounded-md bg-primary/10 p-2 text-primary">
<Globe2 className="h-4 w-4" />
</div>
<div>
<p className="text-xs text-muted-foreground">
Official furnidata source
</p>
<p className="text-sm font-medium">{source.label}</p>
<p className="text-[10px] text-muted-foreground">
Furniture assets: {source.assetCdnHost}
</p>
</div>
</div>
<a
href={source.furnidataUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 text-xs font-medium text-primary hover:underline"
>
{source.host}
<ExternalLink className="h-3 w-3" />
</a>
</div>
{/* Dashboard Statistics */}
{!stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
{Array.from({ length: 5 }, (_, i) => i).map((n) => (
<div
key={`skeleton-furni-${n}`}
className="rounded-lg border bg-card p-3 animate-pulse"
>
<div className="h-3 bg-muted rounded w-2/3 mb-2" />
<div className="h-6 bg-muted rounded w-1/2" />
</div>
))}
</div>
)}
{stats && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3">
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Package className="w-3.5 h-3.5" />
Total in DB
</div>
<div className="text-xl font-bold mt-1">
{stats.totalInDb.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<ShoppingBag className="w-3.5 h-3.5" />
In Catalog
</div>
<div className="text-xl font-bold mt-1">
{stats.inCatalog.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Check className="w-3.5 h-3.5" />
Imported
</div>
<div className="text-xl font-bold mt-1">
{items.filter((i) => i.alreadyImported).length.toLocaleString()}
</div>
<div className="text-[10px] text-muted-foreground">
on this page
</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2 text-xs text-[var(--admin-warning)]">
<FileWarning className="w-3.5 h-3.5" />
Missing .nitro
</div>
<div className="text-xl font-bold mt-1 text-[var(--admin-warning)]">
{stats.missingNitro.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card p-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<BarChart3 className="w-3.5 h-3.5" />
Not in Catalog
</div>
<div className="text-xl font-bold mt-1">
{stats.notInCatalog.toLocaleString()}
</div>
</div>
</div>
)}
{error && (
<div className="rounded-lg border px-4 py-3 text-sm bg-destructive/10 border-destructive/20 text-destructive flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{recentResyncResult && (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold">Imported Furni Update</h3>
<Button
variant="ghost"
size="sm"
onClick={() => setRecentResyncResult(null)}
>
Dismiss
</Button>
</div>
<div className="flex flex-wrap gap-4 text-sm">
<span>{recentResyncResult.examined} examined</span>
<span className="text-[var(--admin-success)]">
{recentResyncResult.resynced} updated
</span>
<span
className={recentResyncResult.failed ? "text-destructive" : ""}
>
{recentResyncResult.failed} failed
</span>
<span
className={
recentResyncResult.rconOk
? "text-[var(--admin-success)]"
: "text-[var(--admin-warning)]"
}
>
RCON {recentResyncResult.rconOk ? "refreshed" : "not refreshed"}
</span>
</div>
{recentResyncResult.errors.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground">
View errors
</summary>
<div className="mt-2 max-h-48 space-y-1 overflow-y-auto">
{recentResyncResult.errors.map((error) => (
<p key={`${error.classname}:${error.message}`}>
<span className="font-mono">{error.classname}</span>:{" "}
{error.message}
</p>
))}
</div>
</details>
)}
</div>
)}
{/* Batch Summary */}
{batchSummary && (
<div className="rounded-lg border p-4 space-y-3 bg-card">
<h3 className="text-sm font-semibold">Import Complete</h3>
<div className="flex gap-4 text-sm">
<span className="text-[var(--admin-success)]">
{batchSummary.succeeded} succeeded
</span>
{batchSummary.failed > 0 && (
<span className="text-destructive">
{batchSummary.failed} failed
</span>
)}
{batchSummary.warnings > 0 && (
<span className="text-[var(--admin-warning)]">
{batchSummary.warnings} with warnings
</span>
)}
<span className="text-muted-foreground">
{(batchSummary.duration / 1000).toFixed(1)}s
</span>
</div>
{batchProgress && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
View details
</summary>
<div className="mt-2 space-y-1 max-h-[200px] overflow-y-auto">
{Array.from(batchProgress.values())
.filter(
(s) =>
s.status === "failed" ||
(s.warnings && s.warnings.length > 0),
)
.map((item) => (
<div
key={item.classname}
className="flex items-start gap-2 py-1"
>
{item.status === "failed" ? (
<XCircle className="w-3 h-3 text-destructive mt-0.5 shrink-0" />
) : (
<AlertCircle className="w-3 h-3 text-[var(--admin-warning)] mt-0.5 shrink-0" />
)}
<div className="min-w-0">
<span className="font-mono">{item.classname}</span>
{item.message && (
<p className="text-destructive">{item.message}</p>
)}
{item.warnings
?.map((w, i) => ({ w, n: i }))
.map(({ w, n }) => (
<p
key={`warn-${n}`}
className="text-[var(--admin-warning)]"
>
{w}
</p>
))}
</div>
</div>
))}
</div>
</details>
)}
<Button
variant="outline"
size="sm"
onClick={() => {
setBatchSummary(null);
setBatchProgress(null);
}}
>
Dismiss
</Button>
</div>
)}
{/* Batch Progress (during import) */}
{batchProgress && batchImporting && progressStats && (
<div className="space-y-3 rounded-lg border p-4 bg-card">
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${progressStats.percent}%` }}
/>
</div>
<span className="text-xs font-mono text-muted-foreground shrink-0">
{progressStats.percent}%
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>
{progressStats.completed}/{progressStats.total} completed
</span>
{progressStats.done > 0 && (
<span className="text-[var(--admin-success)]">
{progressStats.done} OK
</span>
)}
{progressStats.failed > 0 && (
<span className="text-destructive">
{progressStats.failed} failed
</span>
)}
<Button
variant="outline"
size="sm"
className="h-7 ml-auto"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
</div>
<div className="max-h-[250px] overflow-y-auto space-y-0.5">
{Array.from(batchProgress.values()).map((item) => (
<div
key={item.classname}
className="flex items-center gap-2 text-xs py-1 px-2 rounded hover:bg-muted/50"
>
{item.status === "done" && (
<Check className="w-3 h-3 text-[var(--admin-success)] shrink-0" />
)}
{item.status === "failed" && (
<XCircle className="w-3 h-3 text-destructive shrink-0" />
)}
{[
"downloading",
"converting",
"writing_db",
"started",
].includes(item.status) && (
<Loader2 className="w-3 h-3 animate-spin text-primary shrink-0" />
)}
{item.status === "pending" && (
<div className="w-3 h-3 rounded-full border border-muted-foreground/30 shrink-0" />
)}
<span className="font-mono truncate flex-1 min-w-0">
{item.classname}
</span>
<Badge
variant="outline"
className="text-[10px] px-1.5 shrink-0"
>
{STATUS_LABELS[item.status] || item.status}
</Badge>
{item.message && (
<span className="text-destructive truncate max-w-[200px]">
{item.message}
</span>
)}
</div>
))}
</div>
</div>
)}
{/* 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 (
<div className="space-y-3 rounded-lg border p-4 bg-card">
<div className="flex items-center gap-3">
<Loader2 className="w-4 h-4 animate-spin text-primary shrink-0" />
<span className="text-sm font-medium">
Regenerating .nitro files...
</span>
<div className="flex-1 h-2.5 rounded-full bg-muted overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${percent}%` }}
/>
</div>
<span className="text-xs font-mono text-muted-foreground shrink-0">
{percent}%
</span>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>
{completed}/{arr.length}
</span>
{done > 0 && (
<span className="text-[var(--admin-success)]">{done} OK</span>
)}
{failed > 0 && (
<span className="text-destructive">{failed} failed</span>
)}
</div>
</div>
);
})()}
{/* Regen Summary */}
{regenSummary && !batchRegenerating && (
<div className="rounded-lg border p-4 space-y-3 bg-card">
<h3 className="text-sm font-semibold">Regeneration Complete</h3>
<div className="flex gap-4 text-sm">
{regenSummary.succeeded > 0 && (
<span className="text-[var(--admin-success)]">
{regenSummary.succeeded} regenerated
</span>
)}
{regenSummary.failed > 0 && (
<span className="text-destructive">
{regenSummary.failed} failed
</span>
)}
{regenSummary.skipped > 0 && (
<span className="text-muted-foreground">
{regenSummary.skipped} skipped
</span>
)}
<span className="text-muted-foreground">
{(regenSummary.duration / 1000).toFixed(1)}s
</span>
</div>
{/* Show failed items details */}
{regenProgress &&
(() => {
const failedItems = Array.from(regenProgress.entries()).filter(
([, v]) => v.status === "failed",
);
if (failedItems.length === 0) return null;
return (
<details className="text-xs">
<summary className="cursor-pointer text-destructive hover:text-destructive/80">
View {failedItems.length} failed item
{failedItems.length > 1 ? "s" : ""}
</summary>
<div className="mt-2 space-y-1 max-h-[250px] overflow-y-auto">
{failedItems.map(([classname, info]) => (
<div
key={classname}
className="flex items-start gap-2 py-1 px-2 rounded hover:bg-muted/50"
>
<XCircle className="w-3 h-3 text-destructive mt-0.5 shrink-0" />
<div className="min-w-0">
<span className="font-mono">{classname}</span>
{info.message && (
<p className="text-muted-foreground">
{info.message}
</p>
)}
</div>
</div>
))}
</div>
</details>
);
})()}
<Button
variant="outline"
size="sm"
onClick={() => {
setRegenSummary(null);
setRegenProgress(null);
}}
>
Dismiss
</Button>
</div>
)}
{/* Toolbar: Search + View toggle */}
<div className="flex flex-wrap items-center gap-2">
<div className="relative flex-1 min-w-0 max-w-md">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
ref={searchRef}
placeholder="Search furniture... /"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setSearchTerm("");
setActiveSearch("");
searchRef.current?.blur();
}
}}
className="pl-9 pr-8"
/>
{searchTerm && (
<button
type="button"
onClick={() => {
setSearchTerm("");
setActiveSearch("");
fetchItems("", 1, statusFilter);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{meta && (
<span className="text-xs font-bold text-muted-foreground shrink-0">
{meta.total.toLocaleString()} furni
</span>
)}
{/* View toggle */}
<div className="flex items-center rounded-md border bg-card ml-auto">
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-r-none ${viewMode === "grid" ? "bg-accent" : ""}`}
onClick={() => setViewMode("grid")}
>
<LayoutGrid className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-l-none ${viewMode === "list" ? "bg-accent" : ""}`}
onClick={() => setViewMode("list")}
>
<List className="h-3.5 w-3.5" />
</Button>
</div>
</div>
{/* Filters + Sort row */}
<div className="flex flex-wrap items-center gap-2">
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="w-[110px] h-8 text-xs">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
<SelectItem value="flooritem">Floor</SelectItem>
<SelectItem value="wallitem">Wall</SelectItem>
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[130px] h-8 text-xs">
<SelectValue placeholder="Category" />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat === "all" ? "All Categories" : cat}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[150px] h-8 text-xs">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="not-imported">Not Imported</SelectItem>
<SelectItem value="imported">Imported</SelectItem>
<SelectItem value="missing-nitro">Missing .nitro</SelectItem>
<SelectItem value="ok">Imported + .nitro OK</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px] h-8 text-xs">
<ArrowUpDown className="h-3 w-3 mr-1" />
<SelectValue placeholder="Sort" />
</SelectTrigger>
<SelectContent>
<SelectItem value="default">Default order</SelectItem>
<SelectItem value="name">Name A-Z</SelectItem>
<SelectItem value="classname">Classname A-Z</SelectItem>
<SelectItem value="category">Category</SelectItem>
<SelectItem value="imported-first">Imported first</SelectItem>
<SelectItem value="not-imported-first">
Not imported first
</SelectItem>
</SelectContent>
</Select>
<Badge variant="secondary" className="text-[10px] px-2 py-0.5 shrink-0">
{filteredItems.length} / {items.length}
</Badge>
</div>
{/* Actions row */}
<div className="flex flex-wrap items-center gap-2">
{importableCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={toggleSelectAll}
className="text-xs h-8"
disabled={batchImporting}
>
<CheckCheck className="w-3.5 h-3.5 mr-1" />
{selected.size === importableCount
? "Deselect"
: `Select All (${importableCount})`}
</Button>
)}
{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 && (
<Button
size="sm"
onClick={() => setConfirmBatch(true)}
disabled={batchImporting}
className="h-8"
>
{batchImporting ? (
<Loader2 className="w-4 h-4 animate-spin mr-1" />
) : (
<Download className="w-4 h-4 mr-1" />
)}
Import{" "}
{
filteredItems.filter(
(i) => selected.has(i.classname) && !i.alreadyImported,
).length
}
</Button>
)}
{batchImporting && (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => batchAbortRef.current?.abort()}
>
Cancel
</Button>
)}
{hasImported && (
<Button
variant="destructive"
size="sm"
onClick={() => setConfirmBulkDelete(true)}
disabled={batchImporting}
className="h-8"
>
<Trash2 className="w-4 h-4 mr-1" />
Delete{" "}
{
filteredItems.filter(
(i) => selected.has(i.classname) && i.alreadyImported,
).length
}
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => setSelected(new Set())}
className="h-8 text-xs"
>
Clear selection
</Button>
</>
);
})()}
{missingNitroItems.length > 0 && (
<Button
variant="outline"
size="sm"
onClick={regenAllMissing}
disabled={batchRegenerating || regeneratingNitro.size > 0}
className="h-8 text-[var(--admin-warning)] border-[var(--admin-warning-border)] hover:bg-[var(--admin-warning-subtle)]"
>
{batchRegenerating ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-1" />
)}
Regen {missingNitroItems.length} .nitro
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8 ml-auto">
<MoreHorizontal className="w-4 h-4 mr-1" /> Tools
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => setConfirmRecentResync(true)}
disabled={recentResyncing}
>
{recentResyncing ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<DatabaseZap className="mr-2 h-4 w-4" />
)}
{recentResyncing
? "Updating imported furni..."
: "Update imported furni"}
</DropdownMenuItem>
<DropdownMenuItem onClick={startReorganize} disabled={reorganizing}>
<FolderSync className="w-4 h-4 mr-2" />
{reorganizing ? "Reorganizing..." : "Reorganize Catalog"}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={cleanSwf} disabled={cleaningSwf}>
<Trash2 className="w-4 h-4 mr-2" />
{cleaningSwf ? "Cleaning..." : "Clean SWF Files"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Keyboard shortcuts hint */}
{selected.size === 0 && !loading && items.length > 0 && (
<div className="text-[10px] text-muted-foreground flex items-center gap-3">
<span>
<kbd className="rounded border bg-muted px-1 font-mono">/</kbd>{" "}
Search
</span>
<span>
<kbd className="rounded border bg-muted px-1 font-mono">Ctrl+A</kbd>{" "}
Select all
</span>
<span>
<kbd className="rounded border bg-muted px-1 font-mono">Esc</kbd>{" "}
Deselect
</span>
</div>
)}
{/* Furni grid */}
{loading && items.length === 0 && (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
{Array.from({ length: 12 }, (_, i) => i).map((n) => (
<div
key={`skeleton-card-${n}`}
className="rounded-lg border overflow-hidden bg-card animate-pulse"
>
<div className="h-36 bg-muted" />
<div className="px-3 py-2 border-t space-y-2">
<div className="h-4 bg-muted rounded w-3/4" />
<div className="h-3 bg-muted rounded w-1/2" />
</div>
</div>
))}
</div>
)}
{!loading && items.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">
No furniture found{activeSearch ? ` for "${activeSearch}"` : ""}.
</p>
</div>
)}
{filteredItems.length > 0 && viewMode === "list" && (
<div
className={`rounded-md border overflow-auto max-h-[600px] ${loading ? "opacity-50" : ""}`}
>
<table className="w-full text-sm">
<thead className="sticky top-0 z-10 bg-background border-b">
<tr>
<th className="w-8 px-2 py-2">
<Checkbox
checked={
selected.size > 0 && selected.size === importableCount
}
onCheckedChange={toggleSelectAll}
/>
</th>
<th className="w-12 px-1 py-2" />
<th className="text-left px-2 py-2 font-medium">Name</th>
<th className="text-left px-2 py-2 font-medium">Classname</th>
<th className="text-left px-2 py-2 font-medium">Type</th>
<th className="text-left px-2 py-2 font-medium">Category</th>
<th className="text-left px-2 py-2 font-medium">Status</th>
<th className="w-24 px-2 py-2 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item) => (
<tr
key={item.classname}
className="border-b hover:bg-accent/30 transition-colors"
>
<td className="px-2 py-1.5">
{!item.alreadyImported && (
<Checkbox
checked={selected.has(item.classname)}
onCheckedChange={() => toggleSelect(item.classname)}
/>
)}
</td>
<td className="px-1 py-1.5">
<img
src={getFurniImageUrl(item.classname, item.revision)}
alt=""
className="w-8 h-8 object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) =>
handleImgError(e, item.classname, item.revision)
}
/>
</td>
<td className="px-2 py-1.5 font-medium truncate max-w-[200px]">
{item.name}
</td>
<td className="px-2 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[180px]">
{item.classname}
</td>
<td className="px-2 py-1.5 text-xs">
{item.type === "wallitem" ? "Wall" : "Floor"}
</td>
<td className="px-2 py-1.5 text-xs text-muted-foreground">
{item.category || "-"}
</td>
<td className="px-2 py-1.5">
{item.alreadyImported ? (
item.nitroExists ? (
<Badge className="text-[10px] bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-0">
OK
</Badge>
) : (
<Badge className="text-[10px] bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] border-0">
No .nitro
</Badge>
)
) : (
<span className="text-xs text-muted-foreground">—</span>
)}
</td>
<td className="px-2 py-1.5">
<div className="flex gap-1">
{!item.alreadyImported && (
<Button
size="sm"
variant="outline"
className="h-6 text-xs px-2"
onClick={() => doImport(item)}
disabled={importingId === item.classname}
>
{importingId === item.classname ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
</Button>
)}
{item.alreadyImported && item.nitroExists && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={() => setEditingClassname(item.classname)}
>
<Pencil className="w-3 h-3" />
</Button>
)}
{item.alreadyImported && !item.nitroExists && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-[var(--admin-warning)]"
onClick={() => regenNitro(item)}
disabled={regeneratingNitro.has(item.classname)}
>
{regeneratingNitro.has(item.classname) ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
</Button>
)}
{item.alreadyImported && (
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-destructive"
onClick={() => setConfirmDelete(item)}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{filteredItems.length > 0 && viewMode === "grid" && (
<div
className={`grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 ${loading ? "opacity-50" : ""}`}
>
{filteredItems.map((item) => (
<div
key={item.classname}
className={`rounded-lg border border-border/50 overflow-hidden bg-card hover:border-border transition-all group ${
item.alreadyImported
? "ring-1 ring-[var(--admin-success)]/30 hover:ring-2"
: "hover:shadow-md"
}`}
>
{/* Preview area with furni floor bg */}
<div
className="relative flex items-center justify-center h-36"
style={{
backgroundImage: `url(/images/furni_floor.png)`,
backgroundRepeat: "repeat",
backgroundPosition: "center",
}}
>
{/* Checkbox top-left (for non-imported AND imported) */}
<div className="absolute top-2 left-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<Checkbox
checked={selected.has(item.classname)}
onCheckedChange={() => toggleSelect(item.classname)}
/>
</div>
{/* Import button top-right on hover */}
{!item.alreadyImported && (
<div className="absolute top-2 right-2 z-10 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity">
<Button
size="sm"
className="h-7 text-xs px-2 shadow-sm"
onClick={() => doImport(item)}
disabled={importingId === item.classname}
>
{importingId === item.classname ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
</Button>
</div>
)}
{/* Hover detail tooltip */}
<div className="absolute bottom-1 left-1 right-1 z-10 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
<div className="bg-[var(--admin-overlay)] backdrop-blur-sm rounded px-2 py-1.5 text-[10px] text-[var(--admin-sidebar-text)] space-y-0.5">
{item.description && (
<p className="truncate">{item.description}</p>
)}
<div className="flex gap-2 text-[var(--admin-sidebar-text)]/70">
<span>Rev: {item.revision}</span>
<span>{item.type === "wallitem" ? "Wall" : "Floor"}</span>
<span>{item.category || "credit"}</span>
</div>
</div>
</div>
{/* Furni image */}
<img
src={getFurniImageUrl(item.classname, item.revision)}
alt={item.name}
className="max-h-[110px] max-w-[140px] drop-shadow-md transition-transform duration-200 group-hover:scale-110"
style={{ imageRendering: "pixelated" }}
onError={(e) =>
handleImgError(e, item.classname, item.revision)
}
/>
</div>
{/* Info bar */}
<div className="flex items-center gap-2 px-3 py-2 border-t">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold truncate">
{item.name}
</span>
{item.alreadyImported && (
<>
{item.nitroExists ? (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-success-subtle)] text-[var(--admin-success)] border-[var(--admin-success-border)] gap-0.5 shrink-0">
<Check className="w-2.5 h-2.5" />
Imported
</Badge>
) : (
<Badge className="text-[10px] px-1.5 py-0 bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)] border-[var(--admin-warning-border)] gap-0.5 shrink-0">
<AlertCircle className="w-2.5 h-2.5" />
No .nitro
</Badge>
)}
{item.nitroExists ? (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0"
title="Edit Nitro metadata"
onClick={(e) => {
e.stopPropagation();
setEditingClassname(item.classname);
}}
>
<Pencil className="w-3 h-3" />
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0 text-[var(--admin-warning)]"
title="Regenerate .nitro file"
disabled={regeneratingNitro.has(item.classname)}
onClick={(e) => {
e.stopPropagation();
regenNitro(item);
}}
>
{regeneratingNitro.has(item.classname) ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 shrink-0 text-destructive"
title="Delete imported item"
disabled={deletingItem === item.classname}
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(item);
}}
>
{deletingItem === item.classname ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Trash2 className="w-3 h-3" />
)}
</Button>
</>
)}
</div>
<span className="text-xs text-muted-foreground">
{item.type === "wallitem" ? "Wall" : "Room"} -{" "}
{item.category || "credit"}
</span>
</div>
{/* Small icon */}
<div className="w-10 h-10 flex-shrink-0 flex items-center justify-center">
<img
src={getFurniIconUrl(item.classname, item.revision)}
alt=""
className="max-w-full max-h-full"
style={{ imageRendering: "pixelated" }}
onError={(e) =>
handleImgError(e, item.classname, item.revision)
}
/>
</div>
</div>
</div>
))}
</div>
)}
{items.length > 0 && filteredItems.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<p className="text-sm">No furniture matches the selected filters.</p>
</div>
)}
{/* Pagination */}
<Pagination meta={meta} loading={loading} onPageChange={goPage} />
{/* Nitro Editor Dialog */}
<NitroEditorDialog
classname={editingClassname ?? ""}
open={!!editingClassname}
onOpenChange={(open) => {
if (!open) setEditingClassname(null);
}}
/>
{/* Batch Import Confirmation */}
<Dialog open={confirmBatch} onOpenChange={setConfirmBatch}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Confirm Batch Import</DialogTitle>
<DialogDescription>
Import <strong>{selected.size}</strong> furniture items? This will
download SWFs, convert to .nitro, and add to the catalog.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmBatch(false)}>
Cancel
</Button>
<Button
onClick={() => {
setConfirmBatch(false);
doBatchImport();
}}
>
<Download className="w-4 h-4 mr-1" /> Start Import
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={confirmRecentResync} onOpenChange={setConfirmRecentResync}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Update imported furni?</DialogTitle>
<DialogDescription>
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.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmRecentResync(false)}
>
Cancel
</Button>
<Button onClick={resyncRecentImports} disabled={recentResyncing}>
Update last 7 days
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation */}
<Dialog
open={!!confirmDelete}
onOpenChange={() => setConfirmDelete(null)}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Delete Imported Item</DialogTitle>
<DialogDescription>
Delete <strong>{confirmDelete?.name}</strong> (
{confirmDelete?.classname})? This removes database records,
FurnitureData entry, .nitro, .swf, and icon files.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDelete(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => confirmDelete && doDeleteItem(confirmDelete)}
>
<Trash2 className="w-4 h-4 mr-1" /> Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk Delete Confirmation */}
<Dialog open={confirmBulkDelete} onOpenChange={setConfirmBulkDelete}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Bulk Delete</DialogTitle>
<DialogDescription>
Delete{" "}
<strong>
{
filteredItems.filter(
(i) => selected.has(i.classname) && i.alreadyImported,
).length
}
</strong>{" "}
imported item(s)? This removes database records, FurnitureData
entries, .nitro, .swf, and icon files for each.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
variant="outline"
onClick={() => setConfirmBulkDelete(false)}
>
Cancel
</Button>
<Button variant="destructive" onClick={doBulkDelete}>
<Trash2 className="w-4 h-4 mr-1" /> Delete All
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reorganize Preview */}
<Dialog open={!!reorgPreview} onOpenChange={() => setReorgPreview(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Reorganize Catalog</DialogTitle>
<DialogDescription>
Preview of changes to {reorgPreview?.total} imported items:
</DialogDescription>
</DialogHeader>
{reorgPreview && (
<div className="space-y-3 text-sm">
<div className="grid grid-cols-3 gap-3">
{reorgPreview.moved > 0 && (
<div className="rounded-lg border p-3 text-center">
<p className="text-lg font-bold text-primary">
{reorgPreview.moved}
</p>
<p className="text-xs text-muted-foreground">
Items to move
</p>
</div>
)}
{reorgPreview.repriced > 0 && (
<div className="rounded-lg border p-3 text-center">
<p className="text-lg font-bold text-[var(--admin-warning)]">
{reorgPreview.repriced}
</p>
<p className="text-xs text-muted-foreground">
Prices to update
</p>
</div>
)}
{reorgPreview.skipped > 0 && (
<div className="rounded-lg border p-3 text-center">
<p className="text-lg font-bold text-muted-foreground">
{reorgPreview.skipped}
</p>
<p className="text-xs text-muted-foreground">
Already correct
</p>
</div>
)}
</div>
{Object.keys(reorgPreview.categoryCounts || {}).length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">
Categories:
</p>
<div className="flex flex-wrap gap-1">
{Object.entries(reorgPreview.categoryCounts)
.sort(([, a], [, b]) => b - a)
.map(([cat, count]) => (
<Badge
key={cat}
variant="secondary"
className="text-xs"
>
{cat}: {count}
</Badge>
))}
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setReorgPreview(null)}>
Cancel
</Button>
<Button onClick={applyReorganize}>
<FolderSync className="w-4 h-4 mr-1" /> Apply Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}