From 37c5d87baae3b081f1c41dc138153e09c2897790 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Tue, 28 Apr 2026 08:31:36 +0800 Subject: [PATCH] Add admin benchmarks page with search, filter tabs, pagination, verify/remove/restore actions --- .../admin/benchmarks/benchmarks-client.tsx | 604 ++++++++++++++++++ app/(admin)/admin/benchmarks/page.tsx | 10 + lib/api/admin-performance.ts | 10 +- 3 files changed, 622 insertions(+), 2 deletions(-) create mode 100644 app/(admin)/admin/benchmarks/benchmarks-client.tsx create mode 100644 app/(admin)/admin/benchmarks/page.tsx diff --git a/app/(admin)/admin/benchmarks/benchmarks-client.tsx b/app/(admin)/admin/benchmarks/benchmarks-client.tsx new file mode 100644 index 0000000..e304de5 --- /dev/null +++ b/app/(admin)/admin/benchmarks/benchmarks-client.tsx @@ -0,0 +1,604 @@ +"use client" + +import { useCallback, useEffect, useRef, useState } from "react" +import Image from "next/image" +import Link from "next/link" +import { + Loader2, + SearchIcon, + ShieldCheckIcon, + TrashIcon, + RefreshCwIcon, + ExternalLinkIcon, + ChevronLeftIcon, + ChevronRightIcon, +} from "lucide-react" + +interface PerformanceEntry { + id: string + versionId: string + hardwareSlug: string + userId: string + fpsAvg: number | null + fpsLow: number | null + fpsHigh: number | null + protonVersion: string | null + osVersion: string | null + upscalerType: string | null + upscalerVersion: string | null + frameGenMethod: string | null + loadTimeSsd: number | null + loadTimeSd: number | null + launchOptions: string | null + settingsJson: string | null + userNotes: string | null + estimatedBatteryMin: number | null + customSystem: string | null + isRemoved: boolean + removedReason: string | null + upvotes: number + downvotes: number + verifiedAt: string | null + verifiedBy: string | null + createdAt: string + updatedAt: string + gameId: string + gameTitle: string + versionString: string + hardwareName: string + authorName: string | null + authorImage: string | null +} + +interface PerformanceApiResponse { + data: PerformanceEntry[] + total: number + limit: number + offset: number +} + +type StatusFilter = "all" | "active" | "removed" | "unverified" + +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 statusBadgeClasses(entry: PerformanceEntry) { + if (entry.verifiedAt) return "bg-green-500/10 text-green-400" + if (entry.isRemoved) return "bg-red-500/10 text-red-400" + return "bg-text/5 text-text/50" +} + +function statusLabel(entry: PerformanceEntry) { + if (entry.verifiedAt) return "Verified" + if (entry.isRemoved) return "Removed" + return "Active" +} + +function statusDotClass(entry: PerformanceEntry) { + if (entry.verifiedAt) return "bg-green-400" + if (entry.isRemoved) return "bg-red-400" + return "bg-text/40" +} + +function formatFps(entry: PerformanceEntry) { + if (entry.fpsAvg == null) return "—" + if (entry.fpsLow != null && entry.fpsHigh != null) { + return `${entry.fpsAvg} (${entry.fpsLow}–${entry.fpsHigh})` + } + return `${entry.fpsAvg}` +} + +export function BenchmarksClient() { + const [entries, setEntries] = 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 [confirmAction, setConfirmAction] = useState< + | { type: "verify" | "remove" | "restore"; entry: PerformanceEntry } + | null + >(null) + const [removeReason, setRemoveReason] = useState("") + + 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 + } + + const loadData = useCallback(async () => { + setLoading(true) + try { + const params = new URLSearchParams({ + limit: String(LIMIT), + offset: String(offset), + }) + if (statusFilter === "active") { + params.set("removed", "false") + } else if (statusFilter === "removed") { + params.set("removed", "true") + } else if (statusFilter === "unverified") { + params.set("verified", "false") + } + if (search.trim()) { + params.set("search", search.trim()) + } + + const res = await fetch(`/api/admin/performance?${params.toString()}`) + if (res.ok) { + const json = (await res.json()) as PerformanceApiResponse + setEntries(json.data) + setTotal(json.total) + } + } catch { + // ignore + } finally { + setLoading(false) + } + }, [search, offset, statusFilter]) + + useEffect(() => { + const delay = isSearchChangeRef.current ? 300 : 0 + isSearchChangeRef.current = false + + let cancelled = false + const timer = setTimeout(async () => { + if (!cancelled) { + await loadData() + } + }, delay) + + return () => { + cancelled = true + clearTimeout(timer) + } + }, [loadData]) + + const handleVerify = async (entry: PerformanceEntry) => { + setActionLoading((prev) => ({ ...prev, [entry.id]: true })) + try { + const res = await fetch(`/api/admin/performance/${entry.id}/verify`, { + method: "PATCH", + }) + if (res.ok) { + await loadData() + setConfirmAction(null) + } + } finally { + setActionLoading((prev) => ({ ...prev, [entry.id]: false })) + } + } + + const handleRemove = async (entry: PerformanceEntry) => { + setActionLoading((prev) => ({ ...prev, [entry.id]: true })) + try { + const body: { reason?: string } = {} + if (removeReason.trim()) { + body.reason = removeReason.trim() + } + const res = await fetch(`/api/admin/performance/${entry.id}/remove`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + if (res.ok) { + setRemoveReason("") + await loadData() + setConfirmAction(null) + } + } finally { + setActionLoading((prev) => ({ ...prev, [entry.id]: false })) + } + } + + const handleRestore = async (entry: PerformanceEntry) => { + setActionLoading((prev) => ({ ...prev, [entry.id]: true })) + try { + const res = await fetch(`/api/admin/performance/${entry.id}/restore`, { + method: "PATCH", + }) + if (res.ok) { + await loadData() + setConfirmAction(null) + } + } finally { + setActionLoading((prev) => ({ ...prev, [entry.id]: false })) + } + } + + const hasMore = offset + entries.length < total + + const tabs: { label: string; value: StatusFilter }[] = [ + { label: "All", value: "all" }, + { label: "Active", value: "active" }, + { label: "Removed", value: "removed" }, + { label: "Unverified", value: "unverified" }, + ] + + return ( +
+ {/* Search */} +
+ + handleSearchChange(e.target.value)} + placeholder="Search benchmarks..." + 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 ? ( + + + + ) : entries.length === 0 ? ( + + + + ) : ( + entries.map((entry) => ( + + + + + + + + + + + + )) + )} + +
+ Game + + Author + + Hardware + + FPS + + Upscaler + + Status + + Votes + + Date + + Actions +
+ +
+ No benchmarks found. +
+

+ {entry.gameTitle} +

+
+
+ {entry.authorImage ? ( + {entry.authorName + ) : ( +
+ {getInitial(entry.authorName)} +
+ )} +
+

+ {entry.authorName || "Unknown"} +

+
+
+
+

+ {entry.hardwareName} +

+
+ {formatFps(entry)} + + + {entry.upscalerType || "—"} + + + + {entry.verifiedAt ? ( + + ) : ( + + )} + {statusLabel(entry)} + + + + ▲ {entry.upvotes} + ▼ {entry.downvotes} + + + {formatDate(entry.createdAt)} + +
+ + + View + + {!entry.verifiedAt && ( + + )} + {!entry.isRemoved ? ( + + ) : ( + + )} +
+
+
+ + {/* Pagination */} + {entries.length > 0 && ( +
+

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

+
+ + +
+
+ )} + + {/* Confirmation Dialogs */} + {confirmAction && ( +
+
+ {confirmAction.type === "verify" && ( + <> +

+ Confirm Verify +

+

+ Are you sure you want to verify this benchmark? It will be + marked as verified. +

+
+ + +
+ + )} + + {confirmAction.type === "remove" && ( + <> +

+ Confirm Remove +

+

+ Are you sure you want to remove this benchmark? +

+