Files
EpicNext-Cms/src/app/admin/moderation/page.tsx
T
openhands 7a41775c7e fix: disable route prefetching app-wide to avoid spurious requests
Wrap next/link in a shared Link component that ships prefetch=false by
default, so no route is ever prefetched (viewport or hover) anymore, and
drop the DNS prefetch hint. Removes hidden background requests that were
the source of intermittent issues.
2026-08-29 19:27:13 +02:00

237 lines
6.8 KiB
TypeScript

import { and, count, desc, eq, gte, inArray, like } from "drizzle-orm";
import {
AlertTriangle,
Ban as BanIcon,
Clock,
Gavel,
Radio,
Shield,
User as UserIcon,
} from "lucide-react";
import { redirect } from "next/navigation";
import { StatsCard } from "@/components/admin/stats-card";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { AdminAuditLog, Ban, db, SupportTickets, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const CFH_STATES: Record<number, { label: string; class: string }> = {
0: {
label: "Open",
class: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
},
1: {
label: "Picked",
class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
},
2: { label: "Closed", class: "bg-muted text-muted-foreground" },
};
export default async function ModerationDashboard() {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank))
redirect("/admin");
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const minStaffRank = await getMinStaffRank();
const [
openCfhRows,
pickedCfhRows,
todayBansRows,
modsOnlineRows,
recentCfh,
recentModActions,
] = await Promise.all([
db
.select({ total: count() })
.from(SupportTickets)
.where(eq(SupportTickets.state, 0)),
db
.select({ total: count() })
.from(SupportTickets)
.where(eq(SupportTickets.state, 1)),
db
.select({ total: count() })
.from(Ban)
.where(gte(Ban.timestamp, todayStart)),
db
.select({ total: count() })
.from(User)
.where(and(eq(User.online, "1"), gte(User.rank, minStaffRank))),
db.select().from(SupportTickets).orderBy(desc(SupportTickets.id)).limit(10),
db
.select({
id: AdminAuditLog.id,
action: AdminAuditLog.action,
details: AdminAuditLog.details,
createdAt: AdminAuditLog.createdAt,
userId: AdminAuditLog.userId,
target: AdminAuditLog.target,
targetId: AdminAuditLog.targetId,
username: User.username,
})
.from(AdminAuditLog)
.leftJoin(User, eq(AdminAuditLog.userId, User.id))
.where(like(AdminAuditLog.action, "mod_%"))
.orderBy(desc(AdminAuditLog.id))
.limit(10),
]);
const openCfh = Number(openCfhRows[0]?.total ?? 0);
const pickedCfh = Number(pickedCfhRows[0]?.total ?? 0);
const todayBans = Number(todayBansRows[0]?.total ?? 0);
const modsOnline = Number(modsOnlineRows[0]?.total ?? 0);
// Fetch usernames for CFH tickets
const userIds = [
...new Set([
...recentCfh.map((t) => t.senderId),
...recentCfh.map((t) => t.reportedId),
...recentCfh.map((t) => t.modId),
]),
].filter(Boolean) as number[];
const users =
userIds.length > 0
? await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
: [];
const userMap = new Map(users.map((u) => [u.id, u.username]));
return (
<div className="space-y-6">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<StatsCard
title="Open CFH"
value={openCfh}
description={`${pickedCfh} picked`}
icon={<Radio className="h-4 w-4" />}
href="/admin/moderation/cfh"
/>
<StatsCard
title="Bans Today"
value={todayBans}
icon={<BanIcon className="h-4 w-4" />}
href="/admin/bans"
/>
<StatsCard
title="Mods Online"
value={modsOnline}
icon={<Shield className="h-4 w-4" />}
href="/admin/moderation/team"
/>
<StatsCard
title="Quick Actions"
value="→"
description="Kick, mute, alert"
icon={<Gavel className="h-4 w-4" />}
href="/admin/moderation/actions"
/>
</div>
<div className="grid gap-6 lg:grid-cols-2">
{/* Recent CFH */}
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between">
<span className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4 text-[var(--admin-error)]" />
Recent CFH Tickets
</span>
<Link
href="/admin/moderation/cfh"
className="text-xs font-normal text-primary hover:underline"
>
View all
</Link>
</CardTitle>
</CardHeader>
<CardContent>
{recentCfh.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No CFH tickets
</p>
) : (
<div className="divide-y divide-border">
{recentCfh.map((ticket) => {
const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
return (
<Link
key={ticket.id}
href={`/admin/moderation/cfh/${ticket.id}`}
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0 hover:bg-accent/50 -mx-2 px-2 rounded transition-colors"
>
<span className="text-xs font-mono text-muted-foreground w-8">
#{ticket.id}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm truncate">
{ticket.issue || "No description"}
</p>
<p className="text-xs text-muted-foreground">
{userMap.get(ticket.senderId) ?? "?"} →{" "}
{userMap.get(ticket.reportedId) ?? "?"}
</p>
</div>
<Badge className={`text-[10px] ${state.class}`}>
{state.label}
</Badge>
</Link>
);
})}
</div>
)}
</CardContent>
</Card>
{/* Recent Mod Actions */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 w-4" />
Recent Mod Actions
</CardTitle>
</CardHeader>
<CardContent>
{recentModActions.length === 0 ? (
<p className="text-sm text-muted-foreground text-center py-4">
No recent actions
</p>
) : (
<div className="divide-y divide-border">
{recentModActions.map((log) => (
<div
key={log.id}
className="flex items-center gap-3 py-2.5 first:pt-0 last:pb-0"
>
<UserIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">
{log.action.replace("mod_", "").replace(/_/g, " ")}
</p>
<p className="text-xs text-muted-foreground">
by {log.username ?? "?"} · {log.target}{" "}
{log.targetId ? `#${log.targetId}` : ""}
</p>
</div>
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
{log.createdAt}
</span>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}