From a109e47f331b3f2edcaf390ae9a304264cc620a9 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 28 Apr 2026 08:04:17 +0800 Subject: [PATCH] feat(admin): add reports admin page with searchable, paginated table --- app/(admin)/admin/reports/page.tsx | 10 + app/(admin)/admin/reports/reports-client.tsx | 477 +++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 app/(admin)/admin/reports/page.tsx create mode 100644 app/(admin)/admin/reports/reports-client.tsx diff --git a/app/(admin)/admin/reports/page.tsx b/app/(admin)/admin/reports/page.tsx new file mode 100644 index 0000000..6364782 --- /dev/null +++ b/app/(admin)/admin/reports/page.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next" +import { ReportsClient } from "./reports-client" + +export const metadata: Metadata = { + title: "Reports", +} + +export default function ReportsPage() { + return +} diff --git a/app/(admin)/admin/reports/reports-client.tsx b/app/(admin)/admin/reports/reports-client.tsx new file mode 100644 index 0000000..409ed64 --- /dev/null +++ b/app/(admin)/admin/reports/reports-client.tsx @@ -0,0 +1,477 @@ +"use client" + +import { useEffect, useMemo, useRef, useState } from "react" +import Image from "next/image" +import { + Loader2, + SearchIcon, + ShieldCheckIcon, + XIcon, + ChevronLeftIcon, + ChevronRightIcon, + FlagIcon, +} from "lucide-react" + +interface Report { + id: string + entryId: string + reporterId: string + reporterName: string | null + reason: "inaccurate" | "spam" | "inappropriate" | "other" + details: string | null + status: "open" | "reviewed" | "dismissed" + createdAt: string + entry: { + id: string + userId: string + fpsAvg: number | null + fpsLow: number | null + fpsHigh: number | null + upscalerType: string | null + userNotes: string | null + isRemoved: boolean + authorName: string | null + } + gameVersion: { + id: string + versionString: string + } + game: { + id: string + title: string + headerImage: string | null + } +} + +interface ReportsApiResponse { + data: Report[] + total: number + limit: number + offset: number +} + +type StatusFilter = "all" | "open" | "reviewed" | "dismissed" + +const LIMIT = 20 + +function formatDate(value: string | Date | null | undefined) { + if (!value) return "—" + const d = typeof value === "string" ? new Date(value) : value + return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }) +} + +function getInitial(name: string | null | undefined) { + return name?.charAt(0)?.toUpperCase() || "?" +} + +function truncate(str: string | null | undefined, maxLen = 60) { + if (!str) return "—" + return str.length > maxLen ? str.slice(0, maxLen) + "…" : str +} + +function statusBadgeClasses(status: Report["status"]) { + switch (status) { + case "open": + return "bg-yellow-500/10 text-yellow-400" + case "reviewed": + return "bg-green-500/10 text-green-400" + case "dismissed": + return "bg-text/5 text-text/50" + } +} + +function reasonBadgeClasses(reason: Report["reason"]) { + switch (reason) { + case "inaccurate": + return "bg-blue-500/10 text-blue-400" + case "spam": + return "bg-red-500/10 text-red-400" + case "inappropriate": + return "bg-orange-500/10 text-orange-400" + case "other": + return "bg-text/5 text-text/50" + } +} + +export function ReportsClient() { + const [reports, setReports] = useState([]) + const [total, setTotal] = useState(0) + const [loading, setLoading] = useState(true) + const [search, setSearch] = useState("") + const [offset, setOffset] = useState(0) + const [statusFilter, setStatusFilter] = useState("all") + const [actionLoading, setActionLoading] = useState>({}) + const [confirmReport, setConfirmReport] = useState(null) + + const isSearchChangeRef = useRef(false) + + const handleSearchChange = (value: string) => { + setSearch(value) + setOffset(0) + isSearchChangeRef.current = true + } + + const handleStatusChange = (value: StatusFilter) => { + setStatusFilter(value) + setOffset(0) + isSearchChangeRef.current = true + } + + const handlePrev = () => { + setOffset((prev) => Math.max(0, prev - LIMIT)) + isSearchChangeRef.current = false + } + + const handleNext = () => { + setOffset((prev) => prev + LIMIT) + isSearchChangeRef.current = false + } + + useEffect(() => { + const delay = isSearchChangeRef.current ? 300 : 0 + isSearchChangeRef.current = false + + let cancelled = false + const timer = setTimeout(async () => { + setLoading(true) + try { + const params = new URLSearchParams({ + limit: String(LIMIT), + offset: String(offset), + }) + if (statusFilter !== "all") { + params.set("status", statusFilter) + } + + const res = await fetch(`/api/admin/reports?${params.toString()}`) + if (res.ok && !cancelled) { + const json = (await res.json()) as ReportsApiResponse + setReports(json.data) + setTotal(json.total) + } + } catch { + // ignore + } finally { + if (!cancelled) setLoading(false) + } + }, delay) + + return () => { + cancelled = true + clearTimeout(timer) + } + }, [search, offset, statusFilter]) + + const handleUpdateStatus = async (report: Report, status: "reviewed" | "dismissed") => { + setActionLoading((prev) => ({ ...prev, [report.id]: true })) + try { + const res = await fetch(`/api/admin/reports/${report.id}/status`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status }), + }) + + if (res.ok) { + // Refresh list after action + const params = new URLSearchParams({ + limit: String(LIMIT), + offset: String(offset), + }) + if (statusFilter !== "all") { + params.set("status", statusFilter) + } + + const listRes = await fetch(`/api/admin/reports?${params.toString()}`) + if (listRes.ok) { + const json = (await listRes.json()) as ReportsApiResponse + setReports(json.data) + setTotal(json.total) + } + setConfirmReport(null) + } + } finally { + setActionLoading((prev) => ({ ...prev, [report.id]: false })) + } + } + + const filteredReports = useMemo(() => { + const term = search.trim().toLowerCase() + if (!term) return reports + return reports.filter( + (r) => + r.reporterName?.toLowerCase().includes(term) || + r.game.title.toLowerCase().includes(term) || + r.reason.toLowerCase().includes(term) || + r.details?.toLowerCase().includes(term) || + r.status.toLowerCase().includes(term) + ) + }, [reports, search]) + + const hasMore = offset + filteredReports.length < total + + const tabs: { label: string; value: StatusFilter }[] = [ + { label: "All", value: "all" }, + { label: "Open", value: "open" }, + { label: "Reviewed", value: "reviewed" }, + { label: "Dismissed", value: "dismissed" }, + ] + + return ( +
+ {/* Header */} +
+ +

