Files
Epicnabbo-Catalogus-Updated…/src/components/admin/studio/studio-client.tsx
T
openhands 6dced0fb54 feat: per-import translation toggle and language picker in studio
Furni imports can now choose whether to translate (per import) and which
languages to build, instead of always rebuilding all 13 FurnitureData_<lang>
files. The studio header also has a global switch that persists the
furnidata_translate_enabled setting, seeding the per-import default.
2026-08-24 18:16:42 +02:00

1575 lines
51 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import {
ArrowUpDown,
Check,
CircleAlert,
CloudDownload,
Download,
Globe,
Languages,
Layers,
LayoutGrid,
List,
Loader2,
Package,
PanelLeftClose,
PanelLeftOpen,
Pencil,
RefreshCw,
Search,
Sparkles,
Trash2,
WandSparkles,
X,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { toast } from "sonner";
import {
deleteImportedFurni,
setFurnidataTranslateEnabled,
} from "@/actions/import-furni";
import { NitroEditorDialog } from "@/app/admin/import/furni/nitro-editor-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { adminFetch } from "@/lib/admin-fetch";
import {
type AutoCatalogPreview,
previewAutoCatalog,
} from "@/lib/furni/auto-catalog";
import type { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { cn } from "@/lib/utils";
import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import type {
BatchItemStatus,
FurniItem,
FurniStats,
Meta,
VerificationSummary,
} from "./studio-types";
// Language codes that get a FurnitureData_<lang>.json file. Mirrors
// FURNIDATA_LANGUAGES in furni-data-i18n (kept client-safe here so we don't pull
// server-only fs modules into the browser bundle).
const TRANSLATE_LANGS = [
"nl",
"en",
"de",
"fr",
"es",
"tr",
"it",
"pt",
"fi",
"pl",
"ru",
"ar",
"ja",
] as const;
export function StudioClient({
source,
initialTree,
defaultTranslate,
}: {
source: FurniImportSource;
initialTree: TreeNode[];
defaultTranslate: boolean;
}) {
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
const [selectedPage, setSelectedPage] = useState<number | null>(null);
const [railOpen, setRailOpen] = useState(true);
// Search & list
const [searchTerm, setSearchTerm] = useState("");
const [activeSearch, setActiveSearch] = useState("");
const [items, setItems] = useState<FurniItem[]>([]);
const [meta, setMeta] = useState<Meta | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
// Custom clone sources (retro hotels); empty string = official Habbo furnidata
const [sources, setSources] = useState<Array<{ id: string; name: string }>>(
[],
);
const [activeSource, setActiveSource] = useState<string>("");
const [activeSourceName, setActiveSourceName] = useState("");
// Filters
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");
const searchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Stats
const [stats, setStats] = useState<FurniStats | null>(null);
// Selection + detail
const [selected, setSelected] = useState<Set<string>>(new Set());
const [detail, setDetail] = useState<FurniItem | null>(null);
// Import state
const [importingId, setImportingId] = useState<string | null>(null);
const [batchProgress, setBatchProgress] = useState<Map<
string,
BatchItemStatus
> | null>(null);
const [batchDone, setBatchDone] = useState(false);
const [batchSucceeded, setBatchSucceeded] = useState(0);
const [batchFailed, setBatchFailed] = useState(0);
const [batchCancelling, setBatchCancelling] = useState(false);
const [verification, setVerification] = useState<VerificationSummary | null>(
null,
);
const batchAbortRef = useRef<AbortController | null>(null);
// Nitro editor
const [editingClassname, setEditingClassname] = useState<string | null>(null);
// Delete confirm
const [deleting, setDeleting] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
// Nitro regeneration (missing .nitro)
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
new Set(),
);
// Translation controls (per-import toggle + language selection, plus a global
// default that persists to the site setting and seeds the per-import toggle).
const ALL_LANGS = TRANSLATE_LANGS as readonly string[];
const [translate, setTranslate] = useState<boolean>(defaultTranslate);
const [translateLangs, setTranslateLangs] = useState<string[]>([
...ALL_LANGS,
]);
const [langPickerOpen, setLangPickerOpen] = useState(false);
const [globalTranslate, setGlobalTranslate] =
useState<boolean>(defaultTranslate);
const toggleLang = (lang: string) =>
setTranslateLangs((prev) =>
prev.includes(lang) ? prev.filter((l) => l !== lang) : [...prev, lang],
);
const onGlobalTranslateChange = async (next: boolean) => {
setGlobalTranslate(next);
setTranslate(next);
try {
await setFurnidataTranslateEnabled({ enabled: next });
} catch {
toast.error("Kon de globale vertaal-instelling niet opslaan");
}
};
const categories = useMemo(() => {
const cats = new Set(items.map((i) => i.category).filter(Boolean));
return ["all", ...Array.from(cats).sort()];
}, [items]);
const filteredItems = useMemo(() => {
let result = items;
if (typeFilter !== "all") {
result = result.filter((i) => i.type === typeFilter);
}
if (categoryFilter !== "all") {
result = result.filter((i) => i.category === categoryFilter);
}
if (statusFilter !== "all") {
if (statusFilter === "imported")
result = result.filter((i) => i.alreadyImported);
else if (statusFilter === "inCatalog")
result = result.filter((i) => i.alreadyImported && i.nitroExists);
else if (statusFilter === "missingNitro")
result = result.filter((i) => i.alreadyImported && !i.nitroExists);
}
// Not imported filter - works independently (also with statusFilter === "all")
if (statusFilter === "notImported" || statusFilter === "all")
result = result.filter((i) => i.alreadyImported === false);
const sorted = [...result];
if (sortBy === "name") sorted.sort((a, b) => a.name.localeCompare(b.name));
else if (sortBy === "classname")
sorted.sort((a, b) => a.classname.localeCompare(b.classname));
else if (sortBy === "category")
sorted.sort((a, b) => (a.category || "").localeCompare(b.category || ""));
else if (sortBy === "imported-first")
sorted.sort(
(a, b) => (b.alreadyImported ? 1 : 0) - (a.alreadyImported ? 1 : 0),
);
else if (sortBy === "not-imported-first")
sorted.sort(
(a, b) => (a.alreadyImported ? 1 : 0) - (b.alreadyImported ? 1 : 0),
);
return sorted;
}, [items, typeFilter, categoryFilter, statusFilter, sortBy]);
const allSelected = useMemo(
() =>
filteredItems.length > 0 &&
filteredItems.every((i) => selected.has(i.classname)),
[filteredItems, selected],
);
const fetchStats = useCallback(async () => {
try {
const res = await adminFetch("/api/admin/import/furni?action=stats");
if (res.ok) {
const data = await res.json();
setStats(data);
}
} catch {
/* ignore */
}
}, []);
const fetchItems = useCallback(
async (search: string, page: number, source?: string) => {
setLoading(true);
setError("");
try {
const qs = new URLSearchParams({ page: String(page) });
if (search) qs.set("search", search);
if (source) qs.set("source", source);
const res = await adminFetch(
`/api/admin/import/furni?${qs.toString()}`,
);
if (!res.ok) {
setError(`Request failed (${res.status})`);
setLoading(false);
return;
}
const data = await res.json();
if (page === 1) {
setItems(data.items ?? []);
} else {
setItems((prev) => [...prev, ...(data.items ?? [])]);
}
setMeta(data.meta ?? null);
} catch {
setError("Network error");
} finally {
setLoading(false);
}
},
[],
);
const changeSource = (sourceId: string) => {
setActiveSource(sourceId);
setActiveSourceName(
sourceId ? (sources.find((s) => s.id === sourceId)?.name ?? "") : "",
);
setSelected(new Set());
setItems([]);
setActiveSearch("");
setSearchTerm("");
};
const fetchSources = useCallback(async () => {
try {
const res = await adminFetch("/api/admin/import/clone?action=sources");
if (res.ok) {
const data = await res.json();
setSources(data.sources ?? []);
}
} catch {
/* ignore */
}
}, []);
useEffect(() => {
fetchItems("", 1, activeSource);
fetchStats();
fetchSources();
}, [fetchItems, fetchStats, fetchSources, activeSource]);
// Debounced auto-search (also triggered by the "/" shortcut focus).
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
if (searchTerm !== activeSearch) {
setActiveSearch(searchTerm);
fetchItems(searchTerm, 1, activeSource);
}
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchTerm, fetchItems, activeSearch, activeSource]);
// Keep detail in sync with the refreshed list.
useEffect(() => {
if (!detail) return;
const updated = items.find((i) => i.classname === detail.classname);
if (updated) setDetail(updated);
}, [items, detail]);
const clearSearch = () => {
setSearchTerm("");
setActiveSearch("");
fetchItems("", 1, activeSource);
};
const submitSearch = () => {
setActiveSearch(searchTerm);
fetchItems(searchTerm, 1, activeSource);
};
function toggleSelect(classname: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(classname)) next.delete(classname);
else next.add(classname);
return next;
});
}
const toggleAll = useCallback(() => {
if (allSelected) {
const toRemove = new Set(filteredItems.map((i) => i.classname));
setSelected((prev) => {
const next = new Set(prev);
for (const cn of toRemove) next.delete(cn);
return next;
});
} else {
setSelected((prev) => {
const next = new Set(prev);
for (const i of filteredItems) next.add(i.classname);
return next;
});
}
}, [allSelected, filteredItems]);
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement)?.tagName;
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
e.preventDefault();
searchRef.current?.focus();
}
if (e.key === "a" && (e.metaKey || e.ctrlKey) && !isInput) {
e.preventDefault();
toggleAll();
}
if (e.key === "Escape" && !isInput) {
setSelected(new Set());
searchRef.current?.blur();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [toggleAll]);
async function importSingle(item: FurniItem) {
setImportingId(item.classname);
try {
const res = await adminFetch("/api/admin/import/furni", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
id: item.id,
classname: item.classname,
name: item.name,
description: item.description,
type: item.type,
revision: item.revision,
category: item.category,
sourceId: activeSource || undefined,
translate,
langs:
translate && translateLangs.length > 0 ? translateLangs : undefined,
}),
});
const data = await res.json();
if (res.ok) {
setItems((prev) =>
prev.map((i) =>
i.classname === item.classname
? { ...i, alreadyImported: true, nitroExists: true }
: i,
),
);
if (detail?.classname === item.classname) {
setDetail({ ...item, alreadyImported: true, nitroExists: true });
}
fetchStats();
const parts = [`${item.classname} imported into the catalog`];
if (typeof data.furniDataFixedIds === "number") {
parts.push(
`verified: ${data.offerIdsFixed} offer_id, ${data.furniDataFixedIds} furnidata id`,
);
}
if (data.furniDataMissing > 0) {
parts.push(`${data.furniDataMissing} furnidata missing`);
}
toast.success(parts.join(" · "));
} else {
toast.error(data.error || `Import failed for ${item.classname}`);
}
} catch {
toast.error("Network error");
} finally {
setImportingId(null);
}
}
async function importBatch() {
const toImport = filteredItems.filter(
(i) => selected.has(i.classname) && !i.alreadyImported,
);
if (toImport.length === 0) {
toast.info("Select furni that isn't imported yet");
return;
}
setBatchProgress(new Map());
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setBatchCancelling(false);
const initial = new Map<string, BatchItemStatus>();
for (const i of toImport) {
initial.set(i.classname, { classname: i.classname, status: "pending" });
}
setBatchProgress(new Map(initial));
const abort = new AbortController();
batchAbortRef.current = abort;
try {
const res = await adminFetch("/api/admin/import/furni/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: activeSource || undefined,
items: toImport.map((i) => ({
id: i.id,
classname: i.classname,
name: i.name,
description: i.description,
type: i.type,
revision: i.revision,
category: i.category,
})),
concurrency: 3,
translate,
langs:
translate && translateLangs.length > 0 ? translateLangs : undefined,
}),
signal: abort.signal,
});
if (!res.ok || !res.body) {
setBatchProgress(null);
return;
}
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.startsWith("data: ")) continue;
try {
const event = JSON.parse(line.slice(6));
if (event.type === "item_progress") {
setBatchProgress((prev) => {
if (!prev) return prev;
const next = new Map(prev);
next.set(event.classname, {
classname: event.classname,
status: event.status,
message: event.message,
warnings: event.warnings,
itemId: event.itemId,
});
return next;
});
if (event.status === "done") {
setItems((prev) =>
prev.map((i) =>
i.classname === event.classname
? { ...i, alreadyImported: true, nitroExists: true }
: i,
),
);
}
} else if (event.type === "batch_complete") {
setBatchSucceeded(event.succeeded);
setBatchFailed(event.failed);
setVerification({
offerIdsFixed: event.offerIdsFixed ?? 0,
furniDataFixedIds: event.furniDataFixedIds ?? 0,
furniDataMissing: event.furniDataMissing ?? 0,
furniDataConflicts: event.furniDataConflicts ?? 0,
nitrosSynced: event.nitrosSynced ?? [],
iconsSynced: event.iconsSynced ?? [],
ownershipFixed: event.ownershipFixed ?? [],
});
setBatchDone(true);
}
} catch {
/* skip malformed */
}
}
}
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Batch import cancelled");
} else {
toast.error("Connection lost during batch import");
}
} finally {
batchAbortRef.current = null;
setBatchCancelling(false);
setSelected(new Set());
fetchStats();
if (!batchDone) setBatchProgress(null);
}
}
const cancelBatch = () => {
if (batchAbortRef.current) {
setBatchCancelling(true);
batchAbortRef.current.abort();
}
};
async function deleteItem() {
if (!deleting) return;
try {
const result = await deleteImportedFurni({ classname: deleting });
if (result.ok) {
const data = result.data as {
deletedFiles: string[];
errors?: string[];
};
setItems((prev) => prev.filter((i) => i.classname !== deleting));
if (detail?.classname === deleting) setDetail(null);
fetchStats();
toast.success(
`Deleted ${deleting} (${data.deletedFiles?.length ?? 0} files)`,
);
} else {
toast.error(result.error || "Delete failed");
}
} catch {
toast.error("Network error");
} finally {
setDeleting(null);
setConfirmDelete(false);
}
}
async function regenNitro(item: FurniItem) {
setRegeneratingNitro((prev) => new Set(prev).add(item.classname));
try {
const res = await adminFetch("/api/admin/import/furni", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: [{ classname: item.classname, revision: item.revision }],
}),
});
const data = await res.json();
if (res.ok && data.succeeded > 0) {
setItems((prev) =>
prev.map((i) =>
i.classname === item.classname ? { ...i, nitroExists: true } : i,
),
);
toast.success(`Nitro file regenerated for ${item.classname}`);
} else {
toast.error(
data.results?.[0]?.warning || data.error || "Regeneration failed",
);
}
} catch {
toast.error("Network error");
} finally {
setRegeneratingNitro((prev) => {
const next = new Set(prev);
next.delete(item.classname);
return next;
});
}
}
const detailPreview = useMemo<AutoCatalogPreview | null>(() => {
if (!detail) return null;
return previewAutoCatalog(
detail.classname,
detail.type === "wallitem" ? "i" : "s",
);
}, [detail]);
const selectedNonImported = useMemo(
() =>
filteredItems.filter(
(i) => selected.has(i.classname) && !i.alreadyImported,
).length,
[filteredItems, selected],
);
const missingNitroCount = useMemo(
() =>
filteredItems.filter((i) => i.alreadyImported && !i.nitroExists).length,
[filteredItems],
);
const notImportedCount = useMemo(
() => filteredItems.filter((i) => i.alreadyImported === false).length,
[filteredItems],
);
return (
<div className="flex h-full min-h-[520px] flex-col overflow-hidden">
{/* ── Header ─────────────────────────────────────────── */}
<header className="flex shrink-0 flex-wrap items-center gap-3 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-3">
<div className="flex items-center gap-2.5">
<div className="grid h-9 w-9 place-items-center rounded-xl bg-gradient-to-br from-[var(--admin-accent)]/30 to-[var(--admin-accent)]/10">
<Sparkles size={16} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="text-base font-extrabold leading-tight text-[var(--admin-text)]">
Studio
</h1>
<p className="text-[0.65rem] uppercase tracking-wider text-[var(--admin-text-muted)]">
{activeSourceName || source.label} furnidata
</p>
</div>
</div>
<div className="flex flex-1 items-center gap-2">
<div className="relative w-full max-w-md">
<Search
size={14}
className="absolute left-2.5 top-1/2 -translate-y-1/2 text-[var(--admin-text-muted)]"
/>
<Input
ref={searchRef}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submitSearch();
if (e.key === "Escape") clearSearch();
}}
placeholder="Search furni, classnames, keywords… (/)"
className="pl-8 pr-8"
/>
{searchTerm ? (
<button
type="button"
onClick={clearSearch}
aria-label="Clear search"
className="absolute right-2 top-1/2 -translate-y-1/2 grid h-5 w-5 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<X size={12} />
</button>
) : null}
</div>
{meta && (
<span className="shrink-0 text-xs font-semibold text-[var(--admin-text-muted)]">
{meta.total.toLocaleString()} results
</span>
)}
</div>
<div className="flex flex-wrap items-center gap-1.5">
{stats && (
<>
<Badge variant="secondary" className="gap-1">
<Package size={11} /> {stats.totalInDb} in DB
</Badge>
<Badge variant="secondary" className="gap-1">
<Layers size={11} /> {stats.inCatalog} in catalog
</Badge>
<Badge
variant="secondary"
className={cn(
stats.missingNitro > 0 &&
"text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
>
<CircleAlert size={11} /> {stats.missingNitro} missing nitro
</Badge>
</>
)}
</div>
<div className="flex items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 py-1">
<Switch
checked={globalTranslate}
onCheckedChange={onGlobalTranslateChange}
aria-label="Globale vertaling"
/>
<Globe size={13} className="text-[var(--admin-text-muted)]" />
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Vertaling (globaal)
</span>
</div>
</header>
{/* ── Filters + actions ─────────────────────────────── */}
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2">
<button
type="button"
onClick={() => setRailOpen((v) => !v)}
aria-label={railOpen ? "Hide catalog tree" : "Show catalog tree"}
title={railOpen ? "Hide catalog tree" : "Show catalog tree"}
className={cn(
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
railOpen
? "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
: "border-[var(--admin-accent)]/40 bg-[var(--admin-accent)]/10 text-[var(--admin-accent)]",
)}
>
{railOpen ? (
<PanelLeftClose size={13} />
) : (
<PanelLeftOpen size={13} />
)}
Catalog
</button>
<Select value={activeSource} onValueChange={changeSource}>
<SelectTrigger className="h-8 w-44 text-xs">
<SelectValue placeholder="Furni source" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">{source.label} (official)</SelectItem>
{sources.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={typeFilter} onValueChange={setTypeFilter}>
<SelectTrigger className="h-8 w-32 text-xs">
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All types</SelectItem>
<SelectItem value="flooritem">Floor items</SelectItem>
<SelectItem value="wallitem">Wall items</SelectItem>
</SelectContent>
</Select>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="h-8 w-40 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="h-8 w-36 text-xs">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
All statuses {notImportedCount > 0 && notImportedCount}
</SelectItem>
<SelectItem value="imported">Imported</SelectItem>
<SelectItem value="inCatalog">In catalog</SelectItem>
<SelectItem value="missingNitro">Missing nitro</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="h-8 w-44 text-xs">
<ArrowUpDown size={12} className="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>
{missingNitroCount > 0 && (
<button
type="button"
onClick={() => setStatusFilter("missingNitro")}
className={cn(
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
statusFilter === "missingNitro"
? "border-[var(--admin-warning)] bg-[var(--admin-warning)]/10 text-[var(--admin-warning)]"
: "border-[var(--admin-border)] text-[var(--admin-warning)] hover:bg-[var(--admin-warning)]/10",
)}
>
<CircleAlert size={12} />
{missingNitroCount} no nitro
</button>
)}
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
<button
type="button"
onClick={() => setViewMode("grid")}
aria-label="Grid view"
className={cn(
"grid h-8 w-8 place-items-center transition-colors",
viewMode === "grid"
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<LayoutGrid size={13} />
</button>
<button
type="button"
onClick={() => setViewMode("list")}
aria-label="List view"
className={cn(
"grid h-8 w-8 place-items-center transition-colors",
viewMode === "list"
? "bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]"
: "text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<List size={13} />
</button>
</div>
<button
type="button"
onClick={toggleAll}
className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<CheckboxDot checked={allSelected} />
Select all
</button>
{selected.size > 0 && (
<span className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)]">
{selected.size} selected
</span>
)}
{selected.size > 0 && (
<button
type="button"
onClick={() => setSelected(new Set())}
className="flex h-8 items-center gap-1 rounded-md px-2 text-xs font-medium text-[var(--admin-text-muted)] transition-colors hover:text-[var(--admin-warning)]"
>
<X size={12} />
Clear
</button>
)}
{/* ── Translate control (per-import) ─────────────── */}
<div className="flex items-center gap-2">
<div className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-border)] px-2.5">
<Switch
checked={translate}
onCheckedChange={setTranslate}
aria-label="Vertalen bij import"
/>
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Vertalen
</span>
</div>
<Popover open={langPickerOpen} onOpenChange={setLangPickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
disabled={!translate}
>
<Languages size={13} />
{translateLangs.length === 0
? "Geen talen"
: translateLangs.length === ALL_LANGS.length
? "Alle talen"
: `${translateLangs.length} talen`}
</Button>
</PopoverTrigger>
<PopoverContent className="w-60 p-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-xs font-semibold text-[var(--admin-text)]">
Talen
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([...ALL_LANGS])}
>
Alles
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([])}
>
Geen
</Button>
</div>
</div>
<div className="grid max-h-56 grid-cols-2 gap-1 overflow-y-auto">
{ALL_LANGS.map((lang) => {
const on = translateLangs.includes(lang);
return (
<button
key={lang}
type="button"
onClick={() => toggleLang(lang)}
className={cn(
"flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors",
on
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10 text-[var(--admin-text)]"
: "border-[var(--admin-border)] text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10",
)}
>
<Checkbox
checked={on}
aria-label={`Taal ${lang}`}
tabIndex={-1}
/>
{lang.toUpperCase()}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
</div>
<div className="flex-1" />
{selectedNonImported > 0 && (
<Button
size="sm"
className="gap-1.5"
disabled={!!batchProgress}
onClick={importBatch}
>
{batchProgress ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CloudDownload size={14} />
)}
Import {selectedNonImported} auto
</Button>
)}
</div>
{/* ── Body: rail + grid + drawer ────────────────────── */}
<div className="relative flex min-h-0 flex-1">
{railOpen ? (
<CatalogRail
tree={tree}
selectedId={selectedPage}
onSelect={setSelectedPage}
/>
) : null}
<main className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto p-3">
{loading && items.length === 0 ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 10 }, (_, i) => i).map((n) => (
<div
key={`skeleton-${n}`}
className="overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-surface)]"
>
<div className="h-24 animate-pulse bg-[var(--admin-accent)]/10" />
<div className="space-y-2 border-t border-[var(--admin-border)] p-2.5">
<div className="h-3 w-3/4 animate-pulse rounded bg-[var(--admin-accent)]/10" />
<div className="h-2.5 w-1/2 animate-pulse rounded bg-[var(--admin-accent)]/10" />
</div>
</div>
))}
</div>
) : error ? (
<div className="py-16 text-center text-sm text-[var(--admin-warning)]">
{error}
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center py-16 text-center">
<div className="mb-3 grid h-14 w-14 place-items-center rounded-2xl border border-[var(--admin-border)] bg-[var(--admin-surface)]">
<Search
size={22}
className="text-[var(--admin-text-muted)]"
/>
</div>
<p className="text-sm font-semibold text-[var(--admin-text)]">
{activeSearch
? `No furni found for "${activeSearch}"`
: "No furni to show yet"}
</p>
<p className="mt-1 text-xs text-[var(--admin-text-muted)]">
{activeSearch
? "Try a different search or clear your filters."
: "Search a furni library to get started."}
</p>
</div>
) : (
<>
{viewMode === "grid" ? (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{filteredItems.map((item) => {
const isSel = selected.has(item.classname);
return (
<div key={item.classname} className="group relative">
<button
type="button"
onClick={() => setDetail(item)}
onDoubleClick={() => toggleSelect(item.classname)}
className={cn(
"flex w-full flex-col overflow-hidden rounded-xl border text-left transition-all outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]",
isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]/10"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:-translate-y-0.5 hover:border-[var(--admin-accent)]/60 hover:shadow-lg hover:shadow-[var(--admin-accent)]/10",
)}
>
<div
className="relative flex h-24 items-center justify-center overflow-hidden"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
backgroundSize: "14px 14px",
}}
>
<img
src={getFurniImageUrl(item)}
alt={item.name}
className="h-14 w-14 object-contain transition-transform group-hover:scale-110"
style={{ imageRendering: "pixelated" }}
loading="lazy"
onError={(e) => handleImgError(e, item)}
/>
</div>
<div className="flex items-start justify-between gap-1 border-t border-[var(--admin-border)] px-2 py-1.5">
<div className="min-w-0">
<p className="truncate text-xs font-semibold text-[var(--admin-text)]">
{item.name}
</p>
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
{item.classname}
</p>
</div>
</div>
<div className="flex items-center gap-1 px-2 pb-1.5">
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
</Badge>
)}
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleSelect(item.classname);
}}
className={cn(
"absolute right-1.5 top-1.5 grid h-5 w-5 place-items-center rounded-md border transition-colors",
isSel
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)] hover:border-[var(--admin-accent)]/60",
)}
aria-label={isSel ? "Deselect" : "Select"}
>
{isSel ? (
<Check
size={12}
className="text-[var(--admin-accent-foreground)]"
/>
) : null}
</button>
</div>
);
})}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-[var(--admin-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--admin-accent)]/8">
<tr>
<th className="w-9 px-2 py-2 text-left">
<Checkbox
checked={allSelected}
onCheckedChange={toggleAll}
aria-label="Select all"
/>
</th>
<th className="w-12 px-1 py-2" />
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Name
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Classname
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Type
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Category
</th>
<th className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]">
Status
</th>
<th className="w-28 px-2 py-2 text-right font-semibold text-[var(--admin-text-muted)]">
Actions
</th>
</tr>
</thead>
<tbody>
{filteredItems.map((item) => {
const isSel = selected.has(item.classname);
return (
<tr
key={item.classname}
className="border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5"
>
<td className="px-2 py-1.5">
<Checkbox
checked={isSel}
onCheckedChange={() =>
toggleSelect(item.classname)
}
aria-label={`Select ${item.classname}`}
/>
</td>
<td className="px-1 py-1.5">
<button
type="button"
onClick={() => setDetail(item)}
className="rounded outline-none focus-visible:ring-2 focus-visible:ring-[var(--admin-accent)]"
aria-label={`View ${item.name}`}
>
<img
src={getFurniImageUrl(item)}
alt=""
className="h-8 w-8 object-contain"
style={{ imageRendering: "pixelated" }}
loading="lazy"
onError={(e) => handleImgError(e, item)}
/>
</button>
</td>
<td className="max-w-[220px] truncate px-2 py-1.5 font-medium text-[var(--admin-text)]">
{item.name}
</td>
<td className="max-w-[180px] truncate px-2 py-1.5 font-mono text-[0.7rem] text-[var(--admin-text-muted)]">
{item.classname}
</td>
<td className="px-2 py-1.5 text-[var(--admin-text-muted)]">
{TYPE_LABELS[item.type] ?? item.type}
</td>
<td className="max-w-[140px] truncate px-2 py-1.5 text-[var(--admin-text-muted)]">
{item.category || "—"}
</td>
<td className="px-2 py-1.5">
{item.alreadyImported ? (
<Badge
variant="secondary"
className={cn(
"h-4",
item.nitroExists
? "text-[var(--admin-success)] border-[var(--admin-success)]/40"
: "text-[var(--admin-warning)] border-[var(--admin-warning)]/40",
)}
>
{item.nitroExists ? "Imported" : "No nitro"}
</Badge>
) : (
<Badge className="h-4 text-[var(--admin-accent-foreground)]">
New
</Badge>
)}
</td>
<td className="px-2 py-1.5">
<div className="flex items-center justify-end gap-1">
{!item.alreadyImported && (
<button
type="button"
onClick={() => importSingle(item)}
disabled={importingId !== null}
aria-label={`Import ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)] disabled:opacity-50"
>
{importingId === item.classname ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<CloudDownload size={12} />
)}
</button>
)}
{item.alreadyImported && item.nitroExists && (
<button
type="button"
onClick={() =>
setEditingClassname(item.classname)
}
aria-label={`Edit nitro ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-text-muted)] transition-colors hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<Pencil size={12} />
</button>
)}
{item.alreadyImported &&
!item.nitroExists && (
<button
type="button"
onClick={() => regenNitro(item)}
disabled={regeneratingNitro.has(
item.classname,
)}
aria-label={`Regenerate nitro ${item.classname}`}
className="grid h-6 w-6 place-items-center rounded-md text-[var(--admin-warning)] transition-colors hover:bg-[var(--admin-warning)]/10 disabled:opacity-50"
>
{regeneratingNitro.has(
item.classname,
) ? (
<Loader2
size={12}
className="animate-spin"
/>
) : (
<RefreshCw size={12} />
)}
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{meta && meta.currentPage < meta.lastPage && (
<div className="mt-4 flex justify-center">
<Button
variant="secondary"
onClick={() =>
fetchItems(
activeSearch,
meta.currentPage + 1,
activeSource,
)
}
disabled={loading}
>
{loading ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Download size={14} />
)}
Load more ({meta.total - meta.currentPage * meta.perPage}{" "}
left)
</Button>
</div>
)}
</>
)}
</div>
{batchProgress ? (
<BatchProgress
progress={batchProgress}
done={batchDone}
succeeded={batchSucceeded}
failed={batchFailed}
cancelling={batchCancelling}
verification={verification}
onCancel={cancelBatch}
onDismiss={() => {
setBatchProgress(null);
setBatchDone(false);
setVerification(null);
}}
/>
) : null}
</main>
{/* ── Detail drawer ─────────────────────────────── */}
{detail ? (
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-80 shrink-0 flex-col border-l border-[var(--admin-border)] bg-[var(--admin-surface)] shadow-xl lg:static lg:h-full lg:shadow-none">
<div className="flex shrink-0 items-center justify-between border-b border-[var(--admin-border)] px-3 py-2.5">
<span className="text-xs font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
Furni detail
</span>
<button
type="button"
onClick={() => setDetail(null)}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
<X size={14} />
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4">
<div
className="mb-4 grid h-40 place-items-center overflow-hidden rounded-xl border border-[var(--admin-border)]"
style={{
backgroundImage:
"radial-gradient(circle at 1px 1px, color-mix(in srgb, var(--admin-text-muted) 14%, transparent) 1px, transparent 0)",
backgroundSize: "14px 14px",
}}
>
<img
src={getFurniImageUrl(detail)}
alt={detail.name}
className="h-28 w-28 object-contain"
style={{ imageRendering: "pixelated" }}
onError={(e) => handleImgError(e, detail)}
/>
</div>
<h2 className="text-base font-bold leading-tight text-[var(--admin-text)]">
{detail.name}
</h2>
<p className="mb-3 mt-0.5 font-mono text-xs text-[var(--admin-text-muted)]">
{detail.classname}
</p>
{detail.description ? (
<p className="mb-3 text-xs leading-relaxed text-[var(--admin-text-muted)]">
{detail.description}
</p>
) : null}
<div className="mb-4 flex flex-wrap gap-1.5">
<Badge variant="secondary">
{TYPE_LABELS[detail.type] ?? detail.type}
</Badge>
<Badge variant="secondary">rev {detail.revision}</Badge>
{detail.alreadyImported ? (
<Badge className="text-[var(--admin-success)] border-[var(--admin-success)]/40">
<Check size={10} /> In catalog
</Badge>
) : (
<Badge className="text-[var(--admin-accent-foreground)]">
<Sparkles size={10} /> Auto-import ready
</Badge>
)}
</div>
{/* Auto-catalog preview */}
{detailPreview && (
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
<div className="mb-2 flex items-center gap-1.5 text-[0.65rem] font-bold uppercase tracking-wider text-[var(--admin-text-muted)]">
<WandSparkles
size={12}
className="text-[var(--admin-accent)]"
/>
Automatic placement
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
Category
</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.categoryLabel}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-[var(--admin-text-muted)]">
Price
</span>
<span className="font-semibold text-[var(--admin-text)]">
{detailPreview.credits} credits
{detailPreview.points > 0
? ` + ${detailPreview.points} points`
: ""}
</span>
</div>
</div>
</div>
)}
</div>
<div className="shrink-0 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
<div className="space-y-2">
{!detail.alreadyImported ? (
<Button
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={() => importSingle(detail)}
>
{importingId === detail.classname ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CloudDownload size={14} />
)}
Import & add to catalog
</Button>
) : (
<div className="space-y-2">
{!detail.nitroExists && (
<>
<p className="rounded-md border border-[var(--admin-warning)]/30 bg-[var(--admin-warning)]/10 p-2 text-[0.65rem] text-[var(--admin-warning)]">
This furni is in the DB but the .nitro file is
missing.
</p>
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
disabled={regeneratingNitro.has(detail.classname)}
onClick={() => regenNitro(detail)}
>
{regeneratingNitro.has(detail.classname) ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Regenerate .nitro
</Button>
</>
)}
<Button
variant="secondary"
className="w-full gap-1.5"
onClick={() => setEditingClassname(detail.classname)}
>
<Pencil size={14} />
Edit nitro
</Button>
</div>
)}
{detail.alreadyImported && (
<Button
variant="outline"
className="w-full gap-1.5 text-[var(--admin-warning)]"
onClick={() => {
setDeleting(detail.classname);
setConfirmDelete(true);
}}
>
<Trash2 size={14} />
Delete imported furni
</Button>
)}
</div>
</div>
</aside>
) : null}
</div>
<NitroEditorDialog
classname={editingClassname ?? ""}
name={detail?.name ?? ""}
open={editingClassname !== null}
onOpenChange={(open) => !open && setEditingClassname(null)}
/>
<Dialog open={confirmDelete} onOpenChange={setConfirmDelete}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete imported furni</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
This permanently removes{" "}
<span className="font-mono font-semibold text-foreground">
{deleting}
</span>{" "}
from items_base and the catalog, plus its asset files.
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmDelete(false)}>
Cancel
</Button>
<Button
variant="destructive"
className="gap-1.5"
onClick={deleteItem}
disabled={!deleting}
>
<Trash2 size={14} />
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}