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 <[email protected]>
This commit is contained in:
1 parent
8ccade73cc
commit
7d50b72ade
8 files changed
+457
-151
No files matched your search
+110
-7
@@ -169,19 +169,24 @@ export async function toggleBcPage({
|
|||||||
export async function createBcPage(input: {
|
export async function createBcPage(input: {
|
||||||
caption: string;
|
caption: string;
|
||||||
parentId: number;
|
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 staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
const created = await prisma.catalogPagesBc.create({
|
const created = await prisma.catalogPagesBc.create({
|
||||||
data: {
|
data: {
|
||||||
caption: input.caption,
|
caption: input.caption,
|
||||||
parentId: input.parentId,
|
parentId: input.parentId,
|
||||||
pageLayout: input.pageLayout,
|
pageLayout: input.pageLayout ?? "default_3x3",
|
||||||
iconColor: 0,
|
iconColor: input.iconColor ?? 0,
|
||||||
iconImage: 0,
|
iconImage: input.iconImage ?? 0,
|
||||||
orderNum: 0,
|
orderNum: input.orderNum ?? 0,
|
||||||
visible: "1",
|
visible: input.visible ?? "1",
|
||||||
enabled: "1",
|
enabled: input.enabled ?? "1",
|
||||||
pageHeadline: "",
|
pageHeadline: "",
|
||||||
pageTeaser: "",
|
pageTeaser: "",
|
||||||
},
|
},
|
||||||
@@ -194,6 +199,104 @@ export async function createBcPage(input: {
|
|||||||
targetType: "catalog_page_bc",
|
targetType: "catalog_page_bc",
|
||||||
targetId: created.id,
|
targetId: created.id,
|
||||||
});
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
revalidatePath("/admin/catalog/builder-club");
|
revalidatePath("/admin/catalog/builder-club");
|
||||||
return { ok: true as const, data: { id: created.id } };
|
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: {} };
|
||||||
|
}
|
||||||
@@ -208,6 +208,19 @@ export default async function CatalogPagesPage({
|
|||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
<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" />}
|
{canEdit && <CatalogActions catalogType="bc" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -383,6 +396,8 @@ export default async function CatalogPagesPage({
|
|||||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<CatalogManagerDialog
|
<CatalogManagerDialog
|
||||||
|
catalogType="normal"
|
||||||
|
canEdit={canEdit}
|
||||||
initialRoots={rootPages.map((p) => ({
|
initialRoots={rootPages.map((p) => ({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
caption: p.caption,
|
caption: p.caption,
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ import { Fragment, useEffect } from "react";
|
|||||||
import { useCatalogManager } from "./use-catalog-store";
|
import { useCatalogManager } from "./use-catalog-store";
|
||||||
|
|
||||||
export function BreadcrumbBar() {
|
export function BreadcrumbBar() {
|
||||||
const { state, dispatch } = useCatalogManager();
|
const { state, dispatch, catalogType } = useCatalogManager();
|
||||||
const { selectedPageId, ancestors } = state;
|
const { selectedPageId, ancestors } = state;
|
||||||
|
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedPageId || selectedPageId <= 0) {
|
if (!selectedPageId || selectedPageId <= 0) {
|
||||||
@@ -14,7 +15,7 @@ export function BreadcrumbBar() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ac = new AbortController();
|
const ac = new AbortController();
|
||||||
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}`, {
|
fetch(`/api/admin/catalog/tree?ancestors=${selectedPageId}${catQs}`, {
|
||||||
signal: ac.signal,
|
signal: ac.signal,
|
||||||
})
|
})
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
@@ -25,7 +26,7 @@ export function BreadcrumbBar() {
|
|||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
return () => ac.abort();
|
return () => ac.abort();
|
||||||
}, [selectedPageId, dispatch]);
|
}, [selectedPageId, dispatch, catQs]);
|
||||||
|
|
||||||
if (!selectedPageId || ancestors.length === 0) return null;
|
if (!selectedPageId || ancestors.length === 0) return null;
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { createCatalogPage } from "@/actions/catalog";
|
import { createCatalogPage } from "@/actions/catalog";
|
||||||
|
import { createBcPage } from "@/actions/catalog-bc";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import type { RootTab } from "@/types/catalog";
|
import type { RootTab } from "@/types/catalog";
|
||||||
@@ -13,7 +14,12 @@ import { InlineEditor, type PageUpdateData } from "./inline-editor";
|
|||||||
import { RootTabs } from "./root-tabs";
|
import { RootTabs } from "./root-tabs";
|
||||||
import { SortableTree } from "./sortable-tree";
|
import { SortableTree } from "./sortable-tree";
|
||||||
import { StatsBar } from "./stats-bar";
|
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).
|
* Visual Catalog Manager — matches habbo-next UX (full-viewport editor).
|
||||||
@@ -21,8 +27,12 @@ import { CatalogManagerProvider, useCatalogManager } from "./use-catalog-store";
|
|||||||
*/
|
*/
|
||||||
export function CatalogManagerDialog({
|
export function CatalogManagerDialog({
|
||||||
initialRoots = [],
|
initialRoots = [],
|
||||||
|
catalogType = "normal",
|
||||||
|
canEdit = false,
|
||||||
}: {
|
}: {
|
||||||
initialRoots?: RootTab[];
|
initialRoots?: RootTab[];
|
||||||
|
catalogType?: CatalogManagerMode;
|
||||||
|
canEdit?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
@@ -46,6 +56,9 @@ export function CatalogManagerDialog({
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
catalogType === "bc" ? "BC Visual Manager" : "Visual Manager";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
@@ -55,7 +68,7 @@ export function CatalogManagerDialog({
|
|||||||
onClick={() => setOpen(true)}
|
onClick={() => setOpen(true)}
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Visual Manager
|
{title}
|
||||||
</Button>
|
</Button>
|
||||||
{mounted && open
|
{mounted && open
|
||||||
? createPortal(
|
? 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"
|
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)" }}
|
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||||
>
|
>
|
||||||
<CatalogManagerProvider>
|
<CatalogManagerProvider
|
||||||
|
catalogType={catalogType}
|
||||||
|
canEdit={canEdit}
|
||||||
|
>
|
||||||
<CatalogManagerInner
|
<CatalogManagerInner
|
||||||
open={open}
|
open={open}
|
||||||
onOpenChange={setOpen}
|
onOpenChange={setOpen}
|
||||||
@@ -93,12 +109,15 @@ function CatalogManagerInner({
|
|||||||
onOpenChange: (v: boolean) => void;
|
onOpenChange: (v: boolean) => void;
|
||||||
initialRoots: RootTab[];
|
initialRoots: RootTab[];
|
||||||
}) {
|
}) {
|
||||||
const { state, dispatch } = useCatalogManager();
|
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
||||||
|
const catQs = catalogTreeQs(catalogType);
|
||||||
|
|
||||||
const loadRootPages = useCallback(async () => {
|
const loadRootPages = useCallback(async () => {
|
||||||
dispatch({ type: "SET_LOADING_ROOT", loading: true });
|
dispatch({ type: "SET_LOADING_ROOT", loading: true });
|
||||||
try {
|
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(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to load roots");
|
if (!res.ok) throw new Error(data.error || "Failed to load roots");
|
||||||
const pages: RootTab[] = (data.pages || []).map(
|
const pages: RootTab[] = (data.pages || []).map(
|
||||||
@@ -122,7 +141,7 @@ function CatalogManagerInner({
|
|||||||
toast.error("Failed to load catalog root pages");
|
toast.error("Failed to load catalog root pages");
|
||||||
dispatch({ type: "SET_LOADING_ROOT", loading: false });
|
dispatch({ type: "SET_LOADING_ROOT", loading: false });
|
||||||
}
|
}
|
||||||
}, [dispatch, state.activeTabId]);
|
}, [dispatch, state.activeTabId, catQs]);
|
||||||
|
|
||||||
// Seed from SSR immediately, then refresh from API.
|
// Seed from SSR immediately, then refresh from API.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -157,13 +176,20 @@ function CatalogManagerInner({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateRoot = async () => {
|
const handleCreateRoot = async () => {
|
||||||
|
if (!canEdit) return;
|
||||||
const caption = prompt("Root page name:");
|
const caption = prompt("Root page name:");
|
||||||
if (!caption?.trim()) return;
|
if (!caption?.trim()) return;
|
||||||
try {
|
try {
|
||||||
const result = await createCatalogPage({
|
const result =
|
||||||
parentId: -1,
|
catalogType === "bc"
|
||||||
caption: caption.trim(),
|
? await createBcPage({
|
||||||
});
|
parentId: -1,
|
||||||
|
caption: caption.trim(),
|
||||||
|
})
|
||||||
|
: await createCatalogPage({
|
||||||
|
parentId: -1,
|
||||||
|
caption: caption.trim(),
|
||||||
|
});
|
||||||
if (!result.ok) throw new Error(result.error);
|
if (!result.ok) throw new Error(result.error);
|
||||||
await loadRootPages();
|
await loadRootPages();
|
||||||
toast.success("Root page created");
|
toast.success("Root page created");
|
||||||
@@ -211,6 +237,11 @@ function CatalogManagerInner({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const heading =
|
||||||
|
catalogType === "bc"
|
||||||
|
? "BC Visual Catalog Manager"
|
||||||
|
: "Visual Catalog Manager";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider delayDuration={300}>
|
<TooltipProvider delayDuration={300}>
|
||||||
<div
|
<div
|
||||||
@@ -222,7 +253,7 @@ function CatalogManagerInner({
|
|||||||
className="flex items-center gap-2 text-base font-semibold leading-none"
|
className="flex items-center gap-2 text-base font-semibold leading-none"
|
||||||
>
|
>
|
||||||
<LayoutGrid className="h-4 w-4" />
|
<LayoutGrid className="h-4 w-4" />
|
||||||
Visual Catalog Manager
|
{heading}
|
||||||
</h2>
|
</h2>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import Link from "next/link";
|
|||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateCatalogPage } from "@/actions/catalog";
|
import { updateCatalogPage } from "@/actions/catalog";
|
||||||
|
import { updateBcPage } from "@/actions/catalog-bc";
|
||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table";
|
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table";
|
||||||
import { CatalogItemsTable } 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";
|
import { CatalogTranslateTab } from "@/app/admin/catalog/[id]/catalog-translate-tab";
|
||||||
@@ -45,7 +46,7 @@ import {
|
|||||||
} from "@/lib/catalog-layouts";
|
} from "@/lib/catalog-layouts";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ImagePreview } from "./image-preview";
|
import { ImagePreview } from "./image-preview";
|
||||||
import { useCatalogManager } from "./use-catalog-store";
|
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
|
||||||
|
|
||||||
interface PageDetail {
|
interface PageDetail {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -104,7 +105,12 @@ interface InlineEditorProps {
|
|||||||
type FormState = Record<string, string | number | null>;
|
type FormState = Record<string, string | number | null>;
|
||||||
|
|
||||||
export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
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 [page, setPage] = useState<PageDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -133,48 +139,53 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
function onKeyDown(e: KeyboardEvent) {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
if ((e.metaKey || e.ctrlKey) && e.key === "s") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isDirty && !saving && page) handleSave();
|
if (canEdit && isDirty && !saving && page) handleSave();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener("keydown", onKeyDown);
|
document.addEventListener("keydown", onKeyDown);
|
||||||
return () => document.removeEventListener("keydown", onKeyDown);
|
return () => document.removeEventListener("keydown", onKeyDown);
|
||||||
}); // eslint-disable-line react-hooks/exhaustive-deps
|
}); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const loadPage = useCallback(async (id: number) => {
|
const loadPage = useCallback(
|
||||||
setLoading(true);
|
async (id: number) => {
|
||||||
try {
|
setLoading(true);
|
||||||
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}`);
|
try {
|
||||||
if (!res.ok) throw new Error();
|
const res = await fetch(
|
||||||
const data = await res.json();
|
`/api/admin/catalog/tree?pageId=${id}${catQs}`,
|
||||||
const p = data.page as PageDetail;
|
);
|
||||||
setPage(p);
|
if (!res.ok) throw new Error();
|
||||||
const formData: FormState = {
|
const data = await res.json();
|
||||||
caption: p.caption,
|
const p = data.page as PageDetail;
|
||||||
pageLayout: p.pageLayout,
|
setPage(p);
|
||||||
enabled: String(p.enabled),
|
const formData: FormState = {
|
||||||
visible: String(p.visible),
|
caption: p.caption,
|
||||||
minRank: p.minRank,
|
pageLayout: p.pageLayout,
|
||||||
clubOnly: String(p.clubOnly),
|
enabled: String(p.enabled),
|
||||||
orderNum: p.orderNum,
|
visible: String(p.visible),
|
||||||
iconImage: p.iconImage,
|
minRank: p.minRank ?? 0,
|
||||||
iconColor: p.iconColor,
|
clubOnly: String(p.clubOnly ?? "0"),
|
||||||
pageHeadline: p.pageHeadline || "",
|
orderNum: p.orderNum,
|
||||||
pageTeaser: p.pageTeaser || "",
|
iconImage: p.iconImage,
|
||||||
pageSpecial: p.pageSpecial || "",
|
iconColor: p.iconColor,
|
||||||
pageText1: p.pageText1 || "",
|
pageHeadline: p.pageHeadline || "",
|
||||||
pageText2: p.pageText2 || "",
|
pageTeaser: p.pageTeaser || "",
|
||||||
pageTextDetails: p.pageTextDetails || "",
|
pageSpecial: p.pageSpecial || "",
|
||||||
pageTextTeaser: p.pageTextTeaser || "",
|
pageText1: p.pageText1 || "",
|
||||||
};
|
pageText2: p.pageText2 || "",
|
||||||
setForm(formData);
|
pageTextDetails: p.pageTextDetails || "",
|
||||||
setOriginal(formData);
|
pageTextTeaser: p.pageTextTeaser || "",
|
||||||
} catch {
|
};
|
||||||
toast.error("Failed to load page");
|
setForm(formData);
|
||||||
setPage(null);
|
setOriginal(formData);
|
||||||
} finally {
|
} catch {
|
||||||
setLoading(false);
|
toast.error("Failed to load page");
|
||||||
}
|
setPage(null);
|
||||||
}, []);
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[catQs],
|
||||||
|
);
|
||||||
|
|
||||||
const loadItemsData = useCallback(async (id: number) => {
|
const loadItemsData = useCallback(async (id: number) => {
|
||||||
setItemsLoading(true);
|
setItemsLoading(true);
|
||||||
@@ -211,8 +222,9 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
}
|
}
|
||||||
}, [pageId, loadPage]);
|
}, [pageId, loadPage]);
|
||||||
|
|
||||||
// Lazy-load items
|
// Lazy-load items (shop catalog only — BC items live on the detail page)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (catalogType === "bc") return;
|
||||||
if (
|
if (
|
||||||
(activeTab === "items" || activeTab === "translate") &&
|
(activeTab === "items" || activeTab === "translate") &&
|
||||||
!itemsLoaded &&
|
!itemsLoaded &&
|
||||||
@@ -222,17 +234,28 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
) {
|
) {
|
||||||
loadItemsData(pageId);
|
loadItemsData(pageId);
|
||||||
}
|
}
|
||||||
}, [activeTab, itemsLoaded, itemsLoading, pageId, loadItemsData]);
|
}, [
|
||||||
|
activeTab,
|
||||||
|
itemsLoaded,
|
||||||
|
itemsLoading,
|
||||||
|
pageId,
|
||||||
|
loadItemsData,
|
||||||
|
catalogType,
|
||||||
|
]);
|
||||||
|
|
||||||
function update(key: string, value: string | number | null) {
|
function update(key: string, value: string | number | null) {
|
||||||
|
if (!canEdit) return;
|
||||||
setForm((prev) => ({ ...prev, [key]: value }));
|
setForm((prev) => ({ ...prev, [key]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
async function handleSave() {
|
||||||
if (!page) return;
|
if (!page || !canEdit) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
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");
|
if (!result.ok) throw new Error(result.error || "Failed to save");
|
||||||
toast.success("Page updated & catalog synced");
|
toast.success("Page updated & catalog synced");
|
||||||
setOriginal({ ...form });
|
setOriginal({ ...form });
|
||||||
@@ -323,7 +346,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<Button variant="ghost" size="sm" className="h-7 text-xs gap-1">
|
||||||
<ExternalLink className="h-3 w-3" />
|
<ExternalLink className="h-3 w-3" />
|
||||||
Full editor
|
Full editor
|
||||||
@@ -347,7 +370,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-6 text-[11px] px-2"
|
className="h-6 text-[11px] px-2"
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving || !canEdit}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
<Loader2 className="mr-1 h-3 w-3 animate-spin" />
|
||||||
@@ -609,7 +632,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
<div className="pt-2">
|
<div className="pt-2">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving || !isDirty}
|
disabled={saving || !isDirty || !canEdit}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
@@ -628,7 +651,19 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
value="items"
|
value="items"
|
||||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
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">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@@ -636,11 +671,18 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<QuickAddFurni
|
{canEdit && (
|
||||||
pageId={page.id}
|
<>
|
||||||
furniRevisionMap={itemsData.furniRevisionMap}
|
<QuickAddFurni
|
||||||
/>
|
pageId={page.id}
|
||||||
<BulkImportItems pageId={page.id} onImported={reloadItems} />
|
furniRevisionMap={itemsData.furniRevisionMap}
|
||||||
|
/>
|
||||||
|
<BulkImportItems
|
||||||
|
pageId={page.id}
|
||||||
|
onImported={reloadItems}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -657,7 +699,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
items={itemsData.items}
|
items={itemsData.items}
|
||||||
furniRevisionMap={itemsData.furniRevisionMap}
|
furniRevisionMap={itemsData.furniRevisionMap}
|
||||||
layout={page.pageLayout}
|
layout={page.pageLayout}
|
||||||
canEdit={true}
|
canEdit={canEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -666,7 +708,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
items={itemsData.items}
|
items={itemsData.items}
|
||||||
pageId={page.id}
|
pageId={page.id}
|
||||||
pageLayout={page.pageLayout}
|
pageLayout={page.pageLayout}
|
||||||
canEdit={true}
|
canEdit={canEdit}
|
||||||
interactionTypes={itemsData.interactionTypes}
|
interactionTypes={itemsData.interactionTypes}
|
||||||
furniRevisionMap={itemsData.furniRevisionMap}
|
furniRevisionMap={itemsData.furniRevisionMap}
|
||||||
allPages={itemsData.allPages}
|
allPages={itemsData.allPages}
|
||||||
@@ -680,7 +722,11 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
value="translate"
|
value="translate"
|
||||||
className="flex-1 overflow-y-auto px-4 pb-4 mt-2"
|
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">
|
<div className="flex items-center justify-center py-12">
|
||||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
@@ -688,7 +734,7 @@ export function InlineEditor({ pageId, onSaved }: InlineEditorProps) {
|
|||||||
<CatalogTranslateTab
|
<CatalogTranslateTab
|
||||||
baseItems={itemsData.baseItems}
|
baseItems={itemsData.baseItems}
|
||||||
catalogNameMap={itemsData.catalogNameMap}
|
catalogNameMap={itemsData.catalogNameMap}
|
||||||
canEdit={true}
|
canEdit={canEdit}
|
||||||
furniDataIdList={itemsData.furniDataIdList}
|
furniDataIdList={itemsData.furniDataIdList}
|
||||||
furniDescriptionMap={itemsData.furniDescriptionMap}
|
furniDescriptionMap={itemsData.furniDescriptionMap}
|
||||||
furniRevisionMap={itemsData.furniRevisionMap}
|
furniRevisionMap={itemsData.furniRevisionMap}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import {
|
|||||||
reorderTreePage,
|
reorderTreePage,
|
||||||
toggleCatalogPage,
|
toggleCatalogPage,
|
||||||
} from "@/actions/catalog";
|
} from "@/actions/catalog";
|
||||||
|
import {
|
||||||
|
deleteBcTreePage,
|
||||||
|
reorderBcTreePage,
|
||||||
|
toggleBcPage,
|
||||||
|
} from "@/actions/catalog-bc";
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { CatalogIcon } from "@/components/admin/catalog-tree";
|
import { CatalogIcon } from "@/components/admin/catalog-tree";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -36,6 +41,7 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useCatalogManager } from "./use-catalog-store";
|
||||||
|
|
||||||
export type { RootTab } from "@/types/catalog";
|
export type { RootTab } from "@/types/catalog";
|
||||||
|
|
||||||
@@ -58,6 +64,7 @@ export function RootTabs({
|
|||||||
onCreateRoot,
|
onCreateRoot,
|
||||||
loading,
|
loading,
|
||||||
}: RootTabsProps) {
|
}: RootTabsProps) {
|
||||||
|
const { catalogType, canEdit } = useCatalogManager();
|
||||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -78,13 +85,16 @@ export function RootTabs({
|
|||||||
const newIndex = tabs.findIndex((t) => t.id === over.id);
|
const newIndex = tabs.findIndex((t) => t.id === over.id);
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
if (oldIndex === -1 || newIndex === -1) return;
|
||||||
|
|
||||||
|
if (!canEdit) return;
|
||||||
const newOrder = arrayMove(tabs, oldIndex, newIndex);
|
const newOrder = arrayMove(tabs, oldIndex, newIndex);
|
||||||
|
|
||||||
// Batch reorder
|
// Batch reorder
|
||||||
try {
|
try {
|
||||||
|
const reorder =
|
||||||
|
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
newOrder.map((tab, idx) =>
|
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;
|
const failed = results.filter((r) => r.status === "rejected").length;
|
||||||
@@ -94,12 +104,17 @@ export function RootTabs({
|
|||||||
toast.error("Failed to reorder tabs");
|
toast.error("Failed to reorder tabs");
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[tabs],
|
[tabs, canEdit, catalogType],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggle = async (tab: RootTab) => {
|
const handleToggle = async (tab: RootTab) => {
|
||||||
|
if (!canEdit) return;
|
||||||
try {
|
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(
|
toast.success(
|
||||||
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
|
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
|
||||||
);
|
);
|
||||||
@@ -109,6 +124,7 @@ export function RootTabs({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (tab: RootTab) => {
|
const handleDelete = async (tab: RootTab) => {
|
||||||
|
if (!canEdit) return;
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: "Delete root page",
|
title: "Delete root page",
|
||||||
description: `Delete root page "${tab.caption}"? Children will be reparented.`,
|
description: `Delete root page "${tab.caption}"? Children will be reparented.`,
|
||||||
@@ -116,7 +132,11 @@ export function RootTabs({
|
|||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
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`);
|
toast.success(`"${tab.caption}" deleted`);
|
||||||
} catch {
|
} catch {
|
||||||
toast.error("Failed to delete");
|
toast.error("Failed to delete");
|
||||||
@@ -168,16 +188,18 @@ export function RootTabs({
|
|||||||
)}
|
)}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
<Button
|
{canEdit && (
|
||||||
type="button"
|
<Button
|
||||||
variant="ghost"
|
type="button"
|
||||||
size="icon"
|
variant="ghost"
|
||||||
className="h-7 w-7 shrink-0 ml-1"
|
size="icon"
|
||||||
title="New root page"
|
className="h-7 w-7 shrink-0 ml-1"
|
||||||
onClick={onCreateRoot}
|
title="New root page"
|
||||||
>
|
onClick={onCreateRoot}
|
||||||
<Plus className="h-3.5 w-3.5" />
|
>
|
||||||
</Button>
|
<Plus className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,13 @@ import {
|
|||||||
toggleCatalogPage,
|
toggleCatalogPage,
|
||||||
updateCatalogPage,
|
updateCatalogPage,
|
||||||
} from "@/actions/catalog";
|
} from "@/actions/catalog";
|
||||||
|
import {
|
||||||
|
createBcPage,
|
||||||
|
deleteBcTreePage,
|
||||||
|
reorderBcTreePage,
|
||||||
|
toggleBcPage,
|
||||||
|
updateBcPage,
|
||||||
|
} from "@/actions/catalog-bc";
|
||||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -56,7 +63,7 @@ import {
|
|||||||
TreeItemContent,
|
TreeItemContent,
|
||||||
type TreeItemProps,
|
type TreeItemProps,
|
||||||
} from "./tree-item";
|
} from "./tree-item";
|
||||||
import { useCatalogManager } from "./use-catalog-store";
|
import { catalogTreeQs, useCatalogManager } from "./use-catalog-store";
|
||||||
|
|
||||||
/* ─── Types ──────────────────────────────────────────────── */
|
/* ─── Types ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -83,7 +90,10 @@ export function SortableTree({
|
|||||||
onTreeMutation,
|
onTreeMutation,
|
||||||
nodeUpdate,
|
nodeUpdate,
|
||||||
}: SortableTreeProps) {
|
}: 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 { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||||
const {
|
const {
|
||||||
nodes,
|
nodes,
|
||||||
@@ -137,13 +147,13 @@ export function SortableTree({
|
|||||||
const loadChildren = useCallback(
|
const loadChildren = useCallback(
|
||||||
async (parentId: number): Promise<TreeNodeData[]> => {
|
async (parentId: number): Promise<TreeNodeData[]> => {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/catalog/tree?parentId=${parentId}`,
|
`/api/admin/catalog/tree?parentId=${parentId}${catQs}`,
|
||||||
);
|
);
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error(data.error || "Failed to load pages");
|
if (!res.ok) throw new Error(data.error || "Failed to load pages");
|
||||||
return normalizePages(data.pages || []);
|
return normalizePages(data.pages || []);
|
||||||
},
|
},
|
||||||
[normalizePages],
|
[normalizePages, catQs],
|
||||||
);
|
);
|
||||||
|
|
||||||
const loadTabTree = useCallback(async () => {
|
const loadTabTree = useCallback(async () => {
|
||||||
@@ -188,7 +198,7 @@ export function SortableTree({
|
|||||||
setSearching(true);
|
setSearching(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}`,
|
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`,
|
||||||
);
|
);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -201,7 +211,7 @@ export function SortableTree({
|
|||||||
return () => {
|
return () => {
|
||||||
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
if (searchTimeout.current) clearTimeout(searchTimeout.current);
|
||||||
};
|
};
|
||||||
}, [searchQuery]);
|
}, [searchQuery, catQs]);
|
||||||
|
|
||||||
// Apply external node updates
|
// Apply external node updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -435,10 +445,13 @@ export function SortableTree({
|
|||||||
children: newOrder.map((id) => nodes.get(id)!).filter(Boolean),
|
children: newOrder.map((id) => nodes.get(id)!).filter(Boolean),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!canEdit) return;
|
||||||
|
const reorder =
|
||||||
|
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
newOrder.map((id, idx) =>
|
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;
|
const failed = results.filter((r) => r.status === "rejected").length;
|
||||||
@@ -450,11 +463,14 @@ export function SortableTree({
|
|||||||
loadFullTree();
|
loadFullTree();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
if (!canEdit) return;
|
||||||
|
const reorder =
|
||||||
|
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
|
||||||
const newParentId = overNode.parentId;
|
const newParentId = overNode.parentId;
|
||||||
const siblings = childrenMap.get(newParentId) || [];
|
const siblings = childrenMap.get(newParentId) || [];
|
||||||
const overIndex = siblings.indexOf(over.id as number);
|
const overIndex = siblings.indexOf(over.id as number);
|
||||||
try {
|
try {
|
||||||
const result = await reorderTreePage({
|
const result = await reorder({
|
||||||
pageId: active.id as number,
|
pageId: active.id as number,
|
||||||
newParentId,
|
newParentId,
|
||||||
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
|
newOrderNum: overIndex >= 0 ? overIndex + 1 : siblings.length + 1,
|
||||||
@@ -471,18 +487,25 @@ export function SortableTree({
|
|||||||
|
|
||||||
// ── Actions ─────────────────────────────────────────────────
|
// ── Actions ─────────────────────────────────────────────────
|
||||||
const handleAddSubpage = (parentId: number) => {
|
const handleAddSubpage = (parentId: number) => {
|
||||||
|
if (!canEdit) return;
|
||||||
setSubpageParentId(parentId);
|
setSubpageParentId(parentId);
|
||||||
setSubpageName("");
|
setSubpageName("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSubpage = async () => {
|
const handleCreateSubpage = async () => {
|
||||||
if (subpageParentId === null || !subpageName.trim()) return;
|
if (!canEdit || subpageParentId === null || !subpageName.trim()) return;
|
||||||
setCreatingSubpage(true);
|
setCreatingSubpage(true);
|
||||||
try {
|
try {
|
||||||
const result = await createCatalogPage({
|
const result =
|
||||||
parentId: subpageParentId,
|
catalogType === "bc"
|
||||||
caption: subpageName.trim(),
|
? await createBcPage({
|
||||||
});
|
parentId: subpageParentId,
|
||||||
|
caption: subpageName.trim(),
|
||||||
|
})
|
||||||
|
: await createCatalogPage({
|
||||||
|
parentId: subpageParentId,
|
||||||
|
caption: subpageName.trim(),
|
||||||
|
});
|
||||||
if (!result.ok) throw new Error(result.error);
|
if (!result.ok) throw new Error(result.error);
|
||||||
dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true });
|
dispatch({ type: "SET_EXPANDED", id: subpageParentId, expanded: true });
|
||||||
await loadFullTree();
|
await loadFullTree();
|
||||||
@@ -500,9 +523,13 @@ export function SortableTree({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleEnabled = async (id: number, currentEnabled: string) => {
|
const handleToggleEnabled = async (id: number, currentEnabled: string) => {
|
||||||
|
if (!canEdit) return;
|
||||||
const newEnabled = currentEnabled === "1" ? "0" : "1";
|
const newEnabled = currentEnabled === "1" ? "0" : "1";
|
||||||
try {
|
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();
|
if (!result.ok) throw new Error();
|
||||||
dispatch({ type: "UPDATE_NODE", update: { id, enabled: newEnabled } });
|
dispatch({ type: "UPDATE_NODE", update: { id, enabled: newEnabled } });
|
||||||
onTreeMutation();
|
onTreeMutation();
|
||||||
@@ -512,9 +539,13 @@ export function SortableTree({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleVisible = async (id: number, currentVisible: string) => {
|
const handleToggleVisible = async (id: number, currentVisible: string) => {
|
||||||
|
if (!canEdit) return;
|
||||||
const newVisible = currentVisible === "1" ? "0" : "1";
|
const newVisible = currentVisible === "1" ? "0" : "1";
|
||||||
try {
|
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();
|
if (!result.ok) throw new Error();
|
||||||
dispatch({ type: "UPDATE_NODE", update: { id, visible: newVisible } });
|
dispatch({ type: "UPDATE_NODE", update: { id, visible: newVisible } });
|
||||||
onTreeMutation();
|
onTreeMutation();
|
||||||
@@ -524,6 +555,7 @@ export function SortableTree({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!canEdit) return;
|
||||||
const node = nodes.get(id);
|
const node = nodes.get(id);
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: "Delete page",
|
title: "Delete page",
|
||||||
@@ -532,7 +564,10 @@ export function SortableTree({
|
|||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
try {
|
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();
|
if (!result.ok) throw new Error();
|
||||||
dispatch({ type: "REMOVE_NODE", id });
|
dispatch({ type: "REMOVE_NODE", id });
|
||||||
if (selectedPageId === id) onSelectPage(-1);
|
if (selectedPageId === id) onSelectPage(-1);
|
||||||
@@ -544,32 +579,59 @@ export function SortableTree({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDuplicate = async (id: number) => {
|
const handleDuplicate = async (id: number) => {
|
||||||
|
if (!canEdit) return;
|
||||||
try {
|
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();
|
if (!res.ok) throw new Error();
|
||||||
const { page } = await res.json();
|
const { page } = await res.json();
|
||||||
const result = await createCatalogPage({
|
const result =
|
||||||
parentId: page.parentId ?? -1,
|
catalogType === "bc"
|
||||||
caption: `${page.caption} (copy)`,
|
? await createBcPage({
|
||||||
pageLayout: page.pageLayout ?? "default_3x3",
|
parentId: page.parentId ?? -1,
|
||||||
iconImage: page.iconImage ?? 0,
|
caption: `${page.caption} (copy)`,
|
||||||
iconColor: page.iconColor ?? 0,
|
pageLayout: page.pageLayout ?? "default_3x3",
|
||||||
enabled: "0",
|
iconImage: page.iconImage ?? 0,
|
||||||
visible: page.visible ?? "1",
|
iconColor: page.iconColor ?? 0,
|
||||||
minRank: page.minRank ?? 1,
|
enabled: "0",
|
||||||
orderNum: (page.orderNum ?? 0) + 1,
|
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);
|
if (!result.ok) throw new Error(result.error);
|
||||||
const newId = (result.data as Record<string, unknown>).id as number;
|
const newId = (result.data as Record<string, unknown>).id as number;
|
||||||
await updateCatalogPage({
|
if (catalogType === "bc") {
|
||||||
id: newId,
|
await updateBcPage({
|
||||||
pageHeadline: page.pageHeadline ?? "",
|
id: newId,
|
||||||
pageTeaser: page.pageTeaser ?? "",
|
pageHeadline: page.pageHeadline ?? "",
|
||||||
pageText1: page.pageText1,
|
pageTeaser: page.pageTeaser ?? "",
|
||||||
pageText2: page.pageText2,
|
pageText1: page.pageText1,
|
||||||
pageTextDetails: page.pageTextDetails,
|
pageText2: page.pageText2,
|
||||||
pageTextTeaser: page.pageTextTeaser,
|
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();
|
await loadFullTree();
|
||||||
toast.success(`Duplicated "${page.caption}" (items not copied)`);
|
toast.success(`Duplicated "${page.caption}" (items not copied)`);
|
||||||
onSelectPage(newId);
|
onSelectPage(newId);
|
||||||
@@ -580,7 +642,7 @@ export function SortableTree({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenFull = (id: number) =>
|
const handleOpenFull = (id: number) =>
|
||||||
window.open(`/admin/catalog/${id}`, "_blank");
|
window.open(`${pageBase}/${id}`, "_blank");
|
||||||
|
|
||||||
// ── Expand/Collapse all ─────────────────────────────────────
|
// ── Expand/Collapse all ─────────────────────────────────────
|
||||||
const handleExpandAll = useCallback(() => {
|
const handleExpandAll = useCallback(() => {
|
||||||
@@ -610,13 +672,16 @@ export function SortableTree({
|
|||||||
|
|
||||||
// ── Bulk actions ────────────────────────────────────────────
|
// ── Bulk actions ────────────────────────────────────────────
|
||||||
const handleBulkEnable = async (enable: boolean) => {
|
const handleBulkEnable = async (enable: boolean) => {
|
||||||
|
if (!canEdit) return;
|
||||||
const ids = [...multiSelectedIds];
|
const ids = [...multiSelectedIds];
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
ids.map((id) => {
|
ids.map((id) => {
|
||||||
const node = nodes.get(id);
|
const node = nodes.get(id);
|
||||||
if (!node || (node.enabled === "1") === enable)
|
if (!node || (node.enabled === "1") === enable)
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
return toggleCatalogPage({ id, action: "toggleEnabled" });
|
return catalogType === "bc"
|
||||||
|
? toggleBcPage({ id, field: "enabled" })
|
||||||
|
: toggleCatalogPage({ id, action: "toggleEnabled" });
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
for (const id of ids)
|
for (const id of ids)
|
||||||
@@ -633,6 +698,7 @@ export function SortableTree({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleBulkDelete = async () => {
|
const handleBulkDelete = async () => {
|
||||||
|
if (!canEdit) return;
|
||||||
const ids = [...multiSelectedIds];
|
const ids = [...multiSelectedIds];
|
||||||
const ok = await confirm({
|
const ok = await confirm({
|
||||||
title: "Delete pages",
|
title: "Delete pages",
|
||||||
@@ -640,8 +706,9 @@ export function SortableTree({
|
|||||||
confirmLabel: "Delete",
|
confirmLabel: "Delete",
|
||||||
});
|
});
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
|
const del = catalogType === "bc" ? deleteBcTreePage : deleteTreePage;
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
ids.map((id) => deleteTreePage({ pageId: id, mode: "reparent" })),
|
ids.map((id) => del({ pageId: id, mode: "reparent" })),
|
||||||
);
|
);
|
||||||
dispatch({ type: "CLEAR_MULTI_SELECT" });
|
dispatch({ type: "CLEAR_MULTI_SELECT" });
|
||||||
await loadFullTree();
|
await loadFullTree();
|
||||||
@@ -768,25 +835,27 @@ export function SortableTree({
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
{canEdit && (
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button
|
<TooltipTrigger asChild>
|
||||||
variant={sortMode ? "default" : "outline"}
|
<Button
|
||||||
size="icon"
|
variant={sortMode ? "default" : "outline"}
|
||||||
className="h-7 w-7 shrink-0"
|
size="icon"
|
||||||
onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })}
|
className="h-7 w-7 shrink-0"
|
||||||
>
|
onClick={() => dispatch({ type: "TOGGLE_SORT_MODE" })}
|
||||||
{sortMode ? (
|
>
|
||||||
<GripVertical className="h-3.5 w-3.5" />
|
{sortMode ? (
|
||||||
) : (
|
<GripVertical className="h-3.5 w-3.5" />
|
||||||
<MousePointer className="h-3.5 w-3.5" />
|
) : (
|
||||||
)}
|
<MousePointer className="h-3.5 w-3.5" />
|
||||||
</Button>
|
)}
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent>
|
</TooltipTrigger>
|
||||||
<p>{sortMode ? "Sort mode" : "Edit mode"}</p>
|
<TooltipContent>
|
||||||
</TooltipContent>
|
<p>{sortMode ? "Sort mode" : "Edit mode"}</p>
|
||||||
</Tooltip>
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filters + expand/collapse */}
|
{/* Filters + expand/collapse */}
|
||||||
|
|||||||
@@ -314,19 +314,33 @@ function reducer(
|
|||||||
|
|
||||||
// ── Context ───────────────────────────────────────────────────
|
// ── Context ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type CatalogManagerMode = "normal" | "bc";
|
||||||
|
|
||||||
interface CatalogManagerContextValue {
|
interface CatalogManagerContextValue {
|
||||||
state: CatalogManagerState;
|
state: CatalogManagerState;
|
||||||
dispatch: Dispatch<CatalogAction>;
|
dispatch: Dispatch<CatalogAction>;
|
||||||
|
catalogType: CatalogManagerMode;
|
||||||
|
canEdit: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
|
const CatalogManagerContext = createContext<CatalogManagerContextValue | null>(
|
||||||
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);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
return (
|
return (
|
||||||
<CatalogManagerContext.Provider value={{ state, dispatch }}>
|
<CatalogManagerContext.Provider
|
||||||
|
value={{ state, dispatch, catalogType, canEdit }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</CatalogManagerContext.Provider>
|
</CatalogManagerContext.Provider>
|
||||||
);
|
);
|
||||||
@@ -340,3 +354,8 @@ export function useCatalogManager() {
|
|||||||
);
|
);
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Append to tree API query strings when in Builder Club mode. */
|
||||||
|
export function catalogTreeQs(catalogType: CatalogManagerMode): string {
|
||||||
|
return catalogType === "bc" ? "&catalog=bc" : "";
|
||||||
|
}
|
||||||
Reference in new issue
Block a user