From 1f3e8b92019346a81051f8e9671d47f97a9ab299 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 26 Apr 2026 15:52:47 +0800 Subject: [PATCH] feat: rewrite game page with full stats dashboard and presets --- app/game/[id]/game-page-client.tsx | 1164 ++++++++++++++++++++-------- 1 file changed, 823 insertions(+), 341 deletions(-) diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index da9fe77..b28c8f9 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -1,385 +1,867 @@ "use client" -import { useCallback, useState } from "react" +import { useCallback, useState, useEffect, useMemo } from "react" import Image from "next/image" import { - Gamepad2Icon, - MessageSquareIcon, - SettingsIcon, - TrendingUpIcon, - ExternalLinkIcon, - ClockIcon, + Gamepad2Icon, + MessageSquareIcon, + SettingsIcon, + TrendingUpIcon, + ExternalLinkIcon, + ClockIcon, + SparklesIcon, + DatabaseIcon, } from "lucide-react" +import { FaSteam } from "react-icons/fa" +import { motion } from "motion/react" +// Chart imports +import { HistoricalAreaChart } from "@/components/charts/HistoricalAreaChart" +import { UpscalerBarChart } from "@/components/charts/UpscalerBarChart" +import { FpsBoxplot } from "@/components/charts/FpsBoxplot" +import { FpsRangeChart } from "@/components/charts/FpsRangeChart" +import { DeviceDonut } from "@/components/charts/DeviceDonut" +import { TrustBar } from "@/components/charts/TrustBar" + +// Types 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: string - lastSync: string | null - syncStatus: string | null - createdAt: string + 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: string + lastSync: string | null + syncStatus: string | null + createdAt: string + metascore?: number | null + onlineMultiplayerStatus?: string | null } interface Counts { - benchmarks: number - presets: number - comments: number + benchmarks: number + presets: number + comments: number } interface PlatformSupport { - id: string - gameId: string - hardwareSlug: string - isSupported: boolean - protonStatus: string + id: string + gameId: string + hardwareSlug: string + isSupported: boolean + protonStatus: string } -const TABS = [ - { key: "overview", label: "Overview", icon: Gamepad2Icon }, - { key: "benchmarks", label: "Benchmarks", icon: TrendingUpIcon }, - { key: "presets", label: "Presets", icon: SettingsIcon }, - { key: "comments", label: "Comments", icon: MessageSquareIcon }, -] as const +interface Preset { + id: string + name: string + description: string | null + hardwareSlug: string + hardwareName: string + upvotes: number + settingsCount: number + fpsAvg: number | null + fpsLow: number | null + fpsHigh: number | null + fsrVersion: string | null + frameGenMethod: string | null + protonVersion: string | null + osVersion: string | null + createdAt: string +} -type TabKey = (typeof TABS)[number]["key"] +interface StatsResponse { + summary: { + totalEntries: number + avgFps: number + bestDevice: string + verifiedCount: number + versionCount: number + } + isRawPerformer: boolean + boxplot: Array<{ + hardwareSlug: string + hardwareName: string + min: number + q1: number + median: number + q3: number + max: number + }> + historical: Array<{ + period: string + entries: Array<{ + hardwareSlug: string + avgFps: number + count: number + }> + }> + upscalerStats: Array<{ + fsrVersion: string + frameGenMethod: string + hardwareSlug: string + avgFps: number + count: number + }> + fpsRange: Array<{ + id: string + hardwareSlug: string + fpsLow: number + fpsAvg: number + fpsHigh: number + isRawPerformer: boolean + }> + deviceBreakdown: Array<{ + hardwareSlug: string + hardwareName: string + count: number + }> + trust: Array<{ + id: string + hardwareSlug: string + upvotes: number + downvotes: number + verifiedAt: string | null + userNotes: string | null + createdAt: string + }> + filterOptions: { + protonVersions: string[] + osVersions: string[] + } +} + +interface Props { + game: Game + counts: Counts + platformSupport: PlatformSupport[] + presets: Preset[] + gameId: string +} + +const FSR_OPTIONS = ["Any", "None", "FSR1", "FSR2", "FSR3"] +const FRAMEGEN_OPTIONS = ["Any", "None", "FSR FG", "DLSS FG"] + +function formatDate(value: string | null): string { + if (!value) return "—" + return new Date(value).toLocaleDateString() +} + +function isRawPerformerPreset(preset: Preset): boolean { + return ( + preset.fpsAvg !== null && + preset.fpsAvg >= 60 && + preset.fsrVersion === "none" && + preset.frameGenMethod === "none" + ) +} + +function getFpsColor(preset: Preset): string { + if (preset.fpsAvg === null) return "text-text/60" + if (isRawPerformerPreset(preset)) return "text-green-400" + if (preset.frameGenMethod && preset.frameGenMethod !== "none") { + if (preset.frameGenMethod === "dlss_fg") return "text-blue-400" + return "text-orange-400" + } + if (preset.fsrVersion && preset.fsrVersion !== "none") return "text-orange-400" + return "text-text/60" +} export function GamePageClient({ - game, - counts, - platformSupport, -}: { - game: Game - counts: Counts - platformSupport: PlatformSupport[] -}) { - const [activeTab, setActiveTab] = useState("overview") - const [imgError, setImgError] = useState(false) - const handleImgError = useCallback(() => setImgError(true), []) + game, + counts, + platformSupport, + presets, + gameId, +}: Props) { + const [imgError, setImgError] = useState(false) + const [stats, setStats] = useState(null) + const [selectedDevices, setSelectedDevices] = useState([]) + const [filters, setFilters] = useState({ + proton: "all", + os: "all", + fsr: "all", + frameGen: "all", + }) + const [loading, setLoading] = useState(true) - const headerImage = game.capsuleImage || game.headerImage + const handleImgError = useCallback(() => setImgError(true), []) - return ( -
- {/* Hero */} -
- {headerImage && !imgError ? ( - {game.title} - ) : ( -
- -
- )} -
+ // Fetch stats on mount + useEffect(() => { + let cancelled = false + async function fetchStats() { + setLoading(true) + try { + const res = await fetch(`/api/games/${gameId}/stats`) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + const data = await res.json() + if (!cancelled) setStats(data) + } catch (err) { + console.error("Failed to fetch stats:", err) + } finally { + if (!cancelled) setLoading(false) + } + } + fetchStats() + return () => { + cancelled = true + } + }, [gameId]) -
-
-

