14 KiB
Devices Page Redesign & Admin Panel — Design Spec
Date: 2026-04-27 Scope: Fix devices page bugs, redesign devices list/detail pages, add admin management panel, remove broken Community Trust graph
1. Bug Fixes
1.1 "handled" → "handheld" Enum Typo
The device_type PostgreSQL enum has "handled" instead of "handheld". This propagates to every layer:
- DB:
pgEnum("device_type", ["handled", "console"])inlib/db/schema/hardware.ts - Seed:
"handled" as constinlib/db/seed.ts - API responses:
deviceType: "handled"returned from/api/hardware/statsand/api/hardware/:slug/stats - Client maps: List page uses
{ handheld: "Handheld" }(never matches), detail page uses{ handled: "Handheld" }(matches the typo) - JSON-LD: Outputs
"handled"as thecategoryfield on device detail pages
Fix: Create a Drizzle migration that:
- Renames the enum value from
"handled"to"handheld"usingALTER TYPE device_type RENAME VALUE 'handled' TO 'handheld' - Updates any existing rows that reference the old value (though enum values are stored by reference, not value, so the ALTER TYPE handles this)
Then update all code references:
lib/db/schema/hardware.ts:pgEnum("device_type", ["handheld", "console"])lib/db/seed.ts:"handheld" as constapp/devices/page-client.tsx:deviceTypeLabelmap (already correct with"handheld"key)app/devices/[slug]/device-detail-client.tsx:deviceTypeLabelmap (change"handled"→"handheld")
1.2 upscalerBreakdown vs fsrBreakdown Property Mismatch
The API (hardware-stats.ts) returns upscalerBreakdown on the detail stats, but the client (device-detail-client.tsx) TypeScript interface and render code uses fsrBreakdown. At runtime, stats.fsrBreakdown is undefined, silently breaking the upscaler section.
Fix: Rename the client interface and rendering code to use upscalerBreakdown to match the API. Update the TypeScript interface and all references in device-detail-client.tsx.
1.3 avgFps Includes Nulls as Zero
In lib/api/hardware-stats.ts, the /:slug/stats endpoint computes:
const avgFps = Math.round((entries.reduce((s, e) => s + (e.fpsAvg ?? 0), 0) / totalBenchmarks) * 10) / 10
This treats null FPS entries as 0, reducing the true average. The /stats list endpoint already handles this correctly using SQL avg() which ignores nulls.
Fix: Filter out null FPS entries before computing the average:
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
2. Devices Page Redesign
2.1 List Page (/devices)
Architecture: Server component fetches devices with aggregated stats from DB, passes serialized data as props to client component. Eliminates the redundant client-side fetch to /api/hardware/stats.
Layout:
- Hero header with title "Devices" and description "Browse benchmarks for handheld and console devices"
- Pill-based device type filter tabs: All / Handheld / Console (matches game-page filter pattern)
- Responsive card grid (1-col mobile, 2-col tablet, 3-col desktop)
- Stagger animation capped at
min(i * 0.05, 0.5)seconds max delay
Card design:
- Device image (or icon fallback with colored background) — top/leading section
- Device name (bold, hover → primary color)
- Device type badge (colored pill: Handheld = pink, Console = purple)
- 4-column stat row: Benchmarks / Avg FPS / Games / Verified
- Top game preview (title + FPS)
- Arrow icon on hover
SEO:
- Static metadata with
title,description,keywords,openGraph,alternates - JSON-LD
ItemListwith correct device type labels ("Handheld" not "handled")
2.2 Detail Page (/devices/[slug])
Architecture: Server component fetches device info + stats from the API endpoint (server-side), passes serialized data as props. Eliminates client-side fetch. Falls back to notFound() for invalid slugs.
Hero section:
- Device image (or large styled Gamepad2Icon in colored container as fallback)
- Device name (large, bold)
- Device type badge
- Verified count badge
Stats row: 4 stat cards — Total Benchmarks / Average FPS / Games Tested / Verified (same StatCard component pattern as game page)
Charts section:
- Historical FPS line chart (same as current)
- FPS Boxplot per game (same as current)
- Genre Breakdown donut (same as current)
- Proton Version Distribution bar (same as current)
- Upscaler Version Performance list (renamed from FSR, same data)
- Top Games grid (same as current, but fixed device color per device)
Removed: Community Trust bar chart (TrustBar). No replacement — the section is simply removed from both the game detail page and the device detail page.
Error handling: Error state with retry button when fetch fails.
generateStaticParams: Added with revalidate = 3600 (ISR, 1-hour cache).
Per-device OG image: app/devices/[slug]/opengraph-image.tsx renders device name + type + stats on branded dark background.
SEO: Dynamic generateMetadata with title, description, keywords, openGraph, alternates, robots.
2.3 Hardware Schema — Image Column
Add nullable image column to hardware table:
image: text("image"), // nullable — URL to device image
Migration adds the column as optional. Seed updated to include image URLs where available. Where image is null, UI shows a styled icon fallback.
2.4 Device-specific Colors
Currently getDeviceColor(0) hardcodes pink for all devices. Fix to use device slug as index:
const colorIndex = CHART_THEME.deviceColors.length > 0
? devices.findIndex(d => d.slug === device.slug) % CHART_THEME.deviceColors.length
: 0
Or simpler: pass device list index from server.
3. Community Trust Removal
Remove the TrustBar chart from both pages where it appears:
app/game/[id]/game-page-client.tsx: RemoveTrustBarimport, removetrustfrom the stats interface, remove the "Community Trust" section from the rendercomponents/charts/TrustBar.tsx: Delete the filelib/api/game-stats.ts: Remove section 9 (trust data) from the response — stop shippingtrustarraylib/api/hardware-stats.ts: No trust data in this endpoint (already doesn't have it)
The trust/upvote/downvote columns remain in the database — they're not removed. The vote API endpoints (/:id/upvote, /:id/downvote) also remain. This is purely a UI removal of a chart that displays meaningless data. A proper voting system can be built in the future and the chart re-added at that time.
4. Admin Panel
4.1 Route Structure
app/(admin)/
layout.tsx — shared layout: sidebar + auth guard
admin/
page.tsx — redirect to /admin/users
users/
page.tsx — server: fetch users, pass to client
users-client.tsx — client: user management UI
hardware/
page.tsx — server: fetch hardware, pass to client
hardware-client.tsx — client: hardware CRUD UI
games/
page.tsx — server: fetch games, pass to client
games-client.tsx — client: game management UI
The (admin) route group uses parentheses to avoid nesting in URLs. The layout at app/(admin)/layout.tsx wraps all admin pages with the sidebar and auth guard.
URL structure:
/admin→ redirects to/admin/users/admin/users→ user management/admin/hardware→ hardware management/admin/games→ game management
4.2 Auth Guard
Server-side: The admin layout component checks auth.api.getSession() from headers. If no session or user.role !== "admin", call redirect("/") from next/navigation.
Client-side: As a fallback, the client components check useSession() and redirect non-admins.
Metadata: All admin pages set robots: { index: false } to prevent search engine indexing.
4.3 Sidebar Component
components/admin/admin-sidebar.tsx — reusable sidebar nav component.
Pattern: Matches the existing settings-container.tsx sidebar pattern (vertical on desktop, horizontal scroll on mobile).
- Desktop:
md:w-48 shrink-0sidebar with border-right, active item getsbg-primary/10 text-primary border-l-primary border-l-2 - Mobile: Horizontal scrollable tabs with
overflow-x-auto - Active detection: Uses
usePathname()to highlight current section - Items:
- Users (icon:
UsersIcon) - Hardware (icon:
CpuIcon) - Games (icon:
Gamepad2Icon)
- Users (icon:
4.4 Admin — Users Page
Data: Uses authClient.admin.listUsers() with pagination. Falls back to a custom API endpoint if better-auth's listUsers doesn't provide enough data.
UI:
- Search input (filters by name/email)
- Table: Avatar (or initials) | Name | Email | Role (badge) | Status (active/banned) | Joined | Actions
- Actions dropdown: Change Role (user/contributor/admin), Ban/Unban
- Role badges:
adminin red,contributorin yellow,userin gray - Status: active (green dot) / banned (red dot)
API: Uses better-auth admin plugin client methods:
authClient.admin.listUsers()for listingauthClient.admin.banUser({ userId })/authClient.admin.unbanUser({ userId })for banningauthClient.admin.setRole({ userId, role })for role changes
4.5 Admin — Hardware Page
Data: Fetches from /api/hardware/stats (existing endpoint) for the list with aggregated stats. For create/edit, uses /api/hardware CRUD endpoints.
UI:
- "Add Device" button at top
- Card list (not table — matches the public devices page pattern)
- Each card: device image (or icon) | name | slug | type badge | stats row (benchmarks / FPS / games) | Edit / Delete buttons
- Edit modal: inline form for name, slug, device type, sort order, image URL
- Delete: confirmation dialog, calls
DELETE /api/hardware/:slug - Create: modal form, calls
POST /api/hardware
4.6 Admin — Games Page
Data: Fetches from /api/games with pagination and search support (existing CRUD endpoint).
UI:
- Search input with debounce
- Table: Cover image | Title | Developer | Source (steam/manual) | Sync status | Benchmarks count | Actions
- Actions: View on site (link to
/game/:id), Sync now (triggers re-sync from Steam), Delete - Pagination controls at bottom
4.7 Navbar Update
Add admin link to the profile dropdown in components/navbar.tsx:
{session?.user?.role === "admin" && (
<Link href="/admin" className="flex items-center gap-2 ...">
<ShieldIcon className="h-4 w-4" />
Admin
</Link>
)}
Conditionally rendered based on session.user.role === "admin".
4.8 Routes Update
Add admin route to lib/routes.ts — but only for reference, not in the main nav. Admin link goes in the profile dropdown only.
4.9 Sitemap Update
Exclude /admin and its subpages from app/sitemap.ts. Admin pages should not appear in the sitemap.
5. SEO Improvements Summary
| Page | Current | Improved |
|---|---|---|
/devices |
Basic title/description | Full OG, keywords, correct JSON-LD |
/devices/[slug] |
Basic metadata | generateMetadata with full OG, keywords, per-device OG image |
/admin/* |
N/A | robots: { index: false }, noIndex: true |
| All device pages | "handled" in JSON-LD |
"Handheld" corrected label |
| Device detail | No generateStaticParams |
ISR with 1-hour revalidation |
6. Files to Create
| File | Purpose |
|---|---|
drizzle/0011_rename-handled-to-handheld-add-image.sql |
DB migration |
app/devices/opengraph-image.tsx |
Per-device OG image |
app/(admin)/layout.tsx |
Admin layout with sidebar + auth guard |
app/(admin)/admin/page.tsx |
Redirect to /admin/users |
app/(admin)/admin/users/page.tsx |
Users server component |
app/(admin)/admin/users/users-client.tsx |
Users management UI |
app/(admin)/admin/hardware/page.tsx |
Hardware server component |
app/(admin)/admin/hardware/hardware-client.tsx |
Hardware CRUD UI |
app/(admin)/admin/games/page.tsx |
Games server component |
app/(admin)/admin/games/games-client.tsx |
Games management UI |
components/admin/admin-sidebar.tsx |
Reusable admin sidebar nav |
7. Files to Modify
| File | Changes |
|---|---|
lib/db/schema/hardware.ts |
Fix enum to "handheld", add image column |
lib/db/seed.ts |
Fix "handled" → "handheld" |
lib/api/hardware-stats.ts |
Fix avgFps nulls, rename fsrBreakdown → upscalerBreakdown (already named that) |
app/devices/page.tsx |
RSC data fetching, enhanced metadata |
app/devices/page-client.tsx |
Full redesign with filter tabs, cards, fix deviceTypeLabel |
app/devices/[slug]/page.tsx |
Enhanced metadata, generateStaticParams, RSC data passing |
app/devices/[slug]/device-detail-client.tsx |
Fix bugs, remove TrustBar, image/icon hero, error state |
app/game/[id]/game-page-client.tsx |
Remove TrustBar import and section |
lib/api/game-stats.ts |
Remove trust from response |
components/charts/TrustBar.tsx |
Delete |
lib/routes.ts |
Add admin route reference |
components/navbar.tsx |
Add conditional admin link |
app/sitemap.ts |
Exclude admin routes |
8. Out of Scope
- Reports management page — deferred to future iteration
- Admin dashboard/overview — deferred to future iteration
- Performance entry verification UI in admin — deferred, can be added to a future admin/performance page
- Voting system redesign — removing TrustBar is in scope, but building a proper vote deduplication system is out of scope
- Device image uploads — image column accepts URLs; upload flow is out of scope
- SQL aggregation optimization for device stats endpoint — beneficial but out of scope for this iteration