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:
@@ -51,3 +51,6 @@ public/sw.js
|
|||||||
.pi/
|
.pi/
|
||||||
|
|
||||||
.superpowers/
|
.superpowers/
|
||||||
|
|
||||||
|
# temporary files
|
||||||
|
post.md
|
||||||
+429
-137
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+285
-147
@@ -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,15 +47,35 @@ 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 }[] = [
|
||||||
{ value: "recent", label: "Recently Added" },
|
{ value: "recent", label: "Recently Added" },
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -43,7 +43,7 @@ const nextConfig: NextConfig = {
|
|||||||
// Cloudflare R2
|
// Cloudflare R2
|
||||||
{
|
{
|
||||||
protocol: "https",
|
protocol: "https",
|
||||||
hostname: "**.r2.dev",
|
hostname: "cdn.deckyvault.xyz",
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user