From eb944c2b3c12d779d6de6c134d4de98b99437f6b Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Mon, 27 Apr 2026 20:48:15 +0800 Subject: [PATCH] feat: redesign devices list page with filter tabs, image/icon, verified stats - Server component fetches data and passes as props (RSC pattern) - Add device-type filter pills (All/Handheld/Console) - Add device image with icon fallback - Add verified count to stats row - Fix deviceTypeLabel to use 'handheld' key - Cap stagger animation delay at 0.5s - Enhanced SEO metadata with keywords and OpenGraph --- app/devices/page-client.tsx | 240 ++++++++++++++++++++++++------------ app/devices/page.tsx | 134 +++++++++++++++++--- 2 files changed, 273 insertions(+), 101 deletions(-) diff --git a/app/devices/page-client.tsx b/app/devices/page-client.tsx index 039aea0..9d02252 100644 --- a/app/devices/page-client.tsx +++ b/app/devices/page-client.tsx @@ -1,15 +1,26 @@ "use client" -import { useEffect, useState } from "react" +import { useState } from "react" import Link from "next/link" +import Image from "next/image" import { motion } from "motion/react" -import { Gamepad2Icon, TrendingUpIcon, DatabaseIcon, ArrowRightIcon, Loader2 } from "lucide-react" +import { + Gamepad2Icon, + TrendingUpIcon, + DatabaseIcon, + ArrowRightIcon, + MonitorIcon, + CheckCircleIcon, +} from "lucide-react" +import { getDeviceColor } from "@/components/charts/EChartWrapper" -interface DeviceStats { +export interface DeviceStats { slug: string name: string deviceType: string + image: string | null sortOrder: number + colorIndex: number totalBenchmarks: number avgFps: number | null gameCount: number @@ -27,46 +38,26 @@ const deviceTypeLabel: Record = { console: "Console", } -export function DevicesPageClient() { - const [devices, setDevices] = useState([]) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) +const deviceTypeColor: Record = { + handheld: "text-primary bg-primary/10 border-primary/20", + console: "text-secondary bg-secondary/10 border-secondary/20", +} - useEffect(() => { - async function fetchDevices() { - try { - const res = await fetch("/api/hardware/stats") - if (!res.ok) throw new Error(`HTTP ${res.status}`) - const data = await res.json() - setDevices(data) - } catch (err) { - setError("Failed to load devices") - console.error(err) - } finally { - setLoading(false) - } - } - fetchDevices() - }, []) +type FilterType = "all" | "handheld" | "console" - if (loading) { - return ( -
- -
- ) - } +const filterOptions: { id: FilterType; label: string }[] = [ + { id: "all", label: "All" }, + { id: "handheld", label: "Handheld" }, + { id: "console", label: "Console" }, +] - if (error) { - return ( -
-
- -

{error}

-
-
- ) - } +export function DevicesPageClient({ devices }: { devices: DeviceStats[] }) { + const [activeFilter, setActiveFilter] = useState("all") + + const filteredDevices = + activeFilter === "all" + ? devices + : devices.filter((d) => d.deviceType === activeFilter) if (devices.length === 0) { return ( @@ -74,7 +65,9 @@ export function DevicesPageClient() {

No devices found

-

Benchmark data will appear as devices are added

+

+ Benchmark data will appear as devices are added +

) @@ -82,7 +75,7 @@ export function DevicesPageClient() { return (
- {/* Header */} + {/* Hero Header */} + {/* Filter Tabs */} + +
+
+ {filterOptions.map((opt) => ( + + ))} +
+
+
+ {/* Device Grid */}
- {devices.map((device, i) => ( + {filteredDevices.map((device, i) => ( - {/* Header */} -
-
-

- {device.name} -

- - - {deviceTypeLabel[device.deviceType] || device.deviceType} - -
- + {/* Image / Icon Header */} +
+ {device.image ? ( + {device.name} + ) : ( + + )}
- {/* Stats */} -
-
- - {device.totalBenchmarks} - Benchmarks + {/* Card Body */} +
+ {/* Name & Type */} +
+
+

+ {device.name} +

+ + + {deviceTypeLabel[device.deviceType] || device.deviceType} + +
+
-
- - - {device.avgFps !== null ? device.avgFps : "—"} - - Avg FPS -
-
- - {device.gameCount} - Games -
-
- {/* Best game */} - {device.bestGame && ( -
- Top: {device.bestGame.title} · {device.bestGame.fpsAvg} FPS + {/* Stats Row */} +
+
+ + + {device.totalBenchmarks} + + Benchmarks +
+
+ + + {device.avgFps !== null ? device.avgFps : "—"} + + Avg FPS +
+
+ + + {device.gameCount} + + Games +
+
+ + + {device.verifiedCount} + + Verified +
- )} + + {/* Best game */} + {device.bestGame && ( +
+ Top:{" "} + + {device.bestGame.title} + {" "} + · {device.bestGame.fpsAvg} FPS +
+ )} +
))}
+ + {filteredDevices.length === 0 && devices.length > 0 && ( +
+ +

No {activeFilter} devices found

+
+ )}
) diff --git a/app/devices/page.tsx b/app/devices/page.tsx index c9c49ed..e245601 100644 --- a/app/devices/page.tsx +++ b/app/devices/page.tsx @@ -1,31 +1,125 @@ import { db } from "@/lib/db/index" -import { hardware } from "@/lib/db/schema" +import { hardware, performanceEntries, gameVersions, games } from "@/lib/db/schema" +import { eq, sql, desc } from "drizzle-orm" import { DevicesPageClient } from "./page-client" +import type { Metadata } from "next" -export const metadata = { +export const metadata: Metadata = { + title: "Devices — DeckyVault", + 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 handheld and console devices with benchmark data on DeckyVault", - alternates: { canonical: "https://deckyvault.xyz/devices" }, + 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 devices = await db.select({ slug: hardware.slug, name: hardware.name }).from(hardware) + const deviceRows = await db + .select({ + slug: hardware.slug, + name: hardware.name, + deviceType: hardware.deviceType, + image: hardware.image, + sortOrder: hardware.sortOrder, + }) + .from(hardware) + .orderBy(hardware.sortOrder) - 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}`, - })), - } + const statsPerDevice = await db + .select({ + hardwareSlug: performanceEntries.hardwareSlug, + totalBenchmarks: sql`count(*)::int`, + avgFps: sql`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`, + verifiedCount: sql`count(*) filter (where ${performanceEntries.verifiedAt} is not null)::int`, + gameCount: sql`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) - return ( - <> -