feat(games): add autoplay and improve carousel layout

Major improvements to the games hero carousel:
- Added smooth autoplay (4s delay, stops on interaction/hover)
- Made carousel full-width to reduce virtual scrolling artifacts
- Added horizontal padding for better buffer on edges
- Restructured layout: carousel is now full-width, rest of content
  remains constrained to max-width
- Removed containScroll setting to improve infinite loop behavior

The carousel now smoothly rotates through games automatically and
has better visual consistency at the loop edges.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-11-03 09:25:44 -06:00
parent 946e5d1910
commit 9f51edfaa9

View File

@@ -1,5 +1,6 @@
'use client'
import Autoplay from 'embla-carousel-autoplay'
import useEmblaCarousel from 'embla-carousel-react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
@@ -32,12 +33,16 @@ function GamesPageContent() {
// Check if user has any stats to show
const hasStats = profile.gamesPlayed > 0
// Embla carousel setup for games hero carousel
const [gamesEmblaRef, gamesEmblaApi] = useEmblaCarousel({
loop: true,
align: 'center',
containScroll: 'trimSnaps',
})
// Embla carousel setup for games hero carousel with autoplay
const [gamesEmblaRef, gamesEmblaApi] = useEmblaCarousel(
{
loop: true,
align: 'center',
slidesToScroll: 1,
skipSnaps: false,
},
[Autoplay({ delay: 4000, stopOnInteraction: true, stopOnMouseEnter: true })]
)
const [gamesSelectedIndex, setGamesSelectedIndex] = useState(0)
// Embla carousel setup for player carousel
@@ -108,252 +113,254 @@ function GamesPageContent() {
})}
/>
{/* Games Hero Carousel - Full Width */}
<div
className={css({
mb: '16',
pt: '20',
overflow: 'visible',
px: { base: '4', md: '12' },
})}
>
<h2
className={css({
fontSize: { base: 'xl', md: '2xl' },
fontWeight: 'bold',
color: 'gray.900',
textAlign: 'center',
mb: '6',
})}
>
🎮 Available Games
</h2>
{/* Carousel */}
<div
ref={gamesEmblaRef}
className={css({
overflow: 'visible',
cursor: 'grab',
userSelect: 'none',
_active: {
cursor: 'grabbing',
},
})}
>
<div
className={css({
display: 'flex',
gap: '6',
})}
>
{availableGames.map((game) => {
const gameIndex = availableGames.indexOf(game)
const isActive = gameIndex === gamesSelectedIndex
return (
<div
key={game.manifest.name}
className={css({
flex: '0 0 auto',
w: { base: '85%', md: '400px' },
mr: '6',
transitionProperty: 'opacity',
transitionDuration: '0.3s',
transitionTimingFunction: 'ease-out',
opacity: isActive ? 1 : 0.6,
})}
>
<Link
href={`/arcade/${game.manifest.name}`}
className={css({
display: 'block',
textDecoration: 'none',
height: '100%',
})}
>
<div
className={css({
background: 'white',
rounded: '2xl',
p: '6',
border: '2px solid',
borderColor: isActive ? 'blue.400' : 'gray.200',
boxShadow: isActive
? '0 20px 40px rgba(59, 130, 246, 0.3)'
: '0 10px 20px rgba(0, 0, 0, 0.1)',
position: 'relative',
overflow: 'hidden',
height: '100%',
transitionProperty: 'transform, box-shadow, border-color',
transitionDuration: '0.3s',
transitionTimingFunction: 'ease',
_hover: {
transform: 'translateY(-4px)',
boxShadow: '0 25px 50px rgba(59, 130, 246, 0.4)',
borderColor: 'blue.500',
},
})}
style={{
background: game.manifest.gradient || 'white',
}}
>
{/* Dark gradient overlay for readability */}
<div
className={css({
position: 'absolute',
inset: 0,
background:
'linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.5) 100%)',
zIndex: 0,
})}
/>
{/* Content */}
<div
className={css({
position: 'relative',
zIndex: 1,
})}
>
{/* Icon and Title */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
mb: '4',
})}
>
<div
className={css({
fontSize: '3xl',
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
})}
>
{game.manifest.icon}
</div>
<div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'white',
textShadow: '0 2px 8px rgba(0, 0, 0, 0.5)',
})}
>
{game.manifest.displayName}
</h3>
<p
className={css({
fontSize: 'xs',
color: 'rgba(255, 255, 255, 0.9)',
textShadow: '0 1px 4px rgba(0, 0, 0, 0.4)',
})}
>
{game.manifest.difficulty} {' '}
{game.manifest.maxPlayers === 1
? 'Solo'
: `1-${game.manifest.maxPlayers} Players`}
</p>
</div>
</div>
{/* Description */}
<p
className={css({
fontSize: 'sm',
color: 'rgba(255, 255, 255, 0.95)',
mb: '4',
lineHeight: '1.6',
textShadow: '0 1px 4px rgba(0, 0, 0, 0.4)',
})}
>
{game.manifest.description}
</p>
{/* Chips */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '2',
})}
>
{game.manifest.chips.map((chip) => (
<span
key={chip}
className={css({
fontSize: 'xs',
px: '2',
py: '1',
bg: 'rgba(255, 255, 255, 0.2)',
color: 'white',
rounded: 'full',
fontWeight: 'semibold',
textShadow: '0 1px 3px rgba(0, 0, 0, 0.4)',
})}
>
{chip}
</span>
))}
</div>
</div>
</div>
</Link>
</div>
)
})}
</div>
</div>
{/* Navigation Dots */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2',
mt: '6',
})}
>
{availableGames.map((game, index) => (
<button
key={game.manifest.name}
type="button"
onClick={() => gamesEmblaApi?.scrollTo(index)}
className={css({
w: '10',
h: '10',
rounded: 'full',
border: '2px solid',
borderColor: index === gamesSelectedIndex ? 'blue.500' : 'gray.300',
bg: index === gamesSelectedIndex ? 'blue.500' : 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'lg',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.500',
transform: 'scale(1.1)',
},
})}
title={game.manifest.displayName}
>
{game.manifest.icon}
</button>
))}
</div>
</div>
{/* Rest of content - constrained width */}
<div
className={css({
maxW: '6xl',
mx: 'auto',
px: { base: '4', md: '6' },
pt: '20',
position: 'relative',
})}
>
{/* Games Hero Carousel */}
<div
className={css({
mb: '16',
overflow: 'visible',
})}
>
<h2
className={css({
fontSize: { base: 'xl', md: '2xl' },
fontWeight: 'bold',
color: 'gray.900',
textAlign: 'center',
mb: '6',
})}
>
🎮 Available Games
</h2>
{/* Carousel */}
<div
ref={gamesEmblaRef}
className={css({
overflow: 'visible',
cursor: 'grab',
userSelect: 'none',
_active: {
cursor: 'grabbing',
},
})}
>
<div
className={css({
display: 'flex',
gap: '6',
})}
>
{availableGames.map((game) => {
const gameIndex = availableGames.indexOf(game)
const isActive = gameIndex === gamesSelectedIndex
return (
<div
key={game.manifest.name}
className={css({
flex: '0 0 auto',
w: { base: '85%', md: '400px' },
mr: '6',
transitionProperty: 'opacity',
transitionDuration: '0.3s',
transitionTimingFunction: 'ease-out',
opacity: isActive ? 1 : 0.6,
})}
>
<Link
href={`/arcade/${game.manifest.name}`}
className={css({
display: 'block',
textDecoration: 'none',
height: '100%',
})}
>
<div
className={css({
background: 'white',
rounded: '2xl',
p: '6',
border: '2px solid',
borderColor: isActive ? 'blue.400' : 'gray.200',
boxShadow: isActive
? '0 20px 40px rgba(59, 130, 246, 0.3)'
: '0 10px 20px rgba(0, 0, 0, 0.1)',
position: 'relative',
overflow: 'hidden',
height: '100%',
transitionProperty: 'transform, box-shadow, border-color',
transitionDuration: '0.3s',
transitionTimingFunction: 'ease',
_hover: {
transform: 'translateY(-4px)',
boxShadow: '0 25px 50px rgba(59, 130, 246, 0.4)',
borderColor: 'blue.500',
},
})}
style={{
background: game.manifest.gradient || 'white',
}}
>
{/* Dark gradient overlay for readability */}
<div
className={css({
position: 'absolute',
inset: 0,
background:
'linear-gradient(to bottom, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.5) 100%)',
zIndex: 0,
})}
/>
{/* Content */}
<div
className={css({
position: 'relative',
zIndex: 1,
})}
>
{/* Icon and Title */}
<div
className={css({
display: 'flex',
alignItems: 'center',
gap: '3',
mb: '4',
})}
>
<div
className={css({
fontSize: '3xl',
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)',
})}
>
{game.manifest.icon}
</div>
<div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'white',
textShadow: '0 2px 8px rgba(0, 0, 0, 0.5)',
})}
>
{game.manifest.displayName}
</h3>
<p
className={css({
fontSize: 'xs',
color: 'rgba(255, 255, 255, 0.9)',
textShadow: '0 1px 4px rgba(0, 0, 0, 0.4)',
})}
>
{game.manifest.difficulty} {' '}
{game.manifest.maxPlayers === 1
? 'Solo'
: `1-${game.manifest.maxPlayers} Players`}
</p>
</div>
</div>
{/* Description */}
<p
className={css({
fontSize: 'sm',
color: 'rgba(255, 255, 255, 0.95)',
mb: '4',
lineHeight: '1.6',
textShadow: '0 1px 4px rgba(0, 0, 0, 0.4)',
})}
>
{game.manifest.description}
</p>
{/* Chips */}
<div
className={css({
display: 'flex',
flexWrap: 'wrap',
gap: '2',
})}
>
{game.manifest.chips.map((chip) => (
<span
key={chip}
className={css({
fontSize: 'xs',
px: '2',
py: '1',
bg: 'rgba(255, 255, 255, 0.2)',
color: 'white',
rounded: 'full',
fontWeight: 'semibold',
textShadow: '0 1px 3px rgba(0, 0, 0, 0.4)',
})}
>
{chip}
</span>
))}
</div>
</div>
</div>
</Link>
</div>
)
})}
</div>
</div>
{/* Navigation Dots */}
<div
className={css({
display: 'flex',
justifyContent: 'center',
gap: '2',
mt: '6',
})}
>
{availableGames.map((game, index) => (
<button
key={game.manifest.name}
type="button"
onClick={() => gamesEmblaApi?.scrollTo(index)}
className={css({
w: '10',
h: '10',
rounded: 'full',
border: '2px solid',
borderColor: index === gamesSelectedIndex ? 'blue.500' : 'gray.300',
bg: index === gamesSelectedIndex ? 'blue.500' : 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: 'lg',
cursor: 'pointer',
transition: 'all 0.2s',
_hover: {
borderColor: 'blue.500',
transform: 'scale(1.1)',
},
})}
title={game.manifest.displayName}
>
{game.manifest.icon}
</button>
))}
</div>
</div>
{/* Enter Arcade Button */}
<div
className={css({