Reports

+
+ + {/* Search */} +
+ + handleSearchChange(e.target.value)} + placeholder="Search reports..." + className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors" + /> +
+ + {/* Filter tabs */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Table */} +
+ + + + + + + + + + + + + + + {loading ? ( + + + + ) : filteredReports.length === 0 ? ( + + + + ) : ( + filteredReports.map((report) => ( + + + + + + + + + + + )) + )} + +
+ Reporter + + Game + + FPS + + Reason + + Details + + Status + + Date + + Actions +
+ +
+ No reports found. +
+
+
+ {getInitial(report.reporterName)} +
+
+

+ {report.reporterName || "Unknown"} +

+
+
+
+
+ {report.game.headerImage ? ( + {report.game.title} + ) : null} +

+ {report.game.title} +

+
+
+ + {report.entry.fpsAvg ?? "—"} + + + + {report.reason} + + +

+ {truncate(report.details, 80)} +

+
+ + + {report.status} + + + {formatDate(report.createdAt)} + +
+ {report.status === "open" ? ( + <> + + + + ) : ( + + {report.status} + + )} +
+
+
+ + {/* Pagination */} + {filteredReports.length > 0 && ( +
+

+ Showing {offset + 1}–{Math.min(offset + filteredReports.length, total)} of{" "} + {total} +

+
+ + +
+
+ )} + + {/* Confirmation Dialog */} + {confirmReport && ( +
+
+

Confirm Review

+

+ This will also remove the reported benchmark. Are you sure? +

+
+ + +
+
+
+ )} +
+ ) +}