Files
deckyvault/docs/superpowers/plans/2026-04-27-devices-admin.md
T

1583 lines
51 KiB
Markdown

# Devices Redesign & Admin Panel — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Fix critical device page bugs, redesign devices list/detail pages with proper SEO, remove the broken Community Trust graph, and add an admin management panel with nested routes for Users, Hardware, and Games.
**Architecture:** Server components fetch data and pass as props (true RSC pattern). Admin panel uses a `(admin)` route group with shared sidebar layout and nested routes. All devices code fixes the `"handled"``"handheld"` enum typo and the `upscalerBreakdown`/`fsrBreakdown` property mismatch.
**Tech Stack:** Next.js 16 (App Router), React 19, Tailwind CSS v4, Drizzle ORM (PostgreSQL), better-auth (admin plugin), Elysia API, Framer Motion v12, ECharts, Lucide React icons
---
### Task 1: DB Migration — Rename Enum & Add Image Column
**Files:**
- Create: `drizzle/0011_rename-handled-to-handheld-add-image.sql`
- Modify: `lib/db/schema/hardware.ts`
- Modify: `lib/db/seed.ts`
- [ ] **Step 1: Create the SQL migration file**
Create `drizzle/0011_rename-handled-to-handheld-add-image.sql`:
```sql
-- Rename pg_enum value from 'handled' to 'handheld'
ALTER TYPE device_type RENAME VALUE 'handled' TO 'handheld';
-- Add nullable image column to hardware table
ALTER TABLE hardware ADD COLUMN image text;
```
- [ ] **Step 2: Update the Drizzle schema to match**
In `lib/db/schema/hardware.ts`, change the enum and add the image column:
```ts
import { integer, pgEnum, pgTable, text, timestamp } from "drizzle-orm/pg-core"
export const deviceTypeEnum = pgEnum("device_type", ["handheld", "console"])
export const hardware = pgTable("hardware", {
slug: text("slug").primaryKey(),
name: text("name").notNull(),
deviceType: deviceTypeEnum("device_type").notNull(),
image: text("image"),
sortOrder: integer("sort_order").default(0).notNull(),
createdAt: timestamp("created_at").defaultNow().notNull(),
})
```
- [ ] **Step 3: Update the seed file**
In `lib/db/seed.ts`, change all `"handled" as const` to `"handheld" as const`:
```ts
const devices = [
{
slug: "steamdeck-oled",
name: "Steam Deck OLED",
deviceType: "handheld" as const,
sortOrder: 0,
},
{
slug: "steamdeck-lcd",
name: "Steam Deck LCD",
deviceType: "handheld" as const,
sortOrder: 1,
},
{
slug: "steam-machine",
name: "Steam Machine",
deviceType: "console" as const,
sortOrder: 2,
},
]
```
- [ ] **Step 4: Run the migration**
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && bunx drizzle-kit push`
Expected: Migration applies — enum value renamed, image column added.
- [ ] **Step 5: Run the seed to verify**
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && bun run lib/db/seed.ts`
Expected: No conflict errors, devices seeded with `"handheld"` type.
- [ ] **Step 6: Commit**
```bash
git add drizzle/0011_rename-handled-to-handheld-add-image.sql lib/db/schema/hardware.ts lib/db/seed.ts
git commit -m "fix: rename device_type enum 'handled' to 'handheld', add image column"
```
---
### Task 2: Fix API Bug Fixes — avgFps Nulls & upscalerBreakdown Consistency
**Files:**
- Modify: `lib/api/hardware-stats.ts`
- [ ] **Step 1: Fix avgFps calculation to exclude null FPS entries**
In `lib/api/hardware-stats.ts`, locate the `/:slug/stats` handler and replace the `avgFps` calculation:
**Old code (around line ~90):**
```ts
const avgFps = Math.round(
(entries.reduce((s, e) => s + (e.fpsAvg ?? 0), 0) / totalBenchmarks) * 10
) / 10
```
**New code:**
```ts
// Filter out null FPS entries before computing average (SQL avg() ignores nulls)
const fpsEntries = entries.filter((e) => e.fpsAvg !== null)
const avgFps =
fpsEntries.length > 0
? Math.round(
(fpsEntries.reduce((s, e) => s + e.fpsAvg!, 0) / fpsEntries.length) * 10
) / 10
: null
```
- [ ] **Step 2: Verify the API response shape uses `upscalerBreakdown` consistently**
The API already returns `upscalerBreakdown` (this is the correct name). Confirm there is no `fsrBreakdown` in the API file. No changes needed here — the fix happens in the client component (Task 5).
- [ ] **Step 3: Run the dev server and test the endpoint**
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && curl -s http://localhost:3000/api/hardware/steamdeck-oled/stats | head -c 200`
Expected: JSON response with `avgFps` reflecting only non-null entries, and `upscalerBreakdown` (not `fsrBreakdown`).
- [ ] **Step 4: Commit**
```bash
git add lib/api/hardware-stats.ts
git commit -m "fix: exclude null FPS entries from avgFps calculation in device stats API"
```
---
### Task 3: Remove Community Trust Chart
**Files:**
- Modify: `app/game/[id]/game-page-client.tsx`
- Modify: `lib/api/game-stats.ts`
- Delete: `components/charts/TrustBar.tsx`
- [ ] **Step 1: Remove TrustBar from game-page-client.tsx**
In `app/game/[id]/game-page-client.tsx`:
1. **Remove the import** (line ~34):
Delete: `import { TrustBar } from "@/components/charts/TrustBar"`
2. **Remove trust from the stats interface** (around line ~143):
Delete the entire `trust` array from the interface:
```ts
// DELETE these lines:
trust: Array<{
id: string
hardwareSlug: string
upvotes: number
downvotes: number
verifiedAt: string | null
userNotes: string | null
createdAt: string
}>
```
3. **Remove trust from filteredStats** (around line ~326):
Delete the line: `trust: filterByDevice(stats.trust),`
4. **Remove the Community Trust render section** (around line ~995):
Delete the entire block:
```tsx
{/* Row 4 — Trust Bar */}
{filteredStats && filteredStats.trust.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'>
Community Trust
</h3>
<TrustBar data={filteredStats.trust} />
</div>
)}
```
- [ ] **Step 2: Remove trust data from game-stats API**
In `lib/api/game-stats.ts`:
1. **Remove Section 9** (trust data mapping, around line ~242):
Delete:
```ts
// ── 9. Community trust ────────────────────────────────────────
const trust = entries.map((e) => ({
id: e.id,
hardwareSlug: e.hardwareSlug,
upvotes: e.upvotes,
downvotes: e.downvotes,
verifiedAt: e.verifiedAt ? e.verifiedAt.toISOString() : null,
userNotes: e.userNotes,
createdAt: e.createdAt.toISOString(),
}))
```
2. **Remove `trust` from the empty return** (around line ~77):
Change: `trust: [],` → remove this line
3. **Remove `trust` from the final return object** (around line ~276):
Delete the line: `trust,`
- [ ] **Step 3: Delete the TrustBar component file**
Delete: `components/charts/TrustBar.tsx`
- [ ] **Step 4: Verify no other imports of TrustBar exist**
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && grep -r "TrustBar" --include="*.tsx" --include="*.ts" app/ components/ lib/`
Expected: No results (TrustBar fully removed from codebase).
- [ ] **Step 5: Commit**
```bash
git add -A
git commit -m "refactor: remove broken Community Trust chart (TrustBar)
- Remove TrustBar component and chart
- Remove trust data from game-stats API response
- Remove trust section from game detail page
- DB columns and vote endpoints preserved for future voting system"
```
---
### Task 4: Redesign Devices List Page
**Files:**
- Modify: `app/devices/page.tsx`
- Modify: `app/devices/page-client.tsx`
- [ ] **Step 1: Rewrite the server component to fetch data and pass as props**
Replace the entire content of `app/devices/page.tsx` with:
```tsx
import { db } from "@/lib/db/index"
import { hardware, performanceEntries, gameVersions, games } from "@/lib/db/schema"
import { eq, and, sql, desc } from "drizzle-orm"
import { DevicesPageClient } from "./page-client"
import type { Metadata } from "next"
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 benchmark data for handheld and console gaming devices on DeckyVault.",
url: "https://deckyvault.xyz/devices",
siteName: "DeckyVault",
type: "website",
},
}
export default async function DevicesPage() {
// Fetch all hardware devices ordered by sortOrder
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)
// Get aggregated stats per device
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]))
// Best game per device (highest avg FPS)
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),
})
}
}
// Build device data with stats
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,
}
})
// JSON-LD structured data
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) }}
/>
<DevicesPageClient devices={devices} />
</>
)
}
```
- [ ] **Step 2: Rewrite the client component with filter tabs and enhanced cards**
Replace the entire content of `app/devices/page-client.tsx` with:
```tsx
"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,
} 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
}
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 pb-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/[0.03] 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 — 4 columns */}
<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>
{/* 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>
{/* No results for filter */}
{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>
)
}
```
- [ ] **Step 3: Verify the page renders correctly**
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && bun run build --no-lint 2>&1 | tail -20`
Expected: Build succeeds with no type errors.
- [ ] **Step 4: Commit**
```bash
git add app/devices/page.tsx app/devices/page-client.tsx
git commit -m "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"
```
---
### Task 5: Redesign Device Detail Page
**Files:**
- Modify: `app/devices/[slug]/page.tsx`
- Modify: `app/devices/[slug]/device-detail-client.tsx`
- [ ] **Step 1: Rewrite the server component with server-side data fetching and enhanced metadata**
Replace the entire content of `app/devices/[slug]/page.tsx` with:
```tsx
import { db } from "@/lib/db/index"
import { hardware, performanceEntries, gameVersions, games } from "@/lib/db/schema"
import { eq, and } from "drizzle-orm"
import { notFound } from "next/navigation"
import { DeviceDetailClient } from "./device-detail-client"
import type { Metadata } from "next"
export const revalidate = 3600 // ISR: revalidate every hour
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 [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()
}
// Count device index for color assignment (same order as list page)
const allDevices = await db
.select({ slug: hardware.slug })
.from(hardware)
.orderBy(hardware.sortOrder)
const deviceColorIndex = allDevices.findIndex((d) => d.slug === slug)
// JSON-LD
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,
}}
/>
</>
)
}
```
- [ ] **Step 2: Rewrite the client component with fixes and improved layout**
Replace the entire content of `app/devices/[slug]/device-detail-client.tsx` with:
```tsx
"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(() => {
fetchStats()
}, [device.slug])
// ── Chart Options ──────────────────────────────────────────
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 pb-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">
{/* Device Image or Icon */}
<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 State ─────────────────────────────────── */}
{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 Section ────────────────────────────────── */}
{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">
{/* Row 1: Historical FPS */}
{stats.historical.length > 0 && (
<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>
<EChartWrapper option={historicalOption} height={280} />
</div>
)}
{/* Row 2: FPS Distribution + Genre Breakdown */}
<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/[0.03] 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/[0.03] p-4">
<h3 className="text-sm font-medium text-text/80 mb-2">
Genre Breakdown
</h3>
<EChartWrapper option={genreOption} height={300} />
</div>
)}
</div>
{/* Row 3: Proton & Upscaler Breakdown */}
{(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/[0.03] 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/[0.03] 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>
)}
{/* Row 4: Top Games */}
{stats.topGames.length > 0 && (
<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>
<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/[0.03] min-w-[160px]">
<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>
)
}
```
- [ ] **Step 3: Verify the page builds**
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && bun run build --no-lint 2>&1 | tail -20`
Expected: Build succeeds.
- [ ] **Step 4: Commit**
```bash
git add app/devices/[slug]/page.tsx app/devices/[slug]/device-detail-client.tsx
git commit -m "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"
```
---
### Task 6: Per-Device OG Image & Remaining SEO
**Files:**
- Create: `app/devices/[slug]/opengraph-image.tsx`
- Modify: `app/sitemap.ts`
- [ ] **Step 1: Create the per-device OG image generator**
Create `app/devices/[slug]/opengraph-image.tsx`:
```tsx
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 runtime = "edge"
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 }
)
}
// Fetch basic stats
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",
}}
>
{/* Device Type Badge */}
<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>
{/* Device Name */}
<div
style={{
fontSize: 56,
fontWeight: 700,
color: "#ebe4f1",
lineHeight: 1.1,
marginBottom: 24,
}}
>
{device.name}
</div>
{/* Stats Row */}
<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>
{/* Brand */}
<div
style={{
position: "absolute",
bottom: 40,
left: 80,
fontSize: 16,
color: "#4a3a5c",
fontWeight: 500,
}}
>
deckyvault.xyz
</div>
</div>
),
{ ...size }
)
}
```
- [ ] **Step 2: Update sitemap to exclude admin routes**
In `app/sitemap.ts`, there's currently nothing to exclude since admin pages don't exist yet, but we should ensure we don't add them. Since admin pages will set `robots: { index: false }`, Next.js won't include them. No code change needed here now — the admin layout will handle it.
However, verify the current sitemap doesn't list `/search` (which is noindexed). Check and fix if present.
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && grep -n "search" app/sitemap.ts`
Expected: Either no match or a line that should be removed.
If a `/search` entry exists with a sitemap listing, remove it since search is noindexed.
- [ ] **Step 3: Verify the OG image route compiles**
Run: `cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && bun run build --no-lint 2>&1 | tail -20`
Expected: Build succeeds.
- [ ] **Step 4: Commit**
```bash
git add app/devices/[slug]/opengraph-image.tsx
# Only if sitemap.ts was changed:
git add app/sitemap.ts
git commit -m "feat: add per-device OG image generator for device detail pages
- Renders device name, type badge, and stats on branded dark background
- Uses Edge runtime for fast generation
- References OG image in device detail page metadata"
```