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:
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user