Add Catalog Studio inspection and guided import review
CI / check (push) Successful in 1m39s
CI / deploy (push) Successful in 1m18s
CI / e2e (push) Successful in 25s

This commit is contained in:
Simo committed 2026-09-05 13:10:43 +02:00
1 parent 2578bf6a09
commit 26f071117b
7 files changed
+961 -44

No files matched your search

+134 -44
View File
@@ -67,6 +67,8 @@ import type { TreeNode } from "@/types/catalog";
import { BatchProgress } from "./batch-progress";
import { CatalogRail } from "./catalog-rail";
import { CheckboxDot } from "./checkbox-dot";
import { FurnitureInspector } from "./furniture-inspector";
import { ImportReview } from "./import-review";
import {
getFurniImageUrl,
handleImgError,
@@ -108,6 +110,12 @@ export function StudioClient({
initialTree: TreeNode[];
defaultTranslate: boolean;
}) {
const [review, setReview] = useState<{
items: FurniItem[];
single: boolean;
cloneAll?: boolean;
} | null>(null);
const [preparingReview, setPreparingReview] = useState(false);
// Catalog tree
const [tree] = useState<TreeNode[]>(initialTree);
const [selectedPage, setSelectedPage] = useState<number | null>(null);
@@ -389,44 +397,62 @@ export function StudioClient({
}
}, []);
async function cloneAllMissing() {
if (!activeSource) return;
// Fetch all clonable classnames
let classnames: string[] = [];
async function prepareCloneReview() {
if (!activeSource || preparingReview) return;
setPreparingReview(true);
try {
const res = await adminFetch(
`/api/admin/import/clone?source=${encodeURIComponent(activeSource)}&action=clonable`,
const response = 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");
const result = await response.json();
if (!response.ok || !Array.isArray(result.classnames))
throw new Error(result.error || "Could not load missing furniture");
const missing = new Set<string>(result.classnames);
if (!missing.size) {
toast.info("All items from this source are already imported");
return;
}
classnames = data.classnames || [];
} catch {
toast.error("Network error fetching clonable items");
return;
const collected = new Map<string, FurniItem>();
let page = 1,
lastPage = 1;
do {
const res = await adminFetch(
"/api/admin/import/furni?source=" +
encodeURIComponent(activeSource) +
"&page=" +
page,
);
const data = await res.json();
if (
!res.ok ||
!Array.isArray(data.items) ||
!Number.isFinite(data.meta?.lastPage)
)
throw new Error(data.error || "Could not prepare the full selection");
for (const item of data.items as FurniItem[])
if (missing.has(item.classname)) collected.set(item.classname, item);
lastPage = data.meta.lastPage;
page++;
} while (page <= lastPage && collected.size < missing.size);
if (collected.size !== missing.size)
throw new Error(
"The source changed while preparing the selection. Refresh and retry.",
);
setReview({
items: [...collected.values()],
single: false,
cloneAll: true,
});
} catch (error) {
toast.error((error as Error).message);
} finally {
setPreparingReview(false);
}
if (classnames.length === 0) {
toast.info("All items from this source are already imported");
return;
}
const sourceName =
sources.find((s) => s.id === activeSource)?.name || activeSource;
const confirmed = await confirm({
title: "Import all missing",
description: `${classnames.length} items to import from "${sourceName}"? This may take a while.`,
confirmLabel: "Import all missing",
});
if (!confirmed) return;
}
async function cloneAllMissing(classnames: string[]) {
if (!activeSource || !classnames.length) return;
// Initialize progress tracking as a Map for BatchProgress compatibility
const initial = new Map<string, BatchItemStatus>();
for (const cn of classnames) {
@@ -639,6 +665,7 @@ export function StudioClient({
// Keyboard shortcuts: "/" focus search, Ctrl/Cmd+A select all, Esc clear.
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (review) return;
const tag = (e.target as HTMLElement)?.tagName;
const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT";
if (e.key === "/" && !e.metaKey && !e.ctrlKey && !e.altKey && !isInput) {
@@ -656,7 +683,7 @@ export function StudioClient({
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [toggleAll]);
}, [toggleAll, review]);
async function importSingle(item: FurniItem) {
setImportingId(item.classname);
@@ -711,12 +738,18 @@ export function StudioClient({
}
}
async function importBatch(requested?: string[]) {
const toImport = (requested ? items : filteredItems).filter(
async function importBatch(
requested?: string[],
reviewedItems?: FurniItem[],
) {
const toImport = (
reviewedItems ?? (requested ? items : filteredItems)
).filter(
(i) =>
(requested
? requested.includes(i.classname)
: selected.has(i.classname)) && !i.alreadyImported,
: selected.has(i.classname)) &&
(!!reviewedItems || !i.alreadyImported),
);
if (toImport.length === 0) {
toast.info("Select furni that isn't imported yet");
@@ -1292,7 +1325,11 @@ export function StudioClient({
Catalog
</button>
<Select value={activeSource} onValueChange={changeSource}>
<Select
value={activeSource}
onValueChange={changeSource}
disabled={preparingReview}
>
<SelectTrigger
aria-label="Furniture source"
className="h-8 w-44 text-xs"
@@ -1638,9 +1675,16 @@ export function StudioClient({
<div className="flex-1" />
{cloneStats && cloneStats.clonable > 0 && !batchProgress && (
<Button size="sm" className="gap-1.5" onClick={cloneAllMissing}>
<Button
size="sm"
className="gap-1.5"
onClick={prepareCloneReview}
disabled={preparingReview}
>
<CloudDownload size={14} />
Import all missing ({cloneStats.clonable})
{preparingReview
? "Preparing review…"
: `Import all missing (${cloneStats.clonable})`}
</Button>
)}
@@ -1649,7 +1693,14 @@ export function StudioClient({
size="sm"
className="gap-1.5"
disabled={!!batchProgress}
onClick={() => importBatch()}
onClick={() =>
setReview({
items: filteredItems.filter((item) =>
selected.has(item.classname),
),
single: false,
})
}
>
{batchProgress ? (
<Loader2 size={14} className="animate-spin" />
@@ -2035,7 +2086,12 @@ export function StudioClient({
{!item.alreadyImported && (
<button
type="button"
onClick={() => importSingle(item)}
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"
@@ -2149,13 +2205,14 @@ export function StudioClient({
{/* ── 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">
<aside className="absolute inset-y-0 right-0 z-20 flex h-full w-[min(28rem,100%)] 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"
aria-label="Close furniture details"
onClick={() => setDetail(null)}
className="rounded-md p-1 text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-text)]"
>
@@ -2210,6 +2267,10 @@ export function StudioClient({
)}
</div>
<FurnitureInspector
key={`${detail.classname}-${detail.alreadyImported}`}
item={detail}
/>
{/* Auto-catalog preview */}
{detailPreview && (
<div className="mb-4 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] p-3">
@@ -2251,7 +2312,7 @@ export function StudioClient({
<Button
className="w-full gap-1.5"
disabled={importingId !== null}
onClick={() => importSingle(detail)}
onClick={() => setReview({ items: [detail], single: true })}
>
{importingId === detail.classname ? (
<Loader2 size={14} className="animate-spin" />
@@ -2313,6 +2374,35 @@ export function StudioClient({
) : null}
</div>
{review && (
<ImportReview
items={review.items}
source={
sources.find((entry) => entry.id === activeSource)?.name ??
source.label
}
translation={
translate
? translateLangs.length
? translateLangs.join(", ")
: "Default languages"
: "Off"
}
onCancel={() => setReview(null)}
onConfirm={(accepted) => {
const single = review.single;
setReview(null);
if (review.cloneAll)
void cloneAllMissing(accepted.map((item) => item.classname));
else if (single && accepted[0]) void importSingle(accepted[0]);
else
void importBatch(
accepted.map((item) => item.classname),
accepted,
);
}}
/>
)}
<NitroEditorDialog
classname={editingClassname ?? ""}
name={detail?.name ?? ""}