feat: improve room creation UX and add password support for share links
- Update placeholder text in room creation forms to show auto-generated format - Make room.name nullable in database schema (migration 0008) - Add accessMode field to RoomData interface - Implement password prompt UI for password-protected rooms via share links - Add password support to room browser join flow - Remove autoFocus attribute for accessibility compliance 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
41
apps/web/drizzle/0008_make_room_name_nullable.sql
Normal file
41
apps/web/drizzle/0008_make_room_name_nullable.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Make room name nullable to support auto-generated names
|
||||
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
||||
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
|
||||
-- Create temporary table with correct schema
|
||||
CREATE TABLE `arcade_rooms_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`code` text(6) NOT NULL,
|
||||
`name` text(50),
|
||||
`created_by` text NOT NULL,
|
||||
`creator_name` text(50) NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_activity` integer NOT NULL,
|
||||
`ttl_minutes` integer DEFAULT 60 NOT NULL,
|
||||
`access_mode` text DEFAULT 'open' NOT NULL,
|
||||
`password` text(255),
|
||||
`game_name` text NOT NULL,
|
||||
`game_config` text NOT NULL,
|
||||
`status` text DEFAULT 'lobby' NOT NULL,
|
||||
`current_session_id` text,
|
||||
`total_games_played` integer DEFAULT 0 NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- Copy all data
|
||||
INSERT INTO `arcade_rooms_new`
|
||||
SELECT `id`, `code`, `name`, `created_by`, `creator_name`, `created_at`,
|
||||
`last_activity`, `ttl_minutes`, `access_mode`, `password`,
|
||||
`game_name`, `game_config`, `status`, `current_session_id`, `total_games_played`
|
||||
FROM `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE `arcade_rooms_new` RENAME TO `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Recreate index
|
||||
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1760527200000,
|
||||
"tag": "0007_access_modes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1760548800000,
|
||||
"tag": "0008_make_room_name_nullable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,16 +4,18 @@ import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
|
||||
interface Room {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
name: string | null
|
||||
gameName: string
|
||||
status: 'lobby' | 'playing' | 'finished'
|
||||
createdAt: Date
|
||||
creatorName: string
|
||||
isLocked: boolean
|
||||
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
memberCount?: number
|
||||
playerCount?: number
|
||||
isMember?: boolean
|
||||
@@ -48,7 +50,7 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const createRoom = async (name: string, gameName: string) => {
|
||||
const createRoom = async (name: string | null, gameName: string) => {
|
||||
try {
|
||||
const response = await fetch('/api/arcade/rooms', {
|
||||
method: 'POST',
|
||||
@@ -73,9 +75,41 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const joinRoom = async (roomId: string) => {
|
||||
const joinRoom = async (room: Room) => {
|
||||
try {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
|
||||
// Check access mode
|
||||
if (room.accessMode === 'password') {
|
||||
const password = prompt(`Enter password for ${room.name || `Room ${room.code}`}:`)
|
||||
if (!password) return // User cancelled
|
||||
|
||||
const response = await fetch(`/api/arcade/rooms/${room.id}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: 'Player', password }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
alert(errorData.error || 'Failed to join room')
|
||||
return
|
||||
}
|
||||
|
||||
router.push(`/arcade-rooms/${room.id}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'approval-only') {
|
||||
alert('This room requires host approval. Please use the Join Room modal to request access.')
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
alert('This room is invitation-only. Please ask the host for an invitation.')
|
||||
return
|
||||
}
|
||||
|
||||
// For open rooms
|
||||
const response = await fetch(`/api/arcade/rooms/${room.id}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: 'Player' }),
|
||||
@@ -103,7 +137,7 @@ export default function RoomBrowserPage() {
|
||||
// Could show a toast notification here in the future
|
||||
}
|
||||
|
||||
router.push(`/arcade-rooms/${roomId}`)
|
||||
router.push(`/arcade-rooms/${room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
@@ -237,7 +271,11 @@ export default function RoomBrowserPage() {
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
{room.name}
|
||||
{getRoomDisplayWithEmoji({
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
gameName: room.gameName,
|
||||
})}
|
||||
</h3>
|
||||
<span
|
||||
className={css({
|
||||
@@ -325,23 +363,51 @@ export default function RoomBrowserPage() {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
joinRoom(room.id)
|
||||
joinRoom(room)
|
||||
}}
|
||||
disabled={room.isLocked}
|
||||
disabled={
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
}
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: room.isLocked ? '#6b7280' : '#3b82f6',
|
||||
bg:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? '#6b7280'
|
||||
: room.accessMode === 'password'
|
||||
? '#f59e0b'
|
||||
: '#3b82f6',
|
||||
color: 'white',
|
||||
rounded: 'lg',
|
||||
fontWeight: '600',
|
||||
cursor: room.isLocked ? 'not-allowed' : 'pointer',
|
||||
opacity: room.isLocked ? 0.5 : 1,
|
||||
_hover: room.isLocked ? {} : { bg: '#2563eb' },
|
||||
cursor:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? 'not-allowed'
|
||||
: 'pointer',
|
||||
opacity:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? 0.5
|
||||
: 1,
|
||||
_hover:
|
||||
room.isLocked ||
|
||||
room.accessMode === 'locked' ||
|
||||
room.accessMode === 'retired'
|
||||
? {}
|
||||
: room.accessMode === 'password'
|
||||
? { bg: '#d97706' }
|
||||
: { bg: '#2563eb' },
|
||||
transition: 'all 0.2s',
|
||||
})}
|
||||
>
|
||||
Join Room
|
||||
{room.accessMode === 'password' ? '🔑 Join with Password' : 'Join Room'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -393,9 +459,11 @@ export default function RoomBrowserPage() {
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const nameValue = formData.get('name') as string
|
||||
const gameName = formData.get('gameName') as string
|
||||
if (name && gameName) {
|
||||
// Treat empty name as null
|
||||
const name = nameValue?.trim() || null
|
||||
if (gameName) {
|
||||
createRoom(name, gameName)
|
||||
}
|
||||
}}
|
||||
@@ -408,13 +476,13 @@ export default function RoomBrowserPage() {
|
||||
fontWeight: '600',
|
||||
})}
|
||||
>
|
||||
Room Name
|
||||
Room Name{' '}
|
||||
<span className={css({ fontWeight: '400', color: '#9ca3af' })}>(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="My Awesome Room"
|
||||
placeholder="e.g., Friday Night Games (defaults to: 🎮 CODE)"
|
||||
className={css({
|
||||
w: 'full',
|
||||
px: '4',
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
|
||||
interface RoomSwitchConfirmationProps {
|
||||
currentRoom: { name: string; code: string }
|
||||
targetRoom: { name: string; code: string }
|
||||
currentRoom: { name: string | null; code: string; gameName: string }
|
||||
targetRoom: { name: string | null; code: string; gameName: string }
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
@@ -84,7 +85,11 @@ function RoomSwitchConfirmation({
|
||||
Current Room
|
||||
</div>
|
||||
<div style={{ color: 'rgba(253, 186, 116, 1)', fontWeight: '600' }}>
|
||||
{currentRoom.name}
|
||||
{getRoomDisplayWithEmoji({
|
||||
name: currentRoom.name,
|
||||
code: currentRoom.code,
|
||||
gameName: currentRoom.gameName,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -116,7 +121,11 @@ function RoomSwitchConfirmation({
|
||||
New Room
|
||||
</div>
|
||||
<div style={{ color: 'rgba(134, 239, 172, 1)', fontWeight: '600' }}>
|
||||
{targetRoom.name}
|
||||
{getRoomDisplayWithEmoji({
|
||||
name: targetRoom.name,
|
||||
code: targetRoom.code,
|
||||
gameName: targetRoom.gameName,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
@@ -195,26 +204,33 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
const { mutateAsync: joinRoom } = useJoinRoom()
|
||||
const [targetRoomData, setTargetRoomData] = useState<{
|
||||
id: string
|
||||
name: string
|
||||
name: string | null
|
||||
code: string
|
||||
gameName: string
|
||||
accessMode: string
|
||||
} | null>(null)
|
||||
const [showConfirmation, setShowConfirmation] = useState(false)
|
||||
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isJoining, setIsJoining] = useState(false)
|
||||
const code = params.code.toUpperCase()
|
||||
|
||||
const handleJoin = useCallback(
|
||||
async (targetRoomId: string) => {
|
||||
async (targetRoomId: string, roomPassword?: string) => {
|
||||
setIsJoining(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await joinRoom({ roomId: targetRoomId, displayName: 'Player' })
|
||||
await joinRoom({
|
||||
roomId: targetRoomId,
|
||||
displayName: 'Player',
|
||||
password: roomPassword,
|
||||
})
|
||||
// Navigate to the game
|
||||
router.push('/arcade/room')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
} finally {
|
||||
setIsJoining(false)
|
||||
}
|
||||
},
|
||||
@@ -236,6 +252,8 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
id: room.id,
|
||||
name: room.name,
|
||||
code: room.code,
|
||||
gameName: room.gameName,
|
||||
accessMode: room.accessMode,
|
||||
})
|
||||
|
||||
// If user is already in this exact room, just navigate to game
|
||||
@@ -244,11 +262,33 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if room needs password
|
||||
if (room.accessMode === 'password') {
|
||||
setShowPasswordPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Check for other access modes
|
||||
if (room.accessMode === 'locked' || room.accessMode === 'retired') {
|
||||
setError('This room is no longer accepting new members')
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
setError('This room is invitation-only')
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'approval-only') {
|
||||
setError('This room requires host approval. Please join via the room browser.')
|
||||
return
|
||||
}
|
||||
|
||||
// If user is in a different room, show confirmation
|
||||
if (roomData) {
|
||||
setShowConfirmation(true)
|
||||
} else {
|
||||
// Otherwise, auto-join
|
||||
// Otherwise, auto-join (for open rooms)
|
||||
handleJoin(room.id)
|
||||
}
|
||||
})
|
||||
@@ -264,7 +304,12 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (targetRoomData) {
|
||||
handleJoin(targetRoomData.id)
|
||||
if (targetRoomData.accessMode === 'password') {
|
||||
setShowConfirmation(false)
|
||||
setShowPasswordPrompt(true)
|
||||
} else {
|
||||
handleJoin(targetRoomData.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +317,12 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
router.push('/arcade/room') // Stay in current room
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
if (targetRoomData && password) {
|
||||
handleJoin(targetRoomData.id, password)
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div
|
||||
@@ -316,16 +367,180 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (showConfirmation && roomData) {
|
||||
if (showConfirmation && roomData && targetRoomData) {
|
||||
return (
|
||||
<RoomSwitchConfirmation
|
||||
currentRoom={{ name: roomData.name, code: roomData.code }}
|
||||
targetRoom={{ name: targetRoomData.name, code: targetRoomData.code }}
|
||||
currentRoom={{ name: roomData.name, code: roomData.code, gameName: roomData.gameName }}
|
||||
targetRoom={{
|
||||
name: targetRoomData.name,
|
||||
code: targetRoomData.code,
|
||||
gameName: targetRoomData.gameName,
|
||||
}}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (showPasswordPrompt && targetRoomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.98), rgba(31, 41, 55, 0.98))',
|
||||
borderRadius: '16px',
|
||||
padding: '32px',
|
||||
maxWidth: '450px',
|
||||
width: '90%',
|
||||
border: '2px solid rgba(251, 191, 36, 0.3)',
|
||||
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
marginBottom: '8px',
|
||||
color: 'rgba(251, 191, 36, 1)',
|
||||
}}
|
||||
>
|
||||
🔑 Password Required
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
This room is password protected. Enter the password to join.
|
||||
</p>
|
||||
|
||||
<div
|
||||
style={{
|
||||
background: 'rgba(251, 191, 36, 0.1)',
|
||||
border: '1px solid rgba(251, 191, 36, 0.3)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginBottom: '20px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '14px', fontWeight: '600', color: 'rgba(251, 191, 36, 1)' }}>
|
||||
{getRoomDisplayWithEmoji({
|
||||
name: targetRoomData.name,
|
||||
code: targetRoomData.code,
|
||||
gameName: targetRoomData.gameName,
|
||||
})}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(209, 213, 219, 0.7)',
|
||||
fontFamily: 'monospace',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Code: {targetRoomData.code}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && password) {
|
||||
handlePasswordSubmit()
|
||||
}
|
||||
}}
|
||||
placeholder="Enter password"
|
||||
disabled={isJoining}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid rgba(251, 191, 36, 0.4)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'rgba(251, 191, 36, 1)',
|
||||
fontSize: '16px',
|
||||
outline: 'none',
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(248, 113, 113, 1)',
|
||||
marginBottom: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', gap: '12px', marginTop: '20px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.push('/arcade')}
|
||||
disabled={isJoining}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: isJoining ? 'not-allowed' : 'pointer',
|
||||
opacity: isJoining ? 0.5 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePasswordSubmit}
|
||||
disabled={!password || isJoining}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background:
|
||||
password && !isJoining
|
||||
? 'linear-gradient(135deg, rgba(251, 191, 36, 0.8), rgba(245, 158, 11, 0.8))'
|
||||
: 'rgba(75, 85, 99, 0.3)',
|
||||
color: password && !isJoining ? 'rgba(255, 255, 255, 1)' : 'rgba(156, 163, 175, 1)',
|
||||
border:
|
||||
password && !isJoining
|
||||
? '2px solid rgba(251, 191, 36, 0.6)'
|
||||
: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '15px',
|
||||
fontWeight: '600',
|
||||
cursor: password && !isJoining ? 'pointer' : 'not-allowed',
|
||||
opacity: password && !isJoining ? 1 : 0.5,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
{isJoining ? 'Joining...' : 'Join Room'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -25,9 +25,19 @@ export interface CreateRoomModalProps {
|
||||
export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalProps) {
|
||||
const { mutateAsync: createRoom, isPending } = useCreateRoom()
|
||||
const [error, setError] = useState('')
|
||||
const [gameName, setGameName] = useState<'matching' | 'memory-quiz' | 'complement-race'>(
|
||||
'matching'
|
||||
)
|
||||
const [accessMode, setAccessMode] = useState<
|
||||
'open' | 'password' | 'approval-only' | 'restricted'
|
||||
>('open')
|
||||
const [password, setPassword] = useState('')
|
||||
|
||||
const handleClose = () => {
|
||||
setError('')
|
||||
setGameName('matching')
|
||||
setAccessMode('open')
|
||||
setPassword('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
@@ -36,11 +46,14 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
|
||||
setError('')
|
||||
|
||||
const formData = new FormData(e.currentTarget)
|
||||
const name = formData.get('name') as string
|
||||
const gameName = formData.get('gameName') as string
|
||||
const nameValue = formData.get('name') as string
|
||||
|
||||
if (!name || !gameName) {
|
||||
setError('Please fill in all fields')
|
||||
// Treat empty name as null
|
||||
const name = nameValue?.trim() || null
|
||||
|
||||
// Validate password for password-protected rooms
|
||||
if (accessMode === 'password' && !password) {
|
||||
setError('Password is required for password-protected rooms')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -51,6 +64,8 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
|
||||
gameName,
|
||||
creatorName: 'Player',
|
||||
gameConfig: { difficulty: 6 },
|
||||
accessMode,
|
||||
password: accessMode === 'password' ? password : undefined,
|
||||
})
|
||||
|
||||
// Success! Close modal
|
||||
@@ -67,6 +82,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
|
||||
style={{
|
||||
border: '2px solid rgba(34, 197, 94, 0.3)',
|
||||
borderRadius: '16px',
|
||||
padding: '24px',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
@@ -90,32 +106,37 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
{/* Room Name */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
marginBottom: '6px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Room Name
|
||||
Room Name{' '}
|
||||
<span
|
||||
style={{ fontWeight: '400', color: 'rgba(156, 163, 175, 1)', fontSize: '12px' }}
|
||||
>
|
||||
(optional)
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
name="name"
|
||||
type="text"
|
||||
required
|
||||
placeholder="My Awesome Room"
|
||||
placeholder="e.g., Friday Night Games (defaults to: 🎮 CODE)"
|
||||
disabled={isPending}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
padding: '10px 12px',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
borderRadius: '8px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '15px',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
@@ -127,46 +148,198 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
{/* Game Selection */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Game
|
||||
Choose Game
|
||||
</label>
|
||||
<select
|
||||
name="gameName"
|
||||
required
|
||||
disabled={isPending}
|
||||
<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)}
|
||||
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)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{/* Access Mode Selection */}
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<label
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '15px',
|
||||
outline: 'none',
|
||||
cursor: isPending ? 'not-allowed' : 'pointer',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
<option value="matching">Memory Matching</option>
|
||||
<option value="memory-quiz">Memory Quiz</option>
|
||||
<option value="complement-race">Complement Race</option>
|
||||
</select>
|
||||
Who Can Join
|
||||
</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
{[
|
||||
{ value: 'open', emoji: '🌐', label: 'Open', desc: 'Anyone' },
|
||||
{ value: 'password', emoji: '🔑', label: 'Password', desc: 'With key' },
|
||||
{ value: 'approval-only', emoji: '✋', label: 'Approval', desc: 'Request' },
|
||||
{ value: 'restricted', emoji: '🚫', label: 'Restricted', desc: 'Invite only' },
|
||||
].map((mode) => (
|
||||
<button
|
||||
key={mode.value}
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
setAccessMode(mode.value as typeof accessMode)
|
||||
if (mode.value !== 'password') setPassword('')
|
||||
}}
|
||||
style={{
|
||||
padding: '10px 12px',
|
||||
background:
|
||||
accessMode === mode.value
|
||||
? 'rgba(34, 197, 94, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.05)',
|
||||
border:
|
||||
accessMode === mode.value
|
||||
? '2px solid rgba(34, 197, 94, 0.6)'
|
||||
: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '8px',
|
||||
color:
|
||||
accessMode === mode.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,
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isPending && accessMode !== mode.value) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
|
||||
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.4)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (accessMode !== mode.value) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '18px' }}>{mode.emoji}</span>
|
||||
<div style={{ textAlign: 'left', flex: 1, lineHeight: '1.2' }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: '600' }}>{mode.label}</div>
|
||||
<div style={{ fontSize: '11px', opacity: 0.7 }}>{mode.desc}</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{accessMode === 'password' && (
|
||||
<div style={{ marginBottom: '20px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
marginBottom: '8px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Room Password
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter a password"
|
||||
disabled={isPending}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px',
|
||||
border: '2px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '15px',
|
||||
outline: 'none',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p
|
||||
style={{
|
||||
|
||||
@@ -23,15 +23,18 @@ export interface RoomData {
|
||||
name: string
|
||||
code: string
|
||||
gameName: string
|
||||
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
|
||||
}
|
||||
|
||||
export interface CreateRoomParams {
|
||||
name: string
|
||||
name: string | null
|
||||
gameName: string
|
||||
creatorName?: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
password?: string
|
||||
}
|
||||
|
||||
export interface JoinRoomResult {
|
||||
@@ -68,6 +71,7 @@ async function fetchCurrentRoom(): Promise<RoomData | null> {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
@@ -85,6 +89,8 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
gameName: params.gameName,
|
||||
creatorName: params.creatorName || 'Player',
|
||||
gameConfig: params.gameConfig || { difficulty: 6 },
|
||||
accessMode: params.accessMode,
|
||||
password: params.password,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -99,6 +105,7 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
@@ -110,11 +117,15 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
async function joinRoomApi(params: {
|
||||
roomId: string
|
||||
displayName?: string
|
||||
password?: string
|
||||
}): Promise<JoinRoomResult> {
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ displayName: params.displayName || 'Player' }),
|
||||
body: JSON.stringify({
|
||||
displayName: params.displayName || 'Player',
|
||||
password: params.password,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -130,6 +141,7 @@ async function joinRoomApi(params: {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
},
|
||||
@@ -171,6 +183,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
|
||||
name: data.room.name,
|
||||
code: data.room.code,
|
||||
gameName: data.room.gameName,
|
||||
accessMode: data.room.accessMode || 'open',
|
||||
members: data.members || [],
|
||||
memberPlayers: data.memberPlayers || {},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user