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(" · ")
|
return parts.join(" · ")
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GamePageClient({
|
function HeroInfo({
|
||||||
game,
|
game,
|
||||||
|
stats,
|
||||||
|
session,
|
||||||
counts,
|
counts,
|
||||||
|
coverImage,
|
||||||
|
imgError,
|
||||||
|
handleImgError,
|
||||||
platformSupport,
|
platformSupport,
|
||||||
presets,
|
protonDbUrl,
|
||||||
|
steamDbUrl,
|
||||||
gameId,
|
gameId,
|
||||||
}: Props) {
|
formatDate,
|
||||||
const router = useRouter()
|
Badge,
|
||||||
const searchParams = useSearchParams()
|
}: {
|
||||||
const { data: session } = useSession()
|
game: Game
|
||||||
const [imgError, setImgError] = useState(false)
|
stats: StatsResponse | null
|
||||||
const [stats, setStats] = useState<StatsResponse | null>(null)
|
session: { user?: { id?: string } } | null
|
||||||
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
counts: Counts
|
||||||
const [filters, setFilters] = useState({
|
coverImage: string | null
|
||||||
proton: "all",
|
imgError: boolean
|
||||||
os: "all",
|
handleImgError: () => void
|
||||||
upscaler: "all",
|
platformSupport: PlatformSupport[]
|
||||||
frameGen: "all",
|
protonDbUrl: string | null
|
||||||
})
|
steamDbUrl: string | null
|
||||||
const [loading, setLoading] = useState(true)
|
gameId: string
|
||||||
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(null)
|
formatDate: (value: string | null) => string
|
||||||
const [showSystemReq, setShowSystemReq] = useState(true)
|
Badge: React.ComponentType<{ icon: React.ElementType; value: number; label: string }>
|
||||||
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 (
|
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 */}
|
{/* Title row */}
|
||||||
<div className='flex flex-wrap items-center gap-2'>
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
<h1 className='text-xl sm:text-2xl md:text-3xl font-bold truncate'>
|
<h1 className='text-xl sm:text-2xl md:text-3xl font-bold truncate'>
|
||||||
@@ -720,6 +520,293 @@ export function GamePageClient({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
Reference in New Issue
Block a user