feat: mobile hero background with gradient overlay on game details page
This commit is contained in:
+312
-225
@@ -252,237 +252,37 @@ function generatePresetName(preset: Preset): string {
|
||||
return parts.join(" · ")
|
||||
}
|
||||
|
||||
export function GamePageClient({
|
||||
function HeroInfo({
|
||||
game,
|
||||
stats,
|
||||
session,
|
||||
counts,
|
||||
coverImage,
|
||||
imgError,
|
||||
handleImgError,
|
||||
platformSupport,
|
||||
presets,
|
||||
protonDbUrl,
|
||||
steamDbUrl,
|
||||
gameId,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session } = useSession()
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null)
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
const [filters, setFilters] = useState({
|
||||
proton: "all",
|
||||
os: "all",
|
||||
upscaler: "all",
|
||||
frameGen: "all",
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
|
||||
const [showSystemReq, setShowSystemReq] = useState(true)
|
||||
const [reportedPresets, setReportedPresets] = useState<Set<string>>(new Set())
|
||||
const presetsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// On mount, auto-open preset from URL
|
||||
useEffect(() => {
|
||||
const presetId = searchParams.get("preset")
|
||||
queueMicrotask(() => {
|
||||
if (presetId) {
|
||||
setSelectedPresetId(presetId)
|
||||
} else {
|
||||
setSelectedPresetId(null)
|
||||
}
|
||||
})
|
||||
}, [searchParams])
|
||||
|
||||
const handlePresetOpen = (presetId: string) => {
|
||||
setSelectedPresetId(presetId)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("preset", presetId)
|
||||
router.replace(url.toString(), { scroll: false })
|
||||
}
|
||||
|
||||
const handlePresetClose = () => {
|
||||
setSelectedPresetId(null)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("preset")
|
||||
router.replace(url.toString(), { scroll: false })
|
||||
}
|
||||
|
||||
const handleDeletePreset = async (presetId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/performance/${presetId}/user-delete`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
if (res.ok) {
|
||||
setSelectedPresetId(null)
|
||||
router.refresh()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete preset:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReportPreset = async (presetId: string, reason: "inaccurate" | "spam" | "inappropriate" | "other", details?: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/performance/${presetId}/report`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason, details }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setReportedPresets((prev) => new Set(prev).add(presetId))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to report preset:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImgError = useCallback(() => setImgError(true), [])
|
||||
|
||||
// Fetch stats on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function fetchStats() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/games/${gameId}/stats`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!cancelled) setStats(data)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch stats:", err)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchStats()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [gameId])
|
||||
|
||||
// Initialize selected devices when stats load
|
||||
const didInitDevices = useRef(false)
|
||||
useEffect(() => {
|
||||
if (stats && !didInitDevices.current) {
|
||||
didInitDevices.current = true
|
||||
const slugs = stats.deviceBreakdown.map((d) => d.hardwareSlug)
|
||||
queueMicrotask(() => setSelectedDevices(slugs))
|
||||
}
|
||||
}, [stats])
|
||||
|
||||
// Filtered stats
|
||||
const filteredStats = useMemo(() => {
|
||||
if (!stats) return null
|
||||
|
||||
const deviceSet = new Set(selectedDevices)
|
||||
|
||||
const filterByDevice = <T extends { hardwareSlug: string }>(arr: T[] | undefined) =>
|
||||
arr?.filter((item) => deviceSet.has(item.hardwareSlug)) ?? []
|
||||
|
||||
const filterUpscaler = (arr: StatsResponse["upscalerStats"] | undefined) =>
|
||||
(arr ?? []).filter((item) => {
|
||||
if (!deviceSet.has(item.hardwareSlug)) return false
|
||||
if (filters.upscaler !== "all" && item.upscalerType !== filters.upscaler)
|
||||
return false
|
||||
if (
|
||||
filters.frameGen !== "all" &&
|
||||
item.frameGenMethod !== filters.frameGen
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
...stats,
|
||||
boxplot: filterByDevice(stats.boxplot),
|
||||
historical: (stats.historical ?? [])
|
||||
.map((h) => ({
|
||||
...h,
|
||||
entries: h.entries.filter((e) =>
|
||||
deviceSet.has(e.hardwareSlug),
|
||||
),
|
||||
}))
|
||||
.filter((h) => h.entries.length > 0),
|
||||
upscalerStats: filterUpscaler(stats.upscalerStats),
|
||||
fpsRange: filterByDevice(stats.fpsRange),
|
||||
deviceBreakdown: filterByDevice(stats.deviceBreakdown),
|
||||
performanceTiers: filterByDevice(stats.performanceTiers),
|
||||
stabilityScatter: filterByDevice(stats.stabilityScatter),
|
||||
}
|
||||
}, [stats, selectedDevices, filters])
|
||||
|
||||
// Filtered presets
|
||||
const filteredPresets = useMemo(() => {
|
||||
return presets.filter((p) => {
|
||||
if (
|
||||
selectedDevices.length > 0 &&
|
||||
!selectedDevices.includes(p.hardwareSlug)
|
||||
)
|
||||
return false
|
||||
if (filters.proton !== "all" && p.protonVersion !== filters.proton)
|
||||
return false
|
||||
if (filters.os !== "all" && p.osVersion !== filters.os) return false
|
||||
if (filters.upscaler !== "all" && p.upscalerType !== filters.upscaler)
|
||||
return false
|
||||
if (
|
||||
filters.frameGen !== "all" &&
|
||||
p.frameGenMethod !== filters.frameGen
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}, [presets, selectedDevices, filters])
|
||||
|
||||
const pinnedPresets = useMemo(() => filteredPresets.filter((p) => p.isPinned), [filteredPresets])
|
||||
const regularPresets = useMemo(() => filteredPresets.filter((p) => !p.isPinned), [filteredPresets])
|
||||
|
||||
const coverImage = game.capsuleImage || game.headerImage
|
||||
|
||||
const protonDbUrl = game.steamAppId
|
||||
? `https://www.protondb.com/app/${game.steamAppId}`
|
||||
: null
|
||||
const steamDbUrl = game.steamAppId
|
||||
? `https://steamdb.info/app/${game.steamAppId}`
|
||||
: null
|
||||
|
||||
const toggleDevice = useCallback((slug: string) => {
|
||||
setSelectedDevices((prev) =>
|
||||
prev.includes(slug)
|
||||
? prev.filter((s) => s !== slug)
|
||||
: [...prev, slug],
|
||||
)
|
||||
}, [])
|
||||
|
||||
formatDate,
|
||||
Badge,
|
||||
}: {
|
||||
game: Game
|
||||
stats: StatsResponse | null
|
||||
session: { user?: { id?: string } } | null
|
||||
counts: Counts
|
||||
coverImage: string | null
|
||||
imgError: boolean
|
||||
handleImgError: () => void
|
||||
platformSupport: PlatformSupport[]
|
||||
protonDbUrl: string | null
|
||||
steamDbUrl: string | null
|
||||
gameId: string
|
||||
formatDate: (value: string | null) => string
|
||||
Badge: React.ComponentType<{ icon: React.ElementType; value: number; label: string }>
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<section className='w-full flex flex-col gap-8 pb-16'>
|
||||
{/* Section 1: Hero Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className='px-4 md:px-[10svw] pt-6'
|
||||
>
|
||||
<div className='max-w-7xl mx-auto flex gap-4 sm:gap-6'>
|
||||
{/* 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'>
|
||||
{coverImage && !imgError ? (
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt={game.title}
|
||||
width={600}
|
||||
height={900}
|
||||
className='object-cover'
|
||||
priority
|
||||
onError={handleImgError}
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
<Gamepad2Icon className='h-10 w-10 text-text/20' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className='flex flex-col gap-2 min-w-0'>
|
||||
{/* Title row */}
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h1 className='text-xl sm:text-2xl md:text-3xl font-bold truncate'>
|
||||
@@ -720,6 +520,293 @@ export function GamePageClient({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function GamePageClient({
|
||||
game,
|
||||
counts,
|
||||
platformSupport,
|
||||
presets,
|
||||
gameId,
|
||||
}: Props) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const { data: session } = useSession()
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null)
|
||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||
const [filters, setFilters] = useState({
|
||||
proton: "all",
|
||||
os: "all",
|
||||
upscaler: "all",
|
||||
frameGen: "all",
|
||||
})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
|
||||
const [showSystemReq, setShowSystemReq] = useState(true)
|
||||
const [reportedPresets, setReportedPresets] = useState<Set<string>>(new Set())
|
||||
const presetsRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// On mount, auto-open preset from URL
|
||||
useEffect(() => {
|
||||
const presetId = searchParams.get("preset")
|
||||
queueMicrotask(() => {
|
||||
if (presetId) {
|
||||
setSelectedPresetId(presetId)
|
||||
} else {
|
||||
setSelectedPresetId(null)
|
||||
}
|
||||
})
|
||||
}, [searchParams])
|
||||
|
||||
const handlePresetOpen = (presetId: string) => {
|
||||
setSelectedPresetId(presetId)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set("preset", presetId)
|
||||
router.replace(url.toString(), { scroll: false })
|
||||
}
|
||||
|
||||
const handlePresetClose = () => {
|
||||
setSelectedPresetId(null)
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.delete("preset")
|
||||
router.replace(url.toString(), { scroll: false })
|
||||
}
|
||||
|
||||
const handleDeletePreset = async (presetId: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/performance/${presetId}/user-delete`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})
|
||||
if (res.ok) {
|
||||
setSelectedPresetId(null)
|
||||
router.refresh()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete preset:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleReportPreset = async (presetId: string, reason: "inaccurate" | "spam" | "inappropriate" | "other", details?: string) => {
|
||||
try {
|
||||
const res = await fetch(`/api/performance/${presetId}/report`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reason, details }),
|
||||
})
|
||||
if (res.ok) {
|
||||
setReportedPresets((prev) => new Set(prev).add(presetId))
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to report preset:", err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImgError = useCallback(() => setImgError(true), [])
|
||||
|
||||
// Fetch stats on mount
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function fetchStats() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await fetch(`/api/games/${gameId}/stats`)
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const data = await res.json()
|
||||
if (!cancelled) setStats(data)
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch stats:", err)
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchStats()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [gameId])
|
||||
|
||||
// Initialize selected devices when stats load
|
||||
const didInitDevices = useRef(false)
|
||||
useEffect(() => {
|
||||
if (stats && !didInitDevices.current) {
|
||||
didInitDevices.current = true
|
||||
const slugs = stats.deviceBreakdown.map((d) => d.hardwareSlug)
|
||||
queueMicrotask(() => setSelectedDevices(slugs))
|
||||
}
|
||||
}, [stats])
|
||||
|
||||
// Filtered stats
|
||||
const filteredStats = useMemo(() => {
|
||||
if (!stats) return null
|
||||
|
||||
const deviceSet = new Set(selectedDevices)
|
||||
|
||||
const filterByDevice = <T extends { hardwareSlug: string }>(arr: T[] | undefined) =>
|
||||
arr?.filter((item) => deviceSet.has(item.hardwareSlug)) ?? []
|
||||
|
||||
const filterUpscaler = (arr: StatsResponse["upscalerStats"] | undefined) =>
|
||||
(arr ?? []).filter((item) => {
|
||||
if (!deviceSet.has(item.hardwareSlug)) return false
|
||||
if (filters.upscaler !== "all" && item.upscalerType !== filters.upscaler)
|
||||
return false
|
||||
if (
|
||||
filters.frameGen !== "all" &&
|
||||
item.frameGenMethod !== filters.frameGen
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
|
||||
return {
|
||||
...stats,
|
||||
boxplot: filterByDevice(stats.boxplot),
|
||||
historical: (stats.historical ?? [])
|
||||
.map((h) => ({
|
||||
...h,
|
||||
entries: h.entries.filter((e) =>
|
||||
deviceSet.has(e.hardwareSlug),
|
||||
),
|
||||
}))
|
||||
.filter((h) => h.entries.length > 0),
|
||||
upscalerStats: filterUpscaler(stats.upscalerStats),
|
||||
fpsRange: filterByDevice(stats.fpsRange),
|
||||
deviceBreakdown: filterByDevice(stats.deviceBreakdown),
|
||||
performanceTiers: filterByDevice(stats.performanceTiers),
|
||||
stabilityScatter: filterByDevice(stats.stabilityScatter),
|
||||
}
|
||||
}, [stats, selectedDevices, filters])
|
||||
|
||||
// Filtered presets
|
||||
const filteredPresets = useMemo(() => {
|
||||
return presets.filter((p) => {
|
||||
if (
|
||||
selectedDevices.length > 0 &&
|
||||
!selectedDevices.includes(p.hardwareSlug)
|
||||
)
|
||||
return false
|
||||
if (filters.proton !== "all" && p.protonVersion !== filters.proton)
|
||||
return false
|
||||
if (filters.os !== "all" && p.osVersion !== filters.os) return false
|
||||
if (filters.upscaler !== "all" && p.upscalerType !== filters.upscaler)
|
||||
return false
|
||||
if (
|
||||
filters.frameGen !== "all" &&
|
||||
p.frameGenMethod !== filters.frameGen
|
||||
)
|
||||
return false
|
||||
return true
|
||||
})
|
||||
}, [presets, selectedDevices, filters])
|
||||
|
||||
const pinnedPresets = useMemo(() => filteredPresets.filter((p) => p.isPinned), [filteredPresets])
|
||||
const regularPresets = useMemo(() => filteredPresets.filter((p) => !p.isPinned), [filteredPresets])
|
||||
|
||||
const coverImage = game.capsuleImage || game.headerImage
|
||||
|
||||
const protonDbUrl = game.steamAppId
|
||||
? `https://www.protondb.com/app/${game.steamAppId}`
|
||||
: null
|
||||
const steamDbUrl = game.steamAppId
|
||||
? `https://steamdb.info/app/${game.steamAppId}`
|
||||
: null
|
||||
|
||||
const toggleDevice = useCallback((slug: string) => {
|
||||
setSelectedDevices((prev) =>
|
||||
prev.includes(slug)
|
||||
? prev.filter((s) => s !== slug)
|
||||
: [...prev, slug],
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className='w-full flex flex-col gap-8 pb-16'>
|
||||
{/* Section 1: Hero Header */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className='px-4 md:px-[10svw] pt-6'
|
||||
>
|
||||
{/* Mobile hero — full-bleed background */}
|
||||
<div className='relative md:hidden'>
|
||||
{coverImage && !imgError ? (
|
||||
<div className='absolute inset-0 z-0'>
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt=''
|
||||
fill
|
||||
className='object-cover'
|
||||
sizes='100vw'
|
||||
priority
|
||||
onError={handleImgError}
|
||||
/>
|
||||
<div className='absolute inset-0 bg-gradient-to-t from-black/70 via-black/40 to-black/30' />
|
||||
</div>
|
||||
) : (
|
||||
<div className='absolute inset-0 z-0 bg-gradient-to-b from-primary/20 to-background' />
|
||||
)}
|
||||
<div className='relative z-10 flex flex-col gap-2 min-w-0 px-4 pt-24 pb-6'>
|
||||
<HeroInfo
|
||||
game={game}
|
||||
stats={stats}
|
||||
session={session}
|
||||
counts={counts}
|
||||
coverImage={coverImage}
|
||||
imgError={imgError}
|
||||
handleImgError={handleImgError}
|
||||
platformSupport={platformSupport}
|
||||
protonDbUrl={protonDbUrl}
|
||||
steamDbUrl={steamDbUrl}
|
||||
gameId={gameId}
|
||||
formatDate={formatDate}
|
||||
Badge={Badge}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Desktop hero — side-by-side layout */}
|
||||
<div className='hidden md:flex gap-4 sm:gap-6'>
|
||||
{/* 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'>
|
||||
{coverImage && !imgError ? (
|
||||
<Image
|
||||
src={coverImage}
|
||||
alt={game.title}
|
||||
width={600}
|
||||
height={900}
|
||||
className='object-cover'
|
||||
priority
|
||||
onError={handleImgError}
|
||||
loading="eager"
|
||||
/>
|
||||
) : (
|
||||
<div className='w-full h-full flex items-center justify-center'>
|
||||
<Gamepad2Icon className='h-10 w-10 text-text/20' />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Info */}
|
||||
<div className='flex flex-col gap-2 min-w-0'>
|
||||
<HeroInfo
|
||||
game={game}
|
||||
stats={stats}
|
||||
session={session}
|
||||
counts={counts}
|
||||
coverImage={coverImage}
|
||||
imgError={imgError}
|
||||
handleImgError={handleImgError}
|
||||
platformSupport={platformSupport}
|
||||
protonDbUrl={protonDbUrl}
|
||||
steamDbUrl={steamDbUrl}
|
||||
gameId={gameId}
|
||||
formatDate={formatDate}
|
||||
Badge={Badge}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
Reference in New Issue
Block a user