From 7d50b72ade9874c843ea34bfe865884a1abcda05 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 18 Jul 2026 21:03:07 +0200 Subject: [PATCH] feat(catalog): Visual Manager for Builder Club with honest canEdit Mount BC Visual Manager, thread catalogType through tree/editor actions, and stop hardcoding canEdit=true in items/translate tabs. Co-authored-by: Cursor --- src/actions/catalog-bc.ts | 117 ++++++++++- src/app/admin/catalog/page.tsx | 15 ++ .../admin/catalog-manager/breadcrumb-bar.tsx | 7 +- .../catalog-manager-dialog.tsx | 53 +++-- .../admin/catalog-manager/inline-editor.tsx | 156 +++++++++------ .../admin/catalog-manager/root-tabs.tsx | 50 +++-- .../admin/catalog-manager/sortable-tree.tsx | 187 ++++++++++++------ .../catalog-manager/use-catalog-store.tsx | 23 ++- 8 files changed, 457 insertions(+), 151 deletions(-) diff --git a/src/actions/catalog-bc.ts b/src/actions/catalog-bc.ts index c1387c9c..22fa98c8 100644 --- a/src/actions/catalog-bc.ts +++ b/src/actions/catalog-bc.ts @@ -169,19 +169,24 @@ export async function toggleBcPage({ export async function createBcPage(input: { caption: string; parentId: number; - pageLayout: string; + pageLayout?: string; + iconImage?: number; + iconColor?: number; + enabled?: "0" | "1"; + visible?: "0" | "1"; + orderNum?: number; }) { const staff = await requirePermission(PERMS.CATALOG_EDIT); const created = await prisma.catalogPagesBc.create({ data: { caption: input.caption, parentId: input.parentId, - pageLayout: input.pageLayout, - iconColor: 0, - iconImage: 0, - orderNum: 0, - visible: "1", - enabled: "1", + pageLayout: input.pageLayout ?? "default_3x3", + iconColor: input.iconColor ?? 0, + iconImage: input.iconImage ?? 0, + orderNum: input.orderNum ?? 0, + visible: input.visible ?? "1", + enabled: input.enabled ?? "1", pageHeadline: "", pageTeaser: "", }, @@ -194,6 +199,104 @@ export async function createBcPage(input: { targetType: "catalog_page_bc", targetId: created.id, }); + revalidatePath("/admin/catalog"); revalidatePath("/admin/catalog/builder-club"); return { ok: true as const, data: { id: created.id } }; } + +async function moveBcPage(pageId: number, newParentId: number): Promise { + if (newParentId > 0) { + let currentId = newParentId; + for (let i = 0; i < 50; i++) { + if (currentId === pageId) { + throw new Error("Cannot move page: would create a circular hierarchy"); + } + const parent = await prisma.catalogPagesBc.findUnique({ + where: { id: currentId }, + select: { parentId: true }, + }); + if (!parent || parent.parentId <= 0) break; + currentId = parent.parentId; + } + } + await prisma.catalogPagesBc.update({ + where: { id: pageId }, + data: { parentId: newParentId }, + }); +} + +export async function reorderBcTreePage(input: { + pageId: number; + newParentId?: number; + newOrderNum: number; +}) { + await requirePermission(PERMS.CATALOG_EDIT); + if (input.newParentId !== undefined) { + try { + await moveBcPage(input.pageId, input.newParentId); + } catch (err) { + return { + ok: false as const, + error: err instanceof Error ? err.message : "Invalid move", + }; + } + } + await prisma.catalogPagesBc.update({ + where: { id: input.pageId }, + data: { orderNum: input.newOrderNum }, + }); + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: {} }; +} + +export async function deleteBcTreePage(input: { + pageId: number; + mode: "reparent" | "cascade"; +}) { + await requirePermission(PERMS.CATALOG_EDIT); + const page = await prisma.catalogPagesBc.findUnique({ + where: { id: input.pageId }, + select: { parentId: true }, + }); + if (!page) return { ok: false as const, error: "Page not found" }; + + if (input.mode === "reparent") { + await prisma.$transaction([ + prisma.catalogPagesBc.updateMany({ + where: { parentId: input.pageId }, + data: { parentId: page.parentId }, + }), + prisma.catalogItemsBc.deleteMany({ where: { pageId: input.pageId } }), + prisma.catalogPagesBc.delete({ where: { id: input.pageId } }), + ]); + } else { + const toDelete: number[] = [input.pageId]; + const queue: number[] = [input.pageId]; + while (queue.length > 0) { + const children = await prisma.catalogPagesBc.findMany({ + where: { parentId: { in: queue } }, + select: { id: true }, + }); + queue.length = 0; + for (const child of children) { + toDelete.push(child.id); + queue.push(child.id); + } + } + await prisma.$transaction([ + prisma.catalogItemsBc.deleteMany({ + where: { pageId: { in: toDelete } }, + }), + prisma.catalogPagesBc.deleteMany({ + where: { id: { in: toDelete } }, + }), + ]); + } + + await rcon.updateCatalog(); + revalidatePath("/admin/catalog"); + revalidatePath("/admin/catalog/builder-club"); + return { ok: true as const, data: {} }; +} diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index fb9a79e4..04964678 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -208,6 +208,19 @@ export default async function CatalogPagesPage({

+ {canEdit && ( + ({ + id: p.id, + caption: p.caption, + iconImage: p.iconImage, + enabled: p.enabled, + orderNum: 0, + }))} + /> + )} {canEdit && }
@@ -383,6 +396,8 @@ export default async function CatalogPagesPage({ {canEdit && ( ({ id: p.id, caption: p.caption, diff --git a/src/components/admin/catalog-manager/breadcrumb-bar.tsx b/src/components/admin/catalog-manager/breadcrumb-bar.tsx index e410f378..e2f44817 100644 --- a/src/components/admin/catalog-manager/breadcrumb-bar.tsx +++ b/src/components/admin/catalog-manager/breadcrumb-bar.tsx @@ -5,8 +5,9 @@ import { Fragment, useEffect } from "react"; import { useCatalogManager } from "./use-catalog-store"; export function BreadcrumbBar() { - const { state, dispatch } = useCatalogManager(); + const { state, dispatch, catalogType } = useCatalogManager(); const { selectedPageId, ancestors } = state; + const catQs = catalogType === "bc" ? "&catalog=bc" : ""; useEffect(() => { if (!selectedPageId || selectedPageId <= 0) { @@ -14,7 +15,7 @@ export function BreadcrumbBar() { return; } const ac = new AbortController(); - fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, { + fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}${catQs}`, { signal: ac.signal, }) .then((r) => r.json()) @@ -25,7 +26,7 @@ export function BreadcrumbBar() { }) .catch(() => {}); return () => ac.abort(); - }, [selectedPageId, dispatch]); + }, [selectedPageId, dispatch, catQs]); if (!selectedPageId || ancestors.length === 0) return null; diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index 9244d9e0..da5f54f9 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -5,6 +5,7 @@ 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"; @@ -13,7 +14,12 @@ import { InlineEditor, type PageUpdateData } from "./inline-editor"; import { RootTabs } from "./root-tabs"; import { SortableTree } from "./sortable-tree"; import { StatsBar } from "./stats-bar"; -import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store"; +import { + CatalogManagerProvider, + type CatalogManagerMode, + catalogTreeQs, + useCatalogManager, +} from "./use-catalog-store"; /** * Visual Catalog Manager — matches habbo-next UX (full-viewport editor). @@ -21,8 +27,12 @@ import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store"; */ export function CatalogManagerDialog({ initialRoots = [], + catalogType = "normal", + canEdit = false, }: { initialRoots?: RootTab[]; + catalogType?: CatalogManagerMode; + canEdit?: boolean; }) { const [open, setOpen] = useState(false); const [mounted, setMounted] = useState(false); @@ -46,6 +56,9 @@ export function CatalogManagerDialog({ }; }, [open]); + const title = + catalogType === "bc" ? "BC Visual Manager" : "Visual Manager"; + return ( <> {mounted && open ? createPortal( @@ -68,7 +81,10 @@ export function CatalogManagerDialog({ 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)" }} > - + void; initialRoots: RootTab[]; }) { - const { state, dispatch } = useCatalogManager(); + 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"); + 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( @@ -122,7 +141,7 @@ function CatalogManagerInner({ toast.error("Failed to load catalog root pages"); dispatch({ type: "SET_LOADING_ROOT", loading: false }); } - }, [dispatch, state.activeTabId]); + }, [dispatch, state.activeTabId, catQs]); // Seed from SSR immediately, then refresh from API. useEffect(() => { @@ -157,13 +176,20 @@ function CatalogManagerInner({ }; const handleCreateRoot = async () => { + if (!canEdit) return; const caption = prompt("Root page name:"); if (!caption?.trim()) return; try { - const result = await createCatalogPage({ - parentId: -1, - caption: caption.trim(), - }); + 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"); @@ -211,6 +237,11 @@ function CatalogManagerInner({ } }; + const heading = + catalogType === "bc" + ? "BC Visual Catalog Manager" + : "Visual Catalog Manager"; + return (
- Visual Catalog Manager + {heading}
- + + + + ) : itemsLoading ? (
@@ -636,11 +671,18 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
{/* Toolbar */}
- - + {canEdit && ( + <> + + + + )} + {canEdit && ( + + )}
); } diff --git a/src/components/admin/catalog-manager/sortable-tree.tsx b/src/components/admin/catalog-manager/sortable-tree.tsx index e0aca4b3..d20e9cd4 100644 --- a/src/components/admin/catalog-manager/sortable-tree.tsx +++ b/src/components/admin/catalog-manager/sortable-tree.tsx @@ -40,6 +40,13 @@ import { toggleCatalogPage, updateCatalogPage, } from "@/actions/catalog"; +import { + createBcPage, + deleteBcTreePage, + reorderBcTreePage, + toggleBcPage, + updateBcPage, +} from "@/actions/catalog-bc"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -56,7 +63,7 @@ import { TreeItemContent, type TreeItemProps, } from "./tree-item"; -import { useCatalogManager } from "./use-catalog-store"; +import { catalogTreeQs, useCatalogManager } from "./use-catalog-store"; /* ─── Types ──────────────────────────────────────────────── */ @@ -83,7 +90,10 @@ export function SortableTree({ onTreeMutation, nodeUpdate, }: SortableTreeProps) { - const { state, dispatch } = useCatalogManager(); + const { state, dispatch, catalogType, canEdit } = useCatalogManager(); + const catQs = catalogTreeQs(catalogType); + const pageBase = + catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog"; const { confirm, dialog: confirmDialog } = useConfirmDialog(); const { nodes, @@ -137,13 +147,13 @@ export function SortableTree({ const loadChildren = useCallback( async (parentId: number): Promise => { const res = await fetch( - `/api/admin/catalog/tree?parentId=${parentId}`, + `/api/admin/catalog/tree?parentId=${parentId}${catQs}`, ); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error(data.error || "Failed to load pages"); return normalizePages(data.pages || []); }, - [normalizePages], + [normalizePages, catQs], ); const loadTabTree = useCallback(async () => { @@ -188,7 +198,7 @@ export function SortableTree({ setSearching(true); try { const res = await fetch( - `/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`, + `/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`, ); if (res.ok) { const data = await res.json(); @@ -201,7 +211,7 @@ export function SortableTree({ return () => { if (searchTimeout.current) clearTimeout(searchTimeout.current); }; - }, [searchQuery]); + }, [searchQuery, catQs]); // Apply external node updates useEffect(() => { @@ -435,10 +445,13 @@ export function SortableTree({ children: newOrder.map((id) => nodes.get(id)!).filter(Boolean), }); + if (!canEdit) return; + const reorder = + catalogType === "bc" ? reorderBcTreePage : reorderTreePage; try { const results = await Promise.allSettled( newOrder.map((id, idx) => - reorderTreePage({ pageId: id, newOrderNum: idx + 1 }), + reorder({ pageId: id, newOrderNum: idx + 1 }), ), ); const failed = results.filter((r) => r.status === "rejected").length; @@ -450,11 +463,14 @@ export function SortableTree({ loadFullTree(); } } else { + if (!canEdit) return; + const reorder = + catalogType === "bc" ? reorderBcTreePage : reorderTreePage; const newParentId = overNode.parentId; const siblings = childrenMap.get(newParentId) || []; const overIndex = siblings.indexOf(over.id as number); try { - const result = await reorderTreePage({ + const result = await reorder({ pageId: active.id as number, newParentId, newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1, @@ -471,18 +487,25 @@ export function SortableTree({ // ── Actions ───────────────────────────────────────────────── const handleAddSubpage = (parentId: number) => { + if (!canEdit) return; setSubpageParentId(parentId); setSubpageName(""); }; const handleCreateSubpage = async () => { - if (subpageParentId === null || !subpageName.trim()) return; + if (!canEdit || subpageParentId === null || !subpageName.trim()) return; setCreatingSubpage(true); try { - const result = await createCatalogPage({ - parentId: subpageParentId, - caption: subpageName.trim(), - }); + const result = + catalogType === "bc" + ? await createBcPage({ + parentId: subpageParentId, + caption: subpageName.trim(), + }) + : await createCatalogPage({ + parentId: subpageParentId, + caption: subpageName.trim(), + }); if (!result.ok) throw new Error(result.error); dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true }); await loadFullTree(); @@ -500,9 +523,13 @@ export function SortableTree({ }; const handleToggleEnabled = async (id: number, currentEnabled: string) => { + if (!canEdit) return; const newEnabled = currentEnabled === "1" ? "0" : "1"; try { - const result = await toggleCatalogPage({ id, action: "toggleEnabled" }); + const result = + catalogType === "bc" + ? await toggleBcPage({ id, field: "enabled" }) + : await toggleCatalogPage({ id, action: "toggleEnabled" }); if (!result.ok) throw new Error(); dispatch({ type: "UPDATE_NODE", update: { id, enabled: newEnabled } }); onTreeMutation(); @@ -512,9 +539,13 @@ export function SortableTree({ }; const handleToggleVisible = async (id: number, currentVisible: string) => { + if (!canEdit) return; const newVisible = currentVisible === "1" ? "0" : "1"; try { - const result = await toggleCatalogPage({ id, action: "toggleVisible" }); + const result = + catalogType === "bc" + ? await toggleBcPage({ id, field: "visible" }) + : await toggleCatalogPage({ id, action: "toggleVisible" }); if (!result.ok) throw new Error(); dispatch({ type: "UPDATE_NODE", update: { id, visible: newVisible } }); onTreeMutation(); @@ -524,6 +555,7 @@ export function SortableTree({ }; const handleDelete = async (id: number) => { + if (!canEdit) return; const node = nodes.get(id); const ok = await confirm({ title: "Delete page", @@ -532,7 +564,10 @@ export function SortableTree({ }); if (!ok) return; try { - const result = await deleteTreePage({ pageId: id, mode: "reparent" }); + const result = + catalogType === "bc" + ? await deleteBcTreePage({ pageId: id, mode: "reparent" }) + : await deleteTreePage({ pageId: id, mode: "reparent" }); if (!result.ok) throw new Error(); dispatch({ type: "REMOVE_NODE", id }); if (selectedPageId === id) onSelectPage(-1); @@ -544,32 +579,59 @@ export function SortableTree({ }; const handleDuplicate = async (id: number) => { + if (!canEdit) return; try { - const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`); + const res = await fetch( + `/api/admin/catalog/tree?pageId=${id}${catQs}`, + ); if (!res.ok) throw new Error(); const { page } = await res.json(); - const result = await createCatalogPage({ - parentId: page.parentId ?? -1, - caption: `${page.caption} (copy)`, - pageLayout: page.pageLayout ?? "default_3x3", - iconImage: page.iconImage ?? 0, - iconColor: page.iconColor ?? 0, - enabled: "0", - visible: page.visible ?? "1", - minRank: page.minRank ?? 1, - orderNum: (page.orderNum ?? 0) + 1, - }); + const result = + catalogType === "bc" + ? await createBcPage({ + parentId: page.parentId ?? -1, + caption: `${page.caption} (copy)`, + pageLayout: page.pageLayout ?? "default_3x3", + iconImage: page.iconImage ?? 0, + iconColor: page.iconColor ?? 0, + enabled: "0", + visible: page.visible ?? "1", + orderNum: (page.orderNum ?? 0) + 1, + }) + : await createCatalogPage({ + parentId: page.parentId ?? -1, + caption: `${page.caption} (copy)`, + pageLayout: page.pageLayout ?? "default_3x3", + iconImage: page.iconImage ?? 0, + iconColor: page.iconColor ?? 0, + enabled: "0", + visible: page.visible ?? "1", + minRank: page.minRank ?? 1, + orderNum: (page.orderNum ?? 0) + 1, + }); if (!result.ok) throw new Error(result.error); const newId = (result.data as Record).id as number; - await updateCatalogPage({ - id: newId, - pageHeadline: page.pageHeadline ?? "", - pageTeaser: page.pageTeaser ?? "", - pageText1: page.pageText1, - pageText2: page.pageText2, - pageTextDetails: page.pageTextDetails, - pageTextTeaser: page.pageTextTeaser, - }); + if (catalogType === "bc") { + await updateBcPage({ + id: newId, + pageHeadline: page.pageHeadline ?? "", + pageTeaser: page.pageTeaser ?? "", + pageText1: page.pageText1, + pageText2: page.pageText2, + pageTextDetails: page.pageTextDetails, + pageTextTeaser: page.pageTextTeaser, + }); + } else { + await updateCatalogPage({ + id: newId, + pageHeadline: page.pageHeadline ?? "", + pageTeaser: page.pageTeaser ?? "", + pageText1: page.pageText1, + pageText2: page.pageText2, + pageTextDetails: page.pageTextDetails, + pageTextTeaser: page.pageTextTeaser, + }); + } await loadFullTree(); toast.success(`Duplicated "${page.caption}" (items not copied)`); onSelectPage(newId); @@ -580,7 +642,7 @@ export function SortableTree({ }; const handleOpenFull = (id: number) => - window.open(`/admin/catalog/${id}`, "_blank"); + window.open(`${pageBase}/${id}`, "_blank"); // ── Expand/Collapse all ───────────────────────────────────── const handleExpandAll = useCallback(() => { @@ -610,13 +672,16 @@ export function SortableTree({ // ── Bulk actions ──────────────────────────────────────────── const handleBulkEnable = async (enable: boolean) => { + if (!canEdit) return; const ids = [...multiSelectedIds]; const results = await Promise.allSettled( ids.map((id) => { const node = nodes.get(id); if (!node || (node.enabled === "1") === enable) return Promise.resolve(); - return toggleCatalogPage({ id, action: "toggleEnabled" }); + return catalogType === "bc" + ? toggleBcPage({ id, field: "enabled" }) + : toggleCatalogPage({ id, action: "toggleEnabled" }); }), ); for (const id of ids) @@ -633,6 +698,7 @@ export function SortableTree({ }; const handleBulkDelete = async () => { + if (!canEdit) return; const ids = [...multiSelectedIds]; const ok = await confirm({ title: "Delete pages", @@ -640,8 +706,9 @@ export function SortableTree({ confirmLabel: "Delete", }); if (!ok) return; + const del = catalogType === "bc" ? deleteBcTreePage : deleteTreePage; await Promise.allSettled( - ids.map((id) => deleteTreePage({ pageId: id, mode: "reparent" })), + ids.map((id) => del({ pageId: id, mode: "reparent" })), ); dispatch({ type: "CLEAR_MULTI_SELECT" }); await loadFullTree(); @@ -768,25 +835,27 @@ export function SortableTree({ ) : null}
- - - - - -

{sortMode ? "Sort mode" : "Edit mode"}

-
-
+ {canEdit && ( + + + + + +

{sortMode ? "Sort mode" : "Edit mode"}

+
+
+ )} {/* Filters + expand/collapse */} diff --git a/src/components/admin/catalog-manager/use-catalog-store.tsx b/src/components/admin/catalog-manager/use-catalog-store.tsx index 5185cc4c..9b0b69e7 100644 --- a/src/components/admin/catalog-manager/use-catalog-store.tsx +++ b/src/components/admin/catalog-manager/use-catalog-store.tsx @@ -314,19 +314,33 @@ function reducer( // ── Context ─────────────────────────────────────────────────── +export type CatalogManagerMode = "normal" | "bc"; + interface CatalogManagerContextValue { state: CatalogManagerState; dispatch: Dispatch; + catalogType: CatalogManagerMode; + canEdit: boolean; } const CatalogManagerContext = createContext( null, ); -export function CatalogManagerProvider({ children }: { children: ReactNode }) { +export function CatalogManagerProvider({ + children, + catalogType = "normal", + canEdit = false, +}: { + children: ReactNode; + catalogType?: CatalogManagerMode; + canEdit?: boolean; +}) { const [state, dispatch] = useReducer(reducer, initialState); return ( - + {children} ); @@ -340,3 +354,8 @@ export function useCatalogManager() { ); return ctx; } + +/** Append to tree API query strings when in Builder Club mode. */ +export function catalogTreeQs(catalogType: CatalogManagerMode): string { + return catalogType === "bc" ? "&catalog=bc" : ""; +}