Files
Epicnabbo-Catalogus-Updated…/src/components/admin/studio/studio-client.tsx
T

2071 lines
64 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,
X,
} from "lucide-react";
import {
lazy,
Suspense,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { toast } from "sonner";
import { deleteImportedFurni } from "@/actions/import-furni";
import { useConfirmDialog } from "@/components/admin/confirm-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 { FurniImportSource } from "@/lib/habbo-gamedata-hotel";
import { readSseStream } from "@/lib/sse-client";
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 { FurnitureJobHistory } from "./furniture-jobs";
import { filterFurniture } from "./studio-filters";
import {
getFurniImageUrl,
handleImgError,
TYPE_LABELS,
} from "./studio-furni-icons";
import { StudioPanelLoading } from "./studio-panel-loading";
import type {
BatchItemStatus,
FurniItem,
FurniStats,
Meta,
VerificationSummary,
} from "./studio-types";
import { useFurnitureJobs } from "./use-furniture-jobs";
import { useStudioTranslations } from "./use-studio-translations";
const NitroEditorDialog = lazy(() =>
import("@/app/admin/import/furni/nitro-editor-dialog").then((module) => ({
default: module.NitroEditorDialog,
})),
);
const FurnitureDetailDrawer = lazy(() =>
import("./furniture-detail-drawer").then((module) => ({
default: module.FurnitureDetailDrawer,
})),
);
const ImportReview = lazy(() =>
import("./import-review").then((module) => ({
default: module.ImportReview,
})),
);
export function StudioClient({
source,
initialTree,
defaultTranslate,
}: {
source: FurniImportSource;
initialTree: TreeNode[];
defaultTranslate: boolean;
}) {
const [review, setReview] = useState<{
items: FurniItem[];
single: boolean;
cloneAll?: boolean;
} | null>(null);
const jobs = useFurnitureJobs(() => {
void fetchItems(activeSearch, 1, activeSource);
void fetchStats();
});
const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
const [selectedPage, setSelectedPage] = useState<number | null>(null);
const [railOpen, setRailOpen] = useState(false);
useEffect(() => {
setRailOpen(window.matchMedia("(min-width: 1024px)").matches);
}, []);
// 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">("list");
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 = jobs.busy ? (review?.items[0]?.classname ?? null) : null;
const [batchProgress, setBatchProgress] = useState<Map<
string,
BatchItemStatus
> | null>(null);
const [batchDone, setBatchDone] = useState(false);
const batchDoneRef = useRef(false);
const markBatchDone = useCallback(() => {
batchDoneRef.current = true;
setBatchDone(true);
}, []);
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);
// Confirm dialog for clone all
const { confirm, dialog: confirmDialog } = useConfirmDialog();
// Clone source stats (shown when a clone source is active)
const [cloneStats, setCloneStats] = useState<{
total: number;
present: number;
clonable: number;
} | null>(null);
const [cloneAllCancelling, setCloneAllCancelling] = useState(false);
const cloneAllAbortRef = useRef<AbortController | null>(null);
const batchStartTimeRef = useRef<number>(0);
const [failedClassnames, setFailedClassnames] = useState<string[]>([]);
// Nitro regeneration (missing .nitro)
const [regeneratingNitro, setRegeneratingNitro] = useState<Set<string>>(
new Set(),
);
const {
ALL_LANGS,
translate,
setTranslate,
translateLangs,
setTranslateLangs,
langPickerOpen,
setLangPickerOpen,
buildingLanguages,
globalTranslate,
buildAllLanguages,
toggleLang,
onGlobalTranslateChange,
} = useStudioTranslations(defaultTranslate);
const categories = useMemo(() => {
const cats = new Set(items.map((i) => i.category).filter(Boolean));
return ["all", ...Array.from(cats).sort()];
}, [items]);
const filteredItems = useMemo(
() =>
filterFurniture(items, {
typeFilter,
categoryFilter,
statusFilter,
sortBy,
}),
[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("");
setCloneStats(null);
setBatchProgress(null);
fetchCloneStats(sourceId);
};
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 */
}
}, []);
const fetchCloneStats = useCallback(async (sourceId: string) => {
if (!sourceId) {
setCloneStats(null);
return;
}
try {
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(sourceId)}`,
);
if (res.ok) {
const data = await res.json();
setCloneStats(data.stats ?? null);
}
} catch {
/* ignore */
}
}, []);
async function prepareCloneReview() {
if (!activeSource || preparingReview) return;
setPreparingReview(true);
try {
const response = await adminFetch(
"/api/admin/import/clone?source=" +
encodeURIComponent(activeSource) +
"&action=clonable",
);
const result = await response.json();
if (!response.ok || !Array.isArray(result.classnames))
throw new Error(result.error || "Could not load missing furniture");
const missing = new Set<string>(result.classnames);
if (!missing.size) {
toast.info("All items from this source are already imported");
return;
}
const collected = new Map<string, FurniItem>();
let page = 1,
lastPage = 1;
do {
const res = await adminFetch(
"/api/admin/import/furni?source=" +
encodeURIComponent(activeSource) +
"&page=" +
page,
);
const data = await res.json();
if (
!res.ok ||
!Array.isArray(data.items) ||
!Number.isFinite(data.meta?.lastPage)
)
throw new Error(data.error || "Could not prepare the full selection");
for (const item of data.items as FurniItem[])
if (missing.has(item.classname)) collected.set(item.classname, item);
lastPage = data.meta.lastPage;
page++;
} while (page <= lastPage && collected.size < missing.size);
if (collected.size !== missing.size)
throw new Error(
"The source changed while preparing the selection. Refresh and retry.",
);
setReview({
items: [...collected.values()],
single: false,
cloneAll: true,
});
} catch (error) {
toast.error((error as Error).message);
} finally {
setPreparingReview(false);
}
}
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 clearFilters = () => {
setTypeFilter("all");
setCategoryFilter("all");
setStatusFilter("all");
setSelectedPage(null);
};
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) {
if (review) return;
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, review]);
async function importBatch(
requested?: string[],
reviewedItems?: FurniItem[],
) {
const toImport = (
reviewedItems ?? (requested ? items : filteredItems)
).filter(
(i) =>
(requested
? requested.includes(i.classname)
: selected.has(i.classname)) &&
(!!reviewedItems || !i.alreadyImported),
);
if (toImport.length === 0) {
toast.info("Select furni that isn't imported yet");
return;
}
batchDoneRef.current = false;
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setBatchCancelling(false);
setFailedClassnames([]);
batchStartTimeRef.current = Date.now();
const initial = new Map<string, BatchItemStatus>();
for (const i of toImport) {
initial.set(i.classname, { classname: i.classname, status: "pending" });
}
setBatchProgress(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,
repairExisting: true,
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) {
toast.error(`Batch import failed (${res.status})`);
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.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(event.classname)
? prev
: [...prev, event.classname],
);
}
} 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 ?? [],
});
markBatchDone();
}
} catch {
/* skip malformed */
}
}
}
} catch (err) {
if ((err as Error).name === "AbortError") {
toast.info("Import cancelled");
} else if ((err as Error).name === "TimeoutError") {
toast.error("Import timed out. Retry or reduce concurrency.");
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
toast.error(
"Network error during upload. Check your connection and retry.",
);
} else {
toast.error(
"Unexpected import error: " +
(err instanceof Error ? err.message : String(err)),
);
}
} finally {
batchAbortRef.current = null;
setBatchCancelling(false);
setSelected(new Set());
fetchStats();
if (!batchDoneRef.current) markBatchDone();
}
}
const cancelBatch = () => {
if (batchAbortRef.current) {
setBatchCancelling(true);
batchAbortRef.current.abort();
}
};
const cancelCloneAll = () => {
if (cloneAllAbortRef.current) {
setCloneAllCancelling(true);
cloneAllAbortRef.current.abort();
}
};
async function retryFailed(failed: string[]) {
if (failed.length === 0) return;
if (!activeSource) {
await importBatch(failed);
return;
}
// For clone sources, re-run cloneAllMissing with only the failed classnames
batchDoneRef.current = false;
setBatchDone(false);
setBatchSucceeded(0);
setBatchFailed(0);
setFailedClassnames([]);
batchStartTimeRef.current = Date.now();
const initial = new Map<string, BatchItemStatus>();
for (const cn of failed) {
initial.set(cn, { classname: cn, status: "pending" });
}
setBatchProgress(initial);
const abort = new AbortController();
cloneAllAbortRef.current = abort;
let okCount = 0;
let failCount = 0;
try {
const res = await adminFetch("/api/admin/import/clone/batch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sourceId: activeSource,
classnames: failed,
concurrency: 10,
final: true,
}),
signal: abort.signal,
});
if (!res.ok || !res.body) {
toast.error(`Retry failed (${res.status})`);
setBatchProgress(null);
return;
}
await readSseStream(
res.body,
(evt) => {
if (evt.type === "item_progress") {
const cn = String(evt.classname ?? "");
setBatchProgress((prev) => {
if (!prev) return prev;
const next = new Map(prev);
next.set(cn, {
classname: cn,
status: evt.status as BatchItemStatus["status"],
message: evt.message as string | undefined,
});
return next;
});
if (evt.status === "done" || evt.status === "failed") {
if (evt.status === "failed") {
setFailedClassnames((prev) =>
prev.includes(cn) ? prev : [...prev, cn],
);
}
}
}
if (evt.type === "batch_complete") {
okCount += Number(evt.succeeded ?? 0);
failCount += Number(evt.failed ?? 0);
}
},
abort.signal,
);
setBatchSucceeded(okCount);
setBatchFailed(failCount);
markBatchDone();
if (okCount > 0) {
toast.success(`${okCount} items imported again`);
}
fetchItems(activeSearch, 1, activeSource);
fetchStats();
fetchCloneStats(activeSource);
} catch (err) {
if ((err as Error)?.name !== "AbortError") {
toast.error("Connection failed while retrying");
setBatchProgress(null);
}
} finally {
cloneAllAbortRef.current = null;
}
}
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));
setSelected((prev) => {
const next = new Set(prev);
next.delete(deleting);
return next;
});
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 [bulkDeleting, setBulkDeleting] = useState(false);
const [bulkRegenning, setBulkRegenning] = useState(false);
async function bulkDeleteSelected() {
if (selectedImported.length === 0) return;
const confirmed = await confirm({
title: `${selectedImported.length} items verwijderen?`,
description: `Alle ${selectedImported.length} geselecteerde geïmporteerde items worden permanent verwijderd uit de database en het bestandssysteem.`,
confirmLabel: "Verwijderen",
variant: "danger",
});
if (!confirmed) return;
setBulkDeleting(true);
let deleted = 0;
let failed = 0;
try {
for (const item of selectedImported) {
try {
const result = await deleteImportedFurni({
classname: item.classname,
});
if (result.ok) {
deleted++;
} else {
failed++;
}
} catch {
failed++;
}
}
setItems((prev) => prev.filter((i) => !selected.has(i.classname)));
setSelected(new Set());
fetchStats();
toast.success(
`Verwijderd: ${deleted}${failed > 0 ? `, mislukt: ${failed}` : ""}`,
);
} catch {
toast.error("Bulk delete mislukt");
} finally {
setBulkDeleting(false);
}
}
async function bulkRegenNitro() {
if (selectedMissingNitro.length === 0) return;
setBulkRegenning(true);
const cns = selectedMissingNitro.map((i) => i.classname);
setRegeneratingNitro((prev) => {
const next = new Set(prev);
for (const cn of cns) next.add(cn);
return next;
});
try {
const res = await adminFetch("/api/admin/import/furni", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
items: selectedMissingNitro.map((i) => ({
classname: i.classname,
revision: i.revision,
})),
}),
});
const data = await res.json();
if (res.ok) {
const succeededClassnames = new Set<string>(
(data.results ?? [])
.filter((r: { ok: boolean }) => r.ok)
.map((r: { classname: string }) => r.classname),
);
setItems((prev) =>
prev.map((i) =>
succeededClassnames.has(i.classname)
? { ...i, nitroExists: true }
: i,
),
);
const succeeded = Number(data.succeeded ?? 0);
const failed = Number(data.failed ?? 0);
toast.success(
`Nitro geregenereerd: ${succeeded}${failed > 0 ? `, mislukt: ${failed}` : ""}`,
);
} else {
toast.error(data.error || "Regeneratie mislukt");
}
} catch {
toast.error("Network error");
} finally {
setRegeneratingNitro((prev) => {
const next = new Set(prev);
for (const cn of cns) next.delete(cn);
return next;
});
setBulkRegenning(false);
}
}
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 selectedImported = useMemo(
() =>
filteredItems.filter(
(i) => selected.has(i.classname) && i.alreadyImported,
),
[filteredItems, selected],
);
const selectedMissingNitro = useMemo(
() => selectedImported.filter((i) => !i.nitroExists),
[selectedImported],
);
const notImportedCount = useMemo(
() =>
activeSource && cloneStats
? cloneStats.clonable
: filteredItems.filter((i) => i.alreadyImported === false).length,
[filteredItems, activeSource, cloneStats],
);
return (
<div className="flex h-full min-h-0 min-w-0 flex-col overflow-hidden">
<FurnitureJobHistory state={jobs} />
{/* ── 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
style={{ fontSize: 20, margin: 0 }}
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 min-w-[180px] 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
aria-label="Search furniture"
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… (/)"
style={{ paddingLeft: 32, paddingRight: 32 }}
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 && !activeSource && (
<>
<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>
</>
)}
{cloneStats && activeSource && (
<>
<Badge variant="secondary" className="gap-1">
<Package size={11} /> {cloneStats.total} total
</Badge>
<Badge variant="secondary" className="gap-1">
<Check size={11} className="text-[var(--admin-success)]" />{" "}
{cloneStats.present} present
</Badge>
{cloneStats.clonable > 0 && (
<Badge
variant="secondary"
className="gap-1 text-[var(--admin-accent)] border-[var(--admin-accent)]/40"
>
<CloudDownload size={11} /> {cloneStats.clonable} te
importeren
</Badge>
)}
</>
)}
</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}
disabled={preparingReview}
>
<SelectTrigger
aria-label="Furniture source"
className="h-8 w-44 text-xs"
>
<SelectValue>
{sources.find((entry) => entry.id === activeSource)?.name ??
`${source.label} (official)`}
</SelectValue>
</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
aria-label="Furniture type"
className="h-8 w-32 text-xs"
>
<SelectValue>
{typeFilter === "all"
? "All types"
: typeFilter === "flooritem"
? "Floor items"
: "Wall items"}
</SelectValue>
</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
aria-label="Furniture category"
className="h-8 w-40 text-xs"
>
<SelectValue>
{categoryFilter === "all" ? "All categories" : categoryFilter}
</SelectValue>
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat === "all" ? "All categories" : cat}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger
aria-label="Import status"
className="h-8 w-36 text-xs"
>
<SelectValue>
{
(
{
all: "All statuses",
notImported: "Not imported",
imported: "Imported",
inCatalog: "In catalog",
missingNitro: "Missing nitro",
missingFurnidata: "Missing furnidata",
missingCatalog: "Missing catalog entry",
} as Record<string, string>
)[statusFilter]
}
</SelectValue>
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
All statuses{" "}
{notImportedCount > 0 &&
`(${notImportedCount} available to import)`}
</SelectItem>
<SelectItem value="notImported">Not imported</SelectItem>
<SelectItem value="imported">Imported</SelectItem>
<SelectItem value="inCatalog">In catalog</SelectItem>
<SelectItem value="missingNitro">Missing nitro</SelectItem>
<SelectItem value="missingFurnidata">Missing furnidata</SelectItem>
<SelectItem value="missingCatalog">
Missing catalog entry
</SelectItem>
</SelectContent>
</Select>
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger
aria-label="Sort furniture"
className="h-8 w-44 text-xs"
>
<ArrowUpDown size={12} className="mr-1" />
<SelectValue>
{
(
{
default: "Default order",
name: "Name A–Z",
classname: "Classname A–Z",
category: "Category",
"imported-first": "Imported first",
"not-imported-first": "Not imported first",
} as Record<string, string>
)[sortBy]
}
</SelectValue>
</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>
{(typeFilter !== "all" ||
categoryFilter !== "all" ||
statusFilter !== "all" ||
selectedPage !== null) && (
<Button variant="ghost" size="sm" onClick={clearFilters}>
Reset filters
</Button>
)}
{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"
aria-pressed={viewMode === "grid"}
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"
aria-pressed={viewMode === "list"}
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>
</div>
<details className="shrink-0 border-b border-[var(--admin-border)] bg-[var(--admin-surface)] px-4 py-2">
<summary className="cursor-pointer py-1 text-sm font-medium">
Import options{" "}
<span className="font-normal text-[var(--admin-text-muted)]">
· Translation {translate ? "on" : "off"}
</span>
</summary>
<div className="flex max-h-48 flex-wrap items-center gap-3 overflow-auto py-3">
{" "}
{/* ── 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="Translate this import"
/>
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Translate
</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
? "No languages"
: translateLangs.length === ALL_LANGS.length
? "All languages"
: `${translateLangs.length} languages`}
</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)]">
Languages
</span>
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([...ALL_LANGS])}
>
All
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-[0.65rem]"
onClick={() => setTranslateLangs([])}
>
None
</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
style={{
display: "inline-flex",
width: 18,
height: 18,
}}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
checked={on}
aria-label={`Language ${lang}`}
tabIndex={-1}
/>
{lang.toUpperCase()}
</button>
);
})}
</div>
</PopoverContent>
</Popover>
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5"
disabled={buildingLanguages}
onClick={buildAllLanguages}
title="Translate the entire catalog into all languages"
>
{buildingLanguages ? (
<Loader2 size={13} className="animate-spin" />
) : (
<Globe size={13} />
)}
Translate entire catalog
</Button>
</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="Default translation"
/>
<Globe size={13} className="text-[var(--admin-text-muted)]" />
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
Default for future imports
</span>
</div>
</div>
</details>
<fieldset
aria-label="Furniture selection"
className="flex shrink-0 flex-wrap items-center gap-2 border-b border-[var(--admin-border)] bg-[var(--admin-canvas)] px-4 py-2"
>
<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} />
{allSelected ? "Deselect visible" : "Select visible"}
</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)]">
{
filteredItems.filter((item) => selected.has(item.classname))
.length
}{" "}
selected in this view
</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>
)}
<div className="flex-1" />
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
<Button
size="sm"
className="gap-1.5"
onClick={prepareCloneReview}
disabled={preparingReview}
>
<CloudDownload size={14} />
{preparingReview
? "Preparing review…"
: `Import all missing (${cloneStats.clonable})`}
</Button>
)}
{selectedNonImported > 0 && (
<Button
size="sm"
className="gap-1.5"
disabled={!!batchProgress}
onClick={() =>
setReview({
items: filteredItems.filter((item) =>
selected.has(item.classname),
),
single: false,
})
}
>
{batchProgress ? (
<Loader2 size={14} className="animate-spin" />
) : (
<CloudDownload size={14} />
)}
Import selected ({selectedNonImported})
</Button>
)}
{selectedMissingNitro.length > 0 && !batchProgress && (
<Button
size="sm"
variant="outline"
className="gap-1.5"
disabled={bulkRegenning}
onClick={bulkRegenNitro}
>
{bulkRegenning ? (
<Loader2 size={14} className="animate-spin" />
) : (
<RefreshCw size={14} />
)}
Rebuild Nitro ({selectedMissingNitro.length})
</Button>
)}
{selectedImported.length > 0 && !batchProgress && (
<Button
size="sm"
variant="destructive"
className="gap-1.5"
disabled={bulkDeleting}
onClick={bulkDeleteSelected}
>
{bulkDeleting ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
Delete ({selectedImported.length})
</Button>
)}
</fieldset>
{selected.size >
filteredItems.filter((item) => selected.has(item.classname)).length && (
<p className="shrink-0 px-4 py-1 text-xs text-[var(--admin-text-muted)]">
Some selected furniture is hidden by your filters. Actions apply to
the visible selection.
</p>
)}
{/* ── Body: rail + grid + drawer ────────────────────── */}
<div className="relative flex min-h-0 flex-1">
{railOpen && (
<button
type="button"
aria-label="Close category panel"
onClick={() => setRailOpen(false)}
className="absolute inset-0 z-20 bg-[var(--admin-text)]/30 lg:hidden"
/>
)}
{railOpen ? (
<CatalogRail
tree={tree}
selectedId={selectedPage}
onSelect={(id) => {
setSelectedPage(id);
if (!window.matchMedia("(min-width: 1024px)").matches)
setRailOpen(false);
}}
onClose={() => setRailOpen(false)}
/>
) : null}
<main className="flex min-h-0 min-w-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-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{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}
<Button
variant="outline"
className="ml-3"
onClick={() => fetchItems(activeSearch, 1, activeSource)}
>
Try again
</Button>
</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-[repeat(auto-fill,minmax(150px,1fr))] gap-3">
{filteredItems.map((item) => {
const isSel = selected.has(item.classname);
return (
<div key={item.classname} className="group relative">
<button
type="button"
onClick={() => setDetail(item)}
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 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
{TYPE_LABELS[item.type] ?? item.type}
</Badge>
{item.alreadyImported ? (
<>
{!item.nitroExists && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No nitro
</Badge>
)}
{item.hasFurnidata === false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No furnidata
</Badge>
)}
{item.hasCatalogEntry === false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No catalog
</Badge>
)}
{item.nitroExists &&
item.hasFurnidata !== false &&
item.hasCatalogEntry !== false && (
<Badge
variant="secondary"
className="h-4 text-[var(--admin-success)] border-[var(--admin-success)]/40"
>
Imported
</Badge>
)}
</>
) : (
<Badge className="h-4 bg-[var(--admin-accent)] text-[var(--admin-accent-foreground)]">
New
</Badge>
)}
</div>
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
toggleSelect(item.classname);
}}
className={cn(
"absolute right-2 top-2 grid h-8 w-8 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"} ${item.name}`}
aria-pressed={isSel}
>
{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 min-w-[660px] text-sm">
<thead className="sticky top-0 z-10 bg-[var(--admin-surface)]">
<tr>
<th className="w-9 px-2 py-2 text-left">
<Checkbox
style={{
display: "inline-flex",
width: 18,
height: 18,
}}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
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}
aria-selected={isSel}
className={cn(
"border-t border-[var(--admin-border)] transition-colors hover:bg-[var(--admin-accent)]/5",
isSel && "bg-[var(--admin-accent)]/10",
)}
>
<td className="px-2 py-1.5">
<Checkbox
style={{
display: "inline-flex",
width: 18,
height: 18,
}}
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
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 ? (
<div className="flex flex-wrap gap-0.5">
{!item.nitroExists && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No nitro
</Badge>
)}
{item.hasFurnidata === false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No furnidata
</Badge>
)}
{item.hasCatalogEntry === false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-warning)] border-[var(--admin-warning)]/40"
>
No catalog
</Badge>
)}
{item.nitroExists &&
item.hasFurnidata !== false &&
item.hasCatalogEntry !== false && (
<Badge
variant="secondary"
className="h-4 text-[0.6rem] text-[var(--admin-success)] border-[var(--admin-success)]/40"
>
Imported
</Badge>
)}
</div>
) : (
<Badge className="h-4 bg-[var(--admin-accent)] 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={() =>
setReview({
items: [item],
single: true,
})
}
disabled={importingId !== null}
aria-label={`Import ${item.classname}`}
className="grid h-9 w-9 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-9 w-9 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-9 w-9 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 || cloneAllCancelling}
verification={verification}
startTime={batchStartTimeRef.current || undefined}
failedClassnames={
failedClassnames.length > 0 ? failedClassnames : undefined
}
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
onDismiss={() => {
setBatchProgress(null);
batchDoneRef.current = false;
setBatchDone(false);
setVerification(null);
setFailedClassnames([]);
}}
onRetry={retryFailed}
/>
) : null}
</main>
{/* ── Detail drawer ─────────────────────────────── */}
{detail ? (
<Suspense
fallback={
<StudioPanelLoading drawer onClose={() => setDetail(null)} />
}
>
<FurnitureDetailDrawer
detail={detail}
importingId={importingId}
regenerating={regeneratingNitro.has(detail.classname)}
inspectionKey={`${detail.classname}-${detail.alreadyImported}-${jobs.jobs
.filter(
(j) =>
j.state === "completed" ||
j.state === "cancelled" ||
j.state === "interrupted",
)
.map((j) => j.id)
.join(",")}`}
busy={
jobs.busy ||
jobs.jobs.some(
(j) =>
(j.state === "queued" || j.state === "running") &&
j.items.some((i) => i.classname === detail.classname),
)
}
onClose={() => setDetail(null)}
onImport={() => setReview({ items: [detail], single: true })}
onEdit={() => setEditingClassname(detail.classname)}
onRegenerate={() => void regenNitro(detail)}
onDelete={() => {
setDeleting(detail.classname);
setConfirmDelete(true);
}}
onRepair={() =>
jobs.submit([detail], {
sourceId: activeSource || undefined,
translate: false,
mode: "repair",
})
}
/>
</Suspense>
) : null}
</div>
{review && (
<Suspense
fallback={<StudioPanelLoading onClose={() => setReview(null)} />}
>
<ImportReview
items={review.items}
sourceId={activeSource || undefined}
source={
sources.find((entry) => entry.id === activeSource)?.name ??
source.label
}
translation={
translate
? translateLangs.length
? translateLangs.join(", ")
: "Default languages"
: "Off"
}
onCancel={() => setReview(null)}
busy={jobs.busy}
onConfirm={async (accepted) => {
if (
await jobs.submit(accepted, {
sourceId: activeSource || undefined,
translate,
langs: translateLangs.length ? translateLangs : undefined,
})
) {
setReview(null);
setSelected(new Set());
}
}}
/>
</Suspense>
)}
{editingClassname !== null && (
<Suspense
fallback={
<StudioPanelLoading onClose={() => setEditingClassname(null)} />
}
>
<NitroEditorDialog
classname={editingClassname ?? ""}
name={detail?.name ?? ""}
open={editingClassname !== null}
onOpenChange={(open) => !open && setEditingClassname(null)}
/>
</Suspense>
)}
{confirmDialog}
<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>
);
}