Revert "Merge pull request 'Complete Housekeeping migration and /ase cutover' (#52) from codex/housekeeping-complete into main"

This reverts commit 488b6e57c4, reversing
changes made to b506b4499a.
This commit is contained in:
Simo committed 2026-08-30 21:31:34 +02:00
1 parent 488b6e57c4
commit b1ddda66ff
802 files changed
+61370 -76659

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import { ModActionsClient } from "@/app/admin/moderation/actions/mod-actions-client";
import { requireModPermission } from "@/lib/admin/guard";
import { PERMS } from "@/lib/permissions";
export default async function ModQuickActionsPage() {
await requireModPermission([PERMS.MOD_ACTIONS, PERMS.MODERATION_EDIT]);
return (
<div className="space-y-6">
<ModActionsClient />
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { desc, eq, gt, or } from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { requireModPermission } from "@/lib/admin/guard";
import { unixNow } from "@/lib/bans";
import { Ban, db } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
type BanRow = {
id: number;
userId: number;
type: "account" | "ip" | "machine" | "super";
banReason: string;
banExpire: number;
};
export default async function ModBansPage() {
await requireModPermission([PERMS.MOD_BANS_VIEW, PERMS.BANS_VIEW]);
const t = await getTranslations("pages.admin.bans");
const now = unixNow();
let bans: BanRow[] = [];
try {
bans = await db
.select({
id: Ban.id,
userId: Ban.userId,
type: Ban.type,
banReason: Ban.banReason,
banExpire: Ban.banExpire,
})
.from(Ban)
.where(or(eq(Ban.banExpire, 0), gt(Ban.banExpire, now)))
.orderBy(desc(Ban.timestamp))
.limit(100);
} catch {
bans = [];
}
const permanent = bans.filter((b) => b.banExpire === 0).length;
const accountBans = bans.filter((b) => b.type === "account").length;
return (
<main>
<p className="mb-4 text-sm text-[var(--admin-text-muted)]">
Read-only ban list for moderators. Create/lift bans requires the full
admin panel.
</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5 mb-6">
<StatusCard
label={t("activeBans")}
value={bans.length}
state={bans.length > 0 ? "warn" : "ok"}
icon="🔨"
/>
<StatusCard
label={t("permanent")}
value={permanent}
state={permanent > 0 ? "danger" : "neutral"}
icon="⛔"
/>
<StatusCard label={t("accountBans")} value={accountBans} icon="👤" />
</div>
<section className="mt-6">
<h2 className="admin-section-title">
{t("activeBans")} ({bans.length})
</h2>
{bans.length === 0 ? (
<div className="admin-empty">{t("noBans")}</div>
) : (
<div className="overflow-x-auto">
<table>
<thead>
<tr>
<th>{t("colUserId")}</th>
<th>{t("colType")}</th>
<th>{t("colReason")}</th>
<th>{t("colExpires")}</th>
</tr>
</thead>
<tbody>
{bans.map((b) => (
<tr key={b.id}>
<td>{b.userId}</td>
<td>
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-accent)]/18 text-[var(--admin-text)]">
{b.type}
</span>
</td>
<td>{b.banReason || "—"}</td>
<td>
{b.banExpire === 0 ? (
<span className="inline-block text-[0.72rem] font-bold px-2 py-0.5 rounded-full bg-[var(--admin-error-subtle)] text-[var(--admin-text)]">
{t("permanent")}
</span>
) : (
formatDate(new Date(b.banExpire * 1000))
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
);
}
+111
View File
@@ -0,0 +1,111 @@
import { count, desc, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation";
import { CfhDetail } from "@/app/admin/moderation/cfh/[id]/cfh-detail";
import { requireModPermission } from "@/lib/admin/guard";
import { Ban, db, SupportTickets, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ModCfhDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW]);
const { session, permissions } = await getAdminContext();
const { id } = await params;
const ticketId = Number(id);
if (Number.isNaN(ticketId)) notFound();
const [ticket] = await db
.select()
.from(SupportTickets)
.where(eq(SupportTickets.id, ticketId))
.limit(1);
if (!ticket) notFound();
const canEdit =
canAccess(permissions, PERMS.MOD_CFH_EDIT, session.user.rank) ||
canAccess(permissions, PERMS.MODERATION_EDIT, session.user.rank);
const userIds = [ticket.senderId, ticket.reportedId, ticket.modId].filter(
Boolean,
);
const users =
userIds.length > 0
? await db
.select({
id: User.id,
username: User.username,
look: User.look,
rank: User.rank,
online: User.online,
ipRegister: User.ipRegister,
mail: User.mail,
})
.from(User)
.where(inArray(User.id, userIds))
: [];
const userMap = Object.fromEntries(users.map((u) => [u.id, u]));
const reportedId = ticket.reportedId;
let banCount = 0;
let recentBans: {
id: number;
banReason: string;
type: string;
userStaffId: number;
timestamp: number;
}[] = [];
if (reportedId) {
const [countRows, bans] = await Promise.all([
db.select({ total: count() }).from(Ban).where(eq(Ban.userId, reportedId)),
db
.select({
id: Ban.id,
banReason: Ban.banReason,
type: Ban.type,
userStaffId: Ban.userStaffId,
timestamp: Ban.timestamp,
})
.from(Ban)
.where(eq(Ban.userId, reportedId))
.orderBy(desc(Ban.id))
.limit(5),
]);
banCount = countRows[0]?.total ?? 0;
recentBans = bans;
}
return (
<CfhDetail
ticket={{
id: ticket.id,
state: ticket.state,
type: ticket.type,
timestamp: ticket.timestamp,
issue: ticket.issue,
roomId: ticket.roomId,
category: ticket.category,
score: ticket.score,
}}
sender={userMap[ticket.senderId] ?? null}
reported={userMap[ticket.reportedId] ?? null}
moderator={userMap[ticket.modId] ?? null}
banHistory={{
count: banCount,
recent: recentBans.map((b) => ({
id: b.id,
reason: b.banReason,
type: b.type,
staff: `Staff #${b.userStaffId}`,
timestamp: b.timestamp,
})),
}}
canEdit={canEdit}
currentUserId={session.user.id}
listHref="/mod/cfh"
userLinkMode="public"
/>
);
}
+171
View File
@@ -0,0 +1,171 @@
import {
and,
asc,
count,
desc,
eq,
inArray,
like,
or,
type SQL,
} from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import { type CfhRow, CfhTable } from "@/app/admin/moderation/cfh/cfh-table";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, SupportTickets, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
const SORT_COLS = {
id: SupportTickets.id,
issue: SupportTickets.issue,
sender: SupportTickets.senderId,
reported: SupportTickets.reportedId,
mod: SupportTickets.modId,
state: SupportTickets.state,
} as const;
export default async function ModCfhListPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_CFH_VIEW, PERMS.MODERATION_VIEW]);
const t = await getTranslations("pages.admin.cfh");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const stateRaw = raw.filter_state ?? raw.state;
const stateFilter =
stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
const conditions: SQL[] = [];
if (stateFilter >= 0) {
conditions.push(eq(SupportTickets.state, stateFilter));
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = Number(q);
let userIds: number[] = [];
try {
const users = await db
.select({ id: User.id })
.from(User)
.where(like(User.username, `%${q}%`))
.limit(50);
userIds = users.map((u) => u.id);
} catch {
userIds = [];
}
const ors: SQL[] = [like(SupportTickets.issue, `%${q}%`)];
if (Number.isFinite(asId) && asId > 0) {
ors.push(eq(SupportTickets.id, asId));
}
if (userIds.length) {
ors.push(inArray(SupportTickets.senderId, userIds));
ors.push(inArray(SupportTickets.reportedId, userIds));
ors.push(inArray(SupportTickets.modId, userIds));
}
const searchFilter = or(...ors);
if (searchFilter) conditions.push(searchFilter);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortKey =
parsed.sort && parsed.sort in SORT_COLS
? (parsed.sort as keyof typeof SORT_COLS)
: "id";
const sortCol = SORT_COLS[sortKey];
const finalOrder =
parsed.sort && parsed.sort in SORT_COLS
? parsed.order === "asc"
? asc(sortCol)
: desc(sortCol)
: desc(SupportTickets.id);
const total = await db
.select({ total: count() })
.from(SupportTickets)
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await db
.select()
.from(SupportTickets)
.where(where)
.orderBy(finalOrder)
.offset(pagination.offset)
.limit(pagination.perPage)
.catch(() => []);
const userIds = [
...new Set([
...tickets.map((x) => x.senderId),
...tickets.map((x) => x.reportedId),
...tickets.map((x) => x.modId),
]),
].filter(Boolean);
let userMap = new Map<number, string>();
if (userIds.length > 0) {
try {
const users = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds));
userMap = new Map(users.map((u) => [u.id, u.username]));
} catch {
userMap = new Map();
}
}
const stateLabels: Record<number, string> = {
0: t("stateOpen"),
1: t("statePicked"),
2: t("stateClosed"),
};
const rows: CfhRow[] = tickets.map((ticket) => ({
id: ticket.id,
issue: ticket.issue,
roomId: ticket.roomId,
when: ticket.timestamp
? new Date(ticket.timestamp * 1000)
.toISOString()
.slice(0, 16)
.replace("T", " ")
: "—",
sender: userMap.get(ticket.senderId) ?? "—",
senderId: ticket.senderId,
reported: userMap.get(ticket.reportedId) ?? "—",
reportedId: ticket.reportedId,
mod: userMap.get(ticket.modId) ?? "—",
state: ticket.state,
stateLabel: stateLabels[ticket.state] ?? String(ticket.state),
}));
return (
<div className="space-y-6">
{total === 0 && !parsed.search && stateFilter < 0 ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<CfhTable
basePath="/mod/cfh"
userLinkMode="public"
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
import { asc, eq, inArray } from "drizzle-orm";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminHelpTicketDetail } from "@/app/admin/help-tickets/[id]/admin-help-ticket-detail";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import { requireModPermission } from "@/lib/admin/guard";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { positiveBigInt } from "@/lib/api";
import {
db,
User,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ModHelpTicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const { session, permissions } = await getAdminContext();
const { id } = await params;
const ticketId = positiveBigInt(id);
if (!ticketId) notFound();
const [ticket] = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
title: WebsiteHelpCenterTickets.title,
content: WebsiteHelpCenterTickets.content,
open: WebsiteHelpCenterTickets.open,
createdAt: WebsiteHelpCenterTickets.createdAt,
updatedAt: WebsiteHelpCenterTickets.updatedAt,
})
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.id, ticketId))
.limit(1);
if (!ticket) notFound();
const replies = await db
.select({
id: WebsiteHelpCenterTicketReplies.id,
userId: WebsiteHelpCenterTicketReplies.userId,
content: WebsiteHelpCenterTicketReplies.content,
createdAt: WebsiteHelpCenterTicketReplies.createdAt,
})
.from(WebsiteHelpCenterTicketReplies)
.where(eq(WebsiteHelpCenterTicketReplies.ticketId, ticket.id))
.orderBy(asc(WebsiteHelpCenterTicketReplies.id));
const authorIds = [
...new Set([
...(ticket.userId != null ? [ticket.userId] : []),
...replies.map((r) => r.userId),
]),
];
const users =
authorIds.length > 0
? await db
.select({
id: User.id,
username: User.username,
rank: User.rank,
mail: User.mail,
})
.from(User)
.where(inArray(User.id, authorIds))
: [];
const userById = new Map(users.map((u) => [u.id, u]));
const openerId = ticket.userId;
const minStaffRank = await getMinStaffRank();
const messages = [
{
key: "opening",
userId: openerId ?? 0,
username:
openerId != null
? (userById.get(openerId)?.username ?? `#${openerId}`)
: "—",
isStaff: false,
content: ticket.content,
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
},
...replies.map((r) => {
const user = userById.get(r.userId);
const isStaff =
r.userId !== openerId && (user?.rank ?? 0) >= minStaffRank;
return {
key: String(r.id),
userId: r.userId,
username: user?.username ?? `#${r.userId}`,
isStaff,
content: r.content,
createdAt: (r.createdAt ?? new Date()).toISOString(),
};
}),
];
const creator = openerId != null ? userById.get(openerId) : undefined;
const canEdit =
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
const t = await getTranslations("pages.admin.helpTickets");
const queues = await fetchTicketQueueOpenCounts();
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/mod/tickets"
otherLabel={t("queueOtherCta")}
countsLine={t("queueCounts", {
here: queues.helpOpen,
other: queues.cmsOpen,
})}
/>
<AdminHelpTicketDetail
ticket={{
id: String(ticket.id),
title: ticket.title,
open: ticket.open,
createdAt: (ticket.createdAt ?? new Date()).toISOString(),
updatedAt: (ticket.updatedAt ?? new Date()).toISOString(),
creator: creator
? {
id: creator.id,
username: creator.username,
rank: creator.rank,
mail: creator.mail ?? "",
}
: null,
}}
messages={messages}
canEdit={canEdit}
listHref="/mod/help-tickets"
hideRequesterContact
/>
</div>
);
}
+224
View File
@@ -0,0 +1,224 @@
import {
and,
asc,
count,
desc,
eq,
inArray,
like,
or,
type SQL,
} from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import {
type HelpTicketRow,
HelpTicketsTable,
} from "@/app/admin/help-tickets/help-tickets-table";
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import { requireModPermission } from "@/lib/admin/guard";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import {
db,
User,
WebsiteHelpCenterTicketReplies,
WebsiteHelpCenterTickets,
} from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
const SORT_COLS = {
title: WebsiteHelpCenterTickets.title,
open: WebsiteHelpCenterTickets.open,
user: WebsiteHelpCenterTickets.userId,
date: WebsiteHelpCenterTickets.createdAt,
updated: WebsiteHelpCenterTickets.updatedAt,
id: WebsiteHelpCenterTickets.id,
} as const;
export default async function ModHelpTicketsPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const t = await getTranslations("pages.admin.helpTickets");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const statusFilter = raw.filter_status || "open";
const conditions: SQL[] = [];
if (statusFilter === "open") {
conditions.push(eq(WebsiteHelpCenterTickets.open, true));
} else if (statusFilter === "closed") {
conditions.push(eq(WebsiteHelpCenterTickets.open, false));
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = /^\d+$/.test(q) ? BigInt(q) : null;
const matchingUsers = await db
.select({ id: User.id })
.from(User)
.where(like(User.username, `%${q}%`))
.limit(50)
.catch(() => []);
const ors: SQL[] = [
like(WebsiteHelpCenterTickets.title, `%${q}%`),
like(WebsiteHelpCenterTickets.content, `%${q}%`),
];
if (matchingUsers.length > 0) {
ors.push(
inArray(
WebsiteHelpCenterTickets.userId,
matchingUsers.map((u) => u.id),
),
);
}
if (asId !== null) {
ors.push(eq(WebsiteHelpCenterTickets.id, asId));
}
const searchFilter = or(...ors);
if (searchFilter) conditions.push(searchFilter);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortKey =
parsed.sort && parsed.sort in SORT_COLS
? (parsed.sort as keyof typeof SORT_COLS)
: "updated";
const sortCol = SORT_COLS[sortKey];
const finalOrder =
parsed.sort && parsed.sort in SORT_COLS
? parsed.order === "asc"
? asc(sortCol)
: desc(sortCol)
: desc(WebsiteHelpCenterTickets.updatedAt);
const [total, openCount, closedCount, queues] = await Promise.all([
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, false))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
fetchTicketQueueOpenCounts(),
]);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await db
.select({
id: WebsiteHelpCenterTickets.id,
userId: WebsiteHelpCenterTickets.userId,
title: WebsiteHelpCenterTickets.title,
open: WebsiteHelpCenterTickets.open,
createdAt: WebsiteHelpCenterTickets.createdAt,
updatedAt: WebsiteHelpCenterTickets.updatedAt,
})
.from(WebsiteHelpCenterTickets)
.where(where)
.orderBy(finalOrder)
.offset(pagination.offset)
.limit(pagination.perPage)
.catch(() => []);
const ticketIds = tickets.map((ticket) => ticket.id);
const userIds = [
...new Set(
tickets
.map((ticket) => ticket.userId)
.filter((id): id is number => id != null),
),
];
const [replyGroups, users] = await Promise.all([
ticketIds.length > 0
? db
.select({
ticketId: WebsiteHelpCenterTicketReplies.ticketId,
total: count(),
})
.from(WebsiteHelpCenterTicketReplies)
.where(inArray(WebsiteHelpCenterTicketReplies.ticketId, ticketIds))
.groupBy(WebsiteHelpCenterTicketReplies.ticketId)
.catch(() => [])
: Promise.resolve([]),
userIds.length > 0
? db
.select({ id: User.id, username: User.username })
.from(User)
.where(inArray(User.id, userIds))
.catch(() => [])
: Promise.resolve([]),
]);
const replyCountByTicket = new Map(
replyGroups.map((g) => [String(g.ticketId), g.total]),
);
const usernameById = new Map(users.map((u) => [u.id, u.username]));
const rows: HelpTicketRow[] = tickets.map((ticket) => ({
id: String(ticket.id),
title: ticket.title,
user:
ticket.userId != null
? (usernameById.get(ticket.userId) ?? `#${ticket.userId}`)
: "—",
userId: ticket.userId,
replies: replyCountByTicket.get(String(ticket.id)) ?? 0,
open: ticket.open,
date: formatDate(ticket.createdAt, "date"),
updated: formatDate(ticket.updatedAt, "date"),
}));
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/mod/tickets"
otherLabel={t("queueOtherCta")}
countsLine={t("queueCounts", {
here: queues.helpOpen,
other: queues.cmsOpen,
})}
/>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "open" ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<HelpTicketsTable
basePath="/mod/help-tickets"
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</div>
);
}
+145
View File
@@ -0,0 +1,145 @@
import { eq } from "drizzle-orm";
import {
Ban,
Gavel,
LogOut,
Radio,
Shield,
Ticket,
UserRound,
Users,
} from "lucide-react";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import type { ReactNode } from "react";
import { LanguageSwitcher } from "@/components/language-switcher";
import Link from "@/components/link";
import { ThemeSwitcher } from "@/components/theme-switcher";
import { requireMod } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { readCsrfCookieToken } from "@/lib/foundation/security";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { siteSettings } from "@/lib/services/site-settings";
export default async function ModLayout({ children }: { children: ReactNode }) {
const staff = await requireMod();
const csrfToken = await readCsrfCookieToken();
if (await siteSettings.getBool("force_staff_2fa", false)) {
const [u] = await db
.select({ twoFactorConfirmedAt: User.twoFactorConfirmedAt })
.from(User)
.where(eq(User.id, staff.id))
.limit(1)
.catch(() => [] as { twoFactorConfirmedAt: Date | null }[]);
if (!u?.twoFactorConfirmedAt) redirect("/settings/2fa?error=staffrequired");
}
const t = await getTranslations("pages.mod");
const { permissions } = await getAdminContext();
const showCfh =
canAccess(permissions, PERMS.MOD_CFH_VIEW, staff.rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
const showActions =
canAccess(permissions, PERMS.MOD_ACTIONS, staff.rank) ||
canAccess(permissions, PERMS.MODERATION_EDIT, staff.rank);
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
const showTickets =
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.TICKETS_VIEW, staff.rank);
const showTeam =
canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
const showUsers =
canAccess(permissions, PERMS.MOD_USERS_VIEW, staff.rank) ||
canAccess(permissions, PERMS.USERS_VIEW, staff.rank);
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
const nav = [
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
{
href: "/mod/tickets",
label: t("nav.tickets"),
icon: Ticket,
show: showTickets,
},
{
href: "/mod/actions",
label: t("nav.actions"),
icon: Gavel,
show: showActions,
},
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
{
href: "/mod/users",
label: t("nav.users"),
icon: UserRound,
show: showUsers,
},
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
].filter((item) => item.show);
return (
<>
{csrfToken ? <meta name="csrf-token" content={csrfToken} /> : null}
<div
data-admin
className="min-h-screen"
style={{ backgroundColor: "var(--admin-canvas)" }}
>
<header className="border-b border-[var(--admin-border)] bg-[var(--admin-sidebar-background)]">
<div className="mx-auto flex max-w-6xl flex-wrap items-center gap-3 px-4 py-3">
<div className="min-w-0 flex-1">
<p className="m-0 text-xs font-bold uppercase tracking-widest text-[var(--admin-sidebar-muted-readable)]">
{t("badge")}
</p>
<h1 className="m-0 truncate text-lg font-semibold text-[var(--admin-sidebar-text-readable)]">
{t("title")}
</h1>
<p className="m-0 text-xs text-[var(--admin-sidebar-muted-readable)]">
{staff.username} · rank {staff.rank}
</p>
</div>
<div className="flex items-center gap-1.5">
<LanguageSwitcher variant="admin" />
<ThemeSwitcher variant="admin" />
{showAdmin ? (
<Link
href="/admin"
className="rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
>
{t("nav.fullAdmin")}
</Link>
) : null}
<Link
href="/"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
>
<LogOut size={14} />
{t("nav.backToSite")}
</Link>
</div>
</div>
<nav className="mx-auto flex max-w-6xl gap-1 overflow-x-auto px-4 pb-3">
{nav.map((item) => {
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
className="inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-sm font-medium text-[var(--admin-sidebar-muted-readable)] no-underline hover:bg-[var(--admin-accent)]/10 hover:text-[var(--admin-sidebar-text-readable)]"
>
<Icon size={14} />
{item.label}
</Link>
);
})}
</nav>
</header>
<main className="mx-auto max-w-6xl p-4 sm:p-6">{children}</main>
</div>
</>
);
}
+227
View File
@@ -0,0 +1,227 @@
import { and, count, eq, gte, ne } from "drizzle-orm";
import {
Ban,
Gavel,
LifeBuoy,
Radio,
Shield,
Ticket,
Users,
} from "lucide-react";
import { getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card";
import Link from "@/components/link";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import {
Ban as BanTable,
db,
SupportTickets,
User,
WebsiteHelpCenterTickets,
WebsiteTicket,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function ModDashboardPage() {
const t = await getTranslations("pages.mod");
const { session, permissions } = await getAdminContext();
const rank = session.user.rank;
const now = Math.floor(Date.now() / 1000);
const todayStart = now - (now % 86400);
const minStaffRank = await getMinStaffRank();
const showCfh =
canAccess(permissions, PERMS.MOD_CFH_VIEW, rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
const showActions =
canAccess(permissions, PERMS.MOD_ACTIONS, rank) ||
canAccess(permissions, PERMS.MODERATION_EDIT, rank);
const showBans =
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
canAccess(permissions, PERMS.BANS_VIEW, rank);
const showTickets =
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, rank) ||
canAccess(permissions, PERMS.TICKETS_VIEW, rank);
const showTeam =
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
const showUsers =
canAccess(permissions, PERMS.MOD_USERS_VIEW, rank) ||
canAccess(permissions, PERMS.USERS_VIEW, rank);
const [openCfh, todayBans, modsOnline, openTickets, openHelpTickets] =
await Promise.all([
showCfh
? db
.select({ total: count() })
.from(SupportTickets)
.where(eq(SupportTickets.state, 0))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0)
: Promise.resolve(0),
showBans
? db
.select({ total: count() })
.from(BanTable)
.where(gte(BanTable.timestamp, todayStart))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0)
: Promise.resolve(0),
db
.select({ total: count() })
.from(User)
.where(and(eq(User.online, "1"), gte(User.rank, minStaffRank)))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
showTickets
? db
.select({ total: count() })
.from(WebsiteTicket)
.where(ne(WebsiteTicket.status, "closed"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0)
: Promise.resolve(0),
showTickets
? db
.select({ total: count() })
.from(WebsiteHelpCenterTickets)
.where(eq(WebsiteHelpCenterTickets.open, true))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0)
: Promise.resolve(0),
]);
return (
<div className="space-y-6">
<div>
<h2 className="admin-section-title m-0">{t("overviewTitle")}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
{t("overviewSubtitle")}
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{showCfh ? (
<StatsCard
title={t("stats.openCfh")}
value={openCfh}
icon={<Radio className="h-4 w-4" />}
href="/mod/cfh"
/>
) : null}
{showTickets ? (
<StatsCard
title={t("stats.openTickets")}
value={openTickets}
icon={<Ticket className="h-4 w-4" />}
href="/mod/tickets"
/>
) : null}
{showTickets ? (
<StatsCard
title={t("stats.openHelpTickets")}
value={openHelpTickets}
icon={<LifeBuoy className="h-4 w-4" />}
href="/mod/help-tickets"
/>
) : null}
{showBans ? (
<StatsCard
title={t("stats.bansToday")}
value={todayBans}
icon={<Ban className="h-4 w-4" />}
href="/mod/bans"
/>
) : null}
<StatsCard
title={t("stats.modsOnline")}
value={modsOnline}
icon={<Shield className="h-4 w-4" />}
href={showTeam ? "/mod/team" : undefined}
/>
{showActions ? (
<StatsCard
title={t("stats.quickActions")}
value="→"
description={t("stats.quickActionsHint")}
icon={<Gavel className="h-4 w-4" />}
href="/mod/actions"
/>
) : null}
{showTeam ? (
<StatsCard
title={t("stats.team")}
value="→"
description={t("stats.teamHint")}
icon={<Users className="h-4 w-4" />}
href="/mod/team"
/>
) : null}
</div>
<section className="admin-card space-y-2 p-4">
<h3 className="m-0 text-sm font-semibold">{t("shortcutsTitle")}</h3>
<ul className="m-0 list-none space-y-1 p-0 text-sm">
{showCfh ? (
<li>
<Link href="/mod/cfh" className="text-primary hover:underline">
{t("nav.cfh")}
</Link>
</li>
) : null}
{showTickets ? (
<>
<li>
<Link
href="/mod/tickets"
className="text-primary hover:underline"
>
{t("nav.tickets")}
</Link>
</li>
<li>
<Link
href="/mod/help-tickets"
className="text-primary hover:underline"
>
{t("nav.helpTickets")}
</Link>
</li>
</>
) : null}
{showActions ? (
<li>
<Link
href="/mod/actions"
className="text-primary hover:underline"
>
{t("nav.actions")}
</Link>
</li>
) : null}
{showBans ? (
<li>
<Link href="/mod/bans" className="text-primary hover:underline">
{t("nav.bans")}
</Link>
</li>
) : null}
{showUsers ? (
<li>
<Link href="/mod/users" className="text-primary hover:underline">
{t("nav.users")}
</Link>
</li>
) : null}
{showTeam ? (
<li>
<Link href="/mod/team" className="text-primary hover:underline">
{t("nav.team")}
</Link>
</li>
) : null}
</ul>
</section>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { and, count, desc, gte, inArray, like } from "drizzle-orm";
import Link from "@/components/link";
import { AvatarImage } from "@/components/shared/avatar-image";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent } from "@/components/ui/card";
import { requireModPermission } from "@/lib/admin/guard";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import {
AdminAuditLog,
db,
SupportTickets,
User,
WebsiteTicket,
} from "@/lib/db";
import { PERMS } from "@/lib/permissions";
export default async function ModTeamLitePage() {
await requireModPermission([PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW]);
const minStaffRank = await getMinStaffRank();
const staff = await db
.select({
id: User.id,
username: User.username,
look: User.look,
rank: User.rank,
online: User.online,
lastLogin: User.lastLogin,
lastOnline: User.lastOnline,
})
.from(User)
.where(gte(User.rank, minStaffRank))
.orderBy(desc(User.online), desc(User.rank));
const staffIds = staff.map((s) => s.id);
const [cfhCounts, webTicketCounts, modActionCounts] =
staffIds.length === 0
? [[], [], []]
: await Promise.all([
db
.select({
modId: SupportTickets.modId,
total: count(),
})
.from(SupportTickets)
.where(inArray(SupportTickets.modId, staffIds))
.groupBy(SupportTickets.modId),
db
.select({
assigneeId: WebsiteTicket.assigneeId,
total: count(),
})
.from(WebsiteTicket)
.where(inArray(WebsiteTicket.assigneeId, staffIds))
.groupBy(WebsiteTicket.assigneeId),
db
.select({
userId: AdminAuditLog.userId,
total: count(),
})
.from(AdminAuditLog)
.where(
and(
inArray(AdminAuditLog.userId, staffIds),
like(AdminAuditLog.action, "mod_%"),
),
)
.groupBy(AdminAuditLog.userId),
]);
const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c.total]));
const webMap = new Map(webTicketCounts.map((c) => [c.assigneeId, c.total]));
const actionMap = new Map(modActionCounts.map((c) => [c.userId, c.total]));
return (
<div className="space-y-6">
<div className="grid gap-4 grid-cols-3">
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-[var(--admin-success)]">
{staff.filter((s) => s.online === "1").length}
</p>
<p className="text-xs text-muted-foreground">Online Now</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">{staff.length}</p>
<p className="text-xs text-muted-foreground">Total Staff</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-primary">
{staff.filter((s) => s.rank > minStaffRank).length}
</p>
<p className="text-xs text-muted-foreground">Above min rank</p>
</CardContent>
</Card>
</div>
<div className="rounded-lg border">
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 bg-muted/50 text-xs font-medium text-muted-foreground border-b">
<span />
<span>Name</span>
<span>Rank</span>
<span>CFH</span>
<span>Tickets</span>
<span>Actions</span>
<span>Last Seen</span>
</div>
{staff.map((member) => (
<Link
key={member.id}
href={`/u/${encodeURIComponent(member.username)}`}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 items-center hover:bg-accent/50 transition-all duration-150 border-b last:border-0"
>
<div className="relative">
<AvatarImage
figure={member.look}
options={{ headOnly: true }}
alt={member.username}
width={32}
height={32}
/>
<span
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background ${
member.online === "1"
? "bg-[var(--admin-success)]"
: "bg-[var(--admin-text-muted)]"
}`}
/>
</div>
<div>
<p className="text-sm font-medium">{member.username}</p>
</div>
<Badge variant="outline" className="text-[10px]">
Rank {member.rank}
</Badge>
<div className="text-center">
<p className="text-sm font-bold">{cfhMap.get(member.id) ?? 0}</p>
<p className="text-[9px] text-muted-foreground">CFH</p>
</div>
<div className="text-center">
<p className="text-sm font-bold">{webMap.get(member.id) ?? 0}</p>
<p className="text-[9px] text-muted-foreground">Web</p>
</div>
<div className="text-center">
<p className="text-sm font-bold">
{actionMap.get(member.id) ?? 0}
</p>
<p className="text-[9px] text-muted-foreground">Mod</p>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{member.online === "1"
? "Online"
: member.lastOnline
? new Date(member.lastOnline * 1000).toLocaleDateString()
: "—"}
</span>
</Link>
))}
</div>
</div>
);
}
+140
View File
@@ -0,0 +1,140 @@
import { asc, eq, gte } from "drizzle-orm";
import { alias } from "drizzle-orm/mysql-core";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import { requireModPermission } from "@/lib/admin/guard";
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
const Creator = alias(User, "ticket_creator");
const Assignee = alias(User, "ticket_assignee");
const MessageUser = alias(User, "ticket_message_user");
export default async function ModTicketDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const { session, permissions } = await getAdminContext();
const { id } = await params;
const ticketId = Number(id);
if (Number.isNaN(ticketId)) notFound();
const [ticket] = await db
.select({
id: WebsiteTicket.id,
subject: WebsiteTicket.subject,
category: WebsiteTicket.category,
priority: WebsiteTicket.priority,
status: WebsiteTicket.status,
createdAt: WebsiteTicket.createdAt,
closedAt: WebsiteTicket.closedAt,
creatorId: Creator.id,
creatorUsername: Creator.username,
creatorLook: Creator.look,
creatorRank: Creator.rank,
creatorIpRegister: Creator.ipRegister,
creatorMail: Creator.mail,
creatorAccountCreated: Creator.accountCreated,
assigneeId: Assignee.id,
assigneeUsername: Assignee.username,
})
.from(WebsiteTicket)
.innerJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
.where(eq(WebsiteTicket.id, ticketId))
.limit(1);
if (!ticket) notFound();
const messages = await db
.select({
id: WebsiteTicketMessage.id,
message: WebsiteTicketMessage.message,
isStaff: WebsiteTicketMessage.isStaff,
createdAt: WebsiteTicketMessage.createdAt,
userId: MessageUser.id,
username: MessageUser.username,
look: MessageUser.look,
rank: MessageUser.rank,
})
.from(WebsiteTicketMessage)
.innerJoin(MessageUser, eq(WebsiteTicketMessage.userId, MessageUser.id))
.where(eq(WebsiteTicketMessage.ticketId, ticketId))
.orderBy(asc(WebsiteTicketMessage.createdAt));
const minStaffRank = await getMinStaffRank();
const staffMembers = await db
.select({ id: User.id, username: User.username })
.from(User)
.where(gte(User.rank, minStaffRank))
.orderBy(asc(User.username));
const canEdit =
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
const t = await getTranslations("pages.admin.tickets");
const queues = await fetchTicketQueueOpenCounts();
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/mod/help-tickets"
otherLabel={t("queueOtherCta")}
countsLine={t("queueCounts", {
here: queues.cmsOpen,
other: queues.helpOpen,
})}
/>
<AdminTicketDetail
ticket={{
id: ticket.id,
subject: ticket.subject,
category: ticket.category,
priority: ticket.priority,
status: ticket.status,
createdAt: ticket.createdAt.toISOString(),
closedAt: ticket.closedAt?.toISOString() ?? null,
creator: {
id: ticket.creatorId,
username: ticket.creatorUsername,
look: ticket.creatorLook,
rank: ticket.creatorRank,
mail: ticket.creatorMail ?? "",
ipRegister: ticket.creatorIpRegister,
accountCreated: ticket.creatorAccountCreated,
},
assignee:
ticket.assigneeId != null && ticket.assigneeUsername
? { id: ticket.assigneeId, username: ticket.assigneeUsername }
: null,
}}
messages={messages.map((m) => ({
id: m.id,
message: m.message,
isStaff: m.isStaff === 1,
createdAt: m.createdAt.toISOString(),
user: {
id: m.userId,
username: m.username,
look: m.look,
rank: m.rank,
},
}))}
staffMembers={staffMembers}
currentUserId={session.user.id}
canEdit={canEdit}
listHref="/mod/tickets"
userLinkMode="public"
/>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import {
and,
asc,
count,
desc,
eq,
inArray,
like,
ne,
or,
type SQL,
} from "drizzle-orm";
import { alias } from "drizzle-orm/mysql-core";
import { getTranslations } from "next-intl/server";
import {
type TicketRow,
TicketsTable,
} from "@/app/admin/tickets/tickets-table";
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import { requireModPermission } from "@/lib/admin/guard";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { PERMS } from "@/lib/permissions";
const Creator = alias(User, "ticket_creator");
const Assignee = alias(User, "ticket_assignee");
const SORT_COLS = {
subject: WebsiteTicket.subject,
status: WebsiteTicket.status,
priority: WebsiteTicket.priority,
assignee: WebsiteTicket.assigneeId,
date: WebsiteTicket.createdAt,
id: WebsiteTicket.id,
} as const;
export default async function ModTicketsDeskPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const t = await getTranslations("pages.admin.tickets");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const statusFilter = raw.filter_status || raw.status || "active";
const conditions: SQL[] = [];
if (statusFilter === "active") {
conditions.push(ne(WebsiteTicket.status, "closed"));
} else if (statusFilter && statusFilter !== "all") {
conditions.push(eq(WebsiteTicket.status, statusFilter));
}
if (parsed.search.trim()) {
const q = parsed.search.trim();
const asId = Number(q);
const ors: SQL[] = [
like(WebsiteTicket.subject, `%${q}%`),
like(WebsiteTicket.category, `%${q}%`),
like(Creator.username, `%${q}%`),
like(Assignee.username, `%${q}%`),
];
if (Number.isFinite(asId) && asId > 0) {
ors.push(eq(WebsiteTicket.id, asId));
}
const searchFilter = or(...ors);
if (searchFilter) conditions.push(searchFilter);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortKey =
parsed.sort && parsed.sort in SORT_COLS
? (parsed.sort as keyof typeof SORT_COLS)
: "date";
const sortCol = SORT_COLS[sortKey];
const finalOrder =
parsed.sort && parsed.sort in SORT_COLS
? parsed.order === "asc"
? asc(sortCol)
: desc(sortCol)
: desc(WebsiteTicket.createdAt);
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
await Promise.all([
db
.select({ total: count() })
.from(WebsiteTicket)
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteTicket)
.where(eq(WebsiteTicket.status, "open"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteTicket)
.where(eq(WebsiteTicket.status, "in_progress"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteTicket)
.where(eq(WebsiteTicket.status, "waiting"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({ total: count() })
.from(WebsiteTicket)
.where(eq(WebsiteTicket.status, "closed"))
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
fetchTicketQueueOpenCounts(),
]);
const pagination = calcPagination(total, parsed.page, parsed.perPage);
const tickets = await db
.select({
id: WebsiteTicket.id,
subject: WebsiteTicket.subject,
category: WebsiteTicket.category,
status: WebsiteTicket.status,
priority: WebsiteTicket.priority,
creatorId: WebsiteTicket.creatorId,
createdAt: WebsiteTicket.createdAt,
creatorUsername: Creator.username,
assigneeUsername: Assignee.username,
})
.from(WebsiteTicket)
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
.where(where)
.orderBy(finalOrder)
.offset(pagination.offset)
.limit(pagination.perPage)
.catch(() => []);
const ticketIds = tickets.map((ticket) => ticket.id);
const msgRows =
ticketIds.length > 0
? await db
.select({
ticketId: WebsiteTicketMessage.ticketId,
total: count(),
})
.from(WebsiteTicketMessage)
.where(inArray(WebsiteTicketMessage.ticketId, ticketIds))
.groupBy(WebsiteTicketMessage.ticketId)
.catch(() => [])
: [];
const msgMap = new Map(msgRows.map((m) => [m.ticketId, m.total]));
const rows: TicketRow[] = tickets.map((ticket) => ({
id: ticket.id,
subject: ticket.subject,
creator: ticket.creatorUsername ?? "—",
creatorId: ticket.creatorId,
category: ticket.category,
messages: msgMap.get(ticket.id) ?? 0,
status: ticket.status,
priority: ticket.priority,
assignee: ticket.assigneeUsername ?? "—",
date: formatDate(ticket.createdAt, "date"),
}));
return (
<div className="space-y-6">
<TicketQueueBanner
hint={t("queueHint")}
otherHref="/mod/help-tickets"
otherLabel={t("queueOtherCta")}
countsLine={t("queueCounts", {
here: queues.cmsOpen,
other: queues.helpOpen,
})}
/>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
<StatusCard
label={t("statusInProgress")}
value={inProgressCount}
state={inProgressCount > 0 ? "warn" : "neutral"}
icon="⏳"
/>
<StatusCard
label={t("statusWaiting")}
value={waitingCount}
state={waitingCount > 0 ? "warn" : "neutral"}
icon="🕒"
/>
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "active" ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<TicketsTable
basePath="/mod/tickets"
data={{
rows,
total,
page: pagination.page,
perPage: pagination.perPage,
lastPage: pagination.lastPage,
}}
/>
)}
</div>
);
}
+98
View File
@@ -0,0 +1,98 @@
import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard";
import { UnifiedTicketsTable } from "@/components/admin/unified-tickets-table";
import Link from "@/components/link";
import { requireModPermission } from "@/lib/admin/guard";
import {
fetchUnifiedTicketInbox,
type TicketInboxTypeFilter,
} from "@/lib/admin/ticket-inbox";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { parseListParams } from "@/lib/admin-helpers";
import { PERMS } from "@/lib/permissions";
export default async function ModTicketInboxPage({
searchParams,
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const t = await getTranslations("pages.admin.ticketInbox");
const raw = await searchParams;
const parsed = parseListParams(new URLSearchParams(raw));
const typeRaw = raw.filter_type || "all";
const type: TicketInboxTypeFilter =
typeRaw === "cms" || typeRaw === "help" ? typeRaw : "all";
const statusFilter = raw.filter_status || "open";
const openOnly = statusFilter !== "all";
const [inbox, queues] = await Promise.all([
fetchUnifiedTicketInbox({
page: parsed.page,
perPage: parsed.perPage,
search: parsed.search,
type,
openOnly,
base: "mod",
}),
fetchTicketQueueOpenCounts(),
]);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-semibold text-[var(--admin-text)]">
{t("modNavLabel")}
</span>
<Link
href="/mod/tickets"
className="rounded-md px-2 py-1 no-underline text-[var(--admin-accent)] hover:underline"
>
{t("modNavInbox")}
</Link>
<Link
href="/mod/tickets/desk"
className="rounded-md px-2 py-1 no-underline text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
>
{t("modNavDesk")}
</Link>
<Link
href="/mod/help-tickets"
className="rounded-md px-2 py-1 no-underline text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
>
{t("modNavHelp")}
</Link>
</div>
<p className="m-0 text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
<StatusCard label={t("statCmsOpen")} value={queues.cmsOpen} icon="🎫" />
<StatusCard
label={t("statHelpOpen")}
value={queues.helpOpen}
state={queues.helpOpen > 0 ? "warn" : "neutral"}
icon="🛟"
/>
<StatusCard
label={t("statTotal")}
value={queues.cmsOpen + queues.helpOpen}
icon="📥"
/>
</div>
{inbox.total === 0 && !parsed.search ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<UnifiedTicketsTable
data={{
rows: inbox.rows,
total: inbox.total,
page: inbox.page,
perPage: inbox.perPage,
lastPage: inbox.lastPage,
}}
/>
)}
</div>
);
}
+131
View File
@@ -0,0 +1,131 @@
import { eq } from "drizzle-orm";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { db, User } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
import { getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
export default async function ModUserDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
await requireModPermission([PERMS.MOD_USERS_VIEW, PERMS.USERS_VIEW]);
const t = await getTranslations("pages.mod");
const { id: rawId } = await params;
const id = Number(rawId);
if (!Number.isFinite(id) || id <= 0) notFound();
const [user] = await db
.select({
id: User.id,
username: User.username,
rank: User.rank,
online: User.online,
motto: User.motto,
look: User.look,
accountCreated: User.accountCreated,
lastLogin: User.lastLogin,
lastOnline: User.lastOnline,
})
.from(User)
.where(eq(User.id, id))
.limit(1);
if (!user) notFound();
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex items-center gap-4">
<img
src={getAvatarUrl(user.look, {
direction: 2,
headDirection: 2,
})}
alt=""
width={64}
height={110}
className="rounded"
/>
<div>
<h2 className="admin-section-title m-0">{user.username}</h2>
<p className="mt-1 text-sm text-[var(--admin-text-muted)]">
#{user.id} · {t("usersRank")} {user.rank}
</p>
<div className="mt-2 flex gap-2">
{user.online === "1" ? (
<Badge className="bg-[var(--admin-success)]">Online</Badge>
) : (
<Badge variant="outline">Offline</Badge>
)}
</div>
</div>
</div>
<div className="flex gap-3">
<Link
href="/mod/users"
className="text-sm text-muted-foreground underline-offset-2 hover:underline"
>
← {t("nav.users")}
</Link>
<Link
href={`/u/${user.username}`}
className="text-sm text-[var(--admin-accent)] underline-offset-2 hover:underline"
>
{t("usersPublicProfile")}
</Link>
</div>
</div>
<div
className="rounded-lg border border-[var(--admin-info-border)] bg-[var(--admin-info-subtle)] px-4 py-3 text-sm"
role="status"
>
{t("usersPiiNote")}
</div>
<div className="admin-card grid gap-4 p-5 sm:grid-cols-2">
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
Motto
</p>
<p className="m-0 mt-1">{user.motto || "—"}</p>
</div>
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
{t("usersJoined")}
</p>
<p className="m-0 mt-1">
{user.accountCreated > 0
? formatDate(new Date(user.accountCreated * 1000), "date")
: "—"}
</p>
</div>
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
{t("usersLastLogin")}
</p>
<p className="m-0 mt-1">
{user.lastLogin
? formatDate(new Date(user.lastLogin * 1000), "datetime")
: "—"}
</p>
</div>
<div>
<p className="m-0 text-xs uppercase tracking-wide text-muted-foreground">
{t("usersLastOnline")}
</p>
<p className="m-0 mt-1">
{user.lastOnline > 0
? formatDate(new Date(user.lastOnline * 1000), "datetime")
: "—"}
</p>
</div>
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { and, asc, count, desc, eq, like, or, type SQL } from "drizzle-orm";
import { getTranslations } from "next-intl/server";
import { DataTable } from "@/components/admin/data-table";
import Link from "@/components/link";
import { Badge } from "@/components/ui/badge";
import { requireModPermission } from "@/lib/admin/guard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User } from "@/lib/db";
import { getAvatarUrl } from "@/lib/imager";
import { PERMS } from "@/lib/permissions";
import type { DataTableColumn } from "@/types/common";
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>
),
},
];
const SORT_COLS = {
id: User.id,
username: User.username,
rank: User.rank,
online: User.online,
motto: User.motto,
lastLogin: User.lastLogin,
} as const;
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 conditions: SQL[] = [];
if (parsed.search.trim()) {
const q = parsed.search.trim();
const ors = [like(User.username, `%${q}%`), like(User.motto, `%${q}%`)];
if (Number.isFinite(Number(q)) && Number(q) > 0) {
ors.push(eq(User.id, Number(q)));
}
const searchFilter = or(...ors);
if (searchFilter) conditions.push(searchFilter);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
const sortKey =
parsed.sort && parsed.sort in SORT_COLS
? (parsed.sort as keyof typeof SORT_COLS)
: "id";
const sortCol = SORT_COLS[sortKey];
const finalOrder =
parsed.sort && parsed.sort in SORT_COLS
? parsed.order === "asc"
? asc(sortCol)
: desc(sortCol)
: desc(User.id);
const [totals, users] = await Promise.all([
db
.select({ total: count() })
.from(User)
.where(where)
.then((rows) => rows[0]?.total ?? 0)
.catch(() => 0),
db
.select({
id: User.id,
username: User.username,
rank: User.rank,
online: User.online,
motto: User.motto,
look: User.look,
lastLogin: User.lastLogin,
})
.from(User)
.where(where)
.orderBy(finalOrder)
.offset((parsed.page - 1) * parsed.perPage)
.limit(parsed.perPage)
.catch(() => [] as ModUserRow[]),
]);
const total = totals;
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>
);
}