From bcf2d540b84d21de894556a2a0c08115b2dcc8cc Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 10 May 2026 04:15:38 +0800 Subject: [PATCH] chore: format components, switch to CDN hostname, and improve error handling in game stats - Format long lines and union types in game page, preset modal, games page, and filter drawer - Replace **.r2.dev with cdn.deckyvault.xyz in next.config image hostnames - Replace sql ANY with drizzle inArray for hardware query in game stats - Add try/catch error handler in game stats route - Add post.md to .gitignore --- .gitignore | 5 +- app/game/[id]/game-page-client.tsx | 1982 ++++++++++++++----------- app/game/[id]/preset-detail-modal.tsx | 298 ++-- app/games/games-page-client.tsx | 1556 ++++++++++--------- components/filter-drawer.tsx | 146 +- lib/api/game-stats.ts | 42 +- next.config.ts | 2 +- 7 files changed, 2251 insertions(+), 1780 deletions(-) diff --git a/.gitignore b/.gitignore index 439b764..9aa9f27 100644 --- a/.gitignore +++ b/.gitignore @@ -50,4 +50,7 @@ public/sw.js .worktrees/ .pi/ -.superpowers/ \ No newline at end of file +.superpowers/ + +# temporary files +post.md \ No newline at end of file diff --git a/app/game/[id]/game-page-client.tsx b/app/game/[id]/game-page-client.tsx index 2269962..639bfaa 100644 --- a/app/game/[id]/game-page-client.tsx +++ b/app/game/[id]/game-page-client.tsx @@ -66,7 +66,10 @@ interface Game { createdAt: string metascore?: number | null onlineMultiplayerStatus?: string | null - systemRequirements: { minimum: string | null; recommended: string | null } | null + systemRequirements: { + minimum: string | null + recommended: string | null + } | null metacriticScore: number | null metacriticUrl: string | null recommendationsTotal: number | null @@ -77,7 +80,13 @@ interface Game { releaseDate: string | null categories: string[] | null platforms: { windows: boolean; mac: boolean; linux: boolean } | null - playabilityStatus?: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null + playabilityStatus?: + | "great" + | "playable" + | "needs_tweaks" + | "unplayable" + | "unknown" + | null steamReviewScore?: number | null steamReviewSentiment?: string | null steamReviewCount?: number | null @@ -95,7 +104,13 @@ interface PlatformSupport { hardwareSlug: string isSupported: boolean protonStatus: string - playabilityStatus: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null + playabilityStatus: + | "great" + | "playable" + | "needs_tweaks" + | "unplayable" + | "unknown" + | null antiCheatRelevant: boolean antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null antiCheatName: string | null @@ -125,7 +140,13 @@ interface Preset { estimatedBatteryMin: number | null tdpWatts: number | null youtubeVideoId: string | null - screenshots: Array<{ id: string; url: string; width: number; height: number; orderIndex: number }> | null + screenshots: Array<{ + id: string + url: string + width: number + height: number + orderIndex: number + }> | null customSystem: boolean userNotes: string | null userId: string @@ -292,7 +313,11 @@ function HeroInfo({ steamDbUrl: string | null gameId: string formatDate: (value: string | null) => string - Badge: React.ComponentType<{ icon: React.ElementType; value: number; label: string }> + Badge: React.ComponentType<{ + icon: React.ElementType + value: number + label: string + }> }) { return ( <> @@ -323,23 +348,31 @@ function HeroInfo({ p.antiCheatRelevant)?.antiCheatStatus ?? "unknown" + platformSupport.find((p) => p.antiCheatRelevant) + ?.antiCheatStatus ?? "unknown" } antiCheatName={ - platformSupport.find((p) => p.antiCheatRelevant)?.antiCheatName + platformSupport.find((p) => p.antiCheatRelevant) + ?.antiCheatName } /> )} {/* Steam review score */} {game.steamReviewScore != null && ( - - - + + + {game.steamReviewScore}% Positive {game.steamReviewSentiment && ( - ({game.steamReviewSentiment.replace(/_/g, ' ')}) + + ({game.steamReviewSentiment.replace(/_/g, " ")}) + )} )} @@ -379,12 +412,11 @@ function HeroInfo({ value={counts.comments} label='Comments' /> - {game.metascore !== undefined && - game.metascore !== null && ( - - ★ {game.metascore} - - )} + {game.metascore !== undefined && game.metascore !== null && ( + + ★ {game.metascore} + + )} {/* Metadata pills */} @@ -399,16 +431,28 @@ function HeroInfo({ Free to Play )} - {game.priceCurrent != null && !game.isFree && game.priceCurrency && ( - - {new Intl.NumberFormat("en-US", { style: "currency", currency: game.priceCurrency }).format(game.priceCurrent / 100)} - - )} - {game.priceCurrent != null && !game.isFree && game.priceInitial != null && game.priceInitial > game.priceCurrent && game.priceCurrency && ( - - {new Intl.NumberFormat("en-US", { style: "currency", currency: game.priceCurrency }).format(game.priceInitial / 100)} - - )} + {game.priceCurrent != null && + !game.isFree && + game.priceCurrency && ( + + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: game.priceCurrency, + }).format(game.priceCurrent / 100)} + + )} + {game.priceCurrent != null && + !game.isFree && + game.priceInitial != null && + game.priceInitial > game.priceCurrent && + game.priceCurrency && ( + + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: game.priceCurrency, + }).format(game.priceInitial / 100)} + + )} {game.metacriticScore != null && ( ★ {game.metacriticScore}/100 @@ -421,9 +465,15 @@ function HeroInfo({ )} {game.platforms && ( - {game.platforms.windows && } - {game.platforms.mac && } - {game.platforms.linux && } + {game.platforms.windows && ( + + )} + {game.platforms.mac && ( + + )} + {game.platforms.linux && ( + + )} )} {game.steamAppId !== null && ( @@ -488,8 +538,7 @@ function HeroInfo({ · - Last sync{" "} - {formatDate(game.lastSync)} + Last sync {formatDate(game.lastSync)} )} @@ -497,7 +546,10 @@ function HeroInfo({ )} {stats && (
- + {session?.user && game.source !== "steam" && ( )} @@ -557,9 +629,13 @@ export function GamePageClient({ frameGen: "all", }) const [loading, setLoading] = useState(true) - const [selectedPresetId, setSelectedPresetId] = useState(null) + const [selectedPresetId, setSelectedPresetId] = useState( + null, + ) const [showSystemReq, setShowSystemReq] = useState(true) - const [reportedPresets, setReportedPresets] = useState>(new Set()) + const [reportedPresets, setReportedPresets] = useState>( + new Set(), + ) const presetsRef = useRef(null) // On mount, auto-open preset from URL @@ -590,10 +666,13 @@ export function GamePageClient({ const handleDeletePreset = async (presetId: string) => { try { - const res = await fetch(`/api/performance/${presetId}/user-delete`, { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - }) + const res = await fetch( + `/api/performance/${presetId}/user-delete`, + { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + }, + ) if (res.ok) { setSelectedPresetId(null) router.refresh() @@ -603,7 +682,11 @@ export function GamePageClient({ } } - const handleReportPreset = async (presetId: string, reason: "inaccurate" | "spam" | "inappropriate" | "other", details?: string) => { + const handleReportPreset = async ( + presetId: string, + reason: "inaccurate" | "spam" | "inappropriate" | "other", + details?: string, + ) => { try { const res = await fetch(`/api/performance/${presetId}/report`, { method: "POST", @@ -658,13 +741,19 @@ export function GamePageClient({ const deviceSet = new Set(selectedDevices) - const filterByDevice = (arr: T[] | undefined) => - arr?.filter((item) => deviceSet.has(item.hardwareSlug)) ?? [] + const filterByDevice = ( + arr: T[] | undefined, + ) => arr?.filter((item) => deviceSet.has(item.hardwareSlug)) ?? [] - const filterUpscaler = (arr: StatsResponse["upscalerStats"] | undefined) => + const filterUpscaler = ( + arr: StatsResponse["upscalerStats"] | undefined, + ) => (arr ?? []).filter((item) => { if (!deviceSet.has(item.hardwareSlug)) return false - if (filters.upscaler !== "all" && item.upscalerType !== filters.upscaler) + if ( + filters.upscaler !== "all" && + item.upscalerType !== filters.upscaler + ) return false if ( filters.frameGen !== "all" && @@ -705,7 +794,10 @@ export function GamePageClient({ if (filters.proton !== "all" && p.protonVersion !== filters.proton) return false if (filters.os !== "all" && p.osVersion !== filters.os) return false - if (filters.upscaler !== "all" && p.upscalerType !== filters.upscaler) + if ( + filters.upscaler !== "all" && + p.upscalerType !== filters.upscaler + ) return false if ( filters.frameGen !== "all" && @@ -716,8 +808,14 @@ export function GamePageClient({ }) }, [presets, selectedDevices, filters]) - const pinnedPresets = useMemo(() => filteredPresets.filter((p) => p.isPinned), [filteredPresets]) - const regularPresets = useMemo(() => filteredPresets.filter((p) => !p.isPinned), [filteredPresets]) + const pinnedPresets = useMemo( + () => filteredPresets.filter((p) => p.isPinned), + [filteredPresets], + ) + const regularPresets = useMemo( + () => filteredPresets.filter((p) => !p.isPinned), + [filteredPresets], + ) const coverImage = game.capsuleImage || game.headerImage @@ -738,525 +836,623 @@ export function GamePageClient({ return ( <> -
- {/* Section 1: Hero Header */} - - {/* Mobile hero — full-bleed background */} -
- {coverImage && !imgError ? ( -
- -
-
- ) : ( -
- )} -
- -
-
- - {/* Desktop hero — side-by-side layout */} -
- {/* Cover image */} -
+
+ {/* Section 1: Hero Header */} + + {/* Mobile hero — full-bleed background */} +
{coverImage && !imgError ? ( - {game.title} - ) : ( -
- +
+ +
- )} -
- {/* Info */} -
- -
-
- - - {/* Section 2: Overview */} - -
- {/* Description */} -
-

