Add missing admin action files and navigation links
- Add 11 missing server action files: badges, bulk-users, catalog, catalog-bc, catalog-items, import-badges, import-furni, multi-account-detect, permissions, rooms, soundtracks - Add missing admin navigation links: tickets, sounds, translations, import, radio sub-pages - Add translation keys for all new navigation items
This commit is contained in:
1 parent
7e1ae17a3b
commit
41be6835bf
347 files changed
+28378
-6091
No files matched your search
+380
-119
@@ -1,130 +1,391 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Package } from "lucide-react";
|
||||
import dynamic from 'next/dynamic'
|
||||
import { redirect } from 'next/navigation'
|
||||
import { CatalogTree } from '@/components/admin/catalog-tree'
|
||||
import { Prisma } from '@/generated/prisma/client'
|
||||
import { calcPagination, parseListParams } from '@/lib/admin-helpers'
|
||||
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { CatalogActions } from './catalog-actions'
|
||||
import { CatalogTable } from './catalog-table'
|
||||
import { CatalogViewToggle } from './catalog-view-toggle'
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type CatalogPageRow = {
|
||||
id: number;
|
||||
caption: string;
|
||||
parentId: number;
|
||||
pageLayout: string;
|
||||
minRank: number;
|
||||
orderNum: number;
|
||||
visible: string;
|
||||
enabled: string;
|
||||
};
|
||||
|
||||
export default async function AdminCatalog() {
|
||||
const t = await getTranslations("pages.admin.catalog");
|
||||
|
||||
let pages: CatalogPageRow[] = [];
|
||||
try {
|
||||
pages = await prisma.catalogPages.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
caption: true,
|
||||
parentId: true,
|
||||
pageLayout: true,
|
||||
minRank: true,
|
||||
orderNum: true,
|
||||
visible: true,
|
||||
enabled: true,
|
||||
},
|
||||
orderBy: [{ parentId: "asc" }, { orderNum: "asc" }, { id: "asc" }],
|
||||
take: 500,
|
||||
});
|
||||
} catch {
|
||||
pages = [];
|
||||
const CatalogManagerDialog = dynamic(() =>
|
||||
import('@/components/admin/catalog-manager/catalog-manager-dialog').then((m) => ({
|
||||
default: m.CatalogManagerDialog,
|
||||
})),
|
||||
)
|
||||
export default async function CatalogPagesPage({
|
||||
params: paramsPromise,
|
||||
searchParams,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
searchParams: Promise<Record<string, string>>
|
||||
}) {
|
||||
await paramsPromise
|
||||
const { session, permissions } = await getAdminContext()
|
||||
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
||||
redirect('/admin')
|
||||
}
|
||||
|
||||
const captionById = new Map<number, string>();
|
||||
for (const p of pages) captionById.set(p.id, p.caption);
|
||||
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank)
|
||||
|
||||
const visibleCount = pages.filter(
|
||||
(p) => p.visible === "1" && p.enabled === "1",
|
||||
).length;
|
||||
const hiddenCount = pages.length - visibleCount;
|
||||
const rootCount = pages.filter((p) => p.parentId <= 0).length;
|
||||
const params = await searchParams
|
||||
const sp = new URLSearchParams(params)
|
||||
const { search, perPage, page, sort, order } = parseListParams(sp)
|
||||
const viewMode = sp.get('view') || 'tree'
|
||||
const catalogType = sp.get('catalog') || 'normal'
|
||||
|
||||
// ── Builder Club mode ──────────────────────────────────────────
|
||||
type TreeNodeShape = {
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
enabled: string
|
||||
visible: string
|
||||
iconImage: number
|
||||
iconColor: number
|
||||
pageLayout: string
|
||||
childCount: number
|
||||
itemCount: number
|
||||
}
|
||||
|
||||
if (catalogType === 'bc') {
|
||||
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
|
||||
prisma.catalog_pages_bc.count(),
|
||||
prisma.catalog_items_bc.count(),
|
||||
prisma.catalog_pages_bc.count({ where: { enabled: '1' } }),
|
||||
])
|
||||
|
||||
// ── BC tree view ──────────────────────────────────────────────
|
||||
let bcRootPages: TreeNodeShape[] = []
|
||||
if (viewMode === 'tree') {
|
||||
const bcRootRaw = await prisma.catalog_pages_bc.findMany({
|
||||
where: { OR: [{ parent_id: -1 }, { parent_id: 0 }] },
|
||||
orderBy: { order_num: 'asc' },
|
||||
})
|
||||
const bcRootIds = bcRootRaw.map((p) => p.id)
|
||||
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
||||
bcRootIds.length > 0
|
||||
? prisma.catalog_pages_bc.groupBy({
|
||||
by: ['parent_id'],
|
||||
where: { parent_id: { in: bcRootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
bcRootIds.length > 0
|
||||
? prisma.catalog_items_bc.groupBy({
|
||||
by: ['page_id'],
|
||||
where: { page_id: { in: bcRootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
])
|
||||
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parent_id, c._count]))
|
||||
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.page_id, c._count]))
|
||||
bcRootPages = bcRootRaw.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parent_id,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
iconImage: p.icon_image,
|
||||
iconColor: p.icon_color,
|
||||
pageLayout: p.page_layout,
|
||||
childCount: bcChildMap[p.id] ?? 0,
|
||||
itemCount: bcItemMap[p.id] ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── BC table view ─────────────────────────────────────────────
|
||||
let bcRows: {
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
parentCaption: string
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
orderNum: number
|
||||
itemCount: number
|
||||
childCount: number
|
||||
}[] = []
|
||||
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 }
|
||||
|
||||
if (viewMode === 'table') {
|
||||
const parentFilter = sp.get('parent')
|
||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
|
||||
if (search) {
|
||||
const pattern = { contains: search }
|
||||
where.OR = [{ caption: pattern }]
|
||||
}
|
||||
if (hasParent) where.parent_id = parentId
|
||||
|
||||
// Prisma sort key is snake_case for BC model. Translate known keys.
|
||||
const sortKeyMap: Record<string, string> = {
|
||||
parentId: 'parent_id',
|
||||
pageLayout: 'page_layout',
|
||||
orderNum: 'order_num',
|
||||
}
|
||||
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : 'order_num'
|
||||
const orderBy = { [sortKey]: sort ? order : 'asc' }
|
||||
|
||||
const [pages, total] = await Promise.all([
|
||||
prisma.catalog_pages_bc.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.catalog_pages_bc.count({ where }),
|
||||
])
|
||||
|
||||
bcPagination = calcPagination(total, page, perPage)
|
||||
|
||||
const pageIds = pages.map((p) => p.id)
|
||||
const parentIds = [...new Set(pages.map((p) => p.parent_id).filter((id) => id > 0))]
|
||||
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
||||
pageIds.length > 0
|
||||
? prisma.catalog_pages_bc.groupBy({
|
||||
by: ['parent_id'],
|
||||
where: { parent_id: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
pageIds.length > 0
|
||||
? prisma.catalog_items_bc.groupBy({
|
||||
by: ['page_id'],
|
||||
where: { page_id: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
parentIds.length > 0
|
||||
? prisma.catalog_pages_bc.findMany({
|
||||
where: { id: { in: parentIds } },
|
||||
select: { id: true, caption: true },
|
||||
})
|
||||
: [],
|
||||
])
|
||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parent_id, c._count]))
|
||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.page_id, c._count]))
|
||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
|
||||
|
||||
bcRows = pages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parent_id,
|
||||
parentCaption:
|
||||
p.parent_id === -1 ? 'Root' : (parentNameMap[p.parent_id] ?? `#${p.parent_id}`),
|
||||
pageLayout: String(p.page_layout),
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
minRank: 0, // BC pages have no min rank column
|
||||
orderNum: p.order_num,
|
||||
itemCount: itemCountMap[p.id] ?? 0,
|
||||
childCount: childCountMap[p.id] ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{totalBcPages} BC pages ({enabledBcPages} enabled) · {totalBcItems} items
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
||||
{canEdit && <CatalogActions catalogType="bc" />}
|
||||
</div>
|
||||
</div>
|
||||
{viewMode === 'tree' ? (
|
||||
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
||||
) : (
|
||||
<CatalogTable
|
||||
data={{ rows: bcRows, ...bcPagination }}
|
||||
canEdit={canEdit}
|
||||
catalogType="bc"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Normal catalog mode ────────────────────────────────────────
|
||||
|
||||
// Global stats (lightweight counts)
|
||||
const [totalPages, totalItems, enabledPages] = await Promise.all([
|
||||
prisma.catalogPage.count(),
|
||||
prisma.catalogItem.count(),
|
||||
prisma.catalogPage.count({ where: { enabled: '1' } }),
|
||||
])
|
||||
|
||||
// ── Tree view data ──────────────────────────────────────────────
|
||||
let rootPages: TreeNodeShape[] = []
|
||||
if (viewMode === 'tree') {
|
||||
const rootPagesRaw = await prisma.catalogPage.findMany({
|
||||
where: { parentId: -1 },
|
||||
orderBy: { orderNum: 'asc' },
|
||||
})
|
||||
const rootIds = rootPagesRaw.map((p) => p.id)
|
||||
const [rootChildCounts, rootItemCounts] = await Promise.all([
|
||||
rootIds.length > 0
|
||||
? prisma.catalogPage.groupBy({
|
||||
by: ['parentId'],
|
||||
where: { parentId: { in: rootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
rootIds.length > 0
|
||||
? prisma.catalogItem.groupBy({
|
||||
by: ['pageId'],
|
||||
where: { pageId: { in: rootIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
])
|
||||
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]))
|
||||
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]))
|
||||
rootPages = rootPagesRaw.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
iconImage: p.iconImage,
|
||||
iconColor: p.iconColor,
|
||||
pageLayout: p.pageLayout,
|
||||
childCount: rootChildMap[p.id] ?? 0,
|
||||
itemCount: rootItemMap[p.id] ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
// ── Table view data (skip if tree) ────────────────────────────
|
||||
let rows: {
|
||||
id: number
|
||||
caption: string
|
||||
parentId: number
|
||||
parentCaption: string
|
||||
pageLayout: string
|
||||
enabled: string
|
||||
visible: string
|
||||
minRank: number
|
||||
orderNum: number
|
||||
itemCount: number
|
||||
childCount: number
|
||||
}[] = []
|
||||
let pagination = { total: 0, page: 1, perPage, lastPage: 0 }
|
||||
|
||||
if (viewMode === 'table') {
|
||||
const parentFilter = sp.get('parent')
|
||||
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined
|
||||
|
||||
const where: Record<string, unknown> = {}
|
||||
const hasParent = parentId !== undefined && !Number.isNaN(parentId)
|
||||
if (search) {
|
||||
const searchPattern = `%${search}%`
|
||||
const parentClause = hasParent ? Prisma.sql`AND cp.parent_id = ${parentId}` : Prisma.empty
|
||||
|
||||
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
|
||||
SELECT DISTINCT cp.id FROM catalog_pages cp
|
||||
LEFT JOIN catalog_items ci ON ci.page_id = cp.id
|
||||
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
|
||||
WHERE (cp.caption LIKE ${searchPattern}
|
||||
OR cp.page_layout LIKE ${searchPattern}
|
||||
OR ci.catalog_name LIKE ${searchPattern}
|
||||
OR ib.public_name LIKE ${searchPattern}
|
||||
OR ib.item_name LIKE ${searchPattern})
|
||||
${parentClause}
|
||||
`)
|
||||
where.id = { in: matchedIds.map((r) => r.id) }
|
||||
} else if (hasParent) {
|
||||
where.parentId = parentId
|
||||
}
|
||||
|
||||
const orderBy = sort ? { [sort]: order } : { orderNum: 'asc' as const }
|
||||
|
||||
const [pages, total] = await Promise.all([
|
||||
prisma.catalogPage.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: (page - 1) * perPage,
|
||||
take: perPage,
|
||||
}),
|
||||
prisma.catalogPage.count({ where }),
|
||||
])
|
||||
|
||||
pagination = calcPagination(total, page, perPage)
|
||||
|
||||
// Count children and items per page, and resolve parent names in parallel
|
||||
const pageIds = pages.map((p) => p.id)
|
||||
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))]
|
||||
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
||||
pageIds.length > 0
|
||||
? prisma.catalogPage.groupBy({
|
||||
by: ['parentId'],
|
||||
where: { parentId: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
pageIds.length > 0
|
||||
? prisma.catalogItem.groupBy({
|
||||
by: ['pageId'],
|
||||
where: { pageId: { in: pageIds } },
|
||||
_count: true,
|
||||
})
|
||||
: [],
|
||||
parentIds.length > 0
|
||||
? prisma.catalogPage.findMany({
|
||||
where: { id: { in: parentIds } },
|
||||
select: { id: true, caption: true },
|
||||
})
|
||||
: [],
|
||||
])
|
||||
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]))
|
||||
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]))
|
||||
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]))
|
||||
|
||||
rows = pages.map((p) => ({
|
||||
id: p.id,
|
||||
caption: p.caption,
|
||||
parentId: p.parentId,
|
||||
parentCaption: p.parentId === -1 ? 'Root' : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
||||
pageLayout: p.pageLayout,
|
||||
enabled: p.enabled,
|
||||
visible: p.visible,
|
||||
minRank: p.minRank,
|
||||
orderNum: p.orderNum,
|
||||
itemCount: itemCountMap[p.id] ?? 0,
|
||||
childCount: childCountMap[p.id] ?? 0,
|
||||
}))
|
||||
}
|
||||
|
||||
return (
|
||||
<main>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-[var(--color-primary)]/20 to-[var(--color-primary)]/5 grid place-items-center">
|
||||
<Package size={20} className="text-[var(--color-primary)]" />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="m-0 text-xl font-extrabold text-[var(--color-text)]">{t("title")}</h1>
|
||||
<p className="m-0 text-xs text-[var(--color-text-muted)] mt-0.5">{t("subtitle")}</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{totalPages} pages ({enabledPages} enabled) · {totalItems} items
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
||||
{canEdit && <CatalogManagerDialog />}
|
||||
{canEdit && <CatalogActions />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
|
||||
<StatusCard label={t("pages")} value={pages.length} icon="📚" />
|
||||
<StatusCard label={t("visible")} value={visibleCount} state="ok" icon="👁️" />
|
||||
<StatusCard
|
||||
label={t("hidden")}
|
||||
value={hiddenCount}
|
||||
state={hiddenCount > 0 ? "warn" : "neutral"}
|
||||
icon="🚫"
|
||||
/>
|
||||
<StatusCard label={t("rootPages")} value={rootCount} icon="🗂️" />
|
||||
</div>
|
||||
|
||||
<section className="mt-6">
|
||||
<h2>{t("pages")} ({pages.length})</h2>
|
||||
{pages.length === 0 ? (
|
||||
<p className="muted">{t("noPages")}</p>
|
||||
) : (
|
||||
<div style={{ overflowX: "auto" }}>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("colId")}</th>
|
||||
<th>{t("caption")}</th>
|
||||
<th>{t("colParent")}</th>
|
||||
<th>{t("colLayout")}</th>
|
||||
<th>{t("colMinRank")}</th>
|
||||
<th>{t("colOrder")}</th>
|
||||
<th>{t("colStatus")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.map((p) => {
|
||||
const parentName =
|
||||
p.parentId > 0 ? captionById.get(p.parentId) : null;
|
||||
const hidden = p.visible !== "1" || p.enabled !== "1";
|
||||
return (
|
||||
<tr key={p.id}>
|
||||
<td className="muted">{p.id}</td>
|
||||
<td>
|
||||
<Link href={`/admin/catalog/${p.id}`}>{p.caption}</Link>
|
||||
</td>
|
||||
<td className="muted">
|
||||
{p.parentId <= 0
|
||||
? "—"
|
||||
: parentName
|
||||
? `${parentName} (#${p.parentId})`
|
||||
: `#${p.parentId}`}
|
||||
</td>
|
||||
<td className="muted">{p.pageLayout}</td>
|
||||
<td>{p.minRank}</td>
|
||||
<td>{p.orderNum}</td>
|
||||
<td>
|
||||
<span className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${hidden ? "bg-red-500/22 text-[var(--color-text)]" : "bg-green-600/20 text-[var(--color-text)]"}`}>
|
||||
{hidden ? t("statusHidden") : t("statusVisible")}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
{viewMode === 'tree' ? (
|
||||
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
||||
) : (
|
||||
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in new issue
Block a user