Files
EpicNext-Cms/src/app/mod/users/page.tsx
T
SimoandCursor 6eab5e5343 feat(admin): ACL repair, mod users, ticket clarity, ops online hub
Add Repair nav grants on permissions, /mod/users without email/IP, shared ticket queue banners, and shared online roster on CommandoCentrum.

Co-authored-by: Cursor <[email protected]>
2026-07-30 19:37:01 +02:00

171 lines
3.9 KiB
TypeScript

import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { DataTable } from "@/components/admin/data-table";
import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
import type { DataTableColumn } from "@/types/common";
export const dynamic = "force-dynamic";
type ModUserRow = {
id: number;
username: string;
rank: number;
online: string;
motto: string;
look: string;
lastLogin: number | null;
};
const columns: DataTableColumn<ModUserRow>[] = [
{ key: "id", label: "ID", sortable: true },
{
key: "username",
label: "User",
sortable: true,
render: (_, row) => (
<div className="flex items-center gap-2.5">
<img
src={getAvatarUrl(row.look, {
size: "m",
direction: 2,
headDirection: 2,
})}
alt=""
width={32}
height={32}
className="rounded"
/>
<span className="font-medium">{row.username}</span>
</div>
),
},
{
key: "rank",
label: "Rank",
sortable: true,
render: (v) => <Badge variant="secondary">#{String(v)}</Badge>,
},
{
key: "online",
label: "Online",
render: (v) =>
v === "1" ? (
<Badge className="bg-[var(--admin-success)]">Online</Badge>
) : (
<Badge variant="outline">Offline</Badge>
),
},
{
key: "motto",
label: "Motto",
render: (v) => (
<span className="text-muted-foreground truncate max-w-[220px] block">
{String(v || "—")}
</span>
),
},
{
key: "actions",
label: "",
render: (_, row) => (
<div className="flex gap-2 justify-end">
<Link
href={`/mod/users/${row.id}`}
className="text-sm text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
Mod view
</Link>
<Link
href={`/u/${row.username}`}
className="text-sm text-muted-foreground underline-offset-2 hover:underline"
>
Public
</Link>
</div>
),
},
];
export default async function ModUsersPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW]);
const t = await getTranslations("pages.mod");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const where = parsed.search.trim()
? {
OR: [
{ username: { contains: parsed.search.trim() } },
{ motto: { contains: parsed.search.trim() } },
...(Number.isFinite(Number(parsed.search)) &&
Number(parsed.search) > 0
? [{ id: Number(parsed.search) }]
: []),
],
}
: {};
const orderBy = parsed.sort
? { [parsed.sort]: parsed.order }
: { id: "desc" as const };
const [total, users] = await Promise.all([
prisma.user.count({ where }).catch(() => 0),
prisma.user
.findMany({
where,
orderBy,
skip: (parsed.page - 1) * parsed.perPage,
take: parsed.perPage,
select: {
id: true,
username: true,
rank: true,
online: true,
motto: true,
look: true,
lastLogin: true,
},
})
.catch(() => []),
]);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const rows: ModUserRow[] = users;
return (
<div className="space-y-6">
<div>
<h2 className="admin-section-title m-0">{t("usersTitle")}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{t("usersSubtitle")}
</p>
</div>
{total === 0 && !parsed.search ? (
<div className="admin-empty">{t("usersEmpty")}</div>
) : (
<DataTable
columns={columns}
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
searchPlaceholder={t("usersSearchPlaceholder")}
/>
)}
</div>
);
}