Rebuild Visual Manager as portal overlay matching habbo-next UX.
Local Build and Deploy / deploy (push) Failing after 44s

Base UI Dialog broke the Radix-era full-viewport manager. Use a body portal shell like habbo-next behavior, without DialogTitle/Popup, so tree + editor mount and nested pickers stay usable.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 21:26:35 +02:00
1 parent e7a6587b7f
commit b668ab3547
3 files changed
+108 -172

No files matched your search

+8 -6
View File
@@ -9,12 +9,14 @@ import { CatalogActions } from "./catalog-actions";
import { CatalogTable } from "./catalog-table";
import { CatalogViewToggle } from "./catalog-view-toggle";
const CatalogManagerDialog = dynamic(() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({
default: m.CatalogManagerDialog,
}),
),
const CatalogManagerDialog = dynamic(
() =>
import("@/components/admin/catalog-manager/catalog-manager-dialog").then(
(m) => ({
default: m.CatalogManagerDialog,
}),
),
{ ssr: false },
);
export default async function CatalogPagesPage({
params: paramsPromise,
@@ -1,19 +1,11 @@
"use client";
import { LayoutGrid, Plus, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { LayoutGrid, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
import { createCatalogPage } from "@/actions/catalog";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
@@ -24,30 +16,35 @@ 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.
* 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).
*/
export function CatalogManagerDialog() {
const [open, setOpen] = useState(false);
const dirtyRef = useRef(false);
const { confirm, dialog: closeConfirmDialog } = useConfirmDialog();
const [mounted, setMounted] = useState(false);
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);
};
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
const onKey = (e: KeyboardEvent) => {
if (e.key !== "Escape") return;
const tag = (e.target as HTMLElement)?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
setOpen(false);
};
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open]);
return (
<>
{closeConfirmDialog}
<Button
type="button"
variant="outline"
@@ -57,26 +54,27 @@ export function CatalogManagerDialog() {
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
<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>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/50" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-1/2 left-1/2 z-50 flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-lg border bg-card text-card-foreground shadow-lg"
>
<CatalogManagerProvider>
<CatalogManagerInner
open={open}
onOpenChange={setOpen}
/>
</CatalogManagerProvider>
</div>
</div>,
document.body,
)
: null}
</>
);
}
@@ -84,21 +82,11 @@ export function CatalogManagerDialog() {
function CatalogManagerInner({
open,
onOpenChange,
dirtyRef,
}: {
open: boolean;
onOpenChange: (v: boolean) => void | Promise<void>;
dirtyRef: React.MutableRefObject<boolean>;
onOpenChange: (v: boolean) => void;
}) {
const { state, dispatch } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [createOpen, setCreateOpen] = useState(false);
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 });
@@ -138,54 +126,52 @@ function CatalogManagerInner({
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
async function confirmDiscardIfDirty(): Promise<boolean> {
if (!state.editorDirty) return true;
return confirm({
title: "Unsaved changes",
description: "You have unsaved changes. Discard them?",
confirmLabel: "Discard",
cancelLabel: "Keep editing",
});
}
const handleTabClick = async (id: number) => {
if (!(await confirmDiscardIfDirty())) return;
const handleTabClick = (id: number) => {
if (
state.editorDirty &&
!confirm("You have unsaved changes. Discard?")
) {
return;
}
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
};
const handleCreateRoot = async () => {
const caption = newRootName.trim();
if (!caption) return;
setCreating(true);
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result = await createCatalogPage({
parentId: -1,
caption,
caption: caption.trim(),
});
if (!result.ok) throw new Error(result.error);
setCreateOpen(false);
setNewRootName("");
await loadRootPages();
toast.success("Root page created");
} catch {
toast.error("Failed to create root page");
} finally {
setCreating(false);
}
};
const handleSelectPage = async (id: number) => {
if (!(await confirmDiscardIfDirty())) return;
const handleSelectPage = (id: number) => {
if (
state.editorDirty &&
!confirm("You have unsaved changes. Discard?")
) {
return;
}
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
const handleEditTab = async (id: number) => {
const handleEditTab = (id: number) => {
if (state.activeTabId !== id) {
if (!(await confirmDiscardIfDirty())) return;
dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "CLEAR_TREE" });
} else if (!(await confirmDiscardIfDirty())) {
}
if (
state.editorDirty &&
!confirm("You have unsaved changes. Discard?")
) {
return;
}
dispatch({ type: "SELECT_PAGE", id });
@@ -209,25 +195,25 @@ function CatalogManagerInner({
return (
<TooltipProvider delayDuration={300}>
{confirmDialog}
<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>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-4 py-2.5">
<h2
id="visual-catalog-manager-title"
className="flex items-center gap-2 text-base font-semibold leading-none"
>
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
</h2>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={() => onOpenChange(false)}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
</div>
<BreadcrumbBar />
@@ -236,57 +222,10 @@ function CatalogManagerInner({
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={() => setCreateOpen((v) => !v)}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
{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}
@@ -170,21 +170,16 @@ export function RootTabs({
)}
</DragOverlay>
</DndContext>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>New root page</p>
</TooltipContent>
</Tooltip>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
title="New root page"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
);
}