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}
-
+