Merge pull request 'feat(catalog): Visual Manager BC + honest canEdit' (#2) from feat/visual-manager-bc-canedit into main
Local Build and Deploy / deploy (push) Successful in 1m27s

Reviewed-on: #2
This commit is contained in:
Simo committed 2026-07-18 21:04:39 +02:00
commit b7fd19b6d9
8 files changed
+457 -151

No files matched your search

+110 -7
View File
@@ -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<void> {
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: {} };
}
+15
View File
@@ -208,6 +208,19 @@ export default async function CatalogPagesPage({
</p>
<div className="flex items-center gap-2">
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
{canEdit && (
<CatalogManagerDialog
catalogType="bc"
canEdit={canEdit}
initialRoots={bcRootPages.map((p) => ({
id: p.id,
caption: p.caption,
iconImage: p.iconImage,
enabled: p.enabled,
orderNum: 0,
}))}
/>
)}
{canEdit && <CatalogActions catalogType="bc" />}
</div>
</div>
@@ -383,6 +396,8 @@ export default async function CatalogPagesPage({
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
{canEdit && (
<CatalogManagerDialog
catalogType="normal"
canEdit={canEdit}
initialRoots={rootPages.map((p) => ({
id: p.id,
caption: p.caption,
@@ -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;
@@ -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 (
<>
<Button
@@ -55,7 +68,7 @@ export function CatalogManagerDialog({
onClick={() => setOpen(true)}
>
<LayoutGrid className="h-4 w-4" />
Visual Manager
{title}
</Button>
{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)" }}
>
<CatalogManagerProvider>
<CatalogManagerProvider
catalogType={catalogType}
canEdit={canEdit}
>
<CatalogManagerInner
open={open}
onOpenChange={setOpen}
@@ -93,12 +109,15 @@ function CatalogManagerInner({
onOpenChange: (v: boolean) => 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 (
<TooltipProvider delayDuration={300}>
<div
@@ -222,7 +253,7 @@ function CatalogManagerInner({
className="flex items-center gap-2 text-base font-semibold leading-none"
>
<LayoutGrid className="h-4 w-4" />
Visual Catalog Manager
{heading}
</h2>
<Button
type="button"
@@ -15,6 +15,7 @@ import Link from "next/link";
import { useCallback, useEffect, useMemo, useState } from "react";
import { toast } from "sonner";
import { updateCatalogPage } from "@/actions/catalog";
import { updateBcPage } from "@/actions/catalog-bc";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogItemsTable } from "@/app/admin/catalog/[id]/catalog-items-table";
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
@@ -45,7 +46,7 @@ import {
} from "@/lib/catalog-layouts";
import { cn } from "@/lib/utils";
import { ImagePreview } from "./image-preview";
import { useCatalogManager } from "./use-catalog-store";
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
interface PageDetail {
id: number;
@@ -104,7 +105,12 @@ interface InlineEditorProps {
type FormState = Record<string, string | number | null>;
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
const { dispatch } = useCatalogManager();
const { dispatch, catalogType, canEdit } = useCatalogManager();
const catQs = catalogTreeQs(catalogType);
const pageHref =
catalogType === "bc"
? `/admin/catalog/builder-club/${pageId}`
: `/admin/catalog/${pageId}`;
const [page, setPage] = useState<PageDetail | null>(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
@@ -133,48 +139,53 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
function onKeyDown(e: KeyboardEvent) {
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
e.preventDefault();
if (isDirty && !saving && page) handleSave();
if (canEdit && isDirty && !saving && page) handleSave();
}
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}); // eslint-disable-line react-hooks/exhaustive-deps
const loadPage = useCallback(async (id: number) => {
setLoading(true);
try {
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`);
if (!res.ok) throw new Error();
const data = await res.json();
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank,
clubOnly: String(p.clubOnly),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || "",
pageTeaser: p.pageTeaser || "",
pageSpecial: p.pageSpecial || "",
pageText1: p.pageText1 || "",
pageText2: p.pageText2 || "",
pageTextDetails: p.pageTextDetails || "",
pageTextTeaser: p.pageTextTeaser || "",
};
setForm(formData);
setOriginal(formData);
} catch {
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
}
}, []);
const loadPage = useCallback(
async (id: number) => {
setLoading(true);
try {
const res = await fetch(
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
);
if (!res.ok) throw new Error();
const data = await res.json();
const p = data.page as PageDetail;
setPage(p);
const formData: FormState = {
caption: p.caption,
pageLayout: p.pageLayout,
enabled: String(p.enabled),
visible: String(p.visible),
minRank: p.minRank ?? 0,
clubOnly: String(p.clubOnly ?? "0"),
orderNum: p.orderNum,
iconImage: p.iconImage,
iconColor: p.iconColor,
pageHeadline: p.pageHeadline || "",
pageTeaser: p.pageTeaser || "",
pageSpecial: p.pageSpecial || "",
pageText1: p.pageText1 || "",
pageText2: p.pageText2 || "",
pageTextDetails: p.pageTextDetails || "",
pageTextTeaser: p.pageTextTeaser || "",
};
setForm(formData);
setOriginal(formData);
} catch {
toast.error("Failed to load page");
setPage(null);
} finally {
setLoading(false);
}
},
[catQs],
);
const loadItemsData = useCallback(async (id: number) => {
setItemsLoading(true);
@@ -211,8 +222,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
}
}, [pageId, loadPage]);
// Lazy-load items
// Lazy-load items (shop catalog only — BC items live on the detail page)
useEffect(() => {
if (catalogType === "bc") return;
if (
(activeTab === "items" || activeTab === "translate") &&
!itemsLoaded &&
@@ -222,17 +234,28 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
) {
loadItemsData(pageId);
}
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData]);
}, [
activeTab,
itemsLoaded,
itemsLoading,
pageId,
loadItemsData,
catalogType,
]);
function update(key: string, value: string | number | null) {
if (!canEdit) return;
setForm((prev) => ({ ...prev, [key]: value }));
}
async function handleSave() {
if (!page) return;
if (!page || !canEdit) return;
setSaving(true);
try {
const result = await updateCatalogPage({ id: page.id, ...form });
const result =
catalogType === "bc"
? await updateBcPage({ id: page.id, ...form })
: await updateCatalogPage({ id: page.id, ...form });
if (!result.ok) throw new Error(result.error || "Failed to save");
toast.success("Page updated & catalog synced");
setOriginal({ ...form });
@@ -323,7 +346,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
</p>
</div>
</div>
<Link href={`/admin/catalog/${page.id}`} target="_blank">
<Link href={pageHref} target="_blank">
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
<ExternalLink className="h-3 w-3" />
Full editor
@@ -347,7 +370,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
variant="outline"
className="h-6 text-[11px] px-2"
onClick={handleSave}
disabled={saving}
disabled={saving || !canEdit}
>
{saving ? (
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
@@ -609,7 +632,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="pt-2">
<Button
onClick={handleSave}
disabled={saving || !isDirty}
disabled={saving || !isDirty || !canEdit}
className="w-full"
>
{saving ? (
@@ -628,7 +651,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
value="items"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{itemsLoading ? (
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground space-y-3">
<p>
BC items are managed on the full Builder Club page editor.
</p>
<Link href={pageHref} target="_blank">
<Button variant="outline" size="sm" className="gap-1">
<ExternalLink className="h-3 w-3" />
Open BC page editor
</Button>
</Link>
</div>
) : itemsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -636,11 +671,18 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<div className="space-y-3">
{/* Toolbar */}
<div className="flex items-center gap-2 flex-wrap">
<QuickAddFurni
pageId={page.id}
furniRevisionMap={itemsData.furniRevisionMap}
/>
<BulkImportItems pageId={page.id} onImported={reloadItems} />
{canEdit && (
<>
<QuickAddFurni
pageId={page.id}
furniRevisionMap={itemsData.furniRevisionMap}
/>
<BulkImportItems
pageId={page.id}
onImported={reloadItems}
/>
</>
)}
<Button
variant="outline"
size="sm"
@@ -657,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
items={itemsData.items}
furniRevisionMap={itemsData.furniRevisionMap}
layout={page.pageLayout}
canEdit={true}
canEdit={canEdit}
/>
)}
@@ -666,7 +708,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
items={itemsData.items}
pageId={page.id}
pageLayout={page.pageLayout}
canEdit={true}
canEdit={canEdit}
interactionTypes={itemsData.interactionTypes}
furniRevisionMap={itemsData.furniRevisionMap}
allPages={itemsData.allPages}
@@ -680,7 +722,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
value="translate"
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
>
{itemsLoading ? (
{catalogType === "bc" ? (
<div className="rounded-md border border-dashed px-4 py-8 text-center text-sm text-muted-foreground">
Translate tools are available for the shop catalog only.
</div>
) : itemsLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
@@ -688,7 +734,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
<CatalogTranslateTab
baseItems={itemsData.baseItems}
catalogNameMap={itemsData.catalogNameMap}
canEdit={true}
canEdit={canEdit}
furniDataIdList={itemsData.furniDataIdList}
furniDescriptionMap={itemsData.furniDescriptionMap}
furniRevisionMap={itemsData.furniRevisionMap}
@@ -25,6 +25,11 @@ import {
reorderTreePage,
toggleCatalogPage,
} from "@/actions/catalog";
import {
deleteBcTreePage,
reorderBcTreePage,
toggleBcPage,
} from "@/actions/catalog-bc";
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { CatalogIcon } from "@/components/admin/catalog-tree";
import { Button } from "@/components/ui/button";
@@ -36,6 +41,7 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
export type { RootTab } from "@/types/catalog";
@@ -58,6 +64,7 @@ export function RootTabs({
onCreateRoot,
loading,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
const [activeDragId, setActiveDragId] = useState<number | null>(null);
const sensors = useSensors(
@@ -78,13 +85,16 @@ export function RootTabs({
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
if (!canEdit) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder
try {
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const results = await Promise.allSettled(
newOrder.map((tab, idx) =>
reorderTreePage({ pageId: tab.id, newOrderNum: idx + 1 }),
reorder({ pageId: tab.id, newOrderNum: idx + 1 }),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
@@ -94,12 +104,17 @@ export function RootTabs({
toast.error("Failed to reorder tabs");
}
},
[tabs],
[tabs, canEdit, catalogType],
);
const handleToggle = async (tab: RootTab) => {
if (!canEdit) return;
try {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
if (catalogType === "bc") {
await toggleBcPage({ id: tab.id, field: "enabled" });
} else {
await toggleCatalogPage({ id: tab.id, action: "toggleEnabled" });
}
toast.success(
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
);
@@ -109,6 +124,7 @@ export function RootTabs({
};
const handleDelete = async (tab: RootTab) => {
if (!canEdit) return;
const ok = await confirm({
title: "Delete root page",
description: `Delete root page "${tab.caption}"? Children will be reparented.`,
@@ -116,7 +132,11 @@ export function RootTabs({
});
if (!ok) return;
try {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
if (catalogType === "bc") {
await deleteBcTreePage({ pageId: tab.id, mode: "reparent" });
} else {
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`);
} catch {
toast.error("Failed to delete");
@@ -168,16 +188,18 @@ export function RootTabs({
)}
</DragOverlay>
</DndContext>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
title="New root page"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
{canEdit && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0 ml-1"
title="New root page"
onClick={onCreateRoot}
>
<Plus className="h-3.5 w-3.5" />
</Button>
)}
</div>
);
}
@@ -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<TreeNodeData[]> => {
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<string, unknown>).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({
</button>
) : null}
</div>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={sortMode ? "default" : "outline"}
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })}
>
{sortMode ? (
<GripVertical className="h-3.5 w-3.5" />
) : (
<MousePointer className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{sortMode ? "Sort mode" : "Edit mode"}</p>
</TooltipContent>
</Tooltip>
{canEdit && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={sortMode ? "default" : "outline"}
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })}
>
{sortMode ? (
<GripVertical className="h-3.5 w-3.5" />
) : (
<MousePointer className="h-3.5 w-3.5" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{sortMode ? "Sort mode" : "Edit mode"}</p>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Filters + expand/collapse */}
@@ -314,19 +314,33 @@ function reducer(
// ── Context ───────────────────────────────────────────────────
export type CatalogManagerMode = "normal" | "bc";
interface CatalogManagerContextValue {
state: CatalogManagerState;
dispatch: Dispatch<CatalogAction>;
catalogType: CatalogManagerMode;
canEdit: boolean;
}
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
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 (
<CatalogManagerContext.Provider value={{ state, dispatch }}>
<CatalogManagerContext.Provider
value={{ state, dispatch, catalogType, canEdit }}
>
{children}
</CatalogManagerContext.Provider>
);
@@ -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" : "";
}