diff --git a/src/app/api/admin/catalog/icons/route.ts b/src/app/api/admin/catalog/icons/route.ts new file mode 100644 index 00000000..715ac5a5 --- /dev/null +++ b/src/app/api/admin/catalog/icons/route.ts @@ -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 }); + }, +); diff --git a/src/app/api/admin/catalog/images/route.ts b/src/app/api/admin/catalog/images/route.ts new file mode 100644 index 00000000..543509d1 --- /dev/null +++ b/src/app/api/admin/catalog/images/route.ts @@ -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(); + 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 }); + }, +); diff --git a/src/app/api/admin/catalog/items/route.ts b/src/app/api/admin/catalog/items/route.ts new file mode 100644 index 00000000..e5d29174 --- /dev/null +++ b/src/app/api/admin/catalog/items/route.ts @@ -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); + }, +); diff --git a/src/app/api/admin/catalog/suggest/route.ts b/src/app/api/admin/catalog/suggest/route.ts new file mode 100644 index 00000000..bf4dc547 --- /dev/null +++ b/src/app/api/admin/catalog/suggest/route.ts @@ -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 = + {}; + + 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 }); + }, +); diff --git a/src/app/api/admin/catalog/tree/route.ts b/src/app/api/admin/catalog/tree/route.ts new file mode 100644 index 00000000..e1fed99d --- /dev/null +++ b/src/app/api/admin/catalog/tree/route.ts @@ -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 { + 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(); + 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(); + 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 { + 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 { + 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({}); + }, +); diff --git a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx index 904c964b..1bc13cce 100644 --- a/src/components/admin/catalog-manager/catalog-manager-dialog.tsx +++ b/src/components/admin/catalog-manager/catalog-manager-dialog.tsx @@ -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 ( - - - + - - - + {open ? ( + + + + ) : null} );