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
@@ -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() {
|
||||
<CatalogManagerInner
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
initialRoots={initialRoots}
|
||||
/>
|
||||
</CatalogManagerProvider>
|
||||
</div>
|
||||
@@ -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<string, unknown>) => ({
|
||||
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 &&
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -142,12 +142,35 @@ function reducer(
|
||||
case "SET_FULL_TREE": {
|
||||
const nodes = new Map<number, TreeNodeData>();
|
||||
const childrenMap = new Map<number, number[]>();
|
||||
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) =>
|
||||
|
||||
Reference in new issue
Block a user