refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
@@ -0,0 +1,5 @@
import ForgotPasswordForm from "@/components/auth/forgot-password-form"
export default function ForgotPasswordPage() {
return <ForgotPasswordForm />
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import logo from "@/app/icon.png"
export const metadata: Metadata = {
title: "Authentication",
}
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-[calc(100vh-3.6rem)] flex items-center justify-center px-4 relative overflow-hidden">
{/* Background decorative orbs */}
<div className="absolute top-20 left-10 w-48 h-48 rounded-full bg-primary/10 blur-3xl pointer-events-none" />
<div className="absolute bottom-20 right-10 w-36 h-36 rounded-full bg-secondary/15 blur-2xl pointer-events-none" />
{/* Auth card */}
<div className="w-full max-w-md bg-text/5 border border-border rounded-xl p-8 relative z-10">
{/* Logo */}
<Link
href="/"
className="flex flex-col items-center gap-2 mb-8"
>
<Image
src={logo}
alt="DeckyVault Logo"
className="h-10 w-auto"
/>
<span className="text-lg font-bold text-text">
DeckyVault
</span>
</Link>
{children}
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import LoginForm from "@/components/auth/login-form"
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
)
}
@@ -0,0 +1,21 @@
import { Suspense } from "react"
import ResetPasswordForm from "@/components/auth/reset-password-form"
import { redirect } from "next/navigation"
interface PageProps {
searchParams: Promise<{ email?: string }>
}
export default async function ResetPasswordPage({ searchParams }: PageProps) {
const { email } = await searchParams
if (!email) {
redirect("/forgot-password")
}
return (
<Suspense>
<ResetPasswordForm email={email} />
</Suspense>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import SignupWizard from "@/components/auth/signup-wizard"
export default function SignupPage() {
return (
<Suspense>
<SignupWizard />
</Suspense>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { ManageSidebar } from "@/components/manage/manage-sidebar"
import { auth } from "@/lib/auth"
import type { Metadata } from "next"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
export const metadata: Metadata = {
title: {
template: "%s | Manage — DeckyVault",
default: "Manage — DeckyVault",
},
robots: { index: false, follow: false },
}
export default async function ManageLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth.api.getSession({
headers: await headers(),
})
const role = session?.user?.role ?? "user"
if (role !== "moderator" && role !== "admin") {
redirect("/")
}
return (
<section className='w-full flex flex-col gap-8 py-16'>
<div className='px-4 md:px-8 lg:px-12'>
<div className='mx-auto'>
<h1 className='text-2xl sm:text-3xl font-bold'>Manage</h1>
<p className='text-sm text-text/60 mt-1'>
Manage your platform
</p>
</div>
</div>
<div className='px-4 md:px-8 lg:px-12'>
<div className='mx-auto flex flex-col md:flex-row gap-6'>
<ManageSidebar />
<div className='flex-1 min-w-0'>{children}</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { AnalyticsClient } from "@/components/manage/analytics-client"
export const metadata: Metadata = {
title: "Analytics",
}
export default function AnalyticsPage() {
return <AnalyticsClient />
}
@@ -0,0 +1,581 @@
"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"
import { ConfirmDialog } from "@/components/ui/modal"
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
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<PerformanceEntry[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmAction, setConfirmAction] = useState<
| { type: "verify" | "remove" | "restore" | "hardDelete"; 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 handleHardDelete = async (entry: PerformanceEntry) => {
setActionLoading((prev) => ({ ...prev, [entry.id]: true }))
try {
const res = await fetch(`/api/admin/performance/${entry.id}/hard-delete`, {
method: "DELETE",
})
if (res.ok) {
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
setTotal((prev) => Math.max(0, prev - 1))
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 (
<div className="space-y-4">
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => handleStatusChange(tab.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
statusFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[800px]">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Author
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Hardware
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
FPS
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Upscaler
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Votes
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-text/50">
No benchmarks found.
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[180px]">
{entry.gameTitle}
</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{entry.authorImage ? (
<Image
src={entry.authorImage}
alt={entry.authorName || "User"}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
unoptimized
/>
) : (
<div className="h-8 w-8 rounded-full bg-text/10 flex items-center justify-center text-xs font-medium text-text/70">
{getInitial(entry.authorName)}
</div>
)}
<div className="min-w-0">
<p className="font-medium text-text truncate">
{entry.authorName || "Unknown"}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-text/70 truncate max-w-[120px]">
{entry.hardwareName}
</p>
</td>
<td className="px-4 py-3">
<span className="text-text/70">{formatFps(entry)}</span>
</td>
<td className="px-4 py-3">
<span className="text-text/50">
{entry.upscalerType || "—"}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(entry)}`}
>
{entry.verifiedAt ? (
<ShieldCheckIcon className="h-3 w-3" />
) : (
<span
className={`h-1.5 w-1.5 rounded-full ${statusDotClass(entry)}`}
/>
)}
{statusLabel(entry)}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2 text-xs text-text/70">
<span> {entry.upvotes}</span>
<span> {entry.downvotes}</span>
</span>
</td>
<td className="px-4 py-3 text-xs text-text/50">
{formatDate(entry.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/game/${entry.gameId}`}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
{!entry.verifiedAt && (
<button
onClick={() =>
setConfirmAction({ type: "verify", entry })
}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[entry.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ShieldCheckIcon className="h-3.5 w-3.5" />
)}
Verify
</button>
)}
{!entry.isRemoved ? (
<button
onClick={() =>
setConfirmAction({ type: "remove", entry })
}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[entry.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Remove
</button>
) : (
<button
onClick={() =>
setConfirmAction({ type: "restore", entry })
}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[entry.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCwIcon className="h-3.5 w-3.5" />
)}
Restore
</button>
)}
<button
onClick={() => setConfirmAction({ type: "hardDelete", entry })}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-600/10 text-red-500 hover:bg-red-600/20 transition-colors cursor-pointer disabled:opacity-50"
title="Permanently delete"
>
<TrashIcon className="h-3.5 w-3.5" />
Purge
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{entries.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + entries.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation Dialogs */}
{confirmAction?.type === "verify" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleVerify(confirmAction.entry)}
title="Confirm Verify"
message="Are you sure you want to verify this benchmark? It will be marked as verified."
confirmLabel="Verify"
variant="default"
loading={actionLoading[confirmAction.entry.id]}
/>
)}
{confirmAction?.type === "remove" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRemove(confirmAction.entry)}
title="Confirm Remove"
message="Are you sure you want to remove this benchmark?"
confirmLabel="Remove"
variant="destructive"
loading={actionLoading[confirmAction.entry.id]}
>
<textarea
rows={3}
value={removeReason}
onChange={(e) => setRemoveReason(e.target.value)}
placeholder="Optional reason..."
className="w-full px-3 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 resize-none"
/>
</ConfirmDialog>
)}
{confirmAction?.type === "restore" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRestore(confirmAction.entry)}
title="Confirm Restore"
message="Are you sure you want to restore this benchmark?"
confirmLabel="Restore"
variant="default"
loading={actionLoading[confirmAction.entry.id]}
/>
)}
{confirmAction?.type === "hardDelete" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleHardDelete(confirmAction.entry)}
title="⚠️ Permanent Delete"
message="This will permanently delete this benchmark entry. This action cannot be undone."
confirmLabel="Delete Forever"
variant="destructive"
loading={actionLoading[confirmAction.entry.id]}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { BenchmarksClient } from "./benchmarks-client"
export const metadata: Metadata = {
title: "Benchmarks",
}
export default function BenchmarksPage() {
return <BenchmarksClient />
}
@@ -0,0 +1,450 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import {
Loader2,
SearchIcon,
TrashIcon,
RotateCcwIcon,
ExternalLinkIcon,
ChevronLeftIcon,
ChevronRightIcon,
MessageSquareIcon,
} from "lucide-react"
import { ConfirmDialog } from "@/components/ui/modal"
interface Comment {
id: string
gameId: string
content: Record<string, unknown>
upvotes: number
isRemoved: boolean
createdAt: string
updatedAt: string
userId: string
userName: string | null
userImage: string | null
gameTitle: string
parentId: string | null
}
interface CommentsApiResponse {
data: Comment[]
total: number
limit: number
offset: number
}
type StatusFilter = "all" | "active" | "removed"
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 extractPlainText(content: Record<string, unknown>, maxLength = 80): string {
let result = ""
function walk(node: unknown) {
if (typeof node !== "object" || node === null) return
if (Array.isArray(node)) {
for (const item of node) {
walk(item)
if (result.length >= maxLength) return
}
return
}
const obj = node as Record<string, unknown>
if (typeof obj.text === "string") {
result += obj.text
if (result.length >= maxLength) return
}
if (Array.isArray(obj.content)) {
for (const item of obj.content) {
walk(item)
if (result.length >= maxLength) return
}
}
}
walk(content)
return result.length > maxLength ? result.slice(0, maxLength) + "…" : result
}
function statusBadgeClasses(isRemoved: boolean) {
return isRemoved
? "bg-red-500/10 text-red-400"
: "bg-green-500/10 text-green-400"
}
function statusDotClass(isRemoved: boolean) {
return isRemoved ? "bg-red-400" : "bg-green-400"
}
export function CommentsClient() {
const [comments, setComments] = useState<Comment[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmAction, setConfirmAction] = useState<
| { type: "remove" | "restore"; comment: Comment }
| null
>(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
}
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")
}
if (search.trim()) {
params.set("search", search.trim())
}
const res = await fetch(`/api/admin/comments?${params.toString()}`)
if (res.ok) {
const json = (await res.json()) as CommentsApiResponse
setComments(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 handleRemove = async (comment: Comment) => {
setActionLoading((prev) => ({ ...prev, [comment.id]: true }))
try {
const res = await fetch(`/api/admin/comments/${comment.id}/remove`, {
method: "PATCH",
})
if (res.ok) {
await loadData()
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [comment.id]: false }))
}
}
const handleRestore = async (comment: Comment) => {
setActionLoading((prev) => ({ ...prev, [comment.id]: true }))
try {
const res = await fetch(`/api/admin/comments/${comment.id}/restore`, {
method: "PATCH",
})
if (res.ok) {
await loadData()
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [comment.id]: false }))
}
}
const hasMore = offset + comments.length < total
const tabs: { label: string; value: StatusFilter }[] = [
{ label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Removed", value: "removed" },
]
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<MessageSquareIcon className="h-5 w-5 text-text/70" />
<h1 className="text-lg font-semibold text-text">Comments</h1>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search comments..."
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"
/>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => handleStatusChange(tab.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
statusFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Author
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Content
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Upvotes
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : comments.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
No comments found.
</td>
</tr>
) : (
comments.map((comment) => (
<tr
key={comment.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{comment.userImage ? (
<Image
src={comment.userImage}
alt={comment.userName || "User"}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
unoptimized
/>
) : (
<div className="h-8 w-8 rounded-full bg-text/10 flex items-center justify-center text-xs font-medium text-text/70">
{getInitial(comment.userName)}
</div>
)}
<div className="min-w-0">
<p className="font-medium text-text truncate">
{comment.userName || "Unknown"}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-text/70 truncate max-w-[240px]">
{extractPlainText(comment.content, 80)}
</p>
</td>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[150px]">
{comment.gameTitle}
</p>
</td>
<td className="px-4 py-3">
<span className="text-text/70">{comment.upvotes}</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(comment.isRemoved)}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${statusDotClass(comment.isRemoved)}`}
/>
{comment.isRemoved ? "Removed" : "Active"}
</span>
</td>
<td className="px-4 py-3 text-xs text-text/50">
{formatDate(comment.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/game/${comment.gameId}`}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
{!comment.isRemoved ? (
<button
onClick={() =>
setConfirmAction({ type: "remove", comment })
}
disabled={actionLoading[comment.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[comment.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Remove
</button>
) : (
<button
onClick={() =>
setConfirmAction({ type: "restore", comment })
}
disabled={actionLoading[comment.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[comment.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcwIcon className="h-3.5 w-3.5" />
)}
Restore
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{comments.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + comments.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation Dialogs */}
{confirmAction?.type === "remove" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRemove(confirmAction.comment)}
title="Confirm Remove"
message="Are you sure you want to remove this comment?"
confirmLabel="Remove"
variant="destructive"
loading={actionLoading[confirmAction.comment.id]}
/>
)}
{confirmAction?.type === "restore" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRestore(confirmAction.comment)}
title="Confirm Restore"
message="Are you sure you want to restore this comment?"
confirmLabel="Restore"
variant="default"
loading={actionLoading[confirmAction.comment.id]}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { CommentsClient } from "./comments-client"
export const metadata: Metadata = {
title: "Comments",
}
export default function CommentsPage() {
return <CommentsClient />
}
@@ -0,0 +1,776 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import Image from "next/image"
import Link from "next/link"
import {
Loader2,
SearchIcon,
ExternalLinkIcon,
TrashIcon,
Gamepad2Icon,
RefreshCwIcon,
CheckCircle2Icon,
XCircleIcon,
} from "lucide-react"
interface SyncProgress {
isRunning: boolean
current: number
total: number
currentGame: string | null
synced: number
failed: number
results: Map<string, { success: boolean; error?: string }>
}
interface Game {
id: string
steamAppId: number | null
title: string
description: string | null
developer: string | null
publisher: string | null
genres: string[] | null
headerImage: string | null
capsuleImage: string | null
storeUrl: string | null
source: "steam" | "manual" | "gog" | "epic"
lastSync: string | null
syncStatus: string | null
syncError: string | null
createdAt: string
updatedAt: string
}
interface GamesApiResponse {
data: Game[]
total: number
limit: number
offset: number
}
const LIMIT = 50
export function GamesClient() {
const [games, setGames] = useState<Game[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set())
const [resyncingIds, setResyncingIds] = useState<Set<string>>(new Set())
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [syncing, setSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<SyncProgress>({
isRunning: false,
current: 0,
total: 0,
currentGame: null,
synced: 0,
failed: 0,
results: new Map(),
})
const [syncCompleted, setSyncCompleted] = useState(false)
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(new Set(games.map((g) => g.id)))
} else {
setSelectedIds(new Set())
}
}
const handleSelectOne = (id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
const handleSyncSelected = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
const gamesToSync = games.filter((g) => ids.includes(g.id) && g.steamAppId)
if (gamesToSync.length === 0) {
alert("No Steam games selected to sync")
return
}
setSyncing(true)
setSyncCompleted(false)
setSyncProgress({
isRunning: true,
current: 0,
total: gamesToSync.length,
currentGame: "Preparing sync...",
synced: 0,
failed: 0,
results: new Map(),
})
try {
// Use the bulk sync endpoint with streaming progress
const res = await fetch("/api/games/sync/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "selected",
gameIds: gamesToSync.map((g) => g.id),
}),
})
if (!res.ok) {
const errorData = await res.json().catch(() => null)
throw new Error(errorData?.error || `Sync failed: HTTP ${res.status}`)
}
// Read streaming response
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
try {
const data = JSON.parse(line)
if (data.type === "progress") {
setSyncProgress((prev) => ({
...prev,
current: data.current,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: data.currentGame,
}))
} else if (data.type === "complete") {
setSyncProgress((prev) => ({
...prev,
isRunning: false,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: null,
}))
setSyncCompleted(true)
setSelectedIds(new Set())
}
} catch {
// Ignore parse errors
}
}
}
}
} catch (error) {
console.error("Sync selected failed:", error)
alert(`Sync failed: ${error instanceof Error ? error.message : String(error)}`)
setSyncProgress((prev) => ({
...prev,
isRunning: false,
currentGame: null,
}))
} finally {
setSyncing(false)
}
}
const handleSyncAll = async () => {
if (!confirm("This will sync all Steam games. Continue?")) return
setSyncing(true)
setSyncCompleted(false)
setSyncProgress({
isRunning: true,
current: 0,
total: 0,
currentGame: "Preparing sync...",
synced: 0,
failed: 0,
results: new Map(),
})
try {
// Use the bulk sync endpoint with streaming progress
const res = await fetch("/api/games/sync/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "all" }),
})
if (!res.ok) {
const errorData = await res.json().catch(() => null)
throw new Error(errorData?.error || `Sync failed: HTTP ${res.status}`)
}
// Read streaming response
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
try {
const data = JSON.parse(line)
if (data.type === "progress") {
setSyncProgress((prev) => ({
...prev,
current: data.current,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: data.currentGame,
}))
} else if (data.type === "complete") {
setSyncProgress((prev) => ({
...prev,
isRunning: false,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: null,
}))
setSyncCompleted(true)
}
} catch {
// Ignore parse errors
}
}
}
}
} catch (error) {
console.error("Sync all failed:", error)
alert(`Sync failed: ${error instanceof Error ? error.message : String(error)}`)
setSyncProgress((prev) => ({
...prev,
isRunning: false,
currentGame: null,
}))
} finally {
setSyncing(false)
}
}
const closeSyncOverlay = () => {
setSyncCompleted(false)
setSyncProgress({
isRunning: false,
current: 0,
total: 0,
currentGame: null,
synced: 0,
failed: 0,
results: new Map(),
})
}
// Close sync overlay on Escape key
useEffect(() => {
if (!syncCompleted) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") closeSyncOverlay()
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [syncCompleted])
const isSearchChangeRef = useRef(false)
const handleSearchChange = (value: string) => {
setSearch(value)
setOffset(0)
isSearchChangeRef.current = true
setSelectedIds(new Set()) // Clear selection on search change
}
const handlePrev = () => {
setOffset((prev) => Math.max(0, prev - LIMIT))
isSearchChangeRef.current = false
setSelectedIds(new Set()) // Clear selection on page change
}
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 res = await fetch(
`/api/games?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(search)}&sort=createdAt&order=desc`
)
if (res.ok && !cancelled) {
const json = (await res.json()) as GamesApiResponse
setGames(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false)
}
}, delay)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [search, offset])
const handleResync = async (game: Game) => {
setResyncingIds((prev) => new Set(prev).add(game.id))
try {
const res = await fetch(`/api/games/${game.id}/sync`, {
method: "POST",
})
if (res.ok) {
setGames((prev) =>
prev.map((g) =>
g.id === game.id
? { ...g, syncStatus: "synced", lastSync: new Date().toISOString() }
: g
)
)
}
} catch (error) {
console.error("Resync failed:", error)
} finally {
setResyncingIds((prev) => {
const next = new Set(prev)
next.delete(game.id)
return next
})
}
}
const handleDelete = async (game: Game) => {
if (!confirm(`Are you sure you want to delete "${game.title}"?`)) return
setDeletingIds((prev) => new Set(prev).add(game.id))
try {
const res = await fetch(`/api/games/${game.id}`, { method: "DELETE" })
if (res.ok) {
setGames((prev) => prev.filter((g) => g.id !== game.id))
setTotal((prev) => Math.max(0, prev - 1))
}
} finally {
setDeletingIds((prev) => {
const next = new Set(prev)
next.delete(game.id)
return next
})
}
}
const hasMore = offset + games.length < total
return (
<div className="space-y-4">
{/* Search */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search games..."
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"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSyncAll}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50"
>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
Sync All
</button>
</div>
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 w-10">
<input
type="checkbox"
checked={selectedIds.size === games.length && games.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-border"
/>
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50 w-14">
Cover
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Title
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Developer
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Source
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Sync
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : games.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
No games found.
</td>
</tr>
) : (
games.map((game) => (
<tr
key={game.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedIds.has(game.id)}
onChange={(e) => handleSelectOne(game.id, e.target.checked)}
className="rounded border-border"
/>
</td>
<td className="px-4 py-3">
<div className="h-10 w-10 rounded overflow-hidden bg-text/5 flex items-center justify-center">
{game.capsuleImage || game.headerImage ? (
<Image
src={game.capsuleImage || game.headerImage || ""}
alt={game.title}
width={40}
height={40}
className="h-10 w-10 object-cover"
unoptimized
/>
) : (
<Gamepad2Icon className="h-4 w-4 text-text/40" />
)}
</div>
</td>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[200px]">
{game.title}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[150px]">
{game.developer || "—"}
</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
game.source === "steam"
? "bg-blue-500/10 text-blue-400"
: game.source === "manual"
? "bg-text/5 text-text/50"
: "bg-text/5 text-text/50"
}`}
>
{game.source}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
game.syncStatus === "synced"
? "bg-green-500/10 text-green-400"
: game.syncStatus === "failed"
? "bg-red-500/10 text-red-400"
: "bg-yellow-500/10 text-yellow-400"
}`}
title={game.syncStatus === "failed" ? game.syncError || undefined : undefined}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
game.syncStatus === "synced"
? "bg-green-400"
: game.syncStatus === "failed"
? "bg-red-400"
: "bg-yellow-400"
}`}
/>
{game.syncStatus === "synced" ? "Synced" : game.syncStatus === "failed" ? "Failed" : "Stale"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
href={`/game/${game.id}`}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
<button
onClick={() => handleResync(game)}
disabled={resyncingIds.has(game.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{resyncingIds.has(game.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCwIcon className="h-3.5 w-3.5" />
)}
Resync
</button>
<button
onClick={() => handleDelete(game)}
disabled={deletingIds.has(game.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{deletingIds.has(game.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Delete
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Sync Progress Overlay — portaled to body for guaranteed viewport coverage */}
{(syncProgress.isRunning || syncCompleted) &&
createPortal(
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="sync-overlay-title"
className="w-full max-w-md mx-4 p-6 bg-background border border-border rounded-2xl shadow-2xl max-h-[90vh] overflow-y-auto"
>
{syncProgress.isRunning ? (
<>
<div className="flex items-center gap-3 mb-4">
<div className="relative">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCwIcon className="h-4 w-4 text-primary" />
</div>
</div>
<div>
<h3 id="sync-overlay-title" className="font-semibold text-text">Syncing Games</h3>
<p className="text-sm text-text/50">
{syncProgress.total > 0
? `${syncProgress.current} of ${syncProgress.total} games`
: "Preparing to sync..."
}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mb-4">
<div className="flex justify-between text-xs text-text/50 mb-1">
<span>Progress</span>
<span>{syncProgress.total > 0 ? Math.round((syncProgress.current / syncProgress.total) * 100) : 0}%</span>
</div>
<div className="h-2 bg-text/10 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${(syncProgress.current / syncProgress.total) * 100}%` }}
/>
</div>
</div>
{/* Current game */}
{syncProgress.currentGame && (
<div className="mb-4 p-3 bg-text/5 rounded-lg">
<p className="text-xs text-text/50 mb-1">Currently syncing:</p>
<p className="text-sm font-medium text-text truncate">{syncProgress.currentGame}</p>
</div>
)}
</>
) : (
<>
{/* Completed state header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CheckCircle2Icon className="h-8 w-8 text-green-400" />
<div>
<h3 id="sync-overlay-title" className="font-semibold text-text">Sync Complete</h3>
<p className="text-sm text-text/50">
{syncProgress.synced} synced, {syncProgress.failed} failed
</p>
</div>
</div>
<button
onClick={closeSyncOverlay}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
Close
</button>
</div>
</>
)}
{/* Stats — shown in both running and completed states */}
<div className="flex gap-4 mb-4">
<div className="flex-1 p-3 bg-green-500/10 rounded-lg">
<div className="flex items-center gap-2">
<CheckCircle2Icon className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium text-green-400">{syncProgress.synced}</span>
</div>
<p className="text-xs text-text/50 mt-1">Synced</p>
</div>
<div className="flex-1 p-3 bg-red-500/10 rounded-lg">
<div className="flex items-center gap-2">
<XCircleIcon className="h-4 w-4 text-red-400" />
<span className="text-sm font-medium text-red-400">{syncProgress.failed}</span>
</div>
<p className="text-xs text-text/50 mt-1">Failed</p>
</div>
</div>
{/* Results list — scrollable */}
{syncProgress.results.size > 0 && (
<div className="max-h-48 overflow-y-auto space-y-1">
<p className="text-xs text-text/50 mb-2">
{syncCompleted ? "All results:" : "Recent results:"}
</p>
{(syncCompleted
? Array.from(syncProgress.results.entries())
: Array.from(syncProgress.results.entries()).slice(-5).reverse()
).map(([gameId, result]) => {
const game = games.find((g) => g.id === gameId)
return (
<div key={gameId} className="flex items-center gap-2 py-1">
{result.success ? (
<CheckCircle2Icon className="h-3 w-3 text-green-400 shrink-0" />
) : (
<XCircleIcon className="h-3 w-3 text-red-400 shrink-0" />
)}
<span className="text-xs text-text/70 truncate">
{game?.title || gameId}
</span>
{!result.success && result.error && (
<span className="text-xs text-red-400/70 ml-auto shrink-0 truncate max-w-[150px]" title={result.error}>
{result.error}
</span>
)}
</div>
)
})}
</div>
)}
</div>
</div>,
document.body
)}
{/* Floating action bar */}
{selectedIds.size > 0 && !syncProgress.isRunning && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-3 bg-background border border-border rounded-xl shadow-lg">
<span className="text-sm text-text/70">
{selectedIds.size} game{selectedIds.size !== 1 ? "s" : ""} selected
</span>
<button
onClick={handleSyncSelected}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors text-sm font-medium cursor-pointer disabled:opacity-50"
>
<RefreshCwIcon className="h-4 w-4" />
Resync Selected
</button>
<button
onClick={handleSyncAll}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-text/5 text-text hover:bg-text/10 transition-colors text-sm font-medium cursor-pointer disabled:opacity-50"
>
<RefreshCwIcon className="h-4 w-4" />
Resync All
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-2 rounded-lg text-sm text-text/50 hover:text-text/70 transition-colors cursor-pointer"
>
Clear
</button>
</div>
)}
{/* Pagination */}
{games.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + games.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { GamesClient } from "./games-client"
export const metadata: Metadata = {
title: "Games",
}
export default function GamesPage() {
return <GamesClient />
}
@@ -0,0 +1,411 @@
"use client"
import { useEffect, useState, FormEvent } from "react"
import {
Gamepad2Icon,
PlusIcon,
PencilIcon,
TrashIcon,
Loader2,
XIcon,
} from "lucide-react"
import { getDeviceColor } from "@/components/charts/EChartWrapper"
interface DeviceItem {
slug: string
name: string
deviceType: string
image: string | null
sortOrder: number
totalBenchmarks?: number
avgFps?: number | null
gameCount?: number
wattHours?: number | null
tdpMax?: number | null
}
interface DeviceFormData {
slug: string
name: string
deviceType: "handheld" | "console"
image: string
sortOrder: number
wattHours: string
tdpMax: string
}
export function HardwareClient() {
const [devices, setDevices] = useState<DeviceItem[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingDevice, setEditingDevice] = useState<DeviceItem | null>(null)
const [form, setForm] = useState<DeviceFormData>({
slug: "",
name: "",
deviceType: "handheld",
image: "",
sortOrder: 0,
wattHours: "",
tdpMax: "",
})
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const fetchDevices = async () => {
setLoading(true)
try {
const res = await fetch("/api/hardware/stats")
if (res.ok) {
const data = await res.json()
setDevices(
(data as DeviceItem[]).map((item) => ({
...item,
image: item.image ?? null,
}))
)
}
} catch {
// ignore
} finally {
setLoading(false)
}
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchDevices()
}, [])
const openCreate = () => {
setEditingDevice(null)
setForm({
slug: "",
name: "",
deviceType: "handheld",
image: "",
sortOrder: 0,
wattHours: "",
tdpMax: "",
})
setShowModal(true)
}
const openEdit = (device: DeviceItem) => {
setEditingDevice(device)
setForm({
slug: device.slug,
name: device.name,
deviceType: device.deviceType as "handheld" | "console",
image: device.image ?? "",
sortOrder: device.sortOrder,
wattHours: device.wattHours != null ? String(device.wattHours) : "",
tdpMax: device.tdpMax != null ? String(device.tdpMax) : "",
})
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingDevice(null)
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
setSaving(true)
try {
const body = editingDevice
? {
name: form.name,
deviceType: form.deviceType,
image: form.image || null,
sortOrder: form.sortOrder,
wattHours: form.wattHours ? parseFloat(form.wattHours) : null,
tdpMax: form.tdpMax ? parseFloat(form.tdpMax) : null,
}
: {
slug: form.slug,
name: form.name,
deviceType: form.deviceType,
image: form.image || null,
sortOrder: form.sortOrder,
wattHours: form.wattHours ? parseFloat(form.wattHours) : null,
tdpMax: form.tdpMax ? parseFloat(form.tdpMax) : null,
}
const url = editingDevice
? `/api/hardware/${editingDevice.slug}`
: "/api/hardware"
const method = editingDevice ? "PATCH" : "POST"
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (res.ok) {
closeModal()
await fetchDevices()
}
} finally {
setSaving(false)
}
}
const handleDelete = async (slug: string) => {
if (!confirm("Are you sure you want to delete this device?")) return
setDeleting(true)
try {
const res = await fetch(`/api/hardware/${slug}`, { method: "DELETE" })
if (res.ok) {
await fetchDevices()
}
} finally {
setDeleting(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-text">Hardware</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors cursor-pointer"
>
<PlusIcon className="h-4 w-4" />
Add Device
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-text/50">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : devices.length === 0 ? (
<div className="text-center py-16 text-text/40">
<Gamepad2Icon className="h-10 w-10 mx-auto mb-2" />
<p>No devices found</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{devices.map((device, index) => {
const color = getDeviceColor(index)
return (
<div
key={device.slug}
className="rounded-xl border border-border bg-text/[0.02] p-4 flex flex-col gap-3 hover:bg-text/[0.04] transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="h-10 w-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${color}20`, color }}
>
<Gamepad2Icon className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium text-text text-sm">
{device.name}
</h3>
<p className="text-xs text-text/50">/{device.slug}</p>
</div>
</div>
<span
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${
device.deviceType === "handheld"
? "text-primary bg-primary/10 border-primary/20"
: "text-secondary bg-secondary/10 border-secondary/20"
}`}
>
{device.deviceType}
</span>
</div>
<div className="text-xs text-text/60">
{device.totalBenchmarks ?? 0} benchmarks
</div>
{(device.wattHours != null || device.tdpMax != null) && (
<div className="text-xs text-text/60 flex gap-2">
{device.wattHours != null && <span>{device.wattHours} Wh</span>}
{device.tdpMax != null && <span>{device.tdpMax}W TDP</span>}
</div>
)}
<div className="flex items-center gap-2 mt-auto">
<button
onClick={() => openEdit(device)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
<PencilIcon className="h-3.5 w-3.5" />
Edit
</button>
<button
onClick={() => handleDelete(device.slug)}
disabled={deleting}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
<TrashIcon className="h-3.5 w-3.5" />
Delete
</button>
</div>
</div>
)
})}
</div>
)}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={closeModal}
/>
<div className="relative bg-background border border-border rounded-xl p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-text">
{editingDevice ? "Edit Device" : "Add Device"}
</h2>
<button
onClick={closeModal}
className="p-1 rounded-md hover:bg-text/5 text-text/60 hover:text-text transition-colors cursor-pointer"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Slug
</label>
<input
type="text"
value={form.slug}
onChange={(e) =>
setForm((f) => ({ ...f, slug: e.target.value }))
}
disabled={!!editingDevice}
required
className="w-full px-3 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 disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Name
</label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((f) => ({ ...f, name: e.target.value }))
}
required
className="w-full px-3 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Device Type
</label>
<select
value={form.deviceType}
onChange={(e) =>
setForm((f) => ({
...f,
deviceType: e.target.value as "handheld" | "console",
}))
}
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 transition-colors"
>
<option value="handheld">Handheld</option>
<option value="console">Console</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Sort Order
</label>
<input
type="number"
value={form.sortOrder}
onChange={(e) =>
setForm((f) => ({
...f,
sortOrder: Number(e.target.value),
}))
}
className="w-full px-3 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Image URL
</label>
<input
type="text"
value={form.image}
onChange={(e) =>
setForm((f) => ({ ...f, image: e.target.value }))
}
placeholder="https://..."
className="w-full px-3 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Battery Capacity (Wh)
</label>
<input
type="number"
value={form.wattHours}
onChange={(e) => setForm((f) => ({ ...f, wattHours: e.target.value }))}
placeholder="e.g. 50.0"
min="0"
max="200"
step="0.1"
className="w-full px-3 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"
/>
<p className="text-[10px] text-text/40 mt-1">
Watt-hours (used for battery life estimation)
</p>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Max TDP (W)
</label>
<input
type="number"
value={form.tdpMax}
onChange={(e) => setForm((f) => ({ ...f, tdpMax: e.target.value }))}
placeholder="e.g. 15"
min="0"
max="100"
step="0.1"
className="w-full px-3 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"
/>
<p className="text-[10px] text-text/40 mt-1">
Maximum TDP in watts (used for battery life estimation)
</p>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={closeModal}
className="px-3 py-2 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary text-white hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50"
>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{editingDevice ? "Save Changes" : "Create Device"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { HardwareClient } from "./hardware-client"
export const metadata: Metadata = {
title: "Hardware",
}
export default function HardwarePage() {
return <HardwareClient />
}
+15
View File
@@ -0,0 +1,15 @@
import { DashboardOverview } from "@/components/manage/dashboard-overview";
export default function ManagePage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-zinc-400">
Overview of DeckyVault statistics and moderation queue
</p>
</div>
<DashboardOverview />
</div>
);
}
@@ -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 <ReportsClient />
}
@@ -0,0 +1,460 @@
"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"
import { ConfirmDialog } from "@/components/ui/modal"
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<Report[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmReport, setConfirmReport] = useState<Report | null>(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 (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<FlagIcon className="h-5 w-5 text-text/70" />
<h1 className="text-lg font-semibold text-text">Reports</h1>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => 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"
/>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => handleStatusChange(tab.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
statusFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Reporter
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
FPS
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Reason
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Details
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : filteredReports.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-text/50">
No reports found.
</td>
</tr>
) : (
filteredReports.map((report) => (
<tr
key={report.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-text/10 flex items-center justify-center text-xs font-medium text-text/70">
{getInitial(report.reporterName)}
</div>
<div className="min-w-0">
<p className="font-medium text-text truncate">
{report.reporterName || "Unknown"}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{report.game.headerImage ? (
<Image
src={report.game.headerImage}
alt={report.game.title}
width={40}
height={20}
className="h-5 w-10 object-cover rounded"
unoptimized
/>
) : null}
<p className="font-medium text-text truncate max-w-[150px]">
{report.game.title}
</p>
</div>
</td>
<td className="px-4 py-3">
<span className="text-text/70">
{report.entry.fpsAvg ?? "—"}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${reasonBadgeClasses(report.reason)}`}
>
{report.reason}
</span>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[200px]">
{truncate(report.details, 80)}
</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(report.status)}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
report.status === "open"
? "bg-yellow-400"
: report.status === "reviewed"
? "bg-green-400"
: "bg-text/40"
}`}
/>
{report.status}
</span>
</td>
<td className="px-4 py-3 text-xs text-text/50">
{formatDate(report.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{report.status === "open" ? (
<>
<button
onClick={() => setConfirmReport(report)}
disabled={actionLoading[report.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[report.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ShieldCheckIcon className="h-3.5 w-3.5" />
)}
Review
</button>
<button
onClick={() => handleUpdateStatus(report, "dismissed")}
disabled={actionLoading[report.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text/70 hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[report.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<XIcon className="h-3.5 w-3.5" />
)}
Dismiss
</button>
</>
) : (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(report.status)}`}
>
{report.status}
</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{filteredReports.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + filteredReports.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation Dialog */}
{confirmReport && (
<ConfirmDialog
open={!!confirmReport}
onClose={() => setConfirmReport(null)}
onConfirm={() => handleUpdateStatus(confirmReport, "reviewed")}
title="Confirm Review"
message="This will also remove the reported benchmark. Are you sure?"
confirmLabel="Review"
variant="default"
loading={actionLoading[confirmReport.id]}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { StorageClient } from "./storage-client"
export const metadata: Metadata = {
title: "Storage",
}
export default function StoragePage() {
return <StorageClient />
}
@@ -0,0 +1,447 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
Loader2,
HardDriveIcon,
TrashIcon,
SearchIcon,
RefreshCwIcon,
AlertTriangleIcon,
ChevronLeftIcon,
ChevronRightIcon,
FileIcon,
CheckCircle2Icon,
} from "lucide-react"
import { ConfirmDialog } from "@/components/ui/modal"
type EntityType = "all" | "avatar" | "entry_screenshot" | "game_cover" | "hardware_image" | "orphaned"
interface StorageStats {
configured: boolean
bucketName: string
totalObjects: number
totalSizeBytes: number
orphanedCount: number
orphanedSizeBytes: number
byEntityType: Record<string, { count: number; totalSizeBytes: number }>
}
interface StorageObject {
id: string
key: string
bucket: string
size: number
mimeType: string
entityType: string
entityId: string | null
uploadedBy: string
uploaderName: string | null
createdAt: string
lastAccessedAt: string | null
isOrphaned: boolean
}
interface ObjectsResponse {
data: StorageObject[]
total: number
limit: number
offset: number
}
const LIMIT = 50
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function formatDate(value: string | null | undefined) {
if (!value) return "—"
return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function entityTypeLabel(type: string) {
const labels: Record<string, string> = {
avatar: "Avatar",
entry_screenshot: "Screenshot",
game_cover: "Game Cover",
hardware_image: "Hardware Img",
}
return labels[type] ?? type
}
function entityTypeBadgeClass(type: string) {
const classes: Record<string, string> = {
avatar: "bg-purple-500/10 text-purple-400",
entry_screenshot: "bg-blue-500/10 text-blue-400",
game_cover: "bg-green-500/10 text-green-400",
hardware_image: "bg-amber-500/10 text-amber-400",
}
return classes[type] ?? "bg-text/5 text-text/50"
}
export function StorageClient() {
const [stats, setStats] = useState<StorageStats | null>(null)
const [objects, setObjects] = useState<StorageObject[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [statsLoading, setStatsLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [entityFilter, setEntityFilter] = useState<EntityType>("all")
const [deleting, setDeleting] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<StorageObject | null>(null)
const [cleanupLoading, setCleanupLoading] = useState(false)
const [cleanupResult, setCleanupResult] = useState<{ deleted: number; errors: number } | null>(null)
const isSearchChangeRef = useRef(false)
useEffect(() => {
fetch("/api/admin/storage/stats")
.then((res) => res.json())
.then((data) => {
setStats(data)
setStatsLoading(false)
})
.catch(() => setStatsLoading(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 (entityFilter === "orphaned") {
params.set("orphaned", "true")
} else if (entityFilter !== "all") {
params.set("entityType", entityFilter)
}
if (search.trim()) {
params.set("search", search.trim())
}
const res = await fetch(`/api/admin/storage/objects?${params.toString()}`)
if (res.ok && !cancelled) {
const json = (await res.json()) as ObjectsResponse
setObjects(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false)
}
}, delay)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [search, offset, entityFilter])
const handleSearchChange = (value: string) => {
setSearch(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handleDelete = async (obj: StorageObject) => {
setDeleting(obj.id)
try {
const res = await fetch(`/api/admin/storage/objects/${obj.id}`, {
method: "DELETE",
})
if (res.ok) {
setObjects((prev) => prev.filter((o) => o.id !== obj.id))
setTotal((prev) => Math.max(0, prev - 1))
setConfirmDelete(null)
const statsRes = await fetch("/api/admin/storage/stats")
if (statsRes.ok) {
setStats(await statsRes.json())
}
}
} finally {
setDeleting(null)
}
}
const handleCleanup = async () => {
setCleanupLoading(true)
setCleanupResult(null)
try {
const res = await fetch("/api/admin/storage/cleanup", { method: "POST" })
if (res.ok) {
const data = await res.json()
setCleanupResult({ deleted: data.deleted, errors: data.errors })
const statsRes = await fetch("/api/admin/storage/stats")
if (statsRes.ok) setStats(await statsRes.json())
setOffset(0)
}
} finally {
setCleanupLoading(false)
}
}
const hasMore = offset + objects.length < total
const filterTabs: { label: string; value: EntityType }[] = [
{ label: "All", value: "all" },
{ label: "Avatars", value: "avatar" },
{ label: "Screenshots", value: "entry_screenshot" },
{ label: "Covers", value: "game_cover" },
{ label: "Hardware", value: "hardware_image" },
{ label: "Orphaned", value: "orphaned" },
]
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<HardDriveIcon className="h-5 w-5 text-text/70" />
<h1 className="text-lg font-semibold text-text">Storage</h1>
</div>
{stats && !stats.configured && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4 flex items-start gap-3">
<AlertTriangleIcon className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-400">R2 Not Configured</p>
<p className="text-xs text-text/60 mt-1">
Set the R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_PUBLIC_URL environment variables to enable storage management.
</p>
</div>
</div>
)}
{statsLoading ? (
<div className="animate-pulse h-20 rounded-lg bg-zinc-800" />
) : stats && stats.configured ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<FileIcon className="h-4 w-4 text-blue-400" />
<span className="text-xs text-zinc-500">Total Objects</span>
</div>
<p className="text-2xl font-bold">{stats.totalObjects.toLocaleString()}</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<HardDriveIcon className="h-4 w-4 text-green-400" />
<span className="text-xs text-zinc-500">Total Size</span>
</div>
<p className="text-2xl font-bold">{formatBytes(stats.totalSizeBytes)}</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangleIcon className="h-4 w-4 text-amber-400" />
<span className="text-xs text-zinc-500">Orphaned</span>
</div>
<p className="text-2xl font-bold">{stats.orphanedCount.toLocaleString()}</p>
{stats.orphanedSizeBytes > 0 && (
<p className="text-xs text-zinc-500">{formatBytes(stats.orphanedSizeBytes)}</p>
)}
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<HardDriveIcon className="h-4 w-4 text-purple-400" />
<span className="text-xs text-zinc-500">Bucket</span>
</div>
<p className="text-sm font-mono text-text truncate">{stats.bucketName}</p>
</div>
</div>
) : null}
{stats?.byEntityType && Object.keys(stats.byEntityType).length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<h3 className="mb-3 font-semibold text-sm">Storage by Type</h3>
<div className="flex gap-4 flex-wrap">
{Object.entries(stats.byEntityType).map(([type, data]) => (
<div key={type} className="flex items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${entityTypeBadgeClass(type)}`}>
{entityTypeLabel(type)}
</span>
<span className="text-xs text-text/70">{data.count} · {formatBytes(data.totalSizeBytes)}</span>
</div>
))}
</div>
</div>
)}
{stats?.configured && (
<div className="flex items-center gap-3">
<button
onClick={handleCleanup}
disabled={cleanupLoading || !stats?.configured}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-500/10 text-amber-400 text-sm font-medium hover:bg-amber-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{cleanupLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
Run Orphan Cleanup
</button>
{cleanupResult && (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2Icon className="h-4 w-4 text-green-400" />
<span className="text-text/70">
Deleted {cleanupResult.deleted} objects
{cleanupResult.errors > 0 && `, ${cleanupResult.errors} errors`}
</span>
</div>
)}
</div>
)}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search by key..."
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"
/>
</div>
<div className="flex items-center gap-1 flex-wrap">
{filterTabs.map((tab) => (
<button
key={tab.value}
onClick={() => {
setEntityFilter(tab.value)
setOffset(0)
}}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
entityFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Key</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Type</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Size</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Uploaded By</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Date</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : objects.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">No objects found.</td>
</tr>
) : (
objects.map((obj) => (
<tr
key={obj.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<p className="font-mono text-xs text-text/70 truncate max-w-[300px]" title={obj.key}>{obj.key}</p>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${entityTypeBadgeClass(obj.entityType)}`}>
{entityTypeLabel(obj.entityType)}
</span>
</td>
<td className="px-4 py-3 text-xs text-text/70">{formatBytes(obj.size)}</td>
<td className="px-4 py-3 text-xs text-text/70">{obj.uploaderName || "System"}</td>
<td className="px-4 py-3 text-xs text-text/50">{formatDate(obj.createdAt)}</td>
<td className="px-4 py-3">
{obj.isOrphaned && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider bg-amber-500/10 text-amber-400">
Orphaned
</span>
)}
</td>
<td className="px-4 py-3">
<button
onClick={() => setConfirmDelete(obj)}
disabled={deleting === obj.id}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{deleting === obj.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{objects.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + objects.length, total)} of {total}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setOffset((prev) => Math.max(0, prev - LIMIT))}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={() => setOffset((prev) => prev + LIMIT)}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{confirmDelete && (
<ConfirmDialog
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={() => handleDelete(confirmDelete)}
title="Delete Storage Object"
message={`Are you sure you want to delete "${confirmDelete.key}"? This will remove the file from R2 storage and the database record. This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
loading={deleting === confirmDelete.id}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { SuggestionsClient } from "./suggestions-client"
export const metadata: Metadata = {
title: "Suggestions",
}
export default function SuggestionsPage() {
return <SuggestionsClient />
}
@@ -0,0 +1,383 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { ConfirmDialog } from "@/components/ui/modal"
import {
Loader2,
SearchIcon,
Lightbulb,
ExternalLinkIcon,
CheckCircle2Icon,
XCircleIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
interface Suggestion {
id: string
gameId: string
gameTitle: string
fieldName: string
currentValue: string | null
proposedValue: string
reason: string | null
status: string
createdAt: string
userName: string | null
}
type ConfirmType = "approve" | "reject"
interface SuggestionsApiResponse {
data: Suggestion[]
total: number
limit: number
offset: number
}
const LIMIT = 50
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" })
}
export function SuggestionsClient() {
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [total, setTotal] = useState(0)
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmAction, setConfirmAction] = useState<{
type: ConfirmType
suggestion: Suggestion
} | null>(null)
const [activeTab, setActiveTab] = useState("all")
const statusTabs = [
{ label: "All", value: "all" },
{ label: "Pending", value: "pending" },
{ label: "Approved", value: "approved" },
{ label: "Rejected", value: "rejected" },
]
useEffect(() => {
let cancelled = false
const run = async () => {
try {
const res = await fetch(`/api/community-suggestions/admin?limit=${LIMIT}`)
if (res.ok && !cancelled) {
const json = (await res.json()) as SuggestionsApiResponse
setSuggestions(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false)
}
}
run()
return () => { cancelled = true }
}, [])
const filtered = useMemo(() => {
let result = suggestions
const term = search.trim().toLowerCase()
if (term) {
result = result.filter(
(s) =>
s.gameTitle?.toLowerCase().includes(term) ||
s.fieldName?.toLowerCase().includes(term) ||
s.proposedValue?.toLowerCase().includes(term) ||
s.userName?.toLowerCase().includes(term) ||
s.reason?.toLowerCase().includes(term)
)
}
if (activeTab !== "all") {
result = result.filter((s) => s.status === activeTab)
}
return result
}, [suggestions, search, activeTab])
const paginated = filtered.slice(offset, offset + LIMIT)
const hasMore = offset + LIMIT < total
const handlePrev = () => setOffset((prev) => Math.max(0, prev - LIMIT))
const handleNext = () => setOffset((prev) => prev + LIMIT)
const handleReview = async (
suggestion: Suggestion,
status: "approved" | "rejected"
) => {
setActionLoading((prev) => ({ ...prev, [suggestion.id]: true }))
try {
const res = await fetch(
`/api/community-suggestions/${suggestion.id}/review`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
}
)
if (res.ok) {
setSuggestions((prev) =>
prev.filter((s) => s.id !== suggestion.id)
)
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [suggestion.id]: false }))
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Lightbulb className="h-5 w-5 text-text/50" />
<h2 className="text-lg font-semibold text-text">Suggestions</h2>
<span className="text-sm text-text/50">({filtered.length})</span>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search suggestions..."
className="w-full pl-9 pr-4 py-2.5 rounded-lg bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
{/* Status Tabs */}
<div className="flex items-center gap-2">
{statusTabs.map((tab) => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
activeTab === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/60 hover:bg-text/10"
)}
>
{tab.label}
</button>
))}
</div>
{/* Suggestions List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : paginated.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text/50">
<Lightbulb className="h-8 w-8 mb-2" />
<p className="text-sm">No suggestions found</p>
</div>
) : (
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Field
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Current
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Proposed
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Reason
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
By
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{paginated.map((suggestion) => (
<tr
key={suggestion.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[200px]">
{suggestion.gameTitle}
</p>
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-md bg-text/5 text-text/70">
{suggestion.fieldName}
</span>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[150px]">
{suggestion.currentValue || "—"}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text truncate max-w-[150px]">
{suggestion.proposedValue}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[200px]">
{suggestion.reason || "—"}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text/70 truncate max-w-[120px]">
{suggestion.userName || "Unknown"}
</p>
</td>
<td className="px-4 py-3">
<span className="text-xs text-text/50">
{formatDate(suggestion.createdAt)}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
suggestion.status === "approved"
? "bg-green-500/10 text-green-400"
: suggestion.status === "rejected"
? "bg-red-500/10 text-red-400"
: "bg-yellow-500/10 text-yellow-400"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
suggestion.status === "approved"
? "bg-green-400"
: suggestion.status === "rejected"
? "bg-red-400"
: "bg-yellow-400"
}`}
/>
{suggestion.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
href={`/game/${suggestion.gameId}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
<button
onClick={() =>
setConfirmAction({ type: "approve", suggestion })
}
disabled={actionLoading[suggestion.id]}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[suggestion.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<CheckCircle2Icon className="h-3.5 w-3.5" />
)}
Approve
</button>
<button
onClick={() =>
setConfirmAction({ type: "reject", suggestion })
}
disabled={actionLoading[suggestion.id]}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[suggestion.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<XCircleIcon className="h-3.5 w-3.5" />
)}
Reject
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{total > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + paginated.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirm Dialog */}
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => {
if (confirmAction) {
handleReview(
confirmAction.suggestion,
confirmAction.type === "approve" ? "approved" : "rejected"
)
}
}}
title={`Confirm ${confirmAction?.type === "approve" ? "Approval" : "Rejection"}`}
message={
confirmAction
? `Are you sure you want to ${confirmAction.type === "approve" ? "approve" : "reject"} the "${confirmAction.suggestion.fieldName}" suggestion for "${confirmAction.suggestion.gameTitle}"?`
: ""
}
confirmLabel={confirmAction?.type === "approve" ? "Approve" : "Reject"}
cancelLabel="Cancel"
variant={confirmAction?.type === "approve" ? "default" : "destructive"}
loading={confirmAction ? !!actionLoading[confirmAction.suggestion.id] : false}
/>
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { UsersClient } from "./users-client"
export const metadata: Metadata = {
title: "Users",
}
export default function UsersPage() {
return <UsersClient />
}
@@ -0,0 +1,276 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Image from "next/image"
import { authClient } from "@/lib/auth-client"
import { Loader2, SearchIcon, BanIcon, UserCheckIcon, UsersIcon } from "lucide-react"
type Role = "user" | "contributor" | "admin"
interface AdminUser {
id: string
name: string
email: string
emailVerified: boolean
image: string | null
createdAt: Date | string
role: Role
banned: boolean
}
const roles: Role[] = ["user", "contributor", "admin"]
function formatDate(value: Date | string | 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) {
return name?.charAt(0)?.toUpperCase() || "?"
}
export function UsersClient() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [banTarget, setBanTarget] = useState<AdminUser | null>(null)
const [banReason, setBanReason] = useState("")
const [banExpiryDays, setBanExpiryDays] = useState("")
const [banFilter, setBanFilter] = useState<"all" | "banned" | "active">("all")
useEffect(() => {
const fetchUsers = async () => {
setLoading(true)
const res = await authClient.admin.listUsers({ query: { limit: 100 } })
if (res.data?.users) {
setUsers(res.data.users as AdminUser[])
}
setLoading(false)
}
fetchUsers()
}, [])
const filteredUsers = useMemo(() => {
let result = users
const term = search.trim().toLowerCase()
if (term) {
result = result.filter(
(u) => u.name?.toLowerCase().includes(term) || u.email?.toLowerCase().includes(term)
)
}
if (banFilter === "banned") result = result.filter((u) => u.banned)
if (banFilter === "active") result = result.filter((u) => !u.banned)
return result
}, [users, search, banFilter])
const handleRoleChange = async (userId: string, newRole: Role) => {
setActionLoading((prev) => ({ ...prev, [userId]: true }))
try {
await authClient.admin.setRole({ userId, role: newRole as "user" | "admin" })
setUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u))
)
} finally {
setActionLoading((prev) => ({ ...prev, [userId]: false }))
}
}
const handleBan = async () => {
if (!banTarget) return
setActionLoading((prev) => ({ ...prev, [banTarget.id]: true }))
try {
await authClient.admin.banUser({
userId: banTarget.id,
banReason: banReason.trim() || undefined,
banExpires: banExpiryDays
? new Date(Date.now() + Number(banExpiryDays) * 24 * 60 * 60 * 1000)
: undefined,
} as any)
setUsers((prev) =>
prev.map((u) => (u.id === banTarget.id ? { ...u, banned: true } : u))
)
setBanTarget(null)
setBanReason("")
setBanExpiryDays("")
} finally {
setActionLoading((prev) => ({ ...prev, [banTarget.id]: false }))
}
}
const handleUnban = async (userId: string) => {
setActionLoading((prev) => ({ ...prev, [userId]: true }))
try {
await authClient.admin.unbanUser({ userId })
setUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, banned: false } : u))
)
} finally {
setActionLoading((prev) => ({ ...prev, [userId]: false }))
}
}
return (
<div className="space-y-4">
{/* Header with count */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UsersIcon className="h-5 w-5 text-text/50" />
<h2 className="text-lg font-semibold">Users</h2>
<span className="text-sm text-text/50">({filteredUsers.length})</span>
</div>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or email..."
className="w-full pl-9 pr-4 py-2.5 rounded-lg bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
{/* Filter Tabs */}
<div className="flex gap-2">
{(["all", "active", "banned"] as const).map((f) => (
<button
key={f}
onClick={() => setBanFilter(f)}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
banFilter === f ? "bg-primary/10 text-primary" : "text-text/50 hover:text-text/70"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Banned"}
</button>
))}
</div>
{/* Users List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : filteredUsers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text/50">
<UsersIcon className="h-8 w-8 mb-2" />
<p className="text-sm">No users found</p>
</div>
) : (
<div className="space-y-2">
{filteredUsers.map((user) => (
<div
key={user.id}
className="flex items-center gap-4 p-4 rounded-xl border border-border bg-text/[0.02] hover:bg-text/[0.04] transition-colors"
>
{/* Avatar */}
{user.image ? (
<Image
src={user.image}
alt=""
width={40}
height={40}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div className="h-10 w-10 rounded-full bg-text/10 flex items-center justify-center text-sm font-medium text-text/70">
{getInitial(user.name)}
</div>
)}
{/* User Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-text truncate">{user.name || "Unnamed"}</p>
{user.banned && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
Banned
</span>
)}
</div>
<p className="text-xs text-text/50 truncate">{user.email}</p>
<p className="text-[11px] text-text/30 mt-0.5">Joined {formatDate(user.createdAt)}</p>
</div>
{/* Role Selector */}
<select
value={user.role || "user"}
onChange={(e) => handleRoleChange(user.id, e.target.value as Role)}
disabled={actionLoading[user.id]}
className="text-xs px-3 py-1.5 rounded-lg border border-border bg-text/5 text-text focus:outline-none focus:border-primary/60 transition-colors cursor-pointer disabled:opacity-50"
>
{roles.map((r) => (
<option key={r} value={r}>
{r.charAt(0).toUpperCase() + r.slice(1)}
</option>
))}
</select>
{/* Action Button */}
{actionLoading[user.id] ? (
<Loader2 className="h-4 w-4 animate-spin text-text/50" />
) : user.banned ? (
<button
onClick={() => handleUnban(user.id)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer"
>
<UserCheckIcon className="h-3.5 w-3.5" />
Unban
</button>
) : (
<button
onClick={() => setBanTarget(user)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer"
>
<BanIcon className="h-3.5 w-3.5" />
Ban
</button>
)}
</div>
))}
</div>
)}
{/* Ban Modal */}
{banTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setBanTarget(null)}>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 w-full max-w-md space-y-4" onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold">Ban {banTarget.name || banTarget.email}</h3>
<div className="space-y-2">
<label className="text-xs text-text/60">Reason</label>
<textarea
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder="Reason for ban..."
rows={3}
className="w-full px-3 py-2 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary resize-y"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-text/60">Expiry (days, leave empty for permanent)</label>
<input
type="number"
value={banExpiryDays}
onChange={(e) => setBanExpiryDays(e.target.value)}
placeholder="e.g. 7"
min={1}
className="w-full px-3 py-2 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setBanTarget(null)} className="px-4 py-2 rounded-lg border border-border text-text/70 text-sm hover:bg-text/5 transition-colors">
Cancel
</button>
<button onClick={handleBan} disabled={actionLoading[banTarget.id]} className="px-4 py-2 rounded-lg bg-red-500 text-white text-sm font-semibold hover:bg-red-600 transition-colors disabled:opacity-50">
Ban User
</button>
</div>
</div>
</div>
)}
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
// app/__tests__/metadata.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
// Mock modules that page renders depend on
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(() => ({
limit: vi.fn(() => []),
})),
})),
innerJoin: vi.fn(() => ({
where: vi.fn(() => ({
groupBy: vi.fn(() => ({
orderBy: vi.fn(() => []),
})),
})),
})),
})),
})),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: { id: "id", title: "title", slug: "slug", updatedAt: "updatedAt", capsuleImage: "capsuleImage", syncStatus: "syncStatus" },
gameVersions: { id: "id", gameId: "gameId", versionString: "versionString", buildId: "buildId" },
performanceEntries: { id: "id", versionId: "versionId", isRemoved: "isRemoved", hardwareSlug: "hardwareSlug", fpsAvg: "fpsAvg", fpsLow: "fpsLow", fpsHigh: "fpsHigh" },
hardware: { slug: "slug", name: "name", deviceType: "deviceType", sortOrder: "sortOrder" },
gameComments: { gameId: "gameId", id: "id" },
gamePlatformSupport: { gameId: "gameId", hardwareSlug: "hardwareSlug", protonStatus: "protonStatus" },
user: { id: "id", name: "name", image: "image", role: "role" },
entryScreenshots: { id: "id", entryId: "entryId", storageKey: "storageKey", orderIndex: "orderIndex" },
steamReviewSentimentEnum: {
enumValues: [
"overwhelmingly_positive",
"very_positive",
"positive",
"mostly_positive",
"mixed",
"mostly_negative",
"negative",
"very_negative",
"overwhelmingly_negative",
],
},
}))
vi.mock("drizzle-orm", () => ({
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
and: vi.fn((...args: unknown[]) => args),
desc: vi.fn((col: unknown) => col),
or: vi.fn((...args: unknown[]) => args),
ne: vi.fn((col: unknown) => col),
isNull: vi.fn((col: unknown) => col),
inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ raw: strings, vals: values })),
avg: vi.fn((col: unknown) => col),
count: vi.fn((col: unknown) => col),
}))
vi.mock("@/lib/storage", () => ({
getR2PublicUrl: vi.fn(() => "https://r2.example.com"),
}))
vi.mock("@/lib/auth", () => ({
auth: {
$Infer: { Session: { user: {} } },
api: {},
handler: vi.fn(),
},
}))
vi.mock("@/lib/auth-client", () => ({}))
vi.mock("@/lib/updates", () => ({
getAllUpdates: vi.fn(() => []),
getAllUpdateSlugs: vi.fn(() => []),
getUpdateBySlug: vi.fn(() => Promise.resolve({ meta: { title: "", date: "", version: "", summary: "" }, html: "", headings: [] })),
}))
vi.mock("next/font/google", () => ({
Lexend: vi.fn(() => ({ variable: "font-mock" })),
}))
vi.mock("@/components/navbar", () => ({
default: vi.fn(() => null),
}))
describe("Page Metadata", () => {
describe("Root Layout", () => {
it("exports canonical URL", async () => {
const { metadata } = await import("@/app/layout")
expect(metadata.alternates?.canonical).toBe("https://deckyvault.xyz")
})
})
describe("Games page", () => {
it("has a unique title without duplicate brand name", async () => {
const { metadata } = await import("@/app/games/page")
expect(metadata.title).toBe("Games")
})
})
describe("Devices page", () => {
it("has a unique title without duplicate brand name", async () => {
const { metadata } = await import("@/app/devices/page")
expect(metadata.title).toBe("Devices")
})
})
describe("Compare layout", () => {
it("exports unique title and description", async () => {
const { metadata } = await import("@/app/compare/layout")
expect(metadata.title).toBe("Compare Games")
expect(metadata.description).toBeDefined()
})
})
describe("Profile layout", () => {
it("exports unique title and description", async () => {
const { metadata } = await import("@/app/profile/layout")
expect(metadata.title).toBe("Profile")
expect(metadata.description).toBeDefined()
})
})
describe("End-to-end metadata consolidation", () => {
it("all public pages have unique titles", async () => {
const pages = [
{ name: "layout", expected: "DeckyVault - Steam Deck Benchmarks & Settings" },
{ name: "games/page", expected: "Games" },
{ name: "devices/page", expected: "Devices" },
{ name: "compare/layout", expected: "Compare Games" },
]
const titles = new Set<string>()
for (const page of pages) {
const mod = await import(`@/app/${page.name}`)
const actualTitle = mod.metadata.title?.default ?? mod.metadata.title
expect(actualTitle).toBe(page.expected)
const resolvedTitle = page.name === "layout"
? actualTitle
: `${actualTitle} | DeckyVault`
titles.add(resolvedTitle)
}
expect(titles.has("DeckyVault - Steam Deck Benchmarks & Settings")).toBe(true)
expect(titles.size).toBe(pages.length)
})
})
})
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// ── Mock data ──────────────────────────────────────────────────────────
let mockData: unknown[] = []
// ── Chainable query builder mock ──────────────────────────────────────
function createChainableQuery(resolveWith: unknown[]) {
const then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(resolveWith).then(resolve, reject)
const chain: Record<string, unknown> = {
where: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })),
then,
[Symbol.toPrimitive]: () => resolveWith,
}
return chain
}
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => createChainableQuery(mockData)),
})),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: {
id: "id",
updatedAt: "updatedAt",
capsuleImage: "capsuleImage",
syncStatus: "syncStatus",
},
hardware: { slug: "slug", createdAt: "createdAt" },
}))
vi.mock("drizzle-orm", () => ({
or: vi.fn((...args: unknown[]) => args[0]),
ne: vi.fn((col: unknown) => col),
isNull: vi.fn((col: unknown) => col),
}))
const mockGetAllUpdates = vi.fn(() => [])
vi.mock("@/lib/updates", () => ({
getAllUpdates: mockGetAllUpdates,
}))
describe("Sitemap Integration — Constraint Verification", () => {
beforeEach(() => {
vi.clearAllMocks()
mockData = []
mockGetAllUpdates.mockReturnValue([])
})
// ── Spec §2: Non-negotiables ─────────────────────────────────────
it("PERFORMANCE: sitemap index generation is synchronous (no DB for index)", async () => {
const mod = await import("@/app/sitemap")
const start = Date.now()
await mod.generateSitemaps()
const elapsed = Date.now() - start
// generateSitemaps should resolve quickly (< 500ms even with mocked slow DB)
expect(elapsed).toBeLessThan(500)
})
it("RELIABILITY: generateSitemaps always returns required base IDs", async () => {
mockData = [{ count: 500 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("static")
expect(idValues).toContain("devices")
expect(idValues).toContain("updates")
// Either 'games' or at least one 'games-N' must exist
const hasGames = idValues.some(
(id: string) => id === "games" || /^games-\d+$/.test(id),
)
expect(hasGames).toBe(true)
})
it("RELIABILITY: all child sitemaps return valid arrays even on failure", async () => {
const mod = await import("@/app/sitemap")
for (const id of ["static", "games", "devices", "updates"]) {
const result = await mod.default({ id: Promise.resolve(id) })
expect(Array.isArray(result)).toBe(true)
}
})
it("SECURITY: no non-public routes in static pages", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
const urls = result.map((e: { url: string }) => e.url)
for (const url of urls) {
expect(url).not.toContain("/manage")
expect(url).not.toContain("/api")
expect(url).not.toContain("/profile")
}
})
it("DATA: games sitemap entries have correct shape", async () => {
mockData = [
{ id: "abc123", updatedAt: new Date("2025-06-01"), capsuleImage: "https://cdn.example.com/img.jpg" },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
for (const entry of result) {
expect(entry).toHaveProperty("url")
expect(typeof entry.url).toBe("string")
expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/game\//)
if (entry.lastModified) {
expect(entry.lastModified).toBeInstanceOf(Date)
}
if (entry.images) {
expect(Array.isArray(entry.images)).toBe(true)
}
}
})
it("DATA: devices sitemap entries have correct shape", async () => {
mockData = [
{ slug: "steam-deck-oled", createdAt: new Date("2025-03-01") },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
for (const entry of result) {
expect(entry).toHaveProperty("url")
expect(typeof entry.url).toBe("string")
expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/devices\//)
}
})
it("DATA: updates sitemap entries have correct shape", async () => {
mockGetAllUpdates.mockReturnValue([
{ slug: "2026-01-01", title: "Release", date: "2026-01-01", version: "1.0.0", summary: "First" },
])
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
for (const entry of result) {
expect(entry).toHaveProperty("url")
expect(typeof entry.url).toBe("string")
expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/updates\//)
}
})
it("ISOLATION: static sitemap has no DB dependency", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
// Result must be 7 entries regardless of DB state
expect(result).toHaveLength(7)
})
})
+373
View File
@@ -0,0 +1,373 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// ── Mock data ──────────────────────────────────────────────────────────
let mockData: unknown[] = []
// ── Chainable query builder mock ──────────────────────────────────────
function createChainableQuery(resolveWith: unknown[]) {
const then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(resolveWith).then(resolve, reject)
const chain: Record<string, unknown> = {
where: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })),
then,
[Symbol.toPrimitive]: () => resolveWith,
}
return chain
}
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => createChainableQuery(mockData)),
})),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: {
id: "id",
updatedAt: "updatedAt",
capsuleImage: "capsuleImage",
syncStatus: "syncStatus",
},
gameVersions: {
id: "id",
gameId: "gameId",
versionString: "versionString",
buildId: "buildId",
},
performanceEntries: {
id: "id",
versionId: "versionId",
isRemoved: "isRemoved",
hardwareSlug: "hardwareSlug",
fpsAvg: "fpsAvg",
fpsLow: "fpsLow",
fpsHigh: "fpsHigh",
},
hardware: { slug: "slug", createdAt: "createdAt" },
gameComments: { gameId: "gameId", id: "id" },
gamePlatformSupport: {
gameId: "gameId",
hardwareSlug: "hardwareSlug",
protonStatus: "protonStatus",
},
user: { id: "id", name: "name", image: "image", role: "role" },
entryScreenshots: {
id: "id",
entryId: "entryId",
storageKey: "storageKey",
orderIndex: "orderIndex",
},
steamReviewSentimentEnum: {
enumValues: [
"overwhelmingly_positive",
"very_positive",
"positive",
"mostly_positive",
"mixed",
"mostly_negative",
"negative",
"very_negative",
"overwhelmingly_negative",
],
},
}))
vi.mock("drizzle-orm", () => ({
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
and: vi.fn((...args: unknown[]) => args),
desc: vi.fn((col: unknown) => col),
or: vi.fn((...args: unknown[]) => args[0]),
ne: vi.fn((col: unknown) => col),
isNull: vi.fn((col: unknown) => col),
inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ raw: strings, vals: values })),
avg: vi.fn((col: unknown) => col),
count: vi.fn((col: unknown) => col),
}))
const mockGetAllUpdates = vi.fn(() => [])
vi.mock("@/lib/updates", () => ({
getAllUpdates: mockGetAllUpdates,
}))
describe("Sitemap Generator (app/sitemap.ts)", () => {
beforeEach(() => {
vi.clearAllMocks()
mockData = []
mockGetAllUpdates.mockReturnValue([])
})
// ── Configuration ──────────────────────────────────────────────────
it("has ISR revalidation configured", async () => {
const mod = await import("@/app/sitemap")
expect(mod.revalidate).toBe(3600)
})
// ── generateSitemaps ───────────────────────────────────────────────
describe("generateSitemaps", () => {
it("returns at least 4 child sitemap IDs", async () => {
mockData = [{ count: 500 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("static")
expect(idValues).toContain("devices")
expect(idValues).toContain("updates")
expect(idValues).toContain("games")
})
it("returns unpaginated games when count <= 5000", async () => {
mockData = [{ count: 5000 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("games")
expect(idValues).not.toContain("games-0")
expect(idValues).not.toContain("games-1")
})
it("paginates games when count > 5000", async () => {
mockData = [{ count: 7500 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("games-0")
expect(idValues).toContain("games-1")
expect(idValues).not.toContain("games")
})
it("falls back to unpaginated games when count query fails", async () => {
// mockData empty — count will be 0 from empty array, simulating a failed query
mockData = []
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("games")
})
})
// ── sitemap({ id: 'static' }) ──────────────────────────────────────
describe("sitemap({ id: 'static' })", () => {
it("returns 7 static page entries", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(7)
})
it("first entry is homepage with priority 1.0", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("deckyvault.xyz")
expect(result[0].priority).toBe(1.0)
})
it("includes /games and /compare", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
const urls = result.map((e: { url: string }) => e.url)
expect(urls).toContain("https://deckyvault.xyz/games")
expect(urls).toContain("https://deckyvault.xyz/compare")
})
it("includes lastModified on all entries", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
for (const entry of result) {
expect(entry.lastModified).toBeDefined()
expect(entry.lastModified).toBeInstanceOf(Date)
}
})
it("uses production URL even when env is localhost", async () => {
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000"
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("deckyvault.xyz")
expect(result[0].url).not.toContain("localhost")
delete process.env.NEXT_PUBLIC_SITE_URL
})
it("uses custom NEXT_PUBLIC_SITE_URL for staging", async () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz"
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("staging.deckyvault.xyz")
delete process.env.NEXT_PUBLIC_SITE_URL
})
})
// ── sitemap({ id: 'games' }) ───────────────────────────────────────
describe("sitemap({ id: 'games' })", () => {
it("returns game entries from the database", async () => {
mockData = [
{ id: "abc123", updatedAt: new Date("2025-06-01"), capsuleImage: "https://cdn.example.com/img.jpg" },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result).toHaveLength(1)
expect(result[0].url).toContain("deckyvault.xyz/game/abc123")
expect(result[0].priority).toBe(0.8)
})
it("includes image entries for valid capsule URLs", async () => {
mockData = [
{ id: "img123", updatedAt: null, capsuleImage: "https://cdn.example.com/capsule.jpg" },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].images).toEqual(["https://cdn.example.com/capsule.jpg"])
})
it("omits images for null capsuleImage", async () => {
mockData = [
{ id: "noimg", updatedAt: null, capsuleImage: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].images).toBeUndefined()
})
it("includes lastModified from updatedAt", async () => {
const date = new Date("2025-01-15T10:00:00Z")
mockData = [
{ id: "date123", updatedAt: date, capsuleImage: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].lastModified).toBe(date)
})
it("returns empty array when DB query fails", async () => {
// mockData is empty, querySafe will return undefined (no data)
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
})
it("applies changeFrequency weekly and priority 0.8", async () => {
mockData = [
{ id: "freq", updatedAt: null, capsuleImage: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].changeFrequency).toBe("weekly")
expect(result[0].priority).toBe(0.8)
})
})
// ── sitemap({ id: 'devices' }) ─────────────────────────────────────
describe("sitemap({ id: 'devices' })", () => {
it("returns device entries from the database", async () => {
mockData = [
{ slug: "steam-deck-oled", createdAt: new Date("2025-03-01") },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
expect(result).toHaveLength(1)
expect(result[0].url).toContain("deckyvault.xyz/devices/steam-deck-oled")
})
it("sets priority 0.6 and changeFrequency monthly", async () => {
mockData = [
{ slug: "device-1", createdAt: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
expect(result[0].priority).toBe(0.6)
expect(result[0].changeFrequency).toBe("monthly")
})
it("returns empty array when DB query returns no rows", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
expect(result).toHaveLength(0)
})
})
// ── sitemap({ id: 'updates' }) ─────────────────────────────────────
describe("sitemap({ id: 'updates' })", () => {
it("returns update entries from markdown files", async () => {
mockGetAllUpdates.mockReturnValue([
{ slug: "2026-01-01", title: "Release", date: "2026-01-01", version: "1.0.0", summary: "First" },
])
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
expect(result).toHaveLength(1)
expect(result[0].url).toContain("deckyvault.xyz/updates/2026-01-01")
})
it("sets priority 0.5 and changeFrequency monthly", async () => {
mockGetAllUpdates.mockReturnValue([
{ slug: "upd", title: "T", date: "2026-01-01", version: "1.0.0", summary: "S" },
])
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
expect(result[0].priority).toBe(0.5)
expect(result[0].changeFrequency).toBe("monthly")
})
it("returns empty array when getAllUpdates throws", async () => {
mockGetAllUpdates.mockImplementation(() => {
throw new Error("Failed to read updates directory")
})
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
})
})
// ── Isolation ──────────────────────────────────────────────────────
describe("child sitemap isolation", () => {
it("static sitemap works even when DB is empty", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result).toHaveLength(7)
})
it("unknown id returns empty array", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("nonexistent") })
expect(result).toEqual([])
})
})
})
+7
View File
@@ -0,0 +1,7 @@
import { app } from "@/lib/api/app"
export const GET = app.fetch
export const POST = app.fetch
export const PUT = app.fetch
export const DELETE = app.fetch
export const PATCH = app.fetch
+19
View File
@@ -0,0 +1,19 @@
import { permanentRedirect } from "next/navigation"
import type { Metadata } from "next"
interface Props {
params: Promise<{ steamid: string }>
}
export default async function AppRedirectPage({ params }: Props) {
const { steamid } = await params
permanentRedirect(`/game/${steamid}`)
}
export const dynamic = "force-dynamic"
export function generateMetadata({ params }: Props): Metadata {
return {
robots: { index: false, follow: false },
}
}
@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest"
describe("app/[steamid] redirect route", () => {
it("exports force-dynamic", async () => {
const mod = await import("../[steamid]/page")
expect(mod).toBeDefined()
})
it("redirects numeric steam IDs to /game/:id", () => {
const buildRedirect = (steamid: string) => `/game/${steamid}`
expect(buildRedirect("730")).toBe("/game/730")
expect(buildRedirect("12345")).toBe("/game/12345")
})
})
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Compare Games",
description:
"Compare Steam Deck game benchmarks side by side. See FPS, settings, and performance data across multiple titles on DeckyVault.",
alternates: { canonical: "https://deckyvault.xyz/compare" },
}
export default function CompareLayout({ children }: { children: React.ReactNode }) {
return children
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import { useState, useCallback } from "react"
import { motion } from "motion/react"
import { BarChart3Icon } from "lucide-react"
import { GameSelector } from "@/components/compare/game-selector"
import { StatsComparison } from "@/components/compare/stats-comparison"
import { FpsComparisonChart } from "@/components/compare/fps-comparison-chart"
import { StabilityRadar } from "@/components/compare/stability-radar"
interface SelectedGame {
id: string
appId: number | null
title: string
image: string | null
source: string
}
interface GameComparisonData {
id: string
title: string
stats: {
totalEntries: number
avgFps: number | null
medianFps: number | null
bestFps: number | null
avgOnePercentLow: number | null
avgStability: number | null
bestDevice: string | null
tierBreakdown: { unplayable: number; playable: number; smooth: number; excellent: number } | null
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
}
}
export default function ComparePage() {
const [selectedGames, setSelectedGames] = useState<SelectedGame[]>([])
const [comparisonData, setComparisonData] = useState<GameComparisonData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSelect = useCallback((game: SelectedGame) => {
setSelectedGames(prev => {
if (prev.some(g => g.id === game.id)) return prev
return [...prev, game]
})
}, [])
const handleRemove = useCallback((gameId: string) => {
setSelectedGames(prev => prev.filter(g => g.id !== gameId))
setComparisonData(prev => prev.filter(g => g.id !== gameId))
}, [])
const fetchComparison = useCallback(async () => {
if (selectedGames.length < 2) return
setLoading(true)
setError(null)
try {
const ids = selectedGames.map(g => g.id).join(",")
const res = await fetch(`/api/compare/games?ids=${ids}`)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to fetch comparison")
}
const data = await res.json()
setComparisonData(data.games || [])
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load comparison")
} finally {
setLoading(false)
}
}, [selectedGames])
const canCompare = selectedGames.length >= 2
return (
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
<div className="w-full max-w-7xl">
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-2xl font-light mb-2"
>
Compare Games
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className="text-text/60 text-sm mb-6"
>
Select 2-4 games to compare performance stats side by side.
</motion.p>
{/* Game selector */}
<GameSelector
selectedGames={selectedGames}
onSelect={handleSelect}
onRemove={handleRemove}
maxSelections={4}
/>
{/* Compare button */}
<div className="mt-4">
<button
onClick={fetchComparison}
disabled={!canCompare || loading}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors cursor-pointer"
>
{loading ? "Loading..." : "Compare"}
</button>
{!canCompare && selectedGames.length > 0 && (
<span className="ml-3 text-xs text-text/40">Select at least 2 games</span>
)}
</div>
{/* Error */}
{error && (
<p className="mt-4 text-red-400 text-sm">{error}</p>
)}
{/* Results */}
{comparisonData.length >= 2 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="mt-8 flex flex-col gap-8"
>
{/* Stats comparison table */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-4">Stats Overview</h2>
<StatsComparison games={comparisonData} />
</div>
{/* FPS comparison bar chart */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-2">FPS by Device</h2>
<FpsComparisonChart games={comparisonData} />
</div>
{/* Stability radar */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-2">Performance Profile</h2>
<StabilityRadar games={comparisonData} />
</div>
</motion.div>
)}
{/* Empty state */}
{comparisonData.length === 0 && !loading && (
<div className="mt-16 flex flex-col items-center justify-center gap-4">
<BarChart3Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">Select games above to start comparing</p>
</div>
)}
</div>
</section>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { FaWindows, FaApple, FaLinux } from "react-icons/fa"
export function WindowsIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return <FaWindows className={className} aria-label="Windows" />
}
export function MacIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return <FaApple className={className} aria-label="macOS" />
}
export function LinuxIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return <FaLinux className={className} aria-label="Linux" />
}
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Contact & Report",
description: "Report issues, suggest features, or send feedback to the DeckyVault team.",
robots: { index: false, follow: true },
}
export default function ContactLayout({ children }: { children: React.ReactNode }) {
return children
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import { useState, useRef } from "react"
import { motion } from "motion/react"
import {
SendIcon,
BugIcon,
DatabaseIcon,
FlagIcon,
LightbulbIcon,
MessageSquareIcon,
AlertTriangleIcon,
CheckCircleIcon,
Loader2Icon,
} from "lucide-react"
type Category = "bug" | "game_data" | "user_report" | "feature" | "feedback" | "database"
interface CategoryOption {
id: Category
label: string
icon: React.ElementType
color: string
}
const CATEGORIES: CategoryOption[] = [
{ id: "bug", label: "Bug Report", icon: BugIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" },
{ id: "game_data", label: "Game Data Issue", icon: AlertTriangleIcon, color: "text-yellow-400 border-yellow-500/20 bg-yellow-500/5 hover:bg-yellow-500/10" },
{ id: "user_report", label: "User Report", icon: FlagIcon, color: "text-blue-400 border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10" },
{ id: "feature", label: "Feature Request", icon: LightbulbIcon, color: "text-green-400 border-green-500/20 bg-green-500/5 hover:bg-green-500/10" },
{ id: "feedback", label: "General Feedback", icon: MessageSquareIcon, color: "text-text/60 border-border bg-text/3 hover:bg-text/6" },
{ id: "database", label: "Database Error", icon: DatabaseIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" },
]
export default function ContactPage() {
const [category, setCategory] = useState<Category | "">("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [subject, setSubject] = useState("")
const [message, setMessage] = useState("")
const [gameUrl, setGameUrl] = useState("")
const [honeypot, setHoneypot] = useState("")
const timestampRef = useRef<string>("")
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
// Set timestamp when user starts interacting
const startTimestamp = () => {
if (!timestampRef.current) {
timestampRef.current = Date.now().toString()
}
}
const validate = (): boolean => {
const errors: Record<string, string> = {}
if (!category) errors.category = "Please select a category"
if (!subject.trim()) errors.subject = "Subject is required"
else if (subject.length > 200) errors.subject = "Subject must be 200 characters or less"
if (!message.trim()) errors.message = "Message is required"
else if (message.length > 2000) errors.message = "Message must be 2000 characters or less"
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Invalid email format"
if (category === "game_data" && gameUrl && !gameUrl.includes("/game/")) errors.gameUrl = "Please provide a valid DeckyVault game link"
setFieldErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
startTimestamp()
if (!validate()) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
name: name || undefined,
email: email || undefined,
subject: subject.trim(),
message: message.trim(),
gameUrl: category === "game_data" ? gameUrl || undefined : undefined,
honeypot,
_timestamp: timestampRef.current || undefined,
}),
})
const data = await res.json()
if (!res.ok) {
if (res.status === 429) {
setError("You've sent too many messages. Please try again later.")
} else {
setError(data.error || "Something went wrong. Please try again.")
}
return
}
setSubmitted(true)
} catch {
setError("Network error. Please check your connection and try again.")
} finally {
setSubmitting(false)
}
}
if (submitted) {
return (
<section className="w-full flex flex-col items-center justify-center py-20 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md w-full text-center space-y-4"
>
<CheckCircleIcon className="h-12 w-12 text-green-400 mx-auto" />
<h1 className="text-2xl font-bold">Message Sent</h1>
<p className="text-text/60 text-sm">
Thank you for reaching out. We&apos;ll review your message as soon as possible.
</p>
<button
onClick={() => {
setSubmitted(false)
setCategory("")
setSubject("")
setMessage("")
setGameUrl("")
setName("")
setEmail("")
setHoneypot("")
timestampRef.current = ""
setFieldErrors({})
}}
className="px-4 py-2 rounded-md bg-text/5 border border-border text-sm hover:bg-text/10 transition-colors cursor-pointer"
>
Send another message
</button>
</motion.div>
</section>
)
}
return (
<section className="w-full flex flex-col items-center py-16 p-4">
<div className="max-w-lg w-full space-y-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h1 className="text-2xl sm:text-3xl font-bold">Contact & Report</h1>
<p className="text-sm text-text/60 mt-1">
Report issues, suggest features, or send us feedback.
</p>
</motion.div>
<motion.form
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
onSubmit={handleSubmit}
className="space-y-5"
>
{/* Honeypot */}
<input
type="text"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
aria-hidden="true"
/>
{/* Category */}
<fieldset>
<legend className="text-xs text-text/50 uppercase tracking-wider mb-2">
Category <span className="text-primary">*</span>
</legend>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => {
setCategory(cat.id)
startTimestamp()
setFieldErrors((prev) => {
const next = { ...prev }
delete next.category
return next
})
}}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg border text-xs font-medium transition-colors cursor-pointer ${
category === cat.id
? cat.color + " ring-1 ring-current"
: "text-text/50 border-border bg-text/3 hover:bg-text/6"
}`}
>
<cat.icon className="h-3.5 w-3.5" />
{cat.label}
</button>
))}
</div>
{fieldErrors.category && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.category}</p>
)}
</fieldset>
{/* Name & Email (optional) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label htmlFor="contact-name" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Name <span className="text-text/30">(optional)</span>
</label>
<input
id="contact-name"
type="text"
value={name}
onChange={(e) => { setName(e.target.value); startTimestamp() }}
placeholder="Your name"
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
</div>
<div>
<label htmlFor="contact-email" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Email <span className="text-text/30">(optional)</span>
</label>
<input
id="contact-email"
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); startTimestamp() }}
placeholder="you@example.com"
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.email && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.email}</p>
)}
</div>
</div>
{/* Subject */}
<div>
<label htmlFor="contact-subject" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Subject <span className="text-primary">*</span>
</label>
<input
id="contact-subject"
type="text"
value={subject}
onChange={(e) => {
setSubject(e.target.value)
startTimestamp()
if (fieldErrors.subject) setFieldErrors((prev) => { const next = { ...prev }; delete next.subject; return next })
}}
placeholder="Brief description of your issue"
maxLength={200}
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.subject && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.subject}</p>
)}
</div>
{/* Game URL (conditional) */}
{category === "game_data" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<label htmlFor="contact-game-url" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Game URL <span className="text-text/30">(link to the game page)</span>
</label>
<input
id="contact-game-url"
type="url"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
placeholder="https://deckyvault.xyz/game/..."
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.gameUrl && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.gameUrl}</p>
)}
</motion.div>
)}
{/* Message */}
<div>
<label htmlFor="contact-message" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Message <span className="text-primary">*</span>
</label>
<textarea
id="contact-message"
value={message}
onChange={(e) => {
setMessage(e.target.value)
startTimestamp()
if (fieldErrors.message) setFieldErrors((prev) => { const next = { ...prev }; delete next.message; return next })
}}
placeholder="Describe your issue, suggestion, or feedback in detail..."
rows={5}
maxLength={2000}
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors resize-y"
/>
<div className="flex justify-between items-center mt-1">
{fieldErrors.message ? (
<p className="text-red-400 text-xs">{fieldErrors.message}</p>
) : (
<span />
)}
<span className="text-[10px] text-text/30 tabular-nums">{message.length}/2000</span>
</div>
</div>
{/* Error */}
{error && (
<div className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={submitting || !category}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-primary text-background font-semibold text-sm hover:bg-primary/80 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<SendIcon className="h-4 w-4" />
Send Message
</>
)}
</button>
</motion.form>
</div>
</section>
)
}
@@ -0,0 +1,569 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import Image from "next/image"
import { motion } from "motion/react"
import {
Gamepad2Icon,
TrendingUpIcon,
DatabaseIcon,
CheckCircleIcon,
ArrowRightIcon,
Loader2,
RefreshCwIcon,
MonitorIcon,
} from "lucide-react"
import {
EChartWrapper,
CHART_THEME,
getDeviceColor,
} from "@/components/charts/EChartWrapper"
import type { EChartsOption } from "echarts"
interface DeviceInfo {
slug: string
name: string
deviceType: string
image: string | null
colorIndex: number
}
interface UpscalerEntry {
upscalerType: string
count: number
avgFps: number
}
interface DeviceStats {
slug: string
name: string
deviceType: string
totalBenchmarks: number
avgFps: number | null
verifiedCount: number
gameCount: number
boxplot: Array<{
gameId: string
gameTitle: string
min: number
q1: number
median: number
q3: number
max: number
count: number
}>
historical: Array<{
period: string
avgFps: number
count: number
}>
topGames: Array<{
gameId: string
gameTitle: string
headerImage: string | null
avgFps: number
benchmarkCount: number
}>
genreBreakdown: Array<{ genre: string; count: number }>
protonBreakdown: Array<{ version: string; count: number }>
upscalerBreakdown: UpscalerEntry[]
}
const deviceTypeLabel: Record<string, string> = {
handheld: "Handheld",
console: "Console",
}
const deviceTypeColor: Record<string, string> = {
handheld: "text-primary bg-primary/10 border-primary/20",
console: "text-secondary bg-secondary/10 border-secondary/20",
}
export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
const [stats, setStats] = useState<DeviceStats | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const deviceColor = getDeviceColor(device.colorIndex)
async function fetchStats() {
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/hardware/${device.slug}/stats`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setStats(data)
} catch (err) {
console.error("Failed to fetch device stats:", err)
setError("Failed to load device statistics. Please try again.")
} finally {
setLoading(false)
}
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchStats()
}, [device.slug]) // eslint-disable-line react-hooks/exhaustive-deps
const historicalOption = useMemo<EChartsOption>(() => {
if (!stats || stats.historical.length === 0) return {}
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1020",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text },
},
grid: { left: 50, right: 20, top: 10, bottom: 30 },
xAxis: {
type: "category",
data: stats.historical.map((h) => h.period),
axisLine: { lineStyle: { color: CHART_THEME.border } },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
},
yAxis: {
type: "value",
axisLine: { lineStyle: { color: CHART_THEME.border } },
splitLine: {
lineStyle: { color: CHART_THEME.border, opacity: 0.3 },
},
axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
},
series: [
{
type: "line",
data: stats.historical.map((h) => h.avgFps),
smooth: true,
lineStyle: { color: deviceColor, width: 2 },
areaStyle: {
color: {
type: "linear",
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [
{ offset: 0, color: deviceColor + "40" },
{ offset: 1, color: deviceColor + "05" },
],
},
},
symbol: "circle",
symbolSize: 4,
itemStyle: { color: deviceColor },
},
],
}
}, [stats, deviceColor])
const boxplotOption = useMemo<EChartsOption>(() => {
if (!stats || stats.boxplot.length === 0) return {}
return {
tooltip: {
trigger: "item",
backgroundColor: "#1a1020",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text },
},
grid: { left: 80, right: 20, top: 10, bottom: 40 },
xAxis: {
type: "category",
data: stats.boxplot.map((b) => b.gameTitle),
axisLine: { lineStyle: { color: CHART_THEME.border } },
axisLabel: {
color: CHART_THEME.textMuted,
fontSize: 10,
rotate: 30,
},
},
yAxis: {
type: "value",
name: "FPS",
axisLine: { lineStyle: { color: CHART_THEME.border } },
splitLine: {
lineStyle: { color: CHART_THEME.border, opacity: 0.3 },
},
axisLabel: { color: CHART_THEME.textMuted, fontSize: 11 },
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
},
series: [
{
type: "boxplot",
data: stats.boxplot.map((b) => [
b.min,
b.q1,
b.median,
b.q3,
b.max,
]),
itemStyle: {
color: deviceColor + "30",
borderColor: deviceColor,
},
},
],
}
}, [stats, deviceColor])
const genreOption = useMemo<EChartsOption>(() => {
if (!stats || stats.genreBreakdown.length === 0) return {}
return {
tooltip: {
trigger: "item",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text },
},
series: [
{
type: "pie",
radius: ["40%", "70%"],
center: ["50%", "50%"],
data: stats.genreBreakdown.map((g, i) => ({
name: g.genre,
value: g.count,
itemStyle: {
color: CHART_THEME.deviceColors[
i % CHART_THEME.deviceColors.length
],
},
})),
label: { color: CHART_THEME.textMuted, fontSize: 10 },
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowColor: "rgba(0,0,0,0.5)",
},
},
},
],
}
}, [stats])
return (
<section className='w-full flex flex-col gap-8 py-16'>
{/* Hero Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<div className='flex items-center gap-4 mb-2'>
<div
className='flex items-center justify-center h-14 w-14 rounded-xl shrink-0'
style={{ background: `${deviceColor}15` }}
>
{device.image ? (
<Image
src={device.image}
alt={device.name}
width={56}
height={56}
className='object-contain'
/>
) : (
<Gamepad2Icon
className='h-7 w-7'
style={{ color: deviceColor }}
/>
)}
</div>
<div>
<h1 className='text-2xl sm:text-3xl font-bold'>
{device.name}
</h1>
<div className='flex items-center gap-2 mt-1'>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border capitalize ${
deviceTypeColor[device.deviceType] ||
"text-text/50 bg-text/5 border-border"
}`}
>
<Gamepad2Icon className='h-3 w-3' />
{deviceTypeLabel[device.deviceType] ||
device.deviceType}
</span>
{stats && stats.verifiedCount > 0 && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-blue-500/10 text-blue-400 border border-blue-500/20'>
<CheckCircleIcon className='h-3 w-3' />
{stats.verifiedCount} verified
</span>
)}
</div>
</div>
</div>
<p className='text-sm text-text/60'>
Performance benchmarks and statistics
</p>
</div>
</motion.div>
{/* Overview Stats */}
{stats && !loading && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto flex flex-wrap gap-4'>
<StatCard
icon={DatabaseIcon}
label='Total Benchmarks'
value={String(stats.totalBenchmarks)}
/>
<StatCard
icon={TrendingUpIcon}
label='Average FPS'
value={
stats.avgFps !== null
? String(stats.avgFps)
: "—"
}
/>
<StatCard
icon={MonitorIcon}
label='Games Tested'
value={String(stats.gameCount)}
/>
<StatCard
icon={CheckCircleIcon}
label='Verified'
value={String(stats.verifiedCount)}
/>
</div>
</motion.div>
)}
{/* Loading */}
{loading && (
<div className='flex items-center justify-center py-16'>
<Loader2 className='h-8 w-8 animate-spin text-primary' />
</div>
)}
{/* Error State */}
{error && !loading && (
<div className='px-4 md:px-[10svw]'>
<div className='max-w-7xl mx-auto text-center py-16 text-text/40'>
<Gamepad2Icon className='h-10 w-10 mx-auto mb-3' />
<p className='text-text/60 mb-4'>{error}</p>
<button
onClick={fetchStats}
className='inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary/10 text-primary text-sm font-medium hover:bg-primary/20 transition-colors cursor-pointer'
>
<RefreshCwIcon className='h-4 w-4' />
Retry
</button>
</div>
</div>
)}
{/* Charts */}
{stats && !loading && !error && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto flex flex-col gap-6'>
{stats.historical.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-2'>
Historical Performance
</h3>
<EChartWrapper
option={historicalOption}
height={280}
/>
</div>
)}
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{stats.boxplot.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-2'>
FPS Distribution by Game
</h3>
<EChartWrapper
option={boxplotOption}
height={300}
/>
</div>
)}
{stats.genreBreakdown.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-2'>
Genre Breakdown
</h3>
<EChartWrapper
option={genreOption}
height={300}
/>
</div>
)}
</div>
{(stats.protonBreakdown.length > 0 ||
stats.upscalerBreakdown.length > 0) && (
<div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{stats.protonBreakdown.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-3'>
Proton Version Distribution
</h3>
<div className='space-y-2'>
{stats.protonBreakdown.map((p) => (
<div
key={p.version}
className='flex items-center justify-between text-sm'
>
<span className='text-text/70'>
{p.version}
</span>
<div className='flex items-center gap-2'>
<div className='w-24 h-1.5 rounded-full bg-text/10 overflow-hidden'>
<div
className='h-full rounded-full bg-primary'
style={{
width: `${Math.max(5, (p.count / stats.totalBenchmarks) * 100)}%`,
}}
/>
</div>
<span className='text-text/50 text-xs'>
{p.count}
</span>
</div>
</div>
))}
</div>
</div>
)}
{stats.upscalerBreakdown.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-3'>
Upscaler Performance
</h3>
<div className='space-y-2'>
{stats.upscalerBreakdown.map(
(f) => (
<div
key={f.upscalerType}
className='flex items-center justify-between text-sm'
>
<span className='text-text/70 capitalize'>
{f.upscalerType ===
"none"
? "Native"
: f.upscalerType.toUpperCase()}
</span>
<div className='flex items-center gap-2'>
<span className='text-text/80 font-medium tabular-nums'>
{f.avgFps} FPS
</span>
<span className='text-text/40 text-xs'>
({f.count})
</span>
</div>
</div>
),
)}
</div>
</div>
)}
</div>
)}
{stats.topGames.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-3'>
Top Games by Average FPS
</h3>
<div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3'>
{stats.topGames
.slice(0, 8)
.map((game, i) => (
<Link
key={game.gameId}
href={`/game/${game.gameId}`}
className='flex items-center gap-3 p-3 rounded-lg bg-text/5 border border-border hover:border-primary/30 transition-colors group'
>
<div
className='text-lg font-bold tabular-nums w-6'
style={{
color: deviceColor,
}}
>
{i + 1}
</div>
<div className='min-w-0 flex-1'>
<p className='text-sm font-medium truncate group-hover:text-primary transition-colors'>
{game.gameTitle}
</p>
<div className='flex items-center gap-2 text-xs text-text/50'>
<span className='text-green-400 font-medium'>
{game.avgFps} FPS
</span>
<span>
{
game.benchmarkCount
}{" "}
runs
</span>
</div>
</div>
<ArrowRightIcon className='h-3.5 w-3.5 text-text/20 group-hover:text-primary transition-colors shrink-0' />
</Link>
))}
</div>
</div>
)}
</div>
</motion.div>
)}
{/* Empty state */}
{stats && !loading && !error && stats.totalBenchmarks === 0 && (
<div className='max-w-7xl mx-auto px-4 md:px-[10svw]'>
<div className='text-center py-16 text-text/40'>
<Gamepad2Icon className='h-10 w-10 mx-auto mb-2' />
<p>No benchmark data yet for this device</p>
<p className='text-sm mt-1'>
Data will appear as benchmarks are submitted
</p>
</div>
</div>
)}
</section>
)
}
function StatCard({
icon: Icon,
label,
value,
}: {
icon: React.ElementType
label: string
value: string
}) {
return (
<div className='flex items-center gap-3 p-4 rounded-xl border border-border bg-text/3 min-w-40'>
<div className='flex items-center justify-center h-9 w-9 rounded-lg bg-primary/10 text-primary'>
<Icon className='h-4 w-4' />
</div>
<div className='flex flex-col'>
<span className='text-xs text-text/50'>{label}</span>
<span className='text-lg font-semibold tabular-nums'>
{value}
</span>
</div>
</div>
)
}
@@ -0,0 +1,120 @@
import { ImageResponse } from "next/og"
import { db } from "@/lib/db/index"
import { hardware, performanceEntries, gameVersions, games } from "@/lib/db/schema"
import { eq, and, sql } from "drizzle-orm"
export const dynamic = "force-dynamic"
export const alt = "Device benchmarks on DeckyVault"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"
export default async function Image({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const [device] = await db
.select({
name: hardware.name,
deviceType: hardware.deviceType,
})
.from(hardware)
.where(eq(hardware.slug, slug))
.limit(1)
if (!device) {
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: "#100b14",
color: "#ebe4f1",
}}
>
<div style={{ fontSize: 48, fontWeight: 700 }}>DeckyVault</div>
<div style={{ fontSize: 20, color: "#6b5a7d", marginTop: 8 }}>
Device Not Found
</div>
</div>
),
{ ...size }
)
}
const [stats] = await db
.select({
totalBenchmarks: sql<number>`count(*)::int`,
avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
gameCount: sql<number>`count(distinct ${gameVersions.gameId})::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.where(
and(
eq(performanceEntries.hardwareSlug, slug),
eq(performanceEntries.isRemoved, false)
)
)
const typeLabel = device.deviceType === "handheld" ? "Handheld" : "Console"
const avgFpsStr = stats?.avgFps ? String(Math.round(Number(stats.avgFps))) : "—"
return new ImageResponse(
(
<div
style={{
height: "100%",
width: "100%",
display: "flex",
flexDirection: "column",
justifyContent: "center",
padding: "60px 80px",
backgroundColor: "#100b14",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 16 }}>
<div
style={{
padding: "4px 12px",
borderRadius: 12,
fontSize: 14,
fontWeight: 600,
color: "#eb3779",
backgroundColor: "#eb377920",
border: "1px solid #eb377940",
}}
>
{typeLabel}
</div>
</div>
<div style={{ fontSize: 56, fontWeight: 700, color: "#ebe4f1", lineHeight: 1.1, marginBottom: 24 }}>
{device.name}
</div>
<div style={{ display: "flex", gap: 32, fontSize: 18, color: "#6b5a7d" }}>
<div>
<span style={{ color: "#ebe4f1", fontWeight: 600, fontSize: 24 }}>{stats?.totalBenchmarks ?? 0}</span> Benchmarks
</div>
<div>
<span style={{ color: "#22c55e", fontWeight: 600, fontSize: 24 }}>{avgFpsStr}</span> Avg FPS
</div>
<div>
<span style={{ color: "#ebe4f1", fontWeight: 600, fontSize: 24 }}>{stats?.gameCount ?? 0}</span> Games
</div>
</div>
<div style={{ position: "absolute", bottom: 40, left: 80, fontSize: 16, color: "#4a3a5c", fontWeight: 500 }}>
deckyvault.xyz
</div>
</div>
),
{ ...size }
)
}
+131
View File
@@ -0,0 +1,131 @@
import { db } from "@/lib/db/index"
import { hardware } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
import { notFound } from "next/navigation"
import { DeviceDetailClient } from "./device-detail-client"
import type { Metadata } from "next"
export const revalidate = 3600
export async function generateStaticParams() {
try {
const devices = await db
.select({ slug: hardware.slug })
.from(hardware)
return devices.map((d) => ({ slug: d.slug }))
} catch {
// DB unreachable during build (e.g. Docker builder without network access).
// Return empty — pages will be generated on first request via ISR.
return []
}
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
const [device] = await db
.select({
name: hardware.name,
deviceType: hardware.deviceType,
})
.from(hardware)
.where(eq(hardware.slug, slug))
.limit(1)
if (!device) return { title: "Device Not Found — DeckyVault" }
const typeLabel = device.deviceType === "handheld" ? "Handheld" : "Console"
return {
title: `${device.name} — DeckyVault`,
description: `Benchmark data, FPS stats, and performance analysis for the ${device.name} (${typeLabel}) on DeckyVault.`,
keywords: [
device.name.toLowerCase(),
device.deviceType,
"benchmarks",
"FPS",
"performance",
"steam deck",
],
alternates: { canonical: `https://deckyvault.xyz/devices/${slug}` },
openGraph: {
title: `${device.name} — DeckyVault`,
description: `Benchmark data and performance stats for ${device.name} on DeckyVault.`,
url: `https://deckyvault.xyz/devices/${slug}`,
siteName: "DeckyVault",
type: "website",
images: [
{
url: `/devices/${slug}/opengraph-image`,
width: 1200,
height: 630,
},
],
},
twitter: {
card: "summary_large_image",
title: `${device.name} — DeckyVault`,
description: `Benchmark data and performance stats for ${device.name} on DeckyVault.`,
images: [`/devices/${slug}/opengraph-image`],
},
}
}
export default async function DevicePage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
const [device] = await db
.select({
slug: hardware.slug,
name: hardware.name,
deviceType: hardware.deviceType,
image: hardware.image,
})
.from(hardware)
.where(eq(hardware.slug, slug))
.limit(1)
if (!device) {
notFound()
}
const allDevices = await db
.select({ slug: hardware.slug })
.from(hardware)
.orderBy(hardware.sortOrder)
const deviceColorIndex = allDevices.findIndex((d) => d.slug === slug)
const typeLabel = device.deviceType === "handheld" ? "Handheld" : "Console"
const jsonLd = {
"@context": "https://schema.org",
"@type": "Product",
name: device.name,
category: typeLabel,
url: `https://deckyvault.xyz/devices/${device.slug}`,
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<DeviceDetailClient
device={{
slug: device.slug,
name: device.name,
deviceType: device.deviceType,
image: device.image,
colorIndex: deviceColorIndex,
}}
/>
</>
)
}
+283
View File
@@ -0,0 +1,283 @@
"use client"
import { useState } from "react"
import Link from "next/link"
import Image from "next/image"
import { motion } from "motion/react"
import {
Gamepad2Icon,
TrendingUpIcon,
DatabaseIcon,
ArrowRightIcon,
MonitorIcon,
CheckCircleIcon,
Zap,
Gauge,
} from "lucide-react"
import { getDeviceColor } from "@/components/charts/EChartWrapper"
export interface DeviceStats {
slug: string
name: string
deviceType: string
image: string | null
sortOrder: number
colorIndex: number
totalBenchmarks: number
avgFps: number | null
gameCount: number
verifiedCount: number
bestGame: {
id: string
title: string
headerImage: string | null
fpsAvg: number
} | null
wattHours: number | null
tdpMax: number | null
}
const deviceTypeLabel: Record<string, string> = {
handheld: "Handheld",
console: "Console",
}
const deviceTypeColor: Record<string, string> = {
handheld: "text-primary bg-primary/10 border-primary/20",
console: "text-secondary bg-secondary/10 border-secondary/20",
}
type FilterType = "all" | "handheld" | "console"
const filterOptions: { id: FilterType; label: string }[] = [
{ id: "all", label: "All" },
{ id: "handheld", label: "Handheld" },
{ id: "console", label: "Console" },
]
export function DevicesPageClient({ devices }: { devices: DeviceStats[] }) {
const [activeFilter, setActiveFilter] = useState<FilterType>("all")
const filteredDevices =
activeFilter === "all"
? devices
: devices.filter((d) => d.deviceType === activeFilter)
if (devices.length === 0) {
return (
<div className='max-w-7xl mx-auto px-4 md:px-[10svw] py-8'>
<div className='text-center py-16 text-text/40'>
<Gamepad2Icon className='h-10 w-10 mx-auto mb-2' />
<p>No devices found</p>
<p className='text-sm mt-1'>
Benchmark data will appear as devices are added
</p>
</div>
</div>
)
}
return (
<section className='w-full flex flex-col gap-8 py-16'>
{/* Hero Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<h1 className='text-2xl sm:text-3xl font-bold'>Devices</h1>
<p className='text-sm text-text/60 mt-1'>
Browse benchmark data for handheld and console devices
</p>
</div>
</motion.div>
{/* Filter Tabs */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<div className='flex items-center gap-2'>
{filterOptions.map((opt) => (
<button
key={opt.id}
onClick={() => setActiveFilter(opt.id)}
className={`px-3 py-1.5 rounded-full text-xs font-medium transition-colors cursor-pointer ${
activeFilter === opt.id
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</motion.div>
{/* Device Grid */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
{filteredDevices.map((device, i) => (
<motion.div
key={device.slug}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{
duration: 0.3,
delay: Math.min(0.05 * i, 0.5),
}}
>
<Link
href={`/devices/${device.slug}`}
className='block rounded-xl border border-border bg-text/3 hover:border-primary/30 transition-colors group overflow-hidden'
>
{/* Image / Icon Header */}
<div
className='relative h-28 flex items-center justify-center'
style={{
background: `${getDeviceColor(device.colorIndex)}08`,
}}
>
{device.image ? (
<Image
src={device.image}
alt={device.name}
fill
className='object-contain p-4'
sizes='(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw'
/>
) : (
<Gamepad2Icon
className='h-12 w-12'
style={{
color: getDeviceColor(
device.colorIndex,
),
opacity: 0.6,
}}
/>
)}
</div>
{/* Card Body */}
<div className='p-5'>
{/* Name & Type */}
<div className='flex items-start justify-between gap-2 mb-3'>
<div>
<h2 className='text-lg font-semibold group-hover:text-primary transition-colors'>
{device.name}
</h2>
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-medium border capitalize mt-1 ${
deviceTypeColor[
device.deviceType
] ||
"text-text/50 bg-text/5 border-border"
}`}
>
<Gamepad2Icon className='h-2.5 w-2.5' />
{deviceTypeLabel[
device.deviceType
] || device.deviceType}
</span>
</div>
<ArrowRightIcon className='h-5 w-5 text-text/20 group-hover:text-primary transition-colors' />
</div>
{/* Stats Row */}
<div className='grid grid-cols-4 gap-2 mt-4'>
<div className='flex flex-col items-center text-center'>
<DatabaseIcon className='h-3.5 w-3.5 text-primary mb-1' />
<span className='text-base font-bold tabular-nums'>
{device.totalBenchmarks}
</span>
<span className='text-[9px] text-text/50'>
Benchmarks
</span>
</div>
<div className='flex flex-col items-center text-center'>
<TrendingUpIcon className='h-3.5 w-3.5 text-green-400 mb-1' />
<span className='text-base font-bold tabular-nums'>
{device.avgFps !== null
? device.avgFps
: "—"}
</span>
<span className='text-[9px] text-text/50'>
Avg FPS
</span>
</div>
<div className='flex flex-col items-center text-center'>
<MonitorIcon className='h-3.5 w-3.5 text-accent mb-1' />
<span className='text-base font-bold tabular-nums'>
{device.gameCount}
</span>
<span className='text-[9px] text-text/50'>
Games
</span>
</div>
<div className='flex flex-col items-center text-center'>
<CheckCircleIcon className='h-3.5 w-3.5 text-blue-400 mb-1' />
<span className='text-base font-bold tabular-nums'>
{device.verifiedCount}
</span>
<span className='text-[9px] text-text/50'>
Verified
</span>
</div>
</div>
{/* Power specs */}
{(device.wattHours || device.tdpMax) && (
<div className="flex items-center gap-3 mt-3 text-xs text-text/40">
{device.wattHours && (
<span className="inline-flex items-center gap-1">
<Zap className="h-3 w-3" />
{Math.round(device.wattHours)}Wh
</span>
)}
{device.tdpMax && (
<span className="inline-flex items-center gap-1">
<Gauge className="h-3 w-3" />
Up to {Math.round(device.tdpMax)}W TDP
</span>
)}
</div>
)}
{/* Best game */}
{device.bestGame && (
<div className='mt-3 pt-3 border-t border-border text-xs text-text/50'>
Top:{" "}
<span className='text-text/80 font-medium'>
{device.bestGame.title}
</span>{" "}
· {device.bestGame.fpsAvg} FPS
</div>
)}
</div>
</Link>
</motion.div>
))}
</div>
{filteredDevices.length === 0 && devices.length > 0 && (
<div className='text-center py-12 text-text/40'>
<Gamepad2Icon className='h-8 w-8 mx-auto mb-2' />
<p>No {activeFilter} devices found</p>
</div>
)}
</motion.div>
</section>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { db } from "@/lib/db/index"
import { hardware, performanceEntries, gameVersions, games } from "@/lib/db/schema"
import { eq, sql, desc } from "drizzle-orm"
import { buildBreadcrumbList } from "@/lib/utils/seo"
import { DevicesPageClient } from "./page-client"
import type { Metadata } from "next"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata: Metadata = {
title: "Devices",
description:
"Browse benchmark data for handheld and console gaming devices. Compare FPS, performance stats, and community benchmarks on DeckyVault.",
keywords: ["steam deck", "handheld", "console", "benchmarks", "FPS", "performance", "devices"],
alternates: { canonical: "https://deckyvault.xyz/devices" },
openGraph: {
title: "Devices | DeckyVault",
description:
"Browse benchmark data for handheld and console gaming devices on DeckyVault.",
url: "https://deckyvault.xyz/devices",
siteName: "DeckyVault",
type: "website",
},
}
export default async function DevicesPage() {
const deviceRows = await db
.select({
slug: hardware.slug,
name: hardware.name,
deviceType: hardware.deviceType,
image: hardware.image,
sortOrder: hardware.sortOrder,
wattHours: hardware.wattHours,
tdpMax: hardware.tdpMax,
})
.from(hardware)
.orderBy(hardware.sortOrder)
const statsPerDevice = await db
.select({
hardwareSlug: performanceEntries.hardwareSlug,
totalBenchmarks: sql<number>`count(*)::int`,
avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
verifiedCount: sql<number>`count(*) filter (where ${performanceEntries.verifiedAt} is not null)::int`,
gameCount: sql<number>`count(distinct ${gameVersions.gameId})::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.where(eq(performanceEntries.isRemoved, false))
.groupBy(performanceEntries.hardwareSlug)
const statsMap = new Map(statsPerDevice.map((s) => [s.hardwareSlug, s]))
const bestGames = await db
.select({
hardwareSlug: performanceEntries.hardwareSlug,
gameId: games.id,
gameTitle: games.title,
gameHeaderImage: games.headerImage,
fpsAvg: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.where(eq(performanceEntries.isRemoved, false))
.groupBy(
performanceEntries.hardwareSlug,
games.id,
games.title,
games.headerImage,
)
.orderBy(desc(sql`avg(${performanceEntries.fpsAvg})`))
const bestGameMap = new Map<
string,
{ id: string; title: string; headerImage: string | null; fpsAvg: number }
>()
for (const bg of bestGames) {
if (!bestGameMap.has(bg.hardwareSlug)) {
bestGameMap.set(bg.hardwareSlug, {
id: bg.gameId,
title: bg.gameTitle,
headerImage: bg.gameHeaderImage,
fpsAvg: Number(bg.fpsAvg),
})
}
}
const devices = deviceRows.map((device, index) => {
const stats = statsMap.get(device.slug)
const bestGame = bestGameMap.get(device.slug)
return {
slug: device.slug,
name: device.name,
deviceType: device.deviceType,
image: device.image,
sortOrder: device.sortOrder,
colorIndex: index,
totalBenchmarks: stats?.totalBenchmarks ?? 0,
avgFps: stats?.avgFps ? Number(stats.avgFps) : null,
gameCount: stats?.gameCount ?? 0,
verifiedCount: stats?.verifiedCount ?? 0,
bestGame: bestGame ?? null,
wattHours: device.wattHours ? Number(device.wattHours) : null,
tdpMax: device.tdpMax ? Number(device.tdpMax) : null,
}
})
const jsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
itemListElement: devices.map((d, i) => ({
"@type": "ListItem",
position: i + 1,
name: d.name,
url: `https://deckyvault.xyz/devices/${d.slug}`,
})),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildBreadcrumbList([
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Devices", url: "https://deckyvault.xyz/devices" },
]),
),
}}
/>
<DevicesPageClient devices={devices} />
</>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { notFound, redirect } from "next/navigation"
import { headers } from "next/headers"
import { db } from "@/lib/db/index"
import { games, gamePlatformSupport, hardware } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
import { auth } from "@/lib/auth"
import { NonSteamEditForm } from "@/components/wizard/non-steam-edit-form"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Edit Game",
}
async function resolveGame(id: string) {
const isNumeric = /^\d+$/.test(id)
if (isNumeric) {
const rows = await db.select().from(games).where(eq(games.steamAppId, Number(id))).limit(1)
return rows[0]
}
const rows = await db.select().from(games).where(eq(games.id, id)).limit(1)
return rows[0]
}
export default async function EditGamePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const h = await headers()
const session = await auth.api.getSession({ headers: h })
if (!session?.user) redirect("/login")
const game = await resolveGame(id)
if (!game) notFound()
if (game.source === "steam") {
notFound()
}
const platformSupport = await db
.select()
.from(gamePlatformSupport)
.where(eq(gamePlatformSupport.gameId, game.id))
const hardwareList = await db
.select({ slug: hardware.slug, name: hardware.name, deviceType: hardware.deviceType })
.from(hardware)
.orderBy(hardware.sortOrder)
const serializedGame = {
id: game.id,
title: game.title,
developer: game.developer,
publisher: game.publisher,
description: game.description,
source: game.source,
storeUrl: game.storeUrl,
headerImage: game.headerImage,
capsuleImage: game.capsuleImage,
genres: game.genres,
releaseDate: game.releaseDate,
createdBy: game.createdBy,
}
const serializedPlatformSupport = platformSupport.map(ps => ({
hardwareSlug: ps.hardwareSlug,
isSupported: ps.isSupported,
protonStatus: ps.protonStatus,
}))
return (
<div className="max-w-3xl mx-auto px-4 py-8 w-full">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Edit Game</h1>
<p className="text-sm text-text/60">
Update details for <span className="text-text font-medium">{game.title}</span>
</p>
</div>
<NonSteamEditForm
game={serializedGame}
platformSupport={serializedPlatformSupport}
hardwareList={hardwareList}
isOwner={session.user.id === game.createdBy}
isAdmin={session.user.role === "admin"}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
import { ImageResponse } from "next/og"
import { db } from "@/lib/db/index"
import { games, gameVersions, performanceEntries } from "@/lib/db/schema"
import { eq, and, sql } from "drizzle-orm"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
// This needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const alt = "DeckyVault - Game Benchmarks"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"
export default async function Image({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const isNumeric = /^\d+$/.test(id)
let game
if (isNumeric) {
const rows = await db.select().from(games).where(eq(games.steamAppId, Number(id))).limit(1)
game = rows[0]
} else {
const rows = await db.select().from(games).where(eq(games.id, id)).limit(1)
game = rows[0]
}
if (!game) {
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: "#100b14", color: "#ebe4f1", fontFamily: "sans-serif", gap: "16px" }}>
<div style={{ fontSize: 48, fontWeight: 700 }}>Game Not Found</div>
<div style={{ fontSize: 24, opacity: 0.7 }}>DeckyVault</div>
</div>
),
{ ...size }
)
}
// Get best FPS stat
let avgFps: number | null = null
try {
const [bestStat] = await db
.select({ avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)` })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(and(eq(gameVersions.gameId, game.id), eq(performanceEntries.isRemoved, false)))
.limit(1)
avgFps = bestStat?.avgFps ?? null
} catch {
// No FPS data available — that's fine
}
const logoData = await readFile(join(process.cwd(), "app/icon.png"), "base64")
const logoSrc = `data:image/png;base64,${logoData}`
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: "60px", background: "#100b14", color: "#ebe4f1", fontFamily: "sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: "16px", marginBottom: "24px" }}>
<img src={logoSrc} alt="" height={48} style={{ borderRadius: "8px" }} />
<span style={{ fontSize: 24, fontWeight: 600, opacity: 0.8 }}>DeckyVault</span>
</div>
<div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.1, marginBottom: "16px", maxWidth: "900px" }}>
{game.title}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "24px", fontSize: 24, opacity: 0.8 }}>
{game.developer && <span>by {game.developer}</span>}
{avgFps !== null && <span style={{ color: "#22c55e" }}>~{avgFps} avg FPS</span>}
</div>
{game.genres && game.genres.length > 0 && (
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
{game.genres.slice(0, 4).map((genre: string) => (
<span key={genre} style={{ padding: "4px 12px", borderRadius: "9999px", background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", fontSize: 16 }}>
{genre}
</span>
))}
</div>
)}
</div>
),
{ ...size }
)
}
+429
View File
@@ -0,0 +1,429 @@
import type { Metadata } from "next"
import { Suspense } from "react"
import { notFound } from "next/navigation"
import { after } from "next/server"
import { db } from "@/lib/db/index"
import {
games,
gameVersions,
performanceEntries,
gameComments,
gamePlatformSupport,
hardware,
user,
entryScreenshots,
} from "@/lib/db/schema"
import { and, desc, eq, sql } from "drizzle-orm"
import { isSyncStale, syncSteamGame, ensureSteamGame } from "@/lib/steam/sync"
import { getR2PublicUrl } from "@/lib/storage"
import { smartTruncate, buildBreadcrumbList } from "@/lib/utils/seo"
import { GamePageClient } from "./game-page-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
async function resolveGame(id: string) {
const isNumeric = /^\d+$/.test(id)
let game
if (isNumeric) {
const rows = await db
.select()
.from(games)
.where(eq(games.steamAppId, Number(id)))
.limit(1)
game = rows[0]
} else {
// Try UUID first
const rows = await db
.select()
.from(games)
.where(eq(games.id, id))
.limit(1)
game = rows[0]
// Fallback: try slug lookup
if (!game) {
const slugRows = await db
.select()
.from(games)
.where(eq(games.slug, id))
.limit(1)
game = slugRows[0]
}
}
return game
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const game = await resolveGame(id)
if (!game) {
return { title: "Game Not Found | DeckyVault" }
}
const description = game.description
? smartTruncate(game.description, 160)
: `Find benchmarks, community presets, and performance settings for ${game.title} on Steam Deck. Compare FPS, TDP, and battery life from community reports.`
return {
title: `${game.title} - Benchmarks & Settings`,
description,
alternates: { canonical: `https://deckyvault.xyz/game/${game.id}` },
openGraph: {
title: `${game.title} - Benchmarks & Settings | DeckyVault`,
description: game.description ? smartTruncate(game.description, 200) : `Benchmarks and settings for ${game.title}`,
url: `https://deckyvault.xyz/game/${game.id}`,
images: [{ url: `/game/${game.id}/opengraph-image`, width: 1200, height: 630 }],
type: "website",
siteName: "DeckyVault",
},
twitter: {
card: "summary_large_image",
title: `${game.title} - Benchmarks & Settings | DeckyVault`,
description: game.description ? smartTruncate(game.description, 200) : `Benchmarks and settings for ${game.title}`,
images: [`/game/${game.id}/opengraph-image`],
},
}
}
async function createGameStub(steamAppId: number) {
const result = await ensureSteamGame(steamAppId)
if (!result.game) {
notFound()
}
// If the sync determined this is not a game (DLC, soundtrack, etc.),
// treat it as not found rather than showing a broken page
if (result.game.syncStatus === "error" && result.error?.includes("not a game")) {
notFound()
}
return result.game
}
export default async function GamePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ sync?: string }>
}) {
const { id } = await params
const { sync } = await searchParams
const isNumeric = /^\d+$/.test(id)
const forceSync = sync === "1"
// ── Resolve game ────────────────────────────────────────────────
let game = await resolveGame(id)
if (!game && isNumeric) {
try {
game = await createGameStub(Number(id))
} catch (err) {
console.error("Failed to auto-create game stub:", err)
}
}
if (!game) {
notFound()
}
// ── Fetch related data in parallel ──────────────────────────────
const [
benchmarkCount,
presetCount,
commentCount,
platformSupport,
presetRows,
] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.innerJoin(
gameVersions,
eq(performanceEntries.versionId, gameVersions.id),
)
.where(eq(gameVersions.gameId, game.id))
.then((r) => r[0]?.count ?? 0),
db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
eq(gameVersions.gameId, game.id),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.settingsJson} IS NOT NULL`,
),
)
.then((r) => r[0]?.count ?? 0),
db
.select({ count: sql<number>`count(*)::int` })
.from(gameComments)
.where(eq(gameComments.gameId, game.id))
.then((r) => r[0]?.count ?? 0),
db
.select()
.from(gamePlatformSupport)
.where(eq(gamePlatformSupport.gameId, game.id))
.then((r) => r),
db
.select({
id: performanceEntries.id,
hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name,
upvotes: performanceEntries.upvotes,
settingsJson: performanceEntries.settingsJson,
fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh,
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod,
protonVersion: performanceEntries.protonVersion,
osVersion: performanceEntries.osVersion,
createdAt: performanceEntries.createdAt,
userId: performanceEntries.userId,
userName: user.name,
userImage: user.image,
downvotes: performanceEntries.downvotes,
launchOptions: performanceEntries.launchOptions,
loadTimeSsd: performanceEntries.loadTimeSsd,
loadTimeSd: performanceEntries.loadTimeSd,
tdpWatts: performanceEntries.tdpWatts,
youtubeVideoId: performanceEntries.youtubeVideoId,
customSystem: performanceEntries.customSystem,
userNotes: performanceEntries.userNotes,
versionString: gameVersions.versionString,
buildId: gameVersions.buildId,
gameAntiCheatName: gamePlatformSupport.antiCheatName,
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
verifiedAt: performanceEntries.verifiedAt,
isPinned: performanceEntries.isPinned,
pinnedAt: performanceEntries.pinnedAt,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.innerJoin(user, eq(performanceEntries.userId, user.id))
.innerJoin(
gamePlatformSupport,
and(
eq(gamePlatformSupport.gameId, gameVersions.gameId),
eq(gamePlatformSupport.hardwareSlug, performanceEntries.hardwareSlug),
),
)
.where(
and(
eq(gameVersions.gameId, game.id),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.settingsJson} IS NOT NULL`,
),
)
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)),
])
// ── Sync logic: force or stale-while-revalidate ────────────────
const shouldSync =
game.source === "steam" &&
game.steamAppId &&
(forceSync || isSyncStale(game.lastSync))
if (shouldSync) {
if (forceSync) {
// Block render on forced sync so user sees fresh data immediately
await syncSteamGame(game.steamAppId!)
// Re-fetch game after sync so serialized data is fresh
const refreshed = await resolveGame(game.steamAppId!.toString())
if (refreshed) game = refreshed
} else {
// Stale sync happens after response so page isn't delayed
after(async () => {
await syncSteamGame(game.steamAppId!)
})
}
}
// Serialize for client component (Dates → strings)
const serializedGame = {
id: game.id,
steamAppId: game.steamAppId,
title: game.title,
description: game.description,
developer: game.developer,
publisher: game.publisher,
genres: game.genres,
headerImage: game.headerImage,
capsuleImage: game.capsuleImage,
storeUrl: game.storeUrl,
source: game.source,
slug: game.slug,
lastSync: game.lastSync ? game.lastSync.toISOString() : null,
syncStatus: game.syncStatus,
createdAt: game.createdAt.toISOString(),
systemRequirements: game.systemRequirements,
metacriticScore: game.metacriticScore,
metacriticUrl: game.metacriticUrl,
recommendationsTotal: game.recommendationsTotal,
priceCurrent: game.priceCurrent,
priceInitial: game.priceInitial,
priceCurrency: game.priceCurrency,
isFree: game.isFree,
releaseDate: game.releaseDate,
categories: game.categories,
platforms: game.platforms,
steamReviewScore: game.steamReviewScore,
steamReviewSentiment: game.steamReviewSentiment,
steamReviewCount: game.steamReviewCount,
}
const serializedPresets = presetRows.map((p) => ({
id: p.id,
gameId: game.id,
hardwareSlug: p.hardwareSlug,
hardwareName: p.hardwareName,
upvotes: p.upvotes,
settingsCount: Array.isArray(p.settingsJson)
? p.settingsJson.reduce(
(sum: number, cat: { settings: unknown[] }) =>
sum + cat.settings.length,
0,
)
: 0,
fpsAvg: p.fpsAvg,
fpsLow: p.fpsLow,
fpsHigh: p.fpsHigh,
fpsOnePercentLow: p.fpsOnePercentLow ?? null,
upscalerType: p.upscalerType,
upscalerVersion: p.upscalerVersion,
frameGenMethod: p.frameGenMethod,
protonVersion: p.protonVersion,
osVersion: p.osVersion,
createdAt: p.createdAt.toISOString(),
settingsJson: p.settingsJson,
launchOptions: p.launchOptions,
loadTimeSsd: p.loadTimeSsd ?? null,
loadTimeSd: p.loadTimeSd ?? null,
tdpWatts: p.tdpWatts ?? null,
youtubeVideoId: p.youtubeVideoId ?? null,
screenshots: null as Array<{ id: string; url: string; width: number; height: number; orderIndex: number }> | null,
hardwareWattHours: null as number | null,
hardwareDeviceType: null as string | null,
customSystem: p.customSystem ?? false,
userNotes: p.userNotes,
versionString: p.versionString ?? null,
buildId: p.buildId ?? null,
gameAntiCheatName: p.gameAntiCheatName ?? null,
gameAntiCheatStatus: p.gameAntiCheatStatus ?? null,
userId: p.userId,
userName: p.userName,
userImage: p.userImage,
downvotes: p.downvotes,
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
isPinned: p.isPinned,
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
}))
// Fetch screenshots and hardware details for each preset
const publicUrl = getR2PublicUrl()
for (const preset of serializedPresets) {
const screenshots = await db
.select({
id: entryScreenshots.id,
storageKey: entryScreenshots.storageKey,
orderIndex: entryScreenshots.orderIndex,
width: entryScreenshots.width,
height: entryScreenshots.height,
})
.from(entryScreenshots)
.where(eq(entryScreenshots.entryId, preset.id))
.orderBy(entryScreenshots.orderIndex)
preset.screenshots = screenshots.map((ss) => ({
id: ss.id,
url: `${publicUrl}/${ss.storageKey}`,
width: ss.width,
height: ss.height,
orderIndex: ss.orderIndex,
}))
const [hw] = await db
.select({
wattHours: hardware.wattHours,
deviceType: hardware.deviceType,
})
.from(hardware)
.where(eq(hardware.slug, preset.hardwareSlug))
.limit(1)
preset.hardwareWattHours = hw?.wattHours ? Number(hw.wattHours) : null
preset.hardwareDeviceType = hw?.deviceType ?? null
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "VideoGame",
name: game.title,
...(game.developer && { developer: { "@type": "Organization", name: game.developer } }),
...(game.genres && game.genres.length > 0 && { genre: game.genres }),
...(game.headerImage && { image: game.headerImage }),
url: `https://deckyvault.xyz/game/${game.id}`,
applicationCategory: "Game",
operatingSystem: "SteamOS",
...(game.storeUrl && { offers: { "@type": "Offer", url: game.storeUrl } }),
}),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildBreadcrumbList([
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Games", url: "https://deckyvault.xyz/games" },
{ name: game.title, url: `https://deckyvault.xyz/game/${game.id}` },
]),
),
}}
/>
{/* AggregateRating — based on Steam review score when available */}
{game.steamReviewScore != null && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "AggregateRating",
itemReviewed: {
"@type": "VideoGame",
name: game.title,
},
ratingValue: (game.steamReviewScore / 10).toFixed(1),
bestRating: "10",
worstRating: "0",
ratingCount: game.steamReviewCount ?? undefined,
}),
}}
/>
)}
<Suspense fallback={<div className="min-h-screen" />}>
<GamePageClient
game={serializedGame}
counts={{
benchmarks: benchmarkCount,
presets: presetCount,
comments: commentCount,
}}
platformSupport={platformSupport}
presets={serializedPresets}
gameId={game.id}
/>
</Suspense>
</>
)
}
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
import { notFound } from "next/navigation"
import { db } from "@/lib/db/index"
import { games, gameVersions, performanceEntries, gamePlatformSupport, entryScreenshots } from "@/lib/db/schema"
import { eq, sql } from "drizzle-orm"
import { GameEntryWizard, type GameVersionInfo } from "@/components/wizard/game-entry-wizard"
import { getR2PublicUrl } from "@/lib/storage/r2-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata = {
title: "Submit Benchmark",
}
interface PageProps {
params: Promise<{ id: string }>
searchParams: Promise<{ edit?: string }>
}
export default async function SubmitBenchmarkPage({
params,
searchParams,
}: PageProps) {
const { id } = await params
const { edit } = await searchParams
// Resolve game
const isNumeric = /^\d+$/.test(id)
let game
if (isNumeric) {
const rows = await db
.select()
.from(games)
.where(eq(games.steamAppId, Number(id)))
.limit(1)
game = rows[0]
} else {
const rows = await db
.select()
.from(games)
.where(eq(games.id, id))
.limit(1)
game = rows[0]
}
if (!game) {
notFound()
}
// Fetch all game versions for version selection
const allVersions = await db
.select({
id: gameVersions.id,
versionString: gameVersions.versionString,
buildId: gameVersions.buildId,
isLatest: gameVersions.isLatest,
})
.from(gameVersions)
.where(eq(gameVersions.gameId, game.id))
.orderBy(sql`${gameVersions.createdAt} DESC`)
// Create a default version if none exists
if (allVersions.length === 0) {
const [newVersion] = await db
.insert(gameVersions)
.values({
gameId: game.id,
isLatest: true,
})
.returning()
allVersions.push({
id: newVersion.id,
versionString: newVersion.versionString,
buildId: newVersion.buildId,
isLatest: newVersion.isLatest,
})
}
// If editing, fetch the existing performance entry
let editEntry: any = null
if (edit) {
const [entry] = await db
.select()
.from(performanceEntries)
.where(eq(performanceEntries.id, edit))
.limit(1)
if (entry) {
const publicUrl = getR2PublicUrl()
const screenshots = await db
.select({
id: entryScreenshots.id,
storageKey: entryScreenshots.storageKey,
orderIndex: entryScreenshots.orderIndex,
width: entryScreenshots.width,
height: entryScreenshots.height,
})
.from(entryScreenshots)
.where(eq(entryScreenshots.entryId, entry.id))
.orderBy(entryScreenshots.orderIndex)
editEntry = {
...entry,
screenshots: screenshots.map((ss) => ({
id: ss.id,
url: `${publicUrl}/${ss.storageKey}`,
width: ss.width,
height: ss.height,
orderIndex: ss.orderIndex,
})),
}
}
}
// Determine default version: when editing, use the entry's version;
// otherwise, use the latest (first in DESC order)
let defaultVersionId = allVersions[0].id
if (editEntry?.versionId) {
defaultVersionId = editEntry.versionId
}
const gameVersionInfos: GameVersionInfo[] = allVersions
// Fetch platform support for anti-cheat awareness
const platformSupport = await db
.select({
hardwareSlug: gamePlatformSupport.hardwareSlug,
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
antiCheatName: gamePlatformSupport.antiCheatName,
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
})
.from(gamePlatformSupport)
.where(eq(gamePlatformSupport.gameId, game.id))
return (
<div className="max-w-7xl mx-auto px-4 py-8 w-full">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">
{editEntry ? "Edit Benchmark" : "Submit Benchmark"}
</h1>
<p className="text-sm text-text/60">
{editEntry ? "Update your performance data for" : "Submit performance data for"}{" "}
<span className="text-text font-medium">{game.title}</span>
</p>
</div>
<GameEntryWizard
gameId={game.id}
gameVersions={gameVersionInfos}
defaultVersionId={defaultVersionId}
editEntry={editEntry}
platformSupport={platformSupport}
/>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation"
import { headers } from "next/headers"
import { auth } from "@/lib/auth"
import { NonSteamWizard } from "@/components/wizard/non-steam-wizard"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Add Non-Steam Game",
}
export default async function AddGamePage() {
const h = await headers()
const session = await auth.api.getSession({ headers: h })
if (!session?.user) {
redirect("/login")
}
return (
<div className="max-w-3xl mx-auto px-4 py-8 w-full">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Add a Game</h1>
<p className="text-sm text-text/60">
Add a non-Steam game to DeckyVault. Search for cover art, set platform support, and submit.
</p>
</div>
<NonSteamWizard />
</div>
)
}
+966
View File
@@ -0,0 +1,966 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { motion } from "motion/react"
import {
Gamepad2Icon,
SearchIcon,
TrendingUpIcon,
XIcon,
Loader2Icon,
} from "lucide-react"
import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation"
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
import { FilterDrawer } from "@/components/filter-drawer"
interface GamesListItem {
id: string
steamAppId: number | null
title: string
developer: string | null
capsuleImage: string | null
headerImage: string | null
genres: string[] | null
source: string
steamReviewScore: number | null
playabilityStatus:
| "great"
| "playable"
| "needs_tweaks"
| "unplayable"
| "unknown"
| null
onlineMultiplayerStatus: "none" | "supported" | "unknown" | null
benchmarkCount: number
deckStatus: string | null
antiCheatRelevant: boolean
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null
bestFps: number | null
isRawPerformer: boolean
isPoorPerformance: boolean
estimatedBatteryMin: number | null
}
interface DeviceOption {
slug: string
name: string
}
type SortOption =
| "recent"
| "name"
| "benchmarks"
| "performance"
| "popularity"
| "release_date"
| "steam_reviews"
type SortDirection = "asc" | "desc"
const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> =
{
native: {
label: "Native",
className: "bg-green-500/10 border-green-500/20 text-green-400",
},
proton: {
label: "Proton",
className: "bg-blue-500/10 border-blue-500/20 text-blue-400",
},
unsupported: {
label: "Unsupported",
className: "bg-red-500/10 border-red-500/20 text-red-400",
},
unknown: {
label: "Unknown",
className: "bg-text/5 border-border text-text/40",
},
}
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "recent", label: "Recently Added" },
{ value: "name", label: "Name AZ" },
{ value: "benchmarks", label: "Most Benchmarks" },
{ value: "performance", label: "Best Performance" },
{ value: "popularity", label: "Most Popular" },
{ value: "release_date", label: "Release Date" },
{ value: "steam_reviews", label: "Steam Reviews" },
]
export function GamesPageClient({
initialGames,
totalCount,
allGenres,
allDevices,
}: {
initialGames: GamesListItem[]
totalCount: number
allGenres: string[]
allDevices: DeviceOption[]
}) {
const [games, setGames] = useState<GamesListItem[]>(initialGames)
const [total, setTotal] = useState(totalCount)
const [search, setSearch] = useState("")
const [selectedGenres, setSelectedGenres] = useState<string[]>([])
const [selectedDevice, setSelectedDevice] = useState("")
const [sort, setSort] = useState<SortOption>("recent")
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showFilters, setShowFilters] = useState(false)
const [minFps, setMinFps] = useState<string>("")
const [maxFps, setMaxFps] = useState<string>("")
const [fsrSupport, setFsrSupport] = useState<boolean>(false)
const [protonNative, setProtonNative] = useState<string>("any")
const [antiCheatStatus, setAntiCheatStatus] = useState<string>("any")
const [playabilityStatus, setPlayabilityStatus] = useState<string>("")
const [steamReviewMin, setSteamReviewMin] = useState<string>("")
const [isFree, setIsFree] = useState<boolean>(false)
const [hasMultiplayer, setHasMultiplayer] = useState<boolean>(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const pageRef = useRef<HTMLElement>(null)
const { isGamepadActive } = useGamepadNavigation(pageRef, {
onXButton: () => {
// Navigate to search page
window.location.href = "/search"
},
onYButton: () => {
// Toggle filter panel
setShowFilters((prev) => !prev)
},
})
const router = useRouter()
const hasMore = games.length < total
// ── Initialize filters from URL params on mount ────────────────
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get("search")) setSearch(params.get("search")!)
if (params.get("device")) setSelectedDevice(params.get("device")!)
if (params.get("genre")) {
const genres = params.get("genre")!.split(",").filter(Boolean)
setSelectedGenres(genres)
}
if (params.get("minFps")) setMinFps(params.get("minFps")!)
if (params.get("maxFps")) setMaxFps(params.get("maxFps")!)
if (params.get("fsrSupport") === "true") setFsrSupport(true)
if (params.get("protonNative") && params.get("protonNative") !== "any")
setProtonNative(params.get("protonNative")!)
if (
params.get("antiCheatStatus") &&
params.get("antiCheatStatus") !== "any"
)
setAntiCheatStatus(params.get("antiCheatStatus")!)
if (params.get("playabilityStatus"))
setPlayabilityStatus(params.get("playabilityStatus")!)
if (params.get("steamReviewScore"))
setSteamReviewMin(params.get("steamReviewScore")!)
if (params.get("isFree") === "true") setIsFree(true)
if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true)
if (params.get("sort")) {
const s = params.get("sort")!
if (
[
"recent",
"name",
"benchmarks",
"performance",
"popularity",
"release_date",
"steam_reviews",
].includes(s)
) {
setSort(s as SortOption)
}
}
if (params.get("order"))
setSortDirection(params.get("order") as SortDirection)
}, [])
/* eslint-enable react-hooks/set-state-in-effect */
const buildUrl = useCallback(
(offset: number) => {
const params = new URLSearchParams()
params.set("offset", String(offset))
params.set("limit", "24")
params.set("sort", sort)
params.set("order", sortDirection)
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0)
params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any")
params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus)
params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
return `/api/games/listing?${params.toString()}`
},
[
sort,
sortDirection,
search,
selectedDevice,
selectedGenres,
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
],
)
// Load more function for infinite scroll
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
setError(null)
try {
const url = buildUrl(games.length)
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to load games")
const data = await res.json()
setGames((prev) => [...prev, ...data.data])
setTotal(data.total)
} catch (err) {
setError("Failed to load more games. Please try again.")
console.error(err)
} finally {
setLoading(false)
}
}, [loading, hasMore, games.length, buildUrl])
// ── Sync filter state to URL (replace, not push) ───────────────
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0)
params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any")
params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus)
params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
if (sort !== "recent") params.set("sort", sort)
if (sortDirection !== "desc") params.set("order", sortDirection)
const qs = params.toString()
const url = qs ? `/games?${qs}` : "/games"
router.replace(url, { scroll: false })
}, [
search,
selectedDevice,
selectedGenres,
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
sort,
sortDirection,
router,
])
// Full reload when filters/sort change
useEffect(() => {
let cancelled = false
async function fetchGames() {
setLoading(true)
setError(null)
try {
const url = buildUrl(0)
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to load games")
const data = await res.json()
if (!cancelled) {
setGames(data.data)
setTotal(data.total)
}
} catch (err) {
if (!cancelled)
setError("Failed to load games. Please try again.")
console.error(err)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchGames()
return () => {
cancelled = true
}
}, [buildUrl])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect()
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore()
}
},
{ rootMargin: "200px" },
)
if (sentinelRef.current) {
observer.observe(sentinelRef.current)
}
observerRef.current = observer
return () => observer.disconnect()
}, [hasMore, loading, loadMore])
const toggleGenre = (genre: string) => {
setSelectedGenres((prev) =>
prev.includes(genre)
? prev.filter((g) => g !== genre)
: [...prev, genre],
)
}
const filterPanelContent = (
<>
{/* Device filter */}
<div>
<span className='text-xs text-text/50 uppercase tracking-wider mb-1.5 block'>
Device
</span>
<div className='flex flex-wrap gap-1.5'>
<button
onClick={() => setSelectedDevice("")}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
All Devices
</button>
{allDevices.map((device) => (
<button
key={device.slug}
onClick={() =>
setSelectedDevice(
selectedDevice === device.slug
? ""
: device.slug,
)
}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{device.name}
</button>
))}
</div>
</div>
{/* Genre filter */}
<div>
<span className='text-xs text-text/50 uppercase tracking-wider mb-1.5 block'>
Genre
</span>
<div className='flex flex-wrap gap-1.5 max-h-32 overflow-y-auto scrollbar-hide'>
{allGenres.map((genre) => (
<button
key={genre}
onClick={() => toggleGenre(genre)}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedGenres.includes(genre)
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{genre}
</button>
))}
</div>
</div>
{/* Performance Filters */}
<div className='space-y-2'>
<h4 className='text-sm font-medium text-zinc-300'>
Performance
</h4>
<div className='flex gap-2'>
<input
type='number'
placeholder='Min FPS'
value={minFps}
onChange={(e) => setMinFps(e.target.value)}
className='w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
/>
<input
type='number'
placeholder='Max FPS'
value={maxFps}
onChange={(e) => setMaxFps(e.target.value)}
className='w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
/>
</div>
<select
value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
>
<option value=''>Any Playability</option>
<option value='great'>Plays Great</option>
<option value='playable'>Playable</option>
<option value='needs_tweaks'>Needs Tweaks</option>
<option value='unplayable'>Unplayable</option>
</select>
</div>
{/* Compatibility Filters */}
<div className='space-y-2'>
<h4 className='text-sm font-medium text-zinc-300'>
Compatibility
</h4>
<select
value={protonNative}
onChange={(e) => setProtonNative(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
>
<option value='any'>Any Runtime</option>
<option value='native'>Native</option>
<option value='proton'>Proton</option>
</select>
<select
value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
>
<option value='any'>Any Anti-Cheat</option>
<option value='supported'>AC Supported</option>
<option value='unsupported'>AC Unsupported</option>
<option value='unknown'>AC Unknown</option>
</select>
<label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input
type='checkbox'
checked={fsrSupport}
onChange={(e) => setFsrSupport(e.target.checked)}
className='rounded border-zinc-600'
/>
FSR Support
</label>
</div>
{/* Other Filters */}
<div className='space-y-2'>
<h4 className='text-sm font-medium text-zinc-300'>Other</h4>
<label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input
type='checkbox'
checked={isFree}
onChange={(e) => setIsFree(e.target.checked)}
className='rounded border-zinc-600'
/>
Free to Play
</label>
<label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input
type='checkbox'
checked={hasMultiplayer}
onChange={(e) => setHasMultiplayer(e.target.checked)}
className='rounded border-zinc-600'
/>
Has Multiplayer
</label>
<input
type='number'
placeholder='Min Steam Review %'
value={steamReviewMin}
onChange={(e) => setSteamReviewMin(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm'
/>
</div>
{/* Saved Filters */}
<SavedFilters
currentFilters={{
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
genre: selectedGenres[0] || "",
device: selectedDevice,
sortBy: sort,
}}
onLoad={(filters) => {
setMinFps((filters.minFps as string) || "")
setMaxFps((filters.maxFps as string) || "")
setFsrSupport((filters.fsrSupport as boolean) || false)
setProtonNative((filters.protonNative as string) || "any")
setAntiCheatStatus(
(filters.antiCheatStatus as string) || "any",
)
setPlayabilityStatus(
(filters.playabilityStatus as string) || "",
)
setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer(
(filters.hasMultiplayer as boolean) || false,
)
if (filters.genre)
setSelectedGenres(
(filters.genre as string)
.split(",")
.filter(Boolean),
)
else setSelectedGenres([])
if (filters.device)
setSelectedDevice(filters.device as string)
else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption)
setShowFilters(false)
}}
/>
{/* Clear filters */}
{(selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button
onClick={() => {
setSelectedDevice("")
setSelectedGenres([])
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}}
className='text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start min-h-11 py-2 flex items-center'
>
Clear all filters
</button>
)}
</>
)
return (
<section
ref={pageRef}
className={`w-full flex flex-col gap-8 py-8 ${isGamepadActive ? "gamepad-focus" : ""}`}
>
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<h1 className='text-2xl sm:text-3xl font-bold'>Games</h1>
<p className='text-sm text-text/60 mt-1'>
Browse {total.toLocaleString()} games with benchmarks,
settings, and performance data
</p>
</div>
</motion.div>
{/* Search & Filter Bar */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto flex flex-col gap-3'>
{/* Search + Sort Row */}
<div className='flex flex-row items-center flex-wrap gap-3'>
<label className='flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2.5 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text min-h-11'>
<SearchIcon className='h-4 w-4 text-text/40 shrink-0' />
<input
type='text'
placeholder='Search games...'
value={search}
onChange={(e) => setSearch(e.target.value)}
className='flex-1 outline-none bg-transparent text-sm min-w-0'
/>
{search && (
<button
onClick={() => setSearch("")}
className='text-text/40 hover:text-text/70 transition-colors cursor-pointer'
>
<XIcon className='h-3.5 w-3.5' />
</button>
)}
</label>
<button
onClick={() =>
setSortDirection((prev) =>
prev === "asc" ? "desc" : "asc",
)
}
className='px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer min-h-11'
title={
sortDirection === "asc"
? "Sort ascending"
: "Sort descending"
}
>
{sortDirection === "asc" ? "↑" : "↓"}
</button>
<select
value={sort}
onChange={(e) =>
setSort(e.target.value as SortOption)
}
className='bg-text/5 border border-border rounded-md px-3 py-3 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 cursor-pointer'
>
{SORT_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
>
{opt.label}
</option>
))}
</select>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-11 ${
showFilters ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
? "bg-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`}
>
Filters
{(selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<span className='ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold'>
{selectedGenres.length +
(selectedDevice ? 1 : 0) +
(minFps ? 1 : 0) +
(maxFps ? 1 : 0) +
(fsrSupport ? 1 : 0) +
(protonNative !== "any" ? 1 : 0) +
(antiCheatStatus !== "any" ? 1 : 0) +
(playabilityStatus ? 1 : 0) +
(steamReviewMin ? 1 : 0) +
(isFree ? 1 : 0) +
(hasMultiplayer ? 1 : 0)}
</span>
)}
</button>
</div>
{/* Mobile drawer */}
<FilterDrawer
isOpen={showFilters}
onClose={() => setShowFilters(false)}
>
<div className='flex flex-col gap-3'>
{filterPanelContent}
</div>
</FilterDrawer>
{/* Desktop inline panel */}
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className='hidden lg:flex flex-col gap-3 pt-1'
>
{filterPanelContent}
</motion.div>
)}
</div>
</motion.div>
{/* Error State */}
{error && !loading && (
<div className='max-w-7xl mx-auto px-4 md:px-0 text-center py-12'>
<p className='text-red-400 text-sm'>{error}</p>
<button
onClick={() => window.location.reload()}
className='mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-11 py-2 flex items-center'
>
Try again
</button>
</div>
)}
{/* Games Grid */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4'>
{games.map((game) => (
<GameCard
key={game.id}
game={game}
/>
))}
</div>
{/* Empty state */}
{!loading && games.length === 0 && !error && (
<div className='flex flex-col items-center justify-center py-20 gap-4'>
<Gamepad2Icon className='h-12 w-12 text-text/20' />
<p className='text-text/40 text-sm'>
{search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
? "No games match your filters"
: "No games found"}
</p>
{(search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button
onClick={() => {
setSearch("")
setSelectedDevice("")
setSelectedGenres([])
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}}
className='text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer min-h-11'
>
Clear filters
</button>
)}
</div>
)}
</motion.div>
{/* Loading indicator for infinite scroll */}
{loading && (
<div className='flex items-center justify-center py-8'>
<Loader2Icon className='h-6 w-6 text-primary animate-spin' />
</div>
)}
{/* End of list */}
{!loading && !hasMore && games.length > 0 && (
<div className='text-center py-6'>
<p className='text-text/30 text-xs'>
Showing all {games.length} of {total.toLocaleString()}{" "}
games
</p>
</div>
)}
{/* Infinite scroll sentinel */}
{hasMore && !loading && (
<div
ref={sentinelRef}
className='h-1'
/>
)}
</section>
)
}
function GameCard({ game }: { game: GamesListItem }) {
const [imgError, setImgError] = useState(false)
const imageUrl = game.capsuleImage || game.headerImage
const deckConfig = game.deckStatus
? (DECK_STATUS_CONFIG[game.deckStatus] ?? DECK_STATUS_CONFIG.unknown)
: null
return (
<Link
href={`/game/${game.id}`}
className='group rounded-xl border border-border bg-text/3 hover:border-primary/30 transition-all duration-200 overflow-hidden'
>
<div className='relative w-full aspect-2/3 bg-text/10 overflow-hidden'>
{imageUrl && !imgError ? (
<Image
src={imageUrl}
alt={game.title}
fill
className='object-cover group-hover:scale-105 transition-transform duration-300'
sizes='(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw'
onError={() => setImgError(true)}
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/20' />
</div>
)}
</div>
<div className='p-2.5 sm:p-3'>
<h3 className='text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight'>
{game.title}
</h3>
<div className='flex flex-wrap gap-1 mt-1'>
{game.playabilityStatus && (
<PlayabilityBadge
status={game.playabilityStatus}
compact
/>
)}
{game.antiCheatRelevant &&
game.antiCheatStatus === "unsupported" && (
<AntiCheatBadge
antiCheatRelevant={true}
antiCheatStatus={game.antiCheatStatus}
compact
/>
)}
{game.steamReviewScore != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-blue-400'>
<svg
className='h-2.5 w-2.5'
viewBox='0 0 24 24'
fill='currentColor'
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg>
{game.steamReviewScore}%
</span>
)}
{game.isRawPerformer && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[10px] font-semibold shrink-0'>
RAW PERFORMER
</span>
)}
{game.isPoorPerformance && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-semibold shrink-0'>
POOR PERFORMANCE
</span>
)}
{game.bestFps != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
{Math.round(game.bestFps)}fps best
</span>
)}
{game.estimatedBatteryMin != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
🔋 ~{Math.round(game.estimatedBatteryMin / 60)}h
</span>
)}
</div>
<div className='mt-1.5 flex items-center gap-2 flex-wrap'>
{game.benchmarkCount > 0 ? (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
<TrendingUpIcon className='h-3 w-3 text-primary/60' />
<span className='tabular-nums'>
{game.benchmarkCount}
</span>
</span>
) : (
<span className='text-[10px] text-text/25 italic'>
No data yet
</span>
)}
{deckConfig && (
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${deckConfig.className}`}
>
{deckConfig.label}
</span>
)}
</div>
</div>
</Link>
)
}
+299
View File
@@ -0,0 +1,299 @@
import type { Metadata } from "next"
import { after } from "next/server"
import { db } from "@/lib/db/index"
import {
games,
gameVersions,
performanceEntries,
gamePlatformSupport,
hardware,
} from "@/lib/db/schema"
import { sql, eq, and, desc, inArray } from "drizzle-orm"
import { isSyncStale, syncSteamGame } from "@/lib/steam/sync"
import { buildBreadcrumbList } from "@/lib/utils/seo"
import { GamesPageClient } from "./games-page-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata: Metadata = {
title: "Games",
description:
"Browse the full catalog of Steam Deck games with benchmarks, community settings, and performance data. Filter by genre, device, and more.",
keywords: [
"Steam Deck games",
"game benchmarks",
"Steam Deck settings",
"game catalog",
"performance data",
],
alternates: { canonical: "https://deckyvault.xyz/games" },
openGraph: {
title: "Games | DeckyVault",
description:
"Browse the full catalog of Steam Deck games with benchmarks and performance data.",
url: "https://deckyvault.xyz/games",
siteName: "DeckyVault",
type: "website",
},
}
export default async function GamesPage() {
// Fetch initial 24 games
const gamesData = await db
.select({
id: games.id,
steamAppId: games.steamAppId,
title: games.title,
developer: games.developer,
capsuleImage: games.capsuleImage,
headerImage: games.headerImage,
genres: games.genres,
source: games.source,
createdAt: games.createdAt,
steamReviewScore: games.steamReviewScore,
playabilityStatus: games.playabilityStatus,
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
lastSync: games.lastSync,
})
.from(games)
.orderBy(desc(games.createdAt))
.limit(24)
// Get total count
const [{ count: totalCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(games)
// Get benchmark counts for the initial games
const gameIds = gamesData.map((g) => g.id)
const benchmarkCounts = gameIds.length > 0
? await db
.select({
gameId: gameVersions.gameId,
count: sql<number>`count(*)::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, gameIds),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
: []
const benchmarkMap = new Map<string, number>()
for (const row of benchmarkCounts) {
benchmarkMap.set(row.gameId, row.count)
}
// Fetch platform support for initial games (prioritise Steam Deck)
const platformRows = gameIds.length > 0
? await db
.select({
gameId: gamePlatformSupport.gameId,
hardwareSlug: gamePlatformSupport.hardwareSlug,
protonStatus: gamePlatformSupport.protonStatus,
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
})
.from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, gameIds))
: []
const platformMap = new Map<string, string>()
const antiCheatMap = new Map<string, { antiCheatRelevant: boolean; antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null }>()
for (const row of platformRows) {
const isSteamDeck = row.hardwareSlug.startsWith("steamdeck")
const existing = platformMap.get(row.gameId)
if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) {
platformMap.set(row.gameId, row.protonStatus)
}
const existingAc = antiCheatMap.get(row.gameId)
if (row.antiCheatRelevant) {
if (!existingAc || (!existingAc.antiCheatRelevant && isSteamDeck) || (!existingAc.antiCheatRelevant)) {
antiCheatMap.set(row.gameId, {
antiCheatRelevant: row.antiCheatRelevant,
antiCheatStatus: row.antiCheatStatus,
})
}
}
}
// Performance stats: best FPS, raw performer, poor performance, battery estimate
const rawPerformerMap = new Map<string, boolean>()
const poorPerformerMap = new Map<string, boolean>()
const bestFpsMap = new Map<string, number>()
const batteryMinMap = new Map<string, number>()
if (gameIds.length > 0) {
const perfStats = await db
.select({
gameId: gameVersions.gameId,
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
isRawPerformer: sql<boolean>`BOOL_OR(
${performanceEntries.fpsAvg} >= 60
AND ${performanceEntries.upscalerType} = 'none'
AND ${performanceEntries.frameGenMethod} = 'none'
)`,
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardware, and(
eq(performanceEntries.hardwareSlug, hardware.slug),
eq(hardware.deviceType, "handheld"),
))
.where(
and(
inArray(gameVersions.gameId, gameIds),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
for (const row of perfStats) {
bestFpsMap.set(row.gameId, row.bestFps)
rawPerformerMap.set(row.gameId, row.isRawPerformer)
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
}
// Battery estimate for handheld devices
const batteryStats = await db
.select({
gameId: gameVersions.gameId,
estimatedBatteryMin: sql<number>`ROUND(
(${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
)::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.where(
and(
inArray(gameVersions.gameId, gameIds),
eq(performanceEntries.isRemoved, false),
eq(hardware.deviceType, "handheld"),
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
sql`${hardware.wattHours} IS NOT NULL`,
),
)
.orderBy(desc(performanceEntries.fpsAvg))
const seenGames = new Set<string>()
for (const row of batteryStats) {
if (!seenGames.has(row.gameId)) {
seenGames.add(row.gameId)
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
}
}
}
// Fetch all genres
const genreRows = await db
.select({ genres: games.genres })
.from(games)
.where(sql`${games.genres} IS NOT NULL`)
const genreSet = new Set<string>()
for (const row of genreRows) {
if (Array.isArray(row.genres)) {
for (const g of row.genres) {
if (typeof g === "string") genreSet.add(g)
}
}
}
// Fetch all hardware devices
const deviceRows = await db
.select({ slug: hardware.slug, name: hardware.name })
.from(hardware)
.orderBy(hardware.sortOrder)
// Serialize for client
const initialGames = gamesData.map((g) => ({
id: g.id,
steamAppId: g.steamAppId,
title: g.title,
developer: g.developer,
capsuleImage: g.capsuleImage,
headerImage: g.headerImage,
genres: g.genres,
source: g.source,
steamReviewScore: g.steamReviewScore,
playabilityStatus: g.playabilityStatus,
onlineMultiplayerStatus: g.onlineMultiplayerStatus,
benchmarkCount: benchmarkMap.get(g.id) ?? 0,
deckStatus: platformMap.get(g.id) ?? null,
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null,
bestFps: bestFpsMap.get(g.id) ?? null,
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
}))
const allGenres = Array.from(genreSet).sort()
const allDevices = deviceRows
// ── Background sync for stale games ────────────────────────────────
const staleSteamAppIds = gamesData
.filter((g) => g.source === "steam" && g.steamAppId && isSyncStale(g.lastSync))
.map((g) => g.steamAppId!)
if (staleSteamAppIds.length > 0) {
after(async () => {
// Sync stale games sequentially with a small delay to avoid rate-limiting
for (const appId of staleSteamAppIds) {
try {
await syncSteamGame(appId)
} catch {
// Stale sync failure is non-fatal — data will be refreshed on next visit
}
await new Promise((r) => setTimeout(r, 1500))
}
})
}
// JSON-LD ItemList
const jsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
itemListElement: initialGames.map((game, i) => ({
"@type": "ListItem",
position: i + 1,
name: game.title,
url: `https://deckyvault.xyz/game/${game.id}`,
})),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildBreadcrumbList([
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Games", url: "https://deckyvault.xyz/games" },
]),
),
}}
/>
<GamesPageClient
initialGames={initialGames}
totalCount={totalCount}
allGenres={allGenres}
allDevices={allDevices}
/>
</>
)
}
+57
View File
@@ -0,0 +1,57 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme inline {
/* Colors */
--color-background: #100b14;
--color-foreground: #180161;
--color-primary: #eb3779;
--color-secondary: #571b8b;
--color-accent: #fb793c;
--color-text: #ebe4f1;
--color-border: color-mix(in srgb, var(--color-text) 20%, transparent);
--color-border-active: color-mix(
in srgb,
var(--color-text) 60%,
transparent
);
/* Defaults */
--default-transition-duration: 0.3s;
/* Fonts */
--font-sans: var(--font-lexend);
}
/* Touch targets — WCAG 2.1 AA: minimum 44×44px */
.touch-target {
min-height: 44px;
min-width: 44px;
}
/* Gamepad focus ring — visible only when gamepad navigation is active */
.gamepad-focus :focus,
.gamepad-focus:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 4px;
}
/* Thin scrollbar for horizontal scroll sections */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 15%, transparent) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
height: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-text) 15%, transparent);
border-radius: 2px;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+126
View File
@@ -0,0 +1,126 @@
import type { Metadata, Viewport } from "next"
import { Lexend } from "next/font/google"
import "./globals.css"
import Script from "next/script"
import Navbar from "@/components/navbar"
import { Suspense } from "react"
const font = Lexend({
variable: "--font-lexend",
subsets: ["latin"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#eb3779",
}
export const metadata: Metadata = {
metadataBase: new URL("https://deckyvault.xyz"),
title: {
default: "DeckyVault - Steam Deck Benchmarks & Settings",
template: "%s | DeckyVault",
},
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
keywords: [
"Steam Deck",
"benchmarks",
"settings",
"performance",
"FPS",
"gaming",
"Steam Machine",
"Proton",
"FSR",
"compatibility",
],
authors: [
{ name: "Adrian Bonpin", url: "https://github.com/AdrianBonpin" },
],
creator: "@adrianbonpin",
openGraph: {
type: "website",
locale: "en_US",
url: "https://deckyvault.xyz",
siteName: "DeckyVault",
title: "DeckyVault - Steam Deck Benchmarks & Settings",
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: "DeckyVault - Steam Deck Benchmarks & Settings",
},
],
},
twitter: {
card: "summary_large_image",
title: "DeckyVault - Steam Deck Benchmarks & Settings",
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
creator: "@adrianbonpin",
images: ["/twitter-image"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
alternates: {
canonical: "https://deckyvault.xyz",
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang='en'
className={`${font.variable} bg-background text-text antialiased overscroll-none`}
>
<head>
{/* DNS prefetch + preconnect for external image CDNs */}
<link rel="dns-prefetch" href="https://cdn.akamai.steamstatic.com" />
<link rel="preconnect" href="https://cdn.akamai.steamstatic.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.steamgriddb.com" />
<link rel="preconnect" href="https://cdn.steamgriddb.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn2.steamgriddb.com" />
<link rel="preconnect" href="https://cdn2.steamgriddb.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.deckyvault.xyz" />
<link rel="preconnect" href="https://cdn.deckyvault.xyz" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://lh3.googleusercontent.com" />
<link rel="preconnect" href="https://lh3.googleusercontent.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.discordapp.com" />
<link rel="preconnect" href="https://cdn.discordapp.com" crossOrigin="anonymous" />
{/* PWA meta */}
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head>
<body className='min-h-full w-dvw flex flex-col relative'>
<Suspense>
<Navbar />
</Suspense>
{children}
<Script
src='https://stat.ranio.xyz/api/script.js'
data-site-id='b9817e8df599'
strategy='afterInteractive'
/>
</body>
</html>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next"
export default function manifest(): MetadataRoute.Manifest {
return {
name: "DeckyVault - Steam Deck Benchmarks & Settings",
short_name: "DeckyVault",
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
start_url: "/",
display: "standalone",
background_color: "#100b14",
theme_color: "#eb3779",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icon.png",
sizes: "any",
type: "image/png",
},
],
}
}
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { motion } from "motion/react"
import Link from "next/link"
export default function NotFound() {
return (
<section className="w-dvw h-dvh flex flex-col items-center justify-center relative p-4">
<motion.h1
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="font-bold text-8xl md:text-9xl text-primary"
>
404
</motion.h1>
<motion.h2
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3 } }}
className="mt-4 font-semibold text-xl md:text-2xl text-center"
>
This page doesn&apos;t exist yet
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 0.6, transition: { delay: 0.6 } }}
className="mt-2 text-center max-w-md"
>
The page you&apos;re looking for hasn&apos;t been built yet, or may have
been moved.
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.9 } }}
className="mt-8"
>
<Link
href="/"
className="px-6 py-3 rounded-full bg-primary text-background font-semibold hover:bg-primary/80 transition-colors cursor-pointer"
>
Back to Home
</Link>
</motion.div>
</section>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { ImageResponse } from "next/og"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
export const alt = "DeckyVault - Steam Deck Benchmarks & Settings"
export const size = {
width: 1200,
height: 630,
}
export const contentType = "image/png"
export default async function Image() {
const logoData = await readFile(
join(process.cwd(), "app/icon.png"),
"base64"
)
const logoSrc = `data:image/png;base64,${logoData}`
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#100b14",
color: "#ebe4f1",
fontFamily: "sans-serif",
gap: "16px",
}}
>
<img
src={logoSrc}
alt="DeckyVault"
height={120}
style={{ borderRadius: "16px" }}
/>
<div
style={{
fontSize: 64,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
DeckyVault
</div>
<div
style={{
fontSize: 28,
fontWeight: 400,
opacity: 0.8,
}}
>
Steam Deck Benchmarks & Settings
</div>
</div>
),
{
...size,
}
)
}
+606
View File
@@ -0,0 +1,606 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import {
Gamepad2Icon,
SearchIcon,
TrendingUpIcon,
GaugeIcon,
ClockIcon,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { PlayabilityBadge } from "@/components/playability-badge"
import { cn } from "@/lib/utils"
interface GameCard {
id: string
title: string
capsule_image: string | null
header_image: string | null
playability_status?: string | null
activity_score?: number
benchmark_count?: number
comment_count?: number
upvote_count?: number
avg_fps?: number
report_count?: number
release_date?: string | null
created_at?: string | null
}
interface SectionData {
recentBenchmarks: GameCard[]
trending: GameCard[]
mostTested: GameCard[]
onSale: SaleGameCard[]
}
interface SaleGameCard extends GameCard {
price_current?: number
price_initial?: number
price_currency?: string
steam_review_score?: number
best_fps?: number
}
function SkeletonSections() {
return (
<>
{[1, 2, 3].map((i) => (
<div
key={i}
className='space-y-3'
>
<div className='h-5 w-48 bg-text/5 rounded animate-pulse' />
<div className='overflow-x-auto overflow-y-hidden' style={{ pointerEvents: 'none' }}>
<div className='flex gap-3 pb-2' style={{ pointerEvents: 'auto' }}>
{[1, 2, 3, 4].map((j) => (
<div
key={j}
className='shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border animate-pulse'
>
<div className='aspect-[2/3] bg-text/5 rounded-t-xl' />
<div className='p-3 space-y-2'>
<div className='h-3 bg-text/5 rounded w-3/4' />
<div className='h-2 bg-text/5 rounded w-1/2' />
</div>
</div>
))}
</div>
</div>
</div>
))}
</>
)
}
function GameSection({
title,
icon: Icon,
games,
statKey,
statLabel,
statFormatter,
accentColor = "text-text/50",
muted = false,
}: {
title: string
icon: React.ElementType
games: GameCard[]
statKey: string
statLabel: string
statFormatter?: (v: unknown) => string
accentColor?: string
muted?: boolean
}) {
const router = useRouter()
const formatStat = (v: unknown): string => {
if (statFormatter) return statFormatter(v)
if (typeof v === "number") return `${Math.round(v)} ${statLabel}`
return `${v} ${statLabel}`
}
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
className={muted ? "opacity-70" : ""}
>
<div className='flex items-center gap-2 mb-4'>
<div className='border-l-2 border-primary pl-3'>
<div className='flex items-center gap-2'>
<Icon className={`h-4 w-4 ${accentColor}`} />
<h2 className='text-sm font-semibold text-text/80'>
{title}
</h2>
</div>
</div>
</div>
<div className='overflow-x-auto overflow-y-hidden -mx-1 px-1' style={{ pointerEvents: 'none' }}>
<div className='flex gap-3 pb-2 px-1' style={{ pointerEvents: 'auto' }}>
{games.map((game, idx) => (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
className='group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/6 transition-colors cursor-pointer overflow-hidden'
onClick={() => router.push(`/game/${game.id}?sync=1`)}
>
<div className='relative aspect-2/3 bg-text/10 overflow-hidden'>
{game.capsule_image ? (
<Image
src={game.capsule_image}
alt={game.title}
fill
className='object-cover rounded-xl group-hover:scale-[0.97] transition-transform duration-300'
sizes='(max-width: 640px) 144px, 176px'
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/15' />
</div>
)}
{/* Playability badge pinned at bottom of image */}
{game.playability_status &&
game.playability_status !== "unknown" && (
<div className='absolute bottom-1.5 left-1.5 right-1.5'>
<PlayabilityBadge
status={game.playability_status as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null}
compact
showLabel
className='text-[10px] px-1.5 py-0.5 w-full justify-center'
/>
</div>
)}
</div>
<div className='p-2.5 space-y-1.5'>
<h3 className='text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors'>
{game.title}
</h3>
{/* Performance badges */}
{game.avg_fps !== undefined && game.avg_fps !== null && (
<div className='flex flex-wrap gap-1'>
{game.avg_fps >= 60 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold leading-tight'>
RAW PERFORMER
</span>
) : game.avg_fps < 30 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold leading-tight'>
POOR PERFORMANCE
</span>
) : null}
<span className='inline-flex items-center gap-0.5 text-[9px] text-text/50 font-medium'>
{Math.round(game.avg_fps)}fps
</span>
</div>
)}
<p
className={`text-[10px] ${accentColor} font-medium`}
>
{formatStat(
(
game as unknown as Record<
string,
unknown
>
)[statKey],
)}
</p>
</div>
</motion.div>
))}
</div>
</div>
</motion.section>
)
}
function SaleSection({
games,
}: {
games: SaleGameCard[]
}) {
const router = useRouter()
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
>
<div className='flex items-center gap-2 mb-4'>
<div className='border-l-2 border-green-500 pl-3'>
<div className='flex items-center gap-2'>
<span className="text-green-400 text-sm">💰</span>
<h2 className='text-sm font-semibold text-text/80'>
On Sale & Performing Well
</h2>
</div>
</div>
</div>
<div className='overflow-x-auto overflow-y-hidden -mx-1 px-1' style={{ pointerEvents: 'none' }}>
<div className='flex gap-3 pb-2 px-1' style={{ pointerEvents: 'auto' }}>
{games.map((game, idx) => {
const discountPct = game.price_initial && game.price_current
? Math.round((1 - game.price_current / game.price_initial) * 100)
: 0
return (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
className='group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/6 transition-colors cursor-pointer overflow-hidden'
onClick={() => router.push(`/game/${game.id}?sync=1`)}
>
<div className='relative aspect-2/3 bg-text/10 overflow-hidden'>
{game.capsule_image ? (
<Image
src={game.capsule_image}
alt={game.title}
fill
className='object-cover rounded-xl group-hover:scale-[0.97] transition-transform duration-300'
sizes='(max-width: 640px) 144px, 176px'
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/15' />
</div>
)}
{/* Discount badge */}
{discountPct > 0 && (
<div className='absolute top-2 right-2 px-1.5 py-0.5 rounded bg-green-500 text-white text-[10px] font-bold'>
-{discountPct}%
</div>
)}
{game.playability_status &&
game.playability_status !== "unknown" && (
<div className='absolute bottom-1.5 left-1.5 right-1.5'>
<PlayabilityBadge
status={game.playability_status as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null}
compact
showLabel
className='text-[10px] px-1.5 py-0.5 w-full justify-center'
/>
</div>
)}
</div>
<div className='p-2.5 space-y-1.5'>
<h3 className='text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors'>
{game.title}
</h3>
{/* Pricing */}
<div className='flex items-center gap-1.5'>
{game.price_current !== undefined && (
<span className='text-xs font-bold text-green-400'>
{game.price_currency === "USD" ? "$" : ""}{(game.price_current / 100).toFixed(2)}
</span>
)}
{game.price_initial !== undefined && game.price_initial > (game.price_current ?? 0) && (
<span className='text-[10px] text-text/30 line-through'>
{(game.price_initial / 100).toFixed(2)}
</span>
)}
</div>
{/* Performance badges */}
{game.best_fps !== undefined && game.best_fps !== null && (
<div className='flex flex-wrap gap-1'>
{game.best_fps >= 60 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'>
RAW PERFORMER
</span>
) : game.best_fps < 30 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'>
POOR PERFORMANCE
</span>
) : null}
<span className='inline-flex items-center gap-0.5 text-[9px] text-text/50 font-medium'>
{Math.round(game.best_fps)}fps
</span>
</div>
)}
{game.steam_review_score !== undefined && game.steam_review_score !== null && (
<p className='text-[10px] text-blue-400 font-medium'>
{game.steam_review_score}% positive
</p>
)}
</div>
</motion.div>
)
})}
</div>
</div>
{/* Disclaimer */}
<p className='text-[10px] text-text/20 mt-1 text-right'>
Prices may vary. Data refreshes weekly.
</p>
</motion.section>
)
}
export default function Landing() {
const router = useRouter()
const words = ["benchmarks", "settings", "reviews"]
const [currentWord, setCurrentWord] = useState(0)
const [searchQuery, setSearchQuery] = useState("")
// Landing section state
const [sections, setSections] = useState<SectionData>({
recentBenchmarks: [],
trending: [],
mostTested: [],
onSale: [],
})
const [sectionsLoading, setSectionsLoading] = useState(true)
// Animated words cycle — use useEffect with proper cleanup
useEffect(() => {
const interval = setInterval(() => {
setCurrentWord((prev) => (prev + 1) % words.length)
}, 2000)
return () => clearInterval(interval)
}, [words.length])
// Fetch all 4 sections in parallel on mount
useEffect(() => {
let cancelled = false
async function fetchSections() {
try {
const [recentBenchmarks, trending, mostTested, onSale] =
await Promise.all([
fetch("/api/dashboard/recent-benchmarks").then((r) =>
r.ok ? r.json() : [],
),
fetch("/api/dashboard/trending").then((r) =>
r.ok ? r.json() : [],
),
fetch("/api/dashboard/most-tested").then((r) =>
r.ok ? r.json() : [],
),
fetch("/api/dashboard/on-sale").then((r) =>
r.ok ? r.json() : [],
),
])
if (!cancelled) {
setSections({
recentBenchmarks: Array.isArray(recentBenchmarks) ? recentBenchmarks : [],
trending: Array.isArray(trending) ? trending : [],
mostTested: Array.isArray(mostTested) ? mostTested : [],
onSale: Array.isArray(onSale) ? onSale : [],
})
}
} catch {
// Silently fail — sections are best-effort
} finally {
if (!cancelled) setSectionsLoading(false)
}
}
fetchSections()
return () => {
cancelled = true
}
}, [])
const handleSearchSubmit = () => {
if (searchQuery.trim()) {
sessionStorage.setItem("focusSearch", "true")
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearchSubmit()
}
}
return (
<>
{/* ── Hero Section ── */}
<section
id='hero'
className='w-full min-h-[70svh] mt-[10svh] flex flex-col items-center justify-center relative p-4'
>
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='font-bold text-3xl md:text-5xl text-center flex flex-row gap-2 items-center justify-center'
>
DeckyVault
<span className='border border-border text-xs md:text-base px-2 py-1 rounded-md bg-primary/10 font-medium'>
beta
</span>
</motion.h1>
<motion.h2
layout
initial={{
opacity: 0,
}}
animate={{ opacity: 0.8, transition: { delay: 0.5 } }}
className='mt-4 flex flex-row flex-wrap items-center justify-center gap-x-1 md:gap-x-2 text-base md:text-xl'
>
{"Find your game".split(" ").map((word, index) => (
<motion.span
key={index}
className='text-center'
>
{word}
</motion.span>
))}
<AnimatePresence
mode='wait'
initial={false}
>
<motion.span
key={currentWord}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className='text-primary font-bold'
>
{words[currentWord]}
</motion.span>
</AnimatePresence>
</motion.h2>
<AnimatePresence>
<motion.label
key='search-bar'
layoutId='search-bar'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='mt-8 flex flex-row items-center gap-4 bg-text/5 px-2 py-2 rounded-md border border-border placeholder:text-text/60 group hover:border-border-active transition-colors focus-within:border-primary/80! focus-within:ring-2 focus-within:ring-primary/50! focus-within:ring-offset-2 focus-within:ring-offset-background cursor-text w-full max-w-md'
>
<Gamepad2Icon className='h-6 w-6 group-focus-within:stroke-accent transition-colors shrink-0' />
<input
type='text'
placeholder='search by game or appid...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className='flex-1 outline-none bg-transparent text-xl min-w-0'
/>
<motion.button
onClick={handleSearchSubmit}
whileTap={{ scale: 0.95 }}
className='text-sm flex flex-row gap-1 items-center bg-text text-background px-2 py-1 rounded-sm cursor-pointer hover:opacity-60 transition-opacity shrink-0'
>
<SearchIcon className='h-3 w-3' />
search
</motion.button>
</motion.label>
</AnimatePresence>
<motion.small
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 1.5 } }}
className='mt-8 text-center text-xs flex flex-row gap-1'
>
<Link
title='Visit our Github Repository'
href='/updates'
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
>
See what{"'"}s new.
</Link>
</motion.small>
<motion.small
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 1.5 } }}
className='mt-2 text-center text-xs flex flex-row gap-1'
>
<span className='opacity-60'>2026 DeckyVault.</span>
<Link
title='Visit our Github Repository'
href='https://github.com/AdrianBonpin/deckyvault'
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
>
Github.
</Link>
<span className='opacity-60'>
v{process.env.NEXT_PUBLIC_APP_VERSION}
</span>
</motion.small>
</section>
{/* ── Landing Sections ── */}
<div className='w-full max-w-7xl mx-auto px-4 pb-12 space-y-10'>
{sectionsLoading ? (
<SkeletonSections />
) : (
<>
{sections.recentBenchmarks.length > 0 && (
<GameSection
title='Recently Added Benchmarks'
icon={ClockIcon}
games={sections.recentBenchmarks}
statKey='benchmark_count'
statLabel='benchmarks'
accentColor='text-violet-400'
/>
)}
{sections.trending.length > 0 && (
<GameSection
title='Trending This Week'
icon={TrendingUpIcon}
games={sections.trending}
statKey='benchmark_count'
statLabel='benchmarks this week'
accentColor='text-orange-400'
/>
)}
{sections.mostTested.length > 0 && (
<GameSection
title='Most Tested Games'
icon={GaugeIcon}
games={sections.mostTested}
statKey='benchmark_count'
statLabel='benchmarks'
accentColor='text-blue-400'
/>
)}
{sections.onSale.length > 0 && (
<SaleSection games={sections.onSale} />
)}
</>
)}
</div>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "DeckyVault",
url: "https://deckyvault.xyz",
description:
"Steam Deck benchmarks, settings, and performance guides",
potentialAction: {
"@type": "SearchAction",
target: "https://deckyvault.xyz/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
}),
}}
/>
{/* Organization structured data */}
<script
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "DeckyVault",
url: "https://deckyvault.xyz",
logo: "https://deckyvault.xyz/icon.png",
sameAs: [
"https://github.com/AdrianBonpin/deckyvault",
],
}),
}}
/>
</>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { notFound } from "next/navigation"
import { db } from "@/lib/db/index"
import { user, performanceEntries, games, gameVersions, hardware } from "@/lib/db/schema"
import { eq, sql, and, desc } from "drizzle-orm"
import { ProfilePageClient } from "./profile-page-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata = {
title: "Profile",
}
export default async function ProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const [profile] = await db
.select({
id: user.id,
name: user.name,
image: user.image,
role: user.role,
createdAt: user.createdAt,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, id))
.limit(1)
if (!profile) {
notFound()
}
// Count contributions
const [{ count: contributions }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false)
))
// Count verified entries
const [{ count: verifiedEntries }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.verifiedAt} IS NOT NULL`
))
// Calculate reputation (upvotes - downvotes)
const reputationResult = await db
.select({
totalUpvotes: sql<number>`coalesce(sum(${performanceEntries.upvotes}), 0)::int`,
totalDownvotes: sql<number>`coalesce(sum(${performanceEntries.downvotes}), 0)::int`,
})
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false),
))
// Fetch recent contributions (last 5)
const recentContributions = await db
.select({
id: performanceEntries.id,
fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh,
hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name,
upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod,
verifiedAt: performanceEntries.verifiedAt,
createdAt: performanceEntries.createdAt,
gameTitle: games.title,
gameId: games.id,
gameHeaderImage: games.headerImage,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false)
))
.orderBy(desc(performanceEntries.createdAt))
.limit(5)
return (
<ProfilePageClient
profile={{
...profile,
createdAt: profile.createdAt.toISOString(),
contributions,
verifiedEntries,
reputation: Math.max(0,
(reputationResult[0]?.totalUpvotes ?? 0) -
(reputationResult[0]?.totalDownvotes ?? 0)
),
verified: !!profile.emailVerified,
}}
recentContributions={recentContributions.map((e) => ({
...e,
createdAt: e.createdAt.toISOString(),
verifiedAt: e.verifiedAt?.toISOString() ?? null,
}))}
/>
)
}
@@ -0,0 +1,60 @@
"use client"
import { ProfileHeader } from "@/components/profile/profile-header"
import { StatsRow } from "@/components/profile/stats-row"
import { ContributionList } from "@/components/profile/contribution-list"
import type { ContributionEntry } from "@/types/api"
interface ProfilePageClientProps {
profile: {
id: string
name: string
image: string | null
role: string | null
createdAt: string
contributions: number
verifiedEntries: number
reputation: number
verified: boolean
}
recentContributions: ContributionEntry[]
}
export function ProfilePageClient({ profile, recentContributions }: ProfilePageClientProps) {
return (
<div className="w-full flex flex-col gap-8 pb-16">
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<ProfileHeader
name={profile.name}
role={profile.role}
verified={profile.verified}
createdAt={profile.createdAt}
image={profile.image}
/>
</div>
</div>
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<StatsRow
contributions={profile.contributions}
verifiedEntries={profile.verifiedEntries}
reputation={profile.reputation}
/>
</div>
</div>
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<h2 className="text-lg font-semibold mb-4">Recent Contributions</h2>
<ContributionList
entries={recentContributions}
showViewAll
totalCount={profile.contributions}
/>
</div>
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Profile",
description:
"Your DeckyVault profile — saved games, benchmark contributions, and performance history.",
robots: { index: false, follow: false },
}
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return children
}
+193
View File
@@ -0,0 +1,193 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "@/lib/auth-client"
import { ProfileHeader } from "@/components/profile/profile-header"
import { StatsRow } from "@/components/profile/stats-row"
import { ContributionList } from "@/components/profile/contribution-list"
import { SavedGamesGrid } from "@/components/saved-games/saved-games-grid"
import { Bookmark, Settings, Loader2, TrendingUp } from "lucide-react"
import { SettingsContainer } from "@/components/profile/settings-container"
import { motion } from "motion/react"
import type { ContributionEntry } from "@/types/api"
type Tab = "overview" | "saved" | "settings"
export default function ProfilePage() {
const router = useRouter()
const { data: session, isPending: isSessionLoading } = useSession()
const [activeTab, setActiveTab] = useState<Tab>("overview")
const [profile, setProfile] = useState<{
id: string
name: string
email: string
image: string | null
role: string | null
createdAt: string
contributions: number
verifiedEntries: number
reputation: number
verified: boolean
} | null>(null)
const [contributions, setContributions] = useState<ContributionEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!isSessionLoading && !session) {
router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`)
}
}, [session, isSessionLoading, router])
useEffect(() => {
if (!session) return
async function fetchProfile() {
try {
const [profileRes, contribRes] = await Promise.all([
fetch("/api/user/me"),
fetch(
"/api/user/profile/" +
session!.user.id +
"/contributions?limit=10",
),
])
if (profileRes.ok) {
setProfile(await profileRes.json())
}
if (contribRes.ok) {
const data = await contribRes.json()
setContributions(data.data)
}
} catch (err) {
console.error("Failed to fetch profile:", err)
} finally {
setIsLoading(false)
}
}
fetchProfile()
}, [session])
if (isSessionLoading || isLoading) {
return (
<div className='flex items-center justify-center min-h-[50vh]'>
<Loader2 className='h-8 w-8 animate-spin text-primary' />
</div>
)
}
if (!session || !profile) {
return null
}
const handleImageChange = (url: string | null) => {
setProfile((prev) => (prev ? { ...prev, image: url } : null))
}
const tabs: { id: Tab; label: string; icon: typeof Bookmark }[] = [
{ id: "overview", label: "Overview", icon: TrendingUp },
{ id: "saved", label: "Saved Games", icon: Bookmark },
{ id: "settings", label: "Settings", icon: Settings },
]
return (
<div className='w-full flex flex-col gap-8 pt-4'>
{/* Profile header section */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<ProfileHeader
name={profile.name}
email={profile.email}
role={profile.role}
verified={profile.verified}
createdAt={profile.createdAt}
image={profile.image}
/>
</div>
</motion.div>
{/* Stats section */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
<StatsRow
contributions={profile.contributions}
verifiedEntries={profile.verifiedEntries}
reputation={profile.reputation}
/>
</div>
</motion.div>
{/* Tabs + content section */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
{/* Tabs */}
<div className='flex gap-1 border-b border-border'>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px cursor-pointer ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-text/50 hover:text-text/70"
}`}
>
<tab.icon className='h-4 w-4' />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className='pt-6'
>
{activeTab === "overview" && (
<div>
<h2 className='text-lg font-semibold mb-4'>
Recent Contributions
</h2>
<ContributionList entries={contributions} />
</div>
)}
{activeTab === "saved" && <SavedGamesGrid />}
{activeTab === "settings" && profile && (
<SettingsContainer
name={profile.name}
email={profile.email}
role={profile.role}
createdAt={profile.createdAt}
image={profile.image}
userId={profile.id}
onImageChange={handleImageChange}
/>
)}
</motion.div>
</div>
</motion.div>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/manage", "/api"],
},
],
sitemap: "https://deckyvault.xyz/sitemap.xml",
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Search Games",
description: "Search for games and find benchmarks, settings, and performance data on DeckyVault.",
alternates: { canonical: "https://deckyvault.xyz/search" },
robots: { index: false },
}
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return children
}
+969
View File
@@ -0,0 +1,969 @@
"use client"
import { Suspense, useState, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import {
ExternalLinkIcon,
Gamepad2Icon,
MessageSquareIcon,
SettingsIcon,
TrendingUpIcon,
DatabaseIcon,
SparklesIcon,
SlidersHorizontalIcon,
XIcon,
} from "lucide-react"
import { FaSteam } from "react-icons/fa"
import Image from "next/image"
import Link from "next/link"
import { useSession } from "@/lib/auth-client"
import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons"
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
interface UnifiedResult {
kind: "local" | "steam"
id?: string
appId: number | null
title: string
image: string | null
developer: string | null
publisher: string | null
description: string | null
genres: string[] | null
source: string
counts: { benchmarks: number; presets: number; comments: number } | null
platformSupport: {
isSupported: boolean
protonStatus: string
antiCheatRelevant: boolean
antiCheatName: string | null
antiCheatStatus: string
} | null
metascore?: string | null
price?: { currency: string; initial: number; final: number } | null
platforms?: { windows: boolean; mac: boolean; linux: boolean } | null
controllerSupport?: string | null
isRawPerformer?: boolean
isPoorPerformance?: boolean
bestFps?: number | null
estimatedBatteryMin?: number | null
latestVersion?: string | null
tinyImage?: string | null
// Badges & review fields
playabilityStatus?: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null
steamReviewScore?: number | null
steamReviewSentiment?: string | null
antiCheatRelevant?: boolean
antiCheatStatus?: string | null
antiCheatName?: string | null
}
const DEVICE_OPTIONS = [
{ slug: "steam-deck-oled", name: "Steam Deck OLED" },
{ slug: "steam-deck-lcd", name: 'Steam Deck LCD' },
{ slug: "rog-ally", name: "ROG Ally" },
]
function SearchContent() {
const searchParams = useSearchParams()
const router = useRouter()
const { data: session } = useSession()
const query = searchParams.get("q") || ""
const [results, setResults] = useState<UnifiedResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const isValidQuery = query && query.length >= 2
// Filter state
const [showFilters, setShowFilters] = useState(false)
const [selectedDevice, setSelectedDevice] = useState("")
const [minFps, setMinFps] = useState("")
const [maxFps, setMaxFps] = useState("")
const [fsrSupport, setFsrSupport] = useState(false)
const [protonNative, setProtonNative] = useState("any")
const [antiCheatStatus, setAntiCheatStatus] = useState("any")
const [playabilityStatus, setPlayabilityStatus] = useState("")
const [steamReviewMin, setSteamReviewMin] = useState("")
const [isFree, setIsFree] = useState(false)
const [hasMultiplayer, setHasMultiplayer] = useState(false)
const hasActiveFilters =
selectedDevice ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
const activeFilterCount =
(selectedDevice ? 1 : 0) +
(minFps ? 1 : 0) +
(maxFps ? 1 : 0) +
(fsrSupport ? 1 : 0) +
(protonNative !== "any" ? 1 : 0) +
(antiCheatStatus !== "any" ? 1 : 0) +
(playabilityStatus ? 1 : 0) +
(steamReviewMin ? 1 : 0) +
(isFree ? 1 : 0) +
(hasMultiplayer ? 1 : 0)
// Handle direct navigation / browser back-forward
useEffect(() => {
if (!isValidQuery) return
let cancelled = false
async function fetchResults() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set("q", query)
if (selectedDevice) params.set("device", selectedDevice)
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus) params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
const res = await fetch(`/api/search/unified?${params.toString()}`)
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
if (!cancelled) setResults(data.results || [])
} catch (err) {
if (!cancelled) {
setError("Failed to fetch search results")
console.error(err)
}
} finally {
if (!cancelled) setLoading(false)
}
}
fetchResults()
return () => {
cancelled = true
}
}, [isValidQuery, query, selectedDevice, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer])
function handleClick(result: UnifiedResult) {
const path = result.appId
? `/game/${result.appId}?sync=1`
: `/game/${result.id}?sync=1`
router.push(path)
}
function clearAllFilters() {
setSelectedDevice("")
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}
return (
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
<div className="w-full max-w-7xl">
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-2xl font-light mb-2"
>
{query ? `Results for "${query}"` : "Search using game name or AppID"}
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className="text-text/60 text-sm mb-4"
>
{query
? `${results.length} result${results.length !== 1 ? "s" : ""} found`
: "Enter a game name or AppID to find benchmarks, settings, and reviews."}
</motion.p>
{/* Filter toggle button */}
<div className="flex items-center gap-3 mb-4">
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-[44px] ${
showFilters || hasActiveFilters
? "bg-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`}
>
<SlidersHorizontalIcon className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold">
{activeFilterCount}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-[44px] flex items-center gap-1"
>
<XIcon className="h-3 w-3" />
Clear all filters
</button>
)}
</div>
{/* Filter panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden mb-4"
>
<div className="flex flex-col gap-4 p-4 rounded-xl border border-border bg-text/[0.03]">
{/* Device filter */}
<div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Device
</span>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setSelectedDevice("")}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
All Devices
</button>
{DEVICE_OPTIONS.map((device) => (
<button
key={device.slug}
onClick={() =>
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug)
}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{device.name}
</button>
))}
</div>
</div>
{/* Performance Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Performance</h4>
<div className="flex gap-2">
<input
type="number"
placeholder="Min FPS"
value={minFps}
onChange={(e) => setMinFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
<input
type="number"
placeholder="Max FPS"
value={maxFps}
onChange={(e) => setMaxFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
</div>
<select
value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="">Any Playability</option>
<option value="great">Plays Great</option>
<option value="playable">Playable</option>
<option value="needs_tweaks">Needs Tweaks</option>
<option value="unplayable">Unplayable</option>
</select>
</div>
{/* Compatibility Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Compatibility</h4>
<select
value={protonNative}
onChange={(e) => setProtonNative(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Runtime</option>
<option value="native">Native</option>
<option value="proton">Proton</option>
</select>
<select
value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Anti-Cheat</option>
<option value="supported">AC Supported</option>
<option value="unsupported">AC Unsupported</option>
<option value="unknown">AC Unknown</option>
</select>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={fsrSupport}
onChange={(e) => setFsrSupport(e.target.checked)}
className="rounded border-zinc-600"
/>
FSR Support
</label>
</div>
{/* Other Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Other</h4>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={isFree}
onChange={(e) => setIsFree(e.target.checked)}
className="rounded border-zinc-600"
/>
Free to Play
</label>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={hasMultiplayer}
onChange={(e) => setHasMultiplayer(e.target.checked)}
className="rounded border-zinc-600"
/>
Has Multiplayer
</label>
<input
type="number"
placeholder="Min Steam Review %"
value={steamReviewMin}
onChange={(e) => setSteamReviewMin(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
/>
</div>
{/* Saved Filters */}
<SavedFilters
currentFilters={{
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
device: selectedDevice,
}}
onLoad={(filters) => {
setMinFps((filters.minFps as string) || "")
setMaxFps((filters.maxFps as string) || "")
setFsrSupport((filters.fsrSupport as boolean) || false)
setProtonNative((filters.protonNative as string) || "any")
setAntiCheatStatus((filters.antiCheatStatus as string) || "any")
setPlayabilityStatus((filters.playabilityStatus as string) || "")
setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer((filters.hasMultiplayer as boolean) || false)
if (filters.device) setSelectedDevice(filters.device as string)
else setSelectedDevice("")
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{!isValidQuery && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-20 gap-4"
>
<Gamepad2Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">
Start typing to search for games
</p>
</motion.div>
)}
{isValidQuery && loading && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full"
/>
<p className="text-text/60 text-sm">
Searching for &quot;{query}&quot;...
</p>
</div>
)}
{isValidQuery && !loading && error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-20 gap-4"
>
<p className="text-red-400 text-sm">{error}</p>
</motion.div>
)}
{isValidQuery && !loading && !error && results.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-20 gap-4"
>
<Gamepad2Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">
No results found for &quot;{query}&quot;
</p>
</motion.div>
)}
{isValidQuery && !loading && !error && results.length > 0 && (
<motion.div
className="flex flex-col gap-3"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.04 } },
}}
>
<AnimatePresence mode="popLayout">
{results.map((result, idx) => (
<SearchResultCard
key={
result.kind === "local"
? result.id
: `steam-${result.appId}-${idx}`
}
result={result}
onClick={handleClick}
index={idx}
/>
))}
</AnimatePresence>
</motion.div>
)}
{session?.user && (
<div className="mt-6 text-center">
<p className="text-sm text-text/50">
Can&apos;t find your game?{" "}
<Link href="/game/add" className="text-primary hover:underline cursor-pointer">
Add it manually
</Link>
</p>
</div>
)}
</div>
</section>
)
}
function SearchResultCard({
result,
onClick,
index,
}: {
result: UnifiedResult
onClick: (r: UnifiedResult) => void
index: number
}) {
const isLocal = result.kind === "local"
const counts = result.counts
const hasData =
isLocal &&
counts &&
(counts.benchmarks > 0 || counts.presets > 0 || counts.comments > 0)
return (
<motion.article
layout
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: 0.3, delay: index * 0.02 }}
whileHover={{ scale: 1.005 }}
whileTap={{ scale: 0.995 }}
className="group relative bg-text/3 border border-border rounded-xl p-4 sm:p-5 transition-colors duration-200 hover:border-text/30 hover:bg-text/[0.06] cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-text/20 focus-within:ring-offset-2 focus-within:ring-offset-background"
title={isLocal
? "Click to view game details, benchmarks, and settings"
: "Click to add this game to DeckyVault and view its page"
}
>
{/* Full-card click target */}
<div
className="absolute inset-0 after:content-[''] after:absolute after:inset-0"
onClick={() => onClick(result)}
/>
<div className="flex gap-4 sm:gap-5">
{/* Cover */}
<motion.div
className="relative w-20 sm:w-24 md:w-28 shrink-0 aspect-[2/3] rounded-lg overflow-hidden bg-text/10 shadow-sm"
whileHover={{ scale: 1.03 }}
transition={{ duration: 0.2 }}
>
<GameCover image={result.image} tinyImage={result.tinyImage} title={result.title} />
</motion.div>
{/* Main Content */}
<div className="flex flex-col min-w-0 flex-1">
{/* Row 1: Title + metascore/price row */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex items-center gap-2 flex-wrap">
<h3 className="text-sm sm:text-base font-semibold text-text group-hover:text-primary transition-colors duration-200 truncate">
{result.title}
</h3>
{!isLocal && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-text/5 border border-border text-text/35 capitalize shrink-0">
Steam
</span>
)}
{isLocal && result.source !== "steam" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-amber-400 capitalize shrink-0">
{result.source}
</span>
)}
{result.isRawPerformer && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[10px] font-semibold shrink-0">
RAW PERFORMER
</span>
)}
{result.isPoorPerformance && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-semibold shrink-0">
POOR PERFORMANCE
</span>
)}
{result.estimatedBatteryMin != null && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] font-semibold shrink-0">
🔋 ~{Math.round(result.estimatedBatteryMin / 60)}h
</span>
)}
</div>
{(result.developer || result.publisher) && (
<p className="text-[11px] text-text/45 mt-0.5 truncate">
{result.developer}
{result.developer && result.publisher ? " · " : ""}
{result.publisher}
</p>
)}
</div>
{/* Metascore + Price row */}
<div className="hidden sm:flex items-center gap-2 shrink-0">
{result.metascore ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-500/10 border border-green-500/20 text-green-400 text-[11px] font-semibold"
title={`Metascore: ${result.metascore}/100`}
>
<TrendingUpIcon className="h-3 w-3" />
{result.metascore}
</span>
) : null}
<PriceTag price={result.price} />
</div>
</div>
{/* Row 2: Description */}
{result.description && (
<p className="text-[11px] text-text/40 line-clamp-2 leading-relaxed mt-1.5">
{result.description}
</p>
)}
{/* Row 3: Genre tags + Platform icons + Controller */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2">
{result.genres && result.genres.length > 0 && (
<div className="flex flex-wrap gap-1">
{result.genres.slice(0, 3).map((genre) => (
<span
key={genre}
className="text-[10px] px-1.5 py-0.5 rounded bg-text/5 border border-border text-text/35"
>
{genre}
</span>
))}
{result.genres.length > 3 && (
<span className="text-[10px] text-text/25 self-center">
+{result.genres.length - 3}
</span>
)}
</div>
)}
{/* Platform icons — always show all 3, color if present */}
<div className="flex items-center gap-1.5">
<span title="Windows">
<WindowsIcon
className={`h-3.5 w-3.5 ${result.platforms?.windows ? "text-blue-400" : "text-text/20"}`}
/>
</span>
<span title="macOS">
<MacIcon
className={`h-3.5 w-3.5 ${result.platforms?.mac ? "text-text/60" : "text-text/20"}`}
/>
</span>
<span title="Linux">
<LinuxIcon
className={`h-3.5 w-3.5 ${result.platforms?.linux ? "text-yellow-500" : "text-text/20"}`}
/>
</span>
</div>
{result.controllerSupport && (
<span
className="text-[10px] text-text/25 flex items-center gap-1"
title="Full controller support"
>
<Gamepad2Icon className="h-3 w-3" />
Controller
</span>
)}
</div>
{/* Row 4: Stats row */}
<div className="flex flex-wrap items-center gap-3 mt-2.5">
{hasData ? (
<>
{counts!.benchmarks > 0 && (
<StatBadge
icon={TrendingUpIcon}
count={counts!.benchmarks}
label="Benchmarks"
/>
)}
{counts!.presets > 0 && (
<StatBadge
icon={SettingsIcon}
count={counts!.presets}
label="Presets"
/>
)}
{counts!.comments > 0 && (
<StatBadge
icon={MessageSquareIcon}
count={counts!.comments}
label="Comments"
/>
)}
</>
) : isLocal ? (
<span className="text-[11px] text-text/30 italic">
No data yet be the first to contribute
</span>
) : (
<div className="flex flex-col items-start gap-1.5 w-full sm:w-auto">
<a
href={`https://store.steampowered.com/app/${result.appId}`}
target="_blank"
rel="noopener noreferrer"
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-[#1b2838] border border-[#2a475e] text-[#c6d4df] text-[11px] hover:bg-[#2a475e] transition-colors w-full cursor-pointer"
onClick={(e) => e.stopPropagation()}
title="Open store page on Steam"
>
<FaSteam className="h-3 w-3" />
Steam
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
</a>
<a
href={`https://www.protondb.com/app/${result.appId}`}
target="_blank"
rel="noopener noreferrer"
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-purple-500/10 border border-purple-500/20 text-purple-400 text-[11px] hover:bg-purple-500/20 transition-colors w-full cursor-pointer"
onClick={(e) => e.stopPropagation()}
title="View Proton compatibility on ProtonDB"
>
<SparklesIcon className="h-3 w-3" />
ProtonDB
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
</a>
<a
href={`https://steamdb.info/app/${result.appId}`}
target="_blank"
rel="noopener noreferrer"
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] hover:bg-blue-500/20 transition-colors w-full cursor-pointer"
onClick={(e) => e.stopPropagation()}
title="View app details on SteamDB"
>
<DatabaseIcon className="h-3 w-3" />
SteamDB
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
</a>
</div>
)}
</div>
{/* Row 5: Anti-cheat info */}
{result.platformSupport?.antiCheatRelevant && (
<div className="mt-2">
<span className="text-[10px] text-text/30">
Anti-cheat: {result.platformSupport.antiCheatName || "Unknown"}
<span className="text-text/20">
{" "} {result.platformSupport.antiCheatStatus}
</span>
</span>
</div>
)}
</div>
{/* Right Panel — Desktop Only */}
<div className="hidden md:flex flex-col items-end justify-center gap-2.5 shrink-0 min-w-[120px]">
{/* Deck Status */}
<DataField
label="Deck"
value={result.platformSupport ? protonLabel(result.platformSupport.protonStatus) : "—"}
color={result.platformSupport ? protonColor(result.platformSupport.protonStatus) : undefined}
/>
{/* Best FPS */}
<DataField
label="Best FPS"
value={result.bestFps != null ? String(Math.round(result.bestFps)) : "—"}
bar={result.bestFps != null}
color={result.bestFps != null && result.bestFps >= 60 ? "text-green-400" : undefined}
/>
{/* Battery Estimate */}
{result.estimatedBatteryMin != null && (
<DataField
label="Battery"
value={`~${Math.round(result.estimatedBatteryMin / 60)}h`}
/>
)}
{/* Version */}
<DataField
label="Version"
value={result.latestVersion ?? "—"}
/>
{/* Playability badge */}
{result.playabilityStatus && (
<PlayabilityBadge status={result.playabilityStatus} compact />
)}
{/* Anti-cheat badge */}
{result.antiCheatRelevant && result.antiCheatStatus === "unsupported" && (
<AntiCheatBadge
antiCheatRelevant={true}
antiCheatStatus={result.antiCheatStatus}
antiCheatName={result.antiCheatName}
compact
/>
)}
{/* Steam review score */}
{result.steamReviewScore != null && (
<span className="inline-flex items-center gap-1 text-xs text-blue-400">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{result.steamReviewScore}% Positive
</span>
)}
</div>
</div>
</motion.article>
)
}
function DataField({
label,
value,
bar,
color,
}: {
label: string
value: string
bar?: boolean
color?: string
}) {
return (
<div className="flex flex-col items-end gap-1 w-full">
<span className="text-[10px] text-text/30 uppercase tracking-wider">
{label}
</span>
<div className="flex items-center gap-2 w-full justify-end">
{bar && (
<div className="w-14 h-1.5 rounded-full bg-text/5 overflow-hidden">
<div className="h-full w-0 rounded-full bg-primary/40" />
</div>
)}
<span className={`text-xs tabular-nums ${color || "text-text/25"}`}>
{value}
</span>
</div>
</div>
)
}
function protonLabel(status: string): string {
const map: Record<string, string> = {
native: "Native",
proton: "Proton",
unsupported: "Unsupported",
unknown: "Unknown",
}
return map[status] || status
}
function protonColor(status: string): string {
const map: Record<string, string> = {
native: "text-green-400",
proton: "text-blue-400",
unsupported: "text-red-400",
unknown: "text-text/25",
}
return map[status] || "text-text/25"
}
function GameCover({ image, tinyImage, title }: { image: string | null; tinyImage?: string | null; title: string }) {
const [src, setSrc] = useState(image)
const [fallbackStage, setFallbackStage] = useState(0)
const handleError = () => {
if (fallbackStage === 0 && tinyImage) {
setFallbackStage(1)
setSrc(tinyImage)
} else {
setFallbackStage(2)
setSrc(null)
}
}
if (src) {
return (
<Image
src={src}
alt={title}
fill
className="object-cover"
sizes="(max-width: 640px) 80px, 112px"
onError={handleError}
/>
)
}
return (
<div className="w-full h-full flex items-center justify-center">
<Gamepad2Icon className="h-6 w-6 text-text/20" />
</div>
)
}
function PriceTag({
price,
}: {
price?: { currency: string; initial: number; final: number } | null
}) {
if (!price) {
return null
}
// Price is 0 and initial is 0 → legitimately free-to-play
if (price.initial === 0 && price.final === 0) {
return (
<span className="px-2 py-0.5 rounded-md bg-green-500/10 border border-green-500/20 text-green-400 text-[11px] font-medium">
Free
</span>
)
}
// Price is 0 but initial > 0 → promotional free (free weekend etc.)
if (price.final === 0 && price.initial > 0) {
return (
<span
className="px-2 py-0.5 rounded-md bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[11px] font-medium"
title="Currently free — may be a promotional event"
>
Free*
</span>
)
}
// Normal paid game
const isDiscounted = price.final < price.initial
const fmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
})
return (
<span className="inline-flex items-center gap-1.5">
{isDiscounted && (
<span className="text-[10px] text-text/30 line-through">
{fmt.format(price.initial / 100)}
</span>
)}
<span
className="px-2 py-0.5 rounded-md bg-text/5 border border-border text-text/60 text-[11px] font-medium"
title={isDiscounted ? "Discounted price" : "Current price"}
>
{fmt.format(price.final / 100)}
</span>
</span>
)
}
function StatBadge({
icon: Icon,
count,
label,
}: {
icon: React.ElementType
count: number
label: string
}) {
return (
<span
className="inline-flex items-center gap-1 text-[11px] text-text/50"
title={`${count} ${label.toLowerCase()}`}
>
<Icon className="h-3 w-3 text-text/30" />
<span className="tabular-nums">{count}</span>
<span className="hidden sm:inline">{label}</span>
</span>
)
}
export default function SearchPage() {
return (
<Suspense>
<SearchContent />
</Suspense>
)
}
+212
View File
@@ -0,0 +1,212 @@
import type { MetadataRoute } from "next"
import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema"
import { or, ne, isNull } from "drizzle-orm"
import { getAllUpdates } from "@/lib/updates"
import {
getBaseUrl,
imageEntry,
toDate,
querySafe,
STATIC_PAGES,
} from "@/lib/sitemap-utils"
/** Revalidate sitemap every hour via ISR */
export const revalidate = 3600
/** Maximum entries per individual child sitemap */
const MAX_ENTRIES = 45_000
/** Threshold for game pagination — split into per-page child sitemaps */
const GAMES_PER_SITEMAP = 5_000
// ─── Sitemap Index Generator ────────────────────────────────────────
/**
* Returns the list of child sitemap IDs. Next.js auto-generates
* the sitemap index at /sitemap.xml from this.
*/
export async function generateSitemaps(): Promise<{ id: string }[]> {
const ids: { id: string }[] = [
{ id: "static" },
{ id: "devices" },
{ id: "updates" },
]
// Determine if games need pagination
try {
const countResult = await querySafe("game-count", () =>
db
.select({ count: games.id })
.from(games)
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
)
const count = Number(countResult?.[0]?.count ?? 0)
if (count > GAMES_PER_SITEMAP) {
const pages = Math.ceil(count / GAMES_PER_SITEMAP)
for (let i = 0; i < pages; i++) {
ids.push({ id: `games-${i}` })
}
} else {
ids.push({ id: "games" })
}
} catch {
// Fall back to single unpaginated games sitemap
ids.push({ id: "games" })
}
return ids
}
// ─── Child Sitemap Generator ────────────────────────────────────────
export default async function sitemap(props: {
id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
const id = await props.id
if (id === "static") {
return generateStaticSitemap()
}
if (id === "games" || id.startsWith("games-")) {
return generateGamesSitemap(id)
}
if (id === "devices") {
return generateDevicesSitemap()
}
if (id === "updates") {
return generateUpdatesSitemap()
}
// Unknown sitemap ID — return empty but valid
console.warn(`[Sitemap] Unknown child sitemap ID: "${id}"`)
return []
}
// ─── Individual Generators ──────────────────────────────────────────
/**
* Static pages sitemap — no DB dependency.
* Returns the core browse/utility pages with a fixed lastModified.
*/
async function generateStaticSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
// Use a constant "build date" — updated with each deploy
const buildDate = new Date()
return STATIC_PAGES.map((page) => ({
url: page.urlPath ? `${baseUrl}${page.urlPath}` : baseUrl,
lastModified: buildDate,
changeFrequency: page.changeFrequency,
priority: page.priority,
}))
}
/**
* Games child sitemap — DB-backed with ISR caching.
* Supports pagination: 'games' (unpaginated) or 'games-0', 'games-1', etc.
*/
async function generateGamesSitemap(
id: string,
): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
// Parse pagination: 'games-0' → page 0, 'games' (no suffix) → page 0
const pageMatch = id.match(/^games-(\d+)$/)
const page = pageMatch ? parseInt(pageMatch[1], 10) : 0
const offset = page * GAMES_PER_SITEMAP
const rows = await querySafe("games", () =>
db
.select({
id: games.id,
updatedAt: games.updatedAt,
capsuleImage: games.capsuleImage,
})
.from(games)
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
)
if (!rows) return []
const entries: MetadataRoute.Sitemap = []
for (const row of rows) {
if (entries.length >= MAX_ENTRIES) break
entries.push({
url: `${baseUrl}/game/${row.id}`,
lastModified: toDate(row.updatedAt),
changeFrequency: "weekly" as const,
priority: 0.8,
...imageEntry(row.capsuleImage),
})
}
// Apply pagination slice
const sliced = entries.slice(offset, offset + GAMES_PER_SITEMAP)
return sliced
}
/**
* Devices child sitemap — DB-backed with ISR caching.
*/
async function generateDevicesSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
const rows = await querySafe("hardware", () =>
db
.select({
slug: hardware.slug,
createdAt: hardware.createdAt,
})
.from(hardware),
)
if (!rows) return []
const entries: MetadataRoute.Sitemap = []
for (const row of rows) {
if (entries.length >= MAX_ENTRIES) break
entries.push({
url: `${baseUrl}/devices/${row.slug}`,
lastModified: toDate(row.createdAt),
changeFrequency: "monthly" as const,
priority: 0.6,
})
}
return entries
}
/**
* Updates child sitemap — filesystem-backed with ISR caching.
*/
async function generateUpdatesSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
let updates: ReturnType<typeof getAllUpdates>
try {
updates = getAllUpdates()
} catch (err) {
console.error("[Sitemap] Failed to load updates:", err)
return []
}
const entries: MetadataRoute.Sitemap = []
for (const update of updates) {
if (entries.length >= MAX_ENTRIES) break
entries.push({
url: `${baseUrl}/updates/${update.slug}`,
lastModified: toDate(update.date),
changeFrequency: "monthly" as const,
priority: 0.5,
})
}
return entries
}
+80
View File
@@ -0,0 +1,80 @@
import type { PrecacheEntry } from "@serwist/precaching";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "@serwist/strategies";
import { ExpirationPlugin } from "@serwist/expiration";
import { Serwist } from "serwist";
declare global {
interface Window {
__SW_MANIFEST: (string | PrecacheEntry)[];
}
}
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [
// Game detail pages: stale-while-revalidate, 24h max age
{
matcher: /\/game\/[\w-]+$/,
handler: new StaleWhileRevalidate({
cacheName: "game-pages",
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 }),
],
}),
},
// Game listing page: network-first (filters matter)
{
matcher: /\/games(\?.*)?$/,
handler: new NetworkFirst({
cacheName: "games-listing",
plugins: [
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 5 * 60 }),
],
}),
},
// Steam CDN images: cache-first, 30 days
{
matcher: /^https:\/\/cdn\.akamai\.steamstatic\.com\//,
handler: new CacheFirst({
cacheName: "steam-images",
plugins: [
new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
}),
},
// SteamGridDB images: cache-first, 30 days
{
matcher: /^https:\/\/cdn\d?\.steamgriddb\.com\//,
handler: new CacheFirst({
cacheName: "steamgrid-images",
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
}),
},
// API responses: network-first (live data critical)
{
matcher: /\/api\//,
handler: new NetworkFirst({
cacheName: "api-responses",
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 5 * 60 }),
],
}),
},
// Navigation fallback for HTML pages (not XML/JSON/etc.)
{
matcher: ({ request }) => request.mode === "navigate" && !request.url.endsWith(".xml"),
handler: new NetworkFirst({
cacheName: "navigation",
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }),
],
}),
},
],
});
serwist.addEventListeners();
+215
View File
@@ -0,0 +1,215 @@
"use client"
import { useState, useCallback } from "react"
import { RefreshCwIcon, CheckCircleIcon, XCircleIcon, AlertTriangleIcon } from "lucide-react"
interface StrategyResult {
strategy: string
versionString: string | null
buildId: string | null
success: boolean
error: string | null
}
export default function TestVersionFetchersPage() {
const [appId, setAppId] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<StrategyResult[] | null>(null)
const [best, setBest] = useState<StrategyResult | null>(null)
const [error, setError] = useState<string | null>(null)
const runTest = useCallback(async (id?: string) => {
const targetId = id ?? appId
if (!targetId.trim()) return
setLoading(true)
setError(null)
setResults(null)
setBest(null)
try {
// Use the standalone endpoint — no DB lookup needed
const testRes = await fetch(`/api/version-test?steamAppId=${encodeURIComponent(targetId)}`)
if (!testRes.ok) {
const errData = await testRes.json().catch(() => ({}))
setError(errData.error || `API error: ${testRes.status}`)
setLoading(false)
return
}
const testData = await testRes.json()
if (testData.error) {
setError(testData.error)
setLoading(false)
return
}
setResults(testData.results)
setBest(testData.best)
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error")
} finally {
setLoading(false)
}
}, [appId])
const handleQuickTest = useCallback((id: string) => {
setAppId(id)
runTest(id)
}, [runTest])
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-2">Version Fetcher Strategy Comparison</h1>
<p className="text-sm text-text/60 mb-8">
Enter a <strong>Steam App ID</strong> to test all version-fetching strategies.
The best result (priority: named version &gt; build ID) will be highlighted.
</p>
{/* Input */}
<div className="flex gap-3 mb-8">
<input
type="text"
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="Steam App ID (e.g., 730 for CS2)"
className="flex-1 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
onKeyDown={(e) => e.key === "Enter" && runTest()}
/>
<button
onClick={() => runTest()}
disabled={loading || !appId.trim()}
className="px-6 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<RefreshCwIcon className="h-4 w-4 animate-spin" />
) : (
"Test All"
)}
</button>
</div>
{/* Error */}
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 mb-8">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Results */}
{results && (
<div className="space-y-4">
{/* Best Result */}
{best ? (
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2 mb-2">
<CheckCircleIcon className="h-5 w-5 text-green-400" />
<h3 className="text-sm font-semibold text-green-400">
Best Result: {best.strategy}
</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-text/40">Version String</p>
<p className="text-lg font-mono text-text">
{best.versionString || "—"}
</p>
</div>
<div>
<p className="text-xs text-text/40">Build ID</p>
<p className="text-lg font-mono text-text">
{best.buildId || "—"}
</p>
</div>
</div>
</div>
) : (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-yellow-400" />
<p className="text-sm text-yellow-400">
No strategy found version data for this game.
</p>
</div>
</div>
)}
{/* All Strategy Results */}
<h3 className="text-sm font-semibold text-text mt-6 mb-3">
All Strategy Results
</h3>
<div className="space-y-2">
{results.map((r, i) => (
<div
key={i}
className={`p-3 rounded-lg border ${
r.success
? "border-green-500/20 bg-green-500/5"
: "border-red-500/10 bg-red-500/5"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{r.success ? (
<CheckCircleIcon className="h-4 w-4 text-green-400" />
) : (
<XCircleIcon className="h-4 w-4 text-red-400" />
)}
<span className="text-sm font-medium text-text">
{r.strategy}
</span>
</div>
{r.error && (
<span className="text-xs text-red-400">{r.error}</span>
)}
</div>
{r.success && (
<div className="grid grid-cols-2 gap-4 ml-6">
<div>
<span className="text-xs text-text/40">Version: </span>
<span className="text-sm font-mono text-text">
{r.versionString || "—"}
</span>
</div>
<div>
<span className="text-xs text-text/40">Build: </span>
<span className="text-sm font-mono text-text">
{r.buildId || "—"}
</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Quick Test Buttons */}
<div className="mt-8 p-4 rounded-lg border border-border bg-text/2">
<h3 className="text-xs font-semibold text-text/40 mb-3">
Quick Test (known Steam App IDs)
</h3>
<div className="flex flex-wrap gap-2">
{[
{ id: "730", label: "CS2" },
{ id: "440", label: "TF2" },
{ id: "570", label: "Dota 2" },
{ id: "271590", label: "GTA V" },
{ id: "1174180", label: "RDR2" },
{ id: "1086940", label: "BG3" },
{ id: "1245620", label: "Elden Ring" },
{ id: "292030", label: "Witcher 3" },
].map((g) => (
<button
key={g.id}
onClick={() => handleQuickTest(g.id)}
className="px-3 py-1.5 rounded-md border border-border bg-text/5 text-xs text-text/60 hover:text-text hover:border-primary/50 transition-colors"
>
{g.label} ({g.id})
</button>
))}
</div>
</div>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { ImageResponse } from "next/og"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
export const alt = "DeckyVault - Steam Deck Benchmarks & Settings"
export const size = {
width: 1200,
height: 630,
}
export const contentType = "image/png"
export default async function Image() {
const logoData = await readFile(
join(process.cwd(), "app/icon.png"),
"base64"
)
const logoSrc = `data:image/png;base64,${logoData}`
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#100b14",
color: "#ebe4f1",
fontFamily: "sans-serif",
gap: "16px",
}}
>
<img
src={logoSrc}
alt="DeckyVault"
height={120}
style={{ borderRadius: "16px" }}
/>
<div
style={{
fontSize: 64,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
DeckyVault
</div>
<div
style={{
fontSize: 28,
fontWeight: 400,
opacity: 0.8,
}}
>
Steam Deck Benchmarks & Settings
</div>
</div>
),
{
...size,
}
)
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import Link from "next/link"
import { ArrowLeftIcon } from "lucide-react"
import { getAllUpdateSlugs, getUpdateBySlug } from "@/lib/updates"
import { UpdateViewer } from "@/components/updates/update-viewer"
export async function generateStaticParams() {
const slugs = getAllUpdateSlugs()
return slugs.map((slug) => ({ slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
let update
try {
update = await getUpdateBySlug(slug)
} catch {
return { title: "Update Not Found" }
}
return {
title: `${update.meta.title} | DeckyVault`,
description: update.meta.summary,
}
}
export default async function UpdatePage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
let update
try {
update = await getUpdateBySlug(slug)
} catch {
notFound()
}
return (
<main className="w-full">
<div className="w-full max-w-7xl mx-auto px-4 pt-4">
<Link
href="/updates"
className="inline-flex items-center gap-1 text-sm text-text/60 hover:text-primary transition-colors"
>
<ArrowLeftIcon className="w-3 h-3" />
Back to updates
</Link>
</div>
<UpdateViewer update={update} />
</main>
)
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next"
import Link from "next/link"
import { getAllUpdates } from "@/lib/updates"
import { UpdateCard } from "@/components/updates/update-card"
import { ArrowLeftIcon } from "lucide-react"
export const metadata: Metadata = {
title: "Updates",
description:
"See what's new in DeckyVault — version release notes, features, and improvements.",
}
export default function UpdatesPage() {
const updates = getAllUpdates()
return (
<main className='w-full max-w-7xl mx-auto px-4 py-8'>
<div className='flex flex-row items-center gap-2 mb-6'>
<Link
href='/'
className='text-text/60 hover:text-primary transition-colors'
>
<ArrowLeftIcon className='w-4 h-4' />
</Link>
<h1 className='text-2xl font-bold'>Updates</h1>
</div>
<p className='text-text/60 mb-8'>
Release notes, changelogs, and the like for DeckyVault.
</p>
{updates.length === 0 ? (
<p className='text-text/40 text-center py-16'>
No updates yet.
</p>
) : (
<div className='flex flex-col gap-3'>
{updates.map((update) => (
<UpdateCard
key={update.slug}
update={update}
/>
))}
</div>
)}
</main>
)
}