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
+187 -42

No files matched your search

+72 -6
View File
@@ -184,15 +184,81 @@ async function getChildren(
} }
async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> { async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
const flat = isBc ? await getBcTreeFlat() : await getTreeFlat(); const needle = q.trim();
const needle = q.toLowerCase(); const idExact = Number.parseInt(needle, 10);
return flat.filter( const idFilter =
(p) => Number.isFinite(idExact) && String(idExact) === needle
p.caption.toLowerCase().includes(needle) || ? [{ id: idExact }]
String(p.id) === needle, : [];
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( export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW }, { permission: PERMS.CATALOG_VIEW },
async (request) => { async (request) => {
@@ -59,13 +59,14 @@ export function CatalogManagerDialog({
</Button> </Button>
{mounted && open {mounted && open
? createPortal( ? createPortal(
<div className="fixed inset-0 z-50"> <div className="fixed inset-0 z-[200]">
<div className="absolute inset-0 bg-black/50" /> <div className="absolute inset-0 bg-black/70" />
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-labelledby="visual-catalog-manager-title" 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> <CatalogManagerProvider>
<CatalogManagerInner <CatalogManagerInner
@@ -212,7 +213,10 @@ function CatalogManagerInner({
return ( return (
<TooltipProvider delayDuration={300}> <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 <h2
id="visual-catalog-manager-title" id="visual-catalog-manager-title"
className="flex items-center gap-2 text-base font-semibold leading-none" className="flex items-center gap-2 text-base font-semibold leading-none"
@@ -243,7 +247,10 @@ function CatalogManagerInner({
loading={state.loadingRoot} 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 <SortableTree
activeTabId={state.activeTabId} activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId} selectedPageId={state.selectedPageId}
@@ -251,11 +258,16 @@ function CatalogManagerInner({
onTreeMutation={loadRootPages} onTreeMutation={loadRootPages}
nodeUpdate={null} nodeUpdate={null}
/> />
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<InlineEditor <InlineEditor
pageId={state.selectedPageId} pageId={state.selectedPageId}
onSaved={handleEditorSaved} onSaved={handleEditorSaved}
/> />
</div> </div>
</div>
<StatsBar /> <StatsBar />
</TooltipProvider> </TooltipProvider>
@@ -128,7 +128,10 @@ export function RootTabs({
: null; : null;
return ( 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} {confirmDialog}
{loading && ( {loading && (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" /> <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 } }), useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
); );
// ── Full tree load (single API call) ──────────────────────── const normalizePages = useCallback((pages: TreeNodeData[]): TreeNodeData[] => {
const loadFullTree = useCallback(async () => { 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); setTreeLoading(true);
try { try {
const res = await fetch("/api/admin/catalog/tree?mode=full"); const pages = await loadChildren(activeTabId);
const data = await res.json().catch(() => ({})); dispatch({
if (!res.ok) throw new Error(data.error || "Tree request failed"); type: "SET_CHILDREN",
const pages: TreeNodeData[] = data.pages || []; parentId: activeTabId,
dispatch({ type: "SET_FULL_TREE", nodes: pages }); children: 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 });
}
}
} catch { } catch {
toast.error("Failed to load catalog tree"); toast.error("Failed to load catalog pages");
} finally { } finally {
setTreeLoading(false); 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 // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
useEffect(() => { useEffect(() => {
if (activeTabId === null) return; if (activeTabId === null) return;
@@ -149,7 +174,7 @@ export function SortableTree({
setSearchResults(null); setSearchResults(null);
setFilter("all"); setFilter("all");
dispatch({ type: "COLLAPSE_ALL" }); dispatch({ type: "COLLAPSE_ALL" });
void loadFullTree(); void loadTabTree();
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps }, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
// Debounced search // Debounced search
@@ -184,16 +209,38 @@ export function SortableTree({
dispatch({ type: "UPDATE_NODE", update: nodeUpdate }); dispatch({ type: "UPDATE_NODE", update: nodeUpdate });
}, [nodeUpdate, dispatch]); }, [nodeUpdate, dispatch]);
// Toggle expand (instant — data already loaded) // Toggle expand — lazy-fetch children when needed (CatalogTree pattern)
const handleToggleExpand = useCallback( const handleToggleExpand = useCallback(
(id: number) => { async (id: number) => {
if (expandedIds.has(id)) { if (expandedIds.has(id)) {
dispatch({ type: "SET_EXPANDED", id, expanded: false }); dispatch({ type: "SET_EXPANDED", id, expanded: false });
} else { return;
dispatch({ type: "SET_EXPANDED", id, expanded: true });
} }
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 ──────────────────────── // ── Flatten visible tree with filter ────────────────────────
@@ -537,9 +584,25 @@ export function SortableTree({
// ── Expand/Collapse all ───────────────────────────────────── // ── Expand/Collapse all ─────────────────────────────────────
const handleExpandAll = useCallback(() => { const handleExpandAll = useCallback(() => {
const allIds = Array.from(nodes.keys()); if (activeTabId === null) return;
dispatch({ type: "EXPAND_PATH", ids: allIds }); const firstLevel = childrenMap.get(activeTabId) || [];
}, [nodes, dispatch]); 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(() => { const handleCollapseAll = useCallback(() => {
dispatch({ type: "COLLAPSE_ALL" }); dispatch({ type: "COLLAPSE_ALL" });
@@ -624,7 +687,8 @@ export function SortableTree({
ref={treeRef} ref={treeRef}
role="tree" role="tree"
tabIndex={0} 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} {confirmDialog}
{/* Header */} {/* Header */}