refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -1,6 +1,7 @@
"use client";
import { LayoutGrid, X } from "lucide-react";
import { useTranslations } from "next-intl";
import { useCallback, useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { toast } from "sonner";
@@ -8,6 +9,9 @@ import { createCatalogPage } from "@/actions/catalog";
import { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { CatalogSelection } from "@/features/catalog/client/catalog-selection-url";
import { useCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { useLatestRequest } from "@/features/catalog/client/use-latest-request";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
@@ -21,12 +25,49 @@ import {
useCatalogManager,
} from "./use-catalog-store";
const EMPTY_ROOTS: RootTab[] = [];
interface CatalogManagerProps {
initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
totals?: CatalogStatsTotals | null;
}
export function CatalogManagerWorkspace({
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
}: CatalogManagerProps) {
const t = useTranslations("pages.admin.catalog.workspace");
return (
<section
aria-label={t("manager")}
className="flex h-[calc(100dvh-12rem)] min-h-[32rem] min-w-0 flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]"
>
<CatalogManagerProvider
key={catalogType}
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open
embedded
initialRoots={initialRoots}
totals={totals}
/>
</CatalogManagerProvider>
</section>
);
}
/**
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
* Portal overlay (not Base UI Dialog) so the tree + editor mount reliably.
*/
export function CatalogManagerDialog({
initialRoots = [],
initialRoots = EMPTY_ROOTS,
catalogType = "normal",
canEdit = false,
totals = null,
@@ -44,17 +85,19 @@ export function CatalogManagerDialog({
useEffect(() => {
if (!open) return;
const prev = document.body.style.overflow;
const previousFocus = document.activeElement as HTMLElement | null;
const focusFrame = requestAnimationFrame(() =>
document
.querySelector<HTMLElement>(
'[aria-labelledby="visual-catalog-manager-title"] button[aria-label="Close"]',
)
?.focus(),
);
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 () => {
cancelAnimationFrame(focusFrame);
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
previousFocus?.focus();
};
}, [open]);
@@ -103,24 +146,81 @@ export function CatalogManagerDialog({
}
function CatalogManagerInner({
embedded = false,
open,
onOpenChange,
initialRoots,
totals,
}: {
embedded?: boolean;
open: boolean;
onOpenChange: (v: boolean) => void;
onOpenChange?: (v: boolean) => void;
initialRoots: RootTab[];
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const rootRequests = useLatestRequest();
const tActions = useTranslations("pages.admin.actions");
const t = useTranslations("pages.admin.catalog.workspace");
const mayLeave = useCallback(
() => !state.editorDirty || window.confirm(tActions("leaveUnsaved")),
[state.editorDirty, tActions],
);
const restoreSelection = useCallback(
(selection: CatalogSelection) => {
dispatch({ type: "RESTORE_SELECTION", ...selection });
},
[dispatch],
);
const saveSelectionUrl = useCatalogNavigation({
enabled: embedded,
restore: restoreSelection,
mayLeave,
});
const close = useCallback(() => {
if (mayLeave()) onOpenChange?.(false);
}, [mayLeave, onOpenChange]);
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (embedded || event.key !== "Escape" || event.defaultPrevented) return;
const target = event.target as HTMLElement;
const focusedDialog = target.closest?.("[role=dialog]");
if (
focusedDialog &&
focusedDialog.getAttribute("aria-labelledby") !==
"visual-catalog-manager-title"
)
return;
if (target.closest?.("[role=listbox], [role=menu], [role=combobox]"))
return;
event.preventDefault();
close();
};
const onBeforeUnload = (event: BeforeUnloadEvent) => {
if (state.editorDirty) {
event.preventDefault();
event.returnValue = "";
}
};
window.addEventListener("keydown", onKey);
window.addEventListener("beforeunload", onBeforeUnload);
return () => {
window.removeEventListener("keydown", onKey);
window.removeEventListener("beforeunload", onBeforeUnload);
};
}, [close, state.editorDirty, embedded]);
const loadRootPages = useCallback(async () => {
const request = rootRequests.start();
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`, {
signal: request.signal,
});
const data = await res.json().catch(() => ({}));
if (!request.isCurrent()) return;
if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
@@ -132,51 +232,32 @@ function CatalogManagerInner({
}),
);
dispatch({ type: "SET_ROOT_PAGES", pages });
const activeStillValid =
state.activeTabId !== null &&
pages.some((p) => p.id === state.activeTabId);
if (pages.length > 0 && !activeStillValid) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
} catch {
if (!request.isCurrent()) return;
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
}
}, [dispatch, state.activeTabId, catQs]);
}, [dispatch, catQs, rootRequests]);
// Seed from SSR immediately, then refresh from API.
// Seed once on opening, then refresh independently of category selection.
useEffect(() => {
if (!open) return;
if (initialRoots.length > 0 && state.rootPages.length === 0) {
const pages = initialRoots.map((p) => ({
...p,
id: Number(p.id),
enabled: String(p.enabled),
}));
dispatch({ type: "SET_ROOT_PAGES", pages });
if (state.activeTabId === null && pages[0]) {
dispatch({ type: "SET_ACTIVE_TAB", id: pages[0].id });
}
}
if (initialRoots.length)
dispatch({ type: "SET_ROOT_PAGES", pages: initialRoots });
void loadRootPages();
}, [
open,
initialRoots,
state.rootPages.length,
state.activeTabId,
dispatch,
loadRootPages,
]);
return () => rootRequests.cancel();
}, [open, initialRoots, dispatch, loadRootPages, rootRequests]);
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
if (id === state.activeTabId) return;
if (!mayLeave()) {
return;
}
saveSelectionUrl({ rootId: id, pageId: null });
dispatch({ type: "SET_ACTIVE_TAB", id });
};
@@ -204,25 +285,24 @@ function CatalogManagerInner({
};
const handleSelectPage = (id: number) => {
if (id === state.selectedPageId) return;
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
saveSelectionUrl({ rootId: state.activeTabId, pageId: id > 0 ? id : null });
dispatch({ type: "SELECT_PAGE", id: id > 0 ? id : null });
};
const handleEditTab = (id: number) => {
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
if (id === state.selectedPageId && id === state.activeTabId) return;
if (!mayLeave()) return;
saveSelectionUrl({ rootId: id, pageId: id });
if (state.activeTabId !== id) dispatch({ type: "SET_ACTIVE_TAB", id });
dispatch({ type: "SELECT_PAGE", id });
};
const handleEditorSaved = (updated: PageUpdateData) => {
dispatch({ type: "UPDATE_NODE", update: { ...updated } });
dispatch({ type: "SET_DIRTY", dirty: false });
const rootIdx = state.rootPages.findIndex((p) => p.id === updated.id);
if (rootIdx !== -1) {
@@ -254,19 +334,21 @@ function CatalogManagerInner({
<LayoutGrid className="h-4 w-4" />
{heading}
</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>
{!embedded && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0"
onClick={close}
aria-label="Close"
>
<X className="h-4 w-4" />
</Button>
)}
</div>
<BreadcrumbBar />
<BreadcrumbBar onSelectPage={handleSelectPage} />
<RootTabs
tabs={state.rootPages}
@@ -274,6 +356,7 @@ function CatalogManagerInner({
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
onMutation={loadRootPages}
loading={state.loadingRoot}
/>
@@ -281,17 +364,28 @@ function CatalogManagerInner({
className="flex min-h-0 flex-1 overflow-hidden"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
<div
className="flex min-h-0 min-w-0 flex-1 flex-col"
className={`${state.selectedPageId ? "hidden md:flex" : "flex"} min-h-0 min-w-0 flex-1 md:flex-none [&>div]:w-full md:[&>div]:w-80`}
>
<SortableTree
activeTabId={state.activeTabId}
selectedPageId={state.selectedPageId}
onSelectPage={handleSelectPage}
onTreeMutation={loadRootPages}
nodeUpdate={null}
/>
</div>
<div
className={`${state.selectedPageId ? "flex" : "hidden md:flex"} min-h-0 min-w-0 flex-1 flex-col`}
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<Button
variant="ghost"
className="shrink-0 self-start md:hidden"
onClick={() => handleSelectPage(0)}
>
{t("backToCategories")}
</Button>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}