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:
Thomas Hallock
2025-10-14 07:08:51 -05:00
parent f9ec5d32c5
commit dcbb5072d8
6 changed files with 588 additions and 71 deletions

View 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;

View File

@@ -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
}
]
}

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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={{

View File

@@ -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 || {},
}