Files
EpicNext-Cms/src/components/admin/catalog-manager/catalog-manager-dialog.tsx
T
openhands 58c35a2920
CI / runtime-diagnostics (push) Skipped
CI / check (push) Successful in 32s
CI / release (push) Skipped
CI / deploy (push) Successful in 58s
feat: enforce no hardcoded colors across entire CMS
Added scripts/check-admin-colors.mjs — scans all src/ files for:
- text-white, text-black (use theme text vars)
- bg-white, bg-black (use theme background/overlay vars)
- bg/text/border/ring with gray/slate/zinc/stone palette
- bg/text/border/ring with red/green/blue/etc palette

Fixed 21 violations across 11 files:
- Overlays: bg-black/* → bg-foreground/*
- Text: text-white → text-primary-foreground
- Backgrounds: bg-white/10 → bg-background/10
- Green accents: bg-green-* → bg-primary
- Red accents: bg-red-* → bg-destructive

Integrated into:
- lint-staged: runs on every *.ts/*.tsx commit
- vitest: src/lib/no-hardcoded-colors.test.ts replaces old audit test
- Allowlist: shadcn/ui primitives (button, badge, dialog) + 4 graphical files
2026-09-01 19:33:50 +02:00

306 lines
8.3 KiB
TypeScript

"use client";
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 { createBcPage } from "@/actions/catalog-bc";
import { Button } from "@/components/ui/button";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog";
import { BreadcrumbBar } from "./breadcrumb-bar";
import { InlineEditor, type PageUpdateData } from "./inline-editor";
import { RootTabs } from "./root-tabs";
import { SortableTree } from "./sortable-tree";
import { type CatalogStatsTotals, StatsBar } from "./stats-bar";
import {
type CatalogManagerMode,
CatalogManagerProvider,
catalogTreeQs,
useCatalogManager,
} from "./use-catalog-store";
/**
* 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 = [],
catalogType = "normal",
canEdit = false,
totals = null,
}: {
initialRoots?: RootTab[];
catalogType?: CatalogManagerMode;
canEdit?: boolean;
totals?: CatalogStatsTotals | null;
}) {
const [open, setOpen] = useState(false);
const [mounted, setMounted] = useState(false);
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]);
const title = catalogType === "bc" ? "BC Visual Manager" : "Visual Manager";
return (
<>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => setOpen(true)}
>
<LayoutGrid className="h-4 w-4" />
{title}
</Button>
{mounted && open
? createPortal(
<div className="fixed inset-0 z-[200]">
<div className="absolute inset-0 bg-foreground/70" />
<div
role="dialog"
aria-modal="true"
aria-labelledby="visual-catalog-manager-title"
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<CatalogManagerProvider
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open={open}
onOpenChange={setOpen}
initialRoots={initialRoots}
totals={totals}
/>
</CatalogManagerProvider>
</div>
</div>,
document.body,
)
: null}
</>
);
}
function CatalogManagerInner({
open,
onOpenChange,
initialRoots,
totals,
}: {
open: boolean;
onOpenChange: (v: boolean) => void;
initialRoots: RootTab[];
totals?: CatalogStatsTotals | null;
}) {
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const loadRootPages = useCallback(async () => {
dispatch({ type: "SET_LOADING_ROOT", loading: true });
try {
const res = await fetch(`/api/admin/catalog/tree?parentId=-1${catQs}`);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "Failed to load roots");
const pages: RootTab[] = (data.pages || []).map(
(p: Record<string, unknown>) => ({
id: Number(p.id),
caption: String(p.caption ?? ""),
iconImage: Number(p.iconImage) || 0,
enabled: String(p.enabled ?? "0"),
orderNum: Number(p.orderNum) || 0,
}),
);
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 {
toast.error("Failed to load catalog root pages");
dispatch({ type: "SET_LOADING_ROOT", loading: false });
}
}, [dispatch, state.activeTabId, catQs]);
// Seed from SSR immediately, then refresh from API.
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 });
}
}
void loadRootPages();
}, [
open,
initialRoots,
state.rootPages.length,
state.activeTabId,
dispatch,
loadRootPages,
]);
useEffect(() => {
if (!open) dispatch({ type: "ON_DIALOG_CLOSE" });
}, [open, dispatch]);
const handleTabClick = (id: number) => {
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
dispatch({ type: "SET_ACTIVE_TAB", id });
};
const handleCreateRoot = async () => {
if (!canEdit) return;
const caption = prompt("Root page name:");
if (!caption?.trim()) return;
try {
const result =
catalogType === "bc"
? await createBcPage({
parentId: -1,
caption: caption.trim(),
})
: await createCatalogPage({
parentId: -1,
caption: caption.trim(),
});
if (!result.ok) throw new Error(result.error);
await loadRootPages();
toast.success("Root page created");
} catch {
toast.error("Failed to create root page");
}
};
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 = (id: number) => {
if (state.activeTabId !== id) {
dispatch({ type: "SET_ACTIVE_TAB", id });
}
if (state.editorDirty && !confirm("You have unsaved changes. Discard?")) {
return;
}
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) {
const pages = [...state.rootPages];
pages[rootIdx] = {
...pages[rootIdx],
caption: updated.caption,
enabled: updated.enabled,
};
dispatch({ type: "SET_ROOT_PAGES", pages });
}
};
const heading =
catalogType === "bc"
? "BC Visual Catalog Manager"
: "Visual Catalog Manager";
return (
<TooltipProvider delayDuration={300}>
<div
className="flex shrink-0 items-center justify-between gap-2 border-b border-[var(--admin-border)] px-4 py-2.5"
style={{ backgroundColor: "var(--admin-surface)" }}
>
<h2
id="visual-catalog-manager-title"
className="flex items-center gap-2 text-base font-semibold leading-none"
>
<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>
</div>
<BreadcrumbBar />
<RootTabs
tabs={state.rootPages}
activeTabId={state.activeTabId}
onTabClick={handleTabClick}
onEditTab={handleEditTab}
onCreateRoot={handleCreateRoot}
loading={state.loadingRoot}
/>
<div
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"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<InlineEditor
pageId={state.selectedPageId}
onSaved={handleEditorSaved}
/>
</div>
</div>
<StatsBar totals={totals} />
</TooltipProvider>
);
}