From f6871807c96c64a0d2f900736c4824d3396d6094 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 17 Jul 2026 21:21:40 +0200 Subject: [PATCH] Fix Visual Manager for Base UI: layout, tree load, no nested dialogs. Move trigger outside Dialog.Root, use portal confirms and inline create forms, and load the catalog tree atomically so the manager matches the designed full-viewport UX. Co-authored-by: Cursor --- .../catalog-manager-dialog.tsx | 176 ++++++++++++------ .../admin/catalog-manager/sortable-tree.tsx | 122 +++++------- .../catalog-manager/use-catalog-store.tsx | 19 ++ src/components/admin/confirm-dialog.tsx | 83 ++++++--- src/types/catalog.ts | 2 + 5 files changed, 240 insertions(+), 162 deletions(-) diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index bdd32757..a30724eb 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import { LayoutGrid, Plus } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { LayoutGrid, Plus, X } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { createCatalogPage } from "@/actions/catalog"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; @@ -9,7 +9,6 @@ import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, - DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog"; @@ -24,11 +23,31 @@ import { SortableTree } from "./sortable-tree"; import { StatsBar } from "./stats-bar"; import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store"; +/** + * Visual Catalog Manager — full-viewport editor (tree + inline page editor). + * Trigger stays outside Dialog.Root (Base UI); nested dialogs are avoided. + */ export function CatalogManagerDialog() { const [open, setOpen] = useState(false); + const dirtyRef = useRef(false); + const { confirm, dialog: closeConfirmDialog } = useConfirmDialog(); + + const handleOpenChange = async (next: boolean) => { + if (!next && dirtyRef.current) { + const ok = await confirm({ + title: "Unsaved changes", + description: "You have unsaved changes. Discard them?", + confirmLabel: "Discard", + cancelLabel: "Keep editing", + }); + if (!ok) return; + } + setOpen(next); + }; return ( - + <> + {closeConfirmDialog} - - {open ? ( - - - - ) : null} - - + + + {open ? ( + + + + ) : null} + + + ); } function CatalogManagerInner({ open, + onOpenChange, + dirtyRef, }: { open: boolean; - onOpenChange: (v: boolean) => void; + onOpenChange: (v: boolean) => void | Promise; + dirtyRef: React.MutableRefObject; }) { const { state, dispatch } = useCatalogManager(); const { confirm, dialog: confirmDialog } = useConfirmDialog(); @@ -61,6 +96,10 @@ function CatalogManagerInner({ const [newRootName, setNewRootName] = useState(""); const [creating, setCreating] = useState(false); + useEffect(() => { + dirtyRef.current = state.editorDirty; + }, [state.editorDirty, dirtyRef]); + const loadRootPages = useCallback(async () => { dispatch({ type: "SET_LOADING_ROOT", loading: true }); try { @@ -171,11 +210,23 @@ function CatalogManagerInner({ return ( {confirmDialog} - - - - Visual Catalog Manager - + +
+ + + Visual Catalog Manager + + +
@@ -185,11 +236,58 @@ function CatalogManagerInner({ activeTabId={state.activeTabId} onTabClick={handleTabClick} onEditTab={handleEditTab} - onCreateRoot={() => setCreateOpen(true)} + onCreateRoot={() => setCreateOpen((v) => !v)} loading={state.loadingRoot} /> -
+ {createOpen ? ( +
+
+ + setNewRootName(e.target.value)} + placeholder="e.g. Furniture" + autoFocus + className="h-8" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateRoot(); + if (e.key === "Escape") { + setCreateOpen(false); + setNewRootName(""); + } + }} + /> +
+ + +
+ ) : null} + +
- - - - - New root page - -
- - setNewRootName(e.target.value)} - placeholder="e.g. Furniture" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") void handleCreateRoot(); - }} - /> -
- - - - -
-
); } diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index c569a415..0bb74039 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -43,15 +43,7 @@ import { import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, @@ -131,24 +123,7 @@ export function SortableTree({ if (!res.ok) throw new Error(); const data = await res.json(); const pages: TreeNodeData[] = data.pages || []; - - // Build nodes map and children map in one pass - const nodesMap = new Map(); - const childMap = new Map(); - for (const p of pages) { - nodesMap.set(p.id, p); - const siblings = childMap.get(p.parentId) || []; - siblings.push(p.id); - childMap.set(p.parentId, siblings); - } - dispatch({ type: "SET_NODES", nodes: pages }); - // Set all children maps at once - for (const [parentId, childIds] of childMap) { - const children = childIds - .map((id) => nodesMap.get(id)!) - .filter(Boolean); - dispatch({ type: "SET_CHILDREN", parentId, children }); - } + dispatch({ type: "SET_FULL_TREE", nodes: pages }); } catch { toast.error("Failed to load catalog tree"); } finally { @@ -631,6 +606,49 @@ export function SortableTree({ {confirmDialog} {/* Header */}
+ {subpageParentId !== null ? ( +
+ setSubpageName(e.target.value)} + placeholder="Subpage name…" + autoFocus + className="h-7 text-sm" + onKeyDown={(e) => { + if (e.key === "Enter") void handleCreateSubpage(); + if (e.key === "Escape") { + setSubpageParentId(null); + setSubpageName(""); + } + }} + /> + + +
+ ) : null}
@@ -927,58 +945,6 @@ export function SortableTree({
)} - { - if (!open) { - setSubpageParentId(null); - setSubpageName(""); - } - }} - > - - - New subpage - -
- - setSubpageName(e.target.value)} - placeholder="e.g. Sofas" - autoFocus - onKeyDown={(e) => { - if (e.key === "Enter") void handleCreateSubpage(); - }} - /> -
- - - - -
-
); } diff --git a/src/components/admin/catalog-manager/use-catalog-store.tsx b/src/components/admin/catalog-manager/use-catalog-store.tsx index 4b1aafca..4b9609e6 100644 --- a/src/components/admin/catalog-manager/use-catalog-store.tsx +++ b/src/components/admin/catalog-manager/use-catalog-store.tsx @@ -68,6 +68,7 @@ export type CatalogAction = | { type: "EXPAND_PATH"; ids: number[] } | { type: "COLLAPSE_ALL" } | { type: "SET_NODES"; nodes: TreeNodeData[] } + | { type: "SET_FULL_TREE"; nodes: TreeNodeData[] } | { type: "SET_CHILDREN"; parentId: number; children: TreeNodeData[] } | { type: "UPDATE_NODE"; update: TreeNodeUpdate } | { type: "REMOVE_NODE"; id: number } @@ -138,6 +139,24 @@ function reducer( for (const n of action.nodes) next.set(n.id, n); return { ...state, nodes: next }; } + case "SET_FULL_TREE": { + const nodes = new Map(); + const childrenMap = new Map(); + for (const n of action.nodes) { + nodes.set(n.id, n); + const siblings = childrenMap.get(n.parentId) || []; + siblings.push(n.id); + childrenMap.set(n.parentId, siblings); + } + for (const [parentId, ids] of childrenMap) { + ids.sort( + (a, b) => + (nodes.get(a)?.orderNum ?? 0) - (nodes.get(b)?.orderNum ?? 0), + ); + childrenMap.set(parentId, ids); + } + return { ...state, nodes, childrenMap }; + } case "SET_CHILDREN": { const nextNodes = new Map(state.nodes); const nextChildren = new Map(state.childrenMap); diff --git a/src/components/admin/confirm-dialog.tsx b/src/components/admin/confirm-dialog.tsx index 1d5e7d66..2e324d0f 100644 --- a/src/components/admin/confirm-dialog.tsx +++ b/src/components/admin/confirm-dialog.tsx @@ -1,15 +1,9 @@ "use client"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; export type ConfirmDialogProps = { open: boolean; @@ -23,6 +17,10 @@ export type ConfirmDialogProps = { onConfirm: () => void | Promise; }; +/** + * Portal-based confirm modal (not Dialog.Root) so it can safely stack + * above Visual Manager / other Base UI dialogs without nested-root bugs. + */ export function ConfirmDialog({ open, onOpenChange, @@ -35,8 +33,20 @@ export function ConfirmDialog({ onConfirm, }: ConfirmDialogProps) { const [busy, setBusy] = useState(false); + const [mounted, setMounted] = useState(false); const isLoading = loading ?? busy; + useEffect(() => setMounted(true), []); + + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !isLoading) onOpenChange(false); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, isLoading, onOpenChange]); + async function handleConfirm() { setBusy(true); try { @@ -47,20 +57,38 @@ export function ConfirmDialog({ } } - return ( - { - if (isLoading) return; - onOpenChange(next); - }} - > - - - {title} - {description} - - + if (!open || !mounted) return null; + + return createPortal( +
+ - - -
+
+
+
, + document.body, ); } diff --git a/src/types/catalog.ts b/src/types/catalog.ts index 3f4a04d7..442131c5 100644 --- a/src/types/catalog.ts +++ b/src/types/catalog.ts @@ -21,6 +21,8 @@ export interface TreeNodeData { id: number; caption: string; parentId: number; + depth?: number; + orderNum?: number; enabled: string; visible: string; iconImage: number;