Restore missing catalog admin API routes so Visual Manager works.
Local Build and Deploy / deploy (push) Successful in 1m10s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-17 20:57:55 +02:00
1 parent ce8c3503e9
commit 11d3cf31cc
6 files changed
+490 -10

No files matched your search

+47
View File
@@ -0,0 +1,47 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
const CATALOGUE_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"public",
"swf",
"c_images",
"catalogue",
);
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const sp = request.nextUrl.searchParams;
const limit = Math.min(
Math.max(Number(sp.get("limit") || 120) || 120, 1),
500,
);
const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0);
const search = (sp.get("search") || "").trim().toLowerCase();
let icons: number[] = [];
try {
const files = await fs.readdir(CATALOGUE_DIR);
icons = files
.map((f) => /^icon_(\d+)\.(?:png|gif)$/i.exec(f))
.filter((m): m is RegExpExecArray => Boolean(m))
.map((m) => Number(m[1]))
.filter((n) => Number.isFinite(n))
.sort((a, b) => a - b);
} catch {
icons = [];
}
if (search) {
icons = icons.filter((id) => String(id).includes(search));
}
const total = icons.length;
const slice = icons.slice(offset, offset + limit);
return apiOk({ icons: slice, total });
},
);
+57
View File
@@ -0,0 +1,57 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
const CATALOGUE_DIR = path.join(
/*turbopackIgnore: true*/ process.cwd(),
"public",
"swf",
"c_images",
"catalogue",
);
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const sp = request.nextUrl.searchParams;
const limit = Math.min(
Math.max(Number(sp.get("limit") || 80) || 80, 1),
500,
);
const offset = Math.max(Number(sp.get("offset") || 0) || 0, 0);
const search = (sp.get("search") || "").trim().toLowerCase();
const type = sp.get("type") || "all";
let names: string[] = [];
try {
const files = await fs.readdir(CATALOGUE_DIR);
const seen = new Set<string>();
for (const file of files) {
if (/^icon_\d+\./i.test(file)) continue;
const base = file.replace(/\.(png|gif|jpg|jpeg|webp)$/i, "");
if (!base || seen.has(base)) continue;
seen.add(base);
names.push(base);
}
names.sort((a, b) => a.localeCompare(b));
} catch {
names = [];
}
if (type === "header") {
names = names.filter((n) => /header/i.test(n));
} else if (type === "teaser") {
names = names.filter((n) => /teaser/i.test(n));
}
if (search) {
names = names.filter((n) => n.toLowerCase().includes(search));
}
const total = names.length;
const images = names.slice(offset, offset + limit);
return apiOk({ images, total });
},
);
+16
View File
@@ -0,0 +1,16 @@
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { loadCatalogItemsData } from "@/lib/services/catalog-items-loader";
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const pageId = Number(request.nextUrl.searchParams.get("pageId"));
if (!Number.isFinite(pageId) || pageId <= 0) {
return apiError("Invalid pageId");
}
const data = await loadCatalogItemsData(pageId);
return apiOk(data as unknown as Record<string, unknown>);
},
);
@@ -0,0 +1,32 @@
import { withAdmin } from "@/lib/api-handler";
import { apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { lookupHabboIt } from "@/lib/services/habbo-furnidata-cache";
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const raw = request.nextUrl.searchParams.get("classnames") || "";
const classnames = raw
.split(",")
.map((s) => s.trim())
.filter(Boolean)
.slice(0, 100);
const suggestions: Record<string, { name: string; description: string }> =
{};
await Promise.all(
classnames.map(async (classname) => {
const entry = await lookupHabboIt(classname);
if (!entry) return;
suggestions[classname] = {
name: entry.name ?? "",
description: entry.description ?? "",
};
}),
);
return apiOk({ suggestions });
},
);
+324
View File
@@ -0,0 +1,324 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { withAdmin } from "@/lib/api-handler";
import { apiError, apiOk } from "@/lib/api-response";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import {
deletePage,
getAncestors,
getTreeFlat,
} from "@/lib/services/catalog-tree";
import type { TreeNode } from "@/types/catalog";
type PageRow = {
id: number;
caption: string;
parentId: number;
orderNum: number;
enabled: string;
visible: string;
iconImage: number;
iconColor: number;
pageLayout: string;
};
async function getBcTreeFlat(): Promise<TreeNode[]> {
const [allPages, itemCounts] = await Promise.all([
prisma.catalogPagesBc.findMany({ orderBy: { orderNum: "asc" } }),
prisma.catalogItemsBc.groupBy({ by: ["pageId"], _count: true }),
]);
const itemCountMap = new Map(itemCounts.map((c) => [c.pageId, c._count]));
const childCountMap = new Map<number, number>();
for (const p of allPages) {
childCountMap.set(p.parentId, (childCountMap.get(p.parentId) ?? 0) + 1);
}
const pageMap = new Map(allPages.map((p) => [p.id, p]));
const depthCache = new Map<number, number>();
function computeDepth(pageId: number): number {
if (depthCache.has(pageId)) return depthCache.get(pageId)!;
const page = pageMap.get(pageId);
if (!page || page.parentId <= 0) {
depthCache.set(pageId, 0);
return 0;
}
const d = computeDepth(page.parentId) + 1;
depthCache.set(pageId, d);
return d;
}
return allPages.map((p) => ({
id: p.id,
caption: p.caption,
parentId: p.parentId,
depth: computeDepth(p.id),
orderNum: p.orderNum,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount: childCountMap.get(p.id) ?? 0,
itemCount: itemCountMap.get(p.id) ?? 0,
}));
}
function toTreeNode(
p: PageRow,
childCount: number,
itemCount: number,
depth = 0,
): TreeNode {
return {
id: p.id,
caption: p.caption,
parentId: p.parentId,
depth,
orderNum: p.orderNum,
enabled: String(p.enabled),
visible: String(p.visible),
iconImage: p.iconImage,
iconColor: p.iconColor,
pageLayout: p.pageLayout,
childCount,
itemCount,
};
}
async function getChildren(
parentId: number,
isBc: boolean,
): Promise<TreeNode[]> {
const parentFilter =
parentId === -1
? { OR: [{ parentId: -1 }, { parentId: 0 }] }
: { parentId };
if (isBc) {
const pages = await prisma.catalogPagesBc.findMany({
where: parentFilter,
orderBy: { orderNum: "asc" },
});
const ids = pages.map((p) => p.id);
const [childCounts, itemCounts] = await Promise.all([
ids.length
? prisma.catalogPagesBc.groupBy({
by: ["parentId"],
where: { parentId: { in: ids } },
_count: true,
})
: [],
ids.length
? prisma.catalogItemsBc.groupBy({
by: ["pageId"],
where: { pageId: { in: ids } },
_count: true,
})
: [],
]);
const childMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]),
);
const itemMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]),
);
return pages.map((p) =>
toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0),
);
}
const pages = await prisma.catalogPages.findMany({
where: parentFilter,
orderBy: { orderNum: "asc" },
});
const ids = pages.map((p) => p.id);
const [childCounts, itemCounts] = await Promise.all([
ids.length
? prisma.catalogPages.groupBy({
by: ["parentId"],
where: { parentId: { in: ids } },
_count: true,
})
: [],
ids.length
? prisma.catalogItems.groupBy({
by: ["pageId"],
where: { pageId: { in: ids } },
_count: true,
})
: [],
]);
const childMap = Object.fromEntries(
childCounts.map((c) => [c.parentId, c._count]),
);
const itemMap = Object.fromEntries(
itemCounts.map((c) => [c.pageId, c._count]),
);
return pages.map((p) =>
toTreeNode(p, childMap[p.id] ?? 0, itemMap[p.id] ?? 0),
);
}
async function searchPages(q: string, isBc: boolean): Promise<TreeNode[]> {
const flat = isBc ? await getBcTreeFlat() : await getTreeFlat();
const needle = q.toLowerCase();
return flat.filter(
(p) =>
p.caption.toLowerCase().includes(needle) ||
String(p.id) === needle,
);
}
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const sp = request.nextUrl.searchParams;
const isBc = sp.get("catalog") === "bc";
if (sp.get("mode") === "full") {
const pages = isBc ? await getBcTreeFlat() : await getTreeFlat();
return apiOk({ pages });
}
const pageIdRaw = sp.get("pageId");
if (pageIdRaw) {
const id = Number(pageIdRaw);
if (!Number.isFinite(id) || id <= 0) return apiError("Invalid pageId");
if (isBc) {
const page = await prisma.catalogPagesBc.findUnique({ where: { id } });
if (!page) return apiError("Page not found", 404);
return apiOk({ page });
}
const page = await prisma.catalogPages.findUnique({ where: { id } });
if (!page) return apiError("Page not found", 404);
return apiOk({ page });
}
const ancestorsRaw = sp.get("ancestors");
if (ancestorsRaw) {
const id = Number(ancestorsRaw);
if (!Number.isFinite(id) || id <= 0) return apiError("Invalid id");
if (isBc) {
const chain: Array<{ id: number; caption: string }> = [];
let currentId = id;
for (let i = 0; i < 20; i++) {
const page = await prisma.catalogPagesBc.findUnique({
where: { id: currentId },
select: { id: true, caption: true, parentId: true },
});
if (!page) break;
chain.unshift({ id: page.id, caption: page.caption });
if (page.parentId <= 0) break;
currentId = page.parentId;
}
return apiOk({
ancestors: chain.filter((a) => a.id !== id),
});
}
const chain = await getAncestors(id);
return apiOk({ ancestors: chain.filter((a) => a.id !== id) });
}
const search = sp.get("search");
if (search?.trim()) {
const pages = await searchPages(search.trim(), isBc);
return apiOk({ pages });
}
const parentIdRaw = sp.get("parentId");
if (parentIdRaw !== null) {
const parentId = Number(parentIdRaw);
if (!Number.isFinite(parentId)) return apiError("Invalid parentId");
const pages = await getChildren(parentId, isBc);
return apiOk({ pages });
}
return apiOk({ pages: [] });
},
);
export const PATCH = withAdmin(
{ permission: PERMS.CATALOG_EDIT },
async (request) => {
const body = (await request.json()) as {
pageId?: number;
toggleField?: "toggleEnabled" | "toggleVisible";
catalog?: string;
};
const pageId = Number(body.pageId);
const field =
body.toggleField === "toggleVisible" ? "visible" : "enabled";
if (!Number.isFinite(pageId) || pageId <= 0) {
return apiError("Invalid pageId");
}
const isBc = body.catalog === "bc";
if (isBc) {
const page = await prisma.catalogPagesBc.findUnique({
where: { id: pageId },
select: { enabled: true, visible: true },
});
if (!page) return apiError("Page not found", 404);
const current = field === "enabled" ? page.enabled : page.visible;
await prisma.catalogPagesBc.update({
where: { id: pageId },
data: { [field]: current === "1" ? "0" : "1" },
});
return apiOk({});
}
const page = await prisma.catalogPages.findUnique({
where: { id: pageId },
select: { enabled: true, visible: true },
});
if (!page) return apiError("Page not found", 404);
const current = field === "enabled" ? page.enabled : page.visible;
await prisma.catalogPages.update({
where: { id: pageId },
data: { [field]: current === "1" ? "0" : "1" },
});
return apiOk({});
},
);
export const DELETE = withAdmin(
{ permission: PERMS.CATALOG_EDIT },
async (request) => {
const sp = request.nextUrl.searchParams;
const pageId = Number(sp.get("pageId"));
const mode = sp.get("mode") === "cascade" ? "cascade" : "reparent";
const isBc = sp.get("catalog") === "bc";
if (!Number.isFinite(pageId) || pageId <= 0) {
return apiError("Invalid pageId");
}
if (isBc) {
const page = await prisma.catalogPagesBc.findUnique({
where: { id: pageId },
select: { parentId: true },
});
if (!page) return apiError("Page not found", 404);
if (mode === "reparent") {
await prisma.$transaction([
prisma.catalogPagesBc.updateMany({
where: { parentId: pageId },
data: { parentId: page.parentId },
}),
prisma.catalogItemsBc.deleteMany({ where: { pageId } }),
prisma.catalogPagesBc.delete({ where: { id: pageId } }),
]);
} else {
const children = await prisma.catalogPagesBc.findMany({
where: { parentId: pageId },
select: { id: true },
});
const ids = [pageId, ...children.map((c) => c.id)];
await prisma.$transaction([
prisma.catalogItemsBc.deleteMany({
where: { pageId: { in: ids } },
}),
prisma.catalogPagesBc.deleteMany({ where: { id: { in: ids } } }),
]);
}
return apiOk({});
}
await deletePage(pageId, mode);
return apiOk({});
},
);
@@ -10,7 +10,6 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { RootTab } from "@/types/catalog";
@@ -26,16 +25,21 @@ export function CatalogManagerDialog() {
return (
<Dialog open={open} onOpenChange={setOpen} disablePointerDismissal>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
</DialogTrigger>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => setOpen(true)}
>
<LayoutGrid className="h-4 w-4" />
Visual Manager
</Button>
<DialogContent className="max-w-full w-[98vw] h-[95vh] max-h-[95vh] p-0 flex flex-col gap-0 sm:max-w-[98vw]">
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
{open ? (
<CatalogManagerProvider>
<CatalogManagerInner open={open} onOpenChange={setOpen} />
</CatalogManagerProvider>
) : null}
</DialogContent>
</Dialog>
);