From 060911e29dc2108c3b24461e522a1d75c49c992c Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 27 Apr 2026 20:55:15 +0800 Subject: [PATCH] feat: redesign device detail page with fixes and improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/devices/[slug]/device-detail-client.tsx | 190 ++++++++++++++------ app/devices/[slug]/page.tsx | 84 ++++++++- 2 files changed, 209 insertions(+), 65 deletions(-) diff --git a/app/devices/[slug]/device-detail-client.tsx b/app/devices/[slug]/device-detail-client.tsx index cbb0dc7..fe2fcfa 100644 --- a/app/devices/[slug]/device-detail-client.tsx +++ b/app/devices/[slug]/device-detail-client.tsx @@ -2,15 +2,37 @@ 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 } from "lucide-react" -import { EChartWrapper, CHART_THEME, getDeviceColor } from "@/components/charts/EChartWrapper" +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 { @@ -45,40 +67,46 @@ interface DeviceStats { }> genreBreakdown: Array<{ genre: string; count: number }> protonBreakdown: Array<{ version: string; count: number }> - fsrBreakdown: Array<{ version: string; count: number; avgFps: number }> + upscalerBreakdown: UpscalerEntry[] } const deviceTypeLabel: Record = { - handled: "Handheld", + handheld: "Handheld", console: "Console", } +const deviceTypeColor: Record = { + 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(null) const [loading, setLoading] = useState(true) + const [error, setError] = useState(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(() => { - 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() - return () => { cancelled = true } }, [device.slug]) - const deviceColor = getDeviceColor(0) - - // ── Chart Options ────────────────────────────────────────── const historicalOption = useMemo(() => { if (!stats || stats.historical.length === 0) return {} return { @@ -164,7 +192,7 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) { return { tooltip: { trigger: "item", - backgroundColor: "#1a1020", + backgroundColor: "#1a1025", borderColor: CHART_THEME.border, textStyle: { color: CHART_THEME.text }, }, @@ -176,12 +204,11 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) { data: stats.genreBreakdown.map((g, i) => ({ name: g.genre, value: g.count, - itemStyle: { color: CHART_THEME.deviceColors[i % CHART_THEME.deviceColors.length] }, + itemStyle: { + color: CHART_THEME.deviceColors[i % CHART_THEME.deviceColors.length], + }, })), - label: { - color: CHART_THEME.textMuted, - fontSize: 10, - }, + label: { color: CHART_THEME.textMuted, fontSize: 10 }, emphasis: { itemStyle: { shadowBlur: 10, shadowColor: "rgba(0,0,0,0.5)" }, }, @@ -192,7 +219,7 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) { return (
- {/* ── Hero Header ──────────────────────────────────── */} + {/* Hero Header */}
-
-

{device.name}

- - - {deviceTypeLabel[device.deviceType] || device.deviceType} - +
+
+ {device.image ? ( + {device.name} + ) : ( + + )} +
+
+

{device.name}

+
+ + + {deviceTypeLabel[device.deviceType] || device.deviceType} + + {stats && stats.verifiedCount > 0 && ( + + + {stats.verifiedCount} verified + + )} +
+
-

- Performance benchmarks and statistics -

+

Performance benchmarks and statistics

- {/* ── Overview Stats ────────────────────────────────── */} - {stats && ( + {/* Overview Stats */} + {stats && !loading && ( - +
)} - {/* ── Loading State ─────────────────────────────────── */} + {/* Loading */} {loading && (
)} - {/* ── Charts Section ────────────────────────────────── */} - {stats && !loading && ( + {/* Error State */} + {error && !loading && ( +
+
+ +

{error}

+ +
+
+ )} + + {/* Charts */} + {stats && !loading && !error && (
- {/* Row 1: Historical FPS */} {stats.historical.length > 0 && (

Historical Performance

@@ -254,7 +325,6 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
)} - {/* Row 2: FPS Distribution + Genre Breakdown */}
{stats.boxplot.length > 0 && (
@@ -270,8 +340,7 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) { )}
- {/* Row 3: Proton & FSR Breakdown */} - {(stats.protonBreakdown.length > 0 || stats.fsrBreakdown.length > 0) && ( + {(stats.protonBreakdown.length > 0 || stats.upscalerBreakdown.length > 0) && (
{stats.protonBreakdown.length > 0 && (
@@ -294,13 +363,15 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
)} - {stats.fsrBreakdown.length > 0 && ( + {stats.upscalerBreakdown.length > 0 && (
-

FSR Version Performance

+

Upscaler Performance

- {stats.fsrBreakdown.map((f) => ( -
- {f.version === "none" ? "Native" : f.version.toUpperCase()} + {stats.upscalerBreakdown.map((f) => ( +
+ + {f.upscalerType === "none" ? "Native" : f.upscalerType.toUpperCase()} +
{f.avgFps} FPS ({f.count}) @@ -313,7 +384,6 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) {
)} - {/* Row 4: Top Games */} {stats.topGames.length > 0 && (

Top Games by Average FPS

@@ -324,9 +394,13 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) { 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" > -
{i + 1}
+
+ {i + 1} +
-

{game.gameTitle}

+

+ {game.gameTitle} +

{game.avgFps} FPS {game.benchmarkCount} runs @@ -342,8 +416,8 @@ export function DeviceDetailClient({ device }: { device: DeviceInfo }) { )} - {/* ── Empty state ──────────────────────────────────── */} - {stats && !loading && stats.totalBenchmarks === 0 && ( + {/* Empty state */} + {stats && !loading && !error && stats.totalBenchmarks === 0 && (
diff --git a/app/devices/[slug]/page.tsx b/app/devices/[slug]/page.tsx index 0fff0bf..e628334 100644 --- a/app/devices/[slug]/page.tsx +++ b/app/devices/[slug]/page.tsx @@ -3,25 +3,76 @@ 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 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 { const { slug } = await params const [device] = await db - .select({ name: hardware.name }) + .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 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}` }, + 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 [device] = await db @@ -29,6 +80,7 @@ export default async function DevicePage({ params }: { params: Promise<{ slug: s slug: hardware.slug, name: hardware.name, deviceType: hardware.deviceType, + image: hardware.image, }) .from(hardware) .where(eq(hardware.slug, slug)) @@ -38,18 +90,36 @@ export default async function DevicePage({ params }: { params: Promise<{ slug: s 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: device.deviceType, + category: typeLabel, url: `https://deckyvault.xyz/devices/${device.slug}`, } return ( <> -