diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index 2c01ea6a..df1a450e 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -232,49 +232,48 @@ export default async function CatalogPagesPage({ prisma.catalogPages.count({ where: { enabled: "1" } }), ]); - // ── Tree view data ────────────────────────────────────────────── - let rootPages: TreeNodeShape[] = []; - if (viewMode === "tree") { - const rootPagesRaw = await prisma.catalogPages.findMany({ - where: { parentId: -1 }, - orderBy: { orderNum: "asc" }, - }); - const rootIds = rootPagesRaw.map((p) => p.id); - const [rootChildCounts, rootItemCounts] = await Promise.all([ - rootIds.length > 0 - ? prisma.catalogPages.groupBy({ - by: ["parentId"], - where: { parentId: { in: rootIds } }, - _count: true, - }) - : [], - rootIds.length > 0 - ? prisma.catalogItems.groupBy({ + // ── Root pages (tree view + Visual Manager seed) ──────────────── + const rootPagesRaw = await prisma.catalogPages.findMany({ + where: { OR: [{ parentId: -1 }, { parentId: 0 }] }, + orderBy: { orderNum: "asc" }, + }); + const rootIds = rootPagesRaw.map((p) => p.id); + const [rootChildCounts, rootItemCounts] = await Promise.all([ + rootIds.length > 0 + ? prisma.catalogPages.groupBy({ + by: ["parentId"], + where: { parentId: { in: rootIds } }, + _count: true, + }) + : [], + rootIds.length > 0 + ? prisma.catalogItems + .groupBy({ by: ["pageId"], where: { pageId: { in: rootIds } }, _count: true, }) - : [], - ]); - const rootChildMap = Object.fromEntries( - rootChildCounts.map((c) => [c.parentId, c._count]), - ); - const rootItemMap = Object.fromEntries( - rootItemCounts.map((c) => [c.pageId, c._count]), - ); - rootPages = rootPagesRaw.map((p) => ({ - id: p.id, - caption: p.caption, - parentId: p.parentId, - enabled: String(p.enabled), - visible: String(p.visible), - iconImage: p.iconImage, - iconColor: p.iconColor, - pageLayout: p.pageLayout, - childCount: rootChildMap[p.id] ?? 0, - itemCount: rootItemMap[p.id] ?? 0, - })); - } + .catch(() => []) + : [], + ]); + const rootChildMap = Object.fromEntries( + rootChildCounts.map((c) => [c.parentId, c._count]), + ); + const rootItemMap = Object.fromEntries( + rootItemCounts.map((c) => [c.pageId, c._count]), + ); + const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({ + id: p.id, + caption: p.caption, + parentId: p.parentId, + enabled: String(p.enabled), + visible: String(p.visible), + iconImage: p.iconImage, + iconColor: p.iconColor, + pageLayout: p.pageLayout, + childCount: rootChildMap[p.id] ?? 0, + itemCount: rootItemMap[p.id] ?? 0, + })); // ── Table view data (skip if tree) ──────────────────────────── let rows: { @@ -399,7 +398,17 @@ export default async function CatalogPagesPage({

