diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index a08335c2..9f353bc6 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -184,13 +184,79 @@ async function getChildren( } async function searchPages(q: string, isBc: boolean): Promise { - 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( diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index 809934b5..9244d9e0 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -59,13 +59,14 @@ export function CatalogManagerDialog({ {mounted && open ? createPortal( -
-
+
+
-
+

-
+
- +
+ +
diff --git a/src/components/admin/catalog-manager/root-tabs.tsx b/src/components/admin/catalog-manager/root-tabs.tsx index 4fa2045c..0b3aa855 100644 --- a/src/components/admin/catalog-manager/root-tabs.tsx +++ b/src/components/admin/catalog-manager/root-tabs.tsx @@ -128,7 +128,10 @@ export function RootTabs({ : null; return ( -
+
{confirmDialog} {loading && ( diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index 4b2dc049..e0aca4b3 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -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 => { + 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 */}