Confirm Dialog: - Overlay opacity 80% -> 95% (solid black) - Card uses bg-surface with glow shadow ring-primary/40 - Added AlertTriangle icon for danger variant - Removed backdrop-blur for maximum opacity Studio features: - Bulk delete: select imported items and delete in batch - Bulk nitro regen: select items missing .nitro and regenerate all - Added Trash2 and RefreshCw toolbar buttons for bulk actions Performance: - BatchProgress: React.memo + useMemo for entries/total/completed/percent - BatchProgress: ETA interval uses useRef to avoid re-creation per completed - CatalogRail: React.memo + useCallback for toggle - Removed redundant double setBatchProgress calls - Removed redundant new Map(initial) wrapping Bug fixes: - importBatch: stale batchDone closure always cleared progress panel (use ref) - importBatch: added missing toast.error on HTTP failure - bulkRegenNitro: now checks per-item results instead of marking all as succeeded - single deleteItem: now removes classname from selected set - retryFailed: clears progress panel on HTTP error and stream error - Removed unused 'done' variable (2x) from cloneAllMissing/retryFailed Lint: - biome format fixes for selectedMissingNitro and setItems
2175 lines
68 KiB
TypeScript
2175 lines
68 KiB
TypeScript
"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 { 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 AutoCatalogPreview,
|
||
previewAutoCatalog,
|
||
} from "@/lib/furni/auto-catalog";
|
||
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 {
|
||
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 batchDoneRef = useRef(false);
|
||
const markBatchDone = useCallback(() => {
|
||
batchDoneRef.current = true;
|
||
markBatchDone();
|
||
}, []);
|
||
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(),
|
||
);
|
||
|
||
// 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 [buildingLanguages, setBuildingLanguages] = useState(false);
|
||
const [globalTranslate, setGlobalTranslate] =
|
||
useState<boolean>(defaultTranslate);
|
||
|
||
// Build every language for the whole catalog at once (used to translate
|
||
// everything after importing with translation disabled for speed).
|
||
const buildAllLanguages = async () => {
|
||
setBuildingLanguages(true);
|
||
try {
|
||
const res = await adminFetch(
|
||
"/api/admin/import/furni?action=build-languages",
|
||
);
|
||
const data = (await res.json()) as {
|
||
languages?: Array<{ lang: string; ok: boolean }>;
|
||
error?: string;
|
||
};
|
||
if (res.ok) {
|
||
const langs = data.languages ?? [];
|
||
const okCount = langs.filter((l) => l.ok).length;
|
||
toast.success(
|
||
`Alle talen vertaald (${okCount}/${langs.length} gelukt)`,
|
||
);
|
||
} else {
|
||
toast.error(data.error ?? "Vertaling mislukt");
|
||
}
|
||
} catch {
|
||
toast.error("Netwerkfout bij het vertalen van alle talen");
|
||
} finally {
|
||
setBuildingLanguages(false);
|
||
}
|
||
};
|
||
|
||
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 === "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);
|
||
else if (statusFilter === "missingFurnidata")
|
||
result = result.filter(
|
||
(i) => i.alreadyImported && i.hasFurnidata === false,
|
||
);
|
||
else if (statusFilter === "missingCatalog")
|
||
result = result.filter(
|
||
(i) => i.alreadyImported && i.hasCatalogEntry === false,
|
||
);
|
||
else if (statusFilter === "notImported")
|
||
result = result.filter((i) => !i.alreadyImported);
|
||
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("");
|
||
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 cloneAllMissing() {
|
||
if (!activeSource) return;
|
||
|
||
// Fetch all clonable classnames
|
||
let classnames: string[] = [];
|
||
try {
|
||
const res = await adminFetch(
|
||
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
|
||
);
|
||
const data = (await res.json()) as {
|
||
classnames?: string[];
|
||
error?: string;
|
||
};
|
||
if (!res.ok) {
|
||
toast.error(data.error || "Failed to fetch clonable items");
|
||
return;
|
||
}
|
||
classnames = data.classnames || [];
|
||
} catch {
|
||
toast.error("Network error fetching clonable items");
|
||
return;
|
||
}
|
||
|
||
if (classnames.length === 0) {
|
||
toast.info("Alles is al geïmporteerd voor deze bron");
|
||
return;
|
||
}
|
||
|
||
const sourceName =
|
||
sources.find((s) => s.id === activeSource)?.name || activeSource;
|
||
|
||
const confirmed = await confirm({
|
||
title: "Alles importeren",
|
||
description: `${classnames.length} items importeren uit "${sourceName}"? Dit kan even duren.`,
|
||
confirmLabel: "Alles importeren",
|
||
});
|
||
if (!confirmed) return;
|
||
|
||
// Initialize progress tracking as a Map for BatchProgress compatibility
|
||
const initial = new Map<string, BatchItemStatus>();
|
||
for (const cn of classnames) {
|
||
initial.set(cn, { classname: cn, status: "pending" });
|
||
}
|
||
setBatchProgress(initial);
|
||
setBatchDone(false);
|
||
setBatchSucceeded(0);
|
||
setBatchFailed(0);
|
||
setFailedClassnames([]);
|
||
setCloneAllCancelling(false);
|
||
batchStartTimeRef.current = Date.now();
|
||
|
||
const abort = new AbortController();
|
||
cloneAllAbortRef.current = abort;
|
||
|
||
const CHUNK = 400;
|
||
let okCount = 0;
|
||
let failCount = 0;
|
||
|
||
try {
|
||
for (let i = 0; i < classnames.length; i += CHUNK) {
|
||
if (abort.signal.aborted) break;
|
||
const chunk = classnames.slice(i, i + CHUNK);
|
||
const isLast = i + CHUNK >= classnames.length;
|
||
|
||
const res = await adminFetch("/api/admin/import/clone/batch", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
sourceId: activeSource,
|
||
classnames: chunk,
|
||
concurrency: 10,
|
||
final: isLast,
|
||
}),
|
||
signal: abort.signal,
|
||
});
|
||
|
||
if (!res.ok || !res.body) {
|
||
toast.error(`Batch chunk mislukt (${res.status})`);
|
||
continue;
|
||
}
|
||
|
||
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);
|
||
}
|
||
if (evt.type === "error") {
|
||
toast.error(
|
||
typeof evt.message === "string" ? evt.message : "Import fout",
|
||
);
|
||
}
|
||
},
|
||
abort.signal,
|
||
);
|
||
}
|
||
|
||
if (!abort.signal.aborted) {
|
||
setBatchSucceeded(okCount);
|
||
setBatchFailed(failCount);
|
||
markBatchDone();
|
||
|
||
if (okCount > 0) {
|
||
toast.success(
|
||
`${okCount} items geïmporteerd${
|
||
failCount > 0 ? ` (${failCount} mislukt)` : ""
|
||
}`,
|
||
);
|
||
}
|
||
if (failCount > 0 && okCount === 0) {
|
||
toast.error(`${failCount} items mislukt`);
|
||
}
|
||
|
||
fetchItems(activeSearch, 1, activeSource);
|
||
fetchStats();
|
||
fetchCloneStats(activeSource);
|
||
|
||
// Auto-translate after clone all if translation is enabled
|
||
if (translate && okCount > 0) {
|
||
toast.info("Automatisch vertalen van alle talen...");
|
||
buildAllLanguages();
|
||
}
|
||
} else {
|
||
toast.info("Clone all geannuleerd");
|
||
}
|
||
} catch (err) {
|
||
if ((err as Error)?.name === "AbortError") {
|
||
toast.info("Clone all geannuleerd");
|
||
} else {
|
||
toast.error("Verbindingsfout tijdens clone all");
|
||
}
|
||
} finally {
|
||
cloneAllAbortRef.current = null;
|
||
setCloneAllCancelling(false);
|
||
setSelected(new Set());
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
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,
|
||
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 mislukt (${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("Batch import cancelled");
|
||
} else {
|
||
toast.error("Connection lost during batch import");
|
||
}
|
||
} finally {
|
||
batchAbortRef.current = null;
|
||
setBatchCancelling(false);
|
||
setSelected(new Set());
|
||
fetchStats();
|
||
if (!batchDoneRef.current) setBatchProgress(null);
|
||
}
|
||
}
|
||
|
||
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 (!activeSource || failed.length === 0) return;
|
||
// For clone sources, re-run cloneAllMissing with only the failed classnames
|
||
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 mislukt (${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 opnieuw geïmporteerd`);
|
||
}
|
||
fetchItems(activeSearch, 1, activeSource);
|
||
fetchStats();
|
||
fetchCloneStats(activeSource);
|
||
} catch (err) {
|
||
if ((err as Error)?.name !== "AbortError") {
|
||
toast.error("Verbindingsfout tijdens retry");
|
||
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 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 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-[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 && !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} aanwezig
|
||
</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>
|
||
|
||
<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} te importeren)`}
|
||
</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 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>
|
||
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
className="h-8 gap-1.5"
|
||
disabled={buildingLanguages}
|
||
onClick={buildAllLanguages}
|
||
title="Vertaal de hele catalogus in alle talen (achteraf)"
|
||
>
|
||
{buildingLanguages ? (
|
||
<Loader2 size={13} className="animate-spin" />
|
||
) : (
|
||
<Globe size={13} />
|
||
)}
|
||
Alles vertalen
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="flex-1" />
|
||
|
||
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
|
||
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
|
||
<CloudDownload size={14} />
|
||
Alles importeren ({cloneStats.clonable})
|
||
</Button>
|
||
)}
|
||
|
||
{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>
|
||
)}
|
||
|
||
{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} />
|
||
)}
|
||
Nitro regeneren ({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} />
|
||
)}
|
||
Verwijder ({selectedImported.length})
|
||
</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 ? (
|
||
<>
|
||
{!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 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 ? (
|
||
<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 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 || cloneAllCancelling}
|
||
verification={verification}
|
||
startTime={batchStartTimeRef.current || undefined}
|
||
failedClassnames={
|
||
failedClassnames.length > 0 ? failedClassnames : undefined
|
||
}
|
||
onCancel={cloneAllAbortRef.current ? cancelCloneAll : cancelBatch}
|
||
onDismiss={() => {
|
||
setBatchProgress(null);
|
||
setBatchDone(false);
|
||
setVerification(null);
|
||
setFailedClassnames([]);
|
||
}}
|
||
onRetry={activeSource ? retryFailed : undefined}
|
||
/>
|
||
) : 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)}
|
||
/>
|
||
|
||
{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>
|
||
);
|
||
}
|