From 1d8efefce4d0cf99c19da66ff1f1eb78fdaa4076 Mon Sep 17 00:00:00 2001
From: simoleo89
Date: Fri, 17 Jul 2026 22:02:47 +0200
Subject: [PATCH] Fix Visual Manager empty categories: seed roots, harden tree
API.
Seed root tabs from SSR, load the full tree without CLEAR_TREE races, coerce parent ids, and tolerate catalog_items page_id type mismatches so category pages actually appear.
Co-authored-by: Cursor
---
src/app/admin/catalog/page.tsx | 89 +++++----
src/app/api/admin/catalog/tree/route.ts | 88 ++++++---
.../catalog-manager-dialog.tsx | 53 ++++--
.../admin/catalog-manager/sortable-tree.tsx | 37 +++-
.../catalog-manager/use-catalog-store.tsx | 29 ++-
src/lib/services/catalog-tree.ts | 176 +++++++++---------
6 files changed, 285 insertions(+), 187 deletions(-)
diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx
index 2c01ea6a11..df1a450e5e 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 1c8b1a0607..a08335c271 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 9cc2207ad9..809934b5c3 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 0bb74039e4..4b2dc0499d 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 4b9609e677..5185cc4cb0 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 64a5cd297c..bf02667e8f 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