From b668ab3547d859c9384f3f8ac65aa34955a1c3e2 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Fri, 17 Jul 2026 21:26:33 +0200 Subject: [PATCH] Rebuild Visual Manager as portal overlay matching habbo-next UX. 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 --- src/app/admin/catalog/page.tsx | 14 +- .../catalog-manager-dialog.tsx | 241 +++++++----------- .../admin/catalog-manager/root-tabs.tsx | 25 +- 3 files changed, 108 insertions(+), 172 deletions(-) diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index ca633b61..572e2407 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -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, diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index a30724eb..9cc2207a 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -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} - - - {open ? ( - - - - ) : null} - - + {mounted && open + ? createPortal( +
+
+
+ + + +
+
, + document.body, + ) + : null} ); } @@ -84,21 +82,11 @@ export function CatalogManagerDialog() { function CatalogManagerInner({ open, onOpenChange, - dirtyRef, }: { open: boolean; - onOpenChange: (v: boolean) => void | Promise; - dirtyRef: React.MutableRefObject; + 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 { - 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 ( - {confirmDialog} - -
- - - Visual Catalog Manager - - -
-
+
+

+ + Visual Catalog Manager +

+ +
@@ -236,57 +222,10 @@ function CatalogManagerInner({ activeTabId={state.activeTabId} onTabClick={handleTabClick} onEditTab={handleEditTab} - onCreateRoot={() => setCreateOpen((v) => !v)} + onCreateRoot={handleCreateRoot} 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

-
-
+
); }