Fix Visual Manager empty categories: seed roots, harden tree API.
Local Build and Deploy / deploy (push) Successful in 56s
Local Build and Deploy / deploy (push) Successful in 56s
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 <[email protected]>
This commit is contained in:
1 parent
385cefd0fa
commit
1d8efefce4
6 files changed
+285
-187
No files matched your search
@@ -232,49 +232,48 @@ export default async function CatalogPagesPage({
|
|||||||
prisma.catalogPages.count({ where: { enabled: "1" } }),
|
prisma.catalogPages.count({ where: { enabled: "1" } }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Tree view data ──────────────────────────────────────────────
|
// ── Root pages (tree view + Visual Manager seed) ────────────────
|
||||||
let rootPages: TreeNodeShape[] = [];
|
const rootPagesRaw = await prisma.catalogPages.findMany({
|
||||||
if (viewMode === "tree") {
|
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
|
||||||
const rootPagesRaw = await prisma.catalogPages.findMany({
|
orderBy: { orderNum: "asc" },
|
||||||
where: { parentId: -1 },
|
});
|
||||||
orderBy: { orderNum: "asc" },
|
const rootIds = rootPagesRaw.map((p) => p.id);
|
||||||
});
|
const [rootChildCounts, rootItemCounts] = await Promise.all([
|
||||||
const rootIds = rootPagesRaw.map((p) => p.id);
|
rootIds.length > 0
|
||||||
const [rootChildCounts, rootItemCounts] = await Promise.all([
|
? prisma.catalogPages.groupBy({
|
||||||
rootIds.length > 0
|
by: ["parentId"],
|
||||||
? prisma.catalogPages.groupBy({
|
where: { parentId: { in: rootIds } },
|
||||||
by: ["parentId"],
|
_count: true,
|
||||||
where: { parentId: { in: rootIds } },
|
})
|
||||||
_count: true,
|
: [],
|
||||||
})
|
rootIds.length > 0
|
||||||
: [],
|
? prisma.catalogItems
|
||||||
rootIds.length > 0
|
.groupBy({
|
||||||
? prisma.catalogItems.groupBy({
|
|
||||||
by: ["pageId"],
|
by: ["pageId"],
|
||||||
where: { pageId: { in: rootIds } },
|
where: { pageId: { in: rootIds } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
.catch(() => [])
|
||||||
]);
|
: [],
|
||||||
const rootChildMap = Object.fromEntries(
|
]);
|
||||||
rootChildCounts.map((c) => [c.parentId, c._count]),
|
const rootChildMap = Object.fromEntries(
|
||||||
);
|
rootChildCounts.map((c) => [c.parentId, c._count]),
|
||||||
const rootItemMap = Object.fromEntries(
|
);
|
||||||
rootItemCounts.map((c) => [c.pageId, c._count]),
|
const rootItemMap = Object.fromEntries(
|
||||||
);
|
rootItemCounts.map((c) => [c.pageId, c._count]),
|
||||||
rootPages = rootPagesRaw.map((p) => ({
|
);
|
||||||
id: p.id,
|
const rootPages: TreeNodeShape[] = rootPagesRaw.map((p) => ({
|
||||||
caption: p.caption,
|
id: p.id,
|
||||||
parentId: p.parentId,
|
caption: p.caption,
|
||||||
enabled: String(p.enabled),
|
parentId: p.parentId,
|
||||||
visible: String(p.visible),
|
enabled: String(p.enabled),
|
||||||
iconImage: p.iconImage,
|
visible: String(p.visible),
|
||||||
iconColor: p.iconColor,
|
iconImage: p.iconImage,
|
||||||
pageLayout: p.pageLayout,
|
iconColor: p.iconColor,
|
||||||
childCount: rootChildMap[p.id] ?? 0,
|
pageLayout: p.pageLayout,
|
||||||
itemCount: rootItemMap[p.id] ?? 0,
|
childCount: rootChildMap[p.id] ?? 0,
|
||||||
}));
|
itemCount: rootItemMap[p.id] ?? 0,
|
||||||
}
|
}));
|
||||||
|
|
||||||
// ── Table view data (skip if tree) ────────────────────────────
|
// ── Table view data (skip if tree) ────────────────────────────
|
||||||
let rows: {
|
let rows: {
|
||||||
@@ -399,7 +398,17 @@ export default async function CatalogPagesPage({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||||
{canEdit && <CatalogManagerDialog />}
|
{canEdit && (
|
||||||
|
<CatalogManagerDialog
|
||||||
|
initialRoots={rootPages.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
caption: p.caption,
|
||||||
|
iconImage: p.iconImage,
|
||||||
|
enabled: p.enabled,
|
||||||
|
orderNum: 0,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{canEdit && <CatalogActions />}
|
{canEdit && <CatalogActions />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
deletePage,
|
deletePage,
|
||||||
getAncestors,
|
getAncestors,
|
||||||
getTreeFlat,
|
getTreeFlat,
|
||||||
|
safeItemCounts,
|
||||||
|
toInt,
|
||||||
} from "@/lib/services/catalog-tree";
|
} from "@/lib/services/catalog-tree";
|
||||||
import { rcon } from "@/lib/services/rcon";
|
import { rcon } from "@/lib/services/rcon";
|
||||||
import type { TreeNode } from "@/types/catalog";
|
import type { TreeNode } from "@/types/catalog";
|
||||||
@@ -87,8 +89,9 @@ async function getChildren(
|
|||||||
parentId: number,
|
parentId: number,
|
||||||
isBc: boolean,
|
isBc: boolean,
|
||||||
): Promise<TreeNode[]> {
|
): Promise<TreeNode[]> {
|
||||||
|
// Roots in Habbo DBs are usually parent_id = -1, sometimes 0.
|
||||||
const parentFilter =
|
const parentFilter =
|
||||||
parentId === -1
|
parentId <= 0
|
||||||
? { OR: [{ parentId: -1 }, { parentId: 0 }] }
|
? { OR: [{ parentId: -1 }, { parentId: 0 }] }
|
||||||
: { parentId };
|
: { parentId };
|
||||||
|
|
||||||
@@ -97,7 +100,7 @@ async function getChildren(
|
|||||||
where: parentFilter,
|
where: parentFilter,
|
||||||
orderBy: { orderNum: "asc" },
|
orderBy: { orderNum: "asc" },
|
||||||
});
|
});
|
||||||
const ids = pages.map((p) => p.id);
|
const ids = pages.map((p) => toInt(p.id));
|
||||||
const [childCounts, itemCounts] = await Promise.all([
|
const [childCounts, itemCounts] = await Promise.all([
|
||||||
ids.length
|
ids.length
|
||||||
? prisma.catalogPagesBc.groupBy({
|
? prisma.catalogPagesBc.groupBy({
|
||||||
@@ -105,56 +108,79 @@ async function getChildren(
|
|||||||
where: { parentId: { in: ids } },
|
where: { parentId: { in: ids } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: Promise.resolve([]),
|
||||||
ids.length
|
ids.length
|
||||||
? prisma.catalogItemsBc.groupBy({
|
? prisma.catalogItemsBc
|
||||||
by: ["pageId"],
|
.groupBy({
|
||||||
where: { pageId: { in: ids } },
|
by: ["pageId"],
|
||||||
_count: true,
|
where: { pageId: { in: ids } },
|
||||||
})
|
_count: true,
|
||||||
: [],
|
})
|
||||||
|
.catch(() => [])
|
||||||
|
: Promise.resolve([]),
|
||||||
]);
|
]);
|
||||||
const childMap = Object.fromEntries(
|
const childMap = Object.fromEntries(
|
||||||
childCounts.map((c) => [c.parentId, c._count]),
|
childCounts.map((c) => [toInt(c.parentId), c._count]),
|
||||||
);
|
);
|
||||||
const itemMap = Object.fromEntries(
|
const itemMap = Object.fromEntries(
|
||||||
itemCounts.map((c) => [c.pageId, c._count]),
|
itemCounts.map((c) => [toInt(c.pageId), c._count]),
|
||||||
);
|
|
||||||
return pages.map((p) =>
|
|
||||||
toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0),
|
|
||||||
);
|
);
|
||||||
|
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({
|
const pages = await prisma.catalogPages.findMany({
|
||||||
where: parentFilter,
|
where: parentFilter,
|
||||||
orderBy: { orderNum: "asc" },
|
orderBy: { orderNum: "asc" },
|
||||||
});
|
});
|
||||||
const ids = pages.map((p) => p.id);
|
const ids = pages.map((p) => toInt(p.id));
|
||||||
const [childCounts, itemCounts] = await Promise.all([
|
const [childCounts, itemMap] = await Promise.all([
|
||||||
ids.length
|
ids.length
|
||||||
? prisma.catalogPages.groupBy({
|
? prisma.catalogPages.groupBy({
|
||||||
by: ["parentId"],
|
by: ["parentId"],
|
||||||
where: { parentId: { in: ids } },
|
where: { parentId: { in: ids } },
|
||||||
_count: true,
|
_count: true,
|
||||||
})
|
})
|
||||||
: [],
|
: Promise.resolve([]),
|
||||||
ids.length
|
safeItemCounts(ids),
|
||||||
? prisma.catalogItems.groupBy({
|
|
||||||
by: ["pageId"],
|
|
||||||
where: { pageId: { in: ids } },
|
|
||||||
_count: true,
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
]);
|
]);
|
||||||
const childMap = Object.fromEntries(
|
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),
|
|
||||||
);
|
);
|
||||||
|
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<TreeNode[]> {
|
async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
|
||||||
|
|||||||
@@ -17,10 +17,13 @@ import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
|
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
|
||||||
* Uses a portal overlay instead of Base UI Dialog (DialogTitle/Popup break
|
* Portal overlay (not Base UI Dialog) so the tree + editor mount reliably.
|
||||||
* the Radix-era layout that habbo-next still uses).
|
|
||||||
*/
|
*/
|
||||||
export function CatalogManagerDialog() {
|
export function CatalogManagerDialog({
|
||||||
|
initialRoots = [],
|
||||||
|
}: {
|
||||||
|
initialRoots?: RootTab[];
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@@ -68,6 +71,7 @@ export function CatalogManagerDialog() {
|
|||||||
<CatalogManagerInner
|
<CatalogManagerInner
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
|
initialRoots={initialRoots}
|
||||||
/>
|
/>
|
||||||
</CatalogManagerProvider>
|
</CatalogManagerProvider>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,9 +86,11 @@ export function CatalogManagerDialog() {
|
|||||||
function CatalogManagerInner({
|
function CatalogManagerInner({
|
||||||
open,
|
open,
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
initialRoots,
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
|
initialRoots: RootTab[];
|
||||||
}) {
|
}) {
|
||||||
const { state, dispatch } = useCatalogManager();
|
const { state, dispatch } = useCatalogManager();
|
||||||
|
|
||||||
@@ -92,23 +98,23 @@ function CatalogManagerInner({
|
|||||||
dispatch({ type: "SET_LOADING_ROOT", loading: true });
|
dispatch({ type: "SET_LOADING_ROOT", loading: true });
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/catalog/tree?parentId=-1");
|
const res = await fetch("/api/admin/catalog/tree?parentId=-1");
|
||||||
if (!res.ok) throw new Error();
|
const data = await res.json().catch(() => ({}));
|
||||||
const data = await res.json();
|
if (!res.ok) throw new Error(data.error || "Failed to load roots");
|
||||||
const pages: RootTab[] = (data.pages || []).map(
|
const pages: RootTab[] = (data.pages || []).map(
|
||||||
(p: Record<string, unknown>) => ({
|
(p: Record<string, unknown>) => ({
|
||||||
id: p.id as number,
|
id: Number(p.id),
|
||||||
caption: p.caption as string,
|
caption: String(p.caption ?? ""),
|
||||||
iconImage: p.iconImage as number,
|
iconImage: Number(p.iconImage) || 0,
|
||||||
enabled: String(p.enabled),
|
enabled: String(p.enabled ?? "0"),
|
||||||
orderNum: (p.orderNum as number) || 0,
|
orderNum: Number(p.orderNum) || 0,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
dispatch({ type: "SET_ROOT_PAGES", pages });
|
dispatch({ type: "SET_ROOT_PAGES", pages });
|
||||||
|
|
||||||
if (
|
const activeStillValid =
|
||||||
pages.length > 0 &&
|
state.activeTabId !== null &&
|
||||||
(!state.activeTabId || !pages.find((p) => p.id === state.activeTabId))
|
pages.some((p) => p.id === state.activeTabId);
|
||||||
) {
|
if (pages.length > 0 && !activeStillValid) {
|
||||||
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
|
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -117,9 +123,22 @@ function CatalogManagerInner({
|
|||||||
}
|
}
|
||||||
}, [dispatch, state.activeTabId]);
|
}, [dispatch, state.activeTabId]);
|
||||||
|
|
||||||
// biome-ignore lint/correctness/useExhaustiveDependencies: load once when dialog opens
|
// Seed from SSR immediately, then refresh from API.
|
||||||
useEffect(() => {
|
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]);
|
}, [open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -134,7 +153,6 @@ function CatalogManagerInner({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
dispatch({ type: "SET_ACTIVE_TAB", id });
|
dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||||
dispatch({ type: "CLEAR_TREE" });
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRoot = async () => {
|
const handleCreateRoot = async () => {
|
||||||
@@ -166,7 +184,6 @@ function CatalogManagerInner({
|
|||||||
const handleEditTab = (id: number) => {
|
const handleEditTab = (id: number) => {
|
||||||
if (state.activeTabId !== id) {
|
if (state.activeTabId !== id) {
|
||||||
dispatch({ type: "SET_ACTIVE_TAB", id });
|
dispatch({ type: "SET_ACTIVE_TAB", id });
|
||||||
dispatch({ type: "CLEAR_TREE" });
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
state.editorDirty &&
|
state.editorDirty &&
|
||||||
|
|||||||
@@ -120,26 +120,36 @@ export function SortableTree({
|
|||||||
setTreeLoading(true);
|
setTreeLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/admin/catalog/tree?mode=full");
|
const res = await fetch("/api/admin/catalog/tree?mode=full");
|
||||||
if (!res.ok) throw new Error();
|
const data = await res.json().catch(() => ({}));
|
||||||
const data = await res.json();
|
if (!res.ok) throw new Error(data.error || "Tree request failed");
|
||||||
const pages: TreeNodeData[] = data.pages || [];
|
const pages: TreeNodeData[] = data.pages || [];
|
||||||
dispatch({ type: "SET_FULL_TREE", nodes: 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 {
|
} catch {
|
||||||
toast.error("Failed to load catalog tree");
|
toast.error("Failed to load catalog tree");
|
||||||
} finally {
|
} finally {
|
||||||
setTreeLoading(false);
|
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
|
// biome-ignore lint/correctness/useExhaustiveDependencies: explicitly chosen here, see surrounding code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTabId === null) return;
|
if (activeTabId === null) return;
|
||||||
dispatch({ type: "CLEAR_TREE" });
|
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
setSearchResults(null);
|
setSearchResults(null);
|
||||||
setFilter("all");
|
setFilter("all");
|
||||||
loadFullTree();
|
dispatch({ type: "COLLAPSE_ALL" });
|
||||||
|
void loadFullTree();
|
||||||
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [activeTabId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
// Debounced search
|
// Debounced search
|
||||||
@@ -198,7 +208,6 @@ export function SortableTree({
|
|||||||
for (const cid of childIds) {
|
for (const cid of childIds) {
|
||||||
const node = nodes.get(cid);
|
const node = nodes.get(cid);
|
||||||
if (!node) continue;
|
if (!node) continue;
|
||||||
// Apply filter
|
|
||||||
if (filter === "enabled" && node.enabled !== "1") continue;
|
if (filter === "enabled" && node.enabled !== "1") continue;
|
||||||
if (filter === "disabled" && node.enabled === "1") continue;
|
if (filter === "disabled" && node.enabled === "1") continue;
|
||||||
const isExpanded = expandedIds.has(cid);
|
const isExpanded = expandedIds.has(cid);
|
||||||
@@ -207,6 +216,20 @@ export function SortableTree({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
traverse(activeTabId, 0);
|
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;
|
return result;
|
||||||
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]);
|
}, [activeTabId, nodes, childrenMap, expandedIds, searchResults, filter]);
|
||||||
|
|
||||||
|
|||||||
@@ -142,12 +142,35 @@ function reducer(
|
|||||||
case "SET_FULL_TREE": {
|
case "SET_FULL_TREE": {
|
||||||
const nodes = new Map<number, TreeNodeData>();
|
const nodes = new Map<number, TreeNodeData>();
|
||||||
const childrenMap = new Map<number, number[]>();
|
const childrenMap = new Map<number, number[]>();
|
||||||
for (const n of action.nodes) {
|
for (const raw of action.nodes) {
|
||||||
nodes.set(n.id, n);
|
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) || [];
|
const siblings = childrenMap.get(n.parentId) || [];
|
||||||
siblings.push(n.id);
|
siblings.push(id);
|
||||||
childrenMap.set(n.parentId, siblings);
|
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) {
|
for (const [parentId, ids] of childrenMap) {
|
||||||
ids.sort(
|
ids.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
|
|||||||
@@ -2,69 +2,92 @@ import { prisma } from "@/lib/prisma";
|
|||||||
|
|
||||||
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
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<Map<number, number>> {
|
||||||
|
const map = new Map<number, number>();
|
||||||
|
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.
|
* Get the full catalog tree as a flat list with depth info.
|
||||||
* Returns a flat list with depth info for client-side tree building.
|
|
||||||
* Depth is computed from parentId hierarchy (not stored in DB).
|
|
||||||
*/
|
*/
|
||||||
export async function getTreeFlat(): Promise<TreeNode[]> {
|
export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||||
const [allPages, itemCounts] = await Promise.all([
|
const allPages = await prisma.catalogPages.findMany({
|
||||||
prisma.catalogPages.findMany({
|
orderBy: { orderNum: "asc" },
|
||||||
orderBy: { orderNum: "asc" },
|
});
|
||||||
}),
|
const itemCountMap = await safeItemCounts();
|
||||||
prisma.catalogItems.groupBy({
|
|
||||||
by: ["pageId"],
|
|
||||||
_count: true,
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]));
|
|
||||||
|
|
||||||
// Count children per page
|
|
||||||
const childCountMap = new Map<number, number>();
|
const childCountMap = new Map<number, number>();
|
||||||
for (const p of allPages) {
|
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 pageMap = new Map(allPages.map((p) => [p.id, p]));
|
||||||
const depthCache = new Map<number, number>();
|
const depthCache = new Map<number, number>();
|
||||||
|
|
||||||
function computeDepth(pageId: number): number {
|
function computeDepth(pageId: number): number {
|
||||||
if (depthCache.has(pageId)) return depthCache.get(pageId)!;
|
if (depthCache.has(pageId)) return depthCache.get(pageId)!;
|
||||||
const page = pageMap.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);
|
depthCache.set(pageId, 0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
const d = computeDepth(page.parentId) + 1;
|
const d = computeDepth(parentId) + 1;
|
||||||
depthCache.set(pageId, d);
|
depthCache.set(pageId, d);
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
return allPages.map((p) => ({
|
return allPages.map((p) => {
|
||||||
id: p.id,
|
const id = toInt(p.id);
|
||||||
caption: p.caption,
|
const parentId = toInt(p.parentId, -1);
|
||||||
parentId: p.parentId,
|
return {
|
||||||
depth: computeDepth(p.id),
|
id,
|
||||||
orderNum: p.orderNum,
|
caption: p.caption,
|
||||||
enabled: String(p.enabled),
|
parentId,
|
||||||
visible: String(p.visible),
|
depth: computeDepth(id),
|
||||||
iconImage: p.iconImage,
|
orderNum: toInt(p.orderNum),
|
||||||
iconColor: p.iconColor,
|
enabled: String(p.enabled ?? "0"),
|
||||||
pageLayout: p.pageLayout,
|
visible: String(p.visible ?? "0"),
|
||||||
childCount: childCountMap.get(p.id) ?? 0,
|
iconImage: toInt(p.iconImage),
|
||||||
itemCount: itemCountMap.get(p.id) ?? 0,
|
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.
|
* Build a nested tree structure from flat nodes.
|
||||||
*/
|
*/
|
||||||
import type { NestedTreeNode } from "@/types/catalog";
|
|
||||||
|
|
||||||
export function buildNestedTree(
|
export function buildNestedTree(
|
||||||
flat: TreeNode[],
|
flat: TreeNode[],
|
||||||
rootParentId = -1,
|
rootParentId = -1,
|
||||||
@@ -79,7 +102,6 @@ export function buildNestedTree(
|
|||||||
childrenMap.get(node.parentId)?.push(nested);
|
childrenMap.get(node.parentId)?.push(nested);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attach children
|
|
||||||
for (const node of nodeMap.values()) {
|
for (const node of nodeMap.values()) {
|
||||||
node.children = childrenMap.get(node.id) ?? [];
|
node.children = childrenMap.get(node.id) ?? [];
|
||||||
}
|
}
|
||||||
@@ -99,53 +121,41 @@ export async function createPage(data: {
|
|||||||
iconColor?: number;
|
iconColor?: number;
|
||||||
minRank?: number;
|
minRank?: number;
|
||||||
orderNum?: number;
|
orderNum?: number;
|
||||||
visible?: boolean;
|
visible?: string | boolean;
|
||||||
enabled?: boolean;
|
enabled?: string | boolean;
|
||||||
}): Promise<{ id: number }> {
|
}) {
|
||||||
// Auto-generate captionSave if not provided
|
const visible =
|
||||||
const captionSave =
|
typeof data.visible === "boolean"
|
||||||
data.captionSave ||
|
? data.visible
|
||||||
data.caption
|
? "1"
|
||||||
.toLowerCase()
|
: "0"
|
||||||
.replace(/[^a-z0-9_]/g, "_")
|
: (data.visible ?? "1");
|
||||||
.substring(0, 25);
|
const enabled =
|
||||||
|
typeof data.enabled === "boolean"
|
||||||
|
? data.enabled
|
||||||
|
? "1"
|
||||||
|
: "0"
|
||||||
|
: (data.enabled ?? "1");
|
||||||
|
|
||||||
// Get next orderNum if not provided
|
return prisma.catalogPages.create({
|
||||||
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({
|
|
||||||
data: {
|
data: {
|
||||||
parentId: data.parentId,
|
|
||||||
caption: data.caption,
|
caption: data.caption,
|
||||||
captionSave,
|
parentId: data.parentId,
|
||||||
pageLayout: data.pageLayout ?? "default_3x3",
|
pageLayout: data.pageLayout ?? "default_3x3",
|
||||||
iconImage: data.iconImage ?? 1,
|
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
||||||
iconColor: data.iconColor ?? 1,
|
iconColor: data.iconColor ?? 0,
|
||||||
|
iconImage: data.iconImage ?? 0,
|
||||||
minRank: data.minRank ?? 1,
|
minRank: data.minRank ?? 1,
|
||||||
orderNum,
|
orderNum: data.orderNum ?? 0,
|
||||||
visible: (data.visible ?? true) ? "1" : "0",
|
visible,
|
||||||
enabled: (data.enabled ?? true) ? "1" : "0",
|
enabled,
|
||||||
|
clubOnly: "0",
|
||||||
|
vipOnly: "0",
|
||||||
pageHeadline: "",
|
pageHeadline: "",
|
||||||
pageTeaser: "",
|
pageTeaser: "",
|
||||||
pageSpecial: "",
|
|
||||||
pageText1: "",
|
|
||||||
pageText2: "",
|
|
||||||
pageTextDetails: "",
|
|
||||||
pageTextTeaser: "",
|
|
||||||
includes: "",
|
includes: "",
|
||||||
},
|
},
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return created;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -156,7 +166,6 @@ export async function movePage(
|
|||||||
pageId: number,
|
pageId: number,
|
||||||
newParentId: number,
|
newParentId: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Walk up from newParentId to root — if we hit pageId, it's circular
|
|
||||||
if (newParentId > 0) {
|
if (newParentId > 0) {
|
||||||
let currentId = newParentId;
|
let currentId = newParentId;
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
@@ -180,8 +189,6 @@ export async function movePage(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete a page with cascade or reparent mode.
|
* 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(
|
export async function deletePage(
|
||||||
pageId: number,
|
pageId: number,
|
||||||
@@ -194,27 +201,22 @@ export async function deletePage(
|
|||||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||||
|
|
||||||
if (mode === "reparent") {
|
if (mode === "reparent") {
|
||||||
// Move children to page's parent
|
|
||||||
const result = await prisma.catalogPages.updateMany({
|
const result = await prisma.catalogPages.updateMany({
|
||||||
where: { parentId: pageId },
|
where: { parentId: pageId },
|
||||||
data: { parentId: page.parentId },
|
data: { parentId: page.parentId },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Delete items in this page
|
|
||||||
await prisma.catalogItems.deleteMany({ where: { pageId } });
|
await prisma.catalogItems.deleteMany({ where: { pageId } });
|
||||||
// Delete the page
|
|
||||||
await prisma.catalogPages.delete({ where: { id: pageId } });
|
await prisma.catalogPages.delete({ where: { id: pageId } });
|
||||||
|
|
||||||
return { deletedPages: 1, movedChildren: result.count };
|
return { deletedPages: 1, movedChildren: result.count };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cascade: delete all descendants
|
|
||||||
const deleted = await cascadeDelete(pageId);
|
const deleted = await cascadeDelete(pageId);
|
||||||
return { deletedPages: deleted, movedChildren: 0 };
|
return { deletedPages: deleted, movedChildren: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cascadeDelete(pageId: number): Promise<number> {
|
async function cascadeDelete(pageId: number): Promise<number> {
|
||||||
// Collect all descendant IDs iteratively to avoid N+1 recursive queries
|
|
||||||
const toDelete: number[] = [pageId];
|
const toDelete: number[] = [pageId];
|
||||||
const queue: number[] = [pageId];
|
const queue: number[] = [pageId];
|
||||||
|
|
||||||
@@ -230,9 +232,7 @@ async function cascadeDelete(pageId: number): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete all items and pages in bulk (children first, then parents)
|
|
||||||
await prisma.catalogItems.deleteMany({ where: { pageId: { in: toDelete } } });
|
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--) {
|
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||||
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
await prisma.catalogPages.delete({ where: { id: toDelete[i] } });
|
||||||
}
|
}
|
||||||
@@ -248,9 +248,7 @@ export async function getAncestors(
|
|||||||
): Promise<Array<{ id: number; caption: string }>> {
|
): Promise<Array<{ id: number; caption: string }>> {
|
||||||
const ancestors: Array<{ id: number; caption: string }> = [];
|
const ancestors: Array<{ id: number; caption: string }> = [];
|
||||||
let currentId = pageId;
|
let currentId = pageId;
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
// Safety limit to prevent infinite loops
|
|
||||||
for (let i = 0; i < 20; i++) {
|
|
||||||
const page = await prisma.catalogPages.findUnique({
|
const page = await prisma.catalogPages.findUnique({
|
||||||
where: { id: currentId },
|
where: { id: currentId },
|
||||||
select: { id: true, caption: true, parentId: true },
|
select: { id: true, caption: true, parentId: true },
|
||||||
@@ -260,6 +258,8 @@ export async function getAncestors(
|
|||||||
if (page.parentId <= 0) break;
|
if (page.parentId <= 0) break;
|
||||||
currentId = page.parentId;
|
currentId = page.parentId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ancestors;
|
return ancestors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Safe item-count helper exported for the tree API route. */
|
||||||
|
export { safeItemCounts, toInt };
|
||||||
Reference in new issue
Block a user