Files
Epicnabbo-Catalogus-Updated…/src/app/admin/navigation/page.tsx
T
simoleoandCursor 0e89d03940 Finish fine-grained ACL across remaining admin pages and actions.
Replace leftover requireStaff gates with module PERMS, drop hardcoded room rank thresholds, and expand contract tests so admin mutations cannot regress to dashboard-only checks.

Co-authored-by: Cursor <[email protected]>
2026-07-15 20:15:22 +02:00

252 lines
7.4 KiB
TypeScript

import { Navigation } from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { prisma } from "@/lib/prisma";
import { redirect } from "next/navigation";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export const dynamic = "force-dynamic";
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the
// navigator. The Prisma delegate exists because the table has a primary key.
type Flatcat = {
id: number;
caption: string;
captionSave: string;
minRank: number;
maxUserCount: number;
canTrade: string;
public: string;
listType: number;
orderNum: number;
};
// navigator_publics — emulator-owned public-room assignments. This table has no
// primary/unique key, so Prisma marks the model `@@ignore` and generates NO
// delegate (prisma.navigatorPublics does not exist). It must be read via raw SQL.
type PublicRow = {
public_cat_id: number;
room_id: number;
visible: string;
};
// Reusable yes/no pill so emulator boolean-ish "1"/"0" columns read consistently.
function YesNo({ on }: { on: boolean }) {
return (
<span
className={`inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full ${on ? "bg-[var(--admin-success-subtle)] text-[var(--admin-text)]" : "bg-[var(--admin-error-subtle)] text-[var(--admin-text)]"}`}
>
{on ? "Yes" : "No"}
</span>
);
}
export default async function AdminNavigation() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.PAGES_VIEW, session.user.rank)) {
redirect("/admin");
}
const t = await getTranslations("pages.admin.navigation");
let flatcats: Flatcat[] = [];
try {
flatcats = await prisma.navigatorFlatcats.findMany({
select: {
id: true,
caption: true,
captionSave: true,
minRank: true,
maxUserCount: true,
canTrade: true,
public: true,
listType: true,
orderNum: true,
},
orderBy: [{ orderNum: "asc" }, { id: "asc" }],
});
} catch {
flatcats = [];
}
// Raw read because navigator_publics is an `@@ignore`d (keyless) model.
let publics: PublicRow[] = [];
try {
publics = await prisma.$queryRaw<PublicRow[]>`
SELECT public_cat_id, room_id, visible
FROM navigator_publics
ORDER BY public_cat_id ASC, room_id ASC
`;
} catch {
publics = [];
}
// Resolve the public-category names and room names referenced by the rows above
// so the read-only view is legible. Each lookup degrades to an empty map on error.
const catIds = Array.from(new Set(publics.map((p) => p.public_cat_id)));
const roomIds = Array.from(new Set(publics.map((p) => p.room_id)));
let catNameById = new Map<number, string>();
if (catIds.length) {
try {
const cats = await prisma.navigatorPubliccats.findMany({
where: { id: { in: catIds } },
select: { id: true, name: true },
});
catNameById = new Map(cats.map((c) => [c.id, c.name]));
} catch {
catNameById = new Map();
}
}
let roomNameById = new Map<number, string>();
if (roomIds.length) {
try {
const rooms = await prisma.rooms.findMany({
where: { id: { in: roomIds } },
select: { id: true, name: true },
});
roomNameById = new Map(rooms.map((r) => [r.id, r.name]));
} catch {
roomNameById = new Map();
}
}
const publicFlatcats = flatcats.filter((c) => c.public === "1").length;
const visiblePublics = publics.filter((p) => p.visible === "1").length;
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(--admin-accent)]/20 to-[var(--admin-accent)]/5 grid place-items-center">
<Navigation size={20} className="text-[var(--admin-accent)]" />
</div>
<div>
<h1 className="m-0 text-xl font-extrabold text-[var(--admin-text)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-text-muted)] mt-0.5">
{t("subtitle")}
</p>
</div>
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("flatCategories")}
value={flatcats.length}
icon="📂"
/>
<StatusCard
label={t("publicFlatcats")}
value={publicFlatcats}
hint={t("markedPublic")}
icon="🌐"
/>
<StatusCard label={t("publicRooms")} value={publics.length} icon="🚪" />
<StatusCard
label={t("visibleRooms")}
value={visiblePublics}
state={visiblePublics > 0 ? "ok" : "neutral"}
icon="👁️"
/>
</div>
{/* ── navigator_flatcats ───────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">{t("flatCategories")}</h2>
<p className="text-xs theme-text-muted dark:theme-text-muted">
<code>navigator_flatcats</code> · {flatcats.length} {t("rows")}
</p>
{flatcats.length === 0 ? (
<div className="admin-empty">{t("noCategories")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colId")}</th>
<th>Caption</th>
<th>Caption (save)</th>
<th>Min rank</th>
<th>Max users</th>
<th>Can trade</th>
<th>Public</th>
<th>List type</th>
<th>{t("colOrder")}</th>
</tr>
</thead>
<tbody>
{flatcats.map((c) => (
<tr key={c.id}>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{c.id}
</td>
<td>{c.caption || "—"}</td>
<td className="text-sm theme-text-muted dark:theme-text-muted whitespace-nowrap">
{c.captionSave || "—"}
</td>
<td>{c.minRank}</td>
<td>{c.maxUserCount}</td>
<td>
<YesNo on={c.canTrade === "1"} />
</td>
<td>
<YesNo on={c.public === "1"} />
</td>
<td>{c.listType}</td>
<td>{c.orderNum}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
{/* ── navigator_publics ────────────────────────────────── */}
<section className="mt-6">
<h2 className="admin-section-title">{t("publicRooms")}</h2>
<p className="text-xs theme-text-muted dark:theme-text-muted">
<code>navigator_publics</code> · {publics.length} {t("rows")}
</p>
{publics.length === 0 ? (
<div className="admin-empty">{t("noPublicRooms")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>Public cat</th>
<th>{t("colName")}</th>
<th>Visible</th>
</tr>
</thead>
<tbody>
{publics.map((p, i) => (
<tr key={`${p.public_cat_id}-${p.room_id}-${i}`}>
<td>
{catNameById.get(p.public_cat_id) ?? "—"}{" "}
<span className="text-sm theme-text-muted dark:theme-text-muted">
#{p.public_cat_id}
</span>
</td>
<td>
{roomNameById.get(p.room_id) || t("unnamed")}{" "}
<span className="text-sm theme-text-muted dark:theme-text-muted">
#{p.room_id}
</span>
</td>
<td>
<YesNo on={p.visible === "1"} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}