docs: add design spec for devices redesign & admin panel
This commit is contained in:
@@ -0,0 +1,311 @@
|
|||||||
|
# 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"])` in `lib/db/schema/hardware.ts`
|
||||||
|
- **Seed:** `"handled" as const` in `lib/db/seed.ts`
|
||||||
|
- **API responses:** `deviceType: "handled"` returned from `/api/hardware/stats` and `/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 the `category` field on device detail pages
|
||||||
|
|
||||||
|
**Fix:** Create a Drizzle migration that:
|
||||||
|
1. Renames the enum value from `"handled"` to `"handheld"` using `ALTER TYPE device_type RENAME VALUE 'handled' TO 'handheld'`
|
||||||
|
2. 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 const`
|
||||||
|
- `app/devices/page-client.tsx`: `deviceTypeLabel` map (already correct with `"handheld"` key)
|
||||||
|
- `app/devices/[slug]/device-detail-client.tsx`: `deviceTypeLabel` map (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:
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
```ts
|
||||||
|
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 `ItemList` with 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:
|
||||||
|
```ts
|
||||||
|
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:
|
||||||
|
```ts
|
||||||
|
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`:** Remove `TrustBar` import, remove `trust` from the stats interface, remove the "Community Trust" section from the render
|
||||||
|
- **`components/charts/TrustBar.tsx`:** Delete the file
|
||||||
|
- **`lib/api/game-stats.ts`:** Remove section 9 (trust data) from the response — stop shipping `trust` array
|
||||||
|
- **`lib/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-0` sidebar with border-right, active item gets `bg-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`)
|
||||||
|
|
||||||
|
### 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: `admin` in red, `contributor` in yellow, `user` in gray
|
||||||
|
- Status: active (green dot) / banned (red dot)
|
||||||
|
|
||||||
|
**API:** Uses better-auth admin plugin client methods:
|
||||||
|
- `authClient.admin.listUsers()` for listing
|
||||||
|
- `authClient.admin.banUser({ userId })` / `authClient.admin.unbanUser({ userId })` for banning
|
||||||
|
- `authClient.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`:
|
||||||
|
```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
|
||||||
Reference in New Issue
Block a user