Gitea Actions Runner Test / test-job (push) Successful in 0s
CI / check (push) Successful in 29s
CI / tests-unit (push) Successful in 1m34s
CI / tests-integration (push) Failing after 1m34s
CI / tests-ui (push) Successful in 2m19s
CI / preflight (push) Skipped
CI / deploy (push) Skipped
The previous commit made imports update the Studio without a reload, but the guarantee only held inside the tab that started the import and only as long as every read succeeded. Four holes were left, and this closes them. A session that mounted the tree before an import kept the pre-import tree for the rest of its life, because ensureCatalogTreeLoaded() was a once-per-session no-op. It now asks the server whether what it holds is still current. The answer is a revision: sendCatalogUpdate() already runs after every catalog write, so it bumps one, and clients read it on mount, on focus, on a 20s poll and from other tabs over a BroadcastChannel. An import that finishes in another tab, another browser or the job worker now lands here too. A failed read used to be swallowed, which is the worst outcome available: the rail kept showing pre-import counts as if they were current and nothing said so. The snapshot now carries the error, the rail shows it with a retry, and the previous tree stays on screen because stale beats empty. Every settled import pulled the entire flat tree, which is the one payload that grows with the size of the catalog. The revision doubles as the ETag on mode=full, so an unchanged catalog answers 304 and the poll costs a file read. An import could also report success for an offer the hotel will never sell: a hidden or disabled page, an item_ids that misses the furni id, a zero amount. importSingleFurni reads its own row back and reports each of those as a warning, where the import report already is, instead of leaving it to surface as "the import did not work" in the client. Finally, the catalog items table no longer falls back to router.refresh() — onRefresh is now required, so every mutation ends in a refresh of the caller's own data instead of a route re-render that threw away editor state and scroll position. useServerAction keeps its default, because 47 callers across the app depend on it. The 750-line CatalogTree in catalog-tree.tsx was dead code that kept its own stale tree and three more router.refresh() calls; only CatalogIcon and LAYOUT_COLORS are still imported, so the rest is gone. Tests: the store now covers revisions, 304s, probe failures and error recovery; a jsdom test mounts a consumer and asserts the tree updates in place with no navigation; the old organize-imports e2e asserted nothing about the endpoints the code actually calls, and is replaced by one that asserts a cross-tab write lands in the mounted categories without a reload. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2660 lines
82 KiB
TypeScript
2660 lines
82 KiB
TypeScript
"use client";
|
||
|
||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||
import {
|
||
ArrowDown,
|
||
ArrowUp,
|
||
ArrowUpDown,
|
||
Check,
|
||
CircleAlert,
|
||
CloudDownload,
|
||
FileJson2,
|
||
Globe,
|
||
Languages,
|
||
Layers,
|
||
LayoutGrid,
|
||
List,
|
||
ListChecks,
|
||
Loader2,
|
||
Package,
|
||
PanelLeftClose,
|
||
PanelLeftOpen,
|
||
Pencil,
|
||
RefreshCw,
|
||
Search,
|
||
Sparkles,
|
||
Trash2,
|
||
X,
|
||
} from "lucide-react";
|
||
import { motion } from "motion/react";
|
||
import {
|
||
lazy,
|
||
Suspense,
|
||
useCallback,
|
||
useEffect,
|
||
useLayoutEffect,
|
||
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 {
|
||
refreshCatalogTree,
|
||
useCatalogSync,
|
||
useLiveCatalog,
|
||
} from "@/features/catalog/client/use-live-catalog";
|
||
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 { 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,
|
||
})),
|
||
);
|
||
|
||
// Grid virtualization geometry (px) — matches the grid-card markup below.
|
||
const GRID_CARD_W = 150;
|
||
const GRID_GAP = 12;
|
||
const GRID_ROW_H = 176;
|
||
|
||
// Persisted UI preferences (survive reloads).
|
||
const LS_VIEW_MODE = "studio:viewMode";
|
||
const LS_SORT_BY = "studio:sortBy";
|
||
const SORT_KEYS = [
|
||
"default",
|
||
"name",
|
||
"classname",
|
||
"category",
|
||
"imported-first",
|
||
"not-imported-first",
|
||
] as const;
|
||
function readLs<T extends string>(
|
||
key: string,
|
||
fallback: T,
|
||
valid: ReadonlyArray<T>,
|
||
): T {
|
||
if (typeof window === "undefined") return fallback;
|
||
try {
|
||
const raw = window.localStorage.getItem(key);
|
||
if (!raw) return fallback;
|
||
return valid.includes(raw as T) ? (raw as T) : fallback;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
function writeLs(key: string, value: string) {
|
||
try {
|
||
window.localStorage.setItem(key, value);
|
||
} catch {
|
||
/* private mode / quota — ignore */
|
||
}
|
||
}
|
||
|
||
// Wraps the first case-insensitive match of `query` inside `text` in an accent
|
||
// mark so users can see where their search hit.
|
||
function Highlighted({ text, query }: { text: string; query: string }) {
|
||
if (!query) return text;
|
||
const index = text.toLowerCase().indexOf(query.toLowerCase());
|
||
if (index === -1) return text;
|
||
return (
|
||
<>
|
||
{text.slice(0, index)}
|
||
<mark className="rounded-[2px] bg-[var(--admin-accent)]/25 text-inherit">
|
||
{text.slice(index, index + query.length)}
|
||
</mark>
|
||
{text.slice(index + query.length)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// Table layout: a shared grid template keeps the sticky header and each
|
||
// virtualized row in perfect column alignment.
|
||
const TABLE_COLS =
|
||
"36px 48px minmax(0,1.4fr) minmax(0,1fr) minmax(0,0.6fr) minmax(0,0.7fr) minmax(0,1fr) 112px";
|
||
const TABLE_ROW_H = 40;
|
||
|
||
export function StudioClient({
|
||
actorId,
|
||
source,
|
||
defaultTranslate,
|
||
}: {
|
||
actorId?: number;
|
||
source: FurniImportSource;
|
||
defaultTranslate: boolean;
|
||
}) {
|
||
const [review, setReview] = useState<{
|
||
items: FurniItem[];
|
||
single: boolean;
|
||
cloneAll?: boolean;
|
||
} | null>(null);
|
||
const jobs = useFurnitureJobs(
|
||
() => {
|
||
void fetchItems(activeSearch, 1, activeSource);
|
||
void fetchStats();
|
||
// A durable job derives its "Imported Furniture" pages server-side, so
|
||
// the rail cannot predict them either — pull the tree and totals.
|
||
void refreshCatalogTree();
|
||
},
|
||
false,
|
||
actorId,
|
||
);
|
||
const [preparingReview, setPreparingReview] = useState(false);
|
||
// Catalog tree — read from the live store so pages created by an import show
|
||
// up without re-rendering the route.
|
||
const tree = useLiveCatalog().tree;
|
||
const [selectedPage, setSelectedPage] = useState<number | null>(null);
|
||
const [railOpen, setRailOpen] = useState(false);
|
||
useCatalogSync();
|
||
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>(() =>
|
||
readLs(LS_SORT_BY, "default", SORT_KEYS),
|
||
);
|
||
const [viewMode, setViewMode] = useState<"grid" | "list">(() =>
|
||
readLs(LS_VIEW_MODE, "list", ["grid", "list"] as const),
|
||
);
|
||
const changeViewMode = (mode: "grid" | "list") => {
|
||
setViewMode(mode);
|
||
writeLs(LS_VIEW_MODE, mode);
|
||
};
|
||
const changeSortBy = (value: string) => {
|
||
setSortBy(value);
|
||
writeLs(LS_SORT_BY, value);
|
||
};
|
||
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);
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
// Mirrors `items` synchronously so "select all results" can read the
|
||
// fully-loaded list right after the last page resolves.
|
||
const itemsRef = useRef<FurniItem[]>([]);
|
||
// Bumped when the list query changes (search/source) to cancel a running
|
||
// bulk "select all" loop.
|
||
const listGenRef = useRef(0);
|
||
const detailOrigin = useRef<{
|
||
top: number;
|
||
trigger: HTMLElement | null;
|
||
} | null>(null);
|
||
const openDetail = (item: FurniItem) => {
|
||
detailOrigin.current = {
|
||
top: listRef.current?.scrollTop ?? 0,
|
||
trigger:
|
||
document.activeElement instanceof HTMLElement
|
||
? document.activeElement
|
||
: null,
|
||
};
|
||
setDetail(item);
|
||
};
|
||
useLayoutEffect(() => {
|
||
if (detail || !detailOrigin.current) return;
|
||
const origin = detailOrigin.current;
|
||
detailOrigin.current = null;
|
||
if (listRef.current) listRef.current.scrollTop = origin.top;
|
||
if (origin.trigger?.isConnected)
|
||
origin.trigger.focus({ preventScroll: true });
|
||
}, [detail]);
|
||
const sourceGeneration = useRef(0);
|
||
const listRequest = useRef<AbortController | null>(null);
|
||
useEffect(() => () => listRequest.current?.abort(), []);
|
||
|
||
// 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);
|
||
// How many items the batch endpoints process in parallel.
|
||
const [concurrency, setConcurrency] = useState(4);
|
||
|
||
// 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 hasMorePages = meta ? meta.currentPage < meta.lastPage : false;
|
||
|
||
// ── Grid virtualization ─────────────────────────────────────────
|
||
const [gridCols, setGridCols] = useState(1);
|
||
useEffect(() => {
|
||
const el = listRef.current;
|
||
if (viewMode !== "grid" || !el) return;
|
||
const update = () => {
|
||
const width = Math.max(el.clientWidth - 24, 100);
|
||
const cols = Math.max(
|
||
1,
|
||
Math.floor((width + GRID_GAP) / (GRID_CARD_W + GRID_GAP)),
|
||
);
|
||
setGridCols((previous) => (previous === cols ? previous : cols));
|
||
};
|
||
update();
|
||
const observer = new ResizeObserver(update);
|
||
observer.observe(el);
|
||
return () => observer.disconnect();
|
||
}, [viewMode]);
|
||
|
||
const gridRowCount =
|
||
viewMode === "grid" ? Math.ceil(filteredItems.length / gridCols) : 0;
|
||
const gridVirtualizer = useVirtualizer({
|
||
count: gridRowCount,
|
||
getScrollElement: () => listRef.current,
|
||
estimateSize: () => GRID_ROW_H,
|
||
overscan: 5,
|
||
});
|
||
const tableVirtualizer = useVirtualizer({
|
||
count: viewMode === "list" ? filteredItems.length : 0,
|
||
getScrollElement: () => listRef.current,
|
||
estimateSize: () => TABLE_ROW_H,
|
||
overscan: 8,
|
||
});
|
||
|
||
// Infinite scroll: auto-load the next page when the sentinel enters view.
|
||
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||
// Bulk "select all results" run state; guards the sentinel while pages load.
|
||
const [loadingAll, setLoadingAll] = useState(false);
|
||
const loadingAllRef = useRef(false);
|
||
|
||
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,
|
||
): Promise<FurniItem[] | null> => {
|
||
listRequest.current?.abort();
|
||
const controller = new AbortController();
|
||
listRequest.current = controller;
|
||
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()}`,
|
||
{ signal: controller.signal },
|
||
);
|
||
if (controller.signal.aborted) return null;
|
||
if (!res.ok) {
|
||
setError(`Request failed (${res.status})`);
|
||
setLoading(false);
|
||
return null;
|
||
}
|
||
const data = await res.json();
|
||
if (controller.signal.aborted) return null;
|
||
const fetched: FurniItem[] = data.items ?? [];
|
||
if (page === 1) {
|
||
itemsRef.current = fetched;
|
||
setItems(fetched);
|
||
const available = new Set(fetched.map((item) => item.classname));
|
||
setSelected(
|
||
(previous) =>
|
||
new Set(
|
||
[...previous].filter((classname) => available.has(classname)),
|
||
),
|
||
);
|
||
setDetail((previous) =>
|
||
previous
|
||
? (fetched.find(
|
||
(item) => item.classname === previous.classname,
|
||
) ?? null)
|
||
: null,
|
||
);
|
||
} else {
|
||
// Pages can arrive out of order (infinite scroll + select-all races);
|
||
// skip classnames that are already present.
|
||
const prev = itemsRef.current;
|
||
const seen = new Set(prev.map((item) => item.classname));
|
||
const fresh = fetched.filter((item) => !seen.has(item.classname));
|
||
if (fresh.length > 0) {
|
||
const next = [...prev, ...fresh];
|
||
itemsRef.current = next;
|
||
setItems(next);
|
||
}
|
||
}
|
||
setMeta(data.meta ?? null);
|
||
return fetched;
|
||
} catch {
|
||
if (!controller.signal.aborted) setError("Network error");
|
||
return null;
|
||
} finally {
|
||
if (!controller.signal.aborted) setLoading(false);
|
||
}
|
||
},
|
||
[],
|
||
);
|
||
|
||
// Infinite scroll: auto-load the next page when the sentinel enters view.
|
||
useEffect(() => {
|
||
const el = sentinelRef.current;
|
||
if (!el || !hasMorePages) return;
|
||
let cancelled = false;
|
||
const observer = new IntersectionObserver(
|
||
(entries) => {
|
||
if (cancelled) return;
|
||
if (
|
||
entries.some((e) => e.isIntersecting) &&
|
||
!loading &&
|
||
!loadingAll &&
|
||
!batchProgress
|
||
) {
|
||
fetchItems(activeSearch, (meta?.currentPage ?? 1) + 1, activeSource);
|
||
}
|
||
},
|
||
{ root: listRef.current, rootMargin: "700px 0px" },
|
||
);
|
||
observer.observe(el);
|
||
return () => {
|
||
cancelled = true;
|
||
observer.disconnect();
|
||
};
|
||
}, [
|
||
hasMorePages,
|
||
meta?.currentPage,
|
||
loading,
|
||
loadingAll,
|
||
batchProgress,
|
||
activeSearch,
|
||
activeSource,
|
||
fetchItems,
|
||
]);
|
||
|
||
// Floating "back to top" affordance once the list is long.
|
||
const [showBackToTop, setShowBackToTop] = useState(false);
|
||
useEffect(() => {
|
||
const el = listRef.current;
|
||
if (!el) return;
|
||
let raf = 0;
|
||
const onScroll = () => {
|
||
cancelAnimationFrame(raf);
|
||
raf = requestAnimationFrame(() => {
|
||
setShowBackToTop(el.scrollTop > 600);
|
||
});
|
||
};
|
||
el.addEventListener("scroll", onScroll, { passive: true });
|
||
onScroll();
|
||
return () => {
|
||
cancelAnimationFrame(raf);
|
||
el.removeEventListener("scroll", onScroll);
|
||
};
|
||
}, []);
|
||
|
||
const changeSource = (sourceId: string) => {
|
||
sourceGeneration.current += 1;
|
||
listGenRef.current += 1;
|
||
setConfirmDelete(false);
|
||
setDeleting(null);
|
||
setEditingClassname(null);
|
||
listRequest.current?.abort();
|
||
detailOrigin.current = null;
|
||
setDetail(null);
|
||
setReview(null);
|
||
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;
|
||
const generation = sourceGeneration.current;
|
||
setPreparingReview(true);
|
||
try {
|
||
const response = await adminFetch(
|
||
"/api/admin/import/clone?source=" +
|
||
encodeURIComponent(activeSource) +
|
||
"&action=clonable",
|
||
);
|
||
const result = await response.json();
|
||
if (generation !== sourceGeneration.current) return;
|
||
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 (generation !== sourceGeneration.current) return;
|
||
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 (generation !== sourceGeneration.current) return;
|
||
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) {
|
||
listGenRef.current += 1;
|
||
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 = () => {
|
||
listGenRef.current += 1;
|
||
setSearchTerm("");
|
||
setActiveSearch("");
|
||
fetchItems("", 1, activeSource);
|
||
};
|
||
|
||
const submitSearch = () => {
|
||
listGenRef.current += 1;
|
||
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]);
|
||
|
||
// Bulk "select every matching result": loads all remaining pages in the
|
||
// background, then selects whatever matches the active filters. Cancels
|
||
// itself when the query (search/source) changes or the request is aborted.
|
||
const selectAllResults = useCallback(async () => {
|
||
if (loadingAllRef.current) return;
|
||
const gen = listGenRef.current;
|
||
const search = activeSearch;
|
||
const source = activeSource;
|
||
const first = meta?.currentPage ?? 1;
|
||
const last = meta?.lastPage ?? first;
|
||
if (first >= last) {
|
||
// Everything is already loaded — just select the visible matches.
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
for (const item of filteredItems) next.add(item.classname);
|
||
return next;
|
||
});
|
||
return;
|
||
}
|
||
loadingAllRef.current = true;
|
||
setLoadingAll(true);
|
||
try {
|
||
for (let page = first + 1; page <= last; page += 1) {
|
||
if (listGenRef.current !== gen) break;
|
||
const got = await fetchItems(search, page, source);
|
||
if (!got) break;
|
||
}
|
||
if (listGenRef.current === gen) {
|
||
const matching = filterFurniture(itemsRef.current, {
|
||
typeFilter,
|
||
categoryFilter,
|
||
statusFilter,
|
||
sortBy,
|
||
});
|
||
setSelected((prev) => {
|
||
const next = new Set(prev);
|
||
for (const item of matching) next.add(item.classname);
|
||
return next;
|
||
});
|
||
}
|
||
} finally {
|
||
loadingAllRef.current = false;
|
||
setLoadingAll(false);
|
||
}
|
||
}, [
|
||
activeSearch,
|
||
activeSource,
|
||
meta?.currentPage,
|
||
meta?.lastPage,
|
||
filteredItems,
|
||
typeFilter,
|
||
categoryFilter,
|
||
statusFilter,
|
||
sortBy,
|
||
fetchItems,
|
||
]);
|
||
|
||
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
|
||
useEffect(() => {
|
||
function onKeyDown(e: KeyboardEvent) {
|
||
if (review || editingClassname || confirmDelete || e.defaultPrevented)
|
||
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) {
|
||
if (detail) {
|
||
e.preventDefault();
|
||
setDetail(null);
|
||
return;
|
||
}
|
||
setSelected(new Set());
|
||
searchRef.current?.blur();
|
||
}
|
||
}
|
||
document.addEventListener("keydown", onKeyDown);
|
||
return () => document.removeEventListener("keydown", onKeyDown);
|
||
}, [toggleAll, review, editingClassname, confirmDelete, detail]);
|
||
|
||
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,
|
||
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. Progress was saved — open Import History to resume failed items.",
|
||
);
|
||
} else if (err instanceof TypeError && err.message?.includes("fetch")) {
|
||
toast.error(
|
||
"Network error during upload. Progress was saved — open Import History to resume failed items.",
|
||
);
|
||
} else {
|
||
toast.error(
|
||
"Unexpected import error: " +
|
||
(err instanceof Error ? err.message : String(err)),
|
||
);
|
||
}
|
||
} finally {
|
||
batchAbortRef.current = null;
|
||
setBatchCancelling(false);
|
||
setSelected(new Set());
|
||
fetchStats();
|
||
// The importers derive their "Imported Furniture" category pages from
|
||
// furnidata, so the client cannot predict which pages appeared. Pull the
|
||
// tree over the API instead of re-rendering the route.
|
||
void refreshCatalogTree();
|
||
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,
|
||
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);
|
||
const [bulkFurnidata, setBulkFurnidata] = 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);
|
||
}
|
||
}
|
||
|
||
async function bulkAddFurnidata() {
|
||
if (selectedMissingFurnidata.length === 0) return;
|
||
setBulkFurnidata(true);
|
||
const wanted = selectedMissingFurnidata.map((i) => i.classname);
|
||
try {
|
||
const res = await adminFetch("/api/admin/import/furni/resync", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ classnames: wanted }),
|
||
});
|
||
const data = await res.json();
|
||
if (res.ok) {
|
||
const failedClassnames = new Set<string>(
|
||
((data.errors ?? []) as Array<{ classname: string }>).map(
|
||
(e) => e.classname,
|
||
),
|
||
);
|
||
const fixedClassnames = new Set(
|
||
wanted.filter((cn) => !failedClassnames.has(cn)),
|
||
);
|
||
setItems((prev) =>
|
||
prev.map((i) =>
|
||
fixedClassnames.has(i.classname) ? { ...i, hasFurnidata: true } : i,
|
||
),
|
||
);
|
||
const fixed = Number(data.resynced ?? fixedClassnames.size);
|
||
const failed = Number(data.failed ?? 0);
|
||
toast.success(
|
||
`Furnidata added: ${fixed}${failed > 0 ? `, failed: ${failed}` : ""}`,
|
||
);
|
||
} else {
|
||
toast.error(data.error || "Adding furnidata failed");
|
||
}
|
||
} catch {
|
||
toast.error("Network error");
|
||
} finally {
|
||
setBulkFurnidata(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 missingFurnidataCount = useMemo(
|
||
() =>
|
||
filteredItems.filter((i) => i.alreadyImported && i.hasFurnidata === false)
|
||
.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 selectedMissingFurnidata = useMemo(
|
||
() => selectedImported.filter((i) => i.hasFurnidata === false),
|
||
[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={changeSortBy}>
|
||
<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>
|
||
)}
|
||
{missingFurnidataCount > 0 && (
|
||
<button
|
||
type="button"
|
||
onClick={() => setStatusFilter("missingFurnidata")}
|
||
className={cn(
|
||
"flex h-8 items-center gap-1.5 rounded-md border px-2.5 text-xs font-medium transition-colors",
|
||
statusFilter === "missingFurnidata"
|
||
? "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",
|
||
)}
|
||
>
|
||
<FileJson2 size={12} />
|
||
{missingFurnidataCount} no furnidata
|
||
</button>
|
||
)}
|
||
|
||
<div className="flex h-8 items-center overflow-hidden rounded-md border border-[var(--admin-border)]">
|
||
<button
|
||
type="button"
|
||
onClick={() => changeViewMode("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={() => changeViewMode("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">
|
||
{" "}
|
||
{/* ── Import speed (parallel items per batch) ─────────── */}
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||
Speed
|
||
</span>
|
||
<Select
|
||
value={String(concurrency)}
|
||
onValueChange={(value) =>
|
||
setConcurrency(Math.max(1, Number(value)))
|
||
}
|
||
>
|
||
<SelectTrigger
|
||
aria-label="Import speed"
|
||
className="h-8 w-24 text-xs"
|
||
>
|
||
<SelectValue>{`${concurrency} parallel`}</SelectValue>
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="1">1 (gentle)</SelectItem>
|
||
<SelectItem value="2">2</SelectItem>
|
||
<SelectItem value="4">4 (recommended)</SelectItem>
|
||
<SelectItem value="6">6</SelectItem>
|
||
<SelectItem value="8">8</SelectItem>
|
||
<SelectItem value="12">12 (turbo)</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
{/* ── 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>
|
||
|
||
{hasMorePages && meta && (
|
||
<button
|
||
type="button"
|
||
onClick={selectAllResults}
|
||
disabled={loadingAll}
|
||
className="flex h-8 items-center gap-1.5 rounded-md border border-[var(--admin-accent)]/40 bg-[var(--admin-accent)]/5 px-2.5 text-xs font-medium text-[var(--admin-text)] transition-colors hover:bg-[var(--admin-accent)]/10 disabled:cursor-not-allowed disabled:opacity-60"
|
||
>
|
||
{loadingAll ? (
|
||
<Loader2 size={13} className="animate-spin" />
|
||
) : (
|
||
<ListChecks size={13} />
|
||
)}
|
||
{loadingAll
|
||
? "Loading all…"
|
||
: `Select all ${meta.total.toLocaleString()} results`}
|
||
</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>
|
||
)}
|
||
|
||
{selectedMissingFurnidata.length > 0 && !batchProgress && (
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
className="gap-1.5"
|
||
disabled={bulkFurnidata}
|
||
onClick={bulkAddFurnidata}
|
||
>
|
||
{bulkFurnidata ? (
|
||
<Loader2 size={14} className="animate-spin" />
|
||
) : (
|
||
<FileJson2 size={14} />
|
||
)}
|
||
Add missing furnidata ({selectedMissingFurnidata.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="relative flex min-h-0 min-w-0 flex-1 flex-col">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
listRef.current?.scrollTo({ top: 0, behavior: "smooth" })
|
||
}
|
||
aria-label="Scroll to top"
|
||
title="Scroll to top"
|
||
className={cn(
|
||
"absolute bottom-4 right-4 z-10 grid h-9 w-9 place-items-center rounded-full border border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text-muted)] shadow-lg transition-all hover:text-[var(--admin-text)]",
|
||
showBackToTop
|
||
? "translate-y-0 opacity-100"
|
||
: "pointer-events-none translate-y-2 opacity-0",
|
||
)}
|
||
>
|
||
<ArrowUp size={15} />
|
||
</button>
|
||
<div
|
||
ref={listRef}
|
||
data-testid="studio-furniture-list"
|
||
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>
|
||
) : (
|
||
<motion.div
|
||
key={viewMode}
|
||
initial={{ opacity: 0 }}
|
||
animate={{ opacity: 1 }}
|
||
transition={{ duration: 0.15 }}
|
||
>
|
||
{viewMode === "grid" ? (
|
||
<div
|
||
className="relative w-full"
|
||
style={{ height: `${gridVirtualizer.getTotalSize()}px` }}
|
||
>
|
||
{gridVirtualizer.getVirtualItems().map((vi) => {
|
||
const start = vi.index * gridCols;
|
||
const rowItems = filteredItems.slice(
|
||
start,
|
||
start + gridCols,
|
||
);
|
||
return (
|
||
<div
|
||
key={vi.key}
|
||
data-index={vi.index}
|
||
ref={gridVirtualizer.measureElement}
|
||
className="absolute left-0 top-0 w-full will-change-transform"
|
||
style={{ transform: `translateY(${vi.start}px)` }}
|
||
>
|
||
<div
|
||
className="grid gap-3"
|
||
style={{
|
||
gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))`,
|
||
}}
|
||
>
|
||
{rowItems.map((item) => {
|
||
const isSel = selected.has(item.classname);
|
||
return (
|
||
<div
|
||
key={item.classname}
|
||
className="group relative"
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => openDetail(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 ring-2 ring-[var(--admin-accent)]/20"
|
||
: "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",
|
||
}}
|
||
>
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-[var(--admin-text)]/5 to-transparent" />
|
||
<img
|
||
src={getFurniImageUrl(item)}
|
||
alt={item.name}
|
||
className="h-14 w-14 object-contain drop-shadow-[0_2px_4px_rgba(0,0,0,0.25)] transition-transform duration-200 group-hover:scale-110"
|
||
style={{ imageRendering: "pixelated" }}
|
||
loading="lazy"
|
||
decoding="async"
|
||
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)]">
|
||
<Highlighted
|
||
text={item.name}
|
||
query={activeSearch}
|
||
/>
|
||
</p>
|
||
<p className="truncate font-mono text-[0.6rem] text-[var(--admin-text-muted)]">
|
||
<Highlighted
|
||
text={item.classname}
|
||
query={activeSearch}
|
||
/>
|
||
</p>
|
||
<p className="truncate text-[0.6rem] text-[var(--admin-text-muted)]/80">
|
||
{item.category || "—"}
|
||
</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-all",
|
||
isSel
|
||
? "scale-105 border-[var(--admin-accent)] bg-[var(--admin-accent)] shadow-md shadow-[var(--admin-accent)]/30"
|
||
: "border-[var(--admin-border)] bg-[var(--admin-surface)] opacity-70 hover:border-[var(--admin-accent)]/60 hover:opacity-100",
|
||
)}
|
||
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>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<table
|
||
aria-label="Furniture results"
|
||
className="w-full text-sm"
|
||
style={{ borderCollapse: "separate" }}
|
||
>
|
||
<thead>
|
||
<tr
|
||
className="sticky top-0 z-10 grid w-full min-w-[660px] border-b border-[var(--admin-border)] bg-[var(--admin-surface)]"
|
||
style={{ gridTemplateColumns: TABLE_COLS }}
|
||
>
|
||
<th scope="col" className="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 scope="col" className="px-1 py-2" />
|
||
<th
|
||
scope="col"
|
||
className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]"
|
||
>
|
||
Name
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]"
|
||
>
|
||
Classname
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]"
|
||
>
|
||
Type
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]"
|
||
>
|
||
Category
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-2 py-2 text-left font-semibold text-[var(--admin-text-muted)]"
|
||
>
|
||
Status
|
||
</th>
|
||
<th
|
||
scope="col"
|
||
className="px-2 py-2 text-right font-semibold text-[var(--admin-text-muted)]"
|
||
>
|
||
Actions
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody
|
||
className="relative w-full"
|
||
style={{
|
||
display: "block",
|
||
height: `${tableVirtualizer.getTotalSize()}px`,
|
||
}}
|
||
>
|
||
{tableVirtualizer.getVirtualItems().map((vi) => {
|
||
const item = filteredItems[vi.index];
|
||
const isSel = selected.has(item.classname);
|
||
return (
|
||
<tr
|
||
key={vi.key}
|
||
data-index={vi.index}
|
||
ref={tableVirtualizer.measureElement}
|
||
aria-selected={isSel}
|
||
className={cn(
|
||
"absolute left-0 top-0 grid w-full min-w-[660px] text-sm transition-colors hover:bg-[var(--admin-accent)]/5",
|
||
vi.index % 2 === 1 &&
|
||
"bg-[var(--admin-text)]/[0.03]",
|
||
isSel &&
|
||
"bg-[var(--admin-accent)]/10 shadow-[inset_2px_0_0_0_var(--admin-accent)]",
|
||
)}
|
||
style={{
|
||
display: "grid",
|
||
transform: `translateY(${vi.start}px)`,
|
||
gridTemplateColumns: TABLE_COLS,
|
||
}}
|
||
>
|
||
<td className="border-t border-[var(--admin-border)] 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="border-t border-[var(--admin-border)] px-1 py-1.5">
|
||
<button
|
||
type="button"
|
||
onClick={() => openDetail(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=""
|
||
decoding="async"
|
||
className="h-8 w-8 object-contain"
|
||
style={{ imageRendering: "pixelated" }}
|
||
loading="lazy"
|
||
onError={(e) => handleImgError(e, item)}
|
||
/>
|
||
</button>
|
||
</td>
|
||
<td className="truncate border-t border-[var(--admin-border)] px-2 py-1.5 font-medium text-[var(--admin-text)]">
|
||
<Highlighted
|
||
text={item.name}
|
||
query={activeSearch}
|
||
/>
|
||
</td>
|
||
<td className="truncate border-t border-[var(--admin-border)] px-2 py-1.5 font-mono text-[0.7rem] text-[var(--admin-text-muted)]">
|
||
<Highlighted
|
||
text={item.classname}
|
||
query={activeSearch}
|
||
/>
|
||
</td>
|
||
<td className="truncate border-t border-[var(--admin-border)] px-2 py-1.5 text-[var(--admin-text-muted)]">
|
||
{TYPE_LABELS[item.type] ?? item.type}
|
||
</td>
|
||
<td className="truncate border-t border-[var(--admin-border)] px-2 py-1.5 text-[var(--admin-text-muted)]">
|
||
{item.category || "—"}
|
||
</td>
|
||
<td className="border-t border-[var(--admin-border)] 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="border-t border-[var(--admin-border)] 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
|
||
ref={sentinelRef}
|
||
className="mt-4 flex flex-col items-center justify-center gap-2 py-2 text-xs text-[var(--admin-text-muted)]"
|
||
>
|
||
{hasMorePages ? (
|
||
loading ? (
|
||
<span className="flex items-center gap-2">
|
||
<Loader2
|
||
size={14}
|
||
className="animate-spin text-[var(--admin-accent)]"
|
||
/>
|
||
Loading more…
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center gap-2">
|
||
<ArrowDown size={12} />
|
||
Scroll for more
|
||
</span>
|
||
)
|
||
) : (
|
||
<span className="flex items-center gap-2">
|
||
<span className="h-px w-8 bg-[var(--admin-border)]" />
|
||
End of results
|
||
<span className="h-px w-8 bg-[var(--admin-border)]" />
|
||
</span>
|
||
)}
|
||
</div>
|
||
</motion.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>
|
||
);
|
||
}
|