- About -

- {game.description ? ( -

- {game.description} -

) : ( -

- No description available. -

+
)} - {game.systemRequirements && (game.systemRequirements.minimum || game.systemRequirements.recommended) && ( -
- - {showSystemReq && ( -
- {game.systemRequirements.minimum && ( -
-

Minimum

-
-
- )} - {game.systemRequirements.recommended && ( -
-

Recommended

-
+
+ +
+
+ + {/* Desktop hero — side-by-side layout */} +
+ {/* Cover image */} +
+ {coverImage && !imgError ? ( + {game.title} + ) : ( +
+ +
+ )} +
+ {/* Info */} +
+ +
+
+ + + {/* Section 2: Overview */} + +
+ {/* Description */} +
+

+ About +

+ {game.description ? ( +

+ {game.description} +

+ ) : ( +

+ No description available. +

+ )} + {game.systemRequirements && + (game.systemRequirements.minimum || + game.systemRequirements.recommended) && ( +
+ + {showSystemReq && ( +
+ {game.systemRequirements + .minimum && ( +
+

+ Minimum +

+
+
+ )} + {game.systemRequirements + .recommended && ( +
+

+ Recommended +

+
+
+ )}
)}
)} +
+ + {/* Platforms */} + {game.platforms && ( +
+

+ Platforms +

+
+ + + + + + + + + +
+
+ )} + {platformSupport.length > 0 && ( +
+

+ Platform Support +

+
+ {platformSupport.map((ps) => ( +
+ + {ps.hardwareSlug.replace( + /-/g, + " ", + )} + +
+ + {ps.isSupported + ? "Supported" + : "Unsupported"} + + + {ps.protonStatus} + + + {/* Per-device playability */} + {ps.playabilityStatus && ( + + )} + + {/* Per-device anti-cheat */} + {ps.antiCheatRelevant && ( + + )} +
+
+ ))} +
)}
+ - {/* Platforms */} - {game.platforms && ( -
-

- Platforms -

-
- - - - - - - - - -
-
- )} - {platformSupport.length > 0 && ( -
-

- Platform Support -

-
- {platformSupport.map((ps) => ( -
- - {ps.hardwareSlug.replace( - /-/g, - " ", - )} - -
- - {ps.isSupported - ? "Supported" - : "Unsupported"} + {/* 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, upscaler: v })) + } + /> + + o.toLowerCase().replace(" ", "_"), + )} + onChange={(v) => + setFilters((f) => ({ ...f, frameGen: v })) + } + />
- )} -
- - - {/* 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, upscaler: v })) - } - /> - - o.toLowerCase().replace(" ", "_"), - )} - onChange={(v) => - setFilters((f) => ({ ...f, frameGen: v })) - } - />
-
-
+ - {/* Section 4a: Pinned Presets */} - {pinnedPresets.length > 0 && ( - -
-
-

- 📌 Pinned Presets -

- - {pinnedPresets.length} pinned - -
-
0 && ( + - {pinnedPresets.map((preset) => { - const raw = isRawPerformerPreset(preset) - const fpsColor = getFpsColor(preset) - return ( - handlePresetOpen(preset.id)} - className={`flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${ - raw - ? "border-green-500/30 bg-green-500/5" - : "border-yellow-500/30 bg-yellow-500/5" - }`} - data-gamepad-focusable - > - {/* Header */} -
-
-
-

- {generatePresetName(preset)} -

- {raw && ( - - - Raw +
+
+

+ 📌 Pinned Presets +

+ + {pinnedPresets.length} pinned + +
+
+ {pinnedPresets.map((preset) => { + const raw = isRawPerformerPreset(preset) + const fpsColor = getFpsColor(preset) + return ( + + handlePresetOpen(preset.id) + } + className={`flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${ + raw + ? "border-green-500/30 bg-green-500/5" + : "border-yellow-500/30 bg-yellow-500/5" + }`} + data-gamepad-focusable + > + {/* Header */} +
+
+
+

+ {generatePresetName( + preset, + )} +

+ {raw && ( + + + Raw + + )} + {isPoorPerformancePreset( + preset, + ) && + preset.fpsAvg !== + null && ( + + ⚠ Slow + + )} +
+

+ {preset.hardwareName} +

+
+
+ {preset.youtubeVideoId && ( + + + + )} + + + {preset.upvotes} + + + + {preset.downvotes} + +
+
+ + {/* Settings count */} +
+ {preset.settingsCount} settings +
+ + {/* FPS */} + {preset.fpsAvg !== null && ( +
+ + {preset.fpsAvg} + + + {" "} + avg + + {preset.fpsOnePercentLow !== + null && ( + + {" "} + ·{" "} + { + preset.fpsOnePercentLow + }{" "} + 1% low + + )} + {preset.fpsLow !== null && + preset.fpsHigh !== + null && + !preset.fpsOnePercentLow && ( + + {" "} + ({preset.fpsLow} + – + {preset.fpsHigh} + ) + + )} +
+ )} + + {/* Power / Battery quick-look */} + {(() => { + const dev = + stats?.deviceBreakdown.find( + (d) => + d.hardwareSlug === + preset.hardwareSlug, + ) + const isHandheld = + dev?.deviceType === + "handheld" + const wh = + dev?.wattHours ?? null + const tdp = + preset.tdpWatts ?? null + const estHours = + wh && tdp && tdp > 0 + ? wh / tdp + : null + + if ( + !isHandheld || + (!tdp && !wh) + ) + return null + + return ( +
+ {tdp && ( + + ⚡{" "} + {Math.round( + tdp, + )} + W + + )} + {wh && ( + + 🔋{" "} + {Math.round(wh)} + Wh + + )} + {estHours !== null && ( + + ⏱ ~ + {estHours.toFixed( + 1, + )} + h + + )} +
+ ) + })()} + + {/* Technology tags */} +
+ {preset.upscalerType && + preset.upscalerType !== + "none" && ( + + {preset.upscalerType.toUpperCase()} + {preset.upscalerVersion + ? ` ${preset.upscalerVersion}` + : ""} + + )} + {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} )} - {isPoorPerformancePreset(preset) && preset.fpsAvg !== null && ( - - ⚠ Slow + {preset.osVersion && ( + + {preset.osVersion} )}
-

- {preset.hardwareName} -

-
-
- {preset.youtubeVideoId && ( - - - - )} - - - {preset.upvotes} - - - - {preset.downvotes} - -
-
- - {/* Settings count */} -
- {preset.settingsCount} settings -
- - {/* FPS */} - {preset.fpsAvg !== null && ( -
- - {preset.fpsAvg} - - - {" "} - avg - - {preset.fpsOnePercentLow !== null && ( - - {" "} - · {preset.fpsOnePercentLow} 1% low - - )} - {preset.fpsLow !== null && preset.fpsHigh !== null && !preset.fpsOnePercentLow && ( - - {" "} - ({preset.fpsLow}–{preset.fpsHigh}) - - )} -
- )} - - {/* Power / Battery quick-look */} - {(() => { - const dev = stats?.deviceBreakdown.find((d) => d.hardwareSlug === preset.hardwareSlug) - const isHandheld = dev?.deviceType === "handheld" - const wh = dev?.wattHours ?? null - const tdp = preset.tdpWatts ?? null - const estHours = wh && tdp && tdp > 0 ? wh / tdp : null - - if (!isHandheld || (!tdp && !wh)) return null - - return ( -
- {tdp && ⚡ {Math.round(tdp)}W} - {wh && 🔋 {Math.round(wh)}Wh} - {estHours !== null && ( - ⏱ ~{estHours.toFixed(1)}h - )} -
- ) - })()} - - {/* Technology tags */} -
- {preset.upscalerType && - preset.upscalerType !== - "none" && ( - - {preset.upscalerType.toUpperCase()} - {preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""} - - )} - {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 && ( - - {preset.osVersion} - - )} -
- - ) - })} -
-
- - )} - - {/* Section 4b: Community Presets */} - -
-
-

- Community Presets -

- - {regularPresets.length} preset - {regularPresets.length !== 1 ? "s" : ""} - -
- - {regularPresets.length === 0 ? ( -
- -

- No presets match the selected filters -

+ + ) + })} +
- ) : ( -
+ + )} + + {/* Section 4b: Community Presets */} + +
+
+

+ Community Presets +

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

+ No presets match the selected filters +

+
+ ) : ( +
{regularPresets.map((preset) => { const raw = isRawPerformerPreset(preset) const fpsColor = getFpsColor(preset) return ( handlePresetOpen(preset.id)} + onClick={() => + handlePresetOpen(preset.id) + } className={`flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${ raw ? "border-green-500/30 bg-green-500/5" @@ -1264,357 +1460,453 @@ export function GamePageClient({ }`} data-gamepad-focusable > - {/* Header */} -
-
-
-

- {generatePresetName(preset)} -

- {raw && ( - - - Raw - + {/* Header */} +
+
+
+

+ {generatePresetName( + preset, )} - {isPoorPerformancePreset(preset) && preset.fpsAvg !== null && ( +

+ {raw && ( + + + Raw + + )} + {isPoorPerformancePreset( + preset, + ) && + preset.fpsAvg !== + null && ( ⚠ Slow )} -
-

- {preset.hardwareName} -

-
-
- {preset.youtubeVideoId && ( - - - - )} - - - {preset.upvotes} - - - - {preset.downvotes} -
+

+ {preset.hardwareName} +

- - {/* Settings count */} -
- {preset.settingsCount} settings +
+ {preset.youtubeVideoId && ( + + + + )} + + + {preset.upvotes} + + + + {preset.downvotes} +
+
- {/* FPS */} - {preset.fpsAvg !== null && ( -
- - {preset.fpsAvg} - + {/* Settings count */} +
+ {preset.settingsCount} settings +
+ + {/* FPS */} + {preset.fpsAvg !== null && ( +
+ + {preset.fpsAvg} + + + {" "} + avg + + {preset.fpsOnePercentLow !== + null && ( {" "} - avg + ·{" "} + { + preset.fpsOnePercentLow + }{" "} + 1% low - {preset.fpsOnePercentLow !== null && ( + )} + {preset.fpsLow !== null && + preset.fpsHigh !== + null && + !preset.fpsOnePercentLow && ( {" "} - · {preset.fpsOnePercentLow} 1% low + ({preset.fpsLow} + – + {preset.fpsHigh} + ) )} - {preset.fpsLow !== null && preset.fpsHigh !== null && !preset.fpsOnePercentLow && ( - - {" "} - ({preset.fpsLow}–{preset.fpsHigh}) +
+ )} + + {/* Power / Battery quick-look */} + {(() => { + const dev = + stats?.deviceBreakdown.find( + (d) => + d.hardwareSlug === + preset.hardwareSlug, + ) + const isHandheld = + dev?.deviceType === + "handheld" + const wh = + dev?.wattHours ?? null + const tdp = + preset.tdpWatts ?? null + const estHours = + wh && tdp && tdp > 0 + ? wh / tdp + : null + + if ( + !isHandheld || + (!tdp && !wh) + ) + return null + + return ( +
+ {tdp && ( + + ⚡{" "} + {Math.round( + tdp, + )} + W + + )} + {wh && ( + + 🔋{" "} + {Math.round(wh)} + Wh + + )} + {estHours !== null && ( + + ⏱ ~ + {estHours.toFixed( + 1, + )} + h )}
- )} + ) + })()} - {/* Power / Battery quick-look */} - {(() => { - const dev = stats?.deviceBreakdown.find((d) => d.hardwareSlug === preset.hardwareSlug) - const isHandheld = dev?.deviceType === "handheld" - const wh = dev?.wattHours ?? null - const tdp = preset.tdpWatts ?? null - const estHours = wh && tdp && tdp > 0 ? wh / tdp : null - - if (!isHandheld || (!tdp && !wh)) return null - - return ( -
- {tdp && ⚡ {Math.round(tdp)}W} - {wh && 🔋 {Math.round(wh)}Wh} - {estHours !== null && ( - ⏱ ~{estHours.toFixed(1)}h - )} -
- ) - })()} - - {/* Technology tags */} -
- {preset.upscalerType && - preset.upscalerType !== - "none" && ( - - {preset.upscalerType.toUpperCase()} - {preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""} - - )} - {preset.frameGenMethod && - preset.frameGenMethod !== - "none" && ( - + {preset.upscalerType && + preset.upscalerType !== + "none" && ( + + {preset.upscalerType.toUpperCase()} + {preset.upscalerVersion + ? ` ${preset.upscalerVersion}` + : ""} + + )} + {preset.frameGenMethod && + preset.frameGenMethod !== + "none" && ( + + {preset.frameGenMethod === + "fsr_fg" + ? "FSR FG" + : preset.frameGenMethod === "dlss_fg" - ? "bg-blue-500/10 text-blue-400 border-blue-500/20" - : "bg-orange-500/10 text-orange-400 border-orange-500/20" - }`} - > - {preset.frameGenMethod === - "fsr_fg" - ? "FSR FG" - : preset.frameGenMethod === - "dlss_fg" - ? "DLSS FG" - : preset.frameGenMethod} - - )} -
- - {/* Proton + OS */} -
- {preset.protonVersion && ( - - Proton{" "} - {preset.protonVersion} + ? "DLSS FG" + : preset.frameGenMethod} )} - {preset.osVersion && ( - - {preset.osVersion} - - )} -
- - ) - })} -
- )} -
- +
- {/* Section 5: Statistics Dashboard */} - -
- {/* Row 1 — Featured */} - {filteredStats && ( -
-
-

- Historical Performance -

- {filteredStats.historical.length > 0 ? ( - - ) : ( -
- No historical data -
- )} + {/* Proton + OS */} +
+ {preset.protonVersion && ( + + Proton{" "} + {preset.protonVersion} + + )} + {preset.osVersion && ( + + {preset.osVersion} + + )} +
+ + ) + })}
-
- - - + + + {/* Section 5: Statistics Dashboard */} + +
+ {/* Row 1 — Featured */} + {filteredStats && ( +
+
+

+ Historical Performance +

+ {filteredStats.historical.length > 0 ? ( + + ) : ( +
+ No historical data +
)} - icon={DatabaseIcon} - /> - {filteredStats.summary.avgStability !== null && ( +
+
- )} -
-
- )} - - {/* Loading state */} - {loading && ( -
- - Loading statistics... -
- )} - - {/* Row 2 — Upscaler Bar */} - {filteredStats && - filteredStats.upscalerStats.length > 0 && ( -
-

- Upscaler Performance -

- + + + {filteredStats.summary.avgStability !== + null && ( + + )} +
)} - {/* Row 3 — 3-column grid */} - {filteredStats && ( -
- {filteredStats.boxplot.length > 0 && ( + {/* Loading state */} + {loading && ( +
+ + Loading statistics... +
+ )} + + {/* Row 2 — Upscaler Bar */} + {filteredStats && + filteredStats.upscalerStats.length > 0 && (

- FPS Distribution + Upscaler Performance

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

- FPS Range -

-
)} - {filteredStats.deviceBreakdown.length > 0 && ( + + {/* 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 — Performance Tiers + Stability Scatter */} + {filteredStats && ( +
+ {filteredStats.performanceTiers.length > 0 && ( +
+

+ Performance Tiers +

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

+ Avg FPS vs 1% Low (Stability) +

+ [ + d.hardwareSlug, + d.hardwareName, + ], + ), + )} + /> +
+ )} +
+ )} + + {/* Battery Life Estimates */} + {filteredStats && + filteredStats.batteryLife && + filteredStats.batteryLife.length > 0 && (

- Device Breakdown + Battery Life Estimates

- [ + d.hardwareSlug, + d.hardwareName, + ], + ), + )} />
)} -
- )} - - {/* Row 4 — Performance Tiers + Stability Scatter */} - {filteredStats && ( -
- {filteredStats.performanceTiers.length > 0 && ( -
-

- Performance Tiers -

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

- Avg FPS vs 1% Low (Stability) -

- [d.hardwareSlug, d.hardwareName]))} - /> -
- )} -
- )} - - {/* Battery Life Estimates */} - {filteredStats && filteredStats.batteryLife && filteredStats.batteryLife.length > 0 && ( -
-

- Battery Life Estimates -

- [d.hardwareSlug, d.hardwareName]))} - /> -
- )} - -
- - - {/* Steam Reviews */} - {game.steamAppId && ( -
-
-
-
- )} + - {/* Section 6: Comments */} - -
- -
-
+ {/* Steam Reviews */} + {game.steamAppId && ( +
+
+ +
+
+ )} - {/* Mobile FAB for Add Benchmark */} - {session && ( - - - - )} -
- - {selectedPresetId && (() => { - const preset = filteredPresets.find((p) => p.id === selectedPresetId) - if (!preset) return null - return ( - - ) - })()} - +
+ +
+ + + {/* Mobile FAB for Add Benchmark */} + {session && ( + + + + )} +
+ + {selectedPresetId && + (() => { + const preset = filteredPresets.find( + (p) => p.id === selectedPresetId, + ) + if (!preset) return null + return ( + + ) + })()} + ) } diff --git a/app/game/[id]/preset-detail-modal.tsx b/app/game/[id]/preset-detail-modal.tsx index d54cdce..4718d1d 100644 --- a/app/game/[id]/preset-detail-modal.tsx +++ b/app/game/[id]/preset-detail-modal.tsx @@ -105,6 +105,7 @@ export function PresetDetailModal({ const [userVote, setUserVote] = useState<"up" | "down" | null>(null) const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes) const [localDownvotes, setLocalDownvotes] = useState(preset.downvotes) + const [mobileTab, setMobileTab] = useState<"details" | "settings">("settings") const isOwner = session?.user?.id === preset.userId const isAdmin = session?.user?.role === "admin" @@ -203,7 +204,7 @@ export function PresetDetailModal({ > {/* Modal card */}
+ {/* Mobile tabs */} +
+ + +
+ {/* Two-column body */}
{/* Left panel */} -
+
{/* User info */}
@@ -271,137 +298,8 @@ export function PresetDetailModal({
- {/* Vote buttons */} -
- - -
- - {/* FPS */} - {preset.fpsAvg !== null && ( -
- - Avg FPS - -
- - {preset.fpsAvg} - - {preset.fpsLow !== null && preset.fpsHigh !== null && ( - - ({Math.round(preset.fpsLow)}–{Math.round(preset.fpsHigh)}) - - )} -
-
- )} - {preset.fpsOnePercentLow !== null && ( -
- 1% Low FPS - - {preset.fpsOnePercentLow} fps - -
- )} - -
- - {/* Metadata */} -
- - - {preset.tdpWatts !== null && ( - - )} - {preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && ( - - )} - {preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && ( - - )} - - - - {preset.loadTimeSsd !== null && ( - - )} - {preset.loadTimeSd !== null && ( - - )} - {preset.estimatedBatteryMin !== null && ( - - )} - {preset.customSystem && ( - - )} -
- -
- - {/* Screenshots */} -
- {preset.screenshots && preset.screenshots.length > 0 && ( -
- Screenshots -
- {preset.screenshots.map((ss) => ( - - Screenshot - - ))} -
-
- )} - {/* Actions */} -
+
{isAdmin && (
+ +
+ + {/* Vote buttons */} +
+ + +
+ + {/* FPS */} + {preset.fpsAvg !== null && ( +
+ + Avg FPS + +
+ + {preset.fpsAvg} + + {preset.fpsLow !== null && preset.fpsHigh !== null && ( + + ({Math.round(preset.fpsLow)}–{Math.round(preset.fpsHigh)}) + + )} +
+
+ )} + {preset.fpsOnePercentLow !== null && ( +
+ 1% Low FPS + + {preset.fpsOnePercentLow} fps + +
+ )} + +
+ + {/* Metadata */} +
+ + + {preset.tdpWatts !== null && ( + + )} + {preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && ( + + )} + {preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && ( + + )} + + + + {preset.loadTimeSsd !== null && ( + + )} + {preset.loadTimeSd !== null && ( + + )} + {preset.estimatedBatteryMin !== null && ( + + )} + {preset.customSystem && ( + + )} +
+ +
+ + {/* Screenshots */} + {preset.screenshots && preset.screenshots.length > 0 && ( +
+ Screenshots +
+ {preset.screenshots.map((ss) => ( + + Screenshot + + ))} +
+
+ )}
{/* Right panel */} -
+
{/* YouTube Video */} {preset.youtubeVideoId && (
@@ -662,7 +692,7 @@ function MetaItem({ {label} - + {value ?? "—"}
diff --git a/app/games/games-page-client.tsx b/app/games/games-page-client.tsx index 3cf0f63..ff7d7f1 100644 --- a/app/games/games-page-client.tsx +++ b/app/games/games-page-client.tsx @@ -6,11 +6,11 @@ import Image from "next/image" import Link from "next/link" import { motion } from "motion/react" import { - Gamepad2Icon, - SearchIcon, - TrendingUpIcon, - XIcon, - Loader2Icon, + Gamepad2Icon, + SearchIcon, + TrendingUpIcon, + XIcon, + Loader2Icon, } from "lucide-react" import { useGamepadNavigation } from "@/lib/hooks/use-gamepad-navigation" import { AntiCheatBadge } from "@/components/anti-cheat-badge" @@ -19,446 +19,544 @@ 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 + 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 } interface DeviceOption { - slug: string - name: string + slug: string + name: string } -type SortOption = "recent" | "name" | "benchmarks" | "performance" | "popularity" | "release_date" | "steam_reviews" +type SortOption = + | "recent" + | "name" + | "benchmarks" + | "performance" + | "popularity" + | "release_date" + | "steam_reviews" type SortDirection = "asc" | "desc" -const DECK_STATUS_CONFIG: Record = { - 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 DECK_STATUS_CONFIG: Record = + { + 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" }, + { 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, + totalCount, + allGenres, + allDevices, }: { - initialGames: GamesListItem[] - totalCount: number - allGenres: string[] - allDevices: DeviceOption[] + initialGames: GamesListItem[] + totalCount: number + allGenres: string[] + allDevices: DeviceOption[] }) { - const [games, setGames] = useState(initialGames) - const [total, setTotal] = useState(totalCount) - const [search, setSearch] = useState("") - const [selectedGenres, setSelectedGenres] = useState([]) - const [selectedDevice, setSelectedDevice] = useState("") - const [sort, setSort] = useState("recent") - const [sortDirection, setSortDirection] = useState("desc") - const [loading, setLoading] = useState(false) - const [error, setError] = useState(null) - const [showFilters, setShowFilters] = useState(false) - 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 observerRef = useRef(null) - const sentinelRef = useRef(null) - const pageRef = useRef(null) + const [games, setGames] = useState(initialGames) + const [total, setTotal] = useState(totalCount) + const [search, setSearch] = useState("") + const [selectedGenres, setSelectedGenres] = useState([]) + const [selectedDevice, setSelectedDevice] = useState("") + const [sort, setSort] = useState("recent") + const [sortDirection, setSortDirection] = useState("desc") + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [showFilters, setShowFilters] = useState(false) + 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 observerRef = useRef(null) + const sentinelRef = useRef(null) + const pageRef = useRef(null) - const { isGamepadActive } = useGamepadNavigation(pageRef, { - onXButton: () => { - // Navigate to search page - window.location.href = "/search" - }, - onYButton: () => { - // Toggle filter panel - setShowFilters((prev) => !prev) - }, - }) + const { isGamepadActive } = useGamepadNavigation(pageRef, { + onXButton: () => { + // Navigate to search page + window.location.href = "/search" + }, + onYButton: () => { + // Toggle filter panel + setShowFilters((prev) => !prev) + }, + }) - const router = useRouter() + const router = useRouter() - const hasMore = games.length < total + 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) + // ── 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) } - } 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() + 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) + } } - }, - { rootMargin: "200px" }, + 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, + ], ) - if (sentinelRef.current) { - observer.observe(sentinelRef.current) + // 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], + ) } - 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 */} -
- - Device + const filterPanelContent = ( + <> + {/* Device filter */} +
+ + Device -
- - {allDevices.map((device) => ( +
- ))} + {allDevices.map((device) => ( + + ))}
-
+
- {/* Genre filter */} -
- - Genre + {/* Genre filter */} +
+ + Genre -
- {allGenres.map((genre) => ( - - ))} +
+ {allGenres.map((genre) => ( + + ))}
-
+
- {/* Performance Filters */} -
-

Performance

-
- 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]" - /> - 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]" - /> + {/* Performance Filters */} +
+

+ Performance +

+
+ 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' + /> + 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' + />
-
+
- {/* Compatibility Filters */} -
-

Compatibility

+ {/* Compatibility Filters */} +
+

+ Compatibility +

-
+
- {/* Other Filters */} -
-

Other

-
- {/* Saved 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) + 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 || + {/* Clear filters */} + {(selectedDevice || selectedGenres.length > 0 || minFps || maxFps || @@ -470,335 +568,375 @@ export function GamesPageClient({ isFree || hasMultiplayer) && ( - )} - - - ) - - return ( -
- {/* Header */} - -
-

Games

-

- Browse {total.toLocaleString()} games with benchmarks, settings, and performance data -

-
-
- - {/* Search & Filter Bar */} - -
- {/* Search + Sort Row */} -
- - - - -
- - {/* Mobile drawer */} - setShowFilters(false)}> -
- {filterPanelContent} -
-
- - {/* Desktop inline panel */} - {showFilters && ( - - {filterPanelContent} - - )} -
-
- - {/* Error State */} - {error && !loading && ( -
-

{error}

- -
- )} - - {/* Games Grid */} - -
- {games.map((game) => ( - - ))} -
- - {/* Empty state */} - {!loading && games.length === 0 && !error && ( -
- -

- {search || - selectedDevice || - selectedGenres.length > 0 || - minFps || - maxFps || - fsrSupport || - protonNative !== "any" || - antiCheatStatus !== "any" || - playabilityStatus || - steamReviewMin || - isFree || - hasMultiplayer - ? "No games match your filters" - : "No games found"} -

- {(search || - selectedDevice || - selectedGenres.length > 0 || - minFps || - maxFps || - fsrSupport || - protonNative !== "any" || - antiCheatStatus !== "any" || - playabilityStatus || - steamReviewMin || - isFree || - hasMultiplayer) && ( - )} -
- )} -
+ + ) - {/* Loading indicator for infinite scroll */} - {loading && ( -
- -
- )} + return ( +
+ {/* Header */} + +
+

Games

+

+ Browse {total.toLocaleString()} games with benchmarks, + settings, and performance data +

+
+
- {/* End of list */} - {!loading && !hasMore && games.length > 0 && ( -
-

- Showing all {games.length} of {total.toLocaleString()} games -

-
- )} + {/* Search & Filter Bar */} + +
+ {/* Search + Sort Row */} +
+ + + + +
- {/* Infinite scroll sentinel */} - {hasMore && !loading &&
} -
- ) + {/* Mobile drawer */} + setShowFilters(false)} + > +
+ {filterPanelContent} +
+
+ + {/* Desktop inline panel */} + {showFilters && ( + + {filterPanelContent} + + )} +
+ + + {/* Error State */} + {error && !loading && ( +
+

{error}

+ +
+ )} + + {/* Games Grid */} + +
+ {games.map((game) => ( + + ))} +
+ + {/* Empty state */} + {!loading && games.length === 0 && !error && ( +
+ +

+ {search || + selectedDevice || + selectedGenres.length > 0 || + minFps || + maxFps || + fsrSupport || + protonNative !== "any" || + antiCheatStatus !== "any" || + playabilityStatus || + steamReviewMin || + isFree || + hasMultiplayer + ? "No games match your filters" + : "No games found"} +

+ {(search || + selectedDevice || + selectedGenres.length > 0 || + minFps || + maxFps || + fsrSupport || + protonNative !== "any" || + antiCheatStatus !== "any" || + playabilityStatus || + steamReviewMin || + isFree || + hasMultiplayer) && ( + + )} +
+ )} +
+ + {/* Loading indicator for infinite scroll */} + {loading && ( +
+ +
+ )} + + {/* End of list */} + {!loading && !hasMore && games.length > 0 && ( +
+

+ Showing all {games.length} of {total.toLocaleString()}{" "} + games +

+
+ )} + + {/* Infinite scroll sentinel */} + {hasMore && !loading && ( +
+ )} + + ) } function GameCard({ game }: { game: GamesListItem }) { - const [imgError, setImgError] = useState(false) - const imageUrl = game.capsuleImage || game.headerImage + 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 + const deckConfig = game.deckStatus + ? (DECK_STATUS_CONFIG[game.deckStatus] ?? DECK_STATUS_CONFIG.unknown) + : null - return ( - -
- {imageUrl && !imgError ? ( - {game.title} setImgError(true)} - /> - ) : ( -
- -
- )} -
-
-

- {game.title} -

-
- {game.playabilityStatus && ( - - )} - {game.antiCheatRelevant && game.antiCheatStatus === "unsupported" && ( - - )} - {game.steamReviewScore != null && ( - - - - - {game.steamReviewScore}% - - )} -
-
- {game.benchmarkCount > 0 ? ( - - - {game.benchmarkCount} - - ) : ( - No data yet - )} - {deckConfig && ( - - {deckConfig.label} - - )} -
-
- - ) + return ( + +
+ {imageUrl && !imgError ? ( + {game.title} setImgError(true)} + /> + ) : ( +
+ +
+ )} +
+
+

+ {game.title} +

+
+ {game.playabilityStatus && ( + + )} + {game.antiCheatRelevant && + game.antiCheatStatus === "unsupported" && ( + + )} + {game.steamReviewScore != null && ( + + + + + {game.steamReviewScore}% + + )} +
+
+ {game.benchmarkCount > 0 ? ( + + + + {game.benchmarkCount} + + + ) : ( + + No data yet + + )} + {deckConfig && ( + + {deckConfig.label} + + )} +
+
+ + ) } diff --git a/components/filter-drawer.tsx b/components/filter-drawer.tsx index b17e200..f86a1c0 100644 --- a/components/filter-drawer.tsx +++ b/components/filter-drawer.tsx @@ -5,84 +5,86 @@ import { XIcon } from "lucide-react" import { motion, AnimatePresence } from "motion/react" interface FilterDrawerProps { - isOpen: boolean - onClose: () => void - children: React.ReactNode + isOpen: boolean + onClose: () => void + children: React.ReactNode } export function FilterDrawer({ isOpen, onClose, children }: FilterDrawerProps) { - const drawerRef = useRef(null) + const drawerRef = useRef(null) - // Close on Escape - useEffect(() => { - if (!isOpen) return - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose() - } - document.addEventListener("keydown", handleKeyDown) - return () => document.removeEventListener("keydown", handleKeyDown) - }, [isOpen, onClose]) + // Close on Escape + useEffect(() => { + if (!isOpen) return + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + document.addEventListener("keydown", handleKeyDown) + return () => document.removeEventListener("keydown", handleKeyDown) + }, [isOpen, onClose]) - // Prevent body scroll when open - useEffect(() => { - if (isOpen) { - document.body.style.overflow = "hidden" - } else { - document.body.style.overflow = "" - } - return () => { - document.body.style.overflow = "" - } - }, [isOpen]) + // Prevent body scroll when open + useEffect(() => { + if (isOpen) { + document.body.style.overflow = "hidden" + } else { + document.body.style.overflow = "" + } + return () => { + document.body.style.overflow = "" + } + }, [isOpen]) - // Focus trap - useEffect(() => { - if (isOpen && drawerRef.current) { - const firstFocusable = drawerRef.current.querySelector( - 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', - ) - firstFocusable?.focus() - } - }, [isOpen]) + // Focus trap + useEffect(() => { + if (isOpen && drawerRef.current) { + const firstFocusable = drawerRef.current.querySelector( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ) + firstFocusable?.focus() + } + }, [isOpen]) - return ( - - {isOpen && ( - <> - {/* Backdrop */} - + return ( + + {isOpen && ( + <> + {/* Backdrop */} + - {/* Drawer */} - -
-

Filters

- -
-
- {children} -
-
- - )} -
- ) + {/* Drawer */} + +
+

Filters

+ +
+
{children}
+
+ + )} +
+ ) } diff --git a/lib/api/game-stats.ts b/lib/api/game-stats.ts index 55d342e..19df33c 100644 --- a/lib/api/game-stats.ts +++ b/lib/api/game-stats.ts @@ -6,13 +6,14 @@ import { performanceEntries, hardware, } from "@/lib/db/schema" -import { eq, and, sql } from "drizzle-orm" +import { eq, and, inArray, sql } from "drizzle-orm" export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( "/:gameId/stats", async ({ params, set }) => { const { gameId } = params + try { // Verify game exists const [game] = await db .select({ id: games.id, steamAppId: games.steamAppId }) @@ -163,7 +164,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( Map >() for (const e of entries) { - const date = e.createdAt + const date = new Date(e.createdAt) const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}` if (!histMap.has(month)) histMap.set(month, new Map()) const deviceMap = histMap.get(month)! @@ -245,6 +246,22 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( const bestOnePercentLow = entries.reduce((best, e) => e.fpsOnePercentLow != null && e.fpsOnePercentLow > (best ?? 0) ? e.fpsOnePercentLow : best, null as number | null) + // Fetch hardware wattHours for the relevant devices (used by deviceBreakdown + batteryLife) + const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))] + const deviceData = await db + .select({ + slug: hardware.slug, + wattHours: hardware.wattHours, + tdpMax: hardware.tdpMax, + deviceType: hardware.deviceType, + }) + .from(hardware) + .where(inArray(hardware.slug, deviceSlugs)) + + const deviceWattHoursMap = new Map( + deviceData.map((d) => [d.slug, d]), + ) + // ── 8. Device breakdown ─────────────────────────────────────── const deviceBreakdown = Array.from(boxplotMap.entries()).map( ([slug, { hardwareName, values }]) => { @@ -288,22 +305,6 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( })) // ── 12. Battery life estimates ───────────────────────────── - // Fetch hardware wattHours for the relevant devices - const deviceSlugs = [...new Set(entries.map((e) => e.hardwareSlug))] - const deviceData = await db - .select({ - slug: hardware.slug, - wattHours: hardware.wattHours, - tdpMax: hardware.tdpMax, - deviceType: hardware.deviceType, - }) - .from(hardware) - .where(sql`${hardware.slug} = ANY(${deviceSlugs})`) - - const deviceWattHoursMap = new Map( - deviceData.map((d) => [d.slug, d]), - ) - const batteryLife = entries .filter((e) => e.tdpWatts != null && e.tdpWatts > 0) .map((e) => { @@ -359,6 +360,11 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get( batteryLife, filterOptions: { protonVersions, osVersions }, } + } catch (err) { + console.error("Error computing game stats:", err) + set.status = 500 + return { error: "Failed to compute game stats" } + } }, { params: t.Object({ gameId: t.String() }), diff --git a/next.config.ts b/next.config.ts index 25e6f54..8ea6efb 100644 --- a/next.config.ts +++ b/next.config.ts @@ -43,7 +43,7 @@ const nextConfig: NextConfig = { // Cloudflare R2 { protocol: "https", - hostname: "**.r2.dev", + hostname: "cdn.deckyvault.xyz", } ], },