feat: redesign device detail page with fixes and improvements

- Fix upscalerBreakdown/fsrBreakdown property name mismatch
- Fix deviceTypeLabel map (handled → handheld)
- Fix device color to use per-device colorIndex instead of hardcoded 0
- Add image/icon hero section with device color accent
- Add error state with retry button
- Add generateStaticParams with ISR (1-hour revalidation)
- Enhanced SEO metadata with keywords, OG, Twitter cards
- Fix JSON-LD to use 'Handheld' label instead of raw enum value
This commit is contained in:
2026-04-27 20:55:15 +08:00
parent eb944c2b3c
commit 060911e29d
2 changed files with 209 additions and 65 deletions
+132 -58
View File
@@ -2,15 +2,37 @@
import { useEffect, useMemo, useState } from "react" import { useEffect, useMemo, useState } from "react"
import Link from "next/link" import Link from "next/link"
import Image from "next/image"
import { motion } from "motion/react" import { motion } from "motion/react"
import { Gamepad2Icon, TrendingUpIcon, DatabaseIcon, CheckCircleIcon, ArrowRightIcon, Loader2 } from "lucide-react" import {
import { EChartWrapper, CHART_THEME, getDeviceColor } from "@/components/charts/EChartWrapper" 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" import type { EChartsOption } from "echarts"
interface DeviceInfo { interface DeviceInfo {
slug: string slug: string
name: string name: string
deviceType: string deviceType: string
image: string | null
colorIndex: number
}
interface UpscalerEntry {
upscalerType: string
count: number
avgFps: number
} }
interface DeviceStats { interface DeviceStats {
@@ -45,40 +67,46 @@ interface DeviceStats {
}> }>
genreBreakdown: Array<{ genre: string; count: number }> genreBreakdown: Array<{ genre: string; count: number }>
protonBreakdown: Array<{ version: string; count: number }> protonBreakdown: Array<{ version: string; count: number }>
fsrBreakdown: Array<{ version: string; count: number; avgFps: number }> upscalerBreakdown: UpscalerEntry[]
} }
const deviceTypeLabel: Record<string, string> = { const deviceTypeLabel: Record<string, string> = {
handled: "Handheld", handheld: "Handheld",
console: "Console", 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 }) { export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
const [stats, setStats] = useState<DeviceStats | null>(null) const [stats, setStats] = useState<DeviceStats | null>(null)
const [loading, setLoading] = useState(true) 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(() => { useEffect(() => {
let cancelled = false
async function fetchStats() {
setLoading(true)
try {
const res = await fetch(`/api/hardware/${device.slug}/stats`)
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
if (!cancelled) setStats(data)
} catch (err) {
console.error("Failed to fetch device stats:", err)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchStats() fetchStats()
return () => { cancelled = true }
}, [device.slug]) }, [device.slug])
const deviceColor = getDeviceColor(0)
// ── Chart Options ──────────────────────────────────────────
const historicalOption = useMemo<EChartsOption>(() => { const historicalOption = useMemo<EChartsOption>(() => {
if (!stats || stats.historical.length === 0) return {} if (!stats || stats.historical.length === 0) return {}
return { return {
@@ -164,7 +192,7 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
return { return {
tooltip: { tooltip: {
trigger: "item", trigger: "item",
backgroundColor: "#1a1020", backgroundColor: "#1a1025",
borderColor: CHART_THEME.border, borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text }, textStyle: { color: CHART_THEME.text },
}, },
@@ -176,12 +204,11 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
data: stats.genreBreakdown.map((g, i) => ({ data: stats.genreBreakdown.map((g, i) => ({
name: g.genre, name: g.genre,
value: g.count, value: g.count,
itemStyle: { color: CHART_THEME.deviceColors[i % CHART_THEME.deviceColors.length] }, itemStyle: {
color: CHART_THEME.deviceColors[i % CHART_THEME.deviceColors.length],
},
})), })),
label: { label: { color: CHART_THEME.textMuted, fontSize: 10 },
color: CHART_THEME.textMuted,
fontSize: 10,
},
emphasis: { emphasis: {
itemStyle: { shadowBlur: 10, shadowColor: "rgba(0,0,0,0.5)" }, itemStyle: { shadowBlur: 10, shadowColor: "rgba(0,0,0,0.5)" },
}, },
@@ -192,7 +219,7 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
return ( return (
<section className="w-full flex flex-col gap-8 pb-16"> <section className="w-full flex flex-col gap-8 pb-16">
{/* ── Hero Header ──────────────────────────────────── */} {/* Hero Header */}
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -200,21 +227,49 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
className="px-4 md:px-[10svw]" className="px-4 md:px-[10svw]"
> >
<div className="max-w-7xl mx-auto"> <div className="max-w-7xl mx-auto">
<div className="flex flex-wrap items-center gap-3 mb-2"> <div className="flex items-center gap-4 mb-2">
<h1 className="text-2xl sm:text-3xl font-bold">{device.name}</h1> <div
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-secondary/20 text-secondary border border-secondary/30 capitalize"> className="flex items-center justify-center h-14 w-14 rounded-xl shrink-0"
<Gamepad2Icon className="h-3 w-3" /> style={{ background: `${deviceColor}15` }}
{deviceTypeLabel[device.deviceType] || device.deviceType} >
</span> {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> </div>
<p className="text-sm text-text/60"> <p className="text-sm text-text/60">Performance benchmarks and statistics</p>
Performance benchmarks and statistics
</p>
</div> </div>
</motion.div> </motion.div>
{/* ── Overview Stats ────────────────────────────────── */} {/* Overview Stats */}
{stats && ( {stats && !loading && (
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -224,21 +279,38 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
<div className="max-w-7xl mx-auto flex flex-wrap gap-4"> <div className="max-w-7xl mx-auto flex flex-wrap gap-4">
<StatCard icon={DatabaseIcon} label="Total Benchmarks" value={String(stats.totalBenchmarks)} /> <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={TrendingUpIcon} label="Average FPS" value={stats.avgFps !== null ? String(stats.avgFps) : "—"} />
<StatCard icon={Gamepad2Icon} label="Games Tested" value={String(stats.gameCount)} /> <StatCard icon={MonitorIcon} label="Games Tested" value={String(stats.gameCount)} />
<StatCard icon={CheckCircleIcon} label="Verified" value={String(stats.verifiedCount)} /> <StatCard icon={CheckCircleIcon} label="Verified" value={String(stats.verifiedCount)} />
</div> </div>
</motion.div> </motion.div>
)} )}
{/* ── Loading State ─────────────────────────────────── */} {/* Loading */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-16"> <div className="flex items-center justify-center py-16">
<Loader2 className="h-8 w-8 animate-spin text-primary" /> <Loader2 className="h-8 w-8 animate-spin text-primary" />
</div> </div>
)} )}
{/* ── Charts Section ────────────────────────────────── */} {/* Error State */}
{stats && !loading && ( {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 <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
@@ -246,7 +318,6 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
className="px-4 md:px-[10svw]" className="px-4 md:px-[10svw]"
> >
<div className="max-w-7xl mx-auto flex flex-col gap-6"> <div className="max-w-7xl mx-auto flex flex-col gap-6">
{/* Row 1: Historical FPS */}
{stats.historical.length > 0 && ( {stats.historical.length > 0 && (
<div className="rounded-xl border border-border bg-text/[0.03] p-4"> <div className="rounded-xl border border-border bg-text/[0.03] p-4">
<h3 className="text-sm font-medium text-text/80 mb-2">Historical Performance</h3> <h3 className="text-sm font-medium text-text/80 mb-2">Historical Performance</h3>
@@ -254,7 +325,6 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
</div> </div>
)} )}
{/* Row 2: FPS Distribution + Genre Breakdown */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{stats.boxplot.length > 0 && ( {stats.boxplot.length > 0 && (
<div className="rounded-xl border border-border bg-text/[0.03] p-4"> <div className="rounded-xl border border-border bg-text/[0.03] p-4">
@@ -270,8 +340,7 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
)} )}
</div> </div>
{/* Row 3: Proton & FSR Breakdown */} {(stats.protonBreakdown.length > 0 || stats.upscalerBreakdown.length > 0) && (
{(stats.protonBreakdown.length > 0 || stats.fsrBreakdown.length > 0) && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{stats.protonBreakdown.length > 0 && ( {stats.protonBreakdown.length > 0 && (
<div className="rounded-xl border border-border bg-text/[0.03] p-4"> <div className="rounded-xl border border-border bg-text/[0.03] p-4">
@@ -294,13 +363,15 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
</div> </div>
</div> </div>
)} )}
{stats.fsrBreakdown.length > 0 && ( {stats.upscalerBreakdown.length > 0 && (
<div className="rounded-xl border border-border bg-text/[0.03] p-4"> <div className="rounded-xl border border-border bg-text/[0.03] p-4">
<h3 className="text-sm font-medium text-text/80 mb-3">FSR Version Performance</h3> <h3 className="text-sm font-medium text-text/80 mb-3">Upscaler Performance</h3>
<div className="space-y-2"> <div className="space-y-2">
{stats.fsrBreakdown.map((f) => ( {stats.upscalerBreakdown.map((f) => (
<div key={f.version} className="flex items-center justify-between text-sm"> <div key={f.upscalerType} className="flex items-center justify-between text-sm">
<span className="text-text/70 capitalize">{f.version === "none" ? "Native" : f.version.toUpperCase()}</span> <span className="text-text/70 capitalize">
{f.upscalerType === "none" ? "Native" : f.upscalerType.toUpperCase()}
</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-text/80 font-medium tabular-nums">{f.avgFps} FPS</span> <span className="text-text/80 font-medium tabular-nums">{f.avgFps} FPS</span>
<span className="text-text/40 text-xs">({f.count})</span> <span className="text-text/40 text-xs">({f.count})</span>
@@ -313,7 +384,6 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
</div> </div>
)} )}
{/* Row 4: Top Games */}
{stats.topGames.length > 0 && ( {stats.topGames.length > 0 && (
<div className="rounded-xl border border-border bg-text/[0.03] p-4"> <div className="rounded-xl border border-border bg-text/[0.03] p-4">
<h3 className="text-sm font-medium text-text/80 mb-3">Top Games by Average FPS</h3> <h3 className="text-sm font-medium text-text/80 mb-3">Top Games by Average FPS</h3>
@@ -324,9 +394,13 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
href={`/game/${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" 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 text-text/20 tabular-nums w-6">{i + 1}</div> <div className="text-lg font-bold tabular-nums w-6" style={{ color: deviceColor }}>
{i + 1}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate group-hover:text-primary transition-colors">{game.gameTitle}</p> <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"> <div className="flex items-center gap-2 text-xs text-text/50">
<span className="text-green-400 font-medium">{game.avgFps} FPS</span> <span className="text-green-400 font-medium">{game.avgFps} FPS</span>
<span>{game.benchmarkCount} runs</span> <span>{game.benchmarkCount} runs</span>
@@ -342,8 +416,8 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
</motion.div> </motion.div>
)} )}
{/* ── Empty state ──────────────────────────────────── */} {/* Empty state */}
{stats && !loading && stats.totalBenchmarks === 0 && ( {stats && !loading && !error && stats.totalBenchmarks === 0 && (
<div className="max-w-7xl mx-auto px-4 md:px-[10svw]"> <div className="max-w-7xl mx-auto px-4 md:px-[10svw]">
<div className="text-center py-16 text-text/40"> <div className="text-center py-16 text-text/40">
<Gamepad2Icon className="h-10 w-10 mx-auto mb-2" /> <Gamepad2Icon className="h-10 w-10 mx-auto mb-2" />
+77 -7
View File
@@ -3,25 +3,76 @@ import { hardware } from "@/lib/db/schema"
import { eq } from "drizzle-orm" import { eq } from "drizzle-orm"
import { notFound } from "next/navigation" import { notFound } from "next/navigation"
import { DeviceDetailClient } from "./device-detail-client" import { DeviceDetailClient } from "./device-detail-client"
import type { Metadata } from "next"
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) { export const revalidate = 3600
export async function generateStaticParams() {
const devices = await db
.select({ slug: hardware.slug })
.from(hardware)
return devices.map((d) => ({ slug: d.slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params const { slug } = await params
const [device] = await db const [device] = await db
.select({ name: hardware.name }) .select({
name: hardware.name,
deviceType: hardware.deviceType,
})
.from(hardware) .from(hardware)
.where(eq(hardware.slug, slug)) .where(eq(hardware.slug, slug))
.limit(1) .limit(1)
if (!device) return { title: "Device Not Found — DeckyVault" } if (!device) return { title: "Device Not Found — DeckyVault" }
const typeLabel = device.deviceType === "handheld" ? "Handheld" : "Console"
return { return {
title: `${device.name} — DeckyVault`, title: `${device.name} — DeckyVault`,
description: `Benchmark data and performance stats for ${device.name} on 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}` }, 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 }> }) { export default async function DevicePage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params const { slug } = await params
const [device] = await db const [device] = await db
@@ -29,6 +80,7 @@ export default async function DevicePage({ params }: { params: Promise<{ slug: s
slug: hardware.slug, slug: hardware.slug,
name: hardware.name, name: hardware.name,
deviceType: hardware.deviceType, deviceType: hardware.deviceType,
image: hardware.image,
}) })
.from(hardware) .from(hardware)
.where(eq(hardware.slug, slug)) .where(eq(hardware.slug, slug))
@@ -38,18 +90,36 @@ export default async function DevicePage({ params }: { params: Promise<{ slug: s
notFound() 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 = { const jsonLd = {
"@context": "https://schema.org", "@context": "https://schema.org",
"@type": "Product", "@type": "Product",
name: device.name, name: device.name,
category: device.deviceType, category: typeLabel,
url: `https://deckyvault.xyz/devices/${device.slug}`, url: `https://deckyvault.xyz/devices/${device.slug}`,
} }
return ( return (
<> <>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} /> <script
<DeviceDetailClient device={device} /> 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,
}}
/>
</> </>
) )
} }