feat: improve room navigation and membership UI
Fixes two UX issues in the arcade rooms list: 1. **Show membership status**: Rooms now display whether the user is already a member - API returns `isMember` flag for each room - UI shows "✓ Joined" badge for joined rooms - Shows "Join Room" button for non-joined rooms 2. **Allow navigation without joining**: Users can now view rooms without automatically joining - Entire room card is clickable to navigate to room details - "Join Room" button specifically handles membership (with stopPropagation) - Users can browse room details before deciding to join Changes: - API (src/app/api/arcade/rooms/route.ts): - Added `isMember` check using viewerId - Enriched room response with membership status - Frontend (src/app/arcade/rooms/page.tsx): - Added `isMember` to Room interface - Made room cards clickable for navigation - Show "✓ Joined" badge when user is a member - Show "Join Room" button when user is not a member - Button click stops propagation to prevent double navigation This improves discoverability and prevents confusion about membership status. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
126
apps/web/src/app/api/arcade/rooms/route.ts
Normal file
126
apps/web/src/app/api/arcade/rooms/route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createRoom, listActiveRooms } from '@/lib/arcade/room-manager'
|
||||
import { addRoomMember, getRoomMembers, isMember } from '@/lib/arcade/room-membership'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import type { GameName } from '@/lib/arcade/validation'
|
||||
|
||||
/**
|
||||
* GET /api/arcade/rooms
|
||||
* List all active public rooms (lobby view)
|
||||
* Query params:
|
||||
* - gameName?: string - Filter by game
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(req.url)
|
||||
const gameName = searchParams.get('gameName') as GameName | null
|
||||
|
||||
const viewerId = await getViewerId()
|
||||
const rooms = await listActiveRooms(gameName || undefined)
|
||||
|
||||
// Enrich with member counts, player counts, and membership status
|
||||
const roomsWithCounts = await Promise.all(
|
||||
rooms.map(async (room) => {
|
||||
const members = await getRoomMembers(room.id)
|
||||
const playerMap = await getRoomActivePlayers(room.id)
|
||||
const userIsMember = await isMember(room.id, viewerId)
|
||||
|
||||
let totalPlayers = 0
|
||||
for (const players of playerMap.values()) {
|
||||
totalPlayers += players.length
|
||||
}
|
||||
|
||||
return {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
gameName: room.gameName,
|
||||
status: room.status,
|
||||
createdAt: room.createdAt,
|
||||
creatorName: room.creatorName,
|
||||
isLocked: room.isLocked,
|
||||
memberCount: members.length,
|
||||
playerCount: totalPlayers,
|
||||
isMember: userIsMember,
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return NextResponse.json({ rooms: roomsWithCounts })
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch rooms:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch rooms' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/arcade/rooms
|
||||
* Create a new room
|
||||
* Body:
|
||||
* - name: string
|
||||
* - gameName: string
|
||||
* - gameConfig?: object
|
||||
* - ttlMinutes?: number
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields
|
||||
if (!body.name || !body.gameName) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Missing required fields: name, gameName' },
|
||||
{ status: 400 }
|
||||
)
|
||||
}
|
||||
|
||||
// Validate game name
|
||||
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate name length
|
||||
if (body.name.length > 50) {
|
||||
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Get display name from body or generate from viewerId
|
||||
const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`
|
||||
|
||||
// Create room
|
||||
const room = await createRoom({
|
||||
name: body.name,
|
||||
createdBy: viewerId,
|
||||
creatorName: displayName,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
ttlMinutes: body.ttlMinutes,
|
||||
})
|
||||
|
||||
// Add creator as first member
|
||||
await addRoomMember({
|
||||
roomId: room.id,
|
||||
userId: viewerId,
|
||||
displayName,
|
||||
isCreator: true,
|
||||
})
|
||||
|
||||
// Generate join URL
|
||||
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
|
||||
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
room,
|
||||
joinUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to create room:', error)
|
||||
return NextResponse.json({ error: 'Failed to create room' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
450
apps/web/src/app/arcade/rooms/page.tsx
Normal file
450
apps/web/src/app/arcade/rooms/page.tsx
Normal file
@@ -0,0 +1,450 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
|
||||
interface Room {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
gameName: string
|
||||
status: 'lobby' | 'playing' | 'finished'
|
||||
createdAt: Date
|
||||
creatorName: string
|
||||
isLocked: boolean
|
||||
memberCount?: number
|
||||
playerCount?: number
|
||||
isMember?: boolean
|
||||
}
|
||||
|
||||
export default function RoomBrowserPage() {
|
||||
const router = useRouter()
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetchRooms()
|
||||
}, [])
|
||||
|
||||
const fetchRooms = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await fetch('/api/arcade/rooms')
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
setRooms(data.rooms)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch rooms:', err)
|
||||
setError('Failed to load rooms')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const createRoom = async (name: string, gameName: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/arcade/rooms', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
gameName,
|
||||
creatorName: 'Player',
|
||||
gameConfig: { difficulty: 6 },
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
router.push(`/arcade/rooms/${data.room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create room:', err)
|
||||
alert('Failed to create room')
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
try {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: 'Player' }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
router.push(`/arcade/rooms/${roomId}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav>
|
||||
<div
|
||||
className={css({
|
||||
minH: 'calc(100vh - 80px)',
|
||||
bg: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
p: '8',
|
||||
})}
|
||||
>
|
||||
<div className={css({ maxW: '1200px', mx: 'auto' })}>
|
||||
{/* Header */}
|
||||
<div className={css({ mb: '8', textAlign: 'center' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
🎮 Multiplayer Rooms
|
||||
</h1>
|
||||
<p className={css({ color: '#a0a0ff', fontSize: 'lg', mb: '6' })}>
|
||||
Join a room or create your own to play with friends
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontSize: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#059669' },
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
+ Create New Room
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Room List */}
|
||||
{loading && (
|
||||
<div className={css({ textAlign: 'center', color: 'white', py: '12' })}>
|
||||
Loading rooms...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div
|
||||
className={css({
|
||||
bg: '#fef2f2',
|
||||
border: '1px solid #fecaca',
|
||||
color: '#991b1b',
|
||||
p: '4',
|
||||
rounded: 'lg',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length === 0 && (
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '12',
|
||||
textAlign: 'center',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: 'xl', mb: '2' })}>No rooms available</p>
|
||||
<p className={css({ color: '#a0a0ff' })}>Be the first to create one!</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && rooms.length > 0 && (
|
||||
<div className={css({ display: 'grid', gap: '4' })}>
|
||||
{rooms.map((room) => (
|
||||
<div
|
||||
key={room.id}
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
p: '6',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'rgba(255, 255, 255, 0.08)',
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
onClick={() => router.push(`/arcade/rooms/${room.id}`)}
|
||||
className={css({ flex: 1, cursor: 'pointer' })}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'white' })}
|
||||
>
|
||||
{room.name}
|
||||
</h3>
|
||||
<span
|
||||
className={css({
|
||||
px: '3',
|
||||
py: '1',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
color: '#fbbf24',
|
||||
rounded: 'full',
|
||||
fontSize: 'sm',
|
||||
fontWeight: '600',
|
||||
fontFamily: 'monospace',
|
||||
})}
|
||||
>
|
||||
{room.code}
|
||||
</span>
|
||||
{room.isLocked && (
|
||||
<span className={css({ color: '#f87171', fontSize: 'sm' })}>
|
||||
🔒 Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '4',
|
||||
color: '#a0a0ff',
|
||||
fontSize: 'sm',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<span>👤 Host: {room.creatorName}</span>
|
||||
<span>🎮 {room.gameName}</span>
|
||||
{room.memberCount !== undefined && (
|
||||
<span>
|
||||
👥 {room.memberCount} member{room.memberCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
{room.playerCount !== undefined && room.playerCount > 0 && (
|
||||
<span>
|
||||
🎯 {room.playerCount} player{room.playerCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={css({
|
||||
color:
|
||||
room.status === 'lobby'
|
||||
? '#10b981'
|
||||
: room.status === 'playing'
|
||||
? '#fbbf24'
|
||||
: '#6b7280',
|
||||
})}
|
||||
>
|
||||
{room.status === 'lobby'
|
||||
? '⏳ Waiting'
|
||||
: room.status === 'playing'
|
||||
? '🎮 Playing'
|
||||
: '✓ Finished'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{room.isMember ? (
|
||||
<div
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
✓ Joined
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
joinRoom(room.id)
|
||||
}}
|
||||
disabled={room.isLocked}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: room.isLocked ? '#6b7280' : '#3b82f6',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: room.isLocked ? 'not-allowed' : 'pointer',
|
||||
opacity: room.isLocked ? 0.5 : 1,
|
||||
_hover: room.isLocked ? {} : { bg: '#2563eb' },
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
Join Room
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Room Modal */}
|
||||
{showCreateModal && (
|
||||
<div
|
||||
className={css({
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
bg: 'rgba(0, 0, 0, 0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 50,
|
||||
})}
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
maxW: '500px',
|
||||
w: 'full',
|
||||
mx: '4',
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', mb: '6' })}>
|
||||
Create New Room
|
||||
</h2>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const gameName = formData.get('gameName') as string
|
||||
if (name && gameName) {
|
||||
createRoom(name, gameName)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={css({ mb: '4' })}>
|
||||
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
|
||||
Room Name
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="My Awesome Room"
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
border: '1px solid #d1d5db',
|
||||
rounded: 'lg',
|
||||
_focus: { outline: 'none', borderColor: '#3b82f6' },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
<div className={css({ mb: '6' })}>
|
||||
<label className={css({ display: 'block', mb: '2', fontWeight: '600' })}>
|
||||
Game
|
||||
</label>
|
||||
<select
|
||||
name="gameName"
|
||||
required
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
py: '3',
|
||||
border: '1px solid #d1d5db',
|
||||
rounded: 'lg',
|
||||
_focus: { outline: 'none', borderColor: '#3b82f6' },
|
||||
})}
|
||||
>
|
||||
<option value="matching">Memory Matching</option>
|
||||
<option value="memory-quiz">Memory Quiz</option>
|
||||
<option value="complement-race">Complement Race</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', gap: '3' })}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#e5e7eb',
|
||||
color: '#374151',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#d1d5db' },
|
||||
})}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className={css({
|
||||
flex: 1,
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: '#10b981',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
_hover: { bg: '#059669' },
|
||||
})}
|
||||
>
|
||||
Create Room
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user