refactor: convert to bun workspaces monorepo

- Move web app into apps/web/
- Create packages/shared/ with shared types
- Create plugins/decky-vault/ scaffold
- Root package.json manages workspaces only
This commit is contained in:
2026-06-28 05:20:28 +08:00
parent c4bede20d4
commit cd72b7a948
345 changed files with 488 additions and 126 deletions
+112
View File
@@ -0,0 +1,112 @@
# =============================================================================
# Environment Configuration
# =============================================================================
# Copy this file to .env.local and fill in your actual values
# Never commit .env.local to version control
# =============================================================================
# -----------------------------------------------------------------------------
# SYSTEM & DEPLOYMENT
# -----------------------------------------------------------------------------
NIXPACKS_NODE_VERSION=22
# -----------------------------------------------------------------------------
# DATABASE
# -----------------------------------------------------------------------------
# PostgreSQL connection string
DATABASE_URL="postgresql://user:password@localhost:5432/db"
# -----------------------------------------------------------------------------
# APPLICATION URLS
# -----------------------------------------------------------------------------
# Base URL of the application (client-accessible)
NEXT_PUBLIC_SITE_URL="https://localhost:3000"
# Auth callback URL
NEXT_PUBLIC_APP_URL="https://localhost:3000"
# Used by Better Auth trusted origins and auth client baseURL (same as NEXT_PUBLIC_SITE_URL)
NEXT_PUBLIC_BASE_URL="https://localhost:3000"
# -----------------------------------------------------------------------------
# BETTER AUTH
# -----------------------------------------------------------------------------
# Better Auth configuration
# URL must match your app URL (with protocol)
BETTER_AUTH_URL="https://localhost:3000"
# Secret key for signing tokens (generate with: openssl rand -base64 32)
BETTER_AUTH_SECRET="your-secret-key-here"
# -----------------------------------------------------------------------------
# AUTHENTICATION
# -----------------------------------------------------------------------------
# Google OAuth credentials (required)
GOOGLE_CLIENT_ID=""
GOOGLE_CLIENT_SECRET=""
# Discord OAuth credentials (required)
DISCORD_CLIENT_ID=""
DISCORD_CLIENT_SECRET=""
# -----------------------------------------------------------------------------
# STORAGE (CLOUDFLARE R2)
# -----------------------------------------------------------------------------
R2_ACCOUNT_ID=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET_NAME=deckyvault
R2_PUBLIC_URL=
# -----------------------------------------------------------------------------
# CAPTCHA (CLOUDFLARE TURNSTILE)
# -----------------------------------------------------------------------------
# Cloudflare Turnstile site key (public, used in client-side widget)
NEXT_PUBLIC_TURNSTILE_SITE_KEY="0x4AAAAAADrebUvw0DbPkPSc"
# Cloudflare Turnstile secret key (server-side verification)
TURNSTILE_SECRET_KEY="0x4AAAAAADrebeHpOUElKVG4bxd3EBbdBrk"
# -----------------------------------------------------------------------------
# CRON
# -----------------------------------------------------------------------------
# Secret token for daily cron endpoint (generate with: openssl rand -base64 32)
# Leave empty to disable cron endpoints
CRON_SECRET=
# -----------------------------------------------------------------------------
# PASSKEY (WebAuthn)
# -----------------------------------------------------------------------------
# RP ID: Your domain without protocol (e.g., "localhost" or "deckyvault.xyz")
RP_ID="localhost"
# RP Name: Human-readable name shown in passkey prompts
RP_NAME="DeckyVault"
# Android APK key hash for native passkey support on Android
# Generate with:
# keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -storepass android -keypass android | grep 'SHA256:'
# Then convert to base64url format (remove colons, lowercase, base64url encode)
# Leave empty for web-only passkey support
ANDROID_APK_KEY_HASH=
# -----------------------------------------------------------------------------
# EMAIL
# -----------------------------------------------------------------------------
# Resend API key for sending OTP emails (optional — falls back to console logging)
RESEND_API_KEY=""
# Email sender address
EMAIL_FROM="DeckyVault <noreply@deckyvault.xyz>"
# -----------------------------------------------------------------------------
# STEAMGRIDDB
# -----------------------------------------------------------------------------
# API key for SteamGridDB cover art search (get one at https://www.steamgriddb.com)
STEAMGRIDDB_API_KEY="your_steamgriddb_api_key_here"
# -----------------------------------------------------------------------------
# CONTACT FORM
# -----------------------------------------------------------------------------
# Discord webhook URL for contact/report submissions
DISCORD_WEBHOOK_URL=""
+317
View File
@@ -0,0 +1,317 @@
# Changelog
All notable changes to DeckyVault will be documented in this file.
## [2026.2.2] - 2026-05-27
### Added
- **Sign-up domain restriction for deckyvault.xyz:** new account creation with `@deckyvault.xyz` email addresses is now blocked in production. This prevents unauthorized use of the brand domain. The restriction is lifted in development builds.
- **Branded email placeholder:** all auth forms (sign-up, login, forgot password) on web and mobile now use `you@deckyvault.xyz` as the default placeholder instead of the generic `you@example.com`
### Changed
- Server-side domain enforcement ensures the `@deckyvault.xyz` block cannot be bypassed by direct API calls or social login flows
- Existing `@deckyvault.xyz` account holders can still log in, use password reset, and receive OTPs
### Technical
- Added `lib/auth/domain-block.ts` shared domain validation helper
- Added Elysia middleware in `lib/api/app.ts` for server-side sign-up domain enforcement
- Added Zod `.refine()` on `signupSchema` for client-side domain validation
## [2026.2.1] - 2026-05-25
### Fixed
- **Page title duplication on /games and /devices:** titles now read "Games | DeckyVault" and "Devices | DeckyVault" instead of the duplicated "Games — DeckyVault | DeckyVault"
- **Missing metadata on /compare and /profile:** these pages now have unique `<title>` and `<meta description>` tags instead of reusing the homepage title
- **Duplicate H1 on game detail pages:** each game page now has a single `<h1>` heading
- **Truncated meta descriptions on game pages:** descriptions no longer cut off mid-sentence — truncation now respects word boundaries
### Added
- **Canonical URL tag** on homepage — prevents duplicate-content indexing
- **BreadcrumbList structured data** on game detail, games listing, and devices listing pages — enables breadcrumb rich results in search
- **Organization structured data** on homepage — enhances brand visibility in search results
- **AggregateRating structured data** on game detail pages — enables star ratings in SERPs when Steam review scores are available
- **Preconnect hints** for external CDN origins (Steam, SteamGridDB, Cloudflare R2, Discord, Google) — improves page load performance
### Changed
- Removed `user-scalable=no` from viewport meta tag — improves mobile accessibility compliance (WCAG 1.4.4)
### Technical
- Added `lib/utils/seo.ts` with shared `smartTruncate()` and `buildBreadcrumbList()` utilities
- Added `app/__tests__/metadata.test.ts` integration test suite for page-level SEO metadata
## [2026.2.0] - 2026-05-20
### Added
- **ProtonDB URL compatibility:** `/app/[steamid]` permanently redirects to `/game/[steamid]` — replace `protondb.com` with `deckyvault.xyz` in any ProtonDB URL to land on the matching DeckyVault game page
- **Human-readable share URLs:** share button now copies clean URLs with numeric Steam App IDs for Steam games and title-based slugs for non-Steam games, instead of opaque UUIDs
- **Auto-generated slugs for non-Steam games:** new `slug` column on `games` table, populated on creation from the game title with automatic deduplication
### Changed
- Share URLs now use `steamAppId` (Steam) or `slug` (non-Steam) instead of the internal UUID
### Technical
- Added `slug` column to `games` table with partial unique index
- Extended `resolveGame()` to support slug-based lookups as a fallback resolution path
- Added `lib/utils/slug.ts` for slug generation utility
- Backfilled slugs for all existing non-Steam games
## [2026.1.0] - 2026-05-16
### Added
- **Moderator role** with content moderation permissions (verify entries, handle reports, review suggestions, manage comments) — sits between contributor and admin
- **Moderation analytics tab** with ECharts time-series: benchmark submissions, user registrations, device distribution, and genre popularity over 90 days
- **Ban reason and expiry UI** in user management — modal with optional reason and duration, plus active/banned filter tabs
- **Steam Sale section** on landing page showing discounted games with ≥3 benchmarks, sorted by performance — includes discount badges, pricing, and review scores
- Database performance indexes: `perf_removed_created_idx`, `perf_upvotes_idx`, `reports_status_idx`, `suggestions_status_idx`, `games_sync_status_idx`, `perf_game_lookup_idx`
- In-memory 5-minute cache for manage dashboard stats endpoint
- Auto-pin unit tests covering threshold edge cases
### Changed
- **Performance tags (Raw Performer/Poor Performance/best FPS) now scoped to handheld devices** (Steam Deck OLED/LCD) by default — console (Steam Machine) data no longer influences the badges shown on game cards, listings, and search results
- **Public dashboard** now uses full-width layout matching game details page styling (no `max-w` constraint)
- Moderator role added between contributor and admin; manage sidebar adapts to show role-appropriate navigation (hides Users, Storage for moderators)
- Manage panel now requires moderator or admin role for access (previously contributor+)
- Content moderation endpoints (reports, suggestions, comments, benchmark removal) gated to moderator+
### Fixed
- **Submit wizard drag-and-drop** no longer triggers text selection or touch-scroll interference on settings and screenshot reorder handles
- **Editing entries with screenshots** now shows existing screenshots in the Review step with remove capability; supports hybrid existing + new screenshot management
### Security
- Manage panel access restricted to moderator+ (up from contributor+)
- Destructive benchmark operations (remove, hard-delete, restore) now require moderator+ (up from admin-only — wider moderation capability with proper role separation)
## [2026.0.101] - 2026-05-14
### Added
- SteamDB version auto-fetch — latest game version/build surfaced in submit wizard version selector
- Landing page: Recently Added Benchmarks, Trending This Week, and Most Tested sections with PlayabilityBadge and performance tags on game cards
- Tiered API rate limiting with 5 categories (auth, read, write, strict, default)
- Comment anti-spam: duplicate detection, 50KB content cap, 30/hr per-user limit
- Submission cooldown: 60-second minimum between benchmark entries per user
- Submission validation hardening: FPS bounds (1-500), TDP bounds, settings size caps, userNotes length cap
### Changed
- Landing page sections reordered: Recently Added Benchmarks → Trending This Week → Most Tested Games
- Landing page cards now show PlayabilityBadge (with text label) and performance tags (Raw Performer / Poor Performance / avg FPS)
- Landing hero height adjusted to `calc(100svh - 10svh)` for content "peek" effect
- Rate limiter now uses named categories instead of a single global bucket
### Security
- Hardened validation on performance entry submission (fps bounds, settings size caps)
- Server-side sanitization of comment content before storage
- Per-route rate limiting categories for granular abuse prevention
## [2026.0.100] - 2026-05-10
### Added
- Screenshot upload during benchmark submission (Review step), gated on settings presence
- Best FPS and performance badges on games list cards
- Game version info and anti-cheat context in preset detail modal
- Auto-computed battery life estimate in submit wizard Review step
### Changed
- Screenshot upload moved from post-submission interstitial into wizard Review step
- Preset detail modal shows game version/build and game-level anti-cheat info
### Removed
- Manual `estimatedBatteryMin` field; all battery estimates now auto-computed from TDP + watt-hours
### Technical
- Dropped `estimated_battery_min` column from `performance_entries`
- Listing API returns `bestFps`, `isRawPerformer`, `isPoorPerformance`
- Updated all component interfaces to remove deprecated battery field
## [2026.0.99] - 2026-05-09
### Added
- Screenshot uploads (12 per benchmark entry) with server-side compression and EXIF stripping
- YouTube video linking on performance entries (privacy-enhanced embed)
- Auto-pinning: entries with ≥10 votes and ≥80% approval auto-pin
- Hardware watt-hours (battery capacity) and max TDP fields for battery life estimation
- TDP tracking per performance entry (user-set TDP cap)
- Auto-computed battery life estimates on performance cards and detail view
- Battery Life vs TDP chart on game statistics dashboard
- Mobile filter drawer: slide-out overlay on games list for narrow viewports
- Mobile game cover hero: full-bleed background image with gradient overlay
### Changed
- Performance cards now show TDP/Wh/battery quick-look bar on handheld devices
- Game details hero renders as full-bleed background on mobile (<md) viewports
- Games list filter panel now uses a slide-out drawer on screens below `lg`
- `estimatedBatteryMin` field deprecated in favor of auto-computed values from TDP + device watt-hours
### Fixed
- Games list filter panel no longer overflows the page on mobile
### Technical
- Added `entry_screenshots` database table
- Added `tdpWatts` and `youtubeVideoId` columns to `performance_entries`
- Added `wattHours` and `tdpMax` columns to `hardware`
- Added `sharp` image processing dependency for screenshot compression
- Added on-vote auto-pin trigger in performance vote handlers
- Added `BatteryLifeChart` ECharts component
## [2026.0.98] - 2026-05-09
### Added
- Cloudflare R2 storage integration with upload, delete, and tracking
- Profile photo upload, customization, and deletion (stored in R2)
- Modular daily cron system at `/api/cron/daily` with storage cleanup and sitemap regeneration tasks
- Public data dashboard (`/dashboard`) with trending games, best new releases, and most tested/reported charts
- Advanced search filters on the search page (device, FPS, FSR, Proton, anti-cheat, playability) with saved filter presets
- Admin-only sitemap regeneration endpoint `POST /api/sitemap/regenerate`
### Changed
- Game details page layout restructured: metadata moved inline with hero, system requirements expanded by default
- Improved game details controller navigation (gamepad focusable attributes) and mobile responsiveness
- Switched from dynamic (`force-dynamic`) sitemap generation to build-time static XML files
### Technical
- Added `storage_objects` database table
- Added R2 client module (`lib/storage/`)
- Added `CRON_SECRET` and `R2_*` environment variables; deprecated `S3_*` variables
- Added `build:sitemap` script to `package.json`
## [2026.0.971] - 2026-05-09
### Fixed
- Submit `can't access property "id", (intermediate value).data is undefined`
- Game Card Height in view extending, not fitting actual content.
## [2026.0.97] - 2026-05-06
### Fixed
- Sitemap.xml used ISR caching (`revalidate = 3600`) which poisoned the cache with empty responses on DB hiccups; switched to `force-dynamic` for per-request fresh generation
- DB errors during sitemap generation were silently caught and returned as empty arrays (no games indexed); errors now propagate to observability with structured logging
- Games with NULL `syncStatus` were excluded from sitemap due to SQL `<> 'failed'` returning NULL (not TRUE) for NULL values
- Multi-genre selection in games filter panel only applied the first selected genre; now supports comma-separated OR-matching
- FPS range filter included results from non-active devices; now scoped to the selected device filter when present
### Added
- PWA service worker with offline caching for game pages and images (stale-while-revalidate for HTML, cache-first for Steam CDN images)
- Offline fallback page (`offline.html`) when navigating without network
- Gamepad navigation hook (D-pad/left stick focus, A/B/X/Y buttons, context-aware actions)
- Structured logging for sitemap generation metrics (games, devices, timestamps) via `console.info` JSON
- Filter state synchronized to URL query parameters for shareable/bookmarkable filtered views
- WCAG 2.1 AA touch targets (44×44px) on all games page filter controls
### Changed
- Web manifest icons now declare explicit 192px (maskable) and 512px (any) sizes
- Viewport meta tag added with `viewport-fit=cover` and `user-scalable=no` for installed PWA feel
- Apple mobile web app meta tags added for iOS home screen support
- Loading a saved filter now auto-collapses the filter panel for visual feedback
- Sitemap generation flattened into a single function in `app/sitemap.ts` (removed delegation to `lib/sitemap/`)
### Technical
- Removed `lib/sitemap/fetch-dynamic-entries.ts`, `lib/sitemap/build-static-entries.ts`, `lib/sitemap/validate-image-url.ts`
- Removed `app/api/revalidate-sitemap/` route (no longer needed with `force-dynamic`)
- Added `@serwist/next`, `@serwist/precaching`, `@serwist/sw`, `@serwist/strategies`, `@serwist/expiration`, `@serwist/routing`, and `serwist` dependencies
- Build script updated to use `--webpack` flag for `@serwist/next` compatibility
## [2026.0.96] - 2026-05-01
### Added
- Updates page listing all version release notes
- Update viewer with auto-extracted chapter navigation and reading progress bar
- Markdown-based content pipeline (gray-matter + remark/rehype)
- First update post converted from existing changelog
## [2026.0.95] - 2026-04-30
### Added
#### Anti-Cheat Tracking
- Per-game anti-cheat status display on game details page
- Anti-cheat badges on games list and search results
- Anti-cheat filter in games list
- Anti-cheat awareness step in benchmark submission wizard
- Anti-cheat is game-level property (not per-device)
#### Steam Reviews Integration
- Steam review score badge in game details hero section
- Steam review score display on games list cards
- Steam review score display on search results
- Steam review score and sentiment stored on game records
- Steam review fetching integrated into game sync flow
- Embedded Steam review snippets on game details page
- Steam review score filter in games list (min %)
- Steam review score sort option in games list
#### Playability Indicators
- Auto-calculated playability status (Plays Great / Playable / Needs Tweaks / Unplayable)
- Per-device playability with aggregate game-level status
- Manual override capability for admins/contributors
- Color-coded playability badges across all game views
- "Plays Great" quick-filter in games list
- Auto-recalculates on benchmark submission and Steam sync
#### Games List Revamp
- FPS range filter (min/max)
- FSR support filter
- Proton/Native runtime filter
- Anti-cheat status filter
- Playability status filter
- Steam review score minimum filter
- Free-to-play filter
- Multiplayer filter
- New sort options: Best Performance, Most Popular, Release Date, Steam Reviews
- Saved/bookmarked filter presets
#### Community Suggestions
- "Suggest Edit" button on game details page (non-Steam games only)
- Community suggestion submission for editable fields
- Moderation queue for pending suggestions
- Approve/reject workflow with review notes
- Discord webhook notifications for new suggestions
#### Manage Dashboard
- Overview dashboard replacing simple redirect
- Total games, benchmarks, and users stats cards
- Pending reports and suggestions counters
- 30-day activity metrics
- Top contributors leaderboard
- Playability distribution chart
- Steam sync health overview
#### Benchmark Peer Review
- Enhanced report system for flagging incorrect presets
- Report status tracking (open/reviewed/dismissed)
- Verified badge on peer-reviewed benchmarks
#### Preset Detail Modal
- Added load times (SSD/SD) display
- Added battery life estimate display
- Added custom system indicator
### Changed
- Sync All now processes games in parallel (5 concurrent) instead of one-by-one
- Removed 100-game limit for Sync All (now syncs all Steam games)
- Sync Selected now uses bulk endpoint for faster processing
- Manage page now shows dashboard by default instead of redirecting to users
- Games list now supports 12 filter dimensions and 7 sort options
- Game details page now shows anti-cheat, playability, and Steam reviews prominently
- "Suggest Edit" only appears for non-Steam games (manual, GOG, Epic sources)
- Anti-cheat step in wizard now shows game-level status, not per-device
- Steam sync now sets `syncStatus: "error"` on failure (was missing before)
- Steam sync now handles HTTP 429 rate limiting with Retry-After support
- Steam sync now rejects non-game types (DLC, soundtracks, demos)
### Fixed
- Steam reviews now display correctly (fixed pagination issue with Steam API cursor)
- Steam reviews component now handles missing/error data gracefully
- Anti-cheat badge no longer shows for games without anti-cheat
- Playability calculation only considers anti-cheat if game actually uses it
- API routes registered correctly (removed duplicate `/api` prefix)
- Dashboard and saved-filters routes now accessible
- Sync logic extracted duplicated retry/backoff code into reusable helper
### Technical
- Added `steamReviewScore`, `steamReviewSentiment`, `steamReviewCount` to games table
- Added `playabilityStatus`, `playabilityOverride`, `playabilityCalculatedAt` to games and gamePlatformSupport tables
- Created `community_suggestions` table for moderation workflow
- Created `saved_filters` table for user filter presets
- Added playability auto-calculation engine with `recalculatePlayability()` export
- Added Steam reviews caching API
- Added admin dashboard stats API
- Extracted `recordSyncFailure()` helper for consistent sync error handling
+102
View File
@@ -0,0 +1,102 @@
import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest"
import { NextRequest } from "next/server"
// Mock the auth module — no real betterAuth initialisation should run.
vi.mock("@/lib/auth", () => ({
auth: {
api: {
getSession: vi.fn(),
},
},
}))
// Use dynamic imports so vi.mock is guaranteed to be registered before
// any module evaluation in bun test's shared-module-cache multi-file mode.
let proxy: typeof import("../proxy")["proxy"]
let getSession: ReturnType<typeof vi.fn>
beforeAll(async () => {
const proxyModule = await import("../proxy")
proxy = proxyModule.proxy
const mocked = await import("@/lib/auth")
getSession = mocked.auth.api.getSession
})
function mockSession(overrides: Record<string, unknown> = {}) {
getSession.mockResolvedValue({
user: {
id: "user-1",
name: "Test User",
email: "test@example.com",
role: "user",
...overrides,
},
session: {
id: "session-1",
userId: "user-1",
token: "token-abc",
},
})
}
function mockNoSession() {
getSession.mockResolvedValue(null)
}
beforeEach(() => {
getSession.mockReset()
})
/** Helper to create a NextRequest for a given path on our origin. */
function makeRequest(path: string): NextRequest {
return new NextRequest(new URL(path, "http://localhost:3000"))
}
describe("proxy — signup wizard guard", () => {
it("redirects unauthenticated users on /signup?step=otp to /signup", async () => {
mockNoSession()
const req = makeRequest("/signup?step=otp")
const res = await proxy(req)
expect(res.status).toBe(307)
expect(res.headers.get("location")).toBe("http://localhost:3000/signup")
})
it("redirects unauthenticated users on /signup?step=passkey to /signup", async () => {
mockNoSession()
const req = makeRequest("/signup?step=passkey")
const res = await proxy(req)
expect(res.status).toBe(307)
expect(res.headers.get("location")).toBe("http://localhost:3000/signup")
})
it("allows authenticated users on /signup?step=otp through", async () => {
mockSession()
const req = makeRequest("/signup?step=otp")
const res = await proxy(req)
// NextResponse.next() is not a redirect — it passes the request through.
expect(res.status).not.toBe(307)
})
it("allows authenticated users on /signup?step=passkey through", async () => {
mockSession()
const req = makeRequest("/signup?step=passkey")
const res = await proxy(req)
expect(res.status).not.toBe(307)
})
it("redirects authenticated users on /signup (no step) to /", async () => {
mockSession()
const req = makeRequest("/signup")
const res = await proxy(req)
expect(res.status).toBe(307)
expect(res.headers.get("location")).toBe("http://localhost:3000/")
})
it("allows unauthenticated users on /signup (no step) through", async () => {
mockNoSession()
const req = makeRequest("/signup")
const res = await proxy(req)
expect(res.status).not.toBe(307)
})
})
@@ -0,0 +1,5 @@
import ForgotPasswordForm from "@/components/auth/forgot-password-form"
export default function ForgotPasswordPage() {
return <ForgotPasswordForm />
}
+42
View File
@@ -0,0 +1,42 @@
import type { Metadata } from "next"
import Image from "next/image"
import Link from "next/link"
import logo from "@/app/icon.png"
export const metadata: Metadata = {
title: "Authentication",
}
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="min-h-[calc(100vh-3.6rem)] flex items-center justify-center px-4 relative overflow-hidden">
{/* Background decorative orbs */}
<div className="absolute top-20 left-10 w-48 h-48 rounded-full bg-primary/10 blur-3xl pointer-events-none" />
<div className="absolute bottom-20 right-10 w-36 h-36 rounded-full bg-secondary/15 blur-2xl pointer-events-none" />
{/* Auth card */}
<div className="w-full max-w-md bg-text/5 border border-border rounded-xl p-8 relative z-10">
{/* Logo */}
<Link
href="/"
className="flex flex-col items-center gap-2 mb-8"
>
<Image
src={logo}
alt="DeckyVault Logo"
className="h-10 w-auto"
/>
<span className="text-lg font-bold text-text">
DeckyVault
</span>
</Link>
{children}
</div>
</div>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import LoginForm from "@/components/auth/login-form"
export default function LoginPage() {
return (
<Suspense>
<LoginForm />
</Suspense>
)
}
@@ -0,0 +1,21 @@
import { Suspense } from "react"
import ResetPasswordForm from "@/components/auth/reset-password-form"
import { redirect } from "next/navigation"
interface PageProps {
searchParams: Promise<{ email?: string }>
}
export default async function ResetPasswordPage({ searchParams }: PageProps) {
const { email } = await searchParams
if (!email) {
redirect("/forgot-password")
}
return (
<Suspense>
<ResetPasswordForm email={email} />
</Suspense>
)
}
+10
View File
@@ -0,0 +1,10 @@
import { Suspense } from "react"
import SignupWizard from "@/components/auth/signup-wizard"
export default function SignupPage() {
return (
<Suspense>
<SignupWizard />
</Suspense>
)
}
+47
View File
@@ -0,0 +1,47 @@
import { ManageSidebar } from "@/components/manage/manage-sidebar"
import { auth } from "@/lib/auth"
import type { Metadata } from "next"
import { headers } from "next/headers"
import { redirect } from "next/navigation"
export const metadata: Metadata = {
title: {
template: "%s | Manage — DeckyVault",
default: "Manage — DeckyVault",
},
robots: { index: false, follow: false },
}
export default async function ManageLayout({
children,
}: {
children: React.ReactNode
}) {
const session = await auth.api.getSession({
headers: await headers(),
})
const role = session?.user?.role ?? "user"
if (role !== "moderator" && role !== "admin") {
redirect("/")
}
return (
<section className='w-full flex flex-col gap-8 py-16'>
<div className='px-4 md:px-8 lg:px-12'>
<div className='mx-auto'>
<h1 className='text-2xl sm:text-3xl font-bold'>Manage</h1>
<p className='text-sm text-text/60 mt-1'>
Manage your platform
</p>
</div>
</div>
<div className='px-4 md:px-8 lg:px-12'>
<div className='mx-auto flex flex-col md:flex-row gap-6'>
<ManageSidebar />
<div className='flex-1 min-w-0'>{children}</div>
</div>
</div>
</section>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { AnalyticsClient } from "@/components/manage/analytics-client"
export const metadata: Metadata = {
title: "Analytics",
}
export default function AnalyticsPage() {
return <AnalyticsClient />
}
@@ -0,0 +1,581 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import {
Loader2,
SearchIcon,
ShieldCheckIcon,
TrashIcon,
RefreshCwIcon,
ExternalLinkIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import { ConfirmDialog } from "@/components/ui/modal"
interface PerformanceEntry {
id: string
versionId: string
hardwareSlug: string
userId: string
fpsAvg: number | null
fpsLow: number | null
fpsHigh: number | null
protonVersion: string | null
osVersion: string | null
upscalerType: string | null
upscalerVersion: string | null
frameGenMethod: string | null
loadTimeSsd: number | null
loadTimeSd: number | null
launchOptions: string | null
settingsJson: string | null
userNotes: string | null
customSystem: string | null
isRemoved: boolean
removedReason: string | null
upvotes: number
downvotes: number
verifiedAt: string | null
verifiedBy: string | null
createdAt: string
updatedAt: string
gameId: string
gameTitle: string
versionString: string
hardwareName: string
authorName: string | null
authorImage: string | null
}
interface PerformanceApiResponse {
data: PerformanceEntry[]
total: number
limit: number
offset: number
}
type StatusFilter = "all" | "active" | "removed" | "unverified"
const LIMIT = 20
function formatDate(value: string | Date | null | undefined) {
if (!value) return "—"
const d = typeof value === "string" ? new Date(value) : value
return d.toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
function getInitial(name: string | null | undefined) {
return name?.charAt(0)?.toUpperCase() || "?"
}
function statusBadgeClasses(entry: PerformanceEntry) {
if (entry.verifiedAt) return "bg-green-500/10 text-green-400"
if (entry.isRemoved) return "bg-red-500/10 text-red-400"
return "bg-text/5 text-text/50"
}
function statusLabel(entry: PerformanceEntry) {
if (entry.verifiedAt) return "Verified"
if (entry.isRemoved) return "Removed"
return "Active"
}
function statusDotClass(entry: PerformanceEntry) {
if (entry.verifiedAt) return "bg-green-400"
if (entry.isRemoved) return "bg-red-400"
return "bg-text/40"
}
function formatFps(entry: PerformanceEntry) {
if (entry.fpsAvg == null) return "—"
if (entry.fpsLow != null && entry.fpsHigh != null) {
return `${entry.fpsAvg} (${entry.fpsLow}${entry.fpsHigh})`
}
return `${entry.fpsAvg}`
}
export function BenchmarksClient() {
const [entries, setEntries] = useState<PerformanceEntry[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmAction, setConfirmAction] = useState<
| { type: "verify" | "remove" | "restore" | "hardDelete"; entry: PerformanceEntry }
| null
>(null)
const [removeReason, setRemoveReason] = useState("")
const isSearchChangeRef = useRef(false)
const handleSearchChange = (value: string) => {
setSearch(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handleStatusChange = (value: StatusFilter) => {
setStatusFilter(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handlePrev = () => {
setOffset((prev) => Math.max(0, prev - LIMIT))
isSearchChangeRef.current = false
}
const handleNext = () => {
setOffset((prev) => prev + LIMIT)
isSearchChangeRef.current = false
}
const loadData = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(LIMIT),
offset: String(offset),
})
if (statusFilter === "active") {
params.set("removed", "false")
} else if (statusFilter === "removed") {
params.set("removed", "true")
} else if (statusFilter === "unverified") {
params.set("verified", "false")
}
if (search.trim()) {
params.set("search", search.trim())
}
const res = await fetch(`/api/admin/performance?${params.toString()}`)
if (res.ok) {
const json = (await res.json()) as PerformanceApiResponse
setEntries(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
setLoading(false)
}
}, [search, offset, statusFilter])
useEffect(() => {
const delay = isSearchChangeRef.current ? 300 : 0
isSearchChangeRef.current = false
let cancelled = false
const timer = setTimeout(async () => {
if (!cancelled) {
await loadData()
}
}, delay)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [loadData])
const handleVerify = async (entry: PerformanceEntry) => {
setActionLoading((prev) => ({ ...prev, [entry.id]: true }))
try {
const res = await fetch(`/api/admin/performance/${entry.id}/verify`, {
method: "PATCH",
})
if (res.ok) {
await loadData()
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [entry.id]: false }))
}
}
const handleRemove = async (entry: PerformanceEntry) => {
setActionLoading((prev) => ({ ...prev, [entry.id]: true }))
try {
const body: { reason?: string } = {}
if (removeReason.trim()) {
body.reason = removeReason.trim()
}
const res = await fetch(`/api/admin/performance/${entry.id}/remove`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (res.ok) {
setRemoveReason("")
await loadData()
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [entry.id]: false }))
}
}
const handleHardDelete = async (entry: PerformanceEntry) => {
setActionLoading((prev) => ({ ...prev, [entry.id]: true }))
try {
const res = await fetch(`/api/admin/performance/${entry.id}/hard-delete`, {
method: "DELETE",
})
if (res.ok) {
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
setTotal((prev) => Math.max(0, prev - 1))
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [entry.id]: false }))
}
}
const handleRestore = async (entry: PerformanceEntry) => {
setActionLoading((prev) => ({ ...prev, [entry.id]: true }))
try {
const res = await fetch(`/api/admin/performance/${entry.id}/restore`, {
method: "PATCH",
})
if (res.ok) {
await loadData()
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [entry.id]: false }))
}
}
const hasMore = offset + entries.length < total
const tabs: { label: string; value: StatusFilter }[] = [
{ label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Removed", value: "removed" },
{ label: "Unverified", value: "unverified" },
]
return (
<div className="space-y-4">
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search benchmarks..."
className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => handleStatusChange(tab.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
statusFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[800px]">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Author
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Hardware
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
FPS
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Upscaler
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Votes
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : entries.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-text/50">
No benchmarks found.
</td>
</tr>
) : (
entries.map((entry) => (
<tr
key={entry.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[180px]">
{entry.gameTitle}
</p>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{entry.authorImage ? (
<Image
src={entry.authorImage}
alt={entry.authorName || "User"}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
unoptimized
/>
) : (
<div className="h-8 w-8 rounded-full bg-text/10 flex items-center justify-center text-xs font-medium text-text/70">
{getInitial(entry.authorName)}
</div>
)}
<div className="min-w-0">
<p className="font-medium text-text truncate">
{entry.authorName || "Unknown"}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-text/70 truncate max-w-[120px]">
{entry.hardwareName}
</p>
</td>
<td className="px-4 py-3">
<span className="text-text/70">{formatFps(entry)}</span>
</td>
<td className="px-4 py-3">
<span className="text-text/50">
{entry.upscalerType || "—"}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(entry)}`}
>
{entry.verifiedAt ? (
<ShieldCheckIcon className="h-3 w-3" />
) : (
<span
className={`h-1.5 w-1.5 rounded-full ${statusDotClass(entry)}`}
/>
)}
{statusLabel(entry)}
</span>
</td>
<td className="px-4 py-3">
<span className="inline-flex items-center gap-2 text-xs text-text/70">
<span> {entry.upvotes}</span>
<span> {entry.downvotes}</span>
</span>
</td>
<td className="px-4 py-3 text-xs text-text/50">
{formatDate(entry.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/game/${entry.gameId}`}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
{!entry.verifiedAt && (
<button
onClick={() =>
setConfirmAction({ type: "verify", entry })
}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[entry.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ShieldCheckIcon className="h-3.5 w-3.5" />
)}
Verify
</button>
)}
{!entry.isRemoved ? (
<button
onClick={() =>
setConfirmAction({ type: "remove", entry })
}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[entry.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Remove
</button>
) : (
<button
onClick={() =>
setConfirmAction({ type: "restore", entry })
}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[entry.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCwIcon className="h-3.5 w-3.5" />
)}
Restore
</button>
)}
<button
onClick={() => setConfirmAction({ type: "hardDelete", entry })}
disabled={actionLoading[entry.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-600/10 text-red-500 hover:bg-red-600/20 transition-colors cursor-pointer disabled:opacity-50"
title="Permanently delete"
>
<TrashIcon className="h-3.5 w-3.5" />
Purge
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{entries.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + entries.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation Dialogs */}
{confirmAction?.type === "verify" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleVerify(confirmAction.entry)}
title="Confirm Verify"
message="Are you sure you want to verify this benchmark? It will be marked as verified."
confirmLabel="Verify"
variant="default"
loading={actionLoading[confirmAction.entry.id]}
/>
)}
{confirmAction?.type === "remove" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRemove(confirmAction.entry)}
title="Confirm Remove"
message="Are you sure you want to remove this benchmark?"
confirmLabel="Remove"
variant="destructive"
loading={actionLoading[confirmAction.entry.id]}
>
<textarea
rows={3}
value={removeReason}
onChange={(e) => setRemoveReason(e.target.value)}
placeholder="Optional reason..."
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors resize-none"
/>
</ConfirmDialog>
)}
{confirmAction?.type === "restore" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRestore(confirmAction.entry)}
title="Confirm Restore"
message="Are you sure you want to restore this benchmark?"
confirmLabel="Restore"
variant="default"
loading={actionLoading[confirmAction.entry.id]}
/>
)}
{confirmAction?.type === "hardDelete" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleHardDelete(confirmAction.entry)}
title="⚠️ Permanent Delete"
message="This will permanently delete this benchmark entry. This action cannot be undone."
confirmLabel="Delete Forever"
variant="destructive"
loading={actionLoading[confirmAction.entry.id]}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { BenchmarksClient } from "./benchmarks-client"
export const metadata: Metadata = {
title: "Benchmarks",
}
export default function BenchmarksPage() {
return <BenchmarksClient />
}
@@ -0,0 +1,450 @@
"use client"
import { useCallback, useEffect, useRef, useState } from "react"
import Image from "next/image"
import Link from "next/link"
import {
Loader2,
SearchIcon,
TrashIcon,
RotateCcwIcon,
ExternalLinkIcon,
ChevronLeftIcon,
ChevronRightIcon,
MessageSquareIcon,
} from "lucide-react"
import { ConfirmDialog } from "@/components/ui/modal"
interface Comment {
id: string
gameId: string
content: Record<string, unknown>
upvotes: number
isRemoved: boolean
createdAt: string
updatedAt: string
userId: string
userName: string | null
userImage: string | null
gameTitle: string
parentId: string | null
}
interface CommentsApiResponse {
data: Comment[]
total: number
limit: number
offset: number
}
type StatusFilter = "all" | "active" | "removed"
const LIMIT = 20
function formatDate(value: string | Date | null | undefined) {
if (!value) return "—"
const d = typeof value === "string" ? new Date(value) : value
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function getInitial(name: string | null | undefined) {
return name?.charAt(0)?.toUpperCase() || "?"
}
function extractPlainText(content: Record<string, unknown>, maxLength = 80): string {
let result = ""
function walk(node: unknown) {
if (typeof node !== "object" || node === null) return
if (Array.isArray(node)) {
for (const item of node) {
walk(item)
if (result.length >= maxLength) return
}
return
}
const obj = node as Record<string, unknown>
if (typeof obj.text === "string") {
result += obj.text
if (result.length >= maxLength) return
}
if (Array.isArray(obj.content)) {
for (const item of obj.content) {
walk(item)
if (result.length >= maxLength) return
}
}
}
walk(content)
return result.length > maxLength ? result.slice(0, maxLength) + "…" : result
}
function statusBadgeClasses(isRemoved: boolean) {
return isRemoved
? "bg-red-500/10 text-red-400"
: "bg-green-500/10 text-green-400"
}
function statusDotClass(isRemoved: boolean) {
return isRemoved ? "bg-red-400" : "bg-green-400"
}
export function CommentsClient() {
const [comments, setComments] = useState<Comment[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmAction, setConfirmAction] = useState<
| { type: "remove" | "restore"; comment: Comment }
| null
>(null)
const isSearchChangeRef = useRef(false)
const handleSearchChange = (value: string) => {
setSearch(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handleStatusChange = (value: StatusFilter) => {
setStatusFilter(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handlePrev = () => {
setOffset((prev) => Math.max(0, prev - LIMIT))
isSearchChangeRef.current = false
}
const handleNext = () => {
setOffset((prev) => prev + LIMIT)
isSearchChangeRef.current = false
}
const loadData = useCallback(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(LIMIT),
offset: String(offset),
})
if (statusFilter === "active") {
params.set("removed", "false")
} else if (statusFilter === "removed") {
params.set("removed", "true")
}
if (search.trim()) {
params.set("search", search.trim())
}
const res = await fetch(`/api/admin/comments?${params.toString()}`)
if (res.ok) {
const json = (await res.json()) as CommentsApiResponse
setComments(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
setLoading(false)
}
}, [search, offset, statusFilter])
useEffect(() => {
const delay = isSearchChangeRef.current ? 300 : 0
isSearchChangeRef.current = false
let cancelled = false
const timer = setTimeout(async () => {
if (!cancelled) {
await loadData()
}
}, delay)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [loadData])
const handleRemove = async (comment: Comment) => {
setActionLoading((prev) => ({ ...prev, [comment.id]: true }))
try {
const res = await fetch(`/api/admin/comments/${comment.id}/remove`, {
method: "PATCH",
})
if (res.ok) {
await loadData()
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [comment.id]: false }))
}
}
const handleRestore = async (comment: Comment) => {
setActionLoading((prev) => ({ ...prev, [comment.id]: true }))
try {
const res = await fetch(`/api/admin/comments/${comment.id}/restore`, {
method: "PATCH",
})
if (res.ok) {
await loadData()
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [comment.id]: false }))
}
}
const hasMore = offset + comments.length < total
const tabs: { label: string; value: StatusFilter }[] = [
{ label: "All", value: "all" },
{ label: "Active", value: "active" },
{ label: "Removed", value: "removed" },
]
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<MessageSquareIcon className="h-5 w-5 text-text/70" />
<h1 className="text-lg font-semibold text-text">Comments</h1>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search comments..."
className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => handleStatusChange(tab.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
statusFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Author
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Content
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Upvotes
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : comments.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
No comments found.
</td>
</tr>
) : (
comments.map((comment) => (
<tr
key={comment.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
{comment.userImage ? (
<Image
src={comment.userImage}
alt={comment.userName || "User"}
width={32}
height={32}
className="h-8 w-8 rounded-full object-cover"
unoptimized
/>
) : (
<div className="h-8 w-8 rounded-full bg-text/10 flex items-center justify-center text-xs font-medium text-text/70">
{getInitial(comment.userName)}
</div>
)}
<div className="min-w-0">
<p className="font-medium text-text truncate">
{comment.userName || "Unknown"}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<p className="text-text/70 truncate max-w-[240px]">
{extractPlainText(comment.content, 80)}
</p>
</td>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[150px]">
{comment.gameTitle}
</p>
</td>
<td className="px-4 py-3">
<span className="text-text/70">{comment.upvotes}</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(comment.isRemoved)}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${statusDotClass(comment.isRemoved)}`}
/>
{comment.isRemoved ? "Removed" : "Active"}
</span>
</td>
<td className="px-4 py-3 text-xs text-text/50">
{formatDate(comment.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/game/${comment.gameId}`}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
{!comment.isRemoved ? (
<button
onClick={() =>
setConfirmAction({ type: "remove", comment })
}
disabled={actionLoading[comment.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[comment.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Remove
</button>
) : (
<button
onClick={() =>
setConfirmAction({ type: "restore", comment })
}
disabled={actionLoading[comment.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[comment.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RotateCcwIcon className="h-3.5 w-3.5" />
)}
Restore
</button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{comments.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + comments.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation Dialogs */}
{confirmAction?.type === "remove" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRemove(confirmAction.comment)}
title="Confirm Remove"
message="Are you sure you want to remove this comment?"
confirmLabel="Remove"
variant="destructive"
loading={actionLoading[confirmAction.comment.id]}
/>
)}
{confirmAction?.type === "restore" && (
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => handleRestore(confirmAction.comment)}
title="Confirm Restore"
message="Are you sure you want to restore this comment?"
confirmLabel="Restore"
variant="default"
loading={actionLoading[confirmAction.comment.id]}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { CommentsClient } from "./comments-client"
export const metadata: Metadata = {
title: "Comments",
}
export default function CommentsPage() {
return <CommentsClient />
}
@@ -0,0 +1,776 @@
"use client"
import { useEffect, useRef, useState } from "react"
import { createPortal } from "react-dom"
import Image from "next/image"
import Link from "next/link"
import {
Loader2,
SearchIcon,
ExternalLinkIcon,
TrashIcon,
Gamepad2Icon,
RefreshCwIcon,
CheckCircle2Icon,
XCircleIcon,
} from "lucide-react"
interface SyncProgress {
isRunning: boolean
current: number
total: number
currentGame: string | null
synced: number
failed: number
results: Map<string, { success: boolean; error?: string }>
}
interface Game {
id: string
steamAppId: number | null
title: string
description: string | null
developer: string | null
publisher: string | null
genres: string[] | null
headerImage: string | null
capsuleImage: string | null
storeUrl: string | null
source: "steam" | "manual" | "gog" | "epic"
lastSync: string | null
syncStatus: string | null
syncError: string | null
createdAt: string
updatedAt: string
}
interface GamesApiResponse {
data: Game[]
total: number
limit: number
offset: number
}
const LIMIT = 50
export function GamesClient() {
const [games, setGames] = useState<Game[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set())
const [resyncingIds, setResyncingIds] = useState<Set<string>>(new Set())
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [syncing, setSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<SyncProgress>({
isRunning: false,
current: 0,
total: 0,
currentGame: null,
synced: 0,
failed: 0,
results: new Map(),
})
const [syncCompleted, setSyncCompleted] = useState(false)
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(new Set(games.map((g) => g.id)))
} else {
setSelectedIds(new Set())
}
}
const handleSelectOne = (id: string, checked: boolean) => {
setSelectedIds((prev) => {
const next = new Set(prev)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
const handleSyncSelected = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
const gamesToSync = games.filter((g) => ids.includes(g.id) && g.steamAppId)
if (gamesToSync.length === 0) {
alert("No Steam games selected to sync")
return
}
setSyncing(true)
setSyncCompleted(false)
setSyncProgress({
isRunning: true,
current: 0,
total: gamesToSync.length,
currentGame: "Preparing sync...",
synced: 0,
failed: 0,
results: new Map(),
})
try {
// Use the bulk sync endpoint with streaming progress
const res = await fetch("/api/games/sync/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
mode: "selected",
gameIds: gamesToSync.map((g) => g.id),
}),
})
if (!res.ok) {
const errorData = await res.json().catch(() => null)
throw new Error(errorData?.error || `Sync failed: HTTP ${res.status}`)
}
// Read streaming response
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
try {
const data = JSON.parse(line)
if (data.type === "progress") {
setSyncProgress((prev) => ({
...prev,
current: data.current,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: data.currentGame,
}))
} else if (data.type === "complete") {
setSyncProgress((prev) => ({
...prev,
isRunning: false,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: null,
}))
setSyncCompleted(true)
setSelectedIds(new Set())
}
} catch {
// Ignore parse errors
}
}
}
}
} catch (error) {
console.error("Sync selected failed:", error)
alert(`Sync failed: ${error instanceof Error ? error.message : String(error)}`)
setSyncProgress((prev) => ({
...prev,
isRunning: false,
currentGame: null,
}))
} finally {
setSyncing(false)
}
}
const handleSyncAll = async () => {
if (!confirm("This will sync all Steam games. Continue?")) return
setSyncing(true)
setSyncCompleted(false)
setSyncProgress({
isRunning: true,
current: 0,
total: 0,
currentGame: "Preparing sync...",
synced: 0,
failed: 0,
results: new Map(),
})
try {
// Use the bulk sync endpoint with streaming progress
const res = await fetch("/api/games/sync/bulk", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ mode: "all" }),
})
if (!res.ok) {
const errorData = await res.json().catch(() => null)
throw new Error(errorData?.error || `Sync failed: HTTP ${res.status}`)
}
// Read streaming response
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ""
if (reader) {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split("\n")
buffer = lines.pop() || ""
for (const line of lines) {
if (!line.trim()) continue
try {
const data = JSON.parse(line)
if (data.type === "progress") {
setSyncProgress((prev) => ({
...prev,
current: data.current,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: data.currentGame,
}))
} else if (data.type === "complete") {
setSyncProgress((prev) => ({
...prev,
isRunning: false,
total: data.total,
synced: data.synced,
failed: data.failed,
currentGame: null,
}))
setSyncCompleted(true)
}
} catch {
// Ignore parse errors
}
}
}
}
} catch (error) {
console.error("Sync all failed:", error)
alert(`Sync failed: ${error instanceof Error ? error.message : String(error)}`)
setSyncProgress((prev) => ({
...prev,
isRunning: false,
currentGame: null,
}))
} finally {
setSyncing(false)
}
}
const closeSyncOverlay = () => {
setSyncCompleted(false)
setSyncProgress({
isRunning: false,
current: 0,
total: 0,
currentGame: null,
synced: 0,
failed: 0,
results: new Map(),
})
}
// Close sync overlay on Escape key
useEffect(() => {
if (!syncCompleted) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") closeSyncOverlay()
}
document.addEventListener("keydown", handleKeyDown)
return () => document.removeEventListener("keydown", handleKeyDown)
}, [syncCompleted])
const isSearchChangeRef = useRef(false)
const handleSearchChange = (value: string) => {
setSearch(value)
setOffset(0)
isSearchChangeRef.current = true
setSelectedIds(new Set()) // Clear selection on search change
}
const handlePrev = () => {
setOffset((prev) => Math.max(0, prev - LIMIT))
isSearchChangeRef.current = false
setSelectedIds(new Set()) // Clear selection on page change
}
const handleNext = () => {
setOffset((prev) => prev + LIMIT)
isSearchChangeRef.current = false
}
useEffect(() => {
const delay = isSearchChangeRef.current ? 300 : 0
isSearchChangeRef.current = false
let cancelled = false
const timer = setTimeout(async () => {
setLoading(true)
try {
const res = await fetch(
`/api/games?limit=${LIMIT}&offset=${offset}&search=${encodeURIComponent(search)}&sort=createdAt&order=desc`
)
if (res.ok && !cancelled) {
const json = (await res.json()) as GamesApiResponse
setGames(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false)
}
}, delay)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [search, offset])
const handleResync = async (game: Game) => {
setResyncingIds((prev) => new Set(prev).add(game.id))
try {
const res = await fetch(`/api/games/${game.id}/sync`, {
method: "POST",
})
if (res.ok) {
setGames((prev) =>
prev.map((g) =>
g.id === game.id
? { ...g, syncStatus: "synced", lastSync: new Date().toISOString() }
: g
)
)
}
} catch (error) {
console.error("Resync failed:", error)
} finally {
setResyncingIds((prev) => {
const next = new Set(prev)
next.delete(game.id)
return next
})
}
}
const handleDelete = async (game: Game) => {
if (!confirm(`Are you sure you want to delete "${game.title}"?`)) return
setDeletingIds((prev) => new Set(prev).add(game.id))
try {
const res = await fetch(`/api/games/${game.id}`, { method: "DELETE" })
if (res.ok) {
setGames((prev) => prev.filter((g) => g.id !== game.id))
setTotal((prev) => Math.max(0, prev - 1))
}
} finally {
setDeletingIds((prev) => {
const next = new Set(prev)
next.delete(game.id)
return next
})
}
}
const hasMore = offset + games.length < total
return (
<div className="space-y-4">
{/* Search */}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search games..."
className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSyncAll}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50"
>
{syncing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
Sync All
</button>
</div>
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 w-10">
<input
type="checkbox"
checked={selectedIds.size === games.length && games.length > 0}
onChange={(e) => handleSelectAll(e.target.checked)}
className="rounded border-border"
/>
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50 w-14">
Cover
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Title
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Developer
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Source
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Sync
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : games.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
No games found.
</td>
</tr>
) : (
games.map((game) => (
<tr
key={game.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedIds.has(game.id)}
onChange={(e) => handleSelectOne(game.id, e.target.checked)}
className="rounded border-border"
/>
</td>
<td className="px-4 py-3">
<div className="h-10 w-10 rounded overflow-hidden bg-text/5 flex items-center justify-center">
{game.capsuleImage || game.headerImage ? (
<Image
src={game.capsuleImage || game.headerImage || ""}
alt={game.title}
width={40}
height={40}
className="h-10 w-10 object-cover"
unoptimized
/>
) : (
<Gamepad2Icon className="h-4 w-4 text-text/40" />
)}
</div>
</td>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[200px]">
{game.title}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[150px]">
{game.developer || "—"}
</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
game.source === "steam"
? "bg-blue-500/10 text-blue-400"
: game.source === "manual"
? "bg-text/5 text-text/50"
: "bg-text/5 text-text/50"
}`}
>
{game.source}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
game.syncStatus === "synced"
? "bg-green-500/10 text-green-400"
: game.syncStatus === "failed"
? "bg-red-500/10 text-red-400"
: "bg-yellow-500/10 text-yellow-400"
}`}
title={game.syncStatus === "failed" ? game.syncError || undefined : undefined}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
game.syncStatus === "synced"
? "bg-green-400"
: game.syncStatus === "failed"
? "bg-red-400"
: "bg-yellow-400"
}`}
/>
{game.syncStatus === "synced" ? "Synced" : game.syncStatus === "failed" ? "Failed" : "Stale"}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
href={`/game/${game.id}`}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
<button
onClick={() => handleResync(game)}
disabled={resyncingIds.has(game.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{resyncingIds.has(game.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCwIcon className="h-3.5 w-3.5" />
)}
Resync
</button>
<button
onClick={() => handleDelete(game)}
disabled={deletingIds.has(game.id)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{deletingIds.has(game.id) ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Delete
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Sync Progress Overlay — portaled to body for guaranteed viewport coverage */}
{(syncProgress.isRunning || syncCompleted) &&
createPortal(
<div className="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm flex items-center justify-center">
<div
role="dialog"
aria-modal="true"
aria-labelledby="sync-overlay-title"
className="w-full max-w-md mx-4 p-6 bg-background border border-border rounded-2xl shadow-2xl max-h-[90vh] overflow-y-auto"
>
{syncProgress.isRunning ? (
<>
<div className="flex items-center gap-3 mb-4">
<div className="relative">
<Loader2 className="h-8 w-8 text-primary animate-spin" />
<div className="absolute inset-0 flex items-center justify-center">
<RefreshCwIcon className="h-4 w-4 text-primary" />
</div>
</div>
<div>
<h3 id="sync-overlay-title" className="font-semibold text-text">Syncing Games</h3>
<p className="text-sm text-text/50">
{syncProgress.total > 0
? `${syncProgress.current} of ${syncProgress.total} games`
: "Preparing to sync..."
}
</p>
</div>
</div>
{/* Progress bar */}
<div className="mb-4">
<div className="flex justify-between text-xs text-text/50 mb-1">
<span>Progress</span>
<span>{syncProgress.total > 0 ? Math.round((syncProgress.current / syncProgress.total) * 100) : 0}%</span>
</div>
<div className="h-2 bg-text/10 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300 ease-out"
style={{ width: `${(syncProgress.current / syncProgress.total) * 100}%` }}
/>
</div>
</div>
{/* Current game */}
{syncProgress.currentGame && (
<div className="mb-4 p-3 bg-text/5 rounded-lg">
<p className="text-xs text-text/50 mb-1">Currently syncing:</p>
<p className="text-sm font-medium text-text truncate">{syncProgress.currentGame}</p>
</div>
)}
</>
) : (
<>
{/* Completed state header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<CheckCircle2Icon className="h-8 w-8 text-green-400" />
<div>
<h3 id="sync-overlay-title" className="font-semibold text-text">Sync Complete</h3>
<p className="text-sm text-text/50">
{syncProgress.synced} synced, {syncProgress.failed} failed
</p>
</div>
</div>
<button
onClick={closeSyncOverlay}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
Close
</button>
</div>
</>
)}
{/* Stats — shown in both running and completed states */}
<div className="flex gap-4 mb-4">
<div className="flex-1 p-3 bg-green-500/10 rounded-lg">
<div className="flex items-center gap-2">
<CheckCircle2Icon className="h-4 w-4 text-green-400" />
<span className="text-sm font-medium text-green-400">{syncProgress.synced}</span>
</div>
<p className="text-xs text-text/50 mt-1">Synced</p>
</div>
<div className="flex-1 p-3 bg-red-500/10 rounded-lg">
<div className="flex items-center gap-2">
<XCircleIcon className="h-4 w-4 text-red-400" />
<span className="text-sm font-medium text-red-400">{syncProgress.failed}</span>
</div>
<p className="text-xs text-text/50 mt-1">Failed</p>
</div>
</div>
{/* Results list — scrollable */}
{syncProgress.results.size > 0 && (
<div className="max-h-48 overflow-y-auto space-y-1">
<p className="text-xs text-text/50 mb-2">
{syncCompleted ? "All results:" : "Recent results:"}
</p>
{(syncCompleted
? Array.from(syncProgress.results.entries())
: Array.from(syncProgress.results.entries()).slice(-5).reverse()
).map(([gameId, result]) => {
const game = games.find((g) => g.id === gameId)
return (
<div key={gameId} className="flex items-center gap-2 py-1">
{result.success ? (
<CheckCircle2Icon className="h-3 w-3 text-green-400 shrink-0" />
) : (
<XCircleIcon className="h-3 w-3 text-red-400 shrink-0" />
)}
<span className="text-xs text-text/70 truncate">
{game?.title || gameId}
</span>
{!result.success && result.error && (
<span className="text-xs text-red-400/70 ml-auto shrink-0 truncate max-w-[150px]" title={result.error}>
{result.error}
</span>
)}
</div>
)
})}
</div>
)}
</div>
</div>,
document.body
)}
{/* Floating action bar */}
{selectedIds.size > 0 && !syncProgress.isRunning && (
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 px-4 py-3 bg-background border border-border rounded-xl shadow-lg">
<span className="text-sm text-text/70">
{selectedIds.size} game{selectedIds.size !== 1 ? "s" : ""} selected
</span>
<button
onClick={handleSyncSelected}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-colors text-sm font-medium cursor-pointer disabled:opacity-50"
>
<RefreshCwIcon className="h-4 w-4" />
Resync Selected
</button>
<button
onClick={handleSyncAll}
disabled={syncing}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-text/5 text-text hover:bg-text/10 transition-colors text-sm font-medium cursor-pointer disabled:opacity-50"
>
<RefreshCwIcon className="h-4 w-4" />
Resync All
</button>
<button
onClick={() => setSelectedIds(new Set())}
className="px-3 py-2 rounded-lg text-sm text-text/50 hover:text-text/70 transition-colors cursor-pointer"
>
Clear
</button>
</div>
)}
{/* Pagination */}
{games.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + games.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { GamesClient } from "./games-client"
export const metadata: Metadata = {
title: "Games",
}
export default function GamesPage() {
return <GamesClient />
}
@@ -0,0 +1,411 @@
"use client"
import { useEffect, useState, FormEvent } from "react"
import {
Gamepad2Icon,
PlusIcon,
PencilIcon,
TrashIcon,
Loader2,
XIcon,
} from "lucide-react"
import { getDeviceColor } from "@/components/charts/EChartWrapper"
interface DeviceItem {
slug: string
name: string
deviceType: string
image: string | null
sortOrder: number
totalBenchmarks?: number
avgFps?: number | null
gameCount?: number
wattHours?: number | null
tdpMax?: number | null
}
interface DeviceFormData {
slug: string
name: string
deviceType: "handheld" | "console"
image: string
sortOrder: number
wattHours: string
tdpMax: string
}
export function HardwareClient() {
const [devices, setDevices] = useState<DeviceItem[]>([])
const [loading, setLoading] = useState(true)
const [showModal, setShowModal] = useState(false)
const [editingDevice, setEditingDevice] = useState<DeviceItem | null>(null)
const [form, setForm] = useState<DeviceFormData>({
slug: "",
name: "",
deviceType: "handheld",
image: "",
sortOrder: 0,
wattHours: "",
tdpMax: "",
})
const [saving, setSaving] = useState(false)
const [deleting, setDeleting] = useState(false)
const fetchDevices = async () => {
setLoading(true)
try {
const res = await fetch("/api/hardware/stats")
if (res.ok) {
const data = await res.json()
setDevices(
(data as DeviceItem[]).map((item) => ({
...item,
image: item.image ?? null,
}))
)
}
} catch {
// ignore
} finally {
setLoading(false)
}
}
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchDevices()
}, [])
const openCreate = () => {
setEditingDevice(null)
setForm({
slug: "",
name: "",
deviceType: "handheld",
image: "",
sortOrder: 0,
wattHours: "",
tdpMax: "",
})
setShowModal(true)
}
const openEdit = (device: DeviceItem) => {
setEditingDevice(device)
setForm({
slug: device.slug,
name: device.name,
deviceType: device.deviceType as "handheld" | "console",
image: device.image ?? "",
sortOrder: device.sortOrder,
wattHours: device.wattHours != null ? String(device.wattHours) : "",
tdpMax: device.tdpMax != null ? String(device.tdpMax) : "",
})
setShowModal(true)
}
const closeModal = () => {
setShowModal(false)
setEditingDevice(null)
}
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault()
setSaving(true)
try {
const body = editingDevice
? {
name: form.name,
deviceType: form.deviceType,
image: form.image || null,
sortOrder: form.sortOrder,
wattHours: form.wattHours ? parseFloat(form.wattHours) : null,
tdpMax: form.tdpMax ? parseFloat(form.tdpMax) : null,
}
: {
slug: form.slug,
name: form.name,
deviceType: form.deviceType,
image: form.image || null,
sortOrder: form.sortOrder,
wattHours: form.wattHours ? parseFloat(form.wattHours) : null,
tdpMax: form.tdpMax ? parseFloat(form.tdpMax) : null,
}
const url = editingDevice
? `/api/hardware/${editingDevice.slug}`
: "/api/hardware"
const method = editingDevice ? "PATCH" : "POST"
const res = await fetch(url, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
})
if (res.ok) {
closeModal()
await fetchDevices()
}
} finally {
setSaving(false)
}
}
const handleDelete = async (slug: string) => {
if (!confirm("Are you sure you want to delete this device?")) return
setDeleting(true)
try {
const res = await fetch(`/api/hardware/${slug}`, { method: "DELETE" })
if (res.ok) {
await fetchDevices()
}
} finally {
setDeleting(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-text">Hardware</h1>
<button
onClick={openCreate}
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium bg-primary/10 text-primary hover:bg-primary/20 transition-colors cursor-pointer"
>
<PlusIcon className="h-4 w-4" />
Add Device
</button>
</div>
{loading ? (
<div className="flex items-center justify-center py-16 text-text/50">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : devices.length === 0 ? (
<div className="text-center py-16 text-text/40">
<Gamepad2Icon className="h-10 w-10 mx-auto mb-2" />
<p>No devices found</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{devices.map((device, index) => {
const color = getDeviceColor(index)
return (
<div
key={device.slug}
className="rounded-xl border border-border bg-text/[0.02] p-4 flex flex-col gap-3 hover:bg-text/[0.04] transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div
className="h-10 w-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${color}20`, color }}
>
<Gamepad2Icon className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium text-text text-sm">
{device.name}
</h3>
<p className="text-xs text-text/50">/{device.slug}</p>
</div>
</div>
<span
className={`text-[10px] uppercase tracking-wider px-2 py-0.5 rounded-full border ${
device.deviceType === "handheld"
? "text-primary bg-primary/10 border-primary/20"
: "text-secondary bg-secondary/10 border-secondary/20"
}`}
>
{device.deviceType}
</span>
</div>
<div className="text-xs text-text/60">
{device.totalBenchmarks ?? 0} benchmarks
</div>
{(device.wattHours != null || device.tdpMax != null) && (
<div className="text-xs text-text/60 flex gap-2">
{device.wattHours != null && <span>{device.wattHours} Wh</span>}
{device.tdpMax != null && <span>{device.tdpMax}W TDP</span>}
</div>
)}
<div className="flex items-center gap-2 mt-auto">
<button
onClick={() => openEdit(device)}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
<PencilIcon className="h-3.5 w-3.5" />
Edit
</button>
<button
onClick={() => handleDelete(device.slug)}
disabled={deleting}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
<TrashIcon className="h-3.5 w-3.5" />
Delete
</button>
</div>
</div>
)
})}
</div>
)}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={closeModal}
/>
<div className="relative bg-background border border-border rounded-xl p-6 max-w-md w-full mx-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-medium text-text">
{editingDevice ? "Edit Device" : "Add Device"}
</h2>
<button
onClick={closeModal}
className="p-1 rounded-md hover:bg-text/5 text-text/60 hover:text-text transition-colors cursor-pointer"
>
<XIcon className="h-4 w-4" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Slug
</label>
<input
type="text"
value={form.slug}
onChange={(e) =>
setForm((f) => ({ ...f, slug: e.target.value }))
}
disabled={!!editingDevice}
required
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Name
</label>
<input
type="text"
value={form.name}
onChange={(e) =>
setForm((f) => ({ ...f, name: e.target.value }))
}
required
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Device Type
</label>
<select
value={form.deviceType}
onChange={(e) =>
setForm((f) => ({
...f,
deviceType: e.target.value as "handheld" | "console",
}))
}
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text focus:outline-none focus:border-primary/60 transition-colors"
>
<option value="handheld">Handheld</option>
<option value="console">Console</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Sort Order
</label>
<input
type="number"
value={form.sortOrder}
onChange={(e) =>
setForm((f) => ({
...f,
sortOrder: Number(e.target.value),
}))
}
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Image URL
</label>
<input
type="text"
value={form.image}
onChange={(e) =>
setForm((f) => ({ ...f, image: e.target.value }))
}
placeholder="https://..."
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Battery Capacity (Wh)
</label>
<input
type="number"
value={form.wattHours}
onChange={(e) => setForm((f) => ({ ...f, wattHours: e.target.value }))}
placeholder="e.g. 50.0"
min="0"
max="200"
step="0.1"
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
<p className="text-[10px] text-text/40 mt-1">
Watt-hours (used for battery life estimation)
</p>
</div>
<div>
<label className="block text-xs font-medium text-text/70 mb-1">
Max TDP (W)
</label>
<input
type="number"
value={form.tdpMax}
onChange={(e) => setForm((f) => ({ ...f, tdpMax: e.target.value }))}
placeholder="e.g. 15"
min="0"
max="100"
step="0.1"
className="w-full px-3 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
<p className="text-[10px] text-text/40 mt-1">
Maximum TDP in watts (used for battery life estimation)
</p>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button
type="button"
onClick={closeModal}
className="px-3 py-2 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="flex items-center gap-1.5 px-3 py-2 rounded-md text-xs font-medium bg-primary text-white hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50"
>
{saving && <Loader2 className="h-3.5 w-3.5 animate-spin" />}
{editingDevice ? "Save Changes" : "Create Device"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { HardwareClient } from "./hardware-client"
export const metadata: Metadata = {
title: "Hardware",
}
export default function HardwarePage() {
return <HardwareClient />
}
+15
View File
@@ -0,0 +1,15 @@
import { DashboardOverview } from "@/components/manage/dashboard-overview";
export default function ManagePage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold">Dashboard</h1>
<p className="text-sm text-zinc-400">
Overview of DeckyVault statistics and moderation queue
</p>
</div>
<DashboardOverview />
</div>
);
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { ReportsClient } from "./reports-client"
export const metadata: Metadata = {
title: "Reports",
}
export default function ReportsPage() {
return <ReportsClient />
}
@@ -0,0 +1,460 @@
"use client"
import { useEffect, useMemo, useRef, useState } from "react"
import Image from "next/image"
import {
Loader2,
SearchIcon,
ShieldCheckIcon,
XIcon,
ChevronLeftIcon,
ChevronRightIcon,
FlagIcon,
} from "lucide-react"
import { ConfirmDialog } from "@/components/ui/modal"
interface Report {
id: string
entryId: string
reporterId: string
reporterName: string | null
reason: "inaccurate" | "spam" | "inappropriate" | "other"
details: string | null
status: "open" | "reviewed" | "dismissed"
createdAt: string
entry: {
id: string
userId: string
fpsAvg: number | null
fpsLow: number | null
fpsHigh: number | null
upscalerType: string | null
userNotes: string | null
isRemoved: boolean
authorName: string | null
}
gameVersion: {
id: string
versionString: string
}
game: {
id: string
title: string
headerImage: string | null
}
}
interface ReportsApiResponse {
data: Report[]
total: number
limit: number
offset: number
}
type StatusFilter = "all" | "open" | "reviewed" | "dismissed"
const LIMIT = 20
function formatDate(value: string | Date | null | undefined) {
if (!value) return "—"
const d = typeof value === "string" ? new Date(value) : value
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function getInitial(name: string | null | undefined) {
return name?.charAt(0)?.toUpperCase() || "?"
}
function truncate(str: string | null | undefined, maxLen = 60) {
if (!str) return "—"
return str.length > maxLen ? str.slice(0, maxLen) + "…" : str
}
function statusBadgeClasses(status: Report["status"]) {
switch (status) {
case "open":
return "bg-yellow-500/10 text-yellow-400"
case "reviewed":
return "bg-green-500/10 text-green-400"
case "dismissed":
return "bg-text/5 text-text/50"
}
}
function reasonBadgeClasses(reason: Report["reason"]) {
switch (reason) {
case "inaccurate":
return "bg-blue-500/10 text-blue-400"
case "spam":
return "bg-red-500/10 text-red-400"
case "inappropriate":
return "bg-orange-500/10 text-orange-400"
case "other":
return "bg-text/5 text-text/50"
}
}
export function ReportsClient() {
const [reports, setReports] = useState<Report[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmReport, setConfirmReport] = useState<Report | null>(null)
const isSearchChangeRef = useRef(false)
const handleSearchChange = (value: string) => {
setSearch(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handleStatusChange = (value: StatusFilter) => {
setStatusFilter(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handlePrev = () => {
setOffset((prev) => Math.max(0, prev - LIMIT))
isSearchChangeRef.current = false
}
const handleNext = () => {
setOffset((prev) => prev + LIMIT)
isSearchChangeRef.current = false
}
useEffect(() => {
const delay = isSearchChangeRef.current ? 300 : 0
isSearchChangeRef.current = false
let cancelled = false
const timer = setTimeout(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(LIMIT),
offset: String(offset),
})
if (statusFilter !== "all") {
params.set("status", statusFilter)
}
const res = await fetch(`/api/admin/reports?${params.toString()}`)
if (res.ok && !cancelled) {
const json = (await res.json()) as ReportsApiResponse
setReports(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false)
}
}, delay)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [search, offset, statusFilter])
const handleUpdateStatus = async (report: Report, status: "reviewed" | "dismissed") => {
setActionLoading((prev) => ({ ...prev, [report.id]: true }))
try {
const res = await fetch(`/api/admin/reports/${report.id}/status`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
})
if (res.ok) {
// Refresh list after action
const params = new URLSearchParams({
limit: String(LIMIT),
offset: String(offset),
})
if (statusFilter !== "all") {
params.set("status", statusFilter)
}
const listRes = await fetch(`/api/admin/reports?${params.toString()}`)
if (listRes.ok) {
const json = (await listRes.json()) as ReportsApiResponse
setReports(json.data)
setTotal(json.total)
}
setConfirmReport(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [report.id]: false }))
}
}
const filteredReports = useMemo(() => {
const term = search.trim().toLowerCase()
if (!term) return reports
return reports.filter(
(r) =>
r.reporterName?.toLowerCase().includes(term) ||
r.game.title.toLowerCase().includes(term) ||
r.reason.toLowerCase().includes(term) ||
r.details?.toLowerCase().includes(term) ||
r.status.toLowerCase().includes(term)
)
}, [reports, search])
const hasMore = offset + filteredReports.length < total
const tabs: { label: string; value: StatusFilter }[] = [
{ label: "All", value: "all" },
{ label: "Open", value: "open" },
{ label: "Reviewed", value: "reviewed" },
{ label: "Dismissed", value: "dismissed" },
]
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-3">
<FlagIcon className="h-5 w-5 text-text/70" />
<h1 className="text-lg font-semibold text-text">Reports</h1>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search reports..."
className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
{/* Filter tabs */}
<div className="flex items-center gap-1">
{tabs.map((tab) => (
<button
key={tab.value}
onClick={() => handleStatusChange(tab.value)}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
statusFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
{/* Table */}
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Reporter
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
FPS
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Reason
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Details
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : filteredReports.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-text/50">
No reports found.
</td>
</tr>
) : (
filteredReports.map((report) => (
<tr
key={report.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-text/10 flex items-center justify-center text-xs font-medium text-text/70">
{getInitial(report.reporterName)}
</div>
<div className="min-w-0">
<p className="font-medium text-text truncate">
{report.reporterName || "Unknown"}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{report.game.headerImage ? (
<Image
src={report.game.headerImage}
alt={report.game.title}
width={40}
height={20}
className="h-5 w-10 object-cover rounded"
unoptimized
/>
) : null}
<p className="font-medium text-text truncate max-w-[150px]">
{report.game.title}
</p>
</div>
</td>
<td className="px-4 py-3">
<span className="text-text/70">
{report.entry.fpsAvg ?? "—"}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${reasonBadgeClasses(report.reason)}`}
>
{report.reason}
</span>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[200px]">
{truncate(report.details, 80)}
</p>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(report.status)}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
report.status === "open"
? "bg-yellow-400"
: report.status === "reviewed"
? "bg-green-400"
: "bg-text/40"
}`}
/>
{report.status}
</span>
</td>
<td className="px-4 py-3 text-xs text-text/50">
{formatDate(report.createdAt)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{report.status === "open" ? (
<>
<button
onClick={() => setConfirmReport(report)}
disabled={actionLoading[report.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[report.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<ShieldCheckIcon className="h-3.5 w-3.5" />
)}
Review
</button>
<button
onClick={() => handleUpdateStatus(report, "dismissed")}
disabled={actionLoading[report.id]}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text/70 hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[report.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<XIcon className="h-3.5 w-3.5" />
)}
Dismiss
</button>
</>
) : (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${statusBadgeClasses(report.status)}`}
>
{report.status}
</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
{filteredReports.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + filteredReports.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirmation Dialog */}
{confirmReport && (
<ConfirmDialog
open={!!confirmReport}
onClose={() => setConfirmReport(null)}
onConfirm={() => handleUpdateStatus(confirmReport, "reviewed")}
title="Confirm Review"
message="This will also remove the reported benchmark. Are you sure?"
confirmLabel="Review"
variant="default"
loading={actionLoading[confirmReport.id]}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { StorageClient } from "./storage-client"
export const metadata: Metadata = {
title: "Storage",
}
export default function StoragePage() {
return <StorageClient />
}
@@ -0,0 +1,447 @@
"use client"
import { useEffect, useRef, useState } from "react"
import {
Loader2,
HardDriveIcon,
TrashIcon,
SearchIcon,
RefreshCwIcon,
AlertTriangleIcon,
ChevronLeftIcon,
ChevronRightIcon,
FileIcon,
CheckCircle2Icon,
} from "lucide-react"
import { ConfirmDialog } from "@/components/ui/modal"
type EntityType = "all" | "avatar" | "entry_screenshot" | "game_cover" | "hardware_image" | "orphaned"
interface StorageStats {
configured: boolean
bucketName: string
totalObjects: number
totalSizeBytes: number
orphanedCount: number
orphanedSizeBytes: number
byEntityType: Record<string, { count: number; totalSizeBytes: number }>
}
interface StorageObject {
id: string
key: string
bucket: string
size: number
mimeType: string
entityType: string
entityId: string | null
uploadedBy: string
uploaderName: string | null
createdAt: string
lastAccessedAt: string | null
isOrphaned: boolean
}
interface ObjectsResponse {
data: StorageObject[]
total: number
limit: number
offset: number
}
const LIMIT = 50
function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`
}
function formatDate(value: string | null | undefined) {
if (!value) return "—"
return new Date(value).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function entityTypeLabel(type: string) {
const labels: Record<string, string> = {
avatar: "Avatar",
entry_screenshot: "Screenshot",
game_cover: "Game Cover",
hardware_image: "Hardware Img",
}
return labels[type] ?? type
}
function entityTypeBadgeClass(type: string) {
const classes: Record<string, string> = {
avatar: "bg-purple-500/10 text-purple-400",
entry_screenshot: "bg-blue-500/10 text-blue-400",
game_cover: "bg-green-500/10 text-green-400",
hardware_image: "bg-amber-500/10 text-amber-400",
}
return classes[type] ?? "bg-text/5 text-text/50"
}
export function StorageClient() {
const [stats, setStats] = useState<StorageStats | null>(null)
const [objects, setObjects] = useState<StorageObject[]>([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [statsLoading, setStatsLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [entityFilter, setEntityFilter] = useState<EntityType>("all")
const [deleting, setDeleting] = useState<string | null>(null)
const [confirmDelete, setConfirmDelete] = useState<StorageObject | null>(null)
const [cleanupLoading, setCleanupLoading] = useState(false)
const [cleanupResult, setCleanupResult] = useState<{ deleted: number; errors: number } | null>(null)
const isSearchChangeRef = useRef(false)
useEffect(() => {
fetch("/api/admin/storage/stats")
.then((res) => res.json())
.then((data) => {
setStats(data)
setStatsLoading(false)
})
.catch(() => setStatsLoading(false))
}, [])
useEffect(() => {
const delay = isSearchChangeRef.current ? 300 : 0
isSearchChangeRef.current = false
let cancelled = false
const timer = setTimeout(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
limit: String(LIMIT),
offset: String(offset),
})
if (entityFilter === "orphaned") {
params.set("orphaned", "true")
} else if (entityFilter !== "all") {
params.set("entityType", entityFilter)
}
if (search.trim()) {
params.set("search", search.trim())
}
const res = await fetch(`/api/admin/storage/objects?${params.toString()}`)
if (res.ok && !cancelled) {
const json = (await res.json()) as ObjectsResponse
setObjects(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false)
}
}, delay)
return () => {
cancelled = true
clearTimeout(timer)
}
}, [search, offset, entityFilter])
const handleSearchChange = (value: string) => {
setSearch(value)
setOffset(0)
isSearchChangeRef.current = true
}
const handleDelete = async (obj: StorageObject) => {
setDeleting(obj.id)
try {
const res = await fetch(`/api/admin/storage/objects/${obj.id}`, {
method: "DELETE",
})
if (res.ok) {
setObjects((prev) => prev.filter((o) => o.id !== obj.id))
setTotal((prev) => Math.max(0, prev - 1))
setConfirmDelete(null)
const statsRes = await fetch("/api/admin/storage/stats")
if (statsRes.ok) {
setStats(await statsRes.json())
}
}
} finally {
setDeleting(null)
}
}
const handleCleanup = async () => {
setCleanupLoading(true)
setCleanupResult(null)
try {
const res = await fetch("/api/admin/storage/cleanup", { method: "POST" })
if (res.ok) {
const data = await res.json()
setCleanupResult({ deleted: data.deleted, errors: data.errors })
const statsRes = await fetch("/api/admin/storage/stats")
if (statsRes.ok) setStats(await statsRes.json())
setOffset(0)
}
} finally {
setCleanupLoading(false)
}
}
const hasMore = offset + objects.length < total
const filterTabs: { label: string; value: EntityType }[] = [
{ label: "All", value: "all" },
{ label: "Avatars", value: "avatar" },
{ label: "Screenshots", value: "entry_screenshot" },
{ label: "Covers", value: "game_cover" },
{ label: "Hardware", value: "hardware_image" },
{ label: "Orphaned", value: "orphaned" },
]
return (
<div className="space-y-6">
<div className="flex items-center gap-3">
<HardDriveIcon className="h-5 w-5 text-text/70" />
<h1 className="text-lg font-semibold text-text">Storage</h1>
</div>
{stats && !stats.configured && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 p-4 flex items-start gap-3">
<AlertTriangleIcon className="h-5 w-5 text-amber-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-400">R2 Not Configured</p>
<p className="text-xs text-text/60 mt-1">
Set the R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, and R2_PUBLIC_URL environment variables to enable storage management.
</p>
</div>
</div>
)}
{statsLoading ? (
<div className="animate-pulse h-20 rounded-lg bg-zinc-800" />
) : stats && stats.configured ? (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<FileIcon className="h-4 w-4 text-blue-400" />
<span className="text-xs text-zinc-500">Total Objects</span>
</div>
<p className="text-2xl font-bold">{stats.totalObjects.toLocaleString()}</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<HardDriveIcon className="h-4 w-4 text-green-400" />
<span className="text-xs text-zinc-500">Total Size</span>
</div>
<p className="text-2xl font-bold">{formatBytes(stats.totalSizeBytes)}</p>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<AlertTriangleIcon className="h-4 w-4 text-amber-400" />
<span className="text-xs text-zinc-500">Orphaned</span>
</div>
<p className="text-2xl font-bold">{stats.orphanedCount.toLocaleString()}</p>
{stats.orphanedSizeBytes > 0 && (
<p className="text-xs text-zinc-500">{formatBytes(stats.orphanedSizeBytes)}</p>
)}
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<div className="flex items-center gap-2 mb-2">
<HardDriveIcon className="h-4 w-4 text-purple-400" />
<span className="text-xs text-zinc-500">Bucket</span>
</div>
<p className="text-sm font-mono text-text truncate">{stats.bucketName}</p>
</div>
</div>
) : null}
{stats?.byEntityType && Object.keys(stats.byEntityType).length > 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
<h3 className="mb-3 font-semibold text-sm">Storage by Type</h3>
<div className="flex gap-4 flex-wrap">
{Object.entries(stats.byEntityType).map(([type, data]) => (
<div key={type} className="flex items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${entityTypeBadgeClass(type)}`}>
{entityTypeLabel(type)}
</span>
<span className="text-xs text-text/70">{data.count} · {formatBytes(data.totalSizeBytes)}</span>
</div>
))}
</div>
</div>
)}
{stats?.configured && (
<div className="flex items-center gap-3">
<button
onClick={handleCleanup}
disabled={cleanupLoading || !stats?.configured}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-500/10 text-amber-400 text-sm font-medium hover:bg-amber-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{cleanupLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCwIcon className="h-4 w-4" />
)}
Run Orphan Cleanup
</button>
{cleanupResult && (
<div className="flex items-center gap-2 text-sm">
<CheckCircle2Icon className="h-4 w-4 text-green-400" />
<span className="text-text/70">
Deleted {cleanupResult.deleted} objects
{cleanupResult.errors > 0 && `, ${cleanupResult.errors} errors`}
</span>
</div>
)}
</div>
)}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => handleSearchChange(e.target.value)}
placeholder="Search by key..."
className="w-full pl-9 pr-4 py-2 rounded-md bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
<div className="flex items-center gap-1 flex-wrap">
{filterTabs.map((tab) => (
<button
key={tab.value}
onClick={() => {
setEntityFilter(tab.value)
setOffset(0)
}}
className={`px-3 py-1.5 rounded-md text-xs font-medium transition-colors cursor-pointer ${
entityFilter === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/70 hover:bg-text/10"
}`}
>
{tab.label}
</button>
))}
</div>
<div className="rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Key</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Type</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Size</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Uploaded By</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Date</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Status</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">Actions</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</td>
</tr>
) : objects.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-text/50">No objects found.</td>
</tr>
) : (
objects.map((obj) => (
<tr
key={obj.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<p className="font-mono text-xs text-text/70 truncate max-w-[300px]" title={obj.key}>{obj.key}</p>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${entityTypeBadgeClass(obj.entityType)}`}>
{entityTypeLabel(obj.entityType)}
</span>
</td>
<td className="px-4 py-3 text-xs text-text/70">{formatBytes(obj.size)}</td>
<td className="px-4 py-3 text-xs text-text/70">{obj.uploaderName || "System"}</td>
<td className="px-4 py-3 text-xs text-text/50">{formatDate(obj.createdAt)}</td>
<td className="px-4 py-3">
{obj.isOrphaned && (
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider bg-amber-500/10 text-amber-400">
Orphaned
</span>
)}
</td>
<td className="px-4 py-3">
<button
onClick={() => setConfirmDelete(obj)}
disabled={deleting === obj.id}
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{deleting === obj.id ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<TrashIcon className="h-3.5 w-3.5" />
)}
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{objects.length > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + objects.length, total)} of {total}
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setOffset((prev) => Math.max(0, prev - LIMIT))}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={() => setOffset((prev) => prev + LIMIT)}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{confirmDelete && (
<ConfirmDialog
open={!!confirmDelete}
onClose={() => setConfirmDelete(null)}
onConfirm={() => handleDelete(confirmDelete)}
title="Delete Storage Object"
message={`Are you sure you want to delete "${confirmDelete.key}"? This will remove the file from R2 storage and the database record. This action cannot be undone.`}
confirmLabel="Delete"
variant="destructive"
loading={deleting === confirmDelete.id}
/>
)}
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { SuggestionsClient } from "./suggestions-client"
export const metadata: Metadata = {
title: "Suggestions",
}
export default function SuggestionsPage() {
return <SuggestionsClient />
}
@@ -0,0 +1,383 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { cn } from "@/lib/utils"
import { ConfirmDialog } from "@/components/ui/modal"
import {
Loader2,
SearchIcon,
Lightbulb,
ExternalLinkIcon,
CheckCircle2Icon,
XCircleIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
interface Suggestion {
id: string
gameId: string
gameTitle: string
fieldName: string
currentValue: string | null
proposedValue: string
reason: string | null
status: string
createdAt: string
userName: string | null
}
type ConfirmType = "approve" | "reject"
interface SuggestionsApiResponse {
data: Suggestion[]
total: number
limit: number
offset: number
}
const LIMIT = 50
function formatDate(value: string | Date | null | undefined) {
if (!value) return "—"
const d = typeof value === "string" ? new Date(value) : value
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
export function SuggestionsClient() {
const [suggestions, setSuggestions] = useState<Suggestion[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [offset, setOffset] = useState(0)
const [total, setTotal] = useState(0)
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [confirmAction, setConfirmAction] = useState<{
type: ConfirmType
suggestion: Suggestion
} | null>(null)
const [activeTab, setActiveTab] = useState("all")
const statusTabs = [
{ label: "All", value: "all" },
{ label: "Pending", value: "pending" },
{ label: "Approved", value: "approved" },
{ label: "Rejected", value: "rejected" },
]
useEffect(() => {
let cancelled = false
const run = async () => {
try {
const res = await fetch(`/api/community-suggestions/admin?limit=${LIMIT}`)
if (res.ok && !cancelled) {
const json = (await res.json()) as SuggestionsApiResponse
setSuggestions(json.data)
setTotal(json.total)
}
} catch {
// ignore
} finally {
if (!cancelled) setLoading(false)
}
}
run()
return () => { cancelled = true }
}, [])
const filtered = useMemo(() => {
let result = suggestions
const term = search.trim().toLowerCase()
if (term) {
result = result.filter(
(s) =>
s.gameTitle?.toLowerCase().includes(term) ||
s.fieldName?.toLowerCase().includes(term) ||
s.proposedValue?.toLowerCase().includes(term) ||
s.userName?.toLowerCase().includes(term) ||
s.reason?.toLowerCase().includes(term)
)
}
if (activeTab !== "all") {
result = result.filter((s) => s.status === activeTab)
}
return result
}, [suggestions, search, activeTab])
const paginated = filtered.slice(offset, offset + LIMIT)
const hasMore = offset + LIMIT < total
const handlePrev = () => setOffset((prev) => Math.max(0, prev - LIMIT))
const handleNext = () => setOffset((prev) => prev + LIMIT)
const handleReview = async (
suggestion: Suggestion,
status: "approved" | "rejected"
) => {
setActionLoading((prev) => ({ ...prev, [suggestion.id]: true }))
try {
const res = await fetch(
`/api/community-suggestions/${suggestion.id}/review`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status }),
}
)
if (res.ok) {
setSuggestions((prev) =>
prev.filter((s) => s.id !== suggestion.id)
)
setConfirmAction(null)
}
} finally {
setActionLoading((prev) => ({ ...prev, [suggestion.id]: false }))
}
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center gap-2">
<Lightbulb className="h-5 w-5 text-text/50" />
<h2 className="text-lg font-semibold text-text">Suggestions</h2>
<span className="text-sm text-text/50">({filtered.length})</span>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search suggestions..."
className="w-full pl-9 pr-4 py-2.5 rounded-lg bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
{/* Status Tabs */}
<div className="flex items-center gap-2">
{statusTabs.map((tab) => (
<button
key={tab.value}
onClick={() => setActiveTab(tab.value)}
className={cn(
"px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
activeTab === tab.value
? "bg-primary/10 text-primary"
: "bg-text/5 text-text/60 hover:bg-text/10"
)}
>
{tab.label}
</button>
))}
</div>
{/* Suggestions List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : paginated.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text/50">
<Lightbulb className="h-8 w-8 mb-2" />
<p className="text-sm">No suggestions found</p>
</div>
) : (
<div className="rounded-xl border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-text/[0.03]">
<tr>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Game
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Field
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Current
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Proposed
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Reason
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
By
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Date
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Status
</th>
<th className="text-left px-4 py-3 text-xs font-medium uppercase tracking-wider text-text/50">
Actions
</th>
</tr>
</thead>
<tbody>
{paginated.map((suggestion) => (
<tr
key={suggestion.id}
className="border-t border-border hover:bg-text/[0.02] transition-colors"
>
<td className="px-4 py-3">
<p className="font-medium text-text truncate max-w-[200px]">
{suggestion.gameTitle}
</p>
</td>
<td className="px-4 py-3">
<span className="text-xs px-2 py-0.5 rounded-md bg-text/5 text-text/70">
{suggestion.fieldName}
</span>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[150px]">
{suggestion.currentValue || "—"}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text truncate max-w-[150px]">
{suggestion.proposedValue}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text/50 truncate max-w-[200px]">
{suggestion.reason || "—"}
</p>
</td>
<td className="px-4 py-3">
<p className="text-text/70 truncate max-w-[120px]">
{suggestion.userName || "Unknown"}
</p>
</td>
<td className="px-4 py-3">
<span className="text-xs text-text/50">
{formatDate(suggestion.createdAt)}
</span>
</td>
<td className="px-4 py-3">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium uppercase tracking-wider ${
suggestion.status === "approved"
? "bg-green-500/10 text-green-400"
: suggestion.status === "rejected"
? "bg-red-500/10 text-red-400"
: "bg-yellow-500/10 text-yellow-400"
}`}
>
<span
className={`h-1.5 w-1.5 rounded-full ${
suggestion.status === "approved"
? "bg-green-400"
: suggestion.status === "rejected"
? "bg-red-400"
: "bg-yellow-400"
}`}
/>
{suggestion.status}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Link
href={`/game/${suggestion.gameId}`}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors cursor-pointer"
>
<ExternalLinkIcon className="h-3.5 w-3.5" />
View
</Link>
<button
onClick={() =>
setConfirmAction({ type: "approve", suggestion })
}
disabled={actionLoading[suggestion.id]}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[suggestion.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<CheckCircle2Icon className="h-3.5 w-3.5" />
)}
Approve
</button>
<button
onClick={() =>
setConfirmAction({ type: "reject", suggestion })
}
disabled={actionLoading[suggestion.id]}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer disabled:opacity-50"
>
{actionLoading[suggestion.id] ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<XCircleIcon className="h-3.5 w-3.5" />
)}
Reject
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{total > 0 && (
<div className="flex items-center justify-between">
<p className="text-xs text-text/50">
Showing {offset + 1}{Math.min(offset + paginated.length, total)} of{" "}
{total}
</p>
<div className="flex items-center gap-2">
<button
onClick={handlePrev}
disabled={offset === 0 || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
<ChevronLeftIcon className="h-3.5 w-3.5" />
Previous
</button>
<button
onClick={handleNext}
disabled={!hasMore || loading}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text hover:bg-text/10 transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
Next
<ChevronRightIcon className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Confirm Dialog */}
<ConfirmDialog
open={!!confirmAction}
onClose={() => setConfirmAction(null)}
onConfirm={() => {
if (confirmAction) {
handleReview(
confirmAction.suggestion,
confirmAction.type === "approve" ? "approved" : "rejected"
)
}
}}
title={`Confirm ${confirmAction?.type === "approve" ? "Approval" : "Rejection"}`}
message={
confirmAction
? `Are you sure you want to ${confirmAction.type === "approve" ? "approve" : "reject"} the "${confirmAction.suggestion.fieldName}" suggestion for "${confirmAction.suggestion.gameTitle}"?`
: ""
}
confirmLabel={confirmAction?.type === "approve" ? "Approve" : "Reject"}
cancelLabel="Cancel"
variant={confirmAction?.type === "approve" ? "default" : "destructive"}
loading={confirmAction ? !!actionLoading[confirmAction.suggestion.id] : false}
/>
</div>
)
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next"
import { UsersClient } from "./users-client"
export const metadata: Metadata = {
title: "Users",
}
export default function UsersPage() {
return <UsersClient />
}
@@ -0,0 +1,276 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import Image from "next/image"
import { authClient } from "@/lib/auth-client"
import { Loader2, SearchIcon, BanIcon, UserCheckIcon, UsersIcon } from "lucide-react"
type Role = "user" | "contributor" | "admin"
interface AdminUser {
id: string
name: string
email: string
emailVerified: boolean
image: string | null
createdAt: Date | string
role: Role
banned: boolean
}
const roles: Role[] = ["user", "contributor", "admin"]
function formatDate(value: Date | string | null | undefined) {
if (!value) return "—"
const d = typeof value === "string" ? new Date(value) : value
return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })
}
function getInitial(name: string) {
return name?.charAt(0)?.toUpperCase() || "?"
}
export function UsersClient() {
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState("")
const [actionLoading, setActionLoading] = useState<Record<string, boolean>>({})
const [banTarget, setBanTarget] = useState<AdminUser | null>(null)
const [banReason, setBanReason] = useState("")
const [banExpiryDays, setBanExpiryDays] = useState("")
const [banFilter, setBanFilter] = useState<"all" | "banned" | "active">("all")
useEffect(() => {
const fetchUsers = async () => {
setLoading(true)
const res = await authClient.admin.listUsers({ query: { limit: 100 } })
if (res.data?.users) {
setUsers(res.data.users as AdminUser[])
}
setLoading(false)
}
fetchUsers()
}, [])
const filteredUsers = useMemo(() => {
let result = users
const term = search.trim().toLowerCase()
if (term) {
result = result.filter(
(u) => u.name?.toLowerCase().includes(term) || u.email?.toLowerCase().includes(term)
)
}
if (banFilter === "banned") result = result.filter((u) => u.banned)
if (banFilter === "active") result = result.filter((u) => !u.banned)
return result
}, [users, search, banFilter])
const handleRoleChange = async (userId: string, newRole: Role) => {
setActionLoading((prev) => ({ ...prev, [userId]: true }))
try {
await authClient.admin.setRole({ userId, role: newRole as "user" | "admin" })
setUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, role: newRole } : u))
)
} finally {
setActionLoading((prev) => ({ ...prev, [userId]: false }))
}
}
const handleBan = async () => {
if (!banTarget) return
setActionLoading((prev) => ({ ...prev, [banTarget.id]: true }))
try {
await authClient.admin.banUser({
userId: banTarget.id,
banReason: banReason.trim() || undefined,
banExpires: banExpiryDays
? new Date(Date.now() + Number(banExpiryDays) * 24 * 60 * 60 * 1000)
: undefined,
} as any)
setUsers((prev) =>
prev.map((u) => (u.id === banTarget.id ? { ...u, banned: true } : u))
)
setBanTarget(null)
setBanReason("")
setBanExpiryDays("")
} finally {
setActionLoading((prev) => ({ ...prev, [banTarget.id]: false }))
}
}
const handleUnban = async (userId: string) => {
setActionLoading((prev) => ({ ...prev, [userId]: true }))
try {
await authClient.admin.unbanUser({ userId })
setUsers((prev) =>
prev.map((u) => (u.id === userId ? { ...u, banned: false } : u))
)
} finally {
setActionLoading((prev) => ({ ...prev, [userId]: false }))
}
}
return (
<div className="space-y-4">
{/* Header with count */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<UsersIcon className="h-5 w-5 text-text/50" />
<h2 className="text-lg font-semibold">Users</h2>
<span className="text-sm text-text/50">({filteredUsers.length})</span>
</div>
</div>
{/* Search */}
<div className="relative">
<SearchIcon className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-text/40" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name or email..."
className="w-full pl-9 pr-4 py-2.5 rounded-lg bg-text/5 border border-border text-sm text-text placeholder:text-text/40 focus:outline-none focus:border-primary/60 transition-colors"
/>
</div>
{/* Filter Tabs */}
<div className="flex gap-2">
{(["all", "active", "banned"] as const).map((f) => (
<button
key={f}
onClick={() => setBanFilter(f)}
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors ${
banFilter === f ? "bg-primary/10 text-primary" : "text-text/50 hover:text-text/70"
}`}
>
{f === "all" ? "All" : f === "active" ? "Active" : "Banned"}
</button>
))}
</div>
{/* Users List */}
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin text-primary" />
</div>
) : filteredUsers.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-text/50">
<UsersIcon className="h-8 w-8 mb-2" />
<p className="text-sm">No users found</p>
</div>
) : (
<div className="space-y-2">
{filteredUsers.map((user) => (
<div
key={user.id}
className="flex items-center gap-4 p-4 rounded-xl border border-border bg-text/[0.02] hover:bg-text/[0.04] transition-colors"
>
{/* Avatar */}
{user.image ? (
<Image
src={user.image}
alt=""
width={40}
height={40}
className="h-10 w-10 rounded-full object-cover"
/>
) : (
<div className="h-10 w-10 rounded-full bg-text/10 flex items-center justify-center text-sm font-medium text-text/70">
{getInitial(user.name)}
</div>
)}
{/* User Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="font-medium text-text truncate">{user.name || "Unnamed"}</p>
{user.banned && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-red-500/10 text-red-400">
Banned
</span>
)}
</div>
<p className="text-xs text-text/50 truncate">{user.email}</p>
<p className="text-[11px] text-text/30 mt-0.5">Joined {formatDate(user.createdAt)}</p>
</div>
{/* Role Selector */}
<select
value={user.role || "user"}
onChange={(e) => handleRoleChange(user.id, e.target.value as Role)}
disabled={actionLoading[user.id]}
className="text-xs px-3 py-1.5 rounded-lg border border-border bg-text/5 text-text focus:outline-none focus:border-primary/60 transition-colors cursor-pointer disabled:opacity-50"
>
{roles.map((r) => (
<option key={r} value={r}>
{r.charAt(0).toUpperCase() + r.slice(1)}
</option>
))}
</select>
{/* Action Button */}
{actionLoading[user.id] ? (
<Loader2 className="h-4 w-4 animate-spin text-text/50" />
) : user.banned ? (
<button
onClick={() => handleUnban(user.id)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-green-500/10 text-green-400 hover:bg-green-500/20 transition-colors cursor-pointer"
>
<UserCheckIcon className="h-3.5 w-3.5" />
Unban
</button>
) : (
<button
onClick={() => setBanTarget(user)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors cursor-pointer"
>
<BanIcon className="h-3.5 w-3.5" />
Ban
</button>
)}
</div>
))}
</div>
)}
{/* Ban Modal */}
{banTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={() => setBanTarget(null)}>
<div className="bg-zinc-900 border border-zinc-800 rounded-xl p-6 w-full max-w-md space-y-4" onClick={(e) => e.stopPropagation()}>
<h3 className="font-semibold">Ban {banTarget.name || banTarget.email}</h3>
<div className="space-y-2">
<label className="text-xs text-text/60">Reason</label>
<textarea
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
placeholder="Reason for ban..."
rows={3}
className="w-full px-3 py-2 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary resize-y"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-text/60">Expiry (days, leave empty for permanent)</label>
<input
type="number"
value={banExpiryDays}
onChange={(e) => setBanExpiryDays(e.target.value)}
placeholder="e.g. 7"
min={1}
className="w-full px-3 py-2 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary"
/>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setBanTarget(null)} className="px-4 py-2 rounded-lg border border-border text-text/70 text-sm hover:bg-text/5 transition-colors">
Cancel
</button>
<button onClick={handleBan} disabled={actionLoading[banTarget.id]} className="px-4 py-2 rounded-lg bg-red-500 text-white text-sm font-semibold hover:bg-red-600 transition-colors disabled:opacity-50">
Ban User
</button>
</div>
</div>
</div>
)}
</div>
)
}
+152
View File
@@ -0,0 +1,152 @@
// app/__tests__/metadata.test.ts
import { describe, it, expect, vi, beforeEach } from "vitest"
// Mock modules that page renders depend on
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
orderBy: vi.fn(() => ({
limit: vi.fn(() => []),
})),
})),
innerJoin: vi.fn(() => ({
where: vi.fn(() => ({
groupBy: vi.fn(() => ({
orderBy: vi.fn(() => []),
})),
})),
})),
})),
})),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: { id: "id", title: "title", slug: "slug", updatedAt: "updatedAt", capsuleImage: "capsuleImage", syncStatus: "syncStatus" },
gameVersions: { id: "id", gameId: "gameId", versionString: "versionString", buildId: "buildId" },
performanceEntries: { id: "id", versionId: "versionId", isRemoved: "isRemoved", hardwareSlug: "hardwareSlug", fpsAvg: "fpsAvg", fpsLow: "fpsLow", fpsHigh: "fpsHigh" },
hardware: { slug: "slug", name: "name", deviceType: "deviceType", sortOrder: "sortOrder" },
gameComments: { gameId: "gameId", id: "id" },
gamePlatformSupport: { gameId: "gameId", hardwareSlug: "hardwareSlug", protonStatus: "protonStatus" },
user: { id: "id", name: "name", image: "image", role: "role" },
entryScreenshots: { id: "id", entryId: "entryId", storageKey: "storageKey", orderIndex: "orderIndex" },
steamReviewSentimentEnum: {
enumValues: [
"overwhelmingly_positive",
"very_positive",
"positive",
"mostly_positive",
"mixed",
"mostly_negative",
"negative",
"very_negative",
"overwhelmingly_negative",
],
},
}))
vi.mock("drizzle-orm", () => ({
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
and: vi.fn((...args: unknown[]) => args),
desc: vi.fn((col: unknown) => col),
or: vi.fn((...args: unknown[]) => args),
ne: vi.fn((col: unknown) => col),
isNull: vi.fn((col: unknown) => col),
inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ raw: strings, vals: values })),
avg: vi.fn((col: unknown) => col),
count: vi.fn((col: unknown) => col),
}))
vi.mock("@/lib/storage", () => ({
getR2PublicUrl: vi.fn(() => "https://r2.example.com"),
}))
vi.mock("@/lib/auth", () => ({
auth: {
$Infer: { Session: { user: {} } },
api: {},
handler: vi.fn(),
},
}))
vi.mock("@/lib/auth-client", () => ({}))
vi.mock("@/lib/updates", () => ({
getAllUpdates: vi.fn(() => []),
getAllUpdateSlugs: vi.fn(() => []),
getUpdateBySlug: vi.fn(() => Promise.resolve({ meta: { title: "", date: "", version: "", summary: "" }, html: "", headings: [] })),
}))
vi.mock("next/font/google", () => ({
Lexend: vi.fn(() => ({ variable: "font-mock" })),
}))
vi.mock("@/components/navbar", () => ({
default: vi.fn(() => null),
}))
describe("Page Metadata", () => {
describe("Root Layout", () => {
it("exports canonical URL", async () => {
const { metadata } = await import("@/app/layout")
expect(metadata.alternates?.canonical).toBe("https://deckyvault.xyz")
})
})
describe("Games page", () => {
it("has a unique title without duplicate brand name", async () => {
const { metadata } = await import("@/app/games/page")
expect(metadata.title).toBe("Games")
})
})
describe("Devices page", () => {
it("has a unique title without duplicate brand name", async () => {
const { metadata } = await import("@/app/devices/page")
expect(metadata.title).toBe("Devices")
})
})
describe("Compare layout", () => {
it("exports unique title and description", async () => {
const { metadata } = await import("@/app/compare/layout")
expect(metadata.title).toBe("Compare Games")
expect(metadata.description).toBeDefined()
})
})
describe("Profile layout", () => {
it("exports unique title and description", async () => {
const { metadata } = await import("@/app/profile/layout")
expect(metadata.title).toBe("Profile")
expect(metadata.description).toBeDefined()
})
})
describe("End-to-end metadata consolidation", () => {
it("all public pages have unique titles", async () => {
const pages = [
{ name: "layout", expected: "DeckyVault - Steam Deck Benchmarks & Settings" },
{ name: "games/page", expected: "Games" },
{ name: "devices/page", expected: "Devices" },
{ name: "compare/layout", expected: "Compare Games" },
]
const titles = new Set<string>()
for (const page of pages) {
const mod = await import(`@/app/${page.name}`)
const actualTitle = mod.metadata.title?.default ?? mod.metadata.title
expect(actualTitle).toBe(page.expected)
const resolvedTitle = page.name === "layout"
? actualTitle
: `${actualTitle} | DeckyVault`
titles.add(resolvedTitle)
}
expect(titles.has("DeckyVault - Steam Deck Benchmarks & Settings")).toBe(true)
expect(titles.size).toBe(pages.length)
})
})
})
@@ -0,0 +1,159 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// ── Mock data ──────────────────────────────────────────────────────────
let mockData: unknown[] = []
// ── Chainable query builder mock ──────────────────────────────────────
function createChainableQuery(resolveWith: unknown[]) {
const then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(resolveWith).then(resolve, reject)
const chain: Record<string, unknown> = {
where: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })),
then,
[Symbol.toPrimitive]: () => resolveWith,
}
return chain
}
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => createChainableQuery(mockData)),
})),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: {
id: "id",
updatedAt: "updatedAt",
capsuleImage: "capsuleImage",
syncStatus: "syncStatus",
},
hardware: { slug: "slug", createdAt: "createdAt" },
}))
vi.mock("drizzle-orm", () => ({
or: vi.fn((...args: unknown[]) => args[0]),
ne: vi.fn((col: unknown) => col),
isNull: vi.fn((col: unknown) => col),
}))
const mockGetAllUpdates = vi.fn(() => [])
vi.mock("@/lib/updates", () => ({
getAllUpdates: mockGetAllUpdates,
}))
describe("Sitemap Integration — Constraint Verification", () => {
beforeEach(() => {
vi.clearAllMocks()
mockData = []
mockGetAllUpdates.mockReturnValue([])
})
// ── Spec §2: Non-negotiables ─────────────────────────────────────
it("PERFORMANCE: sitemap index generation is synchronous (no DB for index)", async () => {
const mod = await import("@/app/sitemap")
const start = Date.now()
await mod.generateSitemaps()
const elapsed = Date.now() - start
// generateSitemaps should resolve quickly (< 500ms even with mocked slow DB)
expect(elapsed).toBeLessThan(500)
})
it("RELIABILITY: generateSitemaps always returns required base IDs", async () => {
mockData = [{ count: 500 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("static")
expect(idValues).toContain("devices")
expect(idValues).toContain("updates")
// Either 'games' or at least one 'games-N' must exist
const hasGames = idValues.some(
(id: string) => id === "games" || /^games-\d+$/.test(id),
)
expect(hasGames).toBe(true)
})
it("RELIABILITY: all child sitemaps return valid arrays even on failure", async () => {
const mod = await import("@/app/sitemap")
for (const id of ["static", "games", "devices", "updates"]) {
const result = await mod.default({ id: Promise.resolve(id) })
expect(Array.isArray(result)).toBe(true)
}
})
it("SECURITY: no non-public routes in static pages", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
const urls = result.map((e: { url: string }) => e.url)
for (const url of urls) {
expect(url).not.toContain("/manage")
expect(url).not.toContain("/api")
expect(url).not.toContain("/profile")
}
})
it("DATA: games sitemap entries have correct shape", async () => {
mockData = [
{ id: "abc123", updatedAt: new Date("2025-06-01"), capsuleImage: "https://cdn.example.com/img.jpg" },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
for (const entry of result) {
expect(entry).toHaveProperty("url")
expect(typeof entry.url).toBe("string")
expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/game\//)
if (entry.lastModified) {
expect(entry.lastModified).toBeInstanceOf(Date)
}
if (entry.images) {
expect(Array.isArray(entry.images)).toBe(true)
}
}
})
it("DATA: devices sitemap entries have correct shape", async () => {
mockData = [
{ slug: "steam-deck-oled", createdAt: new Date("2025-03-01") },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
for (const entry of result) {
expect(entry).toHaveProperty("url")
expect(typeof entry.url).toBe("string")
expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/devices\//)
}
})
it("DATA: updates sitemap entries have correct shape", async () => {
mockGetAllUpdates.mockReturnValue([
{ slug: "2026-01-01", title: "Release", date: "2026-01-01", version: "1.0.0", summary: "First" },
])
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
for (const entry of result) {
expect(entry).toHaveProperty("url")
expect(typeof entry.url).toBe("string")
expect(entry.url).toMatch(/^https:\/\/deckyvault\.xyz\/updates\//)
}
})
it("ISOLATION: static sitemap has no DB dependency", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
// Result must be 7 entries regardless of DB state
expect(result).toHaveLength(7)
})
})
+373
View File
@@ -0,0 +1,373 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// ── Mock data ──────────────────────────────────────────────────────────
let mockData: unknown[] = []
// ── Chainable query builder mock ──────────────────────────────────────
function createChainableQuery(resolveWith: unknown[]) {
const then = (resolve: (v: unknown) => unknown, reject: (e: unknown) => unknown) =>
Promise.resolve(resolveWith).then(resolve, reject)
const chain: Record<string, unknown> = {
where: vi.fn(() => ({ then, [Symbol.toPrimitive]: () => resolveWith })),
then,
[Symbol.toPrimitive]: () => resolveWith,
}
return chain
}
vi.mock("@/lib/db/index", () => ({
db: {
select: vi.fn(() => ({
from: vi.fn(() => createChainableQuery(mockData)),
})),
},
}))
vi.mock("@/lib/db/schema", () => ({
games: {
id: "id",
updatedAt: "updatedAt",
capsuleImage: "capsuleImage",
syncStatus: "syncStatus",
},
gameVersions: {
id: "id",
gameId: "gameId",
versionString: "versionString",
buildId: "buildId",
},
performanceEntries: {
id: "id",
versionId: "versionId",
isRemoved: "isRemoved",
hardwareSlug: "hardwareSlug",
fpsAvg: "fpsAvg",
fpsLow: "fpsLow",
fpsHigh: "fpsHigh",
},
hardware: { slug: "slug", createdAt: "createdAt" },
gameComments: { gameId: "gameId", id: "id" },
gamePlatformSupport: {
gameId: "gameId",
hardwareSlug: "hardwareSlug",
protonStatus: "protonStatus",
},
user: { id: "id", name: "name", image: "image", role: "role" },
entryScreenshots: {
id: "id",
entryId: "entryId",
storageKey: "storageKey",
orderIndex: "orderIndex",
},
steamReviewSentimentEnum: {
enumValues: [
"overwhelmingly_positive",
"very_positive",
"positive",
"mostly_positive",
"mixed",
"mostly_negative",
"negative",
"very_negative",
"overwhelmingly_negative",
],
},
}))
vi.mock("drizzle-orm", () => ({
eq: vi.fn((col: unknown, val: unknown) => ({ col, val })),
and: vi.fn((...args: unknown[]) => args),
desc: vi.fn((col: unknown) => col),
or: vi.fn((...args: unknown[]) => args[0]),
ne: vi.fn((col: unknown) => col),
isNull: vi.fn((col: unknown) => col),
inArray: vi.fn((col: unknown, vals: unknown) => ({ col, vals })),
sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ raw: strings, vals: values })),
avg: vi.fn((col: unknown) => col),
count: vi.fn((col: unknown) => col),
}))
const mockGetAllUpdates = vi.fn(() => [])
vi.mock("@/lib/updates", () => ({
getAllUpdates: mockGetAllUpdates,
}))
describe("Sitemap Generator (app/sitemap.ts)", () => {
beforeEach(() => {
vi.clearAllMocks()
mockData = []
mockGetAllUpdates.mockReturnValue([])
})
// ── Configuration ──────────────────────────────────────────────────
it("has ISR revalidation configured", async () => {
const mod = await import("@/app/sitemap")
expect(mod.revalidate).toBe(3600)
})
// ── generateSitemaps ───────────────────────────────────────────────
describe("generateSitemaps", () => {
it("returns at least 4 child sitemap IDs", async () => {
mockData = [{ count: 500 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("static")
expect(idValues).toContain("devices")
expect(idValues).toContain("updates")
expect(idValues).toContain("games")
})
it("returns unpaginated games when count <= 5000", async () => {
mockData = [{ count: 5000 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("games")
expect(idValues).not.toContain("games-0")
expect(idValues).not.toContain("games-1")
})
it("paginates games when count > 5000", async () => {
mockData = [{ count: 7500 }]
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("games-0")
expect(idValues).toContain("games-1")
expect(idValues).not.toContain("games")
})
it("falls back to unpaginated games when count query fails", async () => {
// mockData empty — count will be 0 from empty array, simulating a failed query
mockData = []
const mod = await import("@/app/sitemap")
const ids = await mod.generateSitemaps()
const idValues = ids.map((x: { id: string }) => x.id)
expect(idValues).toContain("games")
})
})
// ── sitemap({ id: 'static' }) ──────────────────────────────────────
describe("sitemap({ id: 'static' })", () => {
it("returns 7 static page entries", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(7)
})
it("first entry is homepage with priority 1.0", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("deckyvault.xyz")
expect(result[0].priority).toBe(1.0)
})
it("includes /games and /compare", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
const urls = result.map((e: { url: string }) => e.url)
expect(urls).toContain("https://deckyvault.xyz/games")
expect(urls).toContain("https://deckyvault.xyz/compare")
})
it("includes lastModified on all entries", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
for (const entry of result) {
expect(entry.lastModified).toBeDefined()
expect(entry.lastModified).toBeInstanceOf(Date)
}
})
it("uses production URL even when env is localhost", async () => {
process.env.NEXT_PUBLIC_SITE_URL = "http://localhost:3000"
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("deckyvault.xyz")
expect(result[0].url).not.toContain("localhost")
delete process.env.NEXT_PUBLIC_SITE_URL
})
it("uses custom NEXT_PUBLIC_SITE_URL for staging", async () => {
process.env.NEXT_PUBLIC_SITE_URL = "https://staging.deckyvault.xyz"
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result[0].url).toContain("staging.deckyvault.xyz")
delete process.env.NEXT_PUBLIC_SITE_URL
})
})
// ── sitemap({ id: 'games' }) ───────────────────────────────────────
describe("sitemap({ id: 'games' })", () => {
it("returns game entries from the database", async () => {
mockData = [
{ id: "abc123", updatedAt: new Date("2025-06-01"), capsuleImage: "https://cdn.example.com/img.jpg" },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result).toHaveLength(1)
expect(result[0].url).toContain("deckyvault.xyz/game/abc123")
expect(result[0].priority).toBe(0.8)
})
it("includes image entries for valid capsule URLs", async () => {
mockData = [
{ id: "img123", updatedAt: null, capsuleImage: "https://cdn.example.com/capsule.jpg" },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].images).toEqual(["https://cdn.example.com/capsule.jpg"])
})
it("omits images for null capsuleImage", async () => {
mockData = [
{ id: "noimg", updatedAt: null, capsuleImage: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].images).toBeUndefined()
})
it("includes lastModified from updatedAt", async () => {
const date = new Date("2025-01-15T10:00:00Z")
mockData = [
{ id: "date123", updatedAt: date, capsuleImage: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].lastModified).toBe(date)
})
it("returns empty array when DB query fails", async () => {
// mockData is empty, querySafe will return undefined (no data)
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
})
it("applies changeFrequency weekly and priority 0.8", async () => {
mockData = [
{ id: "freq", updatedAt: null, capsuleImage: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("games") })
expect(result[0].changeFrequency).toBe("weekly")
expect(result[0].priority).toBe(0.8)
})
})
// ── sitemap({ id: 'devices' }) ─────────────────────────────────────
describe("sitemap({ id: 'devices' })", () => {
it("returns device entries from the database", async () => {
mockData = [
{ slug: "steam-deck-oled", createdAt: new Date("2025-03-01") },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
expect(result).toHaveLength(1)
expect(result[0].url).toContain("deckyvault.xyz/devices/steam-deck-oled")
})
it("sets priority 0.6 and changeFrequency monthly", async () => {
mockData = [
{ slug: "device-1", createdAt: null },
]
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
expect(result[0].priority).toBe(0.6)
expect(result[0].changeFrequency).toBe("monthly")
})
it("returns empty array when DB query returns no rows", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("devices") })
expect(result).toHaveLength(0)
})
})
// ── sitemap({ id: 'updates' }) ─────────────────────────────────────
describe("sitemap({ id: 'updates' })", () => {
it("returns update entries from markdown files", async () => {
mockGetAllUpdates.mockReturnValue([
{ slug: "2026-01-01", title: "Release", date: "2026-01-01", version: "1.0.0", summary: "First" },
])
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
expect(result).toHaveLength(1)
expect(result[0].url).toContain("deckyvault.xyz/updates/2026-01-01")
})
it("sets priority 0.5 and changeFrequency monthly", async () => {
mockGetAllUpdates.mockReturnValue([
{ slug: "upd", title: "T", date: "2026-01-01", version: "1.0.0", summary: "S" },
])
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
expect(result[0].priority).toBe(0.5)
expect(result[0].changeFrequency).toBe("monthly")
})
it("returns empty array when getAllUpdates throws", async () => {
mockGetAllUpdates.mockImplementation(() => {
throw new Error("Failed to read updates directory")
})
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("updates") })
expect(Array.isArray(result)).toBe(true)
expect(result).toHaveLength(0)
})
})
// ── Isolation ──────────────────────────────────────────────────────
describe("child sitemap isolation", () => {
it("static sitemap works even when DB is empty", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("static") })
expect(result).toHaveLength(7)
})
it("unknown id returns empty array", async () => {
const mod = await import("@/app/sitemap")
const result = await mod.default({ id: Promise.resolve("nonexistent") })
expect(result).toEqual([])
})
})
})
+7
View File
@@ -0,0 +1,7 @@
import { app } from "@/lib/api/app"
export const GET = app.fetch
export const POST = app.fetch
export const PUT = app.fetch
export const DELETE = app.fetch
export const PATCH = app.fetch
+19
View File
@@ -0,0 +1,19 @@
import { permanentRedirect } from "next/navigation"
import type { Metadata } from "next"
interface Props {
params: Promise<{ steamid: string }>
}
export default async function AppRedirectPage({ params }: Props) {
const { steamid } = await params
permanentRedirect(`/game/${steamid}`)
}
export const dynamic = "force-dynamic"
export function generateMetadata({ params }: Props): Metadata {
return {
robots: { index: false, follow: false },
}
}
@@ -0,0 +1,14 @@
import { describe, it, expect } from "vitest"
describe("app/[steamid] redirect route", () => {
it("exports force-dynamic", async () => {
const mod = await import("../[steamid]/page")
expect(mod).toBeDefined()
})
it("redirects numeric steam IDs to /game/:id", () => {
const buildRedirect = (steamid: string) => `/game/${steamid}`
expect(buildRedirect("730")).toBe("/game/730")
expect(buildRedirect("12345")).toBe("/game/12345")
})
})
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Compare Games",
description:
"Compare Steam Deck game benchmarks side by side. See FPS, settings, and performance data across multiple titles on DeckyVault.",
alternates: { canonical: "https://deckyvault.xyz/compare" },
}
export default function CompareLayout({ children }: { children: React.ReactNode }) {
return children
}
+158
View File
@@ -0,0 +1,158 @@
"use client"
import { useState, useCallback } from "react"
import { motion } from "motion/react"
import { BarChart3Icon } from "lucide-react"
import { GameSelector } from "@/components/compare/game-selector"
import { StatsComparison } from "@/components/compare/stats-comparison"
import { FpsComparisonChart } from "@/components/compare/fps-comparison-chart"
import { StabilityRadar } from "@/components/compare/stability-radar"
interface SelectedGame {
id: string
appId: number | null
title: string
image: string | null
source: string
}
interface GameComparisonData {
id: string
title: string
stats: {
totalEntries: number
avgFps: number | null
medianFps: number | null
bestFps: number | null
avgOnePercentLow: number | null
avgStability: number | null
bestDevice: string | null
tierBreakdown: { unplayable: number; playable: number; smooth: number; excellent: number } | null
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
}
}
export default function ComparePage() {
const [selectedGames, setSelectedGames] = useState<SelectedGame[]>([])
const [comparisonData, setComparisonData] = useState<GameComparisonData[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSelect = useCallback((game: SelectedGame) => {
setSelectedGames(prev => {
if (prev.some(g => g.id === game.id)) return prev
return [...prev, game]
})
}, [])
const handleRemove = useCallback((gameId: string) => {
setSelectedGames(prev => prev.filter(g => g.id !== gameId))
setComparisonData(prev => prev.filter(g => g.id !== gameId))
}, [])
const fetchComparison = useCallback(async () => {
if (selectedGames.length < 2) return
setLoading(true)
setError(null)
try {
const ids = selectedGames.map(g => g.id).join(",")
const res = await fetch(`/api/compare/games?ids=${ids}`)
if (!res.ok) {
const data = await res.json()
throw new Error(data.error || "Failed to fetch comparison")
}
const data = await res.json()
setComparisonData(data.games || [])
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load comparison")
} finally {
setLoading(false)
}
}, [selectedGames])
const canCompare = selectedGames.length >= 2
return (
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
<div className="w-full max-w-7xl">
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-2xl font-light mb-2"
>
Compare Games
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className="text-text/60 text-sm mb-6"
>
Select 2-4 games to compare performance stats side by side.
</motion.p>
{/* Game selector */}
<GameSelector
selectedGames={selectedGames}
onSelect={handleSelect}
onRemove={handleRemove}
maxSelections={4}
/>
{/* Compare button */}
<div className="mt-4">
<button
onClick={fetchComparison}
disabled={!canCompare || loading}
className="px-4 py-2 rounded-lg bg-primary text-white text-sm font-medium hover:bg-primary/90 disabled:opacity-50 transition-colors cursor-pointer"
>
{loading ? "Loading..." : "Compare"}
</button>
{!canCompare && selectedGames.length > 0 && (
<span className="ml-3 text-xs text-text/40">Select at least 2 games</span>
)}
</div>
{/* Error */}
{error && (
<p className="mt-4 text-red-400 text-sm">{error}</p>
)}
{/* Results */}
{comparisonData.length >= 2 && (
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="mt-8 flex flex-col gap-8"
>
{/* Stats comparison table */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-4">Stats Overview</h2>
<StatsComparison games={comparisonData} />
</div>
{/* FPS comparison bar chart */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-2">FPS by Device</h2>
<FpsComparisonChart games={comparisonData} />
</div>
{/* Stability radar */}
<div className="rounded-xl border border-border bg-text/3 p-4">
<h2 className="text-sm font-medium text-text/80 mb-2">Performance Profile</h2>
<StabilityRadar games={comparisonData} />
</div>
</motion.div>
)}
{/* Empty state */}
{comparisonData.length === 0 && !loading && (
<div className="mt-16 flex flex-col items-center justify-center gap-4">
<BarChart3Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">Select games above to start comparing</p>
</div>
)}
</div>
</section>
)
}
+13
View File
@@ -0,0 +1,13 @@
import { FaWindows, FaApple, FaLinux } from "react-icons/fa"
export function WindowsIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return <FaWindows className={className} aria-label="Windows" />
}
export function MacIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return <FaApple className={className} aria-label="macOS" />
}
export function LinuxIcon({ className = "h-3.5 w-3.5" }: { className?: string }) {
return <FaLinux className={className} aria-label="Linux" />
}
+11
View File
@@ -0,0 +1,11 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Contact & Report",
description: "Report issues, suggest features, or send feedback to the DeckyVault team.",
robots: { index: false, follow: true },
}
export default function ContactLayout({ children }: { children: React.ReactNode }) {
return children
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import { useState, useRef } from "react"
import { motion } from "motion/react"
import {
SendIcon,
BugIcon,
DatabaseIcon,
FlagIcon,
LightbulbIcon,
MessageSquareIcon,
AlertTriangleIcon,
CheckCircleIcon,
Loader2Icon,
} from "lucide-react"
type Category = "bug" | "game_data" | "user_report" | "feature" | "feedback" | "database"
interface CategoryOption {
id: Category
label: string
icon: React.ElementType
color: string
}
const CATEGORIES: CategoryOption[] = [
{ id: "bug", label: "Bug Report", icon: BugIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" },
{ id: "game_data", label: "Game Data Issue", icon: AlertTriangleIcon, color: "text-yellow-400 border-yellow-500/20 bg-yellow-500/5 hover:bg-yellow-500/10" },
{ id: "user_report", label: "User Report", icon: FlagIcon, color: "text-blue-400 border-blue-500/20 bg-blue-500/5 hover:bg-blue-500/10" },
{ id: "feature", label: "Feature Request", icon: LightbulbIcon, color: "text-green-400 border-green-500/20 bg-green-500/5 hover:bg-green-500/10" },
{ id: "feedback", label: "General Feedback", icon: MessageSquareIcon, color: "text-text/60 border-border bg-text/3 hover:bg-text/6" },
{ id: "database", label: "Database Error", icon: DatabaseIcon, color: "text-red-400 border-red-500/20 bg-red-500/5 hover:bg-red-500/10" },
]
export default function ContactPage() {
const [category, setCategory] = useState<Category | "">("")
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [subject, setSubject] = useState("")
const [message, setMessage] = useState("")
const [gameUrl, setGameUrl] = useState("")
const [honeypot, setHoneypot] = useState("")
const timestampRef = useRef<string>("")
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const [error, setError] = useState<string | null>(null)
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({})
// Set timestamp when user starts interacting
const startTimestamp = () => {
if (!timestampRef.current) {
timestampRef.current = Date.now().toString()
}
}
const validate = (): boolean => {
const errors: Record<string, string> = {}
if (!category) errors.category = "Please select a category"
if (!subject.trim()) errors.subject = "Subject is required"
else if (subject.length > 200) errors.subject = "Subject must be 200 characters or less"
if (!message.trim()) errors.message = "Message is required"
else if (message.length > 2000) errors.message = "Message must be 2000 characters or less"
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) errors.email = "Invalid email format"
if (category === "game_data" && gameUrl && !gameUrl.includes("/game/")) errors.gameUrl = "Please provide a valid DeckyVault game link"
setFieldErrors(errors)
return Object.keys(errors).length === 0
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
startTimestamp()
if (!validate()) return
setSubmitting(true)
setError(null)
try {
const res = await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
category,
name: name || undefined,
email: email || undefined,
subject: subject.trim(),
message: message.trim(),
gameUrl: category === "game_data" ? gameUrl || undefined : undefined,
honeypot,
_timestamp: timestampRef.current || undefined,
}),
})
const data = await res.json()
if (!res.ok) {
if (res.status === 429) {
setError("You've sent too many messages. Please try again later.")
} else {
setError(data.error || "Something went wrong. Please try again.")
}
return
}
setSubmitted(true)
} catch {
setError("Network error. Please check your connection and try again.")
} finally {
setSubmitting(false)
}
}
if (submitted) {
return (
<section className="w-full flex flex-col items-center justify-center py-20 p-4">
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
className="max-w-md w-full text-center space-y-4"
>
<CheckCircleIcon className="h-12 w-12 text-green-400 mx-auto" />
<h1 className="text-2xl font-bold">Message Sent</h1>
<p className="text-text/60 text-sm">
Thank you for reaching out. We&apos;ll review your message as soon as possible.
</p>
<button
onClick={() => {
setSubmitted(false)
setCategory("")
setSubject("")
setMessage("")
setGameUrl("")
setName("")
setEmail("")
setHoneypot("")
timestampRef.current = ""
setFieldErrors({})
}}
className="px-4 py-2 rounded-md bg-text/5 border border-border text-sm hover:bg-text/10 transition-colors cursor-pointer"
>
Send another message
</button>
</motion.div>
</section>
)
}
return (
<section className="w-full flex flex-col items-center py-16 p-4">
<div className="max-w-lg w-full space-y-8">
{/* Header */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }}
>
<h1 className="text-2xl sm:text-3xl font-bold">Contact & Report</h1>
<p className="text-sm text-text/60 mt-1">
Report issues, suggest features, or send us feedback.
</p>
</motion.div>
<motion.form
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }}
onSubmit={handleSubmit}
className="space-y-5"
>
{/* Honeypot */}
<input
type="text"
name="honeypot"
value={honeypot}
onChange={(e) => setHoneypot(e.target.value)}
tabIndex={-1}
autoComplete="off"
style={{ position: "absolute", opacity: 0, pointerEvents: "none" }}
aria-hidden="true"
/>
{/* Category */}
<fieldset>
<legend className="text-xs text-text/50 uppercase tracking-wider mb-2">
Category <span className="text-primary">*</span>
</legend>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
{CATEGORIES.map((cat) => (
<button
key={cat.id}
type="button"
onClick={() => {
setCategory(cat.id)
startTimestamp()
setFieldErrors((prev) => {
const next = { ...prev }
delete next.category
return next
})
}}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg border text-xs font-medium transition-colors cursor-pointer ${
category === cat.id
? cat.color + " ring-1 ring-current"
: "text-text/50 border-border bg-text/3 hover:bg-text/6"
}`}
>
<cat.icon className="h-3.5 w-3.5" />
{cat.label}
</button>
))}
</div>
{fieldErrors.category && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.category}</p>
)}
</fieldset>
{/* Name & Email (optional) */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label htmlFor="contact-name" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Name <span className="text-text/30">(optional)</span>
</label>
<input
id="contact-name"
type="text"
value={name}
onChange={(e) => { setName(e.target.value); startTimestamp() }}
placeholder="Your name"
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
</div>
<div>
<label htmlFor="contact-email" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Email <span className="text-text/30">(optional)</span>
</label>
<input
id="contact-email"
type="email"
value={email}
onChange={(e) => { setEmail(e.target.value); startTimestamp() }}
placeholder="you@example.com"
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.email && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.email}</p>
)}
</div>
</div>
{/* Subject */}
<div>
<label htmlFor="contact-subject" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Subject <span className="text-primary">*</span>
</label>
<input
id="contact-subject"
type="text"
value={subject}
onChange={(e) => {
setSubject(e.target.value)
startTimestamp()
if (fieldErrors.subject) setFieldErrors((prev) => { const next = { ...prev }; delete next.subject; return next })
}}
placeholder="Brief description of your issue"
maxLength={200}
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.subject && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.subject}</p>
)}
</div>
{/* Game URL (conditional) */}
{category === "game_data" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
>
<label htmlFor="contact-game-url" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Game URL <span className="text-text/30">(link to the game page)</span>
</label>
<input
id="contact-game-url"
type="url"
value={gameUrl}
onChange={(e) => setGameUrl(e.target.value)}
placeholder="https://deckyvault.xyz/game/..."
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors"
/>
{fieldErrors.gameUrl && (
<p className="text-red-400 text-xs mt-1">{fieldErrors.gameUrl}</p>
)}
</motion.div>
)}
{/* Message */}
<div>
<label htmlFor="contact-message" className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Message <span className="text-primary">*</span>
</label>
<textarea
id="contact-message"
value={message}
onChange={(e) => {
setMessage(e.target.value)
startTimestamp()
if (fieldErrors.message) setFieldErrors((prev) => { const next = { ...prev }; delete next.message; return next })
}}
placeholder="Describe your issue, suggestion, or feedback in detail..."
rows={5}
maxLength={2000}
className="w-full bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 focus:ring-offset-background transition-colors resize-y"
/>
<div className="flex justify-between items-center mt-1">
{fieldErrors.message ? (
<p className="text-red-400 text-xs">{fieldErrors.message}</p>
) : (
<span />
)}
<span className="text-[10px] text-text/30 tabular-nums">{message.length}/2000</span>
</div>
</div>
{/* Error */}
{error && (
<div className="text-red-400 text-sm bg-red-500/10 border border-red-500/20 rounded-md px-3 py-2">
{error}
</div>
)}
{/* Submit */}
<button
type="submit"
disabled={submitting || !category}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 rounded-md bg-primary text-background font-semibold text-sm hover:bg-primary/80 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2Icon className="h-4 w-4 animate-spin" />
Sending...
</>
) : (
<>
<SendIcon className="h-4 w-4" />
Send Message
</>
)}
</button>
</motion.form>
</div>
</section>
)
}
@@ -0,0 +1,569 @@
"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(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchStats()
}, [device.slug]) // eslint-disable-line react-hooks/exhaustive-deps
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 py-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'>
<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 */}
{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 */}
{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'>
{stats.historical.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'>
Historical Performance
</h3>
<EChartWrapper
option={historicalOption}
height={280}
/>
</div>
)}
<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/3 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/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-2'>
Genre Breakdown
</h3>
<EChartWrapper
option={genreOption}
height={300}
/>
</div>
)}
</div>
{(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/3 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/3 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>
)}
{stats.topGames.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 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/3 min-w-40'>
<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>
)
}
@@ -0,0 +1,120 @@
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 dynamic = "force-dynamic"
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 }
)
}
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",
}}
>
<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>
<div style={{ fontSize: 56, fontWeight: 700, color: "#ebe4f1", lineHeight: 1.1, marginBottom: 24 }}>
{device.name}
</div>
<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>
<div style={{ position: "absolute", bottom: 40, left: 80, fontSize: 16, color: "#4a3a5c", fontWeight: 500 }}>
deckyvault.xyz
</div>
</div>
),
{ ...size }
)
}
+131
View File
@@ -0,0 +1,131 @@
import { db } from "@/lib/db/index"
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 const revalidate = 3600
export async function generateStaticParams() {
try {
const devices = await db
.select({ slug: hardware.slug })
.from(hardware)
return devices.map((d) => ({ slug: d.slug }))
} catch {
// DB unreachable during build (e.g. Docker builder without network access).
// Return empty — pages will be generated on first request via ISR.
return []
}
}
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()
}
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: 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,
}}
/>
</>
)
}
+283
View File
@@ -0,0 +1,283 @@
"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,
Zap,
Gauge,
} 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
wattHours: number | null
tdpMax: 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 py-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/3 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 */}
<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>
{/* Power specs */}
{(device.wattHours || device.tdpMax) && (
<div className="flex items-center gap-3 mt-3 text-xs text-text/40">
{device.wattHours && (
<span className="inline-flex items-center gap-1">
<Zap className="h-3 w-3" />
{Math.round(device.wattHours)}Wh
</span>
)}
{device.tdpMax && (
<span className="inline-flex items-center gap-1">
<Gauge className="h-3 w-3" />
Up to {Math.round(device.tdpMax)}W TDP
</span>
)}
</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>
{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>
)
}
+144
View File
@@ -0,0 +1,144 @@
import { db } from "@/lib/db/index"
import { hardware, performanceEntries, gameVersions, games } from "@/lib/db/schema"
import { eq, sql, desc } from "drizzle-orm"
import { buildBreadcrumbList } from "@/lib/utils/seo"
import { DevicesPageClient } from "./page-client"
import type { Metadata } from "next"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata: Metadata = {
title: "Devices",
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() {
const deviceRows = await db
.select({
slug: hardware.slug,
name: hardware.name,
deviceType: hardware.deviceType,
image: hardware.image,
sortOrder: hardware.sortOrder,
wattHours: hardware.wattHours,
tdpMax: hardware.tdpMax,
})
.from(hardware)
.orderBy(hardware.sortOrder)
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]))
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),
})
}
}
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,
wattHours: device.wattHours ? Number(device.wattHours) : null,
tdpMax: device.tdpMax ? Number(device.tdpMax) : null,
}
})
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) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildBreadcrumbList([
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Devices", url: "https://deckyvault.xyz/devices" },
]),
),
}}
/>
<DevicesPageClient devices={devices} />
</>
)
}
+90
View File
@@ -0,0 +1,90 @@
import { notFound, redirect } from "next/navigation"
import { headers } from "next/headers"
import { db } from "@/lib/db/index"
import { games, gamePlatformSupport, hardware } from "@/lib/db/schema"
import { eq } from "drizzle-orm"
import { auth } from "@/lib/auth"
import { NonSteamEditForm } from "@/components/wizard/non-steam-edit-form"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Edit Game",
}
async function resolveGame(id: string) {
const isNumeric = /^\d+$/.test(id)
if (isNumeric) {
const rows = await db.select().from(games).where(eq(games.steamAppId, Number(id))).limit(1)
return rows[0]
}
const rows = await db.select().from(games).where(eq(games.id, id)).limit(1)
return rows[0]
}
export default async function EditGamePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const h = await headers()
const session = await auth.api.getSession({ headers: h })
if (!session?.user) redirect("/login")
const game = await resolveGame(id)
if (!game) notFound()
if (game.source === "steam") {
notFound()
}
const platformSupport = await db
.select()
.from(gamePlatformSupport)
.where(eq(gamePlatformSupport.gameId, game.id))
const hardwareList = await db
.select({ slug: hardware.slug, name: hardware.name, deviceType: hardware.deviceType })
.from(hardware)
.orderBy(hardware.sortOrder)
const serializedGame = {
id: game.id,
title: game.title,
developer: game.developer,
publisher: game.publisher,
description: game.description,
source: game.source,
storeUrl: game.storeUrl,
headerImage: game.headerImage,
capsuleImage: game.capsuleImage,
genres: game.genres,
releaseDate: game.releaseDate,
createdBy: game.createdBy,
}
const serializedPlatformSupport = platformSupport.map(ps => ({
hardwareSlug: ps.hardwareSlug,
isSupported: ps.isSupported,
protonStatus: ps.protonStatus,
}))
return (
<div className="max-w-3xl mx-auto px-4 py-8 w-full">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Edit Game</h1>
<p className="text-sm text-text/60">
Update details for <span className="text-text font-medium">{game.title}</span>
</p>
</div>
<NonSteamEditForm
game={serializedGame}
platformSupport={serializedPlatformSupport}
hardwareList={hardwareList}
isOwner={session.user.id === game.createdBy}
isAdmin={session.user.role === "admin"}
/>
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,84 @@
import { ImageResponse } from "next/og"
import { db } from "@/lib/db/index"
import { games, gameVersions, performanceEntries } from "@/lib/db/schema"
import { eq, and, sql } from "drizzle-orm"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
// This needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const alt = "DeckyVault - Game Benchmarks"
export const size = { width: 1200, height: 630 }
export const contentType = "image/png"
export default async function Image({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const isNumeric = /^\d+$/.test(id)
let game
if (isNumeric) {
const rows = await db.select().from(games).where(eq(games.steamAppId, Number(id))).limit(1)
game = rows[0]
} else {
const rows = await db.select().from(games).where(eq(games.id, id)).limit(1)
game = rows[0]
}
if (!game) {
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", background: "#100b14", color: "#ebe4f1", fontFamily: "sans-serif", gap: "16px" }}>
<div style={{ fontSize: 48, fontWeight: 700 }}>Game Not Found</div>
<div style={{ fontSize: 24, opacity: 0.7 }}>DeckyVault</div>
</div>
),
{ ...size }
)
}
// Get best FPS stat
let avgFps: number | null = null
try {
const [bestStat] = await db
.select({ avgFps: sql<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)` })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(and(eq(gameVersions.gameId, game.id), eq(performanceEntries.isRemoved, false)))
.limit(1)
avgFps = bestStat?.avgFps ?? null
} catch {
// No FPS data available — that's fine
}
const logoData = await readFile(join(process.cwd(), "app/icon.png"), "base64")
const logoSrc = `data:image/png;base64,${logoData}`
return new ImageResponse(
(
<div style={{ width: "100%", height: "100%", display: "flex", flexDirection: "column", justifyContent: "center", padding: "60px", background: "#100b14", color: "#ebe4f1", fontFamily: "sans-serif" }}>
<div style={{ display: "flex", alignItems: "center", gap: "16px", marginBottom: "24px" }}>
<img src={logoSrc} alt="" height={48} style={{ borderRadius: "8px" }} />
<span style={{ fontSize: 24, fontWeight: 600, opacity: 0.8 }}>DeckyVault</span>
</div>
<div style={{ fontSize: 56, fontWeight: 700, lineHeight: 1.1, marginBottom: "16px", maxWidth: "900px" }}>
{game.title}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "24px", fontSize: 24, opacity: 0.8 }}>
{game.developer && <span>by {game.developer}</span>}
{avgFps !== null && <span style={{ color: "#22c55e" }}>~{avgFps} avg FPS</span>}
</div>
{game.genres && game.genres.length > 0 && (
<div style={{ display: "flex", gap: "8px", marginTop: "16px" }}>
{game.genres.slice(0, 4).map((genre: string) => (
<span key={genre} style={{ padding: "4px 12px", borderRadius: "9999px", background: "rgba(139,92,246,0.15)", border: "1px solid rgba(139,92,246,0.3)", fontSize: 16 }}>
{genre}
</span>
))}
</div>
)}
</div>
),
{ ...size }
)
}
+429
View File
@@ -0,0 +1,429 @@
import type { Metadata } from "next"
import { Suspense } from "react"
import { notFound } from "next/navigation"
import { after } from "next/server"
import { db } from "@/lib/db/index"
import {
games,
gameVersions,
performanceEntries,
gameComments,
gamePlatformSupport,
hardware,
user,
entryScreenshots,
} from "@/lib/db/schema"
import { and, desc, eq, sql } from "drizzle-orm"
import { isSyncStale, syncSteamGame, ensureSteamGame } from "@/lib/steam/sync"
import { getR2PublicUrl } from "@/lib/storage"
import { smartTruncate, buildBreadcrumbList } from "@/lib/utils/seo"
import { GamePageClient } from "./game-page-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
async function resolveGame(id: string) {
const isNumeric = /^\d+$/.test(id)
let game
if (isNumeric) {
const rows = await db
.select()
.from(games)
.where(eq(games.steamAppId, Number(id)))
.limit(1)
game = rows[0]
} else {
// Try UUID first
const rows = await db
.select()
.from(games)
.where(eq(games.id, id))
.limit(1)
game = rows[0]
// Fallback: try slug lookup
if (!game) {
const slugRows = await db
.select()
.from(games)
.where(eq(games.slug, id))
.limit(1)
game = slugRows[0]
}
}
return game
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params
const game = await resolveGame(id)
if (!game) {
return { title: "Game Not Found | DeckyVault" }
}
const description = game.description
? smartTruncate(game.description, 160)
: `Find benchmarks, community presets, and performance settings for ${game.title} on Steam Deck. Compare FPS, TDP, and battery life from community reports.`
return {
title: `${game.title} - Benchmarks & Settings`,
description,
alternates: { canonical: `https://deckyvault.xyz/game/${game.id}` },
openGraph: {
title: `${game.title} - Benchmarks & Settings | DeckyVault`,
description: game.description ? smartTruncate(game.description, 200) : `Benchmarks and settings for ${game.title}`,
url: `https://deckyvault.xyz/game/${game.id}`,
images: [{ url: `/game/${game.id}/opengraph-image`, width: 1200, height: 630 }],
type: "website",
siteName: "DeckyVault",
},
twitter: {
card: "summary_large_image",
title: `${game.title} - Benchmarks & Settings | DeckyVault`,
description: game.description ? smartTruncate(game.description, 200) : `Benchmarks and settings for ${game.title}`,
images: [`/game/${game.id}/opengraph-image`],
},
}
}
async function createGameStub(steamAppId: number) {
const result = await ensureSteamGame(steamAppId)
if (!result.game) {
notFound()
}
// If the sync determined this is not a game (DLC, soundtrack, etc.),
// treat it as not found rather than showing a broken page
if (result.game.syncStatus === "error" && result.error?.includes("not a game")) {
notFound()
}
return result.game
}
export default async function GamePage({
params,
searchParams,
}: {
params: Promise<{ id: string }>
searchParams: Promise<{ sync?: string }>
}) {
const { id } = await params
const { sync } = await searchParams
const isNumeric = /^\d+$/.test(id)
const forceSync = sync === "1"
// ── Resolve game ────────────────────────────────────────────────
let game = await resolveGame(id)
if (!game && isNumeric) {
try {
game = await createGameStub(Number(id))
} catch (err) {
console.error("Failed to auto-create game stub:", err)
}
}
if (!game) {
notFound()
}
// ── Fetch related data in parallel ──────────────────────────────
const [
benchmarkCount,
presetCount,
commentCount,
platformSupport,
presetRows,
] = await Promise.all([
db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.innerJoin(
gameVersions,
eq(performanceEntries.versionId, gameVersions.id),
)
.where(eq(gameVersions.gameId, game.id))
.then((r) => r[0]?.count ?? 0),
db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
eq(gameVersions.gameId, game.id),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.settingsJson} IS NOT NULL`,
),
)
.then((r) => r[0]?.count ?? 0),
db
.select({ count: sql<number>`count(*)::int` })
.from(gameComments)
.where(eq(gameComments.gameId, game.id))
.then((r) => r[0]?.count ?? 0),
db
.select()
.from(gamePlatformSupport)
.where(eq(gamePlatformSupport.gameId, game.id))
.then((r) => r),
db
.select({
id: performanceEntries.id,
hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name,
upvotes: performanceEntries.upvotes,
settingsJson: performanceEntries.settingsJson,
fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh,
fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod,
protonVersion: performanceEntries.protonVersion,
osVersion: performanceEntries.osVersion,
createdAt: performanceEntries.createdAt,
userId: performanceEntries.userId,
userName: user.name,
userImage: user.image,
downvotes: performanceEntries.downvotes,
launchOptions: performanceEntries.launchOptions,
loadTimeSsd: performanceEntries.loadTimeSsd,
loadTimeSd: performanceEntries.loadTimeSd,
tdpWatts: performanceEntries.tdpWatts,
youtubeVideoId: performanceEntries.youtubeVideoId,
customSystem: performanceEntries.customSystem,
userNotes: performanceEntries.userNotes,
versionString: gameVersions.versionString,
buildId: gameVersions.buildId,
gameAntiCheatName: gamePlatformSupport.antiCheatName,
gameAntiCheatStatus: gamePlatformSupport.antiCheatStatus,
verifiedAt: performanceEntries.verifiedAt,
isPinned: performanceEntries.isPinned,
pinnedAt: performanceEntries.pinnedAt,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.innerJoin(user, eq(performanceEntries.userId, user.id))
.innerJoin(
gamePlatformSupport,
and(
eq(gamePlatformSupport.gameId, gameVersions.gameId),
eq(gamePlatformSupport.hardwareSlug, performanceEntries.hardwareSlug),
),
)
.where(
and(
eq(gameVersions.gameId, game.id),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.settingsJson} IS NOT NULL`,
),
)
.orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes)),
])
// ── Sync logic: force or stale-while-revalidate ────────────────
const shouldSync =
game.source === "steam" &&
game.steamAppId &&
(forceSync || isSyncStale(game.lastSync))
if (shouldSync) {
if (forceSync) {
// Block render on forced sync so user sees fresh data immediately
await syncSteamGame(game.steamAppId!)
// Re-fetch game after sync so serialized data is fresh
const refreshed = await resolveGame(game.steamAppId!.toString())
if (refreshed) game = refreshed
} else {
// Stale sync happens after response so page isn't delayed
after(async () => {
await syncSteamGame(game.steamAppId!)
})
}
}
// Serialize for client component (Dates → strings)
const serializedGame = {
id: game.id,
steamAppId: game.steamAppId,
title: game.title,
description: game.description,
developer: game.developer,
publisher: game.publisher,
genres: game.genres,
headerImage: game.headerImage,
capsuleImage: game.capsuleImage,
storeUrl: game.storeUrl,
source: game.source,
slug: game.slug,
lastSync: game.lastSync ? game.lastSync.toISOString() : null,
syncStatus: game.syncStatus,
createdAt: game.createdAt.toISOString(),
systemRequirements: game.systemRequirements,
metacriticScore: game.metacriticScore,
metacriticUrl: game.metacriticUrl,
recommendationsTotal: game.recommendationsTotal,
priceCurrent: game.priceCurrent,
priceInitial: game.priceInitial,
priceCurrency: game.priceCurrency,
isFree: game.isFree,
releaseDate: game.releaseDate,
categories: game.categories,
platforms: game.platforms,
steamReviewScore: game.steamReviewScore,
steamReviewSentiment: game.steamReviewSentiment,
steamReviewCount: game.steamReviewCount,
}
const serializedPresets = presetRows.map((p) => ({
id: p.id,
gameId: game.id,
hardwareSlug: p.hardwareSlug,
hardwareName: p.hardwareName,
upvotes: p.upvotes,
settingsCount: Array.isArray(p.settingsJson)
? p.settingsJson.reduce(
(sum: number, cat: { settings: unknown[] }) =>
sum + cat.settings.length,
0,
)
: 0,
fpsAvg: p.fpsAvg,
fpsLow: p.fpsLow,
fpsHigh: p.fpsHigh,
fpsOnePercentLow: p.fpsOnePercentLow ?? null,
upscalerType: p.upscalerType,
upscalerVersion: p.upscalerVersion,
frameGenMethod: p.frameGenMethod,
protonVersion: p.protonVersion,
osVersion: p.osVersion,
createdAt: p.createdAt.toISOString(),
settingsJson: p.settingsJson,
launchOptions: p.launchOptions,
loadTimeSsd: p.loadTimeSsd ?? null,
loadTimeSd: p.loadTimeSd ?? null,
tdpWatts: p.tdpWatts ?? null,
youtubeVideoId: p.youtubeVideoId ?? null,
screenshots: null as Array<{ id: string; url: string; width: number; height: number; orderIndex: number }> | null,
hardwareWattHours: null as number | null,
hardwareDeviceType: null as string | null,
customSystem: p.customSystem ?? false,
userNotes: p.userNotes,
versionString: p.versionString ?? null,
buildId: p.buildId ?? null,
gameAntiCheatName: p.gameAntiCheatName ?? null,
gameAntiCheatStatus: p.gameAntiCheatStatus ?? null,
userId: p.userId,
userName: p.userName,
userImage: p.userImage,
downvotes: p.downvotes,
verifiedAt: p.verifiedAt ? p.verifiedAt.toISOString() : null,
isPinned: p.isPinned,
pinnedAt: p.pinnedAt ? p.pinnedAt.toISOString() : null,
}))
// Fetch screenshots and hardware details for each preset
const publicUrl = getR2PublicUrl()
for (const preset of serializedPresets) {
const screenshots = await db
.select({
id: entryScreenshots.id,
storageKey: entryScreenshots.storageKey,
orderIndex: entryScreenshots.orderIndex,
width: entryScreenshots.width,
height: entryScreenshots.height,
})
.from(entryScreenshots)
.where(eq(entryScreenshots.entryId, preset.id))
.orderBy(entryScreenshots.orderIndex)
preset.screenshots = screenshots.map((ss) => ({
id: ss.id,
url: `${publicUrl}/${ss.storageKey}`,
width: ss.width,
height: ss.height,
orderIndex: ss.orderIndex,
}))
const [hw] = await db
.select({
wattHours: hardware.wattHours,
deviceType: hardware.deviceType,
})
.from(hardware)
.where(eq(hardware.slug, preset.hardwareSlug))
.limit(1)
preset.hardwareWattHours = hw?.wattHours ? Number(hw.wattHours) : null
preset.hardwareDeviceType = hw?.deviceType ?? null
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "VideoGame",
name: game.title,
...(game.developer && { developer: { "@type": "Organization", name: game.developer } }),
...(game.genres && game.genres.length > 0 && { genre: game.genres }),
...(game.headerImage && { image: game.headerImage }),
url: `https://deckyvault.xyz/game/${game.id}`,
applicationCategory: "Game",
operatingSystem: "SteamOS",
...(game.storeUrl && { offers: { "@type": "Offer", url: game.storeUrl } }),
}),
}}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildBreadcrumbList([
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Games", url: "https://deckyvault.xyz/games" },
{ name: game.title, url: `https://deckyvault.xyz/game/${game.id}` },
]),
),
}}
/>
{/* AggregateRating — based on Steam review score when available */}
{game.steamReviewScore != null && (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "AggregateRating",
itemReviewed: {
"@type": "VideoGame",
name: game.title,
},
ratingValue: (game.steamReviewScore / 10).toFixed(1),
bestRating: "10",
worstRating: "0",
ratingCount: game.steamReviewCount ?? undefined,
}),
}}
/>
)}
<Suspense fallback={<div className="min-h-screen" />}>
<GamePageClient
game={serializedGame}
counts={{
benchmarks: benchmarkCount,
presets: presetCount,
comments: commentCount,
}}
platformSupport={platformSupport}
presets={serializedPresets}
gameId={game.id}
/>
</Suspense>
</>
)
}
File diff suppressed because it is too large Load Diff
+157
View File
@@ -0,0 +1,157 @@
import { notFound } from "next/navigation"
import { db } from "@/lib/db/index"
import { games, gameVersions, performanceEntries, gamePlatformSupport, entryScreenshots } from "@/lib/db/schema"
import { eq, sql } from "drizzle-orm"
import { GameEntryWizard, type GameVersionInfo } from "@/components/wizard/game-entry-wizard"
import { getR2PublicUrl } from "@/lib/storage/r2-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata = {
title: "Submit Benchmark",
}
interface PageProps {
params: Promise<{ id: string }>
searchParams: Promise<{ edit?: string }>
}
export default async function SubmitBenchmarkPage({
params,
searchParams,
}: PageProps) {
const { id } = await params
const { edit } = await searchParams
// Resolve game
const isNumeric = /^\d+$/.test(id)
let game
if (isNumeric) {
const rows = await db
.select()
.from(games)
.where(eq(games.steamAppId, Number(id)))
.limit(1)
game = rows[0]
} else {
const rows = await db
.select()
.from(games)
.where(eq(games.id, id))
.limit(1)
game = rows[0]
}
if (!game) {
notFound()
}
// Fetch all game versions for version selection
const allVersions = await db
.select({
id: gameVersions.id,
versionString: gameVersions.versionString,
buildId: gameVersions.buildId,
isLatest: gameVersions.isLatest,
})
.from(gameVersions)
.where(eq(gameVersions.gameId, game.id))
.orderBy(sql`${gameVersions.createdAt} DESC`)
// Create a default version if none exists
if (allVersions.length === 0) {
const [newVersion] = await db
.insert(gameVersions)
.values({
gameId: game.id,
isLatest: true,
})
.returning()
allVersions.push({
id: newVersion.id,
versionString: newVersion.versionString,
buildId: newVersion.buildId,
isLatest: newVersion.isLatest,
})
}
// If editing, fetch the existing performance entry
let editEntry: any = null
if (edit) {
const [entry] = await db
.select()
.from(performanceEntries)
.where(eq(performanceEntries.id, edit))
.limit(1)
if (entry) {
const publicUrl = getR2PublicUrl()
const screenshots = await db
.select({
id: entryScreenshots.id,
storageKey: entryScreenshots.storageKey,
orderIndex: entryScreenshots.orderIndex,
width: entryScreenshots.width,
height: entryScreenshots.height,
})
.from(entryScreenshots)
.where(eq(entryScreenshots.entryId, entry.id))
.orderBy(entryScreenshots.orderIndex)
editEntry = {
...entry,
screenshots: screenshots.map((ss) => ({
id: ss.id,
url: `${publicUrl}/${ss.storageKey}`,
width: ss.width,
height: ss.height,
orderIndex: ss.orderIndex,
})),
}
}
}
// Determine default version: when editing, use the entry's version;
// otherwise, use the latest (first in DESC order)
let defaultVersionId = allVersions[0].id
if (editEntry?.versionId) {
defaultVersionId = editEntry.versionId
}
const gameVersionInfos: GameVersionInfo[] = allVersions
// Fetch platform support for anti-cheat awareness
const platformSupport = await db
.select({
hardwareSlug: gamePlatformSupport.hardwareSlug,
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
antiCheatName: gamePlatformSupport.antiCheatName,
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
})
.from(gamePlatformSupport)
.where(eq(gamePlatformSupport.gameId, game.id))
return (
<div className="max-w-7xl mx-auto px-4 py-8 w-full">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">
{editEntry ? "Edit Benchmark" : "Submit Benchmark"}
</h1>
<p className="text-sm text-text/60">
{editEntry ? "Update your performance data for" : "Submit performance data for"}{" "}
<span className="text-text font-medium">{game.title}</span>
</p>
</div>
<GameEntryWizard
gameId={game.id}
gameVersions={gameVersionInfos}
defaultVersionId={defaultVersionId}
editEntry={editEntry}
platformSupport={platformSupport}
/>
</div>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { redirect } from "next/navigation"
import { headers } from "next/headers"
import { auth } from "@/lib/auth"
import { NonSteamWizard } from "@/components/wizard/non-steam-wizard"
export const dynamic = "force-dynamic"
export const metadata = {
title: "Add Non-Steam Game",
}
export default async function AddGamePage() {
const h = await headers()
const session = await auth.api.getSession({ headers: h })
if (!session?.user) {
redirect("/login")
}
return (
<div className="max-w-3xl mx-auto px-4 py-8 w-full">
<div className="mb-8">
<h1 className="text-2xl font-bold mb-2">Add a Game</h1>
<p className="text-sm text-text/60">
Add a non-Steam game to DeckyVault. Search for cover art, set platform support, and submit.
</p>
</div>
<NonSteamWizard />
</div>
)
}
+966
View File
@@ -0,0 +1,966 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { useRouter } from "next/navigation"
import Image from "next/image"
import Link from "next/link"
import { motion } from "motion/react"
import {
Gamepad2Icon,
SearchIcon,
TrendingUpIcon,
XIcon,
Loader2Icon,
} from "lucide-react"
import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation"
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
import { FilterDrawer } from "@/components/filter-drawer"
interface GamesListItem {
id: string
steamAppId: number | null
title: string
developer: string | null
capsuleImage: string | null
headerImage: string | null
genres: string[] | null
source: string
steamReviewScore: number | null
playabilityStatus:
| "great"
| "playable"
| "needs_tweaks"
| "unplayable"
| "unknown"
| null
onlineMultiplayerStatus: "none" | "supported" | "unknown" | null
benchmarkCount: number
deckStatus: string | null
antiCheatRelevant: boolean
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null
bestFps: number | null
isRawPerformer: boolean
isPoorPerformance: boolean
estimatedBatteryMin: number | null
}
interface DeviceOption {
slug: string
name: string
}
type SortOption =
| "recent"
| "name"
| "benchmarks"
| "performance"
| "popularity"
| "release_date"
| "steam_reviews"
type SortDirection = "asc" | "desc"
const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> =
{
native: {
label: "Native",
className: "bg-green-500/10 border-green-500/20 text-green-400",
},
proton: {
label: "Proton",
className: "bg-blue-500/10 border-blue-500/20 text-blue-400",
},
unsupported: {
label: "Unsupported",
className: "bg-red-500/10 border-red-500/20 text-red-400",
},
unknown: {
label: "Unknown",
className: "bg-text/5 border-border text-text/40",
},
}
const SORT_OPTIONS: { value: SortOption; label: string }[] = [
{ value: "recent", label: "Recently Added" },
{ value: "name", label: "Name AZ" },
{ value: "benchmarks", label: "Most Benchmarks" },
{ value: "performance", label: "Best Performance" },
{ value: "popularity", label: "Most Popular" },
{ value: "release_date", label: "Release Date" },
{ value: "steam_reviews", label: "Steam Reviews" },
]
export function GamesPageClient({
initialGames,
totalCount,
allGenres,
allDevices,
}: {
initialGames: GamesListItem[]
totalCount: number
allGenres: string[]
allDevices: DeviceOption[]
}) {
const [games, setGames] = useState<GamesListItem[]>(initialGames)
const [total, setTotal] = useState(totalCount)
const [search, setSearch] = useState("")
const [selectedGenres, setSelectedGenres] = useState<string[]>([])
const [selectedDevice, setSelectedDevice] = useState("")
const [sort, setSort] = useState<SortOption>("recent")
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [showFilters, setShowFilters] = useState(false)
const [minFps, setMinFps] = useState<string>("")
const [maxFps, setMaxFps] = useState<string>("")
const [fsrSupport, setFsrSupport] = useState<boolean>(false)
const [protonNative, setProtonNative] = useState<string>("any")
const [antiCheatStatus, setAntiCheatStatus] = useState<string>("any")
const [playabilityStatus, setPlayabilityStatus] = useState<string>("")
const [steamReviewMin, setSteamReviewMin] = useState<string>("")
const [isFree, setIsFree] = useState<boolean>(false)
const [hasMultiplayer, setHasMultiplayer] = useState<boolean>(false)
const observerRef = useRef<IntersectionObserver | null>(null)
const sentinelRef = useRef<HTMLDivElement>(null)
const pageRef = useRef<HTMLElement>(null)
const { isGamepadActive } = useGamepadNavigation(pageRef, {
onXButton: () => {
// Navigate to search page
window.location.href = "/search"
},
onYButton: () => {
// Toggle filter panel
setShowFilters((prev) => !prev)
},
})
const router = useRouter()
const hasMore = games.length < total
// ── Initialize filters from URL params on mount ────────────────
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get("search")) setSearch(params.get("search")!)
if (params.get("device")) setSelectedDevice(params.get("device")!)
if (params.get("genre")) {
const genres = params.get("genre")!.split(",").filter(Boolean)
setSelectedGenres(genres)
}
if (params.get("minFps")) setMinFps(params.get("minFps")!)
if (params.get("maxFps")) setMaxFps(params.get("maxFps")!)
if (params.get("fsrSupport") === "true") setFsrSupport(true)
if (params.get("protonNative") && params.get("protonNative") !== "any")
setProtonNative(params.get("protonNative")!)
if (
params.get("antiCheatStatus") &&
params.get("antiCheatStatus") !== "any"
)
setAntiCheatStatus(params.get("antiCheatStatus")!)
if (params.get("playabilityStatus"))
setPlayabilityStatus(params.get("playabilityStatus")!)
if (params.get("steamReviewScore"))
setSteamReviewMin(params.get("steamReviewScore")!)
if (params.get("isFree") === "true") setIsFree(true)
if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true)
if (params.get("sort")) {
const s = params.get("sort")!
if (
[
"recent",
"name",
"benchmarks",
"performance",
"popularity",
"release_date",
"steam_reviews",
].includes(s)
) {
setSort(s as SortOption)
}
}
if (params.get("order"))
setSortDirection(params.get("order") as SortDirection)
}, [])
/* eslint-enable react-hooks/set-state-in-effect */
const buildUrl = useCallback(
(offset: number) => {
const params = new URLSearchParams()
params.set("offset", String(offset))
params.set("limit", "24")
params.set("sort", sort)
params.set("order", sortDirection)
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0)
params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any")
params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus)
params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
return `/api/games/listing?${params.toString()}`
},
[
sort,
sortDirection,
search,
selectedDevice,
selectedGenres,
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
],
)
// Load more function for infinite scroll
const loadMore = useCallback(async () => {
if (loading || !hasMore) return
setLoading(true)
setError(null)
try {
const url = buildUrl(games.length)
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to load games")
const data = await res.json()
setGames((prev) => [...prev, ...data.data])
setTotal(data.total)
} catch (err) {
setError("Failed to load more games. Please try again.")
console.error(err)
} finally {
setLoading(false)
}
}, [loading, hasMore, games.length, buildUrl])
// ── Sync filter state to URL (replace, not push) ───────────────
useEffect(() => {
const params = new URLSearchParams()
if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0)
params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any")
params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus)
params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
if (sort !== "recent") params.set("sort", sort)
if (sortDirection !== "desc") params.set("order", sortDirection)
const qs = params.toString()
const url = qs ? `/games?${qs}` : "/games"
router.replace(url, { scroll: false })
}, [
search,
selectedDevice,
selectedGenres,
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
sort,
sortDirection,
router,
])
// Full reload when filters/sort change
useEffect(() => {
let cancelled = false
async function fetchGames() {
setLoading(true)
setError(null)
try {
const url = buildUrl(0)
const res = await fetch(url)
if (!res.ok) throw new Error("Failed to load games")
const data = await res.json()
if (!cancelled) {
setGames(data.data)
setTotal(data.total)
}
} catch (err) {
if (!cancelled)
setError("Failed to load games. Please try again.")
console.error(err)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchGames()
return () => {
cancelled = true
}
}, [buildUrl])
// IntersectionObserver for infinite scroll
useEffect(() => {
if (observerRef.current) observerRef.current.disconnect()
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore()
}
},
{ rootMargin: "200px" },
)
if (sentinelRef.current) {
observer.observe(sentinelRef.current)
}
observerRef.current = observer
return () => observer.disconnect()
}, [hasMore, loading, loadMore])
const toggleGenre = (genre: string) => {
setSelectedGenres((prev) =>
prev.includes(genre)
? prev.filter((g) => g !== genre)
: [...prev, genre],
)
}
const filterPanelContent = (
<>
{/* Device filter */}
<div>
<span className='text-xs text-text/50 uppercase tracking-wider mb-1.5 block'>
Device
</span>
<div className='flex flex-wrap gap-1.5'>
<button
onClick={() => setSelectedDevice("")}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
All Devices
</button>
{allDevices.map((device) => (
<button
key={device.slug}
onClick={() =>
setSelectedDevice(
selectedDevice === device.slug
? ""
: device.slug,
)
}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{device.name}
</button>
))}
</div>
</div>
{/* Genre filter */}
<div>
<span className='text-xs text-text/50 uppercase tracking-wider mb-1.5 block'>
Genre
</span>
<div className='flex flex-wrap gap-1.5 max-h-32 overflow-y-auto scrollbar-hide'>
{allGenres.map((genre) => (
<button
key={genre}
onClick={() => toggleGenre(genre)}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedGenres.includes(genre)
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{genre}
</button>
))}
</div>
</div>
{/* Performance Filters */}
<div className='space-y-2'>
<h4 className='text-sm font-medium text-zinc-300'>
Performance
</h4>
<div className='flex gap-2'>
<input
type='number'
placeholder='Min FPS'
value={minFps}
onChange={(e) => setMinFps(e.target.value)}
className='w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
/>
<input
type='number'
placeholder='Max FPS'
value={maxFps}
onChange={(e) => setMaxFps(e.target.value)}
className='w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
/>
</div>
<select
value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
>
<option value=''>Any Playability</option>
<option value='great'>Plays Great</option>
<option value='playable'>Playable</option>
<option value='needs_tweaks'>Needs Tweaks</option>
<option value='unplayable'>Unplayable</option>
</select>
</div>
{/* Compatibility Filters */}
<div className='space-y-2'>
<h4 className='text-sm font-medium text-zinc-300'>
Compatibility
</h4>
<select
value={protonNative}
onChange={(e) => setProtonNative(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
>
<option value='any'>Any Runtime</option>
<option value='native'>Native</option>
<option value='proton'>Proton</option>
</select>
<select
value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
>
<option value='any'>Any Anti-Cheat</option>
<option value='supported'>AC Supported</option>
<option value='unsupported'>AC Unsupported</option>
<option value='unknown'>AC Unknown</option>
</select>
<label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input
type='checkbox'
checked={fsrSupport}
onChange={(e) => setFsrSupport(e.target.checked)}
className='rounded border-zinc-600'
/>
FSR Support
</label>
</div>
{/* Other Filters */}
<div className='space-y-2'>
<h4 className='text-sm font-medium text-zinc-300'>Other</h4>
<label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input
type='checkbox'
checked={isFree}
onChange={(e) => setIsFree(e.target.checked)}
className='rounded border-zinc-600'
/>
Free to Play
</label>
<label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input
type='checkbox'
checked={hasMultiplayer}
onChange={(e) => setHasMultiplayer(e.target.checked)}
className='rounded border-zinc-600'
/>
Has Multiplayer
</label>
<input
type='number'
placeholder='Min Steam Review %'
value={steamReviewMin}
onChange={(e) => setSteamReviewMin(e.target.value)}
className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm'
/>
</div>
{/* Saved Filters */}
<SavedFilters
currentFilters={{
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
genre: selectedGenres[0] || "",
device: selectedDevice,
sortBy: sort,
}}
onLoad={(filters) => {
setMinFps((filters.minFps as string) || "")
setMaxFps((filters.maxFps as string) || "")
setFsrSupport((filters.fsrSupport as boolean) || false)
setProtonNative((filters.protonNative as string) || "any")
setAntiCheatStatus(
(filters.antiCheatStatus as string) || "any",
)
setPlayabilityStatus(
(filters.playabilityStatus as string) || "",
)
setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer(
(filters.hasMultiplayer as boolean) || false,
)
if (filters.genre)
setSelectedGenres(
(filters.genre as string)
.split(",")
.filter(Boolean),
)
else setSelectedGenres([])
if (filters.device)
setSelectedDevice(filters.device as string)
else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption)
setShowFilters(false)
}}
/>
{/* Clear filters */}
{(selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button
onClick={() => {
setSelectedDevice("")
setSelectedGenres([])
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}}
className='text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start min-h-11 py-2 flex items-center'
>
Clear all filters
</button>
)}
</>
)
return (
<section
ref={pageRef}
className={`w-full flex flex-col gap-8 py-8 ${isGamepadActive ? "gamepad-focus" : ""}`}
>
{/* 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'>Games</h1>
<p className='text-sm text-text/60 mt-1'>
Browse {total.toLocaleString()} games with benchmarks,
settings, and performance data
</p>
</div>
</motion.div>
{/* Search & Filter Bar */}
<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-col gap-3'>
{/* Search + Sort Row */}
<div className='flex flex-row items-center flex-wrap gap-3'>
<label className='flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2.5 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text min-h-11'>
<SearchIcon className='h-4 w-4 text-text/40 shrink-0' />
<input
type='text'
placeholder='Search games...'
value={search}
onChange={(e) => setSearch(e.target.value)}
className='flex-1 outline-none bg-transparent text-sm min-w-0'
/>
{search && (
<button
onClick={() => setSearch("")}
className='text-text/40 hover:text-text/70 transition-colors cursor-pointer'
>
<XIcon className='h-3.5 w-3.5' />
</button>
)}
</label>
<button
onClick={() =>
setSortDirection((prev) =>
prev === "asc" ? "desc" : "asc",
)
}
className='px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer min-h-11'
title={
sortDirection === "asc"
? "Sort ascending"
: "Sort descending"
}
>
{sortDirection === "asc" ? "↑" : "↓"}
</button>
<select
value={sort}
onChange={(e) =>
setSort(e.target.value as SortOption)
}
className='bg-text/5 border border-border rounded-md px-3 py-3 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 cursor-pointer'
>
{SORT_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
>
{opt.label}
</option>
))}
</select>
<button
onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-11 ${
showFilters ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
? "bg-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`}
>
Filters
{(selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<span className='ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold'>
{selectedGenres.length +
(selectedDevice ? 1 : 0) +
(minFps ? 1 : 0) +
(maxFps ? 1 : 0) +
(fsrSupport ? 1 : 0) +
(protonNative !== "any" ? 1 : 0) +
(antiCheatStatus !== "any" ? 1 : 0) +
(playabilityStatus ? 1 : 0) +
(steamReviewMin ? 1 : 0) +
(isFree ? 1 : 0) +
(hasMultiplayer ? 1 : 0)}
</span>
)}
</button>
</div>
{/* Mobile drawer */}
<FilterDrawer
isOpen={showFilters}
onClose={() => setShowFilters(false)}
>
<div className='flex flex-col gap-3'>
{filterPanelContent}
</div>
</FilterDrawer>
{/* Desktop inline panel */}
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className='hidden lg:flex flex-col gap-3 pt-1'
>
{filterPanelContent}
</motion.div>
)}
</div>
</motion.div>
{/* Error State */}
{error && !loading && (
<div className='max-w-7xl mx-auto px-4 md:px-0 text-center py-12'>
<p className='text-red-400 text-sm'>{error}</p>
<button
onClick={() => window.location.reload()}
className='mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-11 py-2 flex items-center'
>
Try again
</button>
</div>
)}
{/* Games 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-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4'>
{games.map((game) => (
<GameCard
key={game.id}
game={game}
/>
))}
</div>
{/* Empty state */}
{!loading && games.length === 0 && !error && (
<div className='flex flex-col items-center justify-center py-20 gap-4'>
<Gamepad2Icon className='h-12 w-12 text-text/20' />
<p className='text-text/40 text-sm'>
{search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
? "No games match your filters"
: "No games found"}
</p>
{(search ||
selectedDevice ||
selectedGenres.length > 0 ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer) && (
<button
onClick={() => {
setSearch("")
setSelectedDevice("")
setSelectedGenres([])
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}}
className='text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer min-h-11'
>
Clear filters
</button>
)}
</div>
)}
</motion.div>
{/* Loading indicator for infinite scroll */}
{loading && (
<div className='flex items-center justify-center py-8'>
<Loader2Icon className='h-6 w-6 text-primary animate-spin' />
</div>
)}
{/* End of list */}
{!loading && !hasMore && games.length > 0 && (
<div className='text-center py-6'>
<p className='text-text/30 text-xs'>
Showing all {games.length} of {total.toLocaleString()}{" "}
games
</p>
</div>
)}
{/* Infinite scroll sentinel */}
{hasMore && !loading && (
<div
ref={sentinelRef}
className='h-1'
/>
)}
</section>
)
}
function GameCard({ game }: { game: GamesListItem }) {
const [imgError, setImgError] = useState(false)
const imageUrl = game.capsuleImage || game.headerImage
const deckConfig = game.deckStatus
? (DECK_STATUS_CONFIG[game.deckStatus] ?? DECK_STATUS_CONFIG.unknown)
: null
return (
<Link
href={`/game/${game.id}`}
className='group rounded-xl border border-border bg-text/3 hover:border-primary/30 transition-all duration-200 overflow-hidden'
>
<div className='relative w-full aspect-2/3 bg-text/10 overflow-hidden'>
{imageUrl && !imgError ? (
<Image
src={imageUrl}
alt={game.title}
fill
className='object-cover group-hover:scale-105 transition-transform duration-300'
sizes='(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw'
onError={() => setImgError(true)}
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/20' />
</div>
)}
</div>
<div className='p-2.5 sm:p-3'>
<h3 className='text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight'>
{game.title}
</h3>
<div className='flex flex-wrap gap-1 mt-1'>
{game.playabilityStatus && (
<PlayabilityBadge
status={game.playabilityStatus}
compact
/>
)}
{game.antiCheatRelevant &&
game.antiCheatStatus === "unsupported" && (
<AntiCheatBadge
antiCheatRelevant={true}
antiCheatStatus={game.antiCheatStatus}
compact
/>
)}
{game.steamReviewScore != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-blue-400'>
<svg
className='h-2.5 w-2.5'
viewBox='0 0 24 24'
fill='currentColor'
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg>
{game.steamReviewScore}%
</span>
)}
{game.isRawPerformer && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[10px] font-semibold shrink-0'>
RAW PERFORMER
</span>
)}
{game.isPoorPerformance && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-semibold shrink-0'>
POOR PERFORMANCE
</span>
)}
{game.bestFps != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
{Math.round(game.bestFps)}fps best
</span>
)}
{game.estimatedBatteryMin != null && (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
🔋 ~{Math.round(game.estimatedBatteryMin / 60)}h
</span>
)}
</div>
<div className='mt-1.5 flex items-center gap-2 flex-wrap'>
{game.benchmarkCount > 0 ? (
<span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
<TrendingUpIcon className='h-3 w-3 text-primary/60' />
<span className='tabular-nums'>
{game.benchmarkCount}
</span>
</span>
) : (
<span className='text-[10px] text-text/25 italic'>
No data yet
</span>
)}
{deckConfig && (
<span
className={`text-[9px] px-1.5 py-0.5 rounded-full border ${deckConfig.className}`}
>
{deckConfig.label}
</span>
)}
</div>
</div>
</Link>
)
}
+299
View File
@@ -0,0 +1,299 @@
import type { Metadata } from "next"
import { after } from "next/server"
import { db } from "@/lib/db/index"
import {
games,
gameVersions,
performanceEntries,
gamePlatformSupport,
hardware,
} from "@/lib/db/schema"
import { sql, eq, and, desc, inArray } from "drizzle-orm"
import { isSyncStale, syncSteamGame } from "@/lib/steam/sync"
import { buildBreadcrumbList } from "@/lib/utils/seo"
import { GamesPageClient } from "./games-page-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata: Metadata = {
title: "Games",
description:
"Browse the full catalog of Steam Deck games with benchmarks, community settings, and performance data. Filter by genre, device, and more.",
keywords: [
"Steam Deck games",
"game benchmarks",
"Steam Deck settings",
"game catalog",
"performance data",
],
alternates: { canonical: "https://deckyvault.xyz/games" },
openGraph: {
title: "Games | DeckyVault",
description:
"Browse the full catalog of Steam Deck games with benchmarks and performance data.",
url: "https://deckyvault.xyz/games",
siteName: "DeckyVault",
type: "website",
},
}
export default async function GamesPage() {
// Fetch initial 24 games
const gamesData = await db
.select({
id: games.id,
steamAppId: games.steamAppId,
title: games.title,
developer: games.developer,
capsuleImage: games.capsuleImage,
headerImage: games.headerImage,
genres: games.genres,
source: games.source,
createdAt: games.createdAt,
steamReviewScore: games.steamReviewScore,
playabilityStatus: games.playabilityStatus,
onlineMultiplayerStatus: games.onlineMultiplayerStatus,
lastSync: games.lastSync,
})
.from(games)
.orderBy(desc(games.createdAt))
.limit(24)
// Get total count
const [{ count: totalCount }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(games)
// Get benchmark counts for the initial games
const gameIds = gamesData.map((g) => g.id)
const benchmarkCounts = gameIds.length > 0
? await db
.select({
gameId: gameVersions.gameId,
count: sql<number>`count(*)::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
inArray(gameVersions.gameId, gameIds),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
: []
const benchmarkMap = new Map<string, number>()
for (const row of benchmarkCounts) {
benchmarkMap.set(row.gameId, row.count)
}
// Fetch platform support for initial games (prioritise Steam Deck)
const platformRows = gameIds.length > 0
? await db
.select({
gameId: gamePlatformSupport.gameId,
hardwareSlug: gamePlatformSupport.hardwareSlug,
protonStatus: gamePlatformSupport.protonStatus,
antiCheatRelevant: gamePlatformSupport.antiCheatRelevant,
antiCheatStatus: gamePlatformSupport.antiCheatStatus,
})
.from(gamePlatformSupport)
.where(inArray(gamePlatformSupport.gameId, gameIds))
: []
const platformMap = new Map<string, string>()
const antiCheatMap = new Map<string, { antiCheatRelevant: boolean; antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null }>()
for (const row of platformRows) {
const isSteamDeck = row.hardwareSlug.startsWith("steamdeck")
const existing = platformMap.get(row.gameId)
if (!existing || (!existing.startsWith("steamdeck") && isSteamDeck)) {
platformMap.set(row.gameId, row.protonStatus)
}
const existingAc = antiCheatMap.get(row.gameId)
if (row.antiCheatRelevant) {
if (!existingAc || (!existingAc.antiCheatRelevant && isSteamDeck) || (!existingAc.antiCheatRelevant)) {
antiCheatMap.set(row.gameId, {
antiCheatRelevant: row.antiCheatRelevant,
antiCheatStatus: row.antiCheatStatus,
})
}
}
}
// Performance stats: best FPS, raw performer, poor performance, battery estimate
const rawPerformerMap = new Map<string, boolean>()
const poorPerformerMap = new Map<string, boolean>()
const bestFpsMap = new Map<string, number>()
const batteryMinMap = new Map<string, number>()
if (gameIds.length > 0) {
const perfStats = await db
.select({
gameId: gameVersions.gameId,
bestFps: sql<number>`MAX(${performanceEntries.fpsAvg})::real`,
isRawPerformer: sql<boolean>`BOOL_OR(
${performanceEntries.fpsAvg} >= 60
AND ${performanceEntries.upscalerType} = 'none'
AND ${performanceEntries.frameGenMethod} = 'none'
)`,
isPoorPerformance: sql<boolean>`BOOL_OR(${performanceEntries.fpsAvg} < 30)`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardware, and(
eq(performanceEntries.hardwareSlug, hardware.slug),
eq(hardware.deviceType, "handheld"),
))
.where(
and(
inArray(gameVersions.gameId, gameIds),
eq(performanceEntries.isRemoved, false),
),
)
.groupBy(gameVersions.gameId)
for (const row of perfStats) {
bestFpsMap.set(row.gameId, row.bestFps)
rawPerformerMap.set(row.gameId, row.isRawPerformer)
poorPerformerMap.set(row.gameId, row.isPoorPerformance)
}
// Battery estimate for handheld devices
const batteryStats = await db
.select({
gameId: gameVersions.gameId,
estimatedBatteryMin: sql<number>`ROUND(
(${hardware.wattHours}::real / ${performanceEntries.tdpWatts}) * 60
)::int`,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.where(
and(
inArray(gameVersions.gameId, gameIds),
eq(performanceEntries.isRemoved, false),
eq(hardware.deviceType, "handheld"),
sql`${performanceEntries.tdpWatts} IS NOT NULL AND ${performanceEntries.tdpWatts} > 0`,
sql`${hardware.wattHours} IS NOT NULL`,
),
)
.orderBy(desc(performanceEntries.fpsAvg))
const seenGames = new Set<string>()
for (const row of batteryStats) {
if (!seenGames.has(row.gameId)) {
seenGames.add(row.gameId)
batteryMinMap.set(row.gameId, row.estimatedBatteryMin)
}
}
}
// Fetch all genres
const genreRows = await db
.select({ genres: games.genres })
.from(games)
.where(sql`${games.genres} IS NOT NULL`)
const genreSet = new Set<string>()
for (const row of genreRows) {
if (Array.isArray(row.genres)) {
for (const g of row.genres) {
if (typeof g === "string") genreSet.add(g)
}
}
}
// Fetch all hardware devices
const deviceRows = await db
.select({ slug: hardware.slug, name: hardware.name })
.from(hardware)
.orderBy(hardware.sortOrder)
// Serialize for client
const initialGames = gamesData.map((g) => ({
id: g.id,
steamAppId: g.steamAppId,
title: g.title,
developer: g.developer,
capsuleImage: g.capsuleImage,
headerImage: g.headerImage,
genres: g.genres,
source: g.source,
steamReviewScore: g.steamReviewScore,
playabilityStatus: g.playabilityStatus,
onlineMultiplayerStatus: g.onlineMultiplayerStatus,
benchmarkCount: benchmarkMap.get(g.id) ?? 0,
deckStatus: platformMap.get(g.id) ?? null,
antiCheatRelevant: antiCheatMap.get(g.id)?.antiCheatRelevant ?? false,
antiCheatStatus: antiCheatMap.get(g.id)?.antiCheatStatus ?? null,
bestFps: bestFpsMap.get(g.id) ?? null,
isRawPerformer: rawPerformerMap.get(g.id) ?? false,
isPoorPerformance: poorPerformerMap.get(g.id) ?? false,
estimatedBatteryMin: batteryMinMap.get(g.id) ?? null,
}))
const allGenres = Array.from(genreSet).sort()
const allDevices = deviceRows
// ── Background sync for stale games ────────────────────────────────
const staleSteamAppIds = gamesData
.filter((g) => g.source === "steam" && g.steamAppId && isSyncStale(g.lastSync))
.map((g) => g.steamAppId!)
if (staleSteamAppIds.length > 0) {
after(async () => {
// Sync stale games sequentially with a small delay to avoid rate-limiting
for (const appId of staleSteamAppIds) {
try {
await syncSteamGame(appId)
} catch {
// Stale sync failure is non-fatal — data will be refreshed on next visit
}
await new Promise((r) => setTimeout(r, 1500))
}
})
}
// JSON-LD ItemList
const jsonLd = {
"@context": "https://schema.org",
"@type": "ItemList",
itemListElement: initialGames.map((game, i) => ({
"@type": "ListItem",
position: i + 1,
name: game.title,
url: `https://deckyvault.xyz/game/${game.id}`,
})),
}
return (
<>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(
buildBreadcrumbList([
{ name: "Home", url: "https://deckyvault.xyz" },
{ name: "Games", url: "https://deckyvault.xyz/games" },
]),
),
}}
/>
<GamesPageClient
initialGames={initialGames}
totalCount={totalCount}
allGenres={allGenres}
allDevices={allDevices}
/>
</>
)
}
+57
View File
@@ -0,0 +1,57 @@
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme inline {
/* Colors */
--color-background: #100b14;
--color-foreground: #180161;
--color-primary: #eb3779;
--color-secondary: #571b8b;
--color-accent: #fb793c;
--color-text: #ebe4f1;
--color-border: color-mix(in srgb, var(--color-text) 20%, transparent);
--color-border-active: color-mix(
in srgb,
var(--color-text) 60%,
transparent
);
/* Defaults */
--default-transition-duration: 0.3s;
/* Fonts */
--font-sans: var(--font-lexend);
}
/* Touch targets — WCAG 2.1 AA: minimum 44×44px */
.touch-target {
min-height: 44px;
min-width: 44px;
}
/* Gamepad focus ring — visible only when gamepad navigation is active */
.gamepad-focus :focus,
.gamepad-focus:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
border-radius: 4px;
}
/* Thin scrollbar for horizontal scroll sections */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: color-mix(in srgb, var(--color-text) 15%, transparent) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
height: 4px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--color-text) 15%, transparent);
border-radius: 2px;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

+126
View File
@@ -0,0 +1,126 @@
import type { Metadata, Viewport } from "next"
import { Lexend } from "next/font/google"
import "./globals.css"
import Script from "next/script"
import Navbar from "@/components/navbar"
import { Suspense } from "react"
const font = Lexend({
variable: "--font-lexend",
subsets: ["latin"],
})
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
viewportFit: "cover",
themeColor: "#eb3779",
}
export const metadata: Metadata = {
metadataBase: new URL("https://deckyvault.xyz"),
title: {
default: "DeckyVault - Steam Deck Benchmarks & Settings",
template: "%s | DeckyVault",
},
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
keywords: [
"Steam Deck",
"benchmarks",
"settings",
"performance",
"FPS",
"gaming",
"Steam Machine",
"Proton",
"FSR",
"compatibility",
],
authors: [
{ name: "Adrian Bonpin", url: "https://github.com/AdrianBonpin" },
],
creator: "@adrianbonpin",
openGraph: {
type: "website",
locale: "en_US",
url: "https://deckyvault.xyz",
siteName: "DeckyVault",
title: "DeckyVault - Steam Deck Benchmarks & Settings",
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
images: [
{
url: "/opengraph-image",
width: 1200,
height: 630,
alt: "DeckyVault - Steam Deck Benchmarks & Settings",
},
],
},
twitter: {
card: "summary_large_image",
title: "DeckyVault - Steam Deck Benchmarks & Settings",
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
creator: "@adrianbonpin",
images: ["/twitter-image"],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
"max-video-preview": -1,
"max-image-preview": "large",
"max-snippet": -1,
},
},
alternates: {
canonical: "https://deckyvault.xyz",
},
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html
lang='en'
className={`${font.variable} bg-background text-text antialiased overscroll-none`}
>
<head>
{/* DNS prefetch + preconnect for external image CDNs */}
<link rel="dns-prefetch" href="https://cdn.akamai.steamstatic.com" />
<link rel="preconnect" href="https://cdn.akamai.steamstatic.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.steamgriddb.com" />
<link rel="preconnect" href="https://cdn.steamgriddb.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn2.steamgriddb.com" />
<link rel="preconnect" href="https://cdn2.steamgriddb.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.deckyvault.xyz" />
<link rel="preconnect" href="https://cdn.deckyvault.xyz" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://lh3.googleusercontent.com" />
<link rel="preconnect" href="https://lh3.googleusercontent.com" crossOrigin="anonymous" />
<link rel="dns-prefetch" href="https://cdn.discordapp.com" />
<link rel="preconnect" href="https://cdn.discordapp.com" crossOrigin="anonymous" />
{/* PWA meta */}
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head>
<body className='min-h-full w-dvw flex flex-col relative'>
<Suspense>
<Navbar />
</Suspense>
{children}
<Script
src='https://stat.ranio.xyz/api/script.js'
data-site-id='b9817e8df599'
strategy='afterInteractive'
/>
</body>
</html>
)
}
+33
View File
@@ -0,0 +1,33 @@
import type { MetadataRoute } from "next"
export default function manifest(): MetadataRoute.Manifest {
return {
name: "DeckyVault - Steam Deck Benchmarks & Settings",
short_name: "DeckyVault",
description:
"A fast, modern browser for finding game benchmarks, settings, and guides for Steam Deck OLED, Steam Deck LCD, and Steam Machine.",
start_url: "/",
display: "standalone",
background_color: "#100b14",
theme_color: "#eb3779",
icons: [
{
src: "/icon-192.png",
sizes: "192x192",
type: "image/png",
purpose: "maskable",
},
{
src: "/icon-512.png",
sizes: "512x512",
type: "image/png",
purpose: "any",
},
{
src: "/icon.png",
sizes: "any",
type: "image/png",
},
],
}
}
+45
View File
@@ -0,0 +1,45 @@
"use client"
import { motion } from "motion/react"
import Link from "next/link"
export default function NotFound() {
return (
<section className="w-dvw h-dvh flex flex-col items-center justify-center relative p-4">
<motion.h1
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
className="font-bold text-8xl md:text-9xl text-primary"
>
404
</motion.h1>
<motion.h2
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.3 } }}
className="mt-4 font-semibold text-xl md:text-2xl text-center"
>
This page doesn&apos;t exist yet
</motion.h2>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 0.6, transition: { delay: 0.6 } }}
className="mt-2 text-center max-w-md"
>
The page you&apos;re looking for hasn&apos;t been built yet, or may have
been moved.
</motion.p>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.9 } }}
className="mt-8"
>
<Link
href="/"
className="px-6 py-3 rounded-full bg-primary text-background font-semibold hover:bg-primary/80 transition-colors cursor-pointer"
>
Back to Home
</Link>
</motion.div>
</section>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { ImageResponse } from "next/og"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
export const alt = "DeckyVault - Steam Deck Benchmarks & Settings"
export const size = {
width: 1200,
height: 630,
}
export const contentType = "image/png"
export default async function Image() {
const logoData = await readFile(
join(process.cwd(), "app/icon.png"),
"base64"
)
const logoSrc = `data:image/png;base64,${logoData}`
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#100b14",
color: "#ebe4f1",
fontFamily: "sans-serif",
gap: "16px",
}}
>
<img
src={logoSrc}
alt="DeckyVault"
height={120}
style={{ borderRadius: "16px" }}
/>
<div
style={{
fontSize: 64,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
DeckyVault
</div>
<div
style={{
fontSize: 28,
fontWeight: 400,
opacity: 0.8,
}}
>
Steam Deck Benchmarks & Settings
</div>
</div>
),
{
...size,
}
)
}
+606
View File
@@ -0,0 +1,606 @@
"use client"
import { AnimatePresence, motion } from "motion/react"
import { useState, useEffect } from "react"
import Link from "next/link"
import Image from "next/image"
import {
Gamepad2Icon,
SearchIcon,
TrendingUpIcon,
GaugeIcon,
ClockIcon,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { PlayabilityBadge } from "@/components/playability-badge"
import { cn } from "@/lib/utils"
interface GameCard {
id: string
title: string
capsule_image: string | null
header_image: string | null
playability_status?: string | null
activity_score?: number
benchmark_count?: number
comment_count?: number
upvote_count?: number
avg_fps?: number
report_count?: number
release_date?: string | null
created_at?: string | null
}
interface SectionData {
recentBenchmarks: GameCard[]
trending: GameCard[]
mostTested: GameCard[]
onSale: SaleGameCard[]
}
interface SaleGameCard extends GameCard {
price_current?: number
price_initial?: number
price_currency?: string
steam_review_score?: number
best_fps?: number
}
function SkeletonSections() {
return (
<>
{[1, 2, 3].map((i) => (
<div
key={i}
className='space-y-3'
>
<div className='h-5 w-48 bg-text/5 rounded animate-pulse' />
<div className='overflow-x-auto overflow-y-hidden' style={{ pointerEvents: 'none' }}>
<div className='flex gap-3 pb-2' style={{ pointerEvents: 'auto' }}>
{[1, 2, 3, 4].map((j) => (
<div
key={j}
className='shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border animate-pulse'
>
<div className='aspect-[2/3] bg-text/5 rounded-t-xl' />
<div className='p-3 space-y-2'>
<div className='h-3 bg-text/5 rounded w-3/4' />
<div className='h-2 bg-text/5 rounded w-1/2' />
</div>
</div>
))}
</div>
</div>
</div>
))}
</>
)
}
function GameSection({
title,
icon: Icon,
games,
statKey,
statLabel,
statFormatter,
accentColor = "text-text/50",
muted = false,
}: {
title: string
icon: React.ElementType
games: GameCard[]
statKey: string
statLabel: string
statFormatter?: (v: unknown) => string
accentColor?: string
muted?: boolean
}) {
const router = useRouter()
const formatStat = (v: unknown): string => {
if (statFormatter) return statFormatter(v)
if (typeof v === "number") return `${Math.round(v)} ${statLabel}`
return `${v} ${statLabel}`
}
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
className={muted ? "opacity-70" : ""}
>
<div className='flex items-center gap-2 mb-4'>
<div className='border-l-2 border-primary pl-3'>
<div className='flex items-center gap-2'>
<Icon className={`h-4 w-4 ${accentColor}`} />
<h2 className='text-sm font-semibold text-text/80'>
{title}
</h2>
</div>
</div>
</div>
<div className='overflow-x-auto overflow-y-hidden -mx-1 px-1' style={{ pointerEvents: 'none' }}>
<div className='flex gap-3 pb-2 px-1' style={{ pointerEvents: 'auto' }}>
{games.map((game, idx) => (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
className='group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/6 transition-colors cursor-pointer overflow-hidden'
onClick={() => router.push(`/game/${game.id}?sync=1`)}
>
<div className='relative aspect-2/3 bg-text/10 overflow-hidden'>
{game.capsule_image ? (
<Image
src={game.capsule_image}
alt={game.title}
fill
className='object-cover rounded-xl group-hover:scale-[0.97] transition-transform duration-300'
sizes='(max-width: 640px) 144px, 176px'
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/15' />
</div>
)}
{/* Playability badge pinned at bottom of image */}
{game.playability_status &&
game.playability_status !== "unknown" && (
<div className='absolute bottom-1.5 left-1.5 right-1.5'>
<PlayabilityBadge
status={game.playability_status as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null}
compact
showLabel
className='text-[10px] px-1.5 py-0.5 w-full justify-center'
/>
</div>
)}
</div>
<div className='p-2.5 space-y-1.5'>
<h3 className='text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors'>
{game.title}
</h3>
{/* Performance badges */}
{game.avg_fps !== undefined && game.avg_fps !== null && (
<div className='flex flex-wrap gap-1'>
{game.avg_fps >= 60 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold leading-tight'>
RAW PERFORMER
</span>
) : game.avg_fps < 30 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold leading-tight'>
POOR PERFORMANCE
</span>
) : null}
<span className='inline-flex items-center gap-0.5 text-[9px] text-text/50 font-medium'>
{Math.round(game.avg_fps)}fps
</span>
</div>
)}
<p
className={`text-[10px] ${accentColor} font-medium`}
>
{formatStat(
(
game as unknown as Record<
string,
unknown
>
)[statKey],
)}
</p>
</div>
</motion.div>
))}
</div>
</div>
</motion.section>
)
}
function SaleSection({
games,
}: {
games: SaleGameCard[]
}) {
const router = useRouter()
return (
<motion.section
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-50px" }}
transition={{ duration: 0.4 }}
>
<div className='flex items-center gap-2 mb-4'>
<div className='border-l-2 border-green-500 pl-3'>
<div className='flex items-center gap-2'>
<span className="text-green-400 text-sm">💰</span>
<h2 className='text-sm font-semibold text-text/80'>
On Sale & Performing Well
</h2>
</div>
</div>
</div>
<div className='overflow-x-auto overflow-y-hidden -mx-1 px-1' style={{ pointerEvents: 'none' }}>
<div className='flex gap-3 pb-2 px-1' style={{ pointerEvents: 'auto' }}>
{games.map((game, idx) => {
const discountPct = game.price_initial && game.price_current
? Math.round((1 - game.price_current / game.price_initial) * 100)
: 0
return (
<motion.div
key={game.id}
initial={{ opacity: 0, y: 10 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ duration: 0.3, delay: idx * 0.05 }}
className='group shrink-0 w-36 sm:w-44 rounded-xl bg-text/3 border border-border hover:border-text/30 hover:bg-text/6 transition-colors cursor-pointer overflow-hidden'
onClick={() => router.push(`/game/${game.id}?sync=1`)}
>
<div className='relative aspect-2/3 bg-text/10 overflow-hidden'>
{game.capsule_image ? (
<Image
src={game.capsule_image}
alt={game.title}
fill
className='object-cover rounded-xl group-hover:scale-[0.97] transition-transform duration-300'
sizes='(max-width: 640px) 144px, 176px'
/>
) : (
<div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className='h-8 w-8 text-text/15' />
</div>
)}
{/* Discount badge */}
{discountPct > 0 && (
<div className='absolute top-2 right-2 px-1.5 py-0.5 rounded bg-green-500 text-white text-[10px] font-bold'>
-{discountPct}%
</div>
)}
{game.playability_status &&
game.playability_status !== "unknown" && (
<div className='absolute bottom-1.5 left-1.5 right-1.5'>
<PlayabilityBadge
status={game.playability_status as "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null}
compact
showLabel
className='text-[10px] px-1.5 py-0.5 w-full justify-center'
/>
</div>
)}
</div>
<div className='p-2.5 space-y-1.5'>
<h3 className='text-xs font-semibold text-text line-clamp-2 leading-tight group-hover:text-primary transition-colors'>
{game.title}
</h3>
{/* Pricing */}
<div className='flex items-center gap-1.5'>
{game.price_current !== undefined && (
<span className='text-xs font-bold text-green-400'>
{game.price_currency === "USD" ? "$" : ""}{(game.price_current / 100).toFixed(2)}
</span>
)}
{game.price_initial !== undefined && game.price_initial > (game.price_current ?? 0) && (
<span className='text-[10px] text-text/30 line-through'>
{(game.price_initial / 100).toFixed(2)}
</span>
)}
</div>
{/* Performance badges */}
{game.best_fps !== undefined && game.best_fps !== null && (
<div className='flex flex-wrap gap-1'>
{game.best_fps >= 60 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'>
RAW PERFORMER
</span>
) : game.best_fps < 30 ? (
<span className='inline-flex items-center gap-1 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'>
POOR PERFORMANCE
</span>
) : null}
<span className='inline-flex items-center gap-0.5 text-[9px] text-text/50 font-medium'>
{Math.round(game.best_fps)}fps
</span>
</div>
)}
{game.steam_review_score !== undefined && game.steam_review_score !== null && (
<p className='text-[10px] text-blue-400 font-medium'>
{game.steam_review_score}% positive
</p>
)}
</div>
</motion.div>
)
})}
</div>
</div>
{/* Disclaimer */}
<p className='text-[10px] text-text/20 mt-1 text-right'>
Prices may vary. Data refreshes weekly.
</p>
</motion.section>
)
}
export default function Landing() {
const router = useRouter()
const words = ["benchmarks", "settings", "reviews"]
const [currentWord, setCurrentWord] = useState(0)
const [searchQuery, setSearchQuery] = useState("")
// Landing section state
const [sections, setSections] = useState<SectionData>({
recentBenchmarks: [],
trending: [],
mostTested: [],
onSale: [],
})
const [sectionsLoading, setSectionsLoading] = useState(true)
// Animated words cycle — use useEffect with proper cleanup
useEffect(() => {
const interval = setInterval(() => {
setCurrentWord((prev) => (prev + 1) % words.length)
}, 2000)
return () => clearInterval(interval)
}, [words.length])
// Fetch all 4 sections in parallel on mount
useEffect(() => {
let cancelled = false
async function fetchSections() {
try {
const [recentBenchmarks, trending, mostTested, onSale] =
await Promise.all([
fetch("/api/dashboard/recent-benchmarks").then((r) =>
r.ok ? r.json() : [],
),
fetch("/api/dashboard/trending").then((r) =>
r.ok ? r.json() : [],
),
fetch("/api/dashboard/most-tested").then((r) =>
r.ok ? r.json() : [],
),
fetch("/api/dashboard/on-sale").then((r) =>
r.ok ? r.json() : [],
),
])
if (!cancelled) {
setSections({
recentBenchmarks: Array.isArray(recentBenchmarks) ? recentBenchmarks : [],
trending: Array.isArray(trending) ? trending : [],
mostTested: Array.isArray(mostTested) ? mostTested : [],
onSale: Array.isArray(onSale) ? onSale : [],
})
}
} catch {
// Silently fail — sections are best-effort
} finally {
if (!cancelled) setSectionsLoading(false)
}
}
fetchSections()
return () => {
cancelled = true
}
}, [])
const handleSearchSubmit = () => {
if (searchQuery.trim()) {
sessionStorage.setItem("focusSearch", "true")
router.push(`/search?q=${encodeURIComponent(searchQuery.trim())}`)
}
}
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
handleSearchSubmit()
}
}
return (
<>
{/* ── Hero Section ── */}
<section
id='hero'
className='w-full min-h-[70svh] mt-[10svh] flex flex-col items-center justify-center relative p-4'
>
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='font-bold text-3xl md:text-5xl text-center flex flex-row gap-2 items-center justify-center'
>
DeckyVault
<span className='border border-border text-xs md:text-base px-2 py-1 rounded-md bg-primary/10 font-medium'>
beta
</span>
</motion.h1>
<motion.h2
layout
initial={{
opacity: 0,
}}
animate={{ opacity: 0.8, transition: { delay: 0.5 } }}
className='mt-4 flex flex-row flex-wrap items-center justify-center gap-x-1 md:gap-x-2 text-base md:text-xl'
>
{"Find your game".split(" ").map((word, index) => (
<motion.span
key={index}
className='text-center'
>
{word}
</motion.span>
))}
<AnimatePresence
mode='wait'
initial={false}
>
<motion.span
key={currentWord}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className='text-primary font-bold'
>
{words[currentWord]}
</motion.span>
</AnimatePresence>
</motion.h2>
<AnimatePresence>
<motion.label
key='search-bar'
layoutId='search-bar'
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className='mt-8 flex flex-row items-center gap-4 bg-text/5 px-2 py-2 rounded-md border border-border placeholder:text-text/60 group hover:border-border-active transition-colors focus-within:border-primary/80! focus-within:ring-2 focus-within:ring-primary/50! focus-within:ring-offset-2 focus-within:ring-offset-background cursor-text w-full max-w-md'
>
<Gamepad2Icon className='h-6 w-6 group-focus-within:stroke-accent transition-colors shrink-0' />
<input
type='text'
placeholder='search by game or appid...'
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={handleKeyDown}
className='flex-1 outline-none bg-transparent text-xl min-w-0'
/>
<motion.button
onClick={handleSearchSubmit}
whileTap={{ scale: 0.95 }}
className='text-sm flex flex-row gap-1 items-center bg-text text-background px-2 py-1 rounded-sm cursor-pointer hover:opacity-60 transition-opacity shrink-0'
>
<SearchIcon className='h-3 w-3' />
search
</motion.button>
</motion.label>
</AnimatePresence>
<motion.small
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 1.5 } }}
className='mt-8 text-center text-xs flex flex-row gap-1'
>
<Link
title='Visit our Github Repository'
href='/updates'
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
>
See what{"'"}s new.
</Link>
</motion.small>
<motion.small
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 1.5 } }}
className='mt-2 text-center text-xs flex flex-row gap-1'
>
<span className='opacity-60'>2026 DeckyVault.</span>
<Link
title='Visit our Github Repository'
href='https://github.com/AdrianBonpin/deckyvault'
className='text-accent opacity-60 hover:opacity-100 transition-opacity cursor-pointer'
>
Github.
</Link>
<span className='opacity-60'>
v{process.env.NEXT_PUBLIC_APP_VERSION}
</span>
</motion.small>
</section>
{/* ── Landing Sections ── */}
<div className='w-full max-w-7xl mx-auto px-4 pb-12 space-y-10'>
{sectionsLoading ? (
<SkeletonSections />
) : (
<>
{sections.recentBenchmarks.length > 0 && (
<GameSection
title='Recently Added Benchmarks'
icon={ClockIcon}
games={sections.recentBenchmarks}
statKey='benchmark_count'
statLabel='benchmarks'
accentColor='text-violet-400'
/>
)}
{sections.trending.length > 0 && (
<GameSection
title='Trending This Week'
icon={TrendingUpIcon}
games={sections.trending}
statKey='benchmark_count'
statLabel='benchmarks this week'
accentColor='text-orange-400'
/>
)}
{sections.mostTested.length > 0 && (
<GameSection
title='Most Tested Games'
icon={GaugeIcon}
games={sections.mostTested}
statKey='benchmark_count'
statLabel='benchmarks'
accentColor='text-blue-400'
/>
)}
{sections.onSale.length > 0 && (
<SaleSection games={sections.onSale} />
)}
</>
)}
</div>
<script
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "WebSite",
name: "DeckyVault",
url: "https://deckyvault.xyz",
description:
"Steam Deck benchmarks, settings, and performance guides",
potentialAction: {
"@type": "SearchAction",
target: "https://deckyvault.xyz/search?q={search_term_string}",
"query-input": "required name=search_term_string",
},
}),
}}
/>
{/* Organization structured data */}
<script
type='application/ld+json'
dangerouslySetInnerHTML={{
__html: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
name: "DeckyVault",
url: "https://deckyvault.xyz",
logo: "https://deckyvault.xyz/icon.png",
sameAs: [
"https://github.com/AdrianBonpin/deckyvault",
],
}),
}}
/>
</>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { notFound } from "next/navigation"
import { db } from "@/lib/db/index"
import { user, performanceEntries, games, gameVersions, hardware } from "@/lib/db/schema"
import { eq, sql, and, desc } from "drizzle-orm"
import { ProfilePageClient } from "./profile-page-client"
// This page needs live data — skip static generation at build time
export const dynamic = "force-dynamic"
export const metadata = {
title: "Profile",
}
export default async function ProfilePage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
const [profile] = await db
.select({
id: user.id,
name: user.name,
image: user.image,
role: user.role,
createdAt: user.createdAt,
emailVerified: user.emailVerified,
})
.from(user)
.where(eq(user.id, id))
.limit(1)
if (!profile) {
notFound()
}
// Count contributions
const [{ count: contributions }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false)
))
// Count verified entries
const [{ count: verifiedEntries }] = await db
.select({ count: sql<number>`count(*)::int` })
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.verifiedAt} IS NOT NULL`
))
// Calculate reputation (upvotes - downvotes)
const reputationResult = await db
.select({
totalUpvotes: sql<number>`coalesce(sum(${performanceEntries.upvotes}), 0)::int`,
totalDownvotes: sql<number>`coalesce(sum(${performanceEntries.downvotes}), 0)::int`,
})
.from(performanceEntries)
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false),
))
// Fetch recent contributions (last 5)
const recentContributions = await db
.select({
id: performanceEntries.id,
fpsAvg: performanceEntries.fpsAvg,
fpsLow: performanceEntries.fpsLow,
fpsHigh: performanceEntries.fpsHigh,
hardwareSlug: performanceEntries.hardwareSlug,
hardwareName: hardware.name,
upscalerType: performanceEntries.upscalerType,
upscalerVersion: performanceEntries.upscalerVersion,
frameGenMethod: performanceEntries.frameGenMethod,
verifiedAt: performanceEntries.verifiedAt,
createdAt: performanceEntries.createdAt,
gameTitle: games.title,
gameId: games.id,
gameHeaderImage: games.headerImage,
})
.from(performanceEntries)
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.innerJoin(games, eq(gameVersions.gameId, games.id))
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.where(and(
eq(performanceEntries.userId, id),
eq(performanceEntries.isRemoved, false)
))
.orderBy(desc(performanceEntries.createdAt))
.limit(5)
return (
<ProfilePageClient
profile={{
...profile,
createdAt: profile.createdAt.toISOString(),
contributions,
verifiedEntries,
reputation: Math.max(0,
(reputationResult[0]?.totalUpvotes ?? 0) -
(reputationResult[0]?.totalDownvotes ?? 0)
),
verified: !!profile.emailVerified,
}}
recentContributions={recentContributions.map((e) => ({
...e,
createdAt: e.createdAt.toISOString(),
verifiedAt: e.verifiedAt?.toISOString() ?? null,
}))}
/>
)
}
@@ -0,0 +1,60 @@
"use client"
import { ProfileHeader } from "@/components/profile/profile-header"
import { StatsRow } from "@/components/profile/stats-row"
import { ContributionList } from "@/components/profile/contribution-list"
import type { ContributionEntry } from "@/types/api"
interface ProfilePageClientProps {
profile: {
id: string
name: string
image: string | null
role: string | null
createdAt: string
contributions: number
verifiedEntries: number
reputation: number
verified: boolean
}
recentContributions: ContributionEntry[]
}
export function ProfilePageClient({ profile, recentContributions }: ProfilePageClientProps) {
return (
<div className="w-full flex flex-col gap-8 pb-16">
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<ProfileHeader
name={profile.name}
role={profile.role}
verified={profile.verified}
createdAt={profile.createdAt}
image={profile.image}
/>
</div>
</div>
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<StatsRow
contributions={profile.contributions}
verifiedEntries={profile.verifiedEntries}
reputation={profile.reputation}
/>
</div>
</div>
<div className="px-4 md:px-[10svw]">
<div className="max-w-7xl mx-auto">
<h2 className="text-lg font-semibold mb-4">Recent Contributions</h2>
<ContributionList
entries={recentContributions}
showViewAll
totalCount={profile.contributions}
/>
</div>
</div>
</div>
)
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Profile",
description:
"Your DeckyVault profile — saved games, benchmark contributions, and performance history.",
robots: { index: false, follow: false },
}
export default function ProfileLayout({ children }: { children: React.ReactNode }) {
return children
}
+193
View File
@@ -0,0 +1,193 @@
"use client"
import { useEffect, useState } from "react"
import { useRouter } from "next/navigation"
import { useSession } from "@/lib/auth-client"
import { ProfileHeader } from "@/components/profile/profile-header"
import { StatsRow } from "@/components/profile/stats-row"
import { ContributionList } from "@/components/profile/contribution-list"
import { SavedGamesGrid } from "@/components/saved-games/saved-games-grid"
import { Bookmark, Settings, Loader2, TrendingUp } from "lucide-react"
import { SettingsContainer } from "@/components/profile/settings-container"
import { motion } from "motion/react"
import type { ContributionEntry } from "@/types/api"
type Tab = "overview" | "saved" | "settings"
export default function ProfilePage() {
const router = useRouter()
const { data: session, isPending: isSessionLoading } = useSession()
const [activeTab, setActiveTab] = useState<Tab>("overview")
const [profile, setProfile] = useState<{
id: string
name: string
email: string
image: string | null
role: string | null
createdAt: string
contributions: number
verifiedEntries: number
reputation: number
verified: boolean
} | null>(null)
const [contributions, setContributions] = useState<ContributionEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (!isSessionLoading && !session) {
router.push(`/login?redirect=${encodeURIComponent(window.location.pathname)}`)
}
}, [session, isSessionLoading, router])
useEffect(() => {
if (!session) return
async function fetchProfile() {
try {
const [profileRes, contribRes] = await Promise.all([
fetch("/api/user/me"),
fetch(
"/api/user/profile/" +
session!.user.id +
"/contributions?limit=10",
),
])
if (profileRes.ok) {
setProfile(await profileRes.json())
}
if (contribRes.ok) {
const data = await contribRes.json()
setContributions(data.data)
}
} catch (err) {
console.error("Failed to fetch profile:", err)
} finally {
setIsLoading(false)
}
}
fetchProfile()
}, [session])
if (isSessionLoading || isLoading) {
return (
<div className='flex items-center justify-center min-h-[50vh]'>
<Loader2 className='h-8 w-8 animate-spin text-primary' />
</div>
)
}
if (!session || !profile) {
return null
}
const handleImageChange = (url: string | null) => {
setProfile((prev) => (prev ? { ...prev, image: url } : null))
}
const tabs: { id: Tab; label: string; icon: typeof Bookmark }[] = [
{ id: "overview", label: "Overview", icon: TrendingUp },
{ id: "saved", label: "Saved Games", icon: Bookmark },
{ id: "settings", label: "Settings", icon: Settings },
]
return (
<div className='w-full flex flex-col gap-8 pt-4'>
{/* Profile header section */}
<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'>
<ProfileHeader
name={profile.name}
email={profile.email}
role={profile.role}
verified={profile.verified}
createdAt={profile.createdAt}
image={profile.image}
/>
</div>
</motion.div>
{/* Stats section */}
<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'>
<StatsRow
contributions={profile.contributions}
verifiedEntries={profile.verifiedEntries}
reputation={profile.reputation}
/>
</div>
</motion.div>
{/* Tabs + content section */}
<motion.div
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.15 }}
className='px-4 md:px-[10svw]'
>
<div className='max-w-7xl mx-auto'>
{/* Tabs */}
<div className='flex gap-1 border-b border-border'>
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`flex items-center gap-2 px-4 py-2 text-sm font-medium transition-colors border-b-2 -mb-px cursor-pointer ${
activeTab === tab.id
? "border-primary text-primary"
: "border-transparent text-text/50 hover:text-text/70"
}`}
>
<tab.icon className='h-4 w-4' />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className='pt-6'
>
{activeTab === "overview" && (
<div>
<h2 className='text-lg font-semibold mb-4'>
Recent Contributions
</h2>
<ContributionList entries={contributions} />
</div>
)}
{activeTab === "saved" && <SavedGamesGrid />}
{activeTab === "settings" && profile && (
<SettingsContainer
name={profile.name}
email={profile.email}
role={profile.role}
createdAt={profile.createdAt}
image={profile.image}
userId={profile.id}
onImageChange={handleImageChange}
/>
)}
</motion.div>
</div>
</motion.div>
</div>
)
}
+14
View File
@@ -0,0 +1,14 @@
import type { MetadataRoute } from "next"
export default function robots(): MetadataRoute.Robots {
return {
rules: [
{
userAgent: "*",
allow: "/",
disallow: ["/manage", "/api"],
},
],
sitemap: "https://deckyvault.xyz/sitemap.xml",
}
}
+12
View File
@@ -0,0 +1,12 @@
import type { Metadata } from "next"
export const metadata: Metadata = {
title: "Search Games",
description: "Search for games and find benchmarks, settings, and performance data on DeckyVault.",
alternates: { canonical: "https://deckyvault.xyz/search" },
robots: { index: false },
}
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return children
}
+969
View File
@@ -0,0 +1,969 @@
"use client"
import { Suspense, useState, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import { motion, AnimatePresence } from "motion/react"
import {
ExternalLinkIcon,
Gamepad2Icon,
MessageSquareIcon,
SettingsIcon,
TrendingUpIcon,
DatabaseIcon,
SparklesIcon,
SlidersHorizontalIcon,
XIcon,
} from "lucide-react"
import { FaSteam } from "react-icons/fa"
import Image from "next/image"
import Link from "next/link"
import { useSession } from "@/lib/auth-client"
import { WindowsIcon, MacIcon, LinuxIcon } from "@/app/components/PlatformIcons"
import { AntiCheatBadge } from "@/components/anti-cheat-badge"
import { PlayabilityBadge } from "@/components/playability-badge"
import { SavedFilters } from "@/components/saved-filters"
interface UnifiedResult {
kind: "local" | "steam"
id?: string
appId: number | null
title: string
image: string | null
developer: string | null
publisher: string | null
description: string | null
genres: string[] | null
source: string
counts: { benchmarks: number; presets: number; comments: number } | null
platformSupport: {
isSupported: boolean
protonStatus: string
antiCheatRelevant: boolean
antiCheatName: string | null
antiCheatStatus: string
} | null
metascore?: string | null
price?: { currency: string; initial: number; final: number } | null
platforms?: { windows: boolean; mac: boolean; linux: boolean } | null
controllerSupport?: string | null
isRawPerformer?: boolean
isPoorPerformance?: boolean
bestFps?: number | null
estimatedBatteryMin?: number | null
latestVersion?: string | null
tinyImage?: string | null
// Badges & review fields
playabilityStatus?: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null
steamReviewScore?: number | null
steamReviewSentiment?: string | null
antiCheatRelevant?: boolean
antiCheatStatus?: string | null
antiCheatName?: string | null
}
const DEVICE_OPTIONS = [
{ slug: "steam-deck-oled", name: "Steam Deck OLED" },
{ slug: "steam-deck-lcd", name: 'Steam Deck LCD' },
{ slug: "rog-ally", name: "ROG Ally" },
]
function SearchContent() {
const searchParams = useSearchParams()
const router = useRouter()
const { data: session } = useSession()
const query = searchParams.get("q") || ""
const [results, setResults] = useState<UnifiedResult[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const isValidQuery = query && query.length >= 2
// Filter state
const [showFilters, setShowFilters] = useState(false)
const [selectedDevice, setSelectedDevice] = useState("")
const [minFps, setMinFps] = useState("")
const [maxFps, setMaxFps] = useState("")
const [fsrSupport, setFsrSupport] = useState(false)
const [protonNative, setProtonNative] = useState("any")
const [antiCheatStatus, setAntiCheatStatus] = useState("any")
const [playabilityStatus, setPlayabilityStatus] = useState("")
const [steamReviewMin, setSteamReviewMin] = useState("")
const [isFree, setIsFree] = useState(false)
const [hasMultiplayer, setHasMultiplayer] = useState(false)
const hasActiveFilters =
selectedDevice ||
minFps ||
maxFps ||
fsrSupport ||
protonNative !== "any" ||
antiCheatStatus !== "any" ||
playabilityStatus ||
steamReviewMin ||
isFree ||
hasMultiplayer
const activeFilterCount =
(selectedDevice ? 1 : 0) +
(minFps ? 1 : 0) +
(maxFps ? 1 : 0) +
(fsrSupport ? 1 : 0) +
(protonNative !== "any" ? 1 : 0) +
(antiCheatStatus !== "any" ? 1 : 0) +
(playabilityStatus ? 1 : 0) +
(steamReviewMin ? 1 : 0) +
(isFree ? 1 : 0) +
(hasMultiplayer ? 1 : 0)
// Handle direct navigation / browser back-forward
useEffect(() => {
if (!isValidQuery) return
let cancelled = false
async function fetchResults() {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
params.set("q", query)
if (selectedDevice) params.set("device", selectedDevice)
if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus) params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true")
const res = await fetch(`/api/search/unified?${params.toString()}`)
if (!res.ok) throw new Error(await res.text())
const data = await res.json()
if (!cancelled) setResults(data.results || [])
} catch (err) {
if (!cancelled) {
setError("Failed to fetch search results")
console.error(err)
}
} finally {
if (!cancelled) setLoading(false)
}
}
fetchResults()
return () => {
cancelled = true
}
}, [isValidQuery, query, selectedDevice, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer])
function handleClick(result: UnifiedResult) {
const path = result.appId
? `/game/${result.appId}?sync=1`
: `/game/${result.id}?sync=1`
router.push(path)
}
function clearAllFilters() {
setSelectedDevice("")
setMinFps("")
setMaxFps("")
setFsrSupport(false)
setProtonNative("any")
setAntiCheatStatus("any")
setPlayabilityStatus("")
setSteamReviewMin("")
setIsFree(false)
setHasMultiplayer(false)
}
return (
<section className="w-full min-h-[calc(100vh-3.6rem)] flex flex-col items-center p-4 md:px-[10svw]">
<div className="w-full max-w-7xl">
<motion.h1
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-2xl font-light mb-2"
>
{query ? `Results for "${query}"` : "Search using game name or AppID"}
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1, transition: { delay: 0.1 } }}
className="text-text/60 text-sm mb-4"
>
{query
? `${results.length} result${results.length !== 1 ? "s" : ""} found`
: "Enter a game name or AppID to find benchmarks, settings, and reviews."}
</motion.p>
{/* Filter toggle button */}
<div className="flex items-center gap-3 mb-4">
<button
onClick={() => setShowFilters(!showFilters)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-[44px] ${
showFilters || hasActiveFilters
? "bg-primary/10 text-primary border-primary/30"
: "bg-text/5 text-text/60 hover:text-text/80 border-border hover:border-border-active"
}`}
>
<SlidersHorizontalIcon className="h-4 w-4" />
Filters
{activeFilterCount > 0 && (
<span className="ml-0.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold">
{activeFilterCount}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearAllFilters}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-[44px] flex items-center gap-1"
>
<XIcon className="h-3 w-3" />
Clear all filters
</button>
)}
</div>
{/* Filter panel */}
<AnimatePresence>
{showFilters && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
className="overflow-hidden mb-4"
>
<div className="flex flex-col gap-4 p-4 rounded-xl border border-border bg-text/[0.03]">
{/* Device filter */}
<div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block">
Device
</span>
<div className="flex flex-wrap gap-1.5">
<button
onClick={() => setSelectedDevice("")}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
All Devices
</button>
{DEVICE_OPTIONS.map((device) => (
<button
key={device.slug}
onClick={() =>
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug)
}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${
selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
}`}
>
{device.name}
</button>
))}
</div>
</div>
{/* Performance Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Performance</h4>
<div className="flex gap-2">
<input
type="number"
placeholder="Min FPS"
value={minFps}
onChange={(e) => setMinFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
<input
type="number"
placeholder="Max FPS"
value={maxFps}
onChange={(e) => setMaxFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
/>
</div>
<select
value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="">Any Playability</option>
<option value="great">Plays Great</option>
<option value="playable">Playable</option>
<option value="needs_tweaks">Needs Tweaks</option>
<option value="unplayable">Unplayable</option>
</select>
</div>
{/* Compatibility Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Compatibility</h4>
<select
value={protonNative}
onChange={(e) => setProtonNative(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Runtime</option>
<option value="native">Native</option>
<option value="proton">Proton</option>
</select>
<select
value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]"
>
<option value="any">Any Anti-Cheat</option>
<option value="supported">AC Supported</option>
<option value="unsupported">AC Unsupported</option>
<option value="unknown">AC Unknown</option>
</select>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={fsrSupport}
onChange={(e) => setFsrSupport(e.target.checked)}
className="rounded border-zinc-600"
/>
FSR Support
</label>
</div>
{/* Other Filters */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-zinc-300">Other</h4>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={isFree}
onChange={(e) => setIsFree(e.target.checked)}
className="rounded border-zinc-600"
/>
Free to Play
</label>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]">
<input
type="checkbox"
checked={hasMultiplayer}
onChange={(e) => setHasMultiplayer(e.target.checked)}
className="rounded border-zinc-600"
/>
Has Multiplayer
</label>
<input
type="number"
placeholder="Min Steam Review %"
value={steamReviewMin}
onChange={(e) => setSteamReviewMin(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm"
/>
</div>
{/* Saved Filters */}
<SavedFilters
currentFilters={{
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
device: selectedDevice,
}}
onLoad={(filters) => {
setMinFps((filters.minFps as string) || "")
setMaxFps((filters.maxFps as string) || "")
setFsrSupport((filters.fsrSupport as boolean) || false)
setProtonNative((filters.protonNative as string) || "any")
setAntiCheatStatus((filters.antiCheatStatus as string) || "any")
setPlayabilityStatus((filters.playabilityStatus as string) || "")
setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer((filters.hasMultiplayer as boolean) || false)
if (filters.device) setSelectedDevice(filters.device as string)
else setSelectedDevice("")
}}
/>
</div>
</motion.div>
)}
</AnimatePresence>
{!isValidQuery && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-20 gap-4"
>
<Gamepad2Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">
Start typing to search for games
</p>
</motion.div>
)}
{isValidQuery && loading && (
<div className="flex flex-col items-center justify-center py-20 gap-4">
<motion.div
animate={{ rotate: 360 }}
transition={{ repeat: Infinity, duration: 1, ease: "linear" }}
className="h-8 w-8 border-2 border-primary border-t-transparent rounded-full"
/>
<p className="text-text/60 text-sm">
Searching for &quot;{query}&quot;...
</p>
</div>
)}
{isValidQuery && !loading && error && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="flex flex-col items-center justify-center py-20 gap-4"
>
<p className="text-red-400 text-sm">{error}</p>
</motion.div>
)}
{isValidQuery && !loading && !error && results.length === 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex flex-col items-center justify-center py-20 gap-4"
>
<Gamepad2Icon className="h-12 w-12 text-text/20" />
<p className="text-text/40 text-sm">
No results found for &quot;{query}&quot;
</p>
</motion.div>
)}
{isValidQuery && !loading && !error && results.length > 0 && (
<motion.div
className="flex flex-col gap-3"
initial="hidden"
animate="visible"
variants={{
hidden: {},
visible: { transition: { staggerChildren: 0.04 } },
}}
>
<AnimatePresence mode="popLayout">
{results.map((result, idx) => (
<SearchResultCard
key={
result.kind === "local"
? result.id
: `steam-${result.appId}-${idx}`
}
result={result}
onClick={handleClick}
index={idx}
/>
))}
</AnimatePresence>
</motion.div>
)}
{session?.user && (
<div className="mt-6 text-center">
<p className="text-sm text-text/50">
Can&apos;t find your game?{" "}
<Link href="/game/add" className="text-primary hover:underline cursor-pointer">
Add it manually
</Link>
</p>
</div>
)}
</div>
</section>
)
}
function SearchResultCard({
result,
onClick,
index,
}: {
result: UnifiedResult
onClick: (r: UnifiedResult) => void
index: number
}) {
const isLocal = result.kind === "local"
const counts = result.counts
const hasData =
isLocal &&
counts &&
(counts.benchmarks > 0 || counts.presets > 0 || counts.comments > 0)
return (
<motion.article
layout
variants={{
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}}
transition={{ duration: 0.3, delay: index * 0.02 }}
whileHover={{ scale: 1.005 }}
whileTap={{ scale: 0.995 }}
className="group relative bg-text/3 border border-border rounded-xl p-4 sm:p-5 transition-colors duration-200 hover:border-text/30 hover:bg-text/[0.06] cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-text/20 focus-within:ring-offset-2 focus-within:ring-offset-background"
title={isLocal
? "Click to view game details, benchmarks, and settings"
: "Click to add this game to DeckyVault and view its page"
}
>
{/* Full-card click target */}
<div
className="absolute inset-0 after:content-[''] after:absolute after:inset-0"
onClick={() => onClick(result)}
/>
<div className="flex gap-4 sm:gap-5">
{/* Cover */}
<motion.div
className="relative w-20 sm:w-24 md:w-28 shrink-0 aspect-[2/3] rounded-lg overflow-hidden bg-text/10 shadow-sm"
whileHover={{ scale: 1.03 }}
transition={{ duration: 0.2 }}
>
<GameCover image={result.image} tinyImage={result.tinyImage} title={result.title} />
</motion.div>
{/* Main Content */}
<div className="flex flex-col min-w-0 flex-1">
{/* Row 1: Title + metascore/price row */}
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="min-w-0 flex items-center gap-2 flex-wrap">
<h3 className="text-sm sm:text-base font-semibold text-text group-hover:text-primary transition-colors duration-200 truncate">
{result.title}
</h3>
{!isLocal && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-text/5 border border-border text-text/35 capitalize shrink-0">
Steam
</span>
)}
{isLocal && result.source !== "steam" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 border border-amber-500/20 text-amber-400 capitalize shrink-0">
{result.source}
</span>
)}
{result.isRawPerformer && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[10px] font-semibold shrink-0">
RAW PERFORMER
</span>
)}
{result.isPoorPerformance && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[10px] font-semibold shrink-0">
POOR PERFORMANCE
</span>
)}
{result.estimatedBatteryMin != null && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[10px] font-semibold shrink-0">
🔋 ~{Math.round(result.estimatedBatteryMin / 60)}h
</span>
)}
</div>
{(result.developer || result.publisher) && (
<p className="text-[11px] text-text/45 mt-0.5 truncate">
{result.developer}
{result.developer && result.publisher ? " · " : ""}
{result.publisher}
</p>
)}
</div>
{/* Metascore + Price row */}
<div className="hidden sm:flex items-center gap-2 shrink-0">
{result.metascore ? (
<span
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md bg-green-500/10 border border-green-500/20 text-green-400 text-[11px] font-semibold"
title={`Metascore: ${result.metascore}/100`}
>
<TrendingUpIcon className="h-3 w-3" />
{result.metascore}
</span>
) : null}
<PriceTag price={result.price} />
</div>
</div>
{/* Row 2: Description */}
{result.description && (
<p className="text-[11px] text-text/40 line-clamp-2 leading-relaxed mt-1.5">
{result.description}
</p>
)}
{/* Row 3: Genre tags + Platform icons + Controller */}
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5 mt-2">
{result.genres && result.genres.length > 0 && (
<div className="flex flex-wrap gap-1">
{result.genres.slice(0, 3).map((genre) => (
<span
key={genre}
className="text-[10px] px-1.5 py-0.5 rounded bg-text/5 border border-border text-text/35"
>
{genre}
</span>
))}
{result.genres.length > 3 && (
<span className="text-[10px] text-text/25 self-center">
+{result.genres.length - 3}
</span>
)}
</div>
)}
{/* Platform icons — always show all 3, color if present */}
<div className="flex items-center gap-1.5">
<span title="Windows">
<WindowsIcon
className={`h-3.5 w-3.5 ${result.platforms?.windows ? "text-blue-400" : "text-text/20"}`}
/>
</span>
<span title="macOS">
<MacIcon
className={`h-3.5 w-3.5 ${result.platforms?.mac ? "text-text/60" : "text-text/20"}`}
/>
</span>
<span title="Linux">
<LinuxIcon
className={`h-3.5 w-3.5 ${result.platforms?.linux ? "text-yellow-500" : "text-text/20"}`}
/>
</span>
</div>
{result.controllerSupport && (
<span
className="text-[10px] text-text/25 flex items-center gap-1"
title="Full controller support"
>
<Gamepad2Icon className="h-3 w-3" />
Controller
</span>
)}
</div>
{/* Row 4: Stats row */}
<div className="flex flex-wrap items-center gap-3 mt-2.5">
{hasData ? (
<>
{counts!.benchmarks > 0 && (
<StatBadge
icon={TrendingUpIcon}
count={counts!.benchmarks}
label="Benchmarks"
/>
)}
{counts!.presets > 0 && (
<StatBadge
icon={SettingsIcon}
count={counts!.presets}
label="Presets"
/>
)}
{counts!.comments > 0 && (
<StatBadge
icon={MessageSquareIcon}
count={counts!.comments}
label="Comments"
/>
)}
</>
) : isLocal ? (
<span className="text-[11px] text-text/30 italic">
No data yet be the first to contribute
</span>
) : (
<div className="flex flex-col items-start gap-1.5 w-full sm:w-auto">
<a
href={`https://store.steampowered.com/app/${result.appId}`}
target="_blank"
rel="noopener noreferrer"
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-[#1b2838] border border-[#2a475e] text-[#c6d4df] text-[11px] hover:bg-[#2a475e] transition-colors w-full cursor-pointer"
onClick={(e) => e.stopPropagation()}
title="Open store page on Steam"
>
<FaSteam className="h-3 w-3" />
Steam
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
</a>
<a
href={`https://www.protondb.com/app/${result.appId}`}
target="_blank"
rel="noopener noreferrer"
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-purple-500/10 border border-purple-500/20 text-purple-400 text-[11px] hover:bg-purple-500/20 transition-colors w-full cursor-pointer"
onClick={(e) => e.stopPropagation()}
title="View Proton compatibility on ProtonDB"
>
<SparklesIcon className="h-3 w-3" />
ProtonDB
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
</a>
<a
href={`https://steamdb.info/app/${result.appId}`}
target="_blank"
rel="noopener noreferrer"
className="relative z-10 inline-flex items-center justify-center gap-1.5 px-2.5 py-1 rounded-md bg-blue-500/10 border border-blue-500/20 text-blue-400 text-[11px] hover:bg-blue-500/20 transition-colors w-full cursor-pointer"
onClick={(e) => e.stopPropagation()}
title="View app details on SteamDB"
>
<DatabaseIcon className="h-3 w-3" />
SteamDB
<ExternalLinkIcon className="h-2.5 w-2.5 opacity-60" />
</a>
</div>
)}
</div>
{/* Row 5: Anti-cheat info */}
{result.platformSupport?.antiCheatRelevant && (
<div className="mt-2">
<span className="text-[10px] text-text/30">
Anti-cheat: {result.platformSupport.antiCheatName || "Unknown"}
<span className="text-text/20">
{" "} {result.platformSupport.antiCheatStatus}
</span>
</span>
</div>
)}
</div>
{/* Right Panel — Desktop Only */}
<div className="hidden md:flex flex-col items-end justify-center gap-2.5 shrink-0 min-w-[120px]">
{/* Deck Status */}
<DataField
label="Deck"
value={result.platformSupport ? protonLabel(result.platformSupport.protonStatus) : "—"}
color={result.platformSupport ? protonColor(result.platformSupport.protonStatus) : undefined}
/>
{/* Best FPS */}
<DataField
label="Best FPS"
value={result.bestFps != null ? String(Math.round(result.bestFps)) : "—"}
bar={result.bestFps != null}
color={result.bestFps != null && result.bestFps >= 60 ? "text-green-400" : undefined}
/>
{/* Battery Estimate */}
{result.estimatedBatteryMin != null && (
<DataField
label="Battery"
value={`~${Math.round(result.estimatedBatteryMin / 60)}h`}
/>
)}
{/* Version */}
<DataField
label="Version"
value={result.latestVersion ?? "—"}
/>
{/* Playability badge */}
{result.playabilityStatus && (
<PlayabilityBadge status={result.playabilityStatus} compact />
)}
{/* Anti-cheat badge */}
{result.antiCheatRelevant && result.antiCheatStatus === "unsupported" && (
<AntiCheatBadge
antiCheatRelevant={true}
antiCheatStatus={result.antiCheatStatus}
antiCheatName={result.antiCheatName}
compact
/>
)}
{/* Steam review score */}
{result.steamReviewScore != null && (
<span className="inline-flex items-center gap-1 text-xs text-blue-400">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
{result.steamReviewScore}% Positive
</span>
)}
</div>
</div>
</motion.article>
)
}
function DataField({
label,
value,
bar,
color,
}: {
label: string
value: string
bar?: boolean
color?: string
}) {
return (
<div className="flex flex-col items-end gap-1 w-full">
<span className="text-[10px] text-text/30 uppercase tracking-wider">
{label}
</span>
<div className="flex items-center gap-2 w-full justify-end">
{bar && (
<div className="w-14 h-1.5 rounded-full bg-text/5 overflow-hidden">
<div className="h-full w-0 rounded-full bg-primary/40" />
</div>
)}
<span className={`text-xs tabular-nums ${color || "text-text/25"}`}>
{value}
</span>
</div>
</div>
)
}
function protonLabel(status: string): string {
const map: Record<string, string> = {
native: "Native",
proton: "Proton",
unsupported: "Unsupported",
unknown: "Unknown",
}
return map[status] || status
}
function protonColor(status: string): string {
const map: Record<string, string> = {
native: "text-green-400",
proton: "text-blue-400",
unsupported: "text-red-400",
unknown: "text-text/25",
}
return map[status] || "text-text/25"
}
function GameCover({ image, tinyImage, title }: { image: string | null; tinyImage?: string | null; title: string }) {
const [src, setSrc] = useState(image)
const [fallbackStage, setFallbackStage] = useState(0)
const handleError = () => {
if (fallbackStage === 0 && tinyImage) {
setFallbackStage(1)
setSrc(tinyImage)
} else {
setFallbackStage(2)
setSrc(null)
}
}
if (src) {
return (
<Image
src={src}
alt={title}
fill
className="object-cover"
sizes="(max-width: 640px) 80px, 112px"
onError={handleError}
/>
)
}
return (
<div className="w-full h-full flex items-center justify-center">
<Gamepad2Icon className="h-6 w-6 text-text/20" />
</div>
)
}
function PriceTag({
price,
}: {
price?: { currency: string; initial: number; final: number } | null
}) {
if (!price) {
return null
}
// Price is 0 and initial is 0 → legitimately free-to-play
if (price.initial === 0 && price.final === 0) {
return (
<span className="px-2 py-0.5 rounded-md bg-green-500/10 border border-green-500/20 text-green-400 text-[11px] font-medium">
Free
</span>
)
}
// Price is 0 but initial > 0 → promotional free (free weekend etc.)
if (price.final === 0 && price.initial > 0) {
return (
<span
className="px-2 py-0.5 rounded-md bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-[11px] font-medium"
title="Currently free — may be a promotional event"
>
Free*
</span>
)
}
// Normal paid game
const isDiscounted = price.final < price.initial
const fmt = new Intl.NumberFormat("en-US", {
style: "currency",
currency: price.currency,
})
return (
<span className="inline-flex items-center gap-1.5">
{isDiscounted && (
<span className="text-[10px] text-text/30 line-through">
{fmt.format(price.initial / 100)}
</span>
)}
<span
className="px-2 py-0.5 rounded-md bg-text/5 border border-border text-text/60 text-[11px] font-medium"
title={isDiscounted ? "Discounted price" : "Current price"}
>
{fmt.format(price.final / 100)}
</span>
</span>
)
}
function StatBadge({
icon: Icon,
count,
label,
}: {
icon: React.ElementType
count: number
label: string
}) {
return (
<span
className="inline-flex items-center gap-1 text-[11px] text-text/50"
title={`${count} ${label.toLowerCase()}`}
>
<Icon className="h-3 w-3 text-text/30" />
<span className="tabular-nums">{count}</span>
<span className="hidden sm:inline">{label}</span>
</span>
)
}
export default function SearchPage() {
return (
<Suspense>
<SearchContent />
</Suspense>
)
}
+212
View File
@@ -0,0 +1,212 @@
import type { MetadataRoute } from "next"
import { db } from "@/lib/db/index"
import { games, hardware } from "@/lib/db/schema"
import { or, ne, isNull } from "drizzle-orm"
import { getAllUpdates } from "@/lib/updates"
import {
getBaseUrl,
imageEntry,
toDate,
querySafe,
STATIC_PAGES,
} from "@/lib/sitemap-utils"
/** Revalidate sitemap every hour via ISR */
export const revalidate = 3600
/** Maximum entries per individual child sitemap */
const MAX_ENTRIES = 45_000
/** Threshold for game pagination — split into per-page child sitemaps */
const GAMES_PER_SITEMAP = 5_000
// ─── Sitemap Index Generator ────────────────────────────────────────
/**
* Returns the list of child sitemap IDs. Next.js auto-generates
* the sitemap index at /sitemap.xml from this.
*/
export async function generateSitemaps(): Promise<{ id: string }[]> {
const ids: { id: string }[] = [
{ id: "static" },
{ id: "devices" },
{ id: "updates" },
]
// Determine if games need pagination
try {
const countResult = await querySafe("game-count", () =>
db
.select({ count: games.id })
.from(games)
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
)
const count = Number(countResult?.[0]?.count ?? 0)
if (count > GAMES_PER_SITEMAP) {
const pages = Math.ceil(count / GAMES_PER_SITEMAP)
for (let i = 0; i < pages; i++) {
ids.push({ id: `games-${i}` })
}
} else {
ids.push({ id: "games" })
}
} catch {
// Fall back to single unpaginated games sitemap
ids.push({ id: "games" })
}
return ids
}
// ─── Child Sitemap Generator ────────────────────────────────────────
export default async function sitemap(props: {
id: Promise<string>
}): Promise<MetadataRoute.Sitemap> {
const id = await props.id
if (id === "static") {
return generateStaticSitemap()
}
if (id === "games" || id.startsWith("games-")) {
return generateGamesSitemap(id)
}
if (id === "devices") {
return generateDevicesSitemap()
}
if (id === "updates") {
return generateUpdatesSitemap()
}
// Unknown sitemap ID — return empty but valid
console.warn(`[Sitemap] Unknown child sitemap ID: "${id}"`)
return []
}
// ─── Individual Generators ──────────────────────────────────────────
/**
* Static pages sitemap no DB dependency.
* Returns the core browse/utility pages with a fixed lastModified.
*/
async function generateStaticSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
// Use a constant "build date" — updated with each deploy
const buildDate = new Date()
return STATIC_PAGES.map((page) => ({
url: page.urlPath ? `${baseUrl}${page.urlPath}` : baseUrl,
lastModified: buildDate,
changeFrequency: page.changeFrequency,
priority: page.priority,
}))
}
/**
* Games child sitemap DB-backed with ISR caching.
* Supports pagination: 'games' (unpaginated) or 'games-0', 'games-1', etc.
*/
async function generateGamesSitemap(
id: string,
): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
// Parse pagination: 'games-0' → page 0, 'games' (no suffix) → page 0
const pageMatch = id.match(/^games-(\d+)$/)
const page = pageMatch ? parseInt(pageMatch[1], 10) : 0
const offset = page * GAMES_PER_SITEMAP
const rows = await querySafe("games", () =>
db
.select({
id: games.id,
updatedAt: games.updatedAt,
capsuleImage: games.capsuleImage,
})
.from(games)
.where(or(ne(games.syncStatus, "failed"), isNull(games.syncStatus))),
)
if (!rows) return []
const entries: MetadataRoute.Sitemap = []
for (const row of rows) {
if (entries.length >= MAX_ENTRIES) break
entries.push({
url: `${baseUrl}/game/${row.id}`,
lastModified: toDate(row.updatedAt),
changeFrequency: "weekly" as const,
priority: 0.8,
...imageEntry(row.capsuleImage),
})
}
// Apply pagination slice
const sliced = entries.slice(offset, offset + GAMES_PER_SITEMAP)
return sliced
}
/**
* Devices child sitemap DB-backed with ISR caching.
*/
async function generateDevicesSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
const rows = await querySafe("hardware", () =>
db
.select({
slug: hardware.slug,
createdAt: hardware.createdAt,
})
.from(hardware),
)
if (!rows) return []
const entries: MetadataRoute.Sitemap = []
for (const row of rows) {
if (entries.length >= MAX_ENTRIES) break
entries.push({
url: `${baseUrl}/devices/${row.slug}`,
lastModified: toDate(row.createdAt),
changeFrequency: "monthly" as const,
priority: 0.6,
})
}
return entries
}
/**
* Updates child sitemap filesystem-backed with ISR caching.
*/
async function generateUpdatesSitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = getBaseUrl()
let updates: ReturnType<typeof getAllUpdates>
try {
updates = getAllUpdates()
} catch (err) {
console.error("[Sitemap] Failed to load updates:", err)
return []
}
const entries: MetadataRoute.Sitemap = []
for (const update of updates) {
if (entries.length >= MAX_ENTRIES) break
entries.push({
url: `${baseUrl}/updates/${update.slug}`,
lastModified: toDate(update.date),
changeFrequency: "monthly" as const,
priority: 0.5,
})
}
return entries
}
+80
View File
@@ -0,0 +1,80 @@
import type { PrecacheEntry } from "@serwist/precaching";
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from "@serwist/strategies";
import { ExpirationPlugin } from "@serwist/expiration";
import { Serwist } from "serwist";
declare global {
interface Window {
__SW_MANIFEST: (string | PrecacheEntry)[];
}
}
const serwist = new Serwist({
precacheEntries: self.__SW_MANIFEST,
skipWaiting: true,
clientsClaim: true,
runtimeCaching: [
// Game detail pages: stale-while-revalidate, 24h max age
{
matcher: /\/game\/[\w-]+$/,
handler: new StaleWhileRevalidate({
cacheName: "game-pages",
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 24 * 60 * 60 }),
],
}),
},
// Game listing page: network-first (filters matter)
{
matcher: /\/games(\?.*)?$/,
handler: new NetworkFirst({
cacheName: "games-listing",
plugins: [
new ExpirationPlugin({ maxEntries: 20, maxAgeSeconds: 5 * 60 }),
],
}),
},
// Steam CDN images: cache-first, 30 days
{
matcher: /^https:\/\/cdn\.akamai\.steamstatic\.com\//,
handler: new CacheFirst({
cacheName: "steam-images",
plugins: [
new ExpirationPlugin({ maxEntries: 500, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
}),
},
// SteamGridDB images: cache-first, 30 days
{
matcher: /^https:\/\/cdn\d?\.steamgriddb\.com\//,
handler: new CacheFirst({
cacheName: "steamgrid-images",
plugins: [
new ExpirationPlugin({ maxEntries: 200, maxAgeSeconds: 30 * 24 * 60 * 60 }),
],
}),
},
// API responses: network-first (live data critical)
{
matcher: /\/api\//,
handler: new NetworkFirst({
cacheName: "api-responses",
plugins: [
new ExpirationPlugin({ maxEntries: 100, maxAgeSeconds: 5 * 60 }),
],
}),
},
// Navigation fallback for HTML pages (not XML/JSON/etc.)
{
matcher: ({ request }) => request.mode === "navigate" && !request.url.endsWith(".xml"),
handler: new NetworkFirst({
cacheName: "navigation",
plugins: [
new ExpirationPlugin({ maxEntries: 50, maxAgeSeconds: 24 * 60 * 60 }),
],
}),
},
],
});
serwist.addEventListeners();
+215
View File
@@ -0,0 +1,215 @@
"use client"
import { useState, useCallback } from "react"
import { RefreshCwIcon, CheckCircleIcon, XCircleIcon, AlertTriangleIcon } from "lucide-react"
interface StrategyResult {
strategy: string
versionString: string | null
buildId: string | null
success: boolean
error: string | null
}
export default function TestVersionFetchersPage() {
const [appId, setAppId] = useState("")
const [loading, setLoading] = useState(false)
const [results, setResults] = useState<StrategyResult[] | null>(null)
const [best, setBest] = useState<StrategyResult | null>(null)
const [error, setError] = useState<string | null>(null)
const runTest = useCallback(async (id?: string) => {
const targetId = id ?? appId
if (!targetId.trim()) return
setLoading(true)
setError(null)
setResults(null)
setBest(null)
try {
// Use the standalone endpoint — no DB lookup needed
const testRes = await fetch(`/api/version-test?steamAppId=${encodeURIComponent(targetId)}`)
if (!testRes.ok) {
const errData = await testRes.json().catch(() => ({}))
setError(errData.error || `API error: ${testRes.status}`)
setLoading(false)
return
}
const testData = await testRes.json()
if (testData.error) {
setError(testData.error)
setLoading(false)
return
}
setResults(testData.results)
setBest(testData.best)
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error")
} finally {
setLoading(false)
}
}, [appId])
const handleQuickTest = useCallback((id: string) => {
setAppId(id)
runTest(id)
}, [runTest])
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-2">Version Fetcher Strategy Comparison</h1>
<p className="text-sm text-text/60 mb-8">
Enter a <strong>Steam App ID</strong> to test all version-fetching strategies.
The best result (priority: named version &gt; build ID) will be highlighted.
</p>
{/* Input */}
<div className="flex gap-3 mb-8">
<input
type="text"
value={appId}
onChange={(e) => setAppId(e.target.value)}
placeholder="Steam App ID (e.g., 730 for CS2)"
className="flex-1 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
onKeyDown={(e) => e.key === "Enter" && runTest()}
/>
<button
onClick={() => runTest()}
disabled={loading || !appId.trim()}
className="px-6 py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
>
{loading ? (
<RefreshCwIcon className="h-4 w-4 animate-spin" />
) : (
"Test All"
)}
</button>
</div>
{/* Error */}
{error && (
<div className="p-4 rounded-lg bg-red-500/10 border border-red-500/30 mb-8">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{/* Results */}
{results && (
<div className="space-y-4">
{/* Best Result */}
{best ? (
<div className="p-4 rounded-lg bg-green-500/10 border border-green-500/30">
<div className="flex items-center gap-2 mb-2">
<CheckCircleIcon className="h-5 w-5 text-green-400" />
<h3 className="text-sm font-semibold text-green-400">
Best Result: {best.strategy}
</h3>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-text/40">Version String</p>
<p className="text-lg font-mono text-text">
{best.versionString || "—"}
</p>
</div>
<div>
<p className="text-xs text-text/40">Build ID</p>
<p className="text-lg font-mono text-text">
{best.buildId || "—"}
</p>
</div>
</div>
</div>
) : (
<div className="p-4 rounded-lg bg-yellow-500/10 border border-yellow-500/30">
<div className="flex items-center gap-2">
<AlertTriangleIcon className="h-5 w-5 text-yellow-400" />
<p className="text-sm text-yellow-400">
No strategy found version data for this game.
</p>
</div>
</div>
)}
{/* All Strategy Results */}
<h3 className="text-sm font-semibold text-text mt-6 mb-3">
All Strategy Results
</h3>
<div className="space-y-2">
{results.map((r, i) => (
<div
key={i}
className={`p-3 rounded-lg border ${
r.success
? "border-green-500/20 bg-green-500/5"
: "border-red-500/10 bg-red-500/5"
}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{r.success ? (
<CheckCircleIcon className="h-4 w-4 text-green-400" />
) : (
<XCircleIcon className="h-4 w-4 text-red-400" />
)}
<span className="text-sm font-medium text-text">
{r.strategy}
</span>
</div>
{r.error && (
<span className="text-xs text-red-400">{r.error}</span>
)}
</div>
{r.success && (
<div className="grid grid-cols-2 gap-4 ml-6">
<div>
<span className="text-xs text-text/40">Version: </span>
<span className="text-sm font-mono text-text">
{r.versionString || "—"}
</span>
</div>
<div>
<span className="text-xs text-text/40">Build: </span>
<span className="text-sm font-mono text-text">
{r.buildId || "—"}
</span>
</div>
</div>
)}
</div>
))}
</div>
</div>
)}
{/* Quick Test Buttons */}
<div className="mt-8 p-4 rounded-lg border border-border bg-text/2">
<h3 className="text-xs font-semibold text-text/40 mb-3">
Quick Test (known Steam App IDs)
</h3>
<div className="flex flex-wrap gap-2">
{[
{ id: "730", label: "CS2" },
{ id: "440", label: "TF2" },
{ id: "570", label: "Dota 2" },
{ id: "271590", label: "GTA V" },
{ id: "1174180", label: "RDR2" },
{ id: "1086940", label: "BG3" },
{ id: "1245620", label: "Elden Ring" },
{ id: "292030", label: "Witcher 3" },
].map((g) => (
<button
key={g.id}
onClick={() => handleQuickTest(g.id)}
className="px-3 py-1.5 rounded-md border border-border bg-text/5 text-xs text-text/60 hover:text-text hover:border-primary/50 transition-colors"
>
{g.label} ({g.id})
</button>
))}
</div>
</div>
</div>
)
}
+65
View File
@@ -0,0 +1,65 @@
import { ImageResponse } from "next/og"
import { readFile } from "node:fs/promises"
import { join } from "node:path"
export const alt = "DeckyVault - Steam Deck Benchmarks & Settings"
export const size = {
width: 1200,
height: 630,
}
export const contentType = "image/png"
export default async function Image() {
const logoData = await readFile(
join(process.cwd(), "app/icon.png"),
"base64"
)
const logoSrc = `data:image/png;base64,${logoData}`
return new ImageResponse(
(
<div
style={{
width: "100%",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
background: "#100b14",
color: "#ebe4f1",
fontFamily: "sans-serif",
gap: "16px",
}}
>
<img
src={logoSrc}
alt="DeckyVault"
height={120}
style={{ borderRadius: "16px" }}
/>
<div
style={{
fontSize: 64,
fontWeight: 700,
letterSpacing: "-0.02em",
}}
>
DeckyVault
</div>
<div
style={{
fontSize: 28,
fontWeight: 400,
opacity: 0.8,
}}
>
Steam Deck Benchmarks & Settings
</div>
</div>
),
{
...size,
}
)
}
+58
View File
@@ -0,0 +1,58 @@
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import Link from "next/link"
import { ArrowLeftIcon } from "lucide-react"
import { getAllUpdateSlugs, getUpdateBySlug } from "@/lib/updates"
import { UpdateViewer } from "@/components/updates/update-viewer"
export async function generateStaticParams() {
const slugs = getAllUpdateSlugs()
return slugs.map((slug) => ({ slug }))
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>
}): Promise<Metadata> {
const { slug } = await params
let update
try {
update = await getUpdateBySlug(slug)
} catch {
return { title: "Update Not Found" }
}
return {
title: `${update.meta.title} | DeckyVault`,
description: update.meta.summary,
}
}
export default async function UpdatePage({
params,
}: {
params: Promise<{ slug: string }>
}) {
const { slug } = await params
let update
try {
update = await getUpdateBySlug(slug)
} catch {
notFound()
}
return (
<main className="w-full">
<div className="w-full max-w-7xl mx-auto px-4 pt-4">
<Link
href="/updates"
className="inline-flex items-center gap-1 text-sm text-text/60 hover:text-primary transition-colors"
>
<ArrowLeftIcon className="w-3 h-3" />
Back to updates
</Link>
</div>
<UpdateViewer update={update} />
</main>
)
}
+46
View File
@@ -0,0 +1,46 @@
import type { Metadata } from "next"
import Link from "next/link"
import { getAllUpdates } from "@/lib/updates"
import { UpdateCard } from "@/components/updates/update-card"
import { ArrowLeftIcon } from "lucide-react"
export const metadata: Metadata = {
title: "Updates",
description:
"See what's new in DeckyVault — version release notes, features, and improvements.",
}
export default function UpdatesPage() {
const updates = getAllUpdates()
return (
<main className='w-full max-w-7xl mx-auto px-4 py-8'>
<div className='flex flex-row items-center gap-2 mb-6'>
<Link
href='/'
className='text-text/60 hover:text-primary transition-colors'
>
<ArrowLeftIcon className='w-4 h-4' />
</Link>
<h1 className='text-2xl font-bold'>Updates</h1>
</div>
<p className='text-text/60 mb-8'>
Release notes, changelogs, and the like for DeckyVault.
</p>
{updates.length === 0 ? (
<p className='text-text/40 text-center py-16'>
No updates yet.
</p>
) : (
<div className='flex flex-col gap-3'>
{updates.map((update) => (
<UpdateCard
key={update.slug}
update={update}
/>
))}
</div>
)}
</main>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { Shield, ShieldCheck, ShieldX, ShieldQuestion } from "lucide-react";
import { cn } from "@/lib/utils";
interface AntiCheatBadgeProps {
antiCheatRelevant: boolean;
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null;
antiCheatName?: string | null;
compact?: boolean; // for list views
className?: string;
}
const statusConfig = {
supported: {
icon: ShieldCheck,
label: "Anti-Cheat: Supported",
color: "bg-green-500/15 text-green-400 border-green-500/30",
},
unsupported: {
icon: ShieldX,
label: "Anti-Cheat: Unsupported",
color: "bg-red-500/15 text-red-400 border-red-500/30",
},
unknown: {
icon: ShieldQuestion,
label: "Anti-Cheat: Unknown",
color: "bg-yellow-500/15 text-yellow-400 border-yellow-500/30",
},
none: {
icon: Shield,
label: "No Anti-Cheat",
color: "bg-zinc-500/15 text-zinc-400 border-zinc-500/30",
},
} as const;
export function AntiCheatBadge({
antiCheatRelevant,
antiCheatStatus,
antiCheatName,
compact = false,
className,
}: AntiCheatBadgeProps) {
if (!antiCheatRelevant || !antiCheatStatus || antiCheatStatus === "none") {
return null;
}
const config = statusConfig[antiCheatStatus];
const Icon = config.icon;
if (compact) {
return (
<span
className={cn(
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
config.color,
className
)}
title={antiCheatName ? `${config.label} (${antiCheatName})` : config.label}
>
<Icon className="h-3 w-3" />
{antiCheatStatus === "unsupported" && "AC"}
</span>
);
}
return (
<div
className={cn(
"inline-flex items-center gap-2 rounded-lg border px-3 py-1.5 text-sm font-medium",
config.color,
className
)}
>
<Icon className="h-4 w-4" />
<span>{config.label}</span>
{antiCheatName && (
<span className="text-xs opacity-75">({antiCheatName})</span>
)}
</div>
);
}
@@ -0,0 +1,20 @@
import { describe, it, expect } from "vitest"
import fs from "node:fs"
import path from "node:path"
describe("auth form email placeholder text", () => {
it("all auth forms use you@deckyvault.xyz not you@example.com", () => {
const files = [
"components/auth/signup-form-step.tsx",
"components/auth/login-form.tsx",
"components/auth/forgot-password-form.tsx",
]
for (const file of files) {
const fullPath = path.join(process.cwd(), file)
const content = fs.readFileSync(fullPath, "utf8")
expect(content).not.toContain('"you@example.com"')
expect(content).toContain('"you@deckyvault.xyz"')
}
})
})
@@ -0,0 +1,111 @@
"use client"
import { useState, useRef } from "react"
import { KeyRound, Loader2, ArrowLeft } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import {
forgotPasswordSchema,
} from "@/lib/auth/validation"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link"
import { useRouter } from "next/navigation"
export default function ForgotPasswordForm() {
const router = useRouter()
const [email, setEmail] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = forgotPasswordSchema.safeParse({ email })
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
const { error } = await authClient.emailOtp.requestPasswordReset({
email,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
})
setIsLoading(false)
if (error) {
setError(
error.message || "Something went wrong. Please try again.",
)
turnstileRef.current?.reset()
setTurnstileToken("")
return
}
// Redirect to reset password page with email
router.push(`/reset-password?email=${encodeURIComponent(email)}`)
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center mb-2">
<KeyRound className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Forgot your password?
</h1>
<p className="text-sm text-text/50 mt-2 leading-relaxed">
Enter your email and we&apos;ll send you a verification code to
reset your password.
</p>
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label className="text-sm text-text/60 block mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@deckyvault.xyz"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
<button
type="submit"
disabled={isLoading || !turnstileToken}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Send verification code
</button>
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => setTurnstileToken("")}
/>
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in
</Link>
</form>
)
}
+355
View File
@@ -0,0 +1,355 @@
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import { Loader2, Key } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import {
loginEmailSchema,
loginSchema,
} from "@/lib/auth/validation"
import SocialButtons from "./social-buttons"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link"
import { useRouter, useSearchParams } from "next/navigation"
function isWebAuthnAbortError(err: unknown): boolean {
if (err instanceof DOMException && err.name === "AbortError") return true
const msg = err instanceof Error ? err.message : String(err ?? "")
return msg.includes("abort signal") || msg.includes("AbortError")
}
// Suppress the console.error that @better-auth/passkey logs internally
// when a WebAuthn ceremony is aborted (expected on navigation/remount).
let suppressPasskeyErrors = false
const originalConsoleError = console.error
const passkeyErrorPattern = /\[Better Auth\] Error verifying passkey/
console.error = (...args: unknown[]) => {
if (suppressPasskeyErrors) {
const msg = typeof args[0] === 'string' ? args[0] : ''
if (passkeyErrorPattern.test(msg) || args.some(a => isWebAuthnAbortError(a))) {
return
}
}
originalConsoleError(...args)
}
export default function LoginForm() {
const router = useRouter()
const searchParams = useSearchParams()
const redirectTo = searchParams.get("redirect") || "/"
const [showPassword, setShowPassword] = useState(false)
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [emailChecked, setEmailChecked] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const mountedRef = useRef(true)
const passkeyInitiatedRef = useRef(false)
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
// Redirect to the intended page after successful login
const handleLoginSuccess = useCallback(() => {
if (mountedRef.current) router.push(redirectTo)
}, [router, redirectTo])
// Preload passkeys for conditional UI — must be called on mount when
// both email + password fields are in the DOM.
useEffect(() => {
mountedRef.current = true
if ("PublicKeyCredential" in window && !passkeyInitiatedRef.current) {
passkeyInitiatedRef.current = true
suppressPasskeyErrors = true
authClient.signIn.passkey({
autoFill: true,
fetchOptions: {
onSuccess: handleLoginSuccess,
},
}).catch((err) => {
if (!isWebAuthnAbortError(err)) {
console.warn("[passkey-conditional-ui]", err)
}
}).finally(() => {
suppressPasskeyErrors = false
})
}
return () => { mountedRef.current = false }
}, [handleLoginSuccess])
const handleEmailSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = loginEmailSchema.safeParse({ email })
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
try {
const res = await fetch("/api/auth/check-email", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
})
if (!res.ok) {
const data = await res.json()
setError(data.error || "Something went wrong. Please try again.")
return
}
const data = await res.json()
if (!data.exists) {
setError("No account found with this email.")
return
}
// Email exists, show password field
setEmailChecked(true)
setShowPassword(true)
} catch {
setError("Something went wrong. Please try again.")
} finally {
setIsLoading(false)
}
}
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = loginSchema.safeParse({ email, password })
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
const { error } = await authClient.signIn.email({
email,
password,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
})
setIsLoading(false)
if (error) {
setError(error.message || "Invalid credentials. Please try again.")
turnstileRef.current?.reset()
setTurnstileToken("")
return
}
router.push(redirectTo)
}
const handleChangeEmail = () => {
setShowPassword(false)
setPassword("")
setError("")
setEmailChecked(false)
turnstileRef.current?.reset()
setTurnstileToken("")
}
const handlePasskeyError = useCallback((ctx: { error?: { message?: string } }) => {
// Only show errors that aren't from conditional UI cancellation
// (user dismissing the browser prompt is expected and not an error)
const msg = ctx.error?.message || ""
if (
!msg.includes("No available") &&
!msg.includes("cancelled") &&
!msg.includes("NotAllowed") &&
!msg.includes("aborted")
) {
setError(msg || "Passkey sign-in failed. Please try again.")
}
}, [])
const handlePasskeyLogin = async () => {
setError("")
setIsLoading(true)
suppressPasskeyErrors = true
try {
const result = await authClient.signIn.passkey({
autoFill: false,
fetchOptions: {
onSuccess: handleLoginSuccess,
onError: handlePasskeyError,
},
}).catch((err) => {
if (isWebAuthnAbortError(err)) return { data: null, error: null }
return { data: null, error: { message: err?.message || "Passkey sign-in failed" } }
})
if (result?.error) {
handlePasskeyError({ error: result.error })
}
} catch {
setError("Passkey sign-in failed. Please try again.")
} finally {
suppressPasskeyErrors = false
setIsLoading(false)
}
}
return (
<div className="space-y-5">
<div className="text-center mb-2">
<h1 className="text-xl font-bold text-text">
Welcome back
</h1>
<p className="text-sm text-text/50 mt-1">
{showPassword
? "Signing in as "
: "Sign in to DeckyVault"}
{showPassword && (
<>
<strong className="text-text">{email}</strong>
{" "}
<button
onClick={handleChangeEmail}
className="text-primary hover:underline text-xs"
>
change
</button>
</>
)}
</p>
</div>
<SocialButtons />
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text/40 uppercase">
or continue with email
</span>
<div className="flex-1 h-px bg-border" />
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
{error.includes("No account found") && (
<>
{" "}
<Link
href="/signup"
className="text-primary font-semibold hover:underline"
>
Create one
</Link>
</>
)}
</div>
)}
{/* Single form always contains both email and password inputs
so that WebAuthn conditional UI (autofill) works correctly.
The password field is visually hidden until the email is verified. */}
<form onSubmit={showPassword ? handleLogin : handleEmailSubmit} className="space-y-4">
<div>
<label className="text-sm text-text/60 block mb-1.5">
Email
</label>
<input
type="email"
name="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@deckyvault.xyz"
autoComplete="username webauthn"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
{/* Always render the password input for WebAuthn conditional UI,
but visually hide it until the email is verified */}
<div className={showPassword ? "" : "h-0 overflow-hidden opacity-0 pointer-events-none"}>
<label className="text-sm text-text/60 block mb-1.5">
Password
</label>
<input
type="password"
name="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password"
autoComplete="current-password webauthn"
autoFocus={showPassword}
tabIndex={showPassword ? 0 : -1}
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
</div>
{showPassword && (
<div className="text-right">
<Link
href="/forgot-password"
className="text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
Forgot password?
</Link>
</div>
)}
{showPassword && (
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => {
setTurnstileToken("")
}}
/>
)}
<button
type="submit"
disabled={isLoading}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && (
<Loader2 className="h-4 w-4 animate-spin" />
)}
{showPassword ? "Sign in" : "Continue"}
</button>
{showPassword && (
<div className="text-center p-3 rounded-lg bg-primary/5 border border-primary/10">
<p className="text-xs text-text/50">
Your browser may offer to sign in with a passkey
</p>
</div>
)}
</form>
{/* Explicit passkey login button */}
{"PublicKeyCredential" in globalThis && !showPassword && (
<button
onClick={handlePasskeyLogin}
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 py-3 rounded-lg border border-border bg-text/5 text-text/70 text-sm font-medium hover:bg-text/10 hover:text-text transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Key className="h-4 w-4" />
)}
Sign in with a passkey
</button>
)}
<p className="text-center text-sm text-text/50">
Don&apos;t have an account?{" "}
<Link
href="/signup"
className="text-primary hover:underline cursor-pointer"
>
Create one
</Link>
</p>
</div>
)
}
+125
View File
@@ -0,0 +1,125 @@
"use client"
import { useRef, useCallback, useMemo } from "react"
interface OtpInputProps {
length?: number
value: string
onChange: (value: string) => void
disabled?: boolean
error?: string
}
export default function OtpInput({
length = 6,
value,
onChange,
disabled = false,
error,
}: OtpInputProps) {
const digits = useMemo(
() =>
value
.split("")
.concat(Array(length).fill(""))
.slice(0, length),
[value, length],
)
const refs = useRef<(HTMLInputElement | null)[]>([])
const updateDigits = useCallback(
(newDigits: string[]) => {
onChange(newDigits.join(""))
},
[onChange],
)
const handleChange = useCallback(
(index: number, val: string) => {
// Only allow single digit
const digit = val.replace(/\D/g, "").slice(-1)
const newDigits = [...digits]
newDigits[index] = digit
updateDigits(newDigits)
// Auto-advance to next box
if (digit && index < length - 1) {
refs.current[index + 1]?.focus()
}
},
[digits, length, updateDigits],
)
const handleKeyDown = useCallback(
(index: number, e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Backspace") {
if (!digits[index] && index > 0) {
// Move to previous box if current is empty
refs.current[index - 1]?.focus()
const newDigits = [...digits]
newDigits[index - 1] = ""
updateDigits(newDigits)
}
} else if (e.key === "ArrowLeft" && index > 0) {
refs.current[index - 1]?.focus()
} else if (e.key === "ArrowRight" && index < length - 1) {
refs.current[index + 1]?.focus()
}
},
[digits, length, updateDigits],
)
const handlePaste = useCallback(
(e: React.ClipboardEvent<HTMLInputElement>) => {
e.preventDefault()
const pasted = e.clipboardData
.getData("text")
.replace(/\D/g, "")
.slice(0, length)
const newDigits = Array(length).fill("")
for (let i = 0; i < pasted.length; i++) {
newDigits[i] = pasted[i]
}
updateDigits(newDigits)
// Focus last filled box or next empty
const focusIndex = Math.min(pasted.length, length - 1)
refs.current[focusIndex]?.focus()
},
[length, updateDigits],
)
return (
<div>
<div className="flex gap-2 justify-center">
{Array.from({ length }).map((_, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el
}}
type="text"
inputMode="numeric"
maxLength={1}
value={digits[i]}
onChange={(e) => handleChange(i, e.target.value)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
disabled={disabled}
aria-label={`Digit ${i + 1} of ${length}`}
className={`w-12 h-14 text-center text-xl font-bold rounded-lg border bg-text/5 text-text outline-none transition-colors ${
error
? "border-red-500 focus:border-red-500 focus:ring-2 focus:ring-red-500/50"
: "border-border focus:border-primary focus:ring-2 focus:ring-primary/50"
} ${disabled ? "opacity-50 cursor-not-allowed" : ""}`}
/>
))}
</div>
{error && (
<p className="text-red-400 text-sm mt-2 text-center">
{error}
</p>
)}
</div>
)
}
@@ -0,0 +1,143 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import { ArrowLeft, Loader2, Mail } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import OtpInput from "./otp-input"
interface OtpVerificationStepProps {
email: string
onSuccess: () => void
onBack: () => void
}
export default function OtpVerificationStep({
email,
onSuccess,
onBack,
}: OtpVerificationStepProps) {
const [otp, setOtp] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [resendTimer, setResendTimer] = useState(300) // 5 minutes
const canResend = resendTimer <= 0
// Countdown timer
useEffect(() => {
if (resendTimer <= 0) return
const interval = setInterval(() => {
setResendTimer((prev) => prev - 1)
}, 1000)
return () => clearInterval(interval)
}, [resendTimer])
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, "0")}`
}
const handleVerify = useCallback(
async (otpValue: string) => {
if (otpValue.length !== 6) return
setIsLoading(true)
setError("")
const { error } = await authClient.emailOtp.verifyEmail({
email,
otp: otpValue,
})
setIsLoading(false)
if (error) {
setError(
error.code === "TOO_MANY_ATTEMPTS"
? "Too many attempts. Please request a new code."
: "Invalid code. Please try again.",
)
return
}
onSuccess()
},
[email, onSuccess],
)
const handleResend = async () => {
setError("")
await authClient.emailOtp.sendVerificationOtp({
email,
type: "email-verification",
})
setResendTimer(300)
}
return (
<div className="space-y-5">
<div className="text-center mb-2">
<Mail className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Verify your email
</h1>
<p className="text-sm text-text/50 mt-2">
We sent a 6-digit code to{" "}
<strong className="text-text">{email}</strong>
</p>
</div>
<div>
<label className="text-sm text-text/60 block mb-2 text-center">
Verification code
</label>
<OtpInput
value={otp}
onChange={(value) => {
setOtp(value)
if (value.length === 6) {
handleVerify(value)
}
}}
disabled={isLoading}
error={error}
/>
</div>
<div className="text-center text-sm">
{canResend ? (
<button
onClick={handleResend}
className="text-primary hover:underline"
>
Resend code
</button>
) : (
<span className="text-text/40">
Resend code in{" "}
<span className="text-primary font-semibold">
{formatTime(resendTimer)}
</span>
</span>
)}
</div>
<button
onClick={() => handleVerify(otp)}
disabled={isLoading || otp.length !== 6}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Verify
</button>
<button
onClick={onBack}
className="flex items-center justify-center gap-2 w-full text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
<ArrowLeft className="h-4 w-4" />
Back to signup
</button>
</div>
)
}
@@ -0,0 +1,115 @@
"use client"
import { useState } from "react"
import { KeyRound, Check, Loader2, Fingerprint } from "lucide-react"
import { authClient } from "@/lib/auth-client"
interface PasskeySetupStepProps {
onSuccess: () => void
onSkip: () => void
}
export default function PasskeySetupStep({
onSuccess,
onSkip,
}: PasskeySetupStepProps) {
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
const isPasskeySupported =
typeof window !== "undefined" &&
"PublicKeyCredential" in window
const handleAddPasskey = async () => {
setIsLoading(true)
setError("")
const { error } = await authClient.passkey.addPasskey({
name: "Primary passkey",
})
setIsLoading(false)
if (error) {
setError(
error.message ||
"Failed to set up passkey. You can try again later.",
)
return
}
onSuccess()
}
return (
<div className="space-y-5">
<div className="text-center mb-2">
<KeyRound className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Set up a passkey
</h1>
<p className="text-sm text-text/50 mt-2 leading-relaxed">
Sign in faster with biometrics or your device&apos;s security
key. No password needed.
</p>
</div>
{/* Benefits list */}
<div className="bg-text/5 rounded-lg p-4 space-y-3">
<div className="flex items-center gap-3 text-sm text-text/60">
<Check className="h-4 w-4 text-green-400 shrink-0" />
Faster sign-in with fingerprint or face
</div>
<div className="flex items-center gap-3 text-sm text-text/60">
<Check className="h-4 w-4 text-green-400 shrink-0" />
More secure than passwords
</div>
<div className="flex items-center gap-3 text-sm text-text/60">
<Check className="h-4 w-4 text-green-400 shrink-0" />
Works across your devices
</div>
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
</div>
)}
{isPasskeySupported ? (
<>
<button
onClick={handleAddPasskey}
disabled={isLoading}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Fingerprint className="h-4 w-4" />
)}
Set up passkey
</button>
<button
onClick={onSkip}
className="w-full py-3 rounded-lg border border-border text-text/60 text-sm hover:bg-text/5 transition-colors cursor-pointer"
>
Skip for now
</button>
</>
) : (
<div className="text-center">
<p className="text-sm text-text/40 mb-4">
Passkeys are not supported on this device.
</p>
<button
onClick={onSkip}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer"
>
Continue
</button>
</div>
)}
</div>
)
}
@@ -0,0 +1,97 @@
"use client"
import { ShieldCheck, ShieldAlert, ShieldQuestion } from "lucide-react"
import { motion } from "motion/react"
import {
checkPasswordStrength,
type StrengthLevel,
} from "@/lib/auth/password-strength"
interface PasswordStrengthProps {
password: string
}
const levelColors: Record<StrengthLevel, string> = {
weak: "#ef4444",
fair: "#f59e0b",
good: "#eab308",
strong: "#22c55e",
excellent: "#10b981",
}
const levelLabels: Record<StrengthLevel, string> = {
weak: "Weak",
fair: "Fair",
good: "Good",
strong: "Strong",
excellent: "Excellent",
}
const levelBars: Record<StrengthLevel, number> = {
weak: 1,
fair: 2,
good: 3,
strong: 4,
excellent: 5,
}
export default function PasswordStrengthMeter({
password,
}: PasswordStrengthProps) {
if (!password) return null
const { level, feedback } = checkPasswordStrength(password)
const color = levelColors[level]
const bars = levelBars[level]
return (
<div className="mt-2">
{/* Bar indicator */}
<div className="flex gap-1 mb-1.5">
{Array.from({ length: 5 }).map((_, i) => (
<motion.div
key={i}
className="h-[3px] flex-1 rounded-full"
initial={{ backgroundColor: "rgba(255,255,255,0.1)" }}
animate={{
backgroundColor:
i < bars ? color : "rgba(255,255,255,0.1)",
}}
transition={{ duration: 0.2 }}
/>
))}
</div>
{/* Label and feedback */}
<div className="flex items-start gap-1.5">
{level === "excellent" || level === "strong" ? (
<ShieldCheck
className="h-3.5 w-3.5 mt-0.5 shrink-0"
style={{ color }}
/>
) : level === "weak" ? (
<ShieldAlert
className="h-3.5 w-3.5 mt-0.5 shrink-0"
style={{ color }}
/>
) : (
<ShieldQuestion
className="h-3.5 w-3.5 mt-0.5 shrink-0"
style={{ color }}
/>
)}
<div>
<span className="text-xs font-medium" style={{ color }}>
{levelLabels[level]}
</span>
{feedback.length > 0 &&
feedback[0] !== "Great password!" && (
<span className="text-xs text-[#ebe4f1]/50 ml-1">
{feedback[0]}
</span>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,218 @@
"use client"
import { useState, useEffect } from "react"
import {
Mail,
CheckCircle2,
Loader2,
ArrowLeft,
} from "lucide-react"
import { authClient } from "@/lib/auth-client"
import {
resetPasswordSchema,
} from "@/lib/auth/validation"
import OtpInput from "./otp-input"
import PasswordStrengthMeter from "./password-strength"
import Link from "next/link"
interface ResetPasswordFormProps {
email: string
}
export default function ResetPasswordForm({
email,
}: ResetPasswordFormProps) {
const [otp, setOtp] = useState("")
const [newPassword, setNewPassword] = useState("")
const [confirmPassword, setConfirmPassword] = useState("")
const [error, setError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const [resendTimer, setResendTimer] = useState(300)
const canResend = resendTimer <= 0
// Countdown timer
useEffect(() => {
if (resendTimer <= 0) return
const interval = setInterval(() => {
setResendTimer((prev) => prev - 1)
}, 1000)
return () => clearInterval(interval)
}, [resendTimer])
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins}:${secs.toString().padStart(2, "0")}`
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError("")
const result = resetPasswordSchema.safeParse({
otp,
newPassword,
confirmPassword,
})
if (!result.success) {
setError(result.error.issues[0].message)
return
}
setIsLoading(true)
const { error } = await authClient.emailOtp.resetPassword({
email,
otp,
password: newPassword,
})
setIsLoading(false)
if (error) {
setError(
error.code === "TOO_MANY_ATTEMPTS"
? "Too many attempts. Please request a new code."
: error.message || "Failed to reset password.",
)
return
}
setIsSuccess(true)
}
const handleResend = async () => {
setError("")
await authClient.emailOtp.requestPasswordReset({ email })
setResendTimer(300)
}
if (isSuccess) {
return (
<div className="text-center space-y-5">
<CheckCircle2 className="h-10 w-10 text-green-400 mx-auto" />
<h1 className="text-xl font-bold text-text">
Password reset successful
</h1>
<p className="text-sm text-text/50">
Your password has been updated. You can now sign in with
your new password.
</p>
<Link
href="/login"
className="inline-block w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors text-center cursor-pointer"
>
Sign in
</Link>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="text-center mb-2">
<Mail className="h-10 w-10 text-primary mx-auto mb-3" />
<h1 className="text-xl font-bold text-text">
Check your email
</h1>
<p className="text-sm text-text/50 mt-2">
We sent a 6-digit code to{" "}
<strong className="text-text">{email}</strong>
</p>
</div>
<div>
<label className="text-sm text-text/60 block mb-2 text-center">
Verification code
</label>
<OtpInput
value={otp}
onChange={setOtp}
disabled={isLoading}
/>
</div>
<div className="text-center text-sm">
{canResend ? (
<button
type="button"
onClick={handleResend}
className="text-primary hover:underline"
>
Resend code
</button>
) : (
<span className="text-text/40">
Resend code in{" "}
<span className="text-primary font-semibold">
{formatTime(resendTimer)}
</span>
</span>
)}
</div>
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text/40">
then set new password
</span>
<div className="flex-1 h-px bg-border" />
</div>
{error && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label className="text-sm text-text/60 block mb-1.5">
New password
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Min. 10 characters"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
<PasswordStrengthMeter password={newPassword} />
</div>
<div>
<label className="text-sm text-text/60 block mb-1.5">
Confirm new password
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter password"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{confirmPassword &&
newPassword !== confirmPassword && (
<p className="text-red-400 text-sm mt-1">
Passwords do not match
</p>
)}
</div>
<button
type="submit"
disabled={isLoading || otp.length !== 6}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Reset password
</button>
<Link
href="/login"
className="flex items-center justify-center gap-2 text-sm text-text/50 hover:text-text transition-colors cursor-pointer"
>
<ArrowLeft className="h-4 w-4" />
Back to sign in
</Link>
</form>
)
}
@@ -0,0 +1,168 @@
"use client"
import { useState, useRef } from "react"
import { Loader2 } from "lucide-react"
import { authClient } from "@/lib/auth-client"
import { signupSchema } from "@/lib/auth/validation"
import SocialButtons from "./social-buttons"
import PasswordStrengthMeter from "./password-strength"
import TurnstileWidget, { type TurnstileWidgetHandle } from "./turnstile-widget"
import Link from "next/link"
interface SignupFormStepProps {
onSuccess: (email: string) => void
}
export default function SignupFormStep({ onSuccess }: SignupFormStepProps) {
const [name, setName] = useState("")
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [errors, setErrors] = useState<Record<string, string>>({})
const [serverError, setServerError] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [turnstileToken, setTurnstileToken] = useState("")
const turnstileRef = useRef<TurnstileWidgetHandle>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setErrors({})
setServerError("")
const result = signupSchema.safeParse({ name, email, password })
if (!result.success) {
const fieldErrors: Record<string, string> = {}
result.error.issues.forEach((issue) => {
const field = issue.path[0] as string
fieldErrors[field] = issue.message
})
setErrors(fieldErrors)
return
}
setIsLoading(true)
const { error } = await authClient.signUp.email({
name,
email,
password,
fetchOptions: {
headers: {
"x-captcha-response": turnstileToken,
},
},
})
setIsLoading(false)
if (error) {
setServerError(
error.message || "Something went wrong. Please try again.",
)
turnstileRef.current?.reset()
setTurnstileToken("")
return
}
onSuccess(email)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="text-center mb-2">
<h1 className="text-xl font-bold text-text">
Create your account
</h1>
<p className="text-sm text-text/50 mt-1">
Join DeckyVault and start exploring
</p>
</div>
<SocialButtons callbackURL="/signup?step=passkey" />
<div className="flex items-center gap-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-text/40 uppercase">
or use email
</span>
<div className="flex-1 h-px bg-border" />
</div>
{serverError && (
<div className="text-red-400 text-sm text-center bg-red-500/10 border border-red-500/20 rounded-lg px-4 py-3">
{serverError}
</div>
)}
<div>
<label className="text-sm text-text/60 block mb-1.5">
Name
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Your name"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{errors.name && (
<p className="text-red-400 text-sm mt-1">{errors.name}</p>
)}
</div>
<div>
<label className="text-sm text-text/60 block mb-1.5">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@deckyvault.xyz"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{errors.email && (
<p className="text-red-400 text-sm mt-1">{errors.email}</p>
)}
</div>
<div>
<label className="text-sm text-text/60 block mb-1.5">
Password
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 10 characters"
className="w-full px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm placeholder:text-text/40 outline-none focus:border-primary focus:ring-2 focus:ring-primary/50 transition-colors"
/>
{errors.password && (
<p className="text-red-400 text-sm mt-1">
{errors.password}
</p>
)}
<PasswordStrengthMeter password={password} />
</div>
<button
type="submit"
disabled={isLoading || !turnstileToken}
className="w-full py-3 rounded-lg bg-primary text-white text-sm font-semibold hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
>
{isLoading && <Loader2 className="h-4 w-4 animate-spin" />}
Create account
</button>
<TurnstileWidget
ref={turnstileRef}
onToken={setTurnstileToken}
onExpire={() => setTurnstileToken("")}
/>
<p className="text-center text-sm text-text/50">
Already have an account?{" "}
<Link href="/login" className="text-primary hover:underline cursor-pointer">
Sign in
</Link>
</p>
</form>
)
}
@@ -0,0 +1,74 @@
"use client"
import { useState, useEffect } from "react"
import { useRouter, useSearchParams } from "next/navigation"
import StepIndicator from "./step-indicator"
import SignupFormStep from "./signup-form-step"
import OtpVerificationStep from "./otp-verification-step"
import PasskeySetupStep from "./passkey-setup-step"
export default function SignupWizard() {
const router = useRouter()
const searchParams = useSearchParams()
const stepParam = searchParams.get("step")
const [step, setStep] = useState<1 | 2 | 3>(() => {
if (stepParam === "otp") return 2
if (stepParam === "passkey") return 3
return 1
})
const [email, setEmail] = useState("")
// Update URL when step changes
useEffect(() => {
const params = new URLSearchParams()
if (step === 2) params.set("step", "otp")
if (step === 3) params.set("step", "passkey")
const query = params.toString()
router.replace(`/signup${query ? `?${query}` : ""}`, {
scroll: false,
})
}, [step, router])
const handleSignupSuccess = (userEmail: string) => {
setEmail(userEmail)
setStep(2)
}
const handleOtpSuccess = () => {
setStep(3)
}
const handlePasskeyComplete = () => {
router.push("/")
}
const handlePasskeySkip = () => {
router.push("/")
}
return (
<div className="space-y-5">
<StepIndicator currentStep={step} totalSteps={3} />
{step === 1 && (
<SignupFormStep onSuccess={handleSignupSuccess} />
)}
{step === 2 && (
<OtpVerificationStep
email={email}
onSuccess={handleOtpSuccess}
onBack={() => setStep(1)}
/>
)}
{step === 3 && (
<PasskeySetupStep
onSuccess={handlePasskeyComplete}
onSkip={handlePasskeySkip}
/>
)}
</div>
)
}
@@ -0,0 +1,87 @@
"use client"
import { useState } from "react"
import { Loader2 } from "lucide-react"
import { authClient } from "@/lib/auth-client"
interface SocialButtonsProps {
callbackURL?: string
disabled?: boolean
}
export default function SocialButtons({
callbackURL = "/",
disabled = false,
}: SocialButtonsProps) {
const [loadingProvider, setLoadingProvider] = useState<
"google" | "discord" | null
>(null)
const handleSocialLogin = async (provider: "google" | "discord") => {
setLoadingProvider(provider)
await authClient.signIn.social(
{
provider,
callbackURL,
},
{
onError: () => {
setLoadingProvider(null)
},
},
)
}
const isLoading = loadingProvider !== null
return (
<div className="flex gap-3">
<button
type="button"
onClick={() => handleSocialLogin("google")}
disabled={disabled || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm font-medium hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingProvider === "google" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 01-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
)}
Google
</button>
<button
type="button"
onClick={() => handleSocialLogin("discord")}
disabled={disabled || isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 rounded-lg border border-border bg-text/5 text-text text-sm font-medium hover:bg-text/10 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{loadingProvider === "discord" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="#5865F2">
<path d="M19.27 5.33C17.94 4.71 16.5 4.26 15 4a.09.09 0 00-.07.03c-.18.33-.39.76-.53 1.09a16.09 16.09 0 00-4.8 0c-.14-.34-.35-.76-.54-1.09-.01-.01-.04-.03-.07-.03-1.5.26-2.93.71-4.27 1.33-.01 0-.02.01-.03.02-2.72 4.07-3.47 8.03-3.1 11.95 0 .01.01.03.02.04 1.69 1.24 3.33 1.99 4.95 2.49.03.01.06 0 .07-.02.38-.52.72-1.07 1.01-1.65.02-.04 0-.08-.04-.09-.55-.2-1.08-.45-1.59-.73-.04-.02-.04-.08 0-.1.11-.08.22-.17.33-.26.02-.02.05-.02.07-.01 3.44 1.57 7.15 1.57 10.55 0 .02-.01.05-.01.07.01.11.09.22.17.33.26.04.02.04.08 0 .1-.51.28-1.04.53-1.59.73-.04.01-.05.06-.04.09.29.58.64 1.13 1.01 1.65.03.01.06.02.09.01 1.62-.5 3.27-1.25 4.96-2.49.01-.01.02-.03.02-.04.44-4.53-.73-8.46-3.1-11.95-.01-.01-.02-.02-.04-.02zM8.52 14.91c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.84 2.12-1.89 2.12zm6.97 0c-1.03 0-1.89-.95-1.89-2.12s.84-2.12 1.89-2.12c1.06 0 1.9.96 1.89 2.12 0 1.17-.83 2.12-1.89 2.12z" />
</svg>
)}
Discord
</button>
</div>
)
}
@@ -0,0 +1,34 @@
"use client"
import { motion } from "motion/react"
interface StepIndicatorProps {
currentStep: number
totalSteps: number
}
export default function StepIndicator({
currentStep,
totalSteps,
}: StepIndicatorProps) {
return (
<div className="flex gap-2 justify-center">
{Array.from({ length: totalSteps }).map((_, i) => (
<motion.div
key={i}
className="h-1 w-12 rounded-full"
initial={false}
animate={{
backgroundColor:
i < currentStep - 1
? "#22c55e" // completed - green
: i === currentStep - 1
? "#eb3779" // current - primary
: "rgba(235,228,241,0.1)", // upcoming - border color
}}
transition={{ duration: 0.3 }}
/>
))}
</div>
)
}
@@ -0,0 +1,124 @@
"use client"
import { useEffect, useRef, useImperativeHandle, forwardRef, useId } from "react"
declare global {
interface Window {
turnstile?: {
render: (container: string | HTMLElement, options: TurnstileOptions) => string
reset: (widgetId: string) => void
remove: (widgetId: string) => void
getResponse: (widgetId: string) => string | undefined
}
onloadTurnstileCallback?: () => void
}
}
interface TurnstileOptions {
sitekey: string
theme?: "light" | "dark" | "auto"
callback?: (token: string) => void
"expired-callback"?: () => void
"error-callback"?: () => void
}
export interface TurnstileWidgetHandle {
reset: () => void
getToken: () => string | undefined
}
interface TurnstileWidgetProps {
onToken: (token: string) => void
onExpire?: () => void
onError?: () => void
theme?: "light" | "dark" | "auto"
}
const SCRIPT_ID = "cf-turnstile-script"
const SRC = "https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onloadTurnstileCallback&render=explicit"
export default forwardRef<TurnstileWidgetHandle, TurnstileWidgetProps>(
function TurnstileWidget({ onToken, onExpire, onError, theme = "auto" }, ref) {
const containerRef = useRef<HTMLDivElement>(null)
const widgetIdRef = useRef<string | null>(null)
const scriptLoadedRef = useRef(false)
const id = useId()
// Expose reset and getToken to parent
useImperativeHandle(ref, () => ({
reset: () => {
if (widgetIdRef.current && window.turnstile) {
window.turnstile.reset(widgetIdRef.current)
}
},
getToken: () => {
if (widgetIdRef.current && window.turnstile) {
return window.turnstile.getResponse(widgetIdRef.current)
}
return undefined
},
}))
useEffect(() => {
const container = containerRef.current
if (!container) return
const siteKey = process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY
if (!siteKey) {
console.warn("[Turnstile] NEXT_PUBLIC_TURNSTILE_SITE_KEY is not set")
return
}
function renderWidget() {
if (!window.turnstile || !container || !siteKey) return
// Clear any previous content
container.innerHTML = ""
const widgetId = window.turnstile.render(container, {
sitekey: siteKey,
theme,
callback: (token: string) => onToken(token),
"expired-callback": () => {
widgetIdRef.current = null
onExpire?.()
},
"error-callback": () => {
onError?.()
},
})
widgetIdRef.current = widgetId
scriptLoadedRef.current = true
}
// If script is already loaded, render immediately
if (window.turnstile) {
renderWidget()
return
}
// Set up the onload callback before adding the script
window.onloadTurnstileCallback = renderWidget
// Avoid injecting the script twice
if (!document.getElementById(SCRIPT_ID)) {
const script = document.createElement("script")
script.id = SCRIPT_ID
script.src = SRC
script.async = true
script.defer = true
document.head.appendChild(script)
}
return () => {
// Cleanup widget on unmount
if (widgetIdRef.current && window.turnstile) {
window.turnstile.remove(widgetIdRef.current)
widgetIdRef.current = null
}
}
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return <div ref={containerRef} id={`turnstile-container-${id}`} className="flex justify-center min-h-[65px]" />
},
)
@@ -0,0 +1,101 @@
"use client"
import { EChartWrapper, getDeviceColor } from "./EChartWrapper"
interface BatteryLifePoint {
id: string
hardwareSlug: string
tdpWatts: number
estimatedBatteryHours: number
wattHours: number | null
tdpMax: number | null
estimatedAtMaxTdpMin: number | null
}
interface BatteryLifeChartProps {
data: BatteryLifePoint[]
deviceNames?: Record<string, string>
}
export function BatteryLifeChart({ data, deviceNames }: BatteryLifeChartProps) {
if (!data || data.length === 0) {
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No battery data available</div>
}
// Group by device for separate series
const deviceGroups = new Map<string, BatteryLifePoint[]>()
for (const point of data) {
const existing = deviceGroups.get(point.hardwareSlug) || []
existing.push(point)
deviceGroups.set(point.hardwareSlug, existing)
}
// Build trend lines: for each device, compute wattHours / tdp = hours for a range of TDPs
const series: Array<Record<string, unknown>> = []
let seriesIdx = 0
for (const [slug, points] of deviceGroups.entries()) {
const color = getDeviceColor(seriesIdx)
const name = deviceNames?.[slug] || slug
// Scatter points: TDP vs battery hours
series.push({
name,
type: "scatter" as const,
data: points.map((p) => [p.tdpWatts, p.estimatedBatteryHours]),
itemStyle: { color },
symbolSize: 10,
})
// Trend line: compute theoretical curve using average wattHours for this device
const avgWh = points.reduce((sum, p) => sum + (p.wattHours ?? 0), 0) / points.length
if (avgWh > 0) {
const tdpRange = [2, 5, 8, 10, 12, 15, 18, 20, 25, 30].filter(
(tdp) => tdp <= (points[0].tdpMax ?? 30),
)
series.push({
name: `${name} (est.)`,
type: "line" as const,
data: tdpRange.map((tdp) => [tdp, Math.round((avgWh / tdp) * 10) / 10]),
lineStyle: { color, type: "dashed" as const, width: 1 },
symbol: "none",
silent: true,
})
}
seriesIdx++
}
const option = {
tooltip: {
trigger: "item" as const,
formatter: (params: unknown) => {
const p = params as { seriesName?: string; value?: [number, number] }
if (!p.seriesName || p.seriesName.includes("(est.)")) return ""
return `${p.seriesName}<br/>TDP: ${p.value?.[0]}W<br/>Battery: ~${p.value?.[1]}h`
},
},
legend: {
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: {
type: "value" as const,
name: "TDP (W)",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
yAxis: {
type: "value" as const,
name: "Battery (h)",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
series,
}
return <EChartWrapper option={option} height={300} />
}
@@ -0,0 +1,75 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface DeviceEntry {
hardwareSlug: string
hardwareName: string
count: number
}
export function DeviceDonut({
data,
className,
}: {
data: DeviceEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const total = data.reduce((sum, d) => sum + d.count, 0)
return {
tooltip: {
trigger: "item",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
formatter: "{b}: {c} ({d}%)",
},
series: [
{
type: "pie",
radius: ["50%", "75%"],
center: ["50%", "55%"],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 6,
borderColor: "#100b14",
borderWidth: 2,
},
label: {
show: true,
position: "center",
formatter: `{total|${total}}\n{label|entries}`,
rich: {
total: {
fontSize: 22,
fontWeight: "bold",
color: CHART_THEME.text,
lineHeight: 30,
},
label: {
fontSize: 11,
color: CHART_THEME.textMuted,
},
},
},
emphasis: {
label: { show: true },
},
data: data.map((d, idx) => ({
name: d.hardwareName,
value: d.count,
itemStyle: { color: getDeviceColor(idx) },
})),
},
],
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={220} className={className} />
}
@@ -0,0 +1,67 @@
"use client"
import { useRef, useCallback } from "react"
import ReactECharts from "echarts-for-react"
import type { EChartsOption } from "echarts"
// Project theme colors matching globals.css
export const CHART_THEME = {
bg: "transparent",
text: "#ebe4f1",
textMuted: "#6b5a7d",
textSubtle: "#4a3a5c",
border: "#3d2d52",
primary: "#eb3779",
secondary: "#571b8b",
accent: "#fb793c",
success: "#22c55e",
info: "#3b82f6",
warning: "#f59e0b",
// Device-specific colors
deviceColors: [
"#eb3779", // primary (OLED)
"#571b8b", // secondary (LCD)
"#fb793c", // accent (Steam Machine)
"#22c55e",
"#3b82f6",
"#f59e0b",
"#a78bfa",
"#ec4899",
],
}
export function getDeviceColor(index: number): string {
return CHART_THEME.deviceColors[index % CHART_THEME.deviceColors.length]
}
export function EChartWrapper({
option,
height = 300,
className = "",
}: {
option: EChartsOption
height?: number
className?: string
}) {
const chartRef = useRef<ReactECharts>(null)
const onEvents = useCallback(
() => ({
// Placeholder for future event handlers
}),
[],
)
return (
<div className={className} style={{ height }}>
<ReactECharts
ref={chartRef}
option={option}
style={{ height: "100%", width: "100%" }}
opts={{ renderer: "canvas" }}
onEvents={onEvents()}
theme={undefined}
/>
</div>
)
}
+77
View File
@@ -0,0 +1,77 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface BoxplotEntry {
hardwareSlug: string
hardwareName: string
min: number
q1: number
median: number
q3: number
max: number
}
export function FpsBoxplot({
data,
className,
}: {
data: BoxplotEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const categories = data.map((d) => d.hardwareName)
const boxData = data.map((d) => [d.min, d.q1, d.median, d.q3, d.max])
return {
tooltip: {
trigger: "item",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
formatter: (params) => {
const d = (params as { data: number[] }).data
if (!Array.isArray(d)) return ""
return `Min: ${d[0]}<br/>Q1: ${d[1]}<br/>Median: ${d[2]}<br/>Q3: ${d[3]}<br/>Max: ${d[4]}`
},
},
grid: { top: 16, right: 16, bottom: 24, left: 40 },
xAxis: {
type: "category",
data: categories,
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "FPS",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series: [
{
type: "boxplot",
data: boxData,
itemStyle: {
color: CHART_THEME.primary + "20",
borderColor: CHART_THEME.primary,
borderWidth: 2,
},
emphasis: {
itemStyle: {
borderColor: CHART_THEME.accent,
borderWidth: 3,
},
},
},
],
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={220} className={className} />
}
@@ -0,0 +1,75 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface RangeEntry {
id: string
hardwareSlug: string
fpsLow: number
fpsAvg: number
fpsHigh: number
isRawPerformer: boolean
}
export function FpsRangeChart({
data,
className,
}: {
data: RangeEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const devices = [...new Set(data.map((d) => d.hardwareSlug))]
const sorted = [...data].sort((a, b) => b.fpsAvg - a.fpsAvg)
const labels = sorted.map((_, i) => `#${i + 1}`)
const series = devices.map((device, idx) => ({
name: device.replace(/-/g, " "),
type: "bar" as const,
stack: "range",
data: sorted.map((entry) => {
if (entry.hardwareSlug !== device) return 0
return entry.fpsHigh - entry.fpsLow
}),
itemStyle: {
color: getDeviceColor(idx),
borderRadius: [2, 2, 0, 0],
},
barWidth: "60%",
}))
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
},
legend: {
top: 0,
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
},
grid: { top: 30, right: 16, bottom: 24, left: 40 },
xAxis: {
type: "category",
data: labels,
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "FPS Range",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series,
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={220} className={className} />
}
@@ -0,0 +1,78 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface HistoricalEntry {
period: string
entries: Array<{
hardwareSlug: string
avgFps: number
count: number
}>
}
export function HistoricalAreaChart({
data,
className,
}: {
data: HistoricalEntry[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const periods = data.map((d) => d.period)
const deviceSlugs = [
...new Set(data.flatMap((d) => d.entries.map((e) => e.hardwareSlug))),
]
const series = deviceSlugs.map((slug, idx) => ({
name: slug.replace(/-/g, " "),
type: "line" as const,
stack: "total",
areaStyle: { opacity: 0.3 },
emphasis: { focus: "series" as const },
smooth: true,
data: periods.map((period) => {
const entry = data
.find((d) => d.period === period)
?.entries.find((e) => e.hardwareSlug === slug)
return entry?.avgFps ?? null
}),
itemStyle: { color: getDeviceColor(idx) },
lineStyle: { color: getDeviceColor(idx) },
}))
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
},
legend: {
top: 0,
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
},
grid: { top: 30, right: 16, bottom: 24, left: 40 },
xAxis: {
type: "category",
data: periods,
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "AVG FPS",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series,
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={280} className={className} />
}
@@ -0,0 +1,72 @@
"use client"
import { EChartWrapper } from "./EChartWrapper"
interface TierData {
hardwareSlug: string
hardwareName?: string
unplayable: number
playable: number
smooth: number
excellent: number
}
export function PerformanceTierChart({ data }: { data: TierData[] }) {
if (!data || data.length === 0) {
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No tier data</div>
}
const labels = data.map((d) => d.hardwareName || d.hardwareSlug)
const option = {
tooltip: {
trigger: "axis" as const,
axisPointer: { type: "shadow" as const },
},
legend: {
data: ["<30 fps", "30-59", "60-119", "≥120"],
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 100, right: 20, top: 40, bottom: 30 },
xAxis: { type: "value" as const, splitLine: { lineStyle: { color: "#333" } } },
yAxis: {
type: "category" as const,
data: labels,
axisLine: { lineStyle: { color: "#555" } },
axisLabel: { color: "#999" },
},
series: [
{
name: "<30 fps",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.unplayable),
itemStyle: { color: "#ef4444" },
},
{
name: "30-59",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.playable),
itemStyle: { color: "#eab308" },
},
{
name: "60-119",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.smooth),
itemStyle: { color: "#22c55e" },
},
{
name: "≥120",
type: "bar" as const,
stack: "total",
data: data.map((d) => d.excellent),
itemStyle: { color: "#3b82f6" },
},
],
}
return <EChartWrapper option={option} height={250} />
}
@@ -0,0 +1,81 @@
"use client"
import { EChartWrapper } from "./EChartWrapper"
interface ScatterPoint {
id: string
hardwareSlug: string
fpsAvg: number
fpsOnePercentLow: number
stabilityRatio: number
}
export function StabilityScatterChart({ data, deviceNames }: { data: ScatterPoint[]; deviceNames?: Record<string, string> }) {
if (!data || data.length === 0) {
return <div className="flex items-center justify-center h-48 text-sm text-text/40">No stability data yet</div>
}
// Group by device
const deviceGroups = new Map<string, ScatterPoint[]>()
for (const point of data) {
const existing = deviceGroups.get(point.hardwareSlug) || []
existing.push(point)
deviceGroups.set(point.hardwareSlug, existing)
}
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444", "#8b5cf6"]
const series = Array.from(deviceGroups.entries()).map(([slug, points], idx) => ({
name: deviceNames?.[slug] || slug,
type: "scatter" as const,
data: points.map((p) => [p.fpsAvg, p.fpsOnePercentLow]),
itemStyle: { color: colors[idx % colors.length] },
symbolSize: 8,
}))
// Perfect stability line (y = x)
const maxFps = Math.max(...data.map((d) => d.fpsAvg))
const perfectLine = {
name: "Perfect Stability",
type: "line" as const,
data: [
[0, 0],
[maxFps, maxFps],
],
lineStyle: { color: "#555", type: "dashed" as const },
symbol: "none",
silent: true,
}
const option = {
tooltip: {
trigger: "item" as const,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter: (params: any) => {
if (params.seriesName === "Perfect Stability") return ""
return `${params.seriesName}<br/>Avg: ${params.value[0]} fps<br/>1% Low: ${params.value[1]} fps`
},
},
legend: {
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: {
type: "value" as const,
name: "Avg FPS",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
yAxis: {
type: "value" as const,
name: "1% Low FPS",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
series: [...series, perfectLine],
}
return <EChartWrapper option={option} height={300} />
}
@@ -0,0 +1,99 @@
"use client"
import { useMemo } from "react"
import { EChartWrapper, CHART_THEME, getDeviceColor } from "./EChartWrapper"
import type { EChartsOption } from "echarts"
interface UpscalerStat {
upscalerType: string
upscalerVersion?: string | null
frameGenMethod: string
hardwareSlug: string
avgFps: number
count: number
}
function formatCombo(upscalerType: string, upscalerVersion: string | null | undefined, fg: string): string {
const parts: string[] = []
if (upscalerType !== "none") {
const upscalerLabel = upscalerVersion
? `${upscalerType.toUpperCase()} ${upscalerVersion}`
: upscalerType.toUpperCase()
parts.push(upscalerLabel)
}
if (fg !== "none") {
if (fg === "fsr_fg") parts.push("FSR FG")
else if (fg === "dlss_fg") parts.push("DLSS FG")
else if (fg === "lsfg") parts.push("LSFG")
else parts.push(fg.toUpperCase())
}
return parts.length > 0 ? parts.join(" + ") : "Native"
}
export function UpscalerBarChart({
data,
className,
}: {
data: UpscalerStat[]
className?: string
}) {
const option = useMemo<EChartsOption>(() => {
const combos = [
...new Set(
data.map((d) => formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod)),
),
]
const deviceSlugs = [...new Set(data.map((d) => d.hardwareSlug))]
const series = deviceSlugs.map((slug, idx) => ({
name: slug.replace(/-/g, " "),
type: "bar" as const,
data: combos.map((combo) => {
const match = data.find(
(d) =>
formatCombo(d.upscalerType, d.upscalerVersion, d.frameGenMethod) === combo &&
d.hardwareSlug === slug,
)
return match?.avgFps ?? 0
}),
itemStyle: { color: getDeviceColor(idx), borderRadius: [4, 4, 0, 0] },
barGap: "10%",
}))
return {
tooltip: {
trigger: "axis",
backgroundColor: "#1a1025",
borderColor: CHART_THEME.border,
textStyle: { color: CHART_THEME.text, fontSize: 12 },
},
legend: {
top: 0,
textStyle: { color: CHART_THEME.textMuted, fontSize: 11 },
},
grid: { top: 30, right: 16, bottom: 50, left: 40 },
xAxis: {
type: "category",
data: combos,
axisLabel: {
color: CHART_THEME.textMuted,
fontSize: 10,
rotate: 30,
},
axisLine: { lineStyle: { color: CHART_THEME.border } },
},
yAxis: {
type: "value",
name: "AVG FPS",
nameTextStyle: { color: CHART_THEME.textMuted, fontSize: 10 },
axisLabel: { color: CHART_THEME.textMuted, fontSize: 10 },
splitLine: { lineStyle: { color: CHART_THEME.border, opacity: 0.3 } },
},
series,
}
}, [data])
if (data.length === 0) return null
return <EChartWrapper option={option} height={280} className={className} />
}
@@ -0,0 +1,350 @@
"use client"
import { useState, useCallback } from "react"
import Image from "next/image"
import {
ThumbsUpIcon,
ReplyIcon,
MoreHorizontalIcon,
TrashIcon,
ChevronDownIcon,
ChevronUpIcon,
} from "lucide-react"
import { useSession } from "@/lib/auth-client"
import { TiptapRenderer } from "@/components/tiptap-renderer"
import { TiptapEditor } from "@/components/tiptap-editor"
const MAX_DEPTH = 3
export interface CommentData {
id: string
gameId: string
userId: string
parentId: string | null
content: Record<string, unknown>
upvotes: number
createdAt: string
updatedAt: string
userName: string | null
userImage: string | null
}
function formatDate(value: string | null | undefined): string {
if (!value) return ""
return new Date(value).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
})
}
function getInitial(name: string | null | undefined): string {
return name?.charAt(0)?.toUpperCase() || "?"
}
interface CommentItemProps {
comment: CommentData
depth?: number
onReplyPosted: () => void
gameId: string
}
export function CommentItem({
comment,
depth = 0,
onReplyPosted,
gameId,
}: CommentItemProps) {
const { data: session } = useSession()
const [upvotes, setUpvotes] = useState(comment.upvotes)
const [hasUpvoted, setHasUpvoted] = useState(false)
const [isReplying, setIsReplying] = useState(false)
const [replyContent, setReplyContent] = useState<Record<string, unknown> | null>(null)
const [replySubmitting, setReplySubmitting] = useState(false)
const [showReplies, setShowReplies] = useState(false)
const [replies, setReplies] = useState<CommentData[]>([])
const [loadingReplies, setLoadingReplies] = useState(false)
const [menuOpen, setMenuOpen] = useState(false)
const [isDeleted, setIsDeleted] = useState(false)
const isOwner = session?.user?.id === comment.userId
const isAdmin = session?.user?.role === "admin"
const canModerate = isOwner || isAdmin
const handleUpvote = useCallback(async () => {
if (!session) return
try {
const res = await fetch(
`/api/games/${gameId}/comments/${comment.id}/upvote`,
{ method: "POST" },
)
if (res.ok) {
setUpvotes((prev) => (hasUpvoted ? prev - 1 : prev + 1))
setHasUpvoted((prev) => !prev)
}
} catch (err) {
console.error("Failed to upvote comment:", err)
}
}, [session, gameId, comment.id, hasUpvoted])
const handleReplySubmit = useCallback(async () => {
if (!replyContent || !session) return
setReplySubmitting(true)
try {
const res = await fetch(`/api/games/${gameId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parentId: comment.id, content: replyContent }),
})
if (res.ok) {
setIsReplying(false)
setReplyContent(null)
onReplyPosted()
if (showReplies) {
// Refresh replies
setLoadingReplies(true)
const repliesRes = await fetch(
`/api/games/${gameId}/comments/${comment.id}/replies`,
)
if (repliesRes.ok) {
const data = (await repliesRes.json()) as CommentData[]
setReplies(data)
}
setLoadingReplies(false)
}
}
} catch (err) {
console.error("Failed to post reply:", err)
} finally {
setReplySubmitting(false)
}
}, [replyContent, session, gameId, comment.id, onReplyPosted, showReplies])
const handleDelete = useCallback(async () => {
if (!canModerate) return
try {
const res = await fetch(
`/api/games/${gameId}/comments/${comment.id}`,
{ method: "DELETE" },
)
if (res.ok) {
setIsDeleted(true)
}
} catch (err) {
console.error("Failed to delete comment:", err)
}
}, [canModerate, gameId, comment.id])
const handleLoadReplies = useCallback(async () => {
if (showReplies) {
setShowReplies(false)
return
}
setLoadingReplies(true)
try {
const res = await fetch(
`/api/games/${gameId}/comments/${comment.id}/replies`,
)
if (res.ok) {
const data = (await res.json()) as CommentData[]
setReplies(data)
setShowReplies(true)
}
} catch (err) {
console.error("Failed to load replies:", err)
} finally {
setLoadingReplies(false)
}
}, [showReplies, gameId, comment.id])
if (isDeleted) {
return (
<div className="py-3 text-sm text-text/40 italic">
Comment removed
</div>
)
}
return (
<div className={depth > 0 ? "ml-4 border-l border-border pl-3" : ""}>
<div className="flex gap-3 py-3">
{/* Avatar */}
<div className="shrink-0">
{comment.userImage ? (
<Image
src={comment.userImage}
alt={comment.userName || "User"}
width={36}
height={36}
className="h-9 w-9 rounded-full object-cover"
unoptimized
/>
) : (
<div className="h-9 w-9 rounded-full bg-text/10 flex items-center justify-center text-sm font-medium text-text/70">
{getInitial(comment.userName)}
</div>
)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-text">
{comment.userName || "Unknown"}
</span>
<span className="text-xs text-text/40">
{formatDate(comment.createdAt)}
</span>
</div>
<div className="mt-1">
<TiptapRenderer content={JSON.stringify(comment.content)} />
</div>
{/* Actions */}
<div className="flex items-center gap-4 mt-2">
<button
onClick={handleUpvote}
className={`flex items-center gap-1 text-xs transition-colors cursor-pointer ${
hasUpvoted
? "text-primary"
: "text-text/50 hover:text-text/80"
}`}
title="Upvote"
>
<ThumbsUpIcon className="h-3.5 w-3.5" />
<span>{upvotes}</span>
</button>
{session && depth < MAX_DEPTH && (
<button
onClick={() => setIsReplying((prev) => !prev)}
className="flex items-center gap-1 text-xs text-text/50 hover:text-text/80 transition-colors cursor-pointer"
title="Reply"
>
<ReplyIcon className="h-3.5 w-3.5" />
<span>Reply</span>
</button>
)}
{canModerate && (
<div className="relative">
<button
onClick={() => setMenuOpen((prev) => !prev)}
className="flex items-center gap-1 text-xs text-text/50 hover:text-text/80 transition-colors cursor-pointer"
title="More options"
>
<MoreHorizontalIcon className="h-3.5 w-3.5" />
</button>
{menuOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setMenuOpen(false)}
/>
<div className="absolute right-0 z-20 mt-1 w-32 rounded-md border border-border bg-background shadow-lg overflow-hidden">
<button
onClick={() => {
setMenuOpen(false)
handleDelete()
}}
className="w-full flex items-center gap-2 px-3 py-2 text-xs text-red-400 hover:bg-red-500/10 transition-colors cursor-pointer"
>
<TrashIcon className="h-3.5 w-3.5" />
Delete
</button>
</div>
</>
)}
</div>
)}
</div>
{/* Reply form */}
{isReplying && (
<div className="mt-3 flex flex-col gap-2">
<TiptapEditor
placeholder="Write a reply..."
onChange={(json) => setReplyContent(json)}
className="min-h-[100px]"
/>
<div className="flex items-center gap-2">
<button
onClick={handleReplySubmit}
disabled={!replyContent || replySubmitting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium bg-primary text-white hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{replySubmitting ? "Posting..." : "Post Reply"}
</button>
<button
onClick={() => {
setIsReplying(false)
setReplyContent(null)
}}
className="px-3 py-1.5 rounded-md text-xs font-medium bg-text/5 text-text/70 hover:bg-text/10 transition-colors cursor-pointer"
>
Cancel
</button>
</div>
</div>
)}
{/* Load replies */}
{depth < MAX_DEPTH && (
<div className="mt-2">
{replies.length > 0 && !showReplies && (
<button
onClick={() => setShowReplies(true)}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
<ChevronDownIcon className="h-3.5 w-3.5" />
Show {replies.length} replies
</button>
)}
{showReplies && replies.length > 0 && (
<button
onClick={() => setShowReplies(false)}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer"
>
<ChevronUpIcon className="h-3.5 w-3.5" />
Hide replies
</button>
)}
{replies.length === 0 && !showReplies && (
<button
onClick={handleLoadReplies}
disabled={loadingReplies}
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer disabled:opacity-50"
>
{loadingReplies ? (
"Loading..."
) : (
<>
<ChevronDownIcon className="h-3.5 w-3.5" />
Load replies
</>
)}
</button>
)}
</div>
)}
{/* Replies list */}
{depth < MAX_DEPTH && showReplies && replies.length > 0 && (
<div className="mt-2">
{replies.map((reply) => (
<CommentItem
key={reply.id}
comment={reply}
depth={depth + 1}
onReplyPosted={onReplyPosted}
gameId={gameId}
/>
))}
</div>
)}
</div>
</div>
</div>
)
}
@@ -0,0 +1,217 @@
"use client"
import { useState, useCallback, useEffect } from "react"
import Link from "next/link"
import { MessageSquareIcon, Loader2 } from "lucide-react"
import { useSession } from "@/lib/auth-client"
import { TiptapEditor } from "@/components/tiptap-editor"
import { CommentItem, CommentData } from "./comment-item"
interface CommentSectionProps {
gameId: string
initialCount: number
}
interface CommentsApiResponse {
data: CommentData[]
total: number
limit: number
offset: number
}
export function CommentSection({ gameId, initialCount }: CommentSectionProps) {
const { data: session } = useSession()
const [mounted, setMounted] = useState(false)
const [comments, setComments] = useState<CommentData[]>([])
const [total, setTotal] = useState(initialCount)
const [offset, setOffset] = useState(0)
const [loading, setLoading] = useState(true)
const [commentContent, setCommentContent] = useState<Record<string, unknown> | null>(null)
const [submitting, setSubmitting] = useState(false)
const limit = 20
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setMounted(true)
}, [])
// Initial load
useEffect(() => {
let cancelled = false
async function fetchInitial() {
setLoading(true)
try {
const res = await fetch(
`/api/games/${gameId}/comments?limit=${limit}&offset=0`,
)
if (!cancelled && res.ok) {
const json = (await res.json()) as CommentsApiResponse
setComments(json.data)
setTotal(json.total)
}
} catch (err) {
console.error("Failed to load comments:", err)
} finally {
if (!cancelled) setLoading(false)
}
}
fetchInitial()
return () => {
cancelled = true
}
}, [gameId])
const refreshComments = useCallback(async () => {
try {
const res = await fetch(
`/api/games/${gameId}/comments?limit=${limit}&offset=0`,
)
if (res.ok) {
const json = (await res.json()) as CommentsApiResponse
setComments(json.data)
setTotal(json.total)
}
} catch (err) {
console.error("Failed to refresh comments:", err)
}
}, [gameId])
const handleSubmit = useCallback(async () => {
if (!commentContent || !session) return
setSubmitting(true)
try {
const res = await fetch(`/api/games/${gameId}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: commentContent }),
})
if (res.ok) {
setCommentContent(null)
await refreshComments()
}
} catch (err) {
console.error("Failed to post comment:", err)
} finally {
setSubmitting(false)
}
}, [commentContent, session, gameId, refreshComments])
const handleLoadMore = useCallback(async () => {
const newOffset = offset + limit
setLoading(true)
try {
const res = await fetch(
`/api/games/${gameId}/comments?limit=${limit}&offset=${newOffset}`,
)
if (res.ok) {
const json = (await res.json()) as CommentsApiResponse
setComments((prev) => [...prev, ...json.data])
setTotal(json.total)
setOffset(newOffset)
}
} catch (err) {
console.error("Failed to load more comments:", err)
} finally {
setLoading(false)
}
}, [offset, gameId])
const handleReplyPosted = useCallback(() => {
refreshComments()
}, [refreshComments])
const hasMore = comments.length < total
return (
<div className="flex flex-col gap-4">
{/* Header */}
<div className="flex items-center gap-2">
<MessageSquareIcon className="h-5 w-5 text-text/70" />
<h2 className="text-lg font-semibold text-text">Comments</h2>
<span className="text-sm text-text/50">({total})</span>
</div>
{/* Compose — suppress until mounted to avoid hydration mismatch */}
{mounted && session ? (
<div className="flex flex-col gap-2">
<TiptapEditor
placeholder="Leave a comment..."
onChange={(json) => setCommentContent(json)}
/>
<div className="flex justify-end">
<button
onClick={handleSubmit}
disabled={!commentContent || submitting}
className="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium bg-primary text-white hover:bg-primary/90 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
>
{submitting ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Posting...
</>
) : (
"Post Comment"
)}
</button>
</div>
</div>
) : mounted ? (
<div className="p-4 rounded-lg border border-border bg-text/3 text-center">
<p className="text-sm text-text/70">
<Link
href="/auth/sign-in"
className="text-primary hover:text-primary/80 transition-colors"
>
Sign in
</Link>{" "}
to leave a comment
</p>
</div>
) : (
<div className="h-[72px] rounded-lg border border-border bg-text/[0.02] animate-pulse" />
)}
{/* Comment list */}
<div className="flex flex-col">
{loading && comments.length === 0 ? (
<div className="py-8 text-center text-sm text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto mb-2" />
Loading comments...
</div>
) : comments.length === 0 ? (
<div className="py-8 text-center text-sm text-text/50">
No comments yet. Be the first to share your thoughts!
</div>
) : (
comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
depth={0}
onReplyPosted={handleReplyPosted}
gameId={gameId}
/>
))
)}
</div>
{/* Load more */}
{hasMore && !loading && (
<div className="flex justify-center">
<button
onClick={handleLoadMore}
className="px-4 py-2 rounded-lg text-sm font-medium bg-text/5 text-text/70 hover:bg-text/10 transition-colors cursor-pointer"
>
Load more comments
</button>
</div>
)}
{loading && comments.length > 0 && (
<div className="py-4 text-center text-sm text-text/50">
<Loader2 className="h-5 w-5 animate-spin mx-auto" />
</div>
)}
</div>
)
}
@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import { MessageSquarePlus, Send, X } from "lucide-react";
import { cn } from "@/lib/utils";
interface CommunitySuggestionFormProps {
gameId: string;
gameTitle: string;
editableFields: Array<{
name: string;
label: string;
currentValue: string;
}>;
className?: string;
}
export function CommunitySuggestionForm({
gameId,
gameTitle,
editableFields,
className,
}: CommunitySuggestionFormProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedField, setSelectedField] = useState("");
const [proposedValue, setProposedValue] = useState("");
const [reason, setReason] = useState("");
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
if (!selectedField || !proposedValue.trim()) return;
setSubmitting(true);
setError(null);
try {
const res = await fetch("/api/community-suggestions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
gameId,
fieldName: selectedField,
proposedValue: proposedValue.trim(),
reason: reason.trim() || undefined,
}),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Failed to submit suggestion");
}
setSuccess(true);
setTimeout(() => {
setIsOpen(false);
setSuccess(false);
setSelectedField("");
setProposedValue("");
setReason("");
}, 2000);
} catch (err) {
setError(err instanceof Error ? err.message : "Unknown error");
} finally {
setSubmitting(false);
}
};
if (!isOpen) {
return (
<button
onClick={() => setIsOpen(true)}
className={cn(
"flex items-center gap-2 rounded-lg border border-zinc-700 px-3 py-2 text-sm text-zinc-300 hover:bg-zinc-800",
className
)}
>
<MessageSquarePlus className="h-4 w-4" />
Suggest Edit
</button>
);
}
return (
<div className={cn("rounded-lg border border-zinc-700 bg-zinc-900 p-4", className)}>
<div className="mb-4 flex items-center justify-between">
<h4 className="font-medium">Suggest an Edit for {gameTitle}</h4>
<button onClick={() => setIsOpen(false)} className="text-zinc-400 hover:text-zinc-200">
<X className="h-4 w-4" />
</button>
</div>
{success ? (
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-400">
Suggestion submitted! A moderator will review it.
</div>
) : (
<div className="space-y-3">
<div>
<label className="mb-1 block text-sm text-zinc-400">Field</label>
<select
value={selectedField}
onChange={(e) => {
setSelectedField(e.target.value);
const field = editableFields.find((f) => f.name === e.target.value);
setProposedValue(field?.currentValue || "");
}}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm"
>
<option value="">Select a field...</option>
{editableFields.map((field) => (
<option key={field.name} value={field.name}>
{field.label}
</option>
))}
</select>
</div>
{selectedField && (
<>
<div>
<label className="mb-1 block text-sm text-zinc-400">Proposed Value</label>
<textarea
value={proposedValue}
onChange={(e) => setProposedValue(e.target.value)}
rows={3}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm"
placeholder="Enter the corrected value..."
/>
</div>
<div>
<label className="mb-1 block text-sm text-zinc-400">Reason (optional)</label>
<input
value={reason}
onChange={(e) => setReason(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-3 py-2 text-sm"
placeholder="Why should this be changed?"
/>
</div>
</>
)}
{error && (
<p className="text-sm text-red-400">{error}</p>
)}
<button
onClick={handleSubmit}
disabled={!selectedField || !proposedValue.trim() || submitting}
className="flex items-center gap-2 rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50"
>
<Send className="h-4 w-4" />
{submitting ? "Submitting..." : "Submit Suggestion"}
</button>
</div>
)}
</div>
);
}
@@ -0,0 +1,60 @@
"use client"
import { EChartWrapper } from "@/components/charts/EChartWrapper"
interface GameWithStats {
id: string
title: string
stats: {
avgFps: number | null
avgOnePercentLow: number | null
deviceBreakdown: Array<{ hardwareSlug: string; count: number; avgFps: number }>
}
}
export function FpsComparisonChart({ games }: { games: GameWithStats[] }) {
if (games.length === 0) return null
// Collect all unique devices across games
const allDevices = [...new Set(games.flatMap(g => g.stats.deviceBreakdown.map(d => d.hardwareSlug)))]
const colors = ["#3b82f6", "#22c55e", "#f59e0b", "#ef4444"]
// Build series: one series per game, data points per device
const series = games.map((game, idx) => ({
name: game.title,
type: "bar" as const,
data: allDevices.map(slug => {
const device = game.stats.deviceBreakdown.find(d => d.hardwareSlug === slug)
return device ? device.avgFps : null
}),
itemStyle: { color: colors[idx % colors.length] },
barGap: "10%",
}))
const option = {
tooltip: { trigger: "axis" as const, axisPointer: { type: "shadow" as const } },
legend: {
data: games.map(g => g.title),
textStyle: { color: "#999" },
top: 0,
},
grid: { left: 60, right: 20, top: 40, bottom: 30 },
xAxis: {
type: "category" as const,
data: allDevices.map(s => s.replace(/-/g, " ")),
axisLine: { lineStyle: { color: "#555" } },
axisLabel: { color: "#999" },
},
yAxis: {
type: "value" as const,
name: "Avg FPS",
nameTextStyle: { color: "#999" },
splitLine: { lineStyle: { color: "#333" } },
axisLabel: { color: "#999" },
},
series,
}
return <EChartWrapper option={option} height={300} />
}
@@ -0,0 +1,166 @@
"use client"
import { useState, useEffect, useRef } from "react"
import Image from "next/image"
import { SearchIcon, XIcon, Gamepad2Icon } from "lucide-react"
import { useDebounce } from "@/lib/hooks/useDebounce"
interface SearchResult {
id: string
appId: number | null
title: string
image: string | null
source: string
}
interface GameSelectorProps {
selectedGames: SearchResult[]
onSelect: (game: SearchResult) => void
onRemove: (gameId: string) => void
maxSelections?: number
}
export function GameSelector({ selectedGames, onSelect, onRemove, maxSelections = 4 }: GameSelectorProps) {
const [query, setQuery] = useState("")
const [results, setResults] = useState<SearchResult[]>([])
const [loading, setLoading] = useState(false)
const [open, setOpen] = useState(false)
const wrapperRef = useRef<HTMLDivElement>(null)
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (debouncedQuery.length < 2) {
return
}
let cancelled = false
async function fetchResults() {
setLoading(true)
try {
const res = await fetch(`/api/search/unified?q=${encodeURIComponent(debouncedQuery)}`)
if (!res.ok) throw new Error("Search failed")
const data = await res.json() as { results: Array<{ id?: string; appId?: number; title: string; image: string | null; source: string }> }
if (!cancelled) {
setResults(
(data.results || [])
.filter((r) => !selectedGames.some(sg => sg.id === (r.id || `steam-${r.appId}`)))
.slice(0, 8)
.map((r) => ({
id: r.id || `steam-${r.appId}`,
appId: r.appId ?? null,
title: r.title,
image: r.image,
source: r.source,
}))
)
}
} catch {
if (!cancelled) setResults([])
} finally {
if (!cancelled) setLoading(false)
}
}
fetchResults()
return () => {
cancelled = true
}
}, [debouncedQuery, selectedGames])
// Close dropdown on outside click
useEffect(() => {
function handleClick(e: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener("mousedown", handleClick)
return () => document.removeEventListener("mousedown", handleClick)
}, [])
const canAdd = selectedGames.length < maxSelections
return (
<div className="flex flex-col gap-3" ref={wrapperRef}>
{/* Selected games chips */}
{selectedGames.length > 0 && (
<div className="flex flex-wrap gap-2">
{selectedGames.map(game => (
<div
key={game.id}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg border border-primary/30 bg-primary/5 text-sm"
>
{game.image ? (
<Image src={game.image} alt={game.title} width={20} height={30} className="rounded" />
) : (
<Gamepad2Icon className="h-4 w-4 text-text/30" />
)}
<span className="text-text/80 max-w-40 truncate">{game.title}</span>
<button
onClick={() => onRemove(game.id)}
className="text-text/40 hover:text-text/80 transition-colors cursor-pointer"
>
<XIcon className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
{/* Search input */}
{canAdd && (
<div className="relative">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-border bg-text/5">
<SearchIcon className="h-4 w-4 text-text/40" />
<input
type="text"
value={query}
onChange={e => { setQuery(e.target.value); setOpen(true) }}
onFocus={() => setOpen(true)}
placeholder="Search for games to compare..."
className="flex-1 bg-transparent text-sm text-text outline-none placeholder:text-text/40"
/>
</div>
{/* Dropdown results */}
{open && (debouncedQuery.length >= 2) && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 rounded-lg border border-border bg-background shadow-lg max-h-64 overflow-y-auto">
{loading && (
<div className="px-4 py-3 text-xs text-text/40">Searching...</div>
)}
{!loading && results.length === 0 && (
<div className="px-4 py-3 text-xs text-text/40">No results found</div>
)}
{!loading && results.map(game => (
<button
key={game.id}
onClick={() => {
onSelect(game)
setQuery("")
setOpen(false)
}}
className="w-full flex items-center gap-3 px-4 py-2 hover:bg-text/5 transition-colors cursor-pointer text-left"
>
{game.image ? (
<Image src={game.image} alt={game.title} width={24} height={36} className="rounded" />
) : (
<Gamepad2Icon className="h-4 w-4 text-text/30" />
)}
<span className="text-sm text-text/80 truncate">{game.title}</span>
{game.source !== "steam" && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-400 capitalize ml-auto">
{game.source}
</span>
)}
</button>
))}
</div>
)}
</div>
)}
{!canAdd && (
<p className="text-xs text-text/40">Maximum {maxSelections} games can be compared</p>
)}
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More