diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index 63402cb..b201f72 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -3,15 +3,15 @@ import { useCallback, useState, useEffect, useMemo, useRef } from "react" import Image from "next/image" import { - Gamepad2Icon, - MessageSquareIcon, - SettingsIcon, - TrendingUpIcon, - ExternalLinkIcon, - ClockIcon, - SparklesIcon, - DatabaseIcon, - Plus, + Gamepad2Icon, + MessageSquareIcon, + SettingsIcon, + TrendingUpIcon, + ExternalLinkIcon, + ClockIcon, + SparklesIcon, + DatabaseIcon, + Plus, } from "lucide-react" import Link from "next/link" import { useSession } from "@/lib/auth-client" @@ -30,857 +30,975 @@ 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 - metascore?: number | null - onlineMultiplayerStatus?: string | null + 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 } 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 + 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 } 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 + summary: { + totalEntries: number + avgFps: number + bestDevice: string + verifiedCount: number + versionCount: 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[] - } + 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 + 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() + 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" - ) + 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" + 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, - presets, - gameId, + game, + counts, + platformSupport, + presets, + gameId, }: Props) { - const { data: session } = useSession() - 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 handleImgError = useCallback(() => setImgError(true), []) - - // 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]) - - // Initialize selected devices when stats load - const didInitDevices = useRef(false) - useEffect(() => { - if (stats && !didInitDevices.current) { - didInitDevices.current = true - const slugs = stats.deviceBreakdown.map((d) => d.hardwareSlug) - queueMicrotask(() => setSelectedDevices(slugs)) - } - }, [stats]) - - // Filtered stats - const filteredStats = useMemo(() => { - if (!stats) return null - - const deviceSet = new Set(selectedDevices) - - 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 + const { data: session } = useSession() + 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", }) - }, [presets, selectedDevices, filters]) + const [loading, setLoading] = useState(true) - const coverImage = game.capsuleImage || game.headerImage + const handleImgError = useCallback(() => setImgError(true), []) - const protonDbUrl = game.steamAppId - ? `https://www.protondb.com/app/${game.steamAppId}` - : null - const steamDbUrl = game.steamAppId - ? `https://steamdb.info/app/${game.steamAppId}` - : null + // 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]) - const toggleDevice = useCallback((slug: string) => { - setSelectedDevices((prev) => - prev.includes(slug) ? prev.filter((s) => s !== slug) : [...prev, slug] - ) - }, []) + // Initialize selected devices when stats load + const didInitDevices = useRef(false) + useEffect(() => { + if (stats && !didInitDevices.current) { + didInitDevices.current = true + const slugs = stats.deviceBreakdown.map((d) => d.hardwareSlug) + queueMicrotask(() => setSelectedDevices(slugs)) + } + }, [stats]) - return ( -
- {/* Section 1: Hero Header */} - -
- {/* Cover image */} -
- {coverImage && !imgError ? ( - {game.title} - ) : ( -
- -
- )} -
+ // Filtered stats + const filteredStats = useMemo(() => { + if (!stats) return null - {/* Info */} -
- {/* Title row */} -
-

- {game.title} -

- {stats?.isRawPerformer && ( - - - Raw Performer - - )} -
+ const deviceSet = new Set(selectedDevices) - {/* 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(", ")} - - - )} -
+ const filterByDevice = (arr: T[]) => + arr.filter((item) => deviceSet.has(item.hardwareSlug)) - {/* Stats badges */} -
- - - - {game.metascore !== undefined && game.metascore !== null && ( - - ★ {game.metascore} - - )} - - {session && ( - - - Add Benchmark - - )} -
- - {/* 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} - -
-
- ))} -
-
- )} -
-
-
- - {/* Section 3: Device Selector + Filters */} - -
- {/* Device ribbon */} - {stats && stats.deviceBreakdown.length > 0 && ( -
- {stats.deviceBreakdown.map((device) => { - const active = selectedDevices.includes(device.hardwareSlug) - return ( - + 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 + }) - {/* 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 }))} - /> -
-
-
+ 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]) - {/* Section 4: Statistics Dashboard */} - -
- {/* Row 1 — Featured */} - {filteredStats && ( -
-
-

- Historical Performance -

- {filteredStats.historical.length > 0 ? ( - - ) : ( -
- No historical data -
- )} -
-
- - - -
-
- )} + // 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]) - {/* Loading state */} - {loading && ( -
- - Loading statistics... -
- )} + const coverImage = game.capsuleImage || game.headerImage - {/* Row 2 — Upscaler Bar */} - {filteredStats && filteredStats.upscalerStats.length > 0 && ( -
-

- Upscaler Performance -

- -
- )} + const protonDbUrl = game.steamAppId + ? `https://www.protondb.com/app/${game.steamAppId}` + : null + const steamDbUrl = game.steamAppId + ? `https://steamdb.info/app/${game.steamAppId}` + : null - {/* Row 3 — 3-column grid */} - {filteredStats && ( -
- {filteredStats.boxplot.length > 0 && ( -
-

- FPS Distribution -

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

- FPS Range -

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

- Device Breakdown -

- -
- )} -
- )} + const toggleDevice = useCallback((slug: string) => { + setSelectedDevices((prev) => + prev.includes(slug) + ? prev.filter((s) => s !== slug) + : [...prev, slug], + ) + }, []) - {/* 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}) - + 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} + + )} + + {session && ( + + + Add Benchmark + + )} +
+ + {/* 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} + +
+
+ ))} +
+
+ )} +
+
+
+ + {/* Section 3: Device Selector + Filters */} + +
+ {/* Device ribbon */} + {stats && stats.deviceBreakdown.length > 0 && ( +
+ {stats.deviceBreakdown.map((device) => { + const active = selectedDevices.includes( + device.hardwareSlug, + ) + return ( + + ) + })} +
)} - {/* Technology tags */} -
- {preset.fsrVersion && preset.fsrVersion !== "none" && ( - - {preset.fsrVersion.toUpperCase()} + {/* 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" : ""} - )} - {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} - )} -
-
- ) - })} -
- )} -
- -
- ) + {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 Badge({ - icon: Icon, - value, - label, + icon: Icon, + value, + label, }: { - icon: React.ElementType - value: number - label: string + icon: React.ElementType + value: number + label: string }) { - return ( - - - {value} {label} - - ) + return ( + + + {value} {label} + + ) } function MetaItem({ label, value }: { label: string; value: string }) { - return ( -
- - {label} - - {value} -
- ) + return ( +
+ + {label} + + {value} +
+ ) } function StatCard({ - label, - value, - icon: Icon, + label, + value, + icon: Icon, }: { - label: string - value: string - icon: React.ElementType + label: string + value: string + icon: React.ElementType }) { - return ( -
-
- -
-
- {label} - {value} -
-
- ) + return ( +
+
+ +
+
+ {label} + + {value} + +
+
+ ) } function FilterSelect({ - label, - value, - options, - onChange, + label, + value, + options, + onChange, }: { - label: string - value: string - options: string[] - onChange: (value: string) => void + label: string + value: string + options: string[] + onChange: (value: string) => void }) { - return ( -
- - -
- ) + return ( +
+ + +
+ ) } diff --git a/app/game/[id]/page.tsx b/app/game/[id]/page.tsx index b241a09..3e9944c 100644 --- a/app/game/[id]/page.tsx +++ b/app/game/[id]/page.tsx @@ -2,257 +2,262 @@ import { notFound } from "next/navigation" import { after } from "next/server" import { db } from "@/lib/db/index" import { - games, - gameVersions, - performanceEntries, - communityPresets, - gameComments, - gamePlatformSupport, - hardware, + games, + gameVersions, + performanceEntries, + communityPresets, + gameComments, + gamePlatformSupport, + hardware, } from "@/lib/db/schema" import { eq, sql } from "drizzle-orm" import { isSyncStale, syncSteamGame } from "@/lib/steam/sync" import { GamePageClient } from "./game-page-client" export const metadata = { - title: "Game", + title: "Game", } async function createGameStub(steamAppId: number) { - const url = new URL("https://store.steampowered.com/api/appdetails/") - url.searchParams.set("appids", String(steamAppId)) - url.searchParams.set("cc", "US") - url.searchParams.set("l", "en") + const url = new URL("https://store.steampowered.com/api/appdetails/") + url.searchParams.set("appids", String(steamAppId)) + url.searchParams.set("cc", "US") + url.searchParams.set("l", "en") - const res = await fetch(url.toString(), { - headers: { Accept: "application/json" }, - }) - - let title = `Steam App ${steamAppId}` - let developer: string | null = null - let publisher: string | null = null - let genres: string[] | null = null - let headerImage: string | null = null - let capsuleImage: string | null = null - let description: string | null = null - - if (res.ok) { - const data = (await res.json()) as Record< - string, - { - success: boolean - data: { - type?: string - name: string - developers?: string[] - publishers?: string[] - genres?: { description: string }[] - header_image?: string - short_description?: string - } - } - > - const entry = data[String(steamAppId)] - if (entry?.success && entry.data) { - if (entry.data.type && entry.data.type !== "game") { - notFound() - } - title = entry.data.name - developer = entry.data.developers?.[0] ?? null - publisher = entry.data.publishers?.[0] ?? null - genres = entry.data.genres?.map((g) => g.description) ?? [] - headerImage = entry.data.header_image ?? null - capsuleImage = `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg` - description = entry.data.short_description ?? null - } - } - - const [game] = await db - .insert(games) - .values({ - steamAppId, - source: "steam", - title, - developer, - publisher, - genres, - headerImage, - capsuleImage, - description, - storeUrl: `https://store.steampowered.com/app/${steamAppId}`, - lastSync: new Date(), - syncStatus: "synced", + const res = await fetch(url.toString(), { + headers: { Accept: "application/json" }, }) - .returning() - return game + let title = `Steam App ${steamAppId}` + let developer: string | null = null + let publisher: string | null = null + let genres: string[] | null = null + let headerImage: string | null = null + let capsuleImage: string | null = null + let description: string | null = null + + if (res.ok) { + const data = (await res.json()) as Record< + string, + { + success: boolean + data: { + type?: string + name: string + developers?: string[] + publishers?: string[] + genres?: { description: string }[] + header_image?: string + short_description?: string + } + } + > + const entry = data[String(steamAppId)] + if (entry?.success && entry.data) { + if (entry.data.type && entry.data.type !== "game") { + notFound() + } + title = entry.data.name + developer = entry.data.developers?.[0] ?? null + publisher = entry.data.publishers?.[0] ?? null + genres = entry.data.genres?.map((g) => g.description) ?? [] + headerImage = entry.data.header_image ?? null + capsuleImage = `https://cdn.akamai.steamstatic.com/steam/apps/${steamAppId}/library_600x900.jpg` + description = entry.data.short_description ?? null + } + } + + const [game] = await db + .insert(games) + .values({ + steamAppId, + source: "steam", + title, + developer, + publisher, + genres, + headerImage, + capsuleImage, + description, + storeUrl: `https://store.steampowered.com/app/${steamAppId}`, + lastSync: new Date(), + syncStatus: "synced", + }) + .returning() + + return game } export default async function GamePage({ - params, - searchParams, + params, + searchParams, }: { - params: Promise<{ id: string }> - searchParams: Promise<{ sync?: string }> + 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" + const { id } = await params + const { sync } = await searchParams + const isNumeric = /^\d+$/.test(id) + const forceSync = sync === "1" - // ── Resolve game ──────────────────────────────────────────────── - 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 && isNumeric) { - try { - game = await createGameStub(Number(id)) - } catch (err) { - console.error("Failed to auto-create game stub:", err) + // ── Resolve game ──────────────────────────────────────────────── + 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() - } + if (!game && isNumeric) { + try { + game = await createGameStub(Number(id)) + } catch (err) { + console.error("Failed to auto-create game stub:", err) + } + } - // ── Fetch related data in parallel ────────────────────────────── - const [benchmarkCount, presetCount, commentCount, platformSupport, presetRows] = - await Promise.all([ - db - .select({ count: sql`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`count(*)::int` }) - .from(communityPresets) - .where(eq(communityPresets.gameId, game.id)) - .then((r) => r[0]?.count ?? 0), - db - .select({ count: sql`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: communityPresets.id, - name: communityPresets.name, - description: communityPresets.description, - hardwareSlug: communityPresets.hardwareSlug, - hardwareName: hardware.name, - upvotes: communityPresets.upvotes, - settingsJson: communityPresets.settingsJson, - createdAt: communityPresets.createdAt, - fpsAvg: performanceEntries.fpsAvg, - fpsLow: performanceEntries.fpsLow, - fpsHigh: performanceEntries.fpsHigh, - fsrVersion: performanceEntries.fsrVersion, - frameGenMethod: performanceEntries.frameGenMethod, - protonVersion: performanceEntries.protonVersion, - osVersion: performanceEntries.osVersion, - }) - .from(communityPresets) - .leftJoin( - performanceEntries, - eq(communityPresets.performanceEntryId, performanceEntries.id), - ) - .innerJoin( - hardware, - eq(communityPresets.hardwareSlug, hardware.slug), - ) - .where(eq(communityPresets.gameId, game.id)) - .orderBy(sql`${communityPresets.upvotes} DESC`), + if (!game) { + notFound() + } + + // ── Fetch related data in parallel ────────────────────────────── + const [ + benchmarkCount, + presetCount, + commentCount, + platformSupport, + presetRows, + ] = await Promise.all([ + db + .select({ count: sql`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`count(*)::int` }) + .from(communityPresets) + .where(eq(communityPresets.gameId, game.id)) + .then((r) => r[0]?.count ?? 0), + db + .select({ count: sql`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: communityPresets.id, + name: communityPresets.name, + description: communityPresets.description, + hardwareSlug: communityPresets.hardwareSlug, + hardwareName: hardware.name, + upvotes: communityPresets.upvotes, + settingsJson: communityPresets.settingsJson, + createdAt: communityPresets.createdAt, + fpsAvg: performanceEntries.fpsAvg, + fpsLow: performanceEntries.fpsLow, + fpsHigh: performanceEntries.fpsHigh, + fsrVersion: performanceEntries.fsrVersion, + frameGenMethod: performanceEntries.frameGenMethod, + protonVersion: performanceEntries.protonVersion, + osVersion: performanceEntries.osVersion, + }) + .from(communityPresets) + .leftJoin( + performanceEntries, + eq(communityPresets.performanceEntryId, performanceEntries.id), + ) + .innerJoin( + hardware, + eq(communityPresets.hardwareSlug, hardware.slug), + ) + .where(eq(communityPresets.gameId, game.id)) + .orderBy(sql`${communityPresets.upvotes} DESC`), ]) - // ── Sync logic: force or stale-while-revalidate ───────────────── - const shouldSync = - game.source === "steam" && - game.steamAppId && - (forceSync || isSyncStale(game.lastSync)) + // ── Sync logic: force or stale-while-revalidate ───────────────── + const shouldSync = + game.source === "steam" && + game.steamAppId && + (forceSync || isSyncStale(game.lastSync)) - if (shouldSync) { - after(async () => { - await syncSteamGame(game.steamAppId!) - }) - } + if (shouldSync) { + 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, - lastSync: game.lastSync ? game.lastSync.toISOString() : null, - syncStatus: game.syncStatus, - createdAt: game.createdAt.toISOString(), - } + // 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, + lastSync: game.lastSync ? game.lastSync.toISOString() : null, + syncStatus: game.syncStatus, + createdAt: game.createdAt.toISOString(), + } - const serializedPresets = presetRows.map((p) => ({ - id: p.id, - name: p.name, - description: p.description, - 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, - fsrVersion: p.fsrVersion, - frameGenMethod: p.frameGenMethod, - protonVersion: p.protonVersion, - osVersion: p.osVersion, - createdAt: p.createdAt.toISOString(), - })) + const serializedPresets = presetRows.map((p) => ({ + id: p.id, + name: p.name, + description: p.description, + 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, + fsrVersion: p.fsrVersion, + frameGenMethod: p.frameGenMethod, + protonVersion: p.protonVersion, + osVersion: p.osVersion, + createdAt: p.createdAt.toISOString(), + })) - return ( - - ) + return ( + + ) }