Fix Visual Manager opacity and load categories like CatalogTree.
Local Build and Deploy / deploy (push) Successful in 55s
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:
1 parent
1d8efefce4
commit
33a8a19820
4 files changed
+187
-42
No files matched your search
@@ -184,13 +184,79 @@ 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(
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
Reference in new issue
Block a user