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
This commit is contained in:
2026-05-10 04:15:38 +08:00
parent f02408c0c7
commit bcf2d540b8
7 changed files with 2251 additions and 1780 deletions
+3
View File
@@ -51,3 +51,6 @@ public/sw.js
.pi/ .pi/
.superpowers/ .superpowers/
# temporary files
post.md
+429 -137
View File
@@ -66,7 +66,10 @@ interface Game {
createdAt: string createdAt: string
metascore?: number | null metascore?: number | null
onlineMultiplayerStatus?: string | null onlineMultiplayerStatus?: string | null
systemRequirements: { minimum: string | null; recommended: string | null } | null systemRequirements: {
minimum: string | null
recommended: string | null
} | null
metacriticScore: number | null metacriticScore: number | null
metacriticUrl: string | null metacriticUrl: string | null
recommendationsTotal: number | null recommendationsTotal: number | null
@@ -77,7 +80,13 @@ interface Game {
releaseDate: string | null releaseDate: string | null
categories: string[] | null categories: string[] | null
platforms: { windows: boolean; mac: boolean; linux: boolean } | 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 steamReviewScore?: number | null
steamReviewSentiment?: string | null steamReviewSentiment?: string | null
steamReviewCount?: number | null steamReviewCount?: number | null
@@ -95,7 +104,13 @@ interface PlatformSupport {
hardwareSlug: string hardwareSlug: string
isSupported: boolean isSupported: boolean
protonStatus: string protonStatus: string
playabilityStatus: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null playabilityStatus:
| "great"
| "playable"
| "needs_tweaks"
| "unplayable"
| "unknown"
| null
antiCheatRelevant: boolean antiCheatRelevant: boolean
antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null antiCheatStatus: "none" | "supported" | "unsupported" | "unknown" | null
antiCheatName: string | null antiCheatName: string | null
@@ -125,7 +140,13 @@ interface Preset {
estimatedBatteryMin: number | null estimatedBatteryMin: number | null
tdpWatts: number | null tdpWatts: number | null
youtubeVideoId: string | 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 customSystem: boolean
userNotes: string | null userNotes: string | null
userId: string userId: string
@@ -292,7 +313,11 @@ function HeroInfo({
steamDbUrl: string | null steamDbUrl: string | null
gameId: string gameId: string
formatDate: (value: string | null) => 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 ( return (
<> <>
@@ -323,23 +348,31 @@ function HeroInfo({
<AntiCheatBadge <AntiCheatBadge
antiCheatRelevant={true} antiCheatRelevant={true}
antiCheatStatus={ antiCheatStatus={
platformSupport.find((p) => p.antiCheatRelevant)?.antiCheatStatus ?? "unknown" platformSupport.find((p) => p.antiCheatRelevant)
?.antiCheatStatus ?? "unknown"
} }
antiCheatName={ antiCheatName={
platformSupport.find((p) => p.antiCheatRelevant)?.antiCheatName platformSupport.find((p) => p.antiCheatRelevant)
?.antiCheatName
} }
/> />
)} )}
{/* Steam review score */} {/* Steam review score */}
{game.steamReviewScore != null && ( {game.steamReviewScore != null && (
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/30 text-xs font-medium"> <span className='inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-blue-500/15 text-blue-400 border border-blue-500/30 text-xs font-medium'>
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="currentColor"> <svg
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> className='h-3 w-3'
viewBox='0 0 24 24'
fill='currentColor'
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg> </svg>
{game.steamReviewScore}% Positive {game.steamReviewScore}% Positive
{game.steamReviewSentiment && ( {game.steamReviewSentiment && (
<span className="text-blue-300/70">({game.steamReviewSentiment.replace(/_/g, ' ')})</span> <span className='text-blue-300/70'>
({game.steamReviewSentiment.replace(/_/g, " ")})
</span>
)} )}
</span> </span>
)} )}
@@ -379,8 +412,7 @@ function HeroInfo({
value={counts.comments} value={counts.comments}
label='Comments' label='Comments'
/> />
{game.metascore !== undefined && {game.metascore !== undefined && game.metascore !== null && (
game.metascore !== null && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-[10px] font-semibold'> <span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-[10px] font-semibold'>
{game.metascore} {game.metascore}
</span> </span>
@@ -399,14 +431,26 @@ function HeroInfo({
Free to Play Free to Play
</span> </span>
)} )}
{game.priceCurrent != null && !game.isFree && game.priceCurrency && ( {game.priceCurrent != null &&
!game.isFree &&
game.priceCurrency && (
<span className='inline-flex items-center px-2 py-0.5 rounded-full bg-text/5 border border-border text-text/60 text-[10px]'> <span className='inline-flex items-center px-2 py-0.5 rounded-full bg-text/5 border border-border text-text/60 text-[10px]'>
{new Intl.NumberFormat("en-US", { style: "currency", currency: game.priceCurrency }).format(game.priceCurrent / 100)} {new Intl.NumberFormat("en-US", {
style: "currency",
currency: game.priceCurrency,
}).format(game.priceCurrent / 100)}
</span> </span>
)} )}
{game.priceCurrent != null && !game.isFree && game.priceInitial != null && game.priceInitial > game.priceCurrent && game.priceCurrency && ( {game.priceCurrent != null &&
!game.isFree &&
game.priceInitial != null &&
game.priceInitial > game.priceCurrent &&
game.priceCurrency && (
<span className='inline-flex items-center px-2 py-0.5 rounded-full bg-text/5 border border-border text-text/40 text-[10px] line-through'> <span className='inline-flex items-center px-2 py-0.5 rounded-full bg-text/5 border border-border text-text/40 text-[10px] line-through'>
{new Intl.NumberFormat("en-US", { style: "currency", currency: game.priceCurrency }).format(game.priceInitial / 100)} {new Intl.NumberFormat("en-US", {
style: "currency",
currency: game.priceCurrency,
}).format(game.priceInitial / 100)}
</span> </span>
)} )}
{game.metacriticScore != null && ( {game.metacriticScore != null && (
@@ -421,9 +465,15 @@ function HeroInfo({
)} )}
{game.platforms && ( {game.platforms && (
<span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-text/5 border border-border text-text/60 text-[10px]'> <span className='inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-text/5 border border-border text-text/60 text-[10px]'>
{game.platforms.windows && <WindowsIcon className='h-3 w-3 text-blue-400' />} {game.platforms.windows && (
{game.platforms.mac && <MacIcon className='h-3 w-3 text-text/60' />} <WindowsIcon className='h-3 w-3 text-blue-400' />
{game.platforms.linux && <LinuxIcon className='h-3 w-3 text-yellow-500' />} )}
{game.platforms.mac && (
<MacIcon className='h-3 w-3 text-text/60' />
)}
{game.platforms.linux && (
<LinuxIcon className='h-3 w-3 text-yellow-500' />
)}
</span> </span>
)} )}
{game.steamAppId !== null && ( {game.steamAppId !== null && (
@@ -488,8 +538,7 @@ function HeroInfo({
<span>·</span> <span>·</span>
<span className='inline-flex items-center gap-1'> <span className='inline-flex items-center gap-1'>
<ClockIcon className='h-3 w-3' /> <ClockIcon className='h-3 w-3' />
Last sync{" "} Last sync {formatDate(game.lastSync)}
{formatDate(game.lastSync)}
</span> </span>
</> </>
)} )}
@@ -497,7 +546,10 @@ function HeroInfo({
)} )}
{stats && ( {stats && (
<div className='flex flex-wrap items-center gap-2 mt-2'> <div className='flex flex-wrap items-center gap-2 mt-2'>
<BookmarkButton gameId={game.id} data-gamepad-focusable /> <BookmarkButton
gameId={game.id}
data-gamepad-focusable
/>
{session?.user && game.source !== "steam" && ( {session?.user && game.source !== "steam" && (
<Link <Link
href={`/game/${gameId}/edit`} href={`/game/${gameId}/edit`}
@@ -523,11 +575,31 @@ function HeroInfo({
gameId={game.id} gameId={game.id}
gameTitle={game.title} gameTitle={game.title}
editableFields={[ editableFields={[
{ name: "title", label: "Title", currentValue: game.title }, {
{ name: "description", label: "Description", currentValue: game.description || "" }, name: "title",
{ name: "developer", label: "Developer", currentValue: game.developer || "" }, label: "Title",
{ name: "publisher", label: "Publisher", currentValue: game.publisher || "" }, currentValue: game.title,
{ name: "storeUrl", label: "Store URL", currentValue: game.storeUrl || "" }, },
{
name: "description",
label: "Description",
currentValue: game.description || "",
},
{
name: "developer",
label: "Developer",
currentValue: game.developer || "",
},
{
name: "publisher",
label: "Publisher",
currentValue: game.publisher || "",
},
{
name: "storeUrl",
label: "Store URL",
currentValue: game.storeUrl || "",
},
]} ]}
/> />
)} )}
@@ -557,9 +629,13 @@ export function GamePageClient({
frameGen: "all", frameGen: "all",
}) })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null) const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
null,
)
const [showSystemReq, setShowSystemReq] = useState(true) const [showSystemReq, setShowSystemReq] = useState(true)
const [reportedPresets, setReportedPresets] = useState<Set<string>>(new Set()) const [reportedPresets, setReportedPresets] = useState<Set<string>>(
new Set(),
)
const presetsRef = useRef<HTMLDivElement>(null) const presetsRef = useRef<HTMLDivElement>(null)
// On mount, auto-open preset from URL // On mount, auto-open preset from URL
@@ -590,10 +666,13 @@ export function GamePageClient({
const handleDeletePreset = async (presetId: string) => { const handleDeletePreset = async (presetId: string) => {
try { try {
const res = await fetch(`/api/performance/${presetId}/user-delete`, { const res = await fetch(
`/api/performance/${presetId}/user-delete`,
{
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}) },
)
if (res.ok) { if (res.ok) {
setSelectedPresetId(null) setSelectedPresetId(null)
router.refresh() 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 { try {
const res = await fetch(`/api/performance/${presetId}/report`, { const res = await fetch(`/api/performance/${presetId}/report`, {
method: "POST", method: "POST",
@@ -658,13 +741,19 @@ export function GamePageClient({
const deviceSet = new Set(selectedDevices) const deviceSet = new Set(selectedDevices)
const filterByDevice = <T extends { hardwareSlug: string }>(arr: T[] | undefined) => const filterByDevice = <T extends { hardwareSlug: string }>(
arr?.filter((item) => deviceSet.has(item.hardwareSlug)) ?? [] 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) => { (arr ?? []).filter((item) => {
if (!deviceSet.has(item.hardwareSlug)) return false 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 return false
if ( if (
filters.frameGen !== "all" && filters.frameGen !== "all" &&
@@ -705,7 +794,10 @@ export function GamePageClient({
if (filters.proton !== "all" && p.protonVersion !== filters.proton) if (filters.proton !== "all" && p.protonVersion !== filters.proton)
return false return false
if (filters.os !== "all" && p.osVersion !== filters.os) 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 return false
if ( if (
filters.frameGen !== "all" && filters.frameGen !== "all" &&
@@ -716,8 +808,14 @@ export function GamePageClient({
}) })
}, [presets, selectedDevices, filters]) }, [presets, selectedDevices, filters])
const pinnedPresets = useMemo(() => filteredPresets.filter((p) => p.isPinned), [filteredPresets]) const pinnedPresets = useMemo(
const regularPresets = useMemo(() => filteredPresets.filter((p) => !p.isPinned), [filteredPresets]) () => filteredPresets.filter((p) => p.isPinned),
[filteredPresets],
)
const regularPresets = useMemo(
() => filteredPresets.filter((p) => !p.isPinned),
[filteredPresets],
)
const coverImage = game.capsuleImage || game.headerImage const coverImage = game.capsuleImage || game.headerImage
@@ -744,7 +842,7 @@ export function GamePageClient({
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className='px-4 md:px-[10svw] pt-6' className='pt-6'
> >
{/* Mobile hero — full-bleed background */} {/* Mobile hero — full-bleed background */}
<div className='relative md:hidden'> <div className='relative md:hidden'>
@@ -754,15 +852,15 @@ export function GamePageClient({
src={coverImage} src={coverImage}
alt='' alt=''
fill fill
className='object-cover' className='object-cover object-top'
sizes='100vw' sizes='(max-width: 767px) 100vw, 0px'
priority priority
onError={handleImgError} onError={handleImgError}
/> />
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-black/30' /> <div className='absolute inset-0 bg-linear-to-t from-black/80 via-black/50 to-black/40 backdrop-blur-xs' />
</div> </div>
) : ( ) : (
<div className='absolute inset-0 z-0 bg-gradient-to-b from-primary/20 to-background' /> <div className='absolute inset-0 z-0 bg-linear-to-b from-primary/20 to-background backdrop-blur-xs' />
)} )}
<div className='relative z-10 flex flex-col gap-2 min-w-0 px-4 pt-24 pb-6'> <div className='relative z-10 flex flex-col gap-2 min-w-0 px-4 pt-24 pb-6'>
<HeroInfo <HeroInfo
@@ -781,7 +879,7 @@ export function GamePageClient({
</div> </div>
{/* Desktop hero — side-by-side layout */} {/* Desktop hero — side-by-side layout */}
<div className='hidden md:flex gap-4 sm:gap-6'> <div className='hidden md:flex gap-4 sm:gap-6 px-[10svw]'>
{/* Cover image */} {/* Cover image */}
<div className='relative shrink-0 aspect-2/3 w-20 sm:w-28 md:w-36 rounded-lg overflow-hidden border border-border bg-text/5 h-max'> <div className='relative shrink-0 aspect-2/3 w-20 sm:w-28 md:w-36 rounded-lg overflow-hidden border border-border bg-text/5 h-max'>
{coverImage && !imgError ? ( {coverImage && !imgError ? (
@@ -793,7 +891,7 @@ export function GamePageClient({
className='object-cover' className='object-cover'
priority priority
onError={handleImgError} onError={handleImgError}
loading="eager" loading='eager'
/> />
) : ( ) : (
<div className='w-full h-full flex items-center justify-center'> <div className='w-full h-full flex items-center justify-center'>
@@ -841,28 +939,54 @@ export function GamePageClient({
No description available. No description available.
</p> </p>
)} )}
{game.systemRequirements && (game.systemRequirements.minimum || game.systemRequirements.recommended) && ( {game.systemRequirements &&
<div className="mt-6"> (game.systemRequirements.minimum ||
game.systemRequirements.recommended) && (
<div className='mt-6'>
<button <button
onClick={() => setShowSystemReq(!showSystemReq)} onClick={() =>
className="flex items-center gap-2 text-sm font-medium text-text/80 hover:text-primary transition-colors cursor-pointer" setShowSystemReq(!showSystemReq)
}
className='flex items-center gap-2 text-sm font-medium text-text/80 hover:text-primary transition-colors cursor-pointer'
data-gamepad-focusable data-gamepad-focusable
> >
<ChevronDownIcon className={`h-4 w-4 transition-transform ${showSystemReq ? 'rotate-180' : ''}`} /> <ChevronDownIcon
className={`h-4 w-4 transition-transform ${showSystemReq ? "rotate-180" : ""}`}
/>
System Requirements System Requirements
</button> </button>
{showSystemReq && ( {showSystemReq && (
<div className="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"> <div className='mt-3 grid grid-cols-1 md:grid-cols-2 gap-4'>
{game.systemRequirements.minimum && ( {game.systemRequirements
<div className="p-4 rounded-lg border border-border bg-text/3"> .minimum && (
<h4 className="text-xs font-medium uppercase tracking-wider text-text/50 mb-2">Minimum</h4> <div className='p-4 rounded-lg border border-border bg-text/3'>
<div className="text-xs text-text/70 prose prose-sm prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: game.systemRequirements.minimum }} /> <h4 className='text-xs font-medium uppercase tracking-wider text-text/50 mb-2'>
Minimum
</h4>
<div
className='text-xs text-text/70 prose prose-sm prose-invert max-w-none'
dangerouslySetInnerHTML={{
__html: game
.systemRequirements
.minimum,
}}
/>
</div> </div>
)} )}
{game.systemRequirements.recommended && ( {game.systemRequirements
<div className="p-4 rounded-lg border border-border bg-text/3"> .recommended && (
<h4 className="text-xs font-medium uppercase tracking-wider text-text/50 mb-2">Recommended</h4> <div className='p-4 rounded-lg border border-border bg-text/3'>
<div className="text-xs text-text/70 prose prose-sm prose-invert max-w-none" dangerouslySetInnerHTML={{ __html: game.systemRequirements.recommended }} /> <h4 className='text-xs font-medium uppercase tracking-wider text-text/50 mb-2'>
Recommended
</h4>
<div
className='text-xs text-text/70 prose prose-sm prose-invert max-w-none'
dangerouslySetInnerHTML={{
__html: game
.systemRequirements
.recommended,
}}
/>
</div> </div>
)} )}
</div> </div>
@@ -878,17 +1002,17 @@ export function GamePageClient({
Platforms Platforms
</h3> </h3>
<div className='flex items-center gap-1.5'> <div className='flex items-center gap-1.5'>
<span title="Windows"> <span title='Windows'>
<WindowsIcon <WindowsIcon
className={`h-3.5 w-3.5 ${game.platforms.windows ? "text-blue-400" : "text-text/20"}`} className={`h-3.5 w-3.5 ${game.platforms.windows ? "text-blue-400" : "text-text/20"}`}
/> />
</span> </span>
<span title="macOS"> <span title='macOS'>
<MacIcon <MacIcon
className={`h-3.5 w-3.5 ${game.platforms.mac ? "text-text/60" : "text-text/20"}`} className={`h-3.5 w-3.5 ${game.platforms.mac ? "text-text/60" : "text-text/20"}`}
/> />
</span> </span>
<span title="Linux"> <span title='Linux'>
<LinuxIcon <LinuxIcon
className={`h-3.5 w-3.5 ${game.platforms.linux ? "text-yellow-500" : "text-text/20"}`} className={`h-3.5 w-3.5 ${game.platforms.linux ? "text-yellow-500" : "text-text/20"}`}
/> />
@@ -932,7 +1056,9 @@ export function GamePageClient({
{/* Per-device playability */} {/* Per-device playability */}
{ps.playabilityStatus && ( {ps.playabilityStatus && (
<PlayabilityBadge <PlayabilityBadge
status={ps.playabilityStatus} status={
ps.playabilityStatus
}
compact compact
/> />
)} )}
@@ -941,8 +1067,12 @@ export function GamePageClient({
{ps.antiCheatRelevant && ( {ps.antiCheatRelevant && (
<AntiCheatBadge <AntiCheatBadge
antiCheatRelevant={true} antiCheatRelevant={true}
antiCheatStatus={ps.antiCheatStatus} antiCheatStatus={
antiCheatName={ps.antiCheatName} ps.antiCheatStatus
}
antiCheatName={
ps.antiCheatName
}
compact compact
/> />
)} )}
@@ -974,7 +1104,9 @@ export function GamePageClient({
<button <button
key={device.hardwareSlug} key={device.hardwareSlug}
onClick={() => onClick={() =>
toggleDevice(device.hardwareSlug) toggleDevice(
device.hardwareSlug,
)
} }
className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer ${ className={`shrink-0 px-3 py-1.5 rounded-full text-xs font-medium border transition-colors cursor-pointer ${
active active
@@ -1000,7 +1132,8 @@ export function GamePageClient({
value={filters.proton} value={filters.proton}
options={[ options={[
"all", "all",
...(stats?.filterOptions.protonVersions ?? []), ...(stats?.filterOptions.protonVersions ??
[]),
]} ]}
onChange={(v) => onChange={(v) =>
setFilters((f) => ({ ...f, proton: v })) setFilters((f) => ({ ...f, proton: v }))
@@ -1020,7 +1153,9 @@ export function GamePageClient({
<FilterSelect <FilterSelect
label='Upscaler' label='Upscaler'
value={filters.upscaler} value={filters.upscaler}
options={UPSCALER_OPTIONS.map((o) => o.toLowerCase())} options={UPSCALER_OPTIONS.map((o) =>
o.toLowerCase(),
)}
onChange={(v) => onChange={(v) =>
setFilters((f) => ({ ...f, upscaler: v })) setFilters((f) => ({ ...f, upscaler: v }))
} }
@@ -1056,16 +1191,16 @@ export function GamePageClient({
{pinnedPresets.length} pinned {pinnedPresets.length} pinned
</span> </span>
</div> </div>
<div <div className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'>
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
>
{pinnedPresets.map((preset) => { {pinnedPresets.map((preset) => {
const raw = isRawPerformerPreset(preset) const raw = isRawPerformerPreset(preset)
const fpsColor = getFpsColor(preset) const fpsColor = getFpsColor(preset)
return ( return (
<motion.div <motion.div
key={preset.id} key={preset.id}
onClick={() => 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 ${ className={`flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
raw raw
? "border-green-500/30 bg-green-500/5" ? "border-green-500/30 bg-green-500/5"
@@ -1078,7 +1213,9 @@ export function GamePageClient({
<div className='min-w-0'> <div className='min-w-0'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<h3 className='font-semibold text-sm truncate'> <h3 className='font-semibold text-sm truncate'>
{generatePresetName(preset)} {generatePresetName(
preset,
)}
</h3> </h3>
{raw && ( {raw && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'> <span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'>
@@ -1086,7 +1223,11 @@ export function GamePageClient({
Raw Raw
</span> </span>
)} )}
{isPoorPerformancePreset(preset) && preset.fpsAvg !== null && ( {isPoorPerformancePreset(
preset,
) &&
preset.fpsAvg !==
null && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'> <span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'>
Slow Slow
</span> </span>
@@ -1098,8 +1239,12 @@ export function GamePageClient({
</div> </div>
<div className='flex items-center gap-2 text-xs text-text/60 shrink-0'> <div className='flex items-center gap-2 text-xs text-text/60 shrink-0'>
{preset.youtubeVideoId && ( {preset.youtubeVideoId && (
<svg className="h-3 w-3 text-red-400 shrink-0" viewBox="0 0 24 24" fill="currentColor"> <svg
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/> className='h-3 w-3 text-red-400 shrink-0'
viewBox='0 0 24 24'
fill='currentColor'
>
<path d='M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z' />
</svg> </svg>
)} )}
<span className='flex items-center gap-0.5'> <span className='flex items-center gap-0.5'>
@@ -1130,16 +1275,27 @@ export function GamePageClient({
{" "} {" "}
avg avg
</span> </span>
{preset.fpsOnePercentLow !== null && ( {preset.fpsOnePercentLow !==
null && (
<span className='text-text/40'> <span className='text-text/40'>
{" "} {" "}
· {preset.fpsOnePercentLow} 1% low ·{" "}
{
preset.fpsOnePercentLow
}{" "}
1% low
</span> </span>
)} )}
{preset.fpsLow !== null && preset.fpsHigh !== null && !preset.fpsOnePercentLow && ( {preset.fpsLow !== null &&
preset.fpsHigh !==
null &&
!preset.fpsOnePercentLow && (
<span className='text-text/40'> <span className='text-text/40'>
{" "} {" "}
({preset.fpsLow}{preset.fpsHigh}) ({preset.fpsLow}
{preset.fpsHigh}
)
</span> </span>
)} )}
</div> </div>
@@ -1147,20 +1303,56 @@ export function GamePageClient({
{/* Power / Battery quick-look */} {/* Power / Battery quick-look */}
{(() => { {(() => {
const dev = stats?.deviceBreakdown.find((d) => d.hardwareSlug === preset.hardwareSlug) const dev =
const isHandheld = dev?.deviceType === "handheld" stats?.deviceBreakdown.find(
const wh = dev?.wattHours ?? null (d) =>
const tdp = preset.tdpWatts ?? null d.hardwareSlug ===
const estHours = wh && tdp && tdp > 0 ? wh / tdp : null 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 if (
!isHandheld ||
(!tdp && !wh)
)
return null
return ( return (
<div className="text-[11px] text-text/50 flex items-center gap-1.5 flex-wrap"> <div className='text-[11px] text-text/50 flex items-center gap-1.5 flex-wrap'>
{tdp && <span> {Math.round(tdp)}W</span>} {tdp && (
{wh && <span>🔋 {Math.round(wh)}Wh</span>} <span>
{" "}
{Math.round(
tdp,
)}
W
</span>
)}
{wh && (
<span>
🔋{" "}
{Math.round(wh)}
Wh
</span>
)}
{estHours !== null && ( {estHours !== null && (
<span> ~{estHours.toFixed(1)}h</span> <span>
~
{estHours.toFixed(
1,
)}
h
</span>
)} )}
</div> </div>
) )
@@ -1173,7 +1365,9 @@ export function GamePageClient({
"none" && ( "none" && (
<span className='px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20'> <span className='px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20'>
{preset.upscalerType.toUpperCase()} {preset.upscalerType.toUpperCase()}
{preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""} {preset.upscalerVersion
? ` ${preset.upscalerVersion}`
: ""}
</span> </span>
)} )}
{preset.frameGenMethod && {preset.frameGenMethod &&
@@ -1248,7 +1442,7 @@ export function GamePageClient({
) : ( ) : (
<div <div
ref={presetsRef} ref={presetsRef}
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4" className='grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4'
> >
{regularPresets.map((preset) => { {regularPresets.map((preset) => {
const raw = isRawPerformerPreset(preset) const raw = isRawPerformerPreset(preset)
@@ -1256,7 +1450,9 @@ export function GamePageClient({
return ( return (
<motion.div <motion.div
key={preset.id} key={preset.id}
onClick={() => 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 ${ className={`flex flex-col gap-3 p-4 rounded-xl border transition-colors cursor-pointer hover:border-primary/30 ${
raw raw
? "border-green-500/30 bg-green-500/5" ? "border-green-500/30 bg-green-500/5"
@@ -1269,7 +1465,9 @@ export function GamePageClient({
<div className='min-w-0'> <div className='min-w-0'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<h3 className='font-semibold text-sm truncate'> <h3 className='font-semibold text-sm truncate'>
{generatePresetName(preset)} {generatePresetName(
preset,
)}
</h3> </h3>
{raw && ( {raw && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'> <span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-green-500/10 border border-green-500/20 text-green-400 text-[9px] font-semibold'>
@@ -1277,7 +1475,11 @@ export function GamePageClient({
Raw Raw
</span> </span>
)} )}
{isPoorPerformancePreset(preset) && preset.fpsAvg !== null && ( {isPoorPerformancePreset(
preset,
) &&
preset.fpsAvg !==
null && (
<span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'> <span className='inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 text-[9px] font-semibold'>
Slow Slow
</span> </span>
@@ -1289,8 +1491,12 @@ export function GamePageClient({
</div> </div>
<div className='flex items-center gap-2 text-xs text-text/60 shrink-0'> <div className='flex items-center gap-2 text-xs text-text/60 shrink-0'>
{preset.youtubeVideoId && ( {preset.youtubeVideoId && (
<svg className="h-3 w-3 text-red-400 shrink-0" viewBox="0 0 24 24" fill="currentColor"> <svg
<path d="M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z"/> className='h-3 w-3 text-red-400 shrink-0'
viewBox='0 0 24 24'
fill='currentColor'
>
<path d='M23.498 6.186a3.016 3.016 0 0 0-2.122-2.136C19.505 3.545 12 3.545 12 3.545s-7.505 0-9.377.505A3.017 3.017 0 0 0 .502 6.186C0 8.07 0 12 0 12s0 3.93.502 5.814a3.016 3.016 0 0 0 2.122 2.136c1.871.505 9.376.505 9.376.505s7.505 0 9.377-.505a3.015 3.015 0 0 0 2.122-2.136C24 15.93 24 12 24 12s0-3.93-.502-5.814zM9.545 15.568V8.432L15.818 12l-6.273 3.568z' />
</svg> </svg>
)} )}
<span className='flex items-center gap-0.5'> <span className='flex items-center gap-0.5'>
@@ -1321,16 +1527,27 @@ export function GamePageClient({
{" "} {" "}
avg avg
</span> </span>
{preset.fpsOnePercentLow !== null && ( {preset.fpsOnePercentLow !==
null && (
<span className='text-text/40'> <span className='text-text/40'>
{" "} {" "}
· {preset.fpsOnePercentLow} 1% low ·{" "}
{
preset.fpsOnePercentLow
}{" "}
1% low
</span> </span>
)} )}
{preset.fpsLow !== null && preset.fpsHigh !== null && !preset.fpsOnePercentLow && ( {preset.fpsLow !== null &&
preset.fpsHigh !==
null &&
!preset.fpsOnePercentLow && (
<span className='text-text/40'> <span className='text-text/40'>
{" "} {" "}
({preset.fpsLow}{preset.fpsHigh}) ({preset.fpsLow}
{preset.fpsHigh}
)
</span> </span>
)} )}
</div> </div>
@@ -1338,20 +1555,56 @@ export function GamePageClient({
{/* Power / Battery quick-look */} {/* Power / Battery quick-look */}
{(() => { {(() => {
const dev = stats?.deviceBreakdown.find((d) => d.hardwareSlug === preset.hardwareSlug) const dev =
const isHandheld = dev?.deviceType === "handheld" stats?.deviceBreakdown.find(
const wh = dev?.wattHours ?? null (d) =>
const tdp = preset.tdpWatts ?? null d.hardwareSlug ===
const estHours = wh && tdp && tdp > 0 ? wh / tdp : null 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 if (
!isHandheld ||
(!tdp && !wh)
)
return null
return ( return (
<div className="text-[11px] text-text/50 flex items-center gap-1.5 flex-wrap"> <div className='text-[11px] text-text/50 flex items-center gap-1.5 flex-wrap'>
{tdp && <span> {Math.round(tdp)}W</span>} {tdp && (
{wh && <span>🔋 {Math.round(wh)}Wh</span>} <span>
{" "}
{Math.round(
tdp,
)}
W
</span>
)}
{wh && (
<span>
🔋{" "}
{Math.round(wh)}
Wh
</span>
)}
{estHours !== null && ( {estHours !== null && (
<span> ~{estHours.toFixed(1)}h</span> <span>
~
{estHours.toFixed(
1,
)}
h
</span>
)} )}
</div> </div>
) )
@@ -1364,7 +1617,9 @@ export function GamePageClient({
"none" && ( "none" && (
<span className='px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20'> <span className='px-1.5 py-0.5 rounded text-[10px] font-medium bg-purple-500/10 text-purple-400 border border-purple-500/20'>
{preset.upscalerType.toUpperCase()} {preset.upscalerType.toUpperCase()}
{preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""} {preset.upscalerVersion
? ` ${preset.upscalerVersion}`
: ""}
</span> </span>
)} )}
{preset.frameGenMethod && {preset.frameGenMethod &&
@@ -1449,7 +1704,8 @@ export function GamePageClient({
<StatCard <StatCard
label='Best Device' label='Best Device'
value={ value={
filteredStats.summary.bestDevice ?? "—" filteredStats.summary.bestDevice ??
"—"
} }
icon={Gamepad2Icon} icon={Gamepad2Icon}
/> />
@@ -1460,7 +1716,8 @@ export function GamePageClient({
)} )}
icon={DatabaseIcon} icon={DatabaseIcon}
/> />
{filteredStats.summary.avgStability !== null && ( {filteredStats.summary.avgStability !==
null && (
<StatCard <StatCard
label='Avg Stability' label='Avg Stability'
value={`${Math.round(filteredStats.summary.avgStability * 100)}%`} value={`${Math.round(filteredStats.summary.avgStability * 100)}%`}
@@ -1500,7 +1757,9 @@ export function GamePageClient({
<h3 className='text-sm font-medium text-text/80 mb-2'> <h3 className='text-sm font-medium text-text/80 mb-2'>
FPS Distribution FPS Distribution
</h3> </h3>
<FpsBoxplot data={filteredStats.boxplot} /> <FpsBoxplot
data={filteredStats.boxplot}
/>
</div> </div>
)} )}
{filteredStats.fpsRange.length > 0 && ( {filteredStats.fpsRange.length > 0 && (
@@ -1528,23 +1787,36 @@ export function GamePageClient({
{/* Row 4 — Performance Tiers + Stability Scatter */} {/* Row 4 — Performance Tiers + Stability Scatter */}
{filteredStats && ( {filteredStats && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4"> <div className='grid grid-cols-1 lg:grid-cols-2 gap-4'>
{filteredStats.performanceTiers.length > 0 && ( {filteredStats.performanceTiers.length > 0 && (
<div className="rounded-xl border border-border bg-text/3 p-4"> <div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className="text-sm font-medium text-text/80 mb-2"> <h3 className='text-sm font-medium text-text/80 mb-2'>
Performance Tiers Performance Tiers
</h3> </h3>
<PerformanceTierChart data={filteredStats.performanceTiers} /> <PerformanceTierChart
data={
filteredStats.performanceTiers
}
/>
</div> </div>
)} )}
{filteredStats.stabilityScatter.length > 0 && ( {filteredStats.stabilityScatter.length > 0 && (
<div className="rounded-xl border border-border bg-text/3 p-4"> <div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className="text-sm font-medium text-text/80 mb-2"> <h3 className='text-sm font-medium text-text/80 mb-2'>
Avg FPS vs 1% Low (Stability) Avg FPS vs 1% Low (Stability)
</h3> </h3>
<StabilityScatterChart <StabilityScatterChart
data={filteredStats.stabilityScatter} data={
deviceNames={Object.fromEntries(filteredStats.deviceBreakdown.map((d) => [d.hardwareSlug, d.hardwareName]))} filteredStats.stabilityScatter
}
deviceNames={Object.fromEntries(
filteredStats.deviceBreakdown.map(
(d) => [
d.hardwareSlug,
d.hardwareName,
],
),
)}
/> />
</div> </div>
)} )}
@@ -1552,26 +1824,37 @@ export function GamePageClient({
)} )}
{/* Battery Life Estimates */} {/* Battery Life Estimates */}
{filteredStats && filteredStats.batteryLife && filteredStats.batteryLife.length > 0 && ( {filteredStats &&
<div className="rounded-xl border border-border bg-text/3 p-4"> filteredStats.batteryLife &&
<h3 className="text-sm font-medium text-text/80 mb-2"> filteredStats.batteryLife.length > 0 && (
<div className='rounded-xl border border-border bg-text/3 p-4'>
<h3 className='text-sm font-medium text-text/80 mb-2'>
Battery Life Estimates Battery Life Estimates
</h3> </h3>
<BatteryLifeChart <BatteryLifeChart
data={filteredStats.batteryLife} data={filteredStats.batteryLife}
deviceNames={Object.fromEntries(filteredStats.deviceBreakdown.map((d) => [d.hardwareSlug, d.hardwareName]))} deviceNames={Object.fromEntries(
filteredStats.deviceBreakdown.map(
(d) => [
d.hardwareSlug,
d.hardwareName,
],
),
)}
/> />
</div> </div>
)} )}
</div> </div>
</motion.div> </motion.div>
{/* Steam Reviews */} {/* Steam Reviews */}
{game.steamAppId && ( {game.steamAppId && (
<section className="space-y-4 px-4 md:px-[10svw]"> <section className='space-y-4 px-4 md:px-[10svw]'>
<div className="max-w-7xl mx-auto"> <div className='max-w-7xl mx-auto'>
<SteamReviews gameId={game.id} steamAppId={game.steamAppId} /> <SteamReviews
gameId={game.id}
steamAppId={game.steamAppId}
/>
</div> </div>
</section> </section>
)} )}
@@ -1583,8 +1866,14 @@ export function GamePageClient({
transition={{ duration: 0.4, delay: 0.3 }} transition={{ duration: 0.4, delay: 0.3 }}
className='px-4 md:px-[10svw]' className='px-4 md:px-[10svw]'
> >
<div className='max-w-7xl mx-auto' data-gamepad-focusable> <div
<CommentSection gameId={gameId} initialCount={counts.comments} /> className='max-w-7xl mx-auto'
data-gamepad-focusable
>
<CommentSection
gameId={gameId}
initialCount={counts.comments}
/>
</div> </div>
</motion.div> </motion.div>
@@ -1600,8 +1889,11 @@ export function GamePageClient({
)} )}
</section> </section>
<AnimatePresence> <AnimatePresence>
{selectedPresetId && (() => { {selectedPresetId &&
const preset = filteredPresets.find((p) => p.id === selectedPresetId) (() => {
const preset = filteredPresets.find(
(p) => p.id === selectedPresetId,
)
if (!preset) return null if (!preset) return null
return ( return (
<PresetDetailModal <PresetDetailModal
+164 -134
View File
@@ -105,6 +105,7 @@ export function PresetDetailModal({
const [userVote, setUserVote] = useState<"up" | "down" | null>(null) const [userVote, setUserVote] = useState<"up" | "down" | null>(null)
const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes) const [localUpvotes, setLocalUpvotes] = useState(preset.upvotes)
const [localDownvotes, setLocalDownvotes] = useState(preset.downvotes) const [localDownvotes, setLocalDownvotes] = useState(preset.downvotes)
const [mobileTab, setMobileTab] = useState<"details" | "settings">("settings")
const isOwner = session?.user?.id === preset.userId const isOwner = session?.user?.id === preset.userId
const isAdmin = session?.user?.role === "admin" const isAdmin = session?.user?.role === "admin"
@@ -203,7 +204,7 @@ export function PresetDetailModal({
> >
{/* Modal card */} {/* Modal card */}
<motion.div <motion.div
className="relative w-full max-w-4xl max-h-[90vh] overflow-hidden rounded-xl border border-border bg-background flex flex-col" className="relative w-full max-w-4xl h-[90vh] md:max-h-[90vh] overflow-hidden rounded-xl border border-border bg-background flex flex-col"
initial={{ y: "100%", opacity: 0 }} initial={{ y: "100%", opacity: 0 }}
animate={{ y: 0, opacity: 1 }} animate={{ y: 0, opacity: 1 }}
exit={{ y: "100%", opacity: 0 }} exit={{ y: "100%", opacity: 0 }}
@@ -230,10 +231,36 @@ export function PresetDetailModal({
</button> </button>
</div> </div>
{/* Mobile tabs */}
<div className="flex md:hidden shrink-0 border-b border-border">
<button
onClick={() => setMobileTab("details")}
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
mobileTab === "details"
? "text-primary border-b-2 border-primary"
: "text-text/50 hover:text-text/70"
}`}
>
Details
</button>
<button
onClick={() => setMobileTab("settings")}
className={`flex-1 py-2.5 text-sm font-medium transition-colors cursor-pointer ${
mobileTab === "settings"
? "text-primary border-b-2 border-primary"
: "text-text/50 hover:text-text/70"
}`}
>
Settings
</button>
</div>
{/* Two-column body */} {/* Two-column body */}
<div className="flex flex-col md:flex-row overflow-hidden flex-1"> <div className="flex flex-col md:flex-row overflow-hidden flex-1">
{/* Left panel */} {/* Left panel */}
<div className="w-full md:w-1/3 md:min-w-[240px] flex flex-col gap-3 md:gap-4 p-4 md:p-5 border-b md:border-b-0 md:border-r border-border overflow-y-auto"> <div className={`w-full md:w-1/3 md:min-w-60 flex-col gap-3 md:gap-4 p-4 md:p-5 border-b md:border-b-0 md:border-r border-border overflow-y-auto ${
mobileTab === "details" ? "flex" : "hidden md:flex"
}`}>
{/* User info */} {/* User info */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-full bg-text/10 overflow-hidden flex items-center justify-center shrink-0"> <div className="h-10 w-10 rounded-full bg-text/10 overflow-hidden flex items-center justify-center shrink-0">
@@ -271,137 +298,8 @@ export function PresetDetailModal({
<div className="h-px bg-border" /> <div className="h-px bg-border" />
{/* Vote buttons */}
<div className="flex items-center gap-3">
<button
onClick={handleUpvote}
disabled={!isAuthenticated || userVote === "up"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "up"
? "bg-green-500/10 border-green-500/30 text-green-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsUpIcon className="h-4 w-4" />
{localUpvotes}
</button>
<button
onClick={handleDownvote}
disabled={!isAuthenticated || userVote === "down"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "down"
? "bg-red-500/10 border-red-500/30 text-red-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsDownIcon className="h-4 w-4" />
{localDownvotes}
</button>
</div>
{/* FPS */}
{preset.fpsAvg !== null && (
<div className="flex flex-col gap-1">
<span className="text-xs text-text/50 uppercase tracking-wider">
Avg FPS
</span>
<div className="text-sm text-text">
<span className="font-semibold tabular-nums">
{preset.fpsAvg}
</span>
{preset.fpsLow !== null && preset.fpsHigh !== null && (
<span className="text-text/50 ml-1">
({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)})
</span>
)}
</div>
</div>
)}
{preset.fpsOnePercentLow !== null && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">1% Low FPS</span>
<span className="text-sm font-semibold tabular-nums text-text">
{preset.fpsOnePercentLow} fps
</span>
</div>
)}
<div className="h-px bg-border" />
{/* Metadata */}
<div className="flex flex-col gap-3">
<MetaItem label="Proton" value={preset.protonVersion} />
<MetaItem label="OS" value={preset.osVersion} />
{preset.tdpWatts !== null && (
<MetaItem label="TDP" value={`${Math.round(preset.tdpWatts)}W`} />
)}
{preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Battery" value={`${Math.round(preset.hardwareWattHours)}Wh`} />
)}
{preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Est. Battery" value={`~${(preset.hardwareWattHours / preset.tdpWatts).toFixed(1)}h`} />
)}
<MetaItem
label="Upscaler"
value={
preset.upscalerType && preset.upscalerType !== "none"
? `${preset.upscalerType.toUpperCase()}${preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}`
: null
}
/>
<MetaItem
label="Frame Gen"
value={
preset.frameGenMethod && preset.frameGenMethod !== "none"
? preset.frameGenMethod === "fsr_fg"
? "FSR FG"
: preset.frameGenMethod === "dlss_fg"
? "DLSS FG"
: preset.frameGenMethod
: null
}
/>
<MetaItem label="Launch Options" value={preset.launchOptions} />
{preset.loadTimeSsd !== null && (
<MetaItem label="Load Time (SSD)" value={`${preset.loadTimeSsd}s`} />
)}
{preset.loadTimeSd !== null && (
<MetaItem label="Load Time (SD)" value={`${preset.loadTimeSd}s`} />
)}
{preset.estimatedBatteryMin !== null && (
<MetaItem label="Battery Life" value={`${preset.estimatedBatteryMin} min`} />
)}
{preset.customSystem && (
<MetaItem label="Custom System" value="Yes" />
)}
</div>
<div className="h-px bg-border" />
{/* Screenshots */}
<div className="h-px bg-border" />
{preset.screenshots && preset.screenshots.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">Screenshots</span>
<div className="flex gap-2">
{preset.screenshots.map((ss) => (
<a key={ss.id} href={ss.url} target="_blank" rel="noopener noreferrer" className="block">
<img
src={ss.url}
alt="Screenshot"
className="w-20 h-12 object-cover rounded border border-border hover:border-primary/50 transition-colors"
loading="lazy"
/>
</a>
))}
</div>
</div>
)}
{/* Actions */} {/* Actions */}
<div className="flex flex-wrap items-center gap-2 mt-auto"> <div className="flex flex-wrap items-center gap-2">
{isAdmin && ( {isAdmin && (
<button <button
onClick={handleTogglePin} onClick={handleTogglePin}
@@ -535,10 +433,142 @@ export function PresetDetailModal({
</span> </span>
)} )}
</div> </div>
<div className="h-px bg-border" />
{/* Vote buttons */}
<div className="flex items-center gap-3">
<button
onClick={handleUpvote}
disabled={!isAuthenticated || userVote === "up"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "up"
? "bg-green-500/10 border-green-500/30 text-green-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsUpIcon className="h-4 w-4" />
{localUpvotes}
</button>
<button
onClick={handleDownvote}
disabled={!isAuthenticated || userVote === "down"}
className={`inline-flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm font-medium border transition-colors cursor-pointer ${
userVote === "down"
? "bg-red-500/10 border-red-500/30 text-red-400"
: "border-border text-text/70 hover:bg-text/5"
} ${!isAuthenticated ? "opacity-50 cursor-not-allowed" : ""}`}
title={!isAuthenticated ? "Sign in to vote" : undefined}
>
<ThumbsDownIcon className="h-4 w-4" />
{localDownvotes}
</button>
</div>
{/* FPS */}
{preset.fpsAvg !== null && (
<div className="flex flex-col gap-1">
<span className="text-xs text-text/50 uppercase tracking-wider">
Avg FPS
</span>
<div className="text-sm text-text">
<span className="font-semibold tabular-nums">
{preset.fpsAvg}
</span>
{preset.fpsLow !== null && preset.fpsHigh !== null && (
<span className="text-text/50 ml-1">
({Math.round(preset.fpsLow)}{Math.round(preset.fpsHigh)})
</span>
)}
</div>
</div>
)}
{preset.fpsOnePercentLow !== null && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">1% Low FPS</span>
<span className="text-sm font-semibold tabular-nums text-text">
{preset.fpsOnePercentLow} fps
</span>
</div>
)}
<div className="h-px bg-border" />
{/* Metadata */}
<div className="flex flex-col gap-3">
<MetaItem label="Proton" value={preset.protonVersion} />
<MetaItem label="OS" value={preset.osVersion} />
{preset.tdpWatts !== null && (
<MetaItem label="TDP" value={`${Math.round(preset.tdpWatts)}W`} />
)}
{preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Battery" value={`${Math.round(preset.hardwareWattHours)}Wh`} />
)}
{preset.tdpWatts !== null && preset.hardwareWattHours !== null && preset.hardwareDeviceType === "handheld" && (
<MetaItem label="Est. Battery" value={`~${(preset.hardwareWattHours / preset.tdpWatts).toFixed(1)}h`} />
)}
<MetaItem
label="Upscaler"
value={
preset.upscalerType && preset.upscalerType !== "none"
? `${preset.upscalerType.toUpperCase()}${preset.upscalerVersion ? ` ${preset.upscalerVersion}` : ""}`
: null
}
/>
<MetaItem
label="Frame Gen"
value={
preset.frameGenMethod && preset.frameGenMethod !== "none"
? preset.frameGenMethod === "fsr_fg"
? "FSR FG"
: preset.frameGenMethod === "dlss_fg"
? "DLSS FG"
: preset.frameGenMethod
: null
}
/>
<MetaItem label="Launch Options" value={preset.launchOptions} />
{preset.loadTimeSsd !== null && (
<MetaItem label="Load Time (SSD)" value={`${preset.loadTimeSsd}s`} />
)}
{preset.loadTimeSd !== null && (
<MetaItem label="Load Time (SD)" value={`${preset.loadTimeSd}s`} />
)}
{preset.estimatedBatteryMin !== null && (
<MetaItem label="Battery Life" value={`${preset.estimatedBatteryMin} min`} />
)}
{preset.customSystem && (
<MetaItem label="Custom System" value="Yes" />
)}
</div>
<div className="h-px bg-border" />
{/* Screenshots */}
{preset.screenshots && preset.screenshots.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-[10px] text-text/50 uppercase tracking-wider">Screenshots</span>
<div className="flex gap-2">
{preset.screenshots.map((ss) => (
<a key={ss.id} href={ss.url} target="_blank" rel="noopener noreferrer" className="block">
<img
src={ss.url}
alt="Screenshot"
className="w-20 h-12 object-cover rounded border border-border hover:border-primary/50 transition-colors"
loading="lazy"
/>
</a>
))}
</div>
</div>
)}
</div> </div>
{/* Right panel */} {/* Right panel */}
<div className="flex-1 overflow-y-auto p-5"> <div className={`flex-1 overflow-y-auto p-5 ${
mobileTab === "settings" ? "block" : "hidden md:block"
}`}>
{/* YouTube Video */} {/* YouTube Video */}
{preset.youtubeVideoId && ( {preset.youtubeVideoId && (
<div className="mb-4"> <div className="mb-4">
@@ -662,7 +692,7 @@ function MetaItem({
<span className="text-[10px] text-text/50 uppercase tracking-wider"> <span className="text-[10px] text-text/50 uppercase tracking-wider">
{label} {label}
</span> </span>
<span className="text-sm text-text/80 break-words"> <span className="text-sm text-text/80 wrap-break-words">
{value ?? "—"} {value ?? "—"}
</span> </span>
</div> </div>
+284 -146
View File
@@ -28,7 +28,13 @@ interface GamesListItem {
genres: string[] | null genres: string[] | null
source: string source: string
steamReviewScore: number | null steamReviewScore: number | null
playabilityStatus: "great" | "playable" | "needs_tweaks" | "unplayable" | "unknown" | null playabilityStatus:
| "great"
| "playable"
| "needs_tweaks"
| "unplayable"
| "unknown"
| null
onlineMultiplayerStatus: "none" | "supported" | "unknown" | null onlineMultiplayerStatus: "none" | "supported" | "unknown" | null
benchmarkCount: number benchmarkCount: number
deckStatus: string | null deckStatus: string | null
@@ -41,14 +47,34 @@ interface DeviceOption {
name: 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" type SortDirection = "asc" | "desc"
const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> = { const DECK_STATUS_CONFIG: Record<string, { label: string; className: string }> =
native: { label: "Native", className: "bg-green-500/10 border-green-500/20 text-green-400" }, {
proton: { label: "Proton", className: "bg-blue-500/10 border-blue-500/20 text-blue-400" }, native: {
unsupported: { label: "Unsupported", className: "bg-red-500/10 border-red-500/20 text-red-400" }, label: "Native",
unknown: { label: "Unknown", className: "bg-text/5 border-border text-text/40" }, 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 }[] = [ const SORT_OPTIONS: { value: SortOption; label: string }[] = [
@@ -125,19 +151,35 @@ export function GamesPageClient({
if (params.get("fsrSupport") === "true") setFsrSupport(true) if (params.get("fsrSupport") === "true") setFsrSupport(true)
if (params.get("protonNative") && params.get("protonNative") !== "any") if (params.get("protonNative") && params.get("protonNative") !== "any")
setProtonNative(params.get("protonNative")!) setProtonNative(params.get("protonNative")!)
if (params.get("antiCheatStatus") && params.get("antiCheatStatus") !== "any") if (
params.get("antiCheatStatus") &&
params.get("antiCheatStatus") !== "any"
)
setAntiCheatStatus(params.get("antiCheatStatus")!) setAntiCheatStatus(params.get("antiCheatStatus")!)
if (params.get("playabilityStatus")) setPlayabilityStatus(params.get("playabilityStatus")!) if (params.get("playabilityStatus"))
if (params.get("steamReviewScore")) setSteamReviewMin(params.get("steamReviewScore")!) setPlayabilityStatus(params.get("playabilityStatus")!)
if (params.get("steamReviewScore"))
setSteamReviewMin(params.get("steamReviewScore")!)
if (params.get("isFree") === "true") setIsFree(true) if (params.get("isFree") === "true") setIsFree(true)
if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true) if (params.get("hasMultiplayer") === "true") setHasMultiplayer(true)
if (params.get("sort")) { if (params.get("sort")) {
const s = params.get("sort")! const s = params.get("sort")!
if (["recent", "name", "benchmarks", "performance", "popularity", "release_date", "steam_reviews"].includes(s)) { if (
[
"recent",
"name",
"benchmarks",
"performance",
"popularity",
"release_date",
"steam_reviews",
].includes(s)
) {
setSort(s as SortOption) setSort(s as SortOption)
} }
} }
if (params.get("order")) setSortDirection(params.get("order") as SortDirection) if (params.get("order"))
setSortDirection(params.get("order") as SortDirection)
}, []) }, [])
/* eslint-enable react-hooks/set-state-in-effect */ /* eslint-enable react-hooks/set-state-in-effect */
@@ -150,22 +192,36 @@ export function GamesPageClient({
params.set("order", sortDirection) params.set("order", sortDirection)
if (search) params.set("search", search) if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice) if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(",")) if (selectedGenres.length > 0)
params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps) if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps) if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true") if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative) if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus) if (antiCheatStatus !== "any")
if (playabilityStatus) params.set("playabilityStatus", playabilityStatus) params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus)
params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin) if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true") if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true") if (hasMultiplayer) params.set("hasMultiplayer", "true")
return `/api/games/listing?${params.toString()}` return `/api/games/listing?${params.toString()}`
}, },
[ [
sort, sortDirection, search, selectedDevice, selectedGenres, sort,
minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, sortDirection,
playabilityStatus, steamReviewMin, isFree, hasMultiplayer, search,
selectedDevice,
selectedGenres,
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
], ],
) )
@@ -194,13 +250,16 @@ export function GamesPageClient({
const params = new URLSearchParams() const params = new URLSearchParams()
if (search) params.set("search", search) if (search) params.set("search", search)
if (selectedDevice) params.set("device", selectedDevice) if (selectedDevice) params.set("device", selectedDevice)
if (selectedGenres.length > 0) params.set("genre", selectedGenres.join(",")) if (selectedGenres.length > 0)
params.set("genre", selectedGenres.join(","))
if (minFps) params.set("minFps", minFps) if (minFps) params.set("minFps", minFps)
if (maxFps) params.set("maxFps", maxFps) if (maxFps) params.set("maxFps", maxFps)
if (fsrSupport) params.set("fsrSupport", "true") if (fsrSupport) params.set("fsrSupport", "true")
if (protonNative !== "any") params.set("protonNative", protonNative) if (protonNative !== "any") params.set("protonNative", protonNative)
if (antiCheatStatus !== "any") params.set("antiCheatStatus", antiCheatStatus) if (antiCheatStatus !== "any")
if (playabilityStatus) params.set("playabilityStatus", playabilityStatus) params.set("antiCheatStatus", antiCheatStatus)
if (playabilityStatus)
params.set("playabilityStatus", playabilityStatus)
if (steamReviewMin) params.set("steamReviewScore", steamReviewMin) if (steamReviewMin) params.set("steamReviewScore", steamReviewMin)
if (isFree) params.set("isFree", "true") if (isFree) params.set("isFree", "true")
if (hasMultiplayer) params.set("hasMultiplayer", "true") if (hasMultiplayer) params.set("hasMultiplayer", "true")
@@ -210,7 +269,23 @@ export function GamesPageClient({
const qs = params.toString() const qs = params.toString()
const url = qs ? `/games?${qs}` : "/games" const url = qs ? `/games?${qs}` : "/games"
router.replace(url, { scroll: false }) router.replace(url, { scroll: false })
}, [search, selectedDevice, selectedGenres, minFps, maxFps, fsrSupport, protonNative, antiCheatStatus, playabilityStatus, steamReviewMin, isFree, hasMultiplayer, sort, sortDirection, router]) }, [
search,
selectedDevice,
selectedGenres,
minFps,
maxFps,
fsrSupport,
protonNative,
antiCheatStatus,
playabilityStatus,
steamReviewMin,
isFree,
hasMultiplayer,
sort,
sortDirection,
router,
])
// Full reload when filters/sort change // Full reload when filters/sort change
useEffect(() => { useEffect(() => {
@@ -229,7 +304,8 @@ export function GamesPageClient({
setTotal(data.total) setTotal(data.total)
} }
} catch (err) { } catch (err) {
if (!cancelled) setError("Failed to load games. Please try again.") if (!cancelled)
setError("Failed to load games. Please try again.")
console.error(err) console.error(err)
} finally { } finally {
if (!cancelled) setLoading(false) if (!cancelled) setLoading(false)
@@ -265,7 +341,9 @@ export function GamesPageClient({
const toggleGenre = (genre: string) => { const toggleGenre = (genre: string) => {
setSelectedGenres((prev) => setSelectedGenres((prev) =>
prev.includes(genre) ? prev.filter((g) => g !== genre) : [...prev, genre], prev.includes(genre)
? prev.filter((g) => g !== genre)
: [...prev, genre],
) )
} }
@@ -273,13 +351,13 @@ export function GamesPageClient({
<> <>
{/* Device filter */} {/* Device filter */}
<div> <div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block"> <span className='text-xs text-text/50 uppercase tracking-wider mb-1.5 block'>
Device Device
</span> </span>
<div className="flex flex-wrap gap-1.5"> <div className='flex flex-wrap gap-1.5'>
<button <button
onClick={() => setSelectedDevice("")} onClick={() => setSelectedDevice("")}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${ className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedDevice === "" selectedDevice === ""
? "bg-primary/10 text-primary border border-primary/30" ? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent" : "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -291,9 +369,13 @@ export function GamesPageClient({
<button <button
key={device.slug} key={device.slug}
onClick={() => onClick={() =>
setSelectedDevice(selectedDevice === device.slug ? "" : device.slug) setSelectedDevice(
selectedDevice === device.slug
? ""
: device.slug,
)
} }
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${ className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedDevice === device.slug selectedDevice === device.slug
? "bg-primary/10 text-primary border border-primary/30" ? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent" : "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -307,15 +389,15 @@ export function GamesPageClient({
{/* Genre filter */} {/* Genre filter */}
<div> <div>
<span className="text-xs text-text/50 uppercase tracking-wider mb-1.5 block"> <span className='text-xs text-text/50 uppercase tracking-wider mb-1.5 block'>
Genre Genre
</span> </span>
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto scrollbar-hide"> <div className='flex flex-wrap gap-1.5 max-h-32 overflow-y-auto scrollbar-hide'>
{allGenres.map((genre) => ( {allGenres.map((genre) => (
<button <button
key={genre} key={genre}
onClick={() => toggleGenre(genre)} onClick={() => toggleGenre(genre)}
className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-[44px] ${ className={`px-3 py-2.5 rounded-full text-xs font-medium transition-colors cursor-pointer min-h-11 ${
selectedGenres.includes(genre) selectedGenres.includes(genre)
? "bg-primary/10 text-primary border border-primary/30" ? "bg-primary/10 text-primary border border-primary/30"
: "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent" : "text-text/50 hover:text-text/70 hover:bg-text/5 border border-transparent"
@@ -328,97 +410,101 @@ export function GamesPageClient({
</div> </div>
{/* Performance Filters */} {/* Performance Filters */}
<div className="space-y-2"> <div className='space-y-2'>
<h4 className="text-sm font-medium text-zinc-300">Performance</h4> <h4 className='text-sm font-medium text-zinc-300'>
<div className="flex gap-2"> Performance
</h4>
<div className='flex gap-2'>
<input <input
type="number" type='number'
placeholder="Min FPS" placeholder='Min FPS'
value={minFps} value={minFps}
onChange={(e) => setMinFps(e.target.value)} onChange={(e) => setMinFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]" className='w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
/> />
<input <input
type="number" type='number'
placeholder="Max FPS" placeholder='Max FPS'
value={maxFps} value={maxFps}
onChange={(e) => setMaxFps(e.target.value)} onChange={(e) => setMaxFps(e.target.value)}
className="w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]" className='w-24 rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
/> />
</div> </div>
<select <select
value={playabilityStatus} value={playabilityStatus}
onChange={(e) => setPlayabilityStatus(e.target.value)} onChange={(e) => setPlayabilityStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]" className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
> >
<option value="">Any Playability</option> <option value=''>Any Playability</option>
<option value="great">Plays Great</option> <option value='great'>Plays Great</option>
<option value="playable">Playable</option> <option value='playable'>Playable</option>
<option value="needs_tweaks">Needs Tweaks</option> <option value='needs_tweaks'>Needs Tweaks</option>
<option value="unplayable">Unplayable</option> <option value='unplayable'>Unplayable</option>
</select> </select>
</div> </div>
{/* Compatibility Filters */} {/* Compatibility Filters */}
<div className="space-y-2"> <div className='space-y-2'>
<h4 className="text-sm font-medium text-zinc-300">Compatibility</h4> <h4 className='text-sm font-medium text-zinc-300'>
Compatibility
</h4>
<select <select
value={protonNative} value={protonNative}
onChange={(e) => setProtonNative(e.target.value)} onChange={(e) => setProtonNative(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]" className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
> >
<option value="any">Any Runtime</option> <option value='any'>Any Runtime</option>
<option value="native">Native</option> <option value='native'>Native</option>
<option value="proton">Proton</option> <option value='proton'>Proton</option>
</select> </select>
<select <select
value={antiCheatStatus} value={antiCheatStatus}
onChange={(e) => setAntiCheatStatus(e.target.value)} onChange={(e) => setAntiCheatStatus(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-[44px]" className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-2.5 text-sm min-h-11'
> >
<option value="any">Any Anti-Cheat</option> <option value='any'>Any Anti-Cheat</option>
<option value="supported">AC Supported</option> <option value='supported'>AC Supported</option>
<option value="unsupported">AC Unsupported</option> <option value='unsupported'>AC Unsupported</option>
<option value="unknown">AC Unknown</option> <option value='unknown'>AC Unknown</option>
</select> </select>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]"> <label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input <input
type="checkbox" type='checkbox'
checked={fsrSupport} checked={fsrSupport}
onChange={(e) => setFsrSupport(e.target.checked)} onChange={(e) => setFsrSupport(e.target.checked)}
className="rounded border-zinc-600" className='rounded border-zinc-600'
/> />
FSR Support FSR Support
</label> </label>
</div> </div>
{/* Other Filters */} {/* Other Filters */}
<div className="space-y-2"> <div className='space-y-2'>
<h4 className="text-sm font-medium text-zinc-300">Other</h4> <h4 className='text-sm font-medium text-zinc-300'>Other</h4>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]"> <label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input <input
type="checkbox" type='checkbox'
checked={isFree} checked={isFree}
onChange={(e) => setIsFree(e.target.checked)} onChange={(e) => setIsFree(e.target.checked)}
className="rounded border-zinc-600" className='rounded border-zinc-600'
/> />
Free to Play Free to Play
</label> </label>
<label className="flex items-center gap-2 text-sm py-2 min-h-[44px]"> <label className='flex items-center gap-2 text-sm py-2 min-h-11'>
<input <input
type="checkbox" type='checkbox'
checked={hasMultiplayer} checked={hasMultiplayer}
onChange={(e) => setHasMultiplayer(e.target.checked)} onChange={(e) => setHasMultiplayer(e.target.checked)}
className="rounded border-zinc-600" className='rounded border-zinc-600'
/> />
Has Multiplayer Has Multiplayer
</label> </label>
<input <input
type="number" type='number'
placeholder="Min Steam Review %" placeholder='Min Steam Review %'
value={steamReviewMin} value={steamReviewMin}
onChange={(e) => setSteamReviewMin(e.target.value)} onChange={(e) => setSteamReviewMin(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm" className='w-full rounded-md border border-zinc-700 bg-zinc-800 px-2 py-1 text-sm'
/> />
</div> </div>
@@ -443,14 +529,26 @@ export function GamesPageClient({
setMaxFps((filters.maxFps as string) || "") setMaxFps((filters.maxFps as string) || "")
setFsrSupport((filters.fsrSupport as boolean) || false) setFsrSupport((filters.fsrSupport as boolean) || false)
setProtonNative((filters.protonNative as string) || "any") setProtonNative((filters.protonNative as string) || "any")
setAntiCheatStatus((filters.antiCheatStatus as string) || "any") setAntiCheatStatus(
setPlayabilityStatus((filters.playabilityStatus as string) || "") (filters.antiCheatStatus as string) || "any",
)
setPlayabilityStatus(
(filters.playabilityStatus as string) || "",
)
setSteamReviewMin((filters.steamReviewMin as string) || "") setSteamReviewMin((filters.steamReviewMin as string) || "")
setIsFree((filters.isFree as boolean) || false) setIsFree((filters.isFree as boolean) || false)
setHasMultiplayer((filters.hasMultiplayer as boolean) || false) setHasMultiplayer(
if (filters.genre) setSelectedGenres((filters.genre as string).split(",").filter(Boolean)) (filters.hasMultiplayer as boolean) || false,
)
if (filters.genre)
setSelectedGenres(
(filters.genre as string)
.split(",")
.filter(Boolean),
)
else setSelectedGenres([]) else setSelectedGenres([])
if (filters.device) setSelectedDevice(filters.device as string) if (filters.device)
setSelectedDevice(filters.device as string)
else setSelectedDevice("") else setSelectedDevice("")
if (filters.sortBy) setSort(filters.sortBy as SortOption) if (filters.sortBy) setSort(filters.sortBy as SortOption)
setShowFilters(false) setShowFilters(false)
@@ -483,28 +581,31 @@ export function GamesPageClient({
setIsFree(false) setIsFree(false)
setHasMultiplayer(false) setHasMultiplayer(false)
}} }}
className="text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start min-h-[44px] py-2 flex items-center" className='text-xs text-text/50 hover:text-primary transition-colors cursor-pointer self-start min-h-11 py-2 flex items-center'
> >
Clear all filters Clear all filters
</button> </button>
)} )}
</> </>
) )
return ( return (
<section ref={pageRef} className={`w-full flex flex-col gap-8 py-8 ${isGamepadActive ? "gamepad-focus" : ""}`}> <section
ref={pageRef}
className={`w-full flex flex-col gap-8 py-8 ${isGamepadActive ? "gamepad-focus" : ""}`}
>
{/* Header */} {/* Header */}
<motion.div <motion.div
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4 }} transition={{ duration: 0.4 }}
className="px-4 md:px-[10svw]" className='px-4 md:px-[10svw]'
> >
<div className="max-w-7xl mx-auto"> <div className='max-w-7xl mx-auto'>
<h1 className="text-2xl sm:text-3xl font-bold">Games</h1> <h1 className='text-2xl sm:text-3xl font-bold'>Games</h1>
<p className="text-sm text-text/60 mt-1"> <p className='text-sm text-text/60 mt-1'>
Browse {total.toLocaleString()} games with benchmarks, settings, and performance data Browse {total.toLocaleString()} games with benchmarks,
settings, and performance data
</p> </p>
</div> </div>
</motion.div> </motion.div>
@@ -514,50 +615,63 @@ export function GamesPageClient({
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.05 }} transition={{ duration: 0.4, delay: 0.05 }}
className="px-4 md:px-[10svw]" className='px-4 md:px-[10svw]'
> >
<div className="max-w-7xl mx-auto flex flex-col gap-3"> <div className='max-w-7xl mx-auto flex flex-col gap-3'>
{/* Search + Sort Row */} {/* Search + Sort Row */}
<div className="flex flex-row items-center gap-3"> <div className='flex flex-row items-center flex-wrap gap-3'>
<label className="flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2.5 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text min-h-[44px]"> <label className='flex-1 flex flex-row items-center gap-2 bg-text/5 px-3 py-2.5 rounded-md border border-border hover:border-border-active focus-within:border-primary/80 focus-within:ring-2 focus-within:ring-primary/50 focus-within:ring-offset-2 focus-within:ring-offset-background transition-colors cursor-text min-h-11'>
<SearchIcon className="h-4 w-4 text-text/40 shrink-0" /> <SearchIcon className='h-4 w-4 text-text/40 shrink-0' />
<input <input
type="text" type='text'
placeholder="Search games..." placeholder='Search games...'
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
className="flex-1 outline-none bg-transparent text-sm min-w-0" className='flex-1 outline-none bg-transparent text-sm min-w-0'
/> />
{search && ( {search && (
<button <button
onClick={() => setSearch("")} onClick={() => setSearch("")}
className="text-text/40 hover:text-text/70 transition-colors cursor-pointer" className='text-text/40 hover:text-text/70 transition-colors cursor-pointer'
> >
<XIcon className="h-3.5 w-3.5" /> <XIcon className='h-3.5 w-3.5' />
</button> </button>
)} )}
</label> </label>
<button
onClick={() =>
setSortDirection((prev) =>
prev === "asc" ? "desc" : "asc",
)
}
className='px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer min-h-11'
title={
sortDirection === "asc"
? "Sort ascending"
: "Sort descending"
}
>
{sortDirection === "asc" ? "↑" : "↓"}
</button>
<select <select
value={sort} value={sort}
onChange={(e) => setSort(e.target.value as SortOption)} onChange={(e) =>
className="bg-text/5 border border-border rounded-md px-3 py-2 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 cursor-pointer" setSort(e.target.value as SortOption)
}
className='bg-text/5 border border-border rounded-md px-3 py-3 text-sm outline-none focus:border-primary/80 focus:ring-2 focus:ring-primary/50 cursor-pointer'
> >
{SORT_OPTIONS.map((opt) => ( {SORT_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}> <option
key={opt.value}
value={opt.value}
>
{opt.label} {opt.label}
</option> </option>
))} ))}
</select> </select>
<button
onClick={() => setSortDirection(prev => prev === "asc" ? "desc" : "asc")}
className="px-2 py-2 rounded-md text-sm bg-text/5 border border-border hover:bg-text/10 transition-colors cursor-pointer min-h-[44px]"
title={sortDirection === "asc" ? "Sort ascending" : "Sort descending"}
>
{sortDirection === "asc" ? "↑" : "↓"}
</button>
<button <button
onClick={() => setShowFilters(!showFilters)} onClick={() => setShowFilters(!showFilters)}
className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-[44px] ${ className={`px-3 py-2 rounded-md text-sm transition-colors cursor-pointer border min-h-11 ${
showFilters || showFilters ||
selectedDevice || selectedDevice ||
selectedGenres.length > 0 || selectedGenres.length > 0 ||
@@ -586,7 +700,7 @@ export function GamesPageClient({
steamReviewMin || steamReviewMin ||
isFree || isFree ||
hasMultiplayer) && ( hasMultiplayer) && (
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold"> <span className='ml-1.5 inline-flex items-center justify-center w-4 h-4 rounded-full bg-primary text-background text-[10px] font-bold'>
{selectedGenres.length + {selectedGenres.length +
(selectedDevice ? 1 : 0) + (selectedDevice ? 1 : 0) +
(minFps ? 1 : 0) + (minFps ? 1 : 0) +
@@ -604,8 +718,11 @@ export function GamesPageClient({
</div> </div>
{/* Mobile drawer */} {/* Mobile drawer */}
<FilterDrawer isOpen={showFilters} onClose={() => setShowFilters(false)}> <FilterDrawer
<div className="flex flex-col gap-3"> isOpen={showFilters}
onClose={() => setShowFilters(false)}
>
<div className='flex flex-col gap-3'>
{filterPanelContent} {filterPanelContent}
</div> </div>
</FilterDrawer> </FilterDrawer>
@@ -616,7 +733,7 @@ export function GamesPageClient({
initial={{ opacity: 0, height: 0 }} initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }} animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }} exit={{ opacity: 0, height: 0 }}
className="hidden lg:flex flex-col gap-3 pt-1" className='hidden lg:flex flex-col gap-3 pt-1'
> >
{filterPanelContent} {filterPanelContent}
</motion.div> </motion.div>
@@ -626,11 +743,11 @@ export function GamesPageClient({
{/* Error State */} {/* Error State */}
{error && !loading && ( {error && !loading && (
<div className="max-w-7xl mx-auto px-4 md:px-0 text-center py-12"> <div className='max-w-7xl mx-auto px-4 md:px-0 text-center py-12'>
<p className="text-red-400 text-sm">{error}</p> <p className='text-red-400 text-sm'>{error}</p>
<button <button
onClick={() => window.location.reload()} onClick={() => window.location.reload()}
className="mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-[44px] py-2 flex items-center" className='mt-2 text-xs text-text/50 hover:text-primary transition-colors cursor-pointer min-h-11 py-2 flex items-center'
> >
Try again Try again
</button> </button>
@@ -642,19 +759,22 @@ export function GamesPageClient({
initial={{ opacity: 0, y: 12 }} initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.1 }} transition={{ duration: 0.4, delay: 0.1 }}
className="px-4 md:px-[10svw]" className='px-4 md:px-[10svw]'
> >
<div className="max-w-7xl mx-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4"> <div className='max-w-7xl mx-auto grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-3 sm:gap-4'>
{games.map((game) => ( {games.map((game) => (
<GameCard key={game.id} game={game} /> <GameCard
key={game.id}
game={game}
/>
))} ))}
</div> </div>
{/* Empty state */} {/* Empty state */}
{!loading && games.length === 0 && !error && ( {!loading && games.length === 0 && !error && (
<div className="flex flex-col items-center justify-center py-20 gap-4"> <div className='flex flex-col items-center justify-center py-20 gap-4'>
<Gamepad2Icon className="h-12 w-12 text-text/20" /> <Gamepad2Icon className='h-12 w-12 text-text/20' />
<p className="text-text/40 text-sm"> <p className='text-text/40 text-sm'>
{search || {search ||
selectedDevice || selectedDevice ||
selectedGenres.length > 0 || selectedGenres.length > 0 ||
@@ -697,7 +817,7 @@ export function GamesPageClient({
setIsFree(false) setIsFree(false)
setHasMultiplayer(false) setHasMultiplayer(false)
}} }}
className="text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer min-h-[44px]" className='text-xs text-primary hover:text-primary/80 transition-colors cursor-pointer min-h-11'
> >
Clear filters Clear filters
</button> </button>
@@ -708,22 +828,28 @@ export function GamesPageClient({
{/* Loading indicator for infinite scroll */} {/* Loading indicator for infinite scroll */}
{loading && ( {loading && (
<div className="flex items-center justify-center py-8"> <div className='flex items-center justify-center py-8'>
<Loader2Icon className="h-6 w-6 text-primary animate-spin" /> <Loader2Icon className='h-6 w-6 text-primary animate-spin' />
</div> </div>
)} )}
{/* End of list */} {/* End of list */}
{!loading && !hasMore && games.length > 0 && ( {!loading && !hasMore && games.length > 0 && (
<div className="text-center py-6"> <div className='text-center py-6'>
<p className="text-text/30 text-xs"> <p className='text-text/30 text-xs'>
Showing all {games.length} of {total.toLocaleString()} games Showing all {games.length} of {total.toLocaleString()}{" "}
games
</p> </p>
</div> </div>
)} )}
{/* Infinite scroll sentinel */} {/* Infinite scroll sentinel */}
{hasMore && !loading && <div ref={sentinelRef} className="h-1" />} {hasMore && !loading && (
<div
ref={sentinelRef}
className='h-1'
/>
)}
</section> </section>
) )
} }
@@ -733,39 +859,43 @@ function GameCard({ game }: { game: GamesListItem }) {
const imageUrl = game.capsuleImage || game.headerImage const imageUrl = game.capsuleImage || game.headerImage
const deckConfig = game.deckStatus const deckConfig = game.deckStatus
? DECK_STATUS_CONFIG[game.deckStatus] ?? DECK_STATUS_CONFIG.unknown ? (DECK_STATUS_CONFIG[game.deckStatus] ?? DECK_STATUS_CONFIG.unknown)
: null : null
return ( return (
<Link <Link
href={`/game/${game.id}`} href={`/game/${game.id}`}
className="group rounded-xl border border-border bg-text/3 hover:border-primary/30 transition-all duration-200 overflow-hidden" className='group rounded-xl border border-border bg-text/3 hover:border-primary/30 transition-all duration-200 overflow-hidden'
> >
<div className="relative w-full aspect-[2/3] bg-text/10 overflow-hidden"> <div className='relative w-full aspect-2/3 bg-text/10 overflow-hidden'>
{imageUrl && !imgError ? ( {imageUrl && !imgError ? (
<Image <Image
src={imageUrl} src={imageUrl}
alt={game.title} alt={game.title}
fill fill
className="object-cover group-hover:scale-105 transition-transform duration-300" className='object-cover group-hover:scale-105 transition-transform duration-300'
sizes="(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw" sizes='(max-width: 640px) 50vw, (max-width: 768px) 33vw, (max-width: 1024px) 25vw, 20vw'
onError={() => setImgError(true)} onError={() => setImgError(true)}
/> />
) : ( ) : (
<div className="w-full h-full flex items-center justify-center"> <div className='w-full h-full flex items-center justify-center'>
<Gamepad2Icon className="h-8 w-8 text-text/20" /> <Gamepad2Icon className='h-8 w-8 text-text/20' />
</div> </div>
)} )}
</div> </div>
<div className="p-2.5 sm:p-3"> <div className='p-2.5 sm:p-3'>
<h3 className="text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight"> <h3 className='text-xs sm:text-sm font-semibold text-text group-hover:text-primary transition-colors line-clamp-2 leading-tight'>
{game.title} {game.title}
</h3> </h3>
<div className="flex flex-wrap gap-1 mt-1"> <div className='flex flex-wrap gap-1 mt-1'>
{game.playabilityStatus && ( {game.playabilityStatus && (
<PlayabilityBadge status={game.playabilityStatus} compact /> <PlayabilityBadge
status={game.playabilityStatus}
compact
/>
)} )}
{game.antiCheatRelevant && game.antiCheatStatus === "unsupported" && ( {game.antiCheatRelevant &&
game.antiCheatStatus === "unsupported" && (
<AntiCheatBadge <AntiCheatBadge
antiCheatRelevant={true} antiCheatRelevant={true}
antiCheatStatus={game.antiCheatStatus} antiCheatStatus={game.antiCheatStatus}
@@ -773,22 +903,30 @@ function GameCard({ game }: { game: GamesListItem }) {
/> />
)} )}
{game.steamReviewScore != null && ( {game.steamReviewScore != null && (
<span className="inline-flex items-center gap-1 text-[10px] text-blue-400"> <span className='inline-flex items-center gap-1 text-[10px] text-blue-400'>
<svg className="h-2.5 w-2.5" viewBox="0 0 24 24" fill="currentColor"> <svg
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" /> className='h-2.5 w-2.5'
viewBox='0 0 24 24'
fill='currentColor'
>
<path d='M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z' />
</svg> </svg>
{game.steamReviewScore}% {game.steamReviewScore}%
</span> </span>
)} )}
</div> </div>
<div className="mt-1.5 flex items-center gap-2 flex-wrap"> <div className='mt-1.5 flex items-center gap-2 flex-wrap'>
{game.benchmarkCount > 0 ? ( {game.benchmarkCount > 0 ? (
<span className="inline-flex items-center gap-1 text-[10px] text-text/50"> <span className='inline-flex items-center gap-1 text-[10px] text-text/50'>
<TrendingUpIcon className="h-3 w-3 text-primary/60" /> <TrendingUpIcon className='h-3 w-3 text-primary/60' />
<span className="tabular-nums">{game.benchmarkCount}</span> <span className='tabular-nums'>
{game.benchmarkCount}
</span>
</span> </span>
) : ( ) : (
<span className="text-[10px] text-text/25 italic">No data yet</span> <span className='text-[10px] text-text/25 italic'>
No data yet
</span>
)} )}
{deckConfig && ( {deckConfig && (
<span <span
+13 -11
View File
@@ -54,7 +54,7 @@ export function FilterDrawer({ isOpen, onClose, children }: FilterDrawerProps) {
initial={{ opacity: 0 }} initial={{ opacity: 0 }}
animate={{ opacity: 1 }} animate={{ opacity: 1 }}
exit={{ opacity: 0 }} exit={{ opacity: 0 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden" className='fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden'
onClick={onClose} onClick={onClose}
/> />
@@ -64,22 +64,24 @@ export function FilterDrawer({ isOpen, onClose, children }: FilterDrawerProps) {
initial={{ x: "100%" }} initial={{ x: "100%" }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: "100%" }} exit={{ x: "100%" }}
transition={{ type: "spring", damping: 30, stiffness: 300 }} transition={{
className="fixed top-0 right-0 bottom-0 z-50 w-[85vw] max-w-sm overflow-y-auto bg-background border-l border-border lg:hidden" type: "spring",
damping: 30,
stiffness: 300,
}}
className='fixed top-0 right-0 bottom-0 z-50 w-[85vw] max-w-sm overflow-y-auto bg-background border-l border-border lg:hidden'
> >
<div className="sticky top-0 z-10 flex items-center justify-between p-4 border-b border-border bg-background"> <div className='sticky top-0 z-10 flex items-center justify-between p-4 border-b border-border bg-background'>
<h2 className="text-sm font-semibold">Filters</h2> <h2 className='text-sm font-semibold'>Filters</h2>
<button <button
onClick={onClose} onClick={onClose}
className="p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer" className='p-1.5 rounded-md hover:bg-text/5 transition-colors cursor-pointer'
aria-label="Close filters" aria-label='Close filters'
> >
<XIcon className="h-4 w-4 text-text/50" /> <XIcon className='h-4 w-4 text-text/50' />
</button> </button>
</div> </div>
<div className="p-4"> <div className='p-4'>{children}</div>
{children}
</div>
</motion.div> </motion.div>
</> </>
)} )}
+24 -18
View File
@@ -6,13 +6,14 @@ import {
performanceEntries, performanceEntries,
hardware, hardware,
} from "@/lib/db/schema" } 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( export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
"/:gameId/stats", "/:gameId/stats",
async ({ params, set }) => { async ({ params, set }) => {
const { gameId } = params const { gameId } = params
try {
// Verify game exists // Verify game exists
const [game] = await db const [game] = await db
.select({ id: games.id, steamAppId: games.steamAppId }) .select({ id: games.id, steamAppId: games.steamAppId })
@@ -163,7 +164,7 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
Map<string, { sum: number; count: number }> Map<string, { sum: number; count: number }>
>() >()
for (const e of entries) { 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")}` const month = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`
if (!histMap.has(month)) histMap.set(month, new Map()) if (!histMap.has(month)) histMap.set(month, new Map())
const deviceMap = histMap.get(month)! const deviceMap = histMap.get(month)!
@@ -245,6 +246,22 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
const bestOnePercentLow = entries.reduce((best, e) => const bestOnePercentLow = entries.reduce((best, e) =>
e.fpsOnePercentLow != null && e.fpsOnePercentLow > (best ?? 0) ? e.fpsOnePercentLow : best, null as number | null) 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 ─────────────────────────────────────── // ── 8. Device breakdown ───────────────────────────────────────
const deviceBreakdown = Array.from(boxplotMap.entries()).map( const deviceBreakdown = Array.from(boxplotMap.entries()).map(
([slug, { hardwareName, values }]) => { ([slug, { hardwareName, values }]) => {
@@ -288,22 +305,6 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
})) }))
// ── 12. Battery life estimates ───────────────────────────── // ── 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 const batteryLife = entries
.filter((e) => e.tdpWatts != null && e.tdpWatts > 0) .filter((e) => e.tdpWatts != null && e.tdpWatts > 0)
.map((e) => { .map((e) => {
@@ -359,6 +360,11 @@ export const gameStatsRoutes = new Elysia({ prefix: "/games" }).get(
batteryLife, batteryLife,
filterOptions: { protonVersions, osVersions }, 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() }), params: t.Object({ gameId: t.String() }),
+1 -1
View File
@@ -43,7 +43,7 @@ const nextConfig: NextConfig = {
// Cloudflare R2 // Cloudflare R2
{ {
protocol: "https", protocol: "https",
hostname: "**.r2.dev", hostname: "cdn.deckyvault.xyz",
} }
], ],
}, },