Fix Visual Manager opacity and load categories like CatalogTree.
Local Build and Deploy / deploy (push) Successful in 55s

Use an opaque admin-canvas portal and lazy parentId fetches instead of mode=full, which fails on large catalogs. Search no longer loads the entire tree.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 22:09:01 +02:00
1 parent 1d8efefce4
commit 33a8a19820
4 files changed
+191 -46

No files matched your search

+72 -6
View File
@@ -184,13 +184,79 @@ async function getChildren(
}
async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
const flat = isBc ? await getBcTreeFlat() : await getTreeFlat();
const needle = q.toLowerCase();
return flat.filter(
(p) =>
p.caption.toLowerCase().includes(needle) ||
String(p.id) === needle,
const needle = q.trim();
const idExact = Number.parseInt(needle, 10);
const idFilter =
Number.isFinite(idExact) && String(idExact) === needle
? [{ id: idExact }]
: [];
if (isBc) {
const pages = await prisma.catalogPagesBc.findMany({
where: {
OR: [{ caption: { contains: needle } }, ...idFilter],
},
orderBy: { orderNum: "asc" },
take: 50,
});
return pages.map((p) =>
toTreeNode(
{
id: toInt(p.id),
caption: p.caption,
parentId: toInt(p.parentId, -1),
orderNum: toInt(p.orderNum),
enabled: String(p.enabled ?? "0"),
visible: String(p.visible ?? "0"),
iconImage: toInt(p.iconImage),
iconColor: toInt(p.iconColor),
pageLayout: p.pageLayout,
},
0,
0,
),
);
}
const pages = await prisma.catalogPages.findMany({
where: {
OR: [{ caption: { contains: needle } }, ...idFilter],
},
orderBy: { orderNum: "asc" },
take: 50,
});
const ids = pages.map((p) => toInt(p.id));
const [childCounts, itemMap] = await Promise.all([
ids.length
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: ids } },
_count: true,
})
: Promise.resolve([]),
safeItemCounts(ids),
]);
const childMap = Object.fromEntries(
childCounts.map((c) => [toInt(c.parentId), c._count]),
);
return pages.map((p) => {
const id = toInt(p.id);
return toTreeNode(
{
id,
caption: p.caption,
parentId: toInt(p.parentId, -1),
orderNum: toInt(p.orderNum),
enabled: String(p.enabled ?? "0"),
visible: String(p.visible ?? "0"),
iconImage: toInt(p.iconImage),
iconColor: toInt(p.iconColor),
pageLayout: p.pageLayout,
},
childMap[id] ?? 0,
itemMap.get(id) ?? 0,
);
});
}
export const GET = withAdmin(
@@ -59,13 +59,14 @@ export function CatalogManagerDialog({
</Button>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/50" />
<div className="fixed inset-0 z-[200]">
<div className="absolute inset-0 bg-black/70" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-1/2 left-1/2 z-50 flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-lg"
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<CatalogManagerProvider>
<CatalogManagerInner
@@ -212,7 +213,10 @@ function CatalogManagerInner({
return (
<TooltipProvider delayDuration={300}>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5">
<div
className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--admin-border)] px-4 py-2.5"
style={{ backgroundColor: "var(--admin-surface)" }}
>
<h2
id="visual-catalog-manager-title"
className="flex items-center gap-2 text-base font-semibold leading-none"
@@ -243,7 +247,10 @@ function CatalogManagerInner({
loading={state.loadingRoot}
/>
<div className="flex min-h-0 flex-1 overflow-hidden">
<div
className="flex min-h-0 flex-1 overflow-hidden"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
@@ -251,10 +258,15 @@ function CatalogManagerInner({
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
</div>
</div>
<StatsBar />
@@ -128,7 +128,10 @@ export function RootTabs({
: null;
return (
<div className="flex items-center gap-1 border-b bg-muted/30 px-2 py-1.5 overflow-x-auto shrink-0">
<div
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-[var(--admin-border)] px-2 py-1.5"
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />
@@ -115,33 +115,58 @@ export function SortableTree({
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
);
// ── Full tree load (single API call) ────────────────────────
const loadFullTree = useCallback(async () => {
const normalizePages = useCallback((pages: TreeNodeData[]): TreeNodeData[] => {
return pages.map((p) => ({
...p,
id: Number(p.id),
parentId: Number(p.parentId),
enabled: String(p.enabled ?? "0"),
visible: String(p.visible ?? "0"),
iconImage: Number(p.iconImage) || 0,
iconColor: Number(p.iconColor) || 0,
childCount: Number(p.childCount) || 0,
itemCount: Number(p.itemCount) || 0,
orderNum: Number(p.orderNum) || 0,
}));
}, []);
/**
* Load children of a page — same endpoint CatalogTree uses successfully.
* Avoids mode=full which times out / fails on large catalogs (2k+ pages).
*/
const loadChildren = useCallback(
async (parentId: number): Promise<TreeNodeData[]> => {
const res = await fetch(
`/api/admin/catalog/tree?parentId=${parentId}`,
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to load pages");
return normalizePages(data.pages || []);
},
[normalizePages],
);
const loadTabTree = useCallback(async () => {
if (activeTabId === null) return;
setTreeLoading(true);
try {
const res = await fetch("/api/admin/catalog/tree?mode=full");
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Tree request failed");
const pages: TreeNodeData[] = data.pages || [];
dispatch({ type: "SET_FULL_TREE", nodes: pages });
// Auto-expand first level under the active root so categories are visible.
if (activeTabId !== null) {
const firstLevel = pages
.filter((p) => Number(p.parentId) === activeTabId)
.map((p) => Number(p.id));
if (firstLevel.length > 0) {
dispatch({ type: "EXPAND_PATH", ids: firstLevel });
}
}
const pages = await loadChildren(activeTabId);
dispatch({
type: "SET_CHILDREN",
parentId: activeTabId,
children: pages,
});
} catch {
toast.error("Failed to load catalog tree");
toast.error("Failed to load catalog pages");
} finally {
setTreeLoading(false);
}
}, [dispatch, activeTabId]);
}, [activeTabId, dispatch, loadChildren]);
// Load full tree when tab changes — replace atomically (no CLEAR_TREE race).
// Expose reload for mutations (delete/move/create)
const loadFullTree = loadTabTree;
// Load tab children when active root changes (same as Catalog Structure expand)
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => {
if (activeTabId === null) return;
@@ -149,7 +174,7 @@ export function SortableTree({
setSearchResults(null);
setFilter("all");
dispatch({ type: "COLLAPSE_ALL" });
void loadFullTree();
void loadTabTree();
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
// Debounced search
@@ -184,16 +209,38 @@ export function SortableTree({
dispatch({ type: "UPDATE_NODE", update: nodeUpdate });
}, [nodeUpdate, dispatch]);
// Toggle expand (instant — data already loaded)
// Toggle expand — lazy-fetch children when needed (CatalogTree pattern)
const handleToggleExpand = useCallback(
(id: number) => {
async (id: number) => {
if (expandedIds.has(id)) {
dispatch({ type: "SET_EXPANDED", id, expanded: false });
} else {
dispatch({ type: "SET_EXPANDED", id, expanded: true });
return;
}
const node = nodes.get(id);
const existing = childrenMap.get(id);
if (
node &&
node.childCount > 0 &&
(!existing || existing.length === 0)
) {
dispatch({
type: "SET_LOADING_IDS",
ids: new Set([...loadingIds, id]),
});
try {
const pages = await loadChildren(id);
dispatch({ type: "SET_CHILDREN", parentId: id, children: pages });
} catch {
toast.error("Failed to load subpages");
} finally {
const next = new Set(loadingIds);
next.delete(id);
dispatch({ type: "SET_LOADING_IDS", ids: next });
}
}
dispatch({ type: "SET_EXPANDED", id, expanded: true });
},
[expandedIds, dispatch],
[expandedIds, dispatch, nodes, childrenMap, loadingIds, loadChildren],
);
// ── Flatten visible tree with filter ────────────────────────
@@ -537,9 +584,25 @@ export function SortableTree({
// ── Expand/Collapse all ─────────────────────────────────────
const handleExpandAll = useCallback(() => {
const allIds = Array.from(nodes.keys());
dispatch({ type: "EXPAND_PATH", ids: allIds });
}, [nodes, dispatch]);
if (activeTabId === null) return;
const firstLevel = childrenMap.get(activeTabId) || [];
dispatch({ type: "EXPAND_PATH", ids: firstLevel });
// Lazy-load each first-level node's children in background
for (const id of firstLevel) {
const node = nodes.get(id);
if (node && node.childCount > 0 && !(childrenMap.get(id)?.length)) {
void loadChildren(id)
.then((pages) => {
dispatch({ type: "SET_CHILDREN", parentId: id, children: pages });
dispatch({
type: "EXPAND_PATH",
ids: pages.map((p) => p.id),
});
})
.catch(() => {});
}
}
}, [activeTabId, childrenMap, nodes, dispatch, loadChildren]);
const handleCollapseAll = useCallback(() => {
dispatch({ type: "COLLAPSE_ALL" });
@@ -624,7 +687,8 @@ export function SortableTree({
ref={treeRef}
role="tree"
tabIndex={0}
className="flex flex-col h-full border-r w-80 shrink-0"
className="flex h-full w-80 shrink-0 flex-col border-r border-[var(--admin-border)]"
style={{ backgroundColor: "var(--admin-surface)" }}
>
{confirmDialog}
{/* Header */}