feat: mobile hero background with gradient overlay on game details page

This commit is contained in:
2026-05-09 23:01:26 +08:00
parent fbc041d62d
commit f03411a8be
+312 -225
View File
@@ -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>