- {canEdit && } + {canEdit && ( + ({ + id: p.id, + caption: p.caption, + iconImage: p.iconImage, + enabled: p.enabled, + orderNum: 0, + }))} + /> + )} {canEdit && }
diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts index 1c8b1a06..a08335c2 100644 --- a/src/app/api/admin/catalog/tree/route.ts +++ b/src/app/api/admin/catalog/tree/route.ts @@ -6,6 +6,8 @@ import { deletePage, getAncestors, getTreeFlat, + safeItemCounts, + toInt, } from "@/lib/services/catalog-tree"; import { rcon } from "@/lib/services/rcon"; import type { TreeNode } from "@/types/catalog"; @@ -87,8 +89,9 @@ async function getChildren( parentId: number, isBc: boolean, ): Promise { + // Roots in Habbo DBs are usually parent_id = -1, sometimes 0. const parentFilter = - parentId === -1 + parentId <= 0 ? { OR: [{ parentId: -1 }, { parentId: 0 }] } : { parentId }; @@ -97,7 +100,7 @@ async function getChildren( where: parentFilter, orderBy: { orderNum: "asc" }, }); - const ids = pages.map((p) => p.id); + const ids = pages.map((p) => toInt(p.id)); const [childCounts, itemCounts] = await Promise.all([ ids.length ? prisma.catalogPagesBc.groupBy({ @@ -105,56 +108,79 @@ async function getChildren( where: { parentId: { in: ids } }, _count: true, }) - : [], + : Promise.resolve([]), ids.length - ? prisma.catalogItemsBc.groupBy({ - by: ["pageId"], - where: { pageId: { in: ids } }, - _count: true, - }) - : [], + ? prisma.catalogItemsBc + .groupBy({ + by: ["pageId"], + where: { pageId: { in: ids } }, + _count: true, + }) + .catch(() => []) + : Promise.resolve([]), ]); const childMap = Object.fromEntries( - childCounts.map((c) => [c.parentId, c._count]), + childCounts.map((c) => [toInt(c.parentId), c._count]), ); const itemMap = Object.fromEntries( - itemCounts.map((c) => [c.pageId, c._count]), - ); - return pages.map((p) => - toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0), + itemCounts.map((c) => [toInt(c.pageId), 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[id] ?? 0, + ); + }); } const pages = await prisma.catalogPages.findMany({ where: parentFilter, orderBy: { orderNum: "asc" }, }); - const ids = pages.map((p) => p.id); - const [childCounts, itemCounts] = await Promise.all([ + 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, }) - : [], - ids.length - ? prisma.catalogItems.groupBy({ - by: ["pageId"], - where: { pageId: { in: ids } }, - _count: true, - }) - : [], + : Promise.resolve([]), + safeItemCounts(ids), ]); const childMap = Object.fromEntries( - childCounts.map((c) => [c.parentId, c._count]), - ); - const itemMap = Object.fromEntries( - itemCounts.map((c) => [c.pageId, c._count]), - ); - return pages.map((p) => - toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0), + 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, + ); + }); } async function searchPages(q: string, isBc: boolean): Promise { diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index 9cc2207a..809934b5 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -17,10 +17,13 @@ import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store"; /** * Visual Catalog Manager — matches habbo-next UX (full-viewport editor). - * Uses a portal overlay instead of Base UI Dialog (DialogTitle/Popup break - * the Radix-era layout that habbo-next still uses). + * Portal overlay (not Base UI Dialog) so the tree + editor mount reliably. */ -export function CatalogManagerDialog() { +export function CatalogManagerDialog({ + initialRoots = [], +}: { + initialRoots?: RootTab[]; +}) { const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); @@ -68,6 +71,7 @@ export function CatalogManagerDialog() { @@ -82,9 +86,11 @@ export function CatalogManagerDialog() { function CatalogManagerInner({ open, onOpenChange, + initialRoots, }: { open: boolean; onOpenChange: (v: boolean) => void; + initialRoots: RootTab[]; }) { const { state, dispatch } = useCatalogManager(); @@ -92,23 +98,23 @@ function CatalogManagerInner({ dispatch({ type: "SET_LOADING_ROOT", loading: true }); try { const res = await fetch("/api/admin/catalog/tree?parentId=-1"); - if (!res.ok) throw new Error(); - const data = await res.json(); + const data = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(data.error || "Failed to load roots"); const pages: RootTab[] = (data.pages || []).map( (p: Record) => ({ - id: p.id as number, - caption: p.caption as string, - iconImage: p.iconImage as number, - enabled: String(p.enabled), - orderNum: (p.orderNum as number) || 0, + id: Number(p.id), + caption: String(p.caption ?? ""), + iconImage: Number(p.iconImage) || 0, + enabled: String(p.enabled ?? "0"), + orderNum: Number(p.orderNum) || 0, }), ); dispatch({ type: "SET_ROOT_PAGES", pages }); - if ( - pages.length > 0 && - (!state.activeTabId || !pages.find((p) => p.id === state.activeTabId)) - ) { + const activeStillValid = + state.activeTabId !== null && + pages.some((p) => p.id === state.activeTabId); + if (pages.length > 0 && !activeStillValid) { dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id }); } } catch { @@ -117,9 +123,22 @@ function CatalogManagerInner({ } }, [dispatch, state.activeTabId]); - // biome-ignore lint/correctness/useExhaustiveDependencies: load once when dialog opens + // Seed from SSR immediately, then refresh from API. useEffect(() => { - if (open) loadRootPages(); + if (!open) return; + if (initialRoots.length > 0 && state.rootPages.length === 0) { + const pages = initialRoots.map((p) => ({ + ...p, + id: Number(p.id), + enabled: String(p.enabled), + })); + dispatch({ type: "SET_ROOT_PAGES", pages }); + if (state.activeTabId === null && pages[0]) { + dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id }); + } + } + void loadRootPages(); + // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); useEffect(() => { @@ -134,7 +153,6 @@ function CatalogManagerInner({ return; } dispatch({ type: "SET_ACTIVE_TAB", id }); - dispatch({ type: "CLEAR_TREE" }); }; const handleCreateRoot = async () => { @@ -166,7 +184,6 @@ function CatalogManagerInner({ const handleEditTab = (id: number) => { if (state.activeTabId !== id) { dispatch({ type: "SET_ACTIVE_TAB", id }); - dispatch({ type: "CLEAR_TREE" }); } if ( state.editorDirty && diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index 0bb74039..4b2dc049 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -120,26 +120,36 @@ export function SortableTree({ setTreeLoading(true); try { const res = await fetch("/api/admin/catalog/tree?mode=full"); - if (!res.ok) throw new Error(); - const data = await res.json(); + 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 }); + } + } } catch { toast.error("Failed to load catalog tree"); } finally { setTreeLoading(false); } - }, [dispatch]); + }, [dispatch, activeTabId]); - // Load full tree when tab changes + // Load full tree when tab changes — replace atomically (no CLEAR_TREE race). // biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code useEffect(() => { if (activeTabId === null) return; - dispatch({ type: "CLEAR_TREE" }); setSearchQuery(""); setSearchResults(null); setFilter("all"); - loadFullTree(); + dispatch({ type: "COLLAPSE_ALL" }); + void loadFullTree(); }, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps // Debounced search @@ -198,7 +208,6 @@ export function SortableTree({ for (const cid of childIds) { const node = nodes.get(cid); if (!node) continue; - // Apply filter if (filter === "enabled" && node.enabled !== "1") continue; if (filter === "disabled" && node.enabled === "1") continue; const isExpanded = expandedIds.has(cid); @@ -207,6 +216,20 @@ export function SortableTree({ } }; traverse(activeTabId, 0); + + // If this root has no children yet, still show the root page itself + // so categories are editable (matches page CatalogTree expectations). + if (result.length === 0) { + const rootNode = nodes.get(activeTabId); + if (rootNode) { + if ( + !(filter === "enabled" && rootNode.enabled !== "1") && + !(filter === "disabled" && rootNode.enabled === "1") + ) { + result.push({ ...rootNode, depth: 0, isExpanded: false }); + } + } + } return result; }, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]); diff --git a/src/components/admin/catalog-manager/use-catalog-store.tsx b/src/components/admin/catalog-manager/use-catalog-store.tsx index 4b9609e6..5185cc4c 100644 --- a/src/components/admin/catalog-manager/use-catalog-store.tsx +++ b/src/components/admin/catalog-manager/use-catalog-store.tsx @@ -142,12 +142,35 @@ function reducer( case "SET_FULL_TREE": { const nodes = new Map(); const childrenMap = new Map(); - for (const n of action.nodes) { - nodes.set(n.id, n); + for (const raw of action.nodes) { + const id = Number(raw.id); + const parentId = Number(raw.parentId); + if (!Number.isFinite(id)) continue; + const n: TreeNodeData = { + ...raw, + id, + parentId: Number.isFinite(parentId) ? parentId : -1, + enabled: String(raw.enabled ?? "0"), + visible: String(raw.visible ?? "0"), + childCount: Number(raw.childCount) || 0, + itemCount: Number(raw.itemCount) || 0, + iconImage: Number(raw.iconImage) || 0, + iconColor: Number(raw.iconColor) || 0, + orderNum: Number(raw.orderNum) || 0, + }; + nodes.set(id, n); const siblings = childrenMap.get(n.parentId) || []; - siblings.push(n.id); + siblings.push(id); childrenMap.set(n.parentId, siblings); } + // Roots may be parent_id -1 or 0 — mirror both keys so tabs resolve children. + const underNeg1 = childrenMap.get(-1) || []; + const under0 = childrenMap.get(0) || []; + if (underNeg1.length || under0.length) { + const merged = [...new Set([...underNeg1, ...under0])]; + childrenMap.set(-1, merged); + childrenMap.set(0, merged); + } for (const [parentId, ids] of childrenMap) { ids.sort( (a, b) => diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index 64a5cd29..bf02667e 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -2,69 +2,92 @@ import { prisma } from "@/lib/prisma"; export type { NestedTreeNode, TreeNode } from "@/types/catalog"; -import type { TreeNode } from "@/types/catalog"; +import type { NestedTreeNode, TreeNode } from "@/types/catalog"; + +function toInt(value: unknown, fallback = 0): number { + const n = typeof value === "number" ? value : Number(value); + return Number.isFinite(n) ? n : fallback; +} + +async function safeItemCounts( + pageIds?: number[], +): Promise> { + const map = new Map(); + try { + if (pageIds && pageIds.length === 0) return map; + const itemCounts = pageIds + ? await prisma.catalogItems.groupBy({ + by: ["pageId"], + where: { pageId: { in: pageIds } }, + _count: true, + }) + : await prisma.catalogItems.groupBy({ + by: ["pageId"], + _count: true, + }); + for (const c of itemCounts) { + map.set(toInt(c.pageId), c._count); + } + } catch { + // Some DBs store page_id as VARCHAR — ignore counts rather than failing the tree. + } + return map; +} /** - * Get the full catalog tree recursively using iterative Prisma queries. - * Returns a flat list with depth info for client-side tree building. - * Depth is computed from parentId hierarchy (not stored in DB). + * Get the full catalog tree as a flat list with depth info. */ export async function getTreeFlat(): Promise { - const [allPages, itemCounts] = await Promise.all([ - prisma.catalogPages.findMany({ - orderBy: { orderNum: "asc" }, - }), - prisma.catalogItems.groupBy({ - by: ["pageId"], - _count: true, - }), - ]); + const allPages = await prisma.catalogPages.findMany({ + orderBy: { orderNum: "asc" }, + }); + const itemCountMap = await safeItemCounts(); - const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count])); - - // Count children per page const childCountMap = new Map(); for (const p of allPages) { - childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1); + const parentId = toInt(p.parentId, -1); + childCountMap.set(parentId, (childCountMap.get(parentId) ?? 0) + 1); } - // Compute depth from parent hierarchy const pageMap = new Map(allPages.map((p) => [p.id, p])); const depthCache = new Map(); function computeDepth(pageId: number): number { if (depthCache.has(pageId)) return depthCache.get(pageId)!; const page = pageMap.get(pageId); - if (!page || page.parentId <= 0) { + const parentId = page ? toInt(page.parentId, -1) : -1; + if (!page || parentId <= 0) { depthCache.set(pageId, 0); return 0; } - const d = computeDepth(page.parentId) + 1; + const d = computeDepth(parentId) + 1; depthCache.set(pageId, d); return d; } - return allPages.map((p) => ({ - id: p.id, - caption: p.caption, - parentId: p.parentId, - depth: computeDepth(p.id), - orderNum: p.orderNum, - enabled: String(p.enabled), - visible: String(p.visible), - iconImage: p.iconImage, - iconColor: p.iconColor, - pageLayout: p.pageLayout, - childCount: childCountMap.get(p.id) ?? 0, - itemCount: itemCountMap.get(p.id) ?? 0, - })); + return allPages.map((p) => { + const id = toInt(p.id); + const parentId = toInt(p.parentId, -1); + return { + id, + caption: p.caption, + parentId, + depth: computeDepth(id), + 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, + childCount: childCountMap.get(id) ?? 0, + itemCount: itemCountMap.get(id) ?? 0, + }; + }); } /** * Build a nested tree structure from flat nodes. */ -import type { NestedTreeNode } from "@/types/catalog"; - export function buildNestedTree( flat: TreeNode[], rootParentId = -1, @@ -79,7 +102,6 @@ export function buildNestedTree( childrenMap.get(node.parentId)?.push(nested); } - // Attach children for (const node of nodeMap.values()) { node.children = childrenMap.get(node.id) ?? []; } @@ -99,53 +121,41 @@ export async function createPage(data: { iconColor?: number; minRank?: number; orderNum?: number; - visible?: boolean; - enabled?: boolean; -}): Promise<{ id: number }> { - // Auto-generate captionSave if not provided - const captionSave = - data.captionSave || - data.caption - .toLowerCase() - .replace(/[^a-z0-9_]/g, "_") - .substring(0, 25); + visible?: string | boolean; + enabled?: string | boolean; +}) { + const visible = + typeof data.visible === "boolean" + ? data.visible + ? "1" + : "0" + : (data.visible ?? "1"); + const enabled = + typeof data.enabled === "boolean" + ? data.enabled + ? "1" + : "0" + : (data.enabled ?? "1"); - // Get next orderNum if not provided - let orderNum = data.orderNum; - if (orderNum === undefined) { - const lastSibling = await prisma.catalogPages.findFirst({ - where: { parentId: data.parentId }, - orderBy: { orderNum: "desc" }, - select: { orderNum: true }, - }); - orderNum = (lastSibling?.orderNum ?? 0) + 1; - } - - const created = await prisma.catalogPages.create({ + return prisma.catalogPages.create({ data: { - parentId: data.parentId, caption: data.caption, - captionSave, + parentId: data.parentId, pageLayout: data.pageLayout ?? "default_3x3", - iconImage: data.iconImage ?? 1, - iconColor: data.iconColor ?? 1, + captionSave: (data.captionSave ?? data.caption).slice(0, 25), + iconColor: data.iconColor ?? 0, + iconImage: data.iconImage ?? 0, minRank: data.minRank ?? 1, - orderNum, - visible: (data.visible ?? true) ? "1" : "0", - enabled: (data.enabled ?? true) ? "1" : "0", + orderNum: data.orderNum ?? 0, + visible, + enabled, + clubOnly: "0", + vipOnly: "0", pageHeadline: "", pageTeaser: "", - pageSpecial: "", - pageText1: "", - pageText2: "", - pageTextDetails: "", - pageTextTeaser: "", includes: "", }, - select: { id: true }, }); - - return created; } /** @@ -156,7 +166,6 @@ export async function movePage( pageId: number, newParentId: number, ): Promise { - // Walk up from newParentId to root — if we hit pageId, it's circular if (newParentId > 0) { let currentId = newParentId; for (let i = 0; i < 50; i++) { @@ -180,8 +189,6 @@ export async function movePage( /** * Delete a page with cascade or reparent mode. - * - cascade: deletes all children and their items - * - reparent: moves children to the deleted page's parent */ export async function deletePage( pageId: number, @@ -194,27 +201,22 @@ export async function deletePage( if (!page) return { deletedPages: 0, movedChildren: 0 }; if (mode === "reparent") { - // Move children to page's parent const result = await prisma.catalogPages.updateMany({ where: { parentId: pageId }, data: { parentId: page.parentId }, }); - // Delete items in this page await prisma.catalogItems.deleteMany({ where: { pageId } }); - // Delete the page await prisma.catalogPages.delete({ where: { id: pageId } }); return { deletedPages: 1, movedChildren: result.count }; } - // Cascade: delete all descendants const deleted = await cascadeDelete(pageId); return { deletedPages: deleted, movedChildren: 0 }; } async function cascadeDelete(pageId: number): Promise { - // Collect all descendant IDs iteratively to avoid N+1 recursive queries const toDelete: number[] = [pageId]; const queue: number[] = [pageId]; @@ -230,9 +232,7 @@ async function cascadeDelete(pageId: number): Promise { } } - // Delete all items and pages in bulk (children first, then parents) await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } }); - // Delete in reverse order (deepest first) to avoid FK issues for (let i = toDelete.length - 1; i >= 0; i--) { await prisma.catalogPages.delete({ where: { id: toDelete[i] } }); } @@ -248,9 +248,7 @@ export async function getAncestors( ): Promise> { const ancestors: Array<{ id: number; caption: string }> = []; let currentId = pageId; - - // Safety limit to prevent infinite loops - for (let i = 0; i < 20; i++) { + for (let i = 0; i < 50; i++) { const page = await prisma.catalogPages.findUnique({ where: { id: currentId }, select: { id: true, caption: true, parentId: true }, @@ -260,6 +258,8 @@ export async function getAncestors( if (page.parentId <= 0) break; currentId = page.parentId; } - return ancestors; } + +/** Safe item-count helper exported for the tree API route. */ +export { safeItemCounts, toInt };