Restore missing catalog admin API routes so Visual Manager works.
Local Build and Deploy / deploy (push) Successful in 1m10s
Local Build and Deploy / deploy (push) Successful in 1m10s
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
ce8c3503e9
commit
11d3cf31cc
6 files changed
+490
-10
No files matched your search
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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 });
|
||||
},
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user