merge: convert to bun workspaces monorepo
This commit is contained in:
@@ -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=""
|
||||
@@ -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 (1–2 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
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Suspense } from "react"
|
||||
import SignupWizard from "@/components/auth/signup-wizard"
|
||||
|
||||
export default function SignupPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<SignupWizard />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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([])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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" />
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'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 }
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 A–Z" },
|
||||
{ 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
@@ -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'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're looking for hasn'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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 "{query}"...
|
||||
</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 "{query}"
|
||||
</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'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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 > 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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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't have an account?{" "}
|
||||
<Link
|
||||
href="/signup"
|
||||
className="text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
Create one
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user