Fix Visual Manager for Base UI: layout, tree load, no nested dialogs.
Local Build and Deploy / deploy (push) Successful in 1m9s

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 <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 21:21:40 +02:00
1 parent 2ff5b47104
commit f6871807c9
5 files changed
+240 -162

No files matched your search

@@ -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 (
<Dialog open={open} onOpenChange={setOpen} disablePointerDismissal>
<>
{closeConfirmDialog}
<Button
type="button"
variant="outline"
@@ -38,22 +57,38 @@ export function CatalogManagerDialog() {
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
<DialogContent className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]">
{open ? (
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
) : null}
</DialogContent>
</Dialog>
<Dialog
open={open}
onOpenChange={handleOpenChange}
disablePointerDismissal
>
<DialogContent
showCloseButton={false}
className="flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col gap-0 overflow-hidden p-0 sm:max-w-[98vw]"
>
{open ? (
<CatalogManagerProvider>
<CatalogManagerInner
open={open}
onOpenChange={handleOpenChange}
dirtyRef={dirtyRef}
/>
</CatalogManagerProvider>
) : null}
</DialogContent>
</Dialog>
</>
);
}
function CatalogManagerInner({
open,
onOpenChange,
dirtyRef,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
onOpenChange: (v: boolean) => void | Promise<void>;
dirtyRef: React.MutableRefObject<boolean>;
}) {
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 (
<TooltipProvider delayDuration={300}>
{confirmDialog}
<DialogHeader className="px-4 py-2.5 border-b shrink-0">
<DialogTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</DialogTitle>
<DialogHeader className="px-4 py-2.5 border-b shrink-0 space-y-0">
<div className="flex items-center justify-between gap-2">
<DialogTitle className="text-base flex items-center gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</DialogTitle>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => void onOpenChange(false)}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
</DialogHeader>
<BreadcrumbBar />
@@ -185,11 +236,58 @@ function CatalogManagerInner({
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={() => setCreateOpen(true)}
onCreateRoot={() => setCreateOpen((v) => !v)}
loading={state.loadingRoot}
/>
<div className="flex flex-1 min-h-0">
{createOpen ? (
<div className="flex shrink-0 items-end gap-2 border-b bg-muted/30 px-4 py-2.5">
<div className="min-w-0 flex-1 space-y-1">
<Label htmlFor="root-caption" className="text-xs">
New root page
</Label>
<Input
id="root-caption"
value={newRootName}
onChange={(e) => 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("");
}
}}
/>
</div>
<Button
type="button"
variant="outline"
size="sm"
className="h-8"
onClick={() => {
setCreateOpen(false);
setNewRootName("");
}}
>
Cancel
</Button>
<Button
type="button"
size="sm"
className="h-8"
disabled={creating || !newRootName.trim()}
onClick={handleCreateRoot}
>
<Plus className="mr-1 h-4 w-4" />
Create
</Button>
</div>
) : null}
<div className="flex min-h-0 flex-1 overflow-hidden">
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
@@ -204,44 +302,6 @@ function CatalogManagerInner({
</div>
<StatsBar />
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New root page</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="root-caption">Name</Label>
<Input
id="root-caption"
value={newRootName}
onChange={(e) => setNewRootName(e.target.value)}
placeholder="e.g. Furniture"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateRoot();
}}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
>
Cancel
</Button>
<Button
type="button"
disabled={creating || !newRootName.trim()}
onClick={handleCreateRoot}
>
<Plus className="mr-1 h-4 w-4" />
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</TooltipProvider>
);
}
@@ -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<number, TreeNodeData>();
const childMap = new Map<number, number[]>();
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 */}
<div className="border-b px-3 py-2 space-y-2 shrink-0">
{subpageParentId !== null ? (
<div className="flex items-center gap-1.5 rounded-md border bg-muted/40 p-1.5">
<Input
value={subpageName}
onChange={(e) => 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("");
}
}}
/>
<Button
type="button"
size="sm"
className="h-7 shrink-0 px-2"
disabled={creatingSubpage || !subpageName.trim()}
onClick={handleCreateSubpage}
>
{creatingSubpage ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Plus className="h-3.5 w-3.5" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 shrink-0 px-2"
onClick={() => {
setSubpageParentId(null);
setSubpageName("");
}}
>
<X className="h-3.5 w-3.5" />
</Button>
</div>
) : null}
<div className="flex items-center gap-1.5">
<div className="relative flex-1">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
@@ -927,58 +945,6 @@ export function SortableTree({
</div>
)}
<Dialog
open={subpageParentId !== null}
onOpenChange={(open) => {
if (!open) {
setSubpageParentId(null);
setSubpageName("");
}
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New subpage</DialogTitle>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="subpage-caption">Name</Label>
<Input
id="subpage-caption"
value={subpageName}
onChange={(e) => setSubpageName(e.target.value)}
placeholder="e.g. Sofas"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") void handleCreateSubpage();
}}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
setSubpageParentId(null);
setSubpageName("");
}}
>
Cancel
</Button>
<Button
type="button"
disabled={creatingSubpage || !subpageName.trim()}
onClick={handleCreateSubpage}
>
{creatingSubpage ? (
<Loader2 className="mr-1 h-4 w-4 animate-spin" />
) : (
<Plus className="mr-1 h-4 w-4" />
)}
Create
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -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<number, TreeNodeData>();
const childrenMap = new Map<number, number[]>();
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);
+57 -26
View File
@@ -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<void>;
};
/**
* 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 (
<Dialog
open={open}
onOpenChange={(next) => {
if (isLoading) return;
onOpenChange(next);
}}
>
<DialogContent showCloseButton={!isLoading}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
if (!open || !mounted) return null;
return createPortal(
<div className="fixed inset-0 z-[200] flex items-center justify-center p-4">
<button
type="button"
aria-label="Dismiss"
className="absolute inset-0 bg-black/50"
disabled={isLoading}
onClick={() => {
if (!isLoading) onOpenChange(false);
}}
/>
<div
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-desc"
className="relative z-10 w-full max-w-md rounded-lg border bg-card p-6 text-card-foreground shadow-lg"
>
<div className="flex flex-col gap-2 text-left">
<h2
id="confirm-dialog-title"
className="text-lg font-semibold leading-none"
>
{title}
</h2>
<p id="confirm-dialog-desc" className="text-sm text-muted-foreground">
{description}
</p>
</div>
<div className="mt-6 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<Button
type="button"
variant="outline"
@@ -74,12 +102,15 @@ export function ConfirmDialog({
variant={variant === "danger" ? "destructive" : "default"}
disabled={isLoading}
onClick={handleConfirm}
className={cn(variant === "danger" && "bg-destructive text-white")}
autoFocus
>
{confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</div>,
document.body,
);
}
+2
View File
@@ -21,6 +21,8 @@ export interface TreeNodeData {
id: number;
caption: string;
parentId: number;
depth?: number;
orderNum?: number;
enabled: string;
visible: string;
iconImage: number;