- {game.title} -

-
- {game.developer && {game.developer}} - {game.developer && game.publisher && ( - - )} - {game.publisher && {game.publisher}} - {game.genres && game.genres.length > 0 && ( - <> - - - {game.genres.join(", ")} - - - )} -
-
-
-
+ // Initialize selected devices when stats load + useEffect(() => { + if (stats && selectedDevices.length === 0) { + setSelectedDevices(stats.deviceBreakdown.map((d) => d.hardwareSlug)) + } + }, [stats, selectedDevices.length]) - {/* Stats bar */} -
-
- - - - {game.storeUrl && ( - - - Store - - )} -
-
+ // Filtered stats + const filteredStats = useMemo(() => { + if (!stats) return null - {/* Tabs */} -
-
-
- {TABS.map((tab) => { - const Icon = tab.icon - const isActive = activeTab === tab.key - return ( - - ) - })} -
+ const deviceSet = new Set(selectedDevices) - {/* Tab content */} -
- {activeTab === "overview" && ( - - )} - {activeTab === "benchmarks" && ( - - )} - {activeTab === "presets" && ( - - )} - {activeTab === "comments" && ( - - )} -
-
-
-
+ const filterByDevice = (arr: T[]) => + arr.filter((item) => deviceSet.has(item.hardwareSlug)) + + const filterUpscaler = (arr: StatsResponse["upscalerStats"]) => + arr.filter((item) => { + if (!deviceSet.has(item.hardwareSlug)) return false + if (filters.fsr !== "all" && item.fsrVersion !== filters.fsr) return false + if (filters.frameGen !== "all" && item.frameGenMethod !== filters.frameGen) + return false + return true + }) + + return { + ...stats, + boxplot: filterByDevice(stats.boxplot), + historical: stats.historical + .map((h) => ({ + ...h, + entries: h.entries.filter((e) => deviceSet.has(e.hardwareSlug)), + })) + .filter((h) => h.entries.length > 0), + upscalerStats: filterUpscaler(stats.upscalerStats), + fpsRange: filterByDevice(stats.fpsRange), + deviceBreakdown: filterByDevice(stats.deviceBreakdown), + trust: filterByDevice(stats.trust), + } + }, [stats, selectedDevices, filters]) + + // Filtered presets + const filteredPresets = useMemo(() => { + return presets.filter((p) => { + if (selectedDevices.length > 0 && !selectedDevices.includes(p.hardwareSlug)) + return false + if (filters.proton !== "all" && p.protonVersion !== filters.proton) return false + if (filters.os !== "all" && p.osVersion !== filters.os) return false + if (filters.fsr !== "all" && p.fsrVersion !== filters.fsr) return false + if (filters.frameGen !== "all" && p.frameGenMethod !== filters.frameGen) + return false + return true + }) + }, [presets, selectedDevices, filters]) + + const coverImage = game.capsuleImage || game.headerImage + + const protonDbUrl = game.steamAppId + ? `https://www.protondb.com/app/${game.steamAppId}` + : null + const steamDbUrl = game.steamAppId + ? `https://steamdb.info/app/${game.steamAppId}` + : null + + const toggleDevice = useCallback((slug: string) => { + setSelectedDevices((prev) => + prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] ) -} + }, []) -function StatBadge({ - icon: Icon, - value, - label, -}: { - icon: React.ElementType - value: number - label: string -}) { - return ( -
- - {value} - {label} -
- ) -} - -function OverviewTab({ - game, - platformSupport, -}: { - game: Game - platformSupport: PlatformSupport[] -}) { - return ( -
- {game.description && ( -
-

- About -

-

- {game.description} -

-
+ return ( +
+ {/* Section 1: Hero Header */} + +
+ {/* Cover image */} +
+ {coverImage && !imgError ? ( + {game.title} + ) : ( +
+ +
)} +
+ + {/* Info */} +
+ {/* Title row */} +
+

+ {game.title} +

+ {stats?.isRawPerformer && ( + + + Raw Performer + + )} +
+ + {/* Subtitle */} +
+ {game.developer && {game.developer}} + {game.developer && game.publisher && ( + · + )} + {game.publisher && {game.publisher}} + {game.genres && game.genres.length > 0 && ( + <> + · + + {game.genres.slice(0, 3).join(", ")} + + + )} +
+ + {/* Stats badges */} +
+ + + + {game.metascore !== undefined && game.metascore !== null && ( + + ★ {game.metascore} + + )} +
+ + {/* External links */} +
+ {game.storeUrl && ( + + + Steam + + + )} + {protonDbUrl && ( + + ProtonDB + + + )} + {steamDbUrl && ( + + SteamDB + + + )} +
+ + {/* DB stats row */} + {stats && ( +
+ + + {stats.summary.versionCount} versions + + · + + + {stats.summary.verifiedCount} verified + + {game.lastSync && ( + <> + · + + + Last sync {formatDate(game.lastSync)} + + + )} +
+ )} +
+
+
+ + {/* Section 2: Overview */} + +
+ {/* Left: Description */} +
+

+ About +

+ {game.description ? ( +

+ {game.description} +

+ ) : ( +

No description available.

+ )} +
+ + {/* Right: Metadata grid */} +
+

+ Details +

+
+ + {game.steamAppId !== null && ( + + )} + + + {game.onlineMultiplayerStatus && ( + + )} +
{/* Platform Support */} {platformSupport.length > 0 && ( -
-

- Platform Support -

-
- {platformSupport.map((ps) => ( -
- - {ps.hardwareSlug.replace(/-/g, " ")} - -
- - {ps.isSupported - ? "Supported" - : "Unsupported"} - - - {ps.protonStatus} - -
-
- ))} -
-
- )} - - {/* Metadata */} -
-

- Details +
+

+ Platform Support

-
- - {game.steamAppId && ( - - )} - - {game.lastSync && ( - - )} +
+ {platformSupport.map((ps) => ( +
+ + {ps.hardwareSlug.replace(/-/g, " ")} + +
+ + {ps.isSupported ? "Supported" : "Unsupported"} + + + {ps.protonStatus} + +
+
+ ))}
-
+
+ )} +

- ) + + + {/* Section 3: Device Selector + Filters */} + +
+ {/* Device ribbon */} + {stats && stats.deviceBreakdown.length > 0 && ( +
+ {stats.deviceBreakdown.map((device) => { + const active = selectedDevices.includes(device.hardwareSlug) + return ( + + ) + })} +
+ )} + + {/* Fine filters */} +
+ setFilters((f) => ({ ...f, proton: v }))} + /> + setFilters((f) => ({ ...f, os: v }))} + /> + o.toLowerCase())} + onChange={(v) => setFilters((f) => ({ ...f, fsr: v }))} + /> + + o.toLowerCase().replace(" ", "_") + )} + onChange={(v) => setFilters((f) => ({ ...f, frameGen: v }))} + /> +
+
+
+ + {/* Section 4: Statistics Dashboard */} + +
+ {/* Row 1 — Featured */} + {filteredStats && ( +
+
+

+ Historical Performance +

+ {filteredStats.historical.length > 0 ? ( + + ) : ( +
+ No historical data +
+ )} +
+
+ + + +
+
+ )} + + {/* Loading state */} + {loading && ( +
+ + Loading statistics... +
+ )} + + {/* Row 2 — Upscaler Bar */} + {filteredStats && filteredStats.upscalerStats.length > 0 && ( +
+

+ Upscaler Performance +

+ +
+ )} + + {/* Row 3 — 3-column grid */} + {filteredStats && ( +
+ {filteredStats.boxplot.length > 0 && ( +
+

+ FPS Distribution +

+ +
+ )} + {filteredStats.fpsRange.length > 0 && ( +
+

+ FPS Range +

+ +
+ )} + {filteredStats.deviceBreakdown.length > 0 && ( +
+

+ Device Breakdown +

+ +
+ )} +
+ )} + + {/* Row 4 — Trust Bar */} + {filteredStats && filteredStats.trust.length > 0 && ( +
+

+ Community Trust +

+ +
+ )} +
+
+ + {/* Section 5: Community Presets */} + +
+
+

