Add Catalog Studio inspection and guided import review
This commit is contained in:
1 parent
2578bf6a09
commit
26f071117b
7 files changed
+961
-44
No files matched your search
@@ -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 ?? ""}
|
||||
|
||||
Reference in new issue
Block a user