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]>
171 lines
3.9 KiB
TypeScript
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>
|
|
);
|
|
}
|