feat(create-room): replace hardcoded game grid with dynamic Radix Select dropdown

Replaced the hardcoded 3-game grid with a beautiful, dynamic dropdown that:
- Automatically shows all games from getAvailableGames() registry
- No more manual updates needed when adding new games
- Card Sorting now appears in the modal (was missing before)

UI Improvements:
- Fancy Radix UI Select component with rich game cards
- Each game shows: large emoji icon, title, description, player count, difficulty
- Color-coded selection highlights matching game's brand color
- Optional game selection - users can "choose later" on game page

UX Enhancements:
- Smooth CSS scrolling with scroll-behavior: smooth
- Absolutely positioned scroll indicators (no jitter)
- Green ▲▼ arrows show when more content available
- Smart positioning with collision detection (never clips viewport)
- maxHeight: 50vh, collisionPadding: 30px for small screens
- Hover effects on scroll arrows and game cards

Technical:
- Uses react-spring animated components for polish
- Radix Select for accessibility and keyboard navigation
- Single source of truth: game registry manifest data

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock 2025-10-23 10:01:39 -05:00
parent 2004835bc8
commit 83d0ba26f5
1 changed files with 304 additions and 67 deletions

View File

@ -1,6 +1,9 @@
import { useState } from 'react'
import * as Select from '@radix-ui/react-select'
import { animated } from '@react-spring/web'
import { Modal } from '@/components/common/Modal'
import { useCreateRoom } from '@/hooks/useRoomData'
import { getAvailableGames } from '@/lib/arcade/game-registry'
export interface CreateRoomModalProps {
/**
@ -24,10 +27,9 @@ export interface CreateRoomModalProps {
*/
export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalProps) {
const { mutateAsync: createRoom, isPending } = useCreateRoom()
const availableGames = getAvailableGames()
const [error, setError] = useState('')
const [gameName, setGameName] = useState<'matching' | 'memory-quiz' | 'complement-race'>(
'matching'
)
const [gameName, setGameName] = useState<string>('__choose_later__') // Special value = user will choose later
const [accessMode, setAccessMode] = useState<
'open' | 'password' | 'approval-only' | 'restricted'
>('open')
@ -35,7 +37,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
const handleClose = () => {
setError('')
setGameName('matching')
setGameName('__choose_later__')
setAccessMode('open')
setPassword('')
onClose()
@ -59,9 +61,13 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
try {
// Create the room (creator is auto-added as first member)
// If no game selected (choose later), use first available game as default
const selectedGame =
gameName === '__choose_later__' ? availableGames[0]?.manifest.name || 'matching' : gameName
await createRoom({
name,
gameName,
gameName: selectedGame,
creatorName: 'Player',
gameConfig: { difficulty: 6 },
accessMode,
@ -153,77 +159,308 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
<label
style={{
display: 'block',
marginBottom: '8px',
marginBottom: '6px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 1)',
fontSize: '13px',
}}
>
Choose Game
🎮 Choose Your Game{' '}
<span
style={{ fontWeight: '400', color: 'rgba(156, 163, 175, 1)', fontSize: '12px' }}
>
(optional)
</span>
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px' }}>
{[
{ value: 'matching' as const, emoji: '🃏', label: 'Memory', desc: 'Matching' },
{ value: 'memory-quiz' as const, emoji: '🧠', label: 'Memory', desc: 'Quiz' },
{
value: 'complement-race' as const,
emoji: '⚡',
label: 'Complement',
desc: 'Race',
},
].map((game) => (
<button
key={game.value}
type="button"
disabled={isPending}
onClick={() => setGameName(game.value)}
<Select.Root value={gameName} onValueChange={setGameName} disabled={isPending}>
<Select.Trigger
style={{
width: '100%',
padding: '12px 14px',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
background: 'rgba(255, 255, 255, 0.05)',
color: gameName ? 'rgba(209, 213, 219, 1)' : 'rgba(156, 163, 175, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: isPending ? 'not-allowed' : 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
transition: 'all 0.2s ease',
outline: 'none',
}}
onMouseEnter={(e) => {
if (!isPending) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.5)'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}}
>
<Select.Value placeholder="Select a game..." />
<Select.Icon>
<span style={{ fontSize: '12px' }}></span>
</Select.Icon>
</Select.Trigger>
<Select.Portal>
<Select.Content
position="popper"
side="bottom"
align="start"
sideOffset={5}
collisionPadding={30}
avoidCollisions={true}
style={{
padding: '12px 8px',
background:
gameName === game.value
? 'rgba(34, 197, 94, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
border:
gameName === game.value
? '2px solid rgba(34, 197, 94, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '8px',
color:
gameName === game.value
? 'rgba(134, 239, 172, 1)'
: 'rgba(209, 213, 219, 0.8)',
fontSize: '13px',
fontWeight: '500',
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
textAlign: 'center',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
}}
onMouseEnter={(e) => {
if (!isPending && gameName !== game.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.4)'
}
}}
onMouseLeave={(e) => {
if (gameName !== game.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}
background: 'linear-gradient(180deg, #1f2937 0%, #111827 100%)',
borderRadius: '12px',
border: '2px solid rgba(34, 197, 94, 0.3)',
padding: '6px',
boxShadow: '0 10px 40px rgba(0, 0, 0, 0.5)',
zIndex: 1000,
minWidth: '300px',
maxWidth: 'min(400px, 90vw)',
maxHeight: 'min(300px, 50vh)',
overflow: 'hidden',
position: 'relative',
}}
>
<span style={{ fontSize: '24px' }}>{game.emoji}</span>
<div style={{ lineHeight: '1.2' }}>
<div style={{ fontSize: '12px', fontWeight: '600' }}>{game.label}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>{game.desc}</div>
</div>
</button>
))}
</div>
<Select.ScrollUpButton asChild>
<animated.div
style={{
position: 'absolute',
top: '6px',
left: '6px',
right: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '30px',
background:
'linear-gradient(180deg, rgba(31, 41, 55, 0.98) 0%, rgba(31, 41, 55, 0.7) 70%, transparent 100%)',
cursor: 'default',
color: 'rgba(34, 197, 94, 1)',
fontSize: '14px',
transition: 'opacity 0.2s ease',
zIndex: 10,
pointerEvents: 'auto',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1'
}}
>
</animated.div>
</Select.ScrollUpButton>
<Select.Viewport
style={{
maxHeight: 'inherit',
scrollBehavior: 'smooth',
overflowY: 'auto',
}}
>
<Select.Item
value="__choose_later__"
style={{
padding: '12px 14px',
borderRadius: '8px',
cursor: 'pointer',
outline: 'none',
display: 'flex',
alignItems: 'center',
gap: '10px',
transition: 'all 0.15s ease',
background:
gameName === '__choose_later__'
? 'rgba(34, 197, 94, 0.15)'
: 'transparent',
color: 'rgba(209, 213, 219, 0.9)',
fontSize: '14px',
marginBottom: '4px',
}}
onMouseEnter={(e) => {
if (gameName !== '__choose_later__') {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
}
}}
onMouseLeave={(e) => {
if (gameName !== '__choose_later__') {
e.currentTarget.style.background = 'transparent'
}
}}
>
<Select.ItemText>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '20px' }}></span>
<div>
<div style={{ fontWeight: '500', fontSize: '14px' }}>Choose later</div>
<div
style={{
fontSize: '12px',
opacity: 0.7,
color: 'rgba(156, 163, 175, 1)',
}}
>
Pick on the game selection page
</div>
</div>
</div>
</Select.ItemText>
</Select.Item>
<div
style={{
height: '1px',
background: 'rgba(75, 85, 99, 0.5)',
margin: '6px 0',
}}
/>
{availableGames.map((game) => {
const gameId = game.manifest.name
// Map game gradients to colors
const gradientColors: Record<string, string> = {
pink: 'rgba(236, 72, 153, 0.2)',
purple: 'rgba(168, 85, 247, 0.2)',
blue: 'rgba(59, 130, 246, 0.2)',
green: 'rgba(34, 197, 94, 0.2)',
orange: 'rgba(249, 115, 22, 0.2)',
red: 'rgba(239, 68, 68, 0.2)',
}
const bgColor =
gradientColors[game.manifest.gradient || 'blue'] || gradientColors.blue
return (
<Select.Item
key={gameId}
value={gameId}
style={{
padding: '12px 14px',
borderRadius: '8px',
cursor: 'pointer',
outline: 'none',
display: 'flex',
alignItems: 'center',
gap: '12px',
transition: 'all 0.15s ease',
background: gameName === gameId ? bgColor : 'transparent',
marginBottom: '4px',
}}
onMouseEnter={(e) => {
if (gameName !== gameId) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
}
}}
onMouseLeave={(e) => {
if (gameName !== gameId) {
e.currentTarget.style.background = 'transparent'
}
}}
>
<Select.ItemText>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '28px' }}>{game.manifest.icon}</span>
<div style={{ flex: 1 }}>
<div
style={{
fontWeight: '600',
fontSize: '15px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '2px',
}}
>
{game.manifest.displayName}
</div>
<div
style={{
fontSize: '12px',
color: 'rgba(156, 163, 175, 1)',
lineHeight: '1.3',
}}
>
{game.manifest.description}
</div>
<div
style={{
fontSize: '11px',
marginTop: '4px',
display: 'flex',
gap: '8px',
flexWrap: 'wrap',
}}
>
<span
style={{
background: 'rgba(59, 130, 246, 0.2)',
padding: '2px 8px',
borderRadius: '4px',
color: 'rgba(147, 197, 253, 1)',
fontWeight: '500',
}}
>
{game.manifest.maxPlayers === 1
? '👤 Solo'
: `👥 ${game.manifest.maxPlayers}p`}
</span>
<span
style={{
background: 'rgba(168, 85, 247, 0.2)',
padding: '2px 8px',
borderRadius: '4px',
color: 'rgba(196, 181, 253, 1)',
fontWeight: '500',
}}
>
{game.manifest.difficulty}
</span>
</div>
</div>
</div>
</Select.ItemText>
</Select.Item>
)
})}
</Select.Viewport>
<Select.ScrollDownButton asChild>
<animated.div
style={{
position: 'absolute',
bottom: '6px',
left: '6px',
right: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '30px',
background:
'linear-gradient(0deg, rgba(31, 41, 55, 0.98) 0%, rgba(31, 41, 55, 0.7) 70%, transparent 100%)',
cursor: 'default',
color: 'rgba(34, 197, 94, 1)',
fontSize: '14px',
transition: 'opacity 0.2s ease',
zIndex: 10,
pointerEvents: 'auto',
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '0.7'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '1'
}}
>
</animated.div>
</Select.ScrollDownButton>
</Select.Content>
</Select.Portal>
</Select.Root>
</div>
{/* Access Mode Selection */}