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:
@@ -0,0 +1,5 @@
|
||||
import ForgotPasswordForm from "@/components/auth/forgot-password-form"
|
||||
|
||||
export default function ForgotPasswordPage() {
|
||||
return <ForgotPasswordForm />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react"
|
||||
import SignupWizard from "@/components/auth/signup-wizard"
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SignupWizard />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'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 }
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 A–Z" },
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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'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're looking for hasn'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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "{query}"...
|
||||
</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 "{query}"
|
||||
</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'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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 > 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user