Community Presets

+ + {filteredPresets.length} preset{filteredPresets.length !== 1 ? "s" : ""} + +
+ + {filteredPresets.length === 0 ? ( +
+ +

No presets match the selected filters

+
+ ) : ( +
+ {filteredPresets.map((preset) => { + const raw = isRawPerformerPreset(preset) + const fpsColor = getFpsColor(preset) + return ( +
+ {/* Header */} +
+
+
+

+ {preset.name} +

+ {raw && ( + + + Raw + + )} +
+

+ {preset.hardwareName} +

+
+
+ + {preset.upvotes} +
+
+ + {/* Settings count */} +
+ {preset.settingsCount} settings +
+ + {/* FPS */} + {preset.fpsAvg !== null && ( +
+ {preset.fpsAvg} + avg + {preset.fpsLow !== null && ( + + {" "}({preset.fpsLow}–{preset.fpsHigh}) + + )} +
+ )} + + {/* Technology tags */} +
+ {preset.fsrVersion && preset.fsrVersion !== "none" && ( + + {preset.fsrVersion.toUpperCase()} + + )} + {preset.frameGenMethod && preset.frameGenMethod !== "none" && ( + + {preset.frameGenMethod === "fsr_fg" + ? "FSR FG" + : preset.frameGenMethod === "dlss_fg" + ? "DLSS FG" + : preset.frameGenMethod} + + )} +
+ + {/* Proton + OS */} +
+ {preset.protonVersion && ( + Proton {preset.protonVersion} + )} + {preset.osVersion && ( + SteamOS {preset.osVersion} + )} +
+
+ ) + })} +
+ )} +
+
+
+ ) } -function MetaItem({ - label, - value, - icon: Icon, +function Badge({ + icon: Icon, + value, + label, }: { - label: string - value: string - icon?: React.ElementType + icon: React.ElementType + value: number + label: string }) { - return ( -
- - {label} - - - {Icon && } - {value} - -
- ) + return ( + + + {value} {label} + + ) } -function formatDate(value: string | null): string { - if (!value) return "—" - return new Date(value).toLocaleDateString() +function MetaItem({ label, value }: { label: string; value: string }) { + return ( +
+ + {label} + + {value} +
+ ) } -function BenchmarksTab({ count }: { count: number }) { - if (count === 0) { - return ( -
- -

No benchmarks yet

-
- ) - } - return ( -
- -

- {count} benchmark{count !== 1 ? "s" : ""} — coming soon -

-
- ) +function StatCard({ + label, + value, + icon: Icon, +}: { + label: string + value: string + icon: React.ElementType +}) { + return ( +
+
+ +
+
+ {label} + {value} +
+
+ ) } -function PresetsTab({ count }: { count: number }) { - if (count === 0) { - return ( -
- -

No presets yet

-
- ) - } - return ( -
- -

- {count} preset{count !== 1 ? "s" : ""} — coming soon -

-
- ) -} - -function CommentsTab({ count }: { count: number }) { - if (count === 0) { - return ( -
- -

No comments yet

-
- ) - } - return ( -
- -

- {count} comment{count !== 1 ? "s" : ""} — coming soon -

-
- ) +function FilterSelect({ + label, + value, + options, + onChange, +}: { + label: string + value: string + options: string[] + onChange: (value: string) => void +}) { + return ( +
+ + +
+ ) }