refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
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}
|
||||
|
||||
Reference in new issue
Block a user