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