Compare commits

...

16 Commits

Author SHA1 Message Date
semantic-release-bot
ba916e0f65 chore(release): 3.5.0 [skip ci]
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)

### Features

* replace access mode dropdown with visual button grid ([e5d0672](e5d0672059))
2025-10-14 12:31:26 +00:00
Thomas Hallock
e5d0672059 feat: replace access mode dropdown with visual button grid
Updated the ModerationPanel Settings tab to use a visual button grid
for access mode selection, matching the CreateRoomModal UX.

Changes:
- Replaced <select> dropdown with 3x2 grid of buttons
- Each button shows emoji + label + description
- Visual feedback for selected state and hover
- Includes all 6 access modes: open, password, approval-only,
  restricted, locked, retired
- Maintains same functionality with improved UX

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:30:26 -05:00
semantic-release-bot
5b4c69693d chore(release): 3.4.0 [skip ci]
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)

### Features

* add waiting state for approval requests in JoinRoomModal ([f9b0429](f9b0429a2e))
2025-10-14 12:29:42 +00:00
Thomas Hallock
f9b0429a2e feat: add waiting state for approval requests in JoinRoomModal
When users enter an approval-only room code in the JoinRoomModal, they now:
- See a prompt to send a join request
- After sending, see a "Waiting for Approval" screen
- Can close the modal and check back later

This matches the UX flow from the share link approval flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:28:41 -05:00
semantic-release-bot
34998d6b27 chore(release): 3.3.1 [skip ci]
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)

### Bug Fixes

* add POST handler for join requests API endpoint ([d3e5cdf](d3e5cdfc54))
2025-10-14 12:27:05 +00:00
Thomas Hallock
d3e5cdfc54 fix: add POST handler for join requests API endpoint
Previously the endpoint only had a GET handler, causing a 405 error
when users tried to request approval for approval-only rooms.

Now users can POST to create join requests with optional displayName.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:26:08 -05:00
semantic-release-bot
f949003870 chore(release): 3.3.0 [skip ci]
## [3.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.1...v3.3.0) (2025-10-14)

### Features

* implement approval request flow for share links ([4a6b3ca](4a6b3cabe5))
2025-10-14 12:20:01 +00:00
Thomas Hallock
4a6b3cabe5 feat: implement approval request flow for share links
When users click share links for approval-only rooms, they now:
- See a prompt to request approval from the room moderator
- Can send a join request with one click
- Get a waiting screen showing their request is pending

Room moderators now see:
- A prominent blue badge showing pending join request count
- Combined count of join requests + reports in the badge
- Badge turns blue when join requests exist (vs red for reports only)
- Detailed tooltip showing breakdown of pending items
- Real-time polling (30s intervals) for new join requests

Also includes improvements to room display names using emoji prefixes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:19:05 -05:00
semantic-release-bot
2cb6a512fe chore(release): 3.2.1 [skip ci]
## [3.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.0...v3.2.1) (2025-10-14)

### Bug Fixes

* allow password retry when joining via share link ([e469363](e469363699))
2025-10-14 12:13:25 +00:00
Thomas Hallock
e469363699 fix: allow password retry when joining via share link
- Password errors now stay in password prompt UI instead of redirecting to error page
- Error message clears when user starts typing new password
- Users can now retry incorrect passwords without losing the join flow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:12:26 -05:00
semantic-release-bot
b230cd7a1f chore(release): 3.2.0 [skip ci]
## [3.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.2...v3.2.0) (2025-10-14)

### Features

* improve room creation UX and add password support for share links ([dcbb507](dcbb5072d8))
2025-10-14 12:10:25 +00:00
Thomas Hallock
dcbb5072d8 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>
2025-10-14 07:09:22 -05:00
semantic-release-bot
f9ec5d32c5 chore(release): 3.1.2 [skip ci]
## [3.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.1...v3.1.2) (2025-10-14)

### Bug Fixes

* replace last remaining isLoading with isPending in CreateRoomModal ([85d13cc](85d13cc552))
2025-10-14 01:14:40 +00:00
Thomas Hallock
85d13cc552 fix: replace last remaining isLoading with isPending in CreateRoomModal
Missed one instance in the select dropdown cursor style.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 20:13:44 -05:00
semantic-release-bot
ef8a29e8ef chore(release): 3.1.1 [skip ci]
## [3.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.0...v3.1.1) (2025-10-14)

### Bug Fixes

* use useCreateRoom hook instead of nonexistent createRoom from useRoomData ([f7d63b3](f7d63b30ac))
2025-10-14 00:54:35 +00:00
Thomas Hallock
f7d63b30ac fix: use useCreateRoom hook instead of nonexistent createRoom from useRoomData
The CreateRoomModal was trying to destructure createRoom from useRoomData(),
but that hook doesn't export it. Changed to use the proper useCreateRoom() hook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:53:39 -05:00
19 changed files with 2196 additions and 241 deletions

View File

@@ -1,3 +1,59 @@
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)
### Features
* replace access mode dropdown with visual button grid ([e5d0672](https://github.com/antialias/soroban-abacus-flashcards/commit/e5d067205989d7c3105998dcd7d67fd0408f332c))
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)
### Features
* add waiting state for approval requests in JoinRoomModal ([f9b0429](https://github.com/antialias/soroban-abacus-flashcards/commit/f9b0429a2e2d22944acba66009dd87a9d9eb28c2))
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)
### Bug Fixes
* add POST handler for join requests API endpoint ([d3e5cdf](https://github.com/antialias/soroban-abacus-flashcards/commit/d3e5cdfc54f2749f27c6f8b8db854a8d0b6029f8))
## [3.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.1...v3.3.0) (2025-10-14)
### Features
* implement approval request flow for share links ([4a6b3ca](https://github.com/antialias/soroban-abacus-flashcards/commit/4a6b3cabe5c6aa42f4fa00ed09f9b3713f097539))
## [3.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.0...v3.2.1) (2025-10-14)
### Bug Fixes
* allow password retry when joining via share link ([e469363](https://github.com/antialias/soroban-abacus-flashcards/commit/e469363699071610a35e0b5c507d0e15e29daa44))
## [3.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.2...v3.2.0) (2025-10-14)
### Features
* improve room creation UX and add password support for share links ([dcbb507](https://github.com/antialias/soroban-abacus-flashcards/commit/dcbb5072d8e0a12838fe70e3faa85f94cd63b0c1))
## [3.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.1...v3.1.2) (2025-10-14)
### Bug Fixes
* replace last remaining isLoading with isPending in CreateRoomModal ([85d13cc](https://github.com/antialias/soroban-abacus-flashcards/commit/85d13cc552cfe2e825f8ea20c7db00d666599134))
## [3.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.0...v3.1.1) (2025-10-14)
### Bug Fixes
* use useCreateRoom hook instead of nonexistent createRoom from useRoomData ([f7d63b3](https://github.com/antialias/soroban-abacus-flashcards/commit/f7d63b30ac498b63797ae8683a0beb435a1c97b3))
## [3.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.0.0...v3.1.0) (2025-10-14)

View File

@@ -46,7 +46,18 @@
"Bash(npx @biomejs/biome check:*)",
"Bash(printf '\\n')",
"Bash(npm install bcryptjs)",
"Bash(npm install:*)"
"Bash(npm install:*)",
"Bash(pnpm add:*)",
"Bash(sqlite3:*)",
"Bash(shasum:*)",
"Bash(awk:*)",
"Bash(if npx tsc --noEmit)",
"Bash(then echo \"TypeScript errors found in our files\")",
"Bash(else echo \"✓ No TypeScript errors in our modified files\")",
"Bash(fi)",
"Bash(then echo \"TypeScript errors found\")",
"Bash(else echo \"✓ No TypeScript errors in join page\")",
"Bash(npx @biomejs/biome format:*)"
],
"deny": [],
"ask": []

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

@@ -1,6 +1,7 @@
import { type NextRequest, NextResponse } from 'next/server'
import { createJoinRequest, getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
@@ -37,3 +38,58 @@ export async function GET(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Failed to get join requests' }, { status: 500 })
}
}
/**
* POST /api/arcade/rooms/:roomId/join-requests
* Create a join request for an approval-only room
* Body:
* - displayName?: string (optional, will generate from viewerId if not provided)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json().catch(() => ({}))
// Get room to verify it exists
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Verify room is approval-only
if (room.accessMode !== 'approval-only') {
return NextResponse.json(
{ error: 'This room does not require approval to join' },
{ status: 400 }
)
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`
// Validate display name length
if (displayName.length > 50) {
return NextResponse.json(
{ error: 'Display name too long (max 50 characters)' },
{ status: 400 }
)
}
// Create join request
const request = await createJoinRequest({
roomId,
userId: viewerId,
userName: displayName,
})
console.log(
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
)
return NextResponse.json({ request }, { status: 201 })
} catch (error: any) {
console.error('Failed to create join request:', error)
return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 })
}
}

View File

@@ -62,18 +62,17 @@ export async function GET(req: NextRequest) {
* - gameName: string
* - gameConfig?: object
* - ttlMinutes?: number
* - accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
* - password?: string
*/
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 required fields (name is optional, gameName is required)
if (!body.gameName) {
return NextResponse.json({ error: 'Missing required field: gameName' }, { status: 400 })
}
// Validate game name
@@ -82,22 +81,50 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
// Validate name length
if (body.name.length > 50) {
// Validate name length (if provided)
if (body.name && body.name.length > 50) {
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
}
// Normalize empty name to null
const roomName = body.name?.trim() || null
// Validate access mode
if (body.accessMode) {
const validAccessModes = [
'open',
'password',
'approval-only',
'restricted',
'locked',
'retired',
]
if (!validAccessModes.includes(body.accessMode)) {
return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 })
}
}
// Validate password if provided
if (body.accessMode === 'password' && !body.password) {
return NextResponse.json(
{ error: 'Password is required for password-protected rooms' },
{ 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,
name: roomName,
createdBy: viewerId,
creatorName: displayName,
gameName: body.gameName,
gameConfig: body.gameConfig || {},
ttlMinutes: body.ttlMinutes,
accessMode: body.accessMode,
password: body.password,
})
// Add creator as first member

View File

@@ -6,11 +6,12 @@ import { io, type Socket } from 'socket.io-client'
import { css } from '../../../../styled-system/css'
import { PageWithNav } from '@/components/PageWithNav'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
interface Room {
id: string
code: string
name: string
name: string | null
gameName: string
status: 'lobby' | 'playing' | 'finished'
createdBy: string
@@ -357,7 +358,11 @@ export default function RoomDetailPage() {
mb: '2',
})}
>
{room.name}
{getRoomDisplayWithEmoji({
name: room.name,
code: room.code,
gameName: room.gameName,
})}
</h1>
<div
className={css({

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

@@ -1,12 +1,13 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
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,35 @@ 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 [showApprovalPrompt, setShowApprovalPrompt] = useState(false)
const [approvalRequested, setApprovalRequested] = 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 +254,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 +264,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') {
setShowApprovalPrompt(true)
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 +306,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,7 +319,40 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
router.push('/arcade/room') // Stay in current room
}
if (error) {
const handlePasswordSubmit = () => {
if (targetRoomData && password) {
handleJoin(targetRoomData.id, password)
}
}
const handleRequestApproval = async () => {
if (!targetRoomData) return
setIsJoining(true)
setError(null)
try {
const res = await fetch(`/api/arcade/rooms/${targetRoomData.id}/join-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to request approval')
}
// Request sent successfully - show waiting state
setApprovalRequested(true)
setIsJoining(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to request approval')
setIsJoining(false)
}
}
// Only show error page for non-password and non-approval errors
if (error && !showPasswordPrompt && !showApprovalPrompt) {
return (
<div
style={{
@@ -316,16 +396,438 @@ 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)
setError(null) // Clear error when user starts typing
}}
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>
)
}
if (showApprovalPrompt && 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(59, 130, 246, 0.3)',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
}}
>
{approvalRequested ? (
// Waiting for approval state
<>
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(96, 165, 250, 1)',
}}
>
Waiting for Approval
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.8)',
}}
>
Your request has been sent to the room moderator.
</p>
</div>
<div
style={{
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '12px',
padding: '16px',
marginBottom: '20px',
}}
>
<div
style={{ fontSize: '14px', fontWeight: '600', color: 'rgba(96, 165, 250, 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>
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
textAlign: 'center',
marginBottom: '20px',
}}
>
You'll be able to join once the host approves your request. You can close this page
and check back later.
</p>
<button
type="button"
onClick={() => router.push('/arcade')}
style={{
width: '100%',
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: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}}
>
Go to Champion Arena
</button>
</>
) : (
// Request approval prompt
<>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(96, 165, 250, 1)',
}}
>
✋ Approval Required
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '20px',
}}
>
This room requires host approval to join. Send a request?
</p>
<div
style={{
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '12px',
padding: '16px',
marginBottom: '20px',
}}
>
<div
style={{ fontSize: '14px', fontWeight: '600', color: 'rgba(96, 165, 250, 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>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<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',
}}
onMouseEnter={(e) => {
if (!isJoining) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isJoining) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRequestApproval}
disabled={isJoining}
style={{
flex: 1,
padding: '12px',
background: isJoining
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: isJoining ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
border: isJoining
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isJoining ? 'not-allowed' : 'pointer',
opacity: isJoining ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isJoining) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isJoining) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isJoining ? 'Sending...' : 'Request to Join'}
</button>
</div>
</>
)}
</div>
</div>
)
}
return null
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { useRoomData } from '@/hooks/useRoomData'
import { useCreateRoom } from '@/hooks/useRoomData'
export interface CreateRoomModalProps {
/**
@@ -23,13 +23,21 @@ export interface CreateRoomModalProps {
* Modal for creating a new multiplayer room
*/
export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalProps) {
const { createRoom } = useRoomData()
const { mutateAsync: createRoom, isPending } = useCreateRoom()
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
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('')
setIsLoading(false)
setGameName('matching')
setAccessMode('open')
setPassword('')
onClose()
}
@@ -38,16 +46,17 @@ 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
}
setIsLoading(true)
try {
// Create the room (creator is auto-added as first member)
await createRoom({
@@ -55,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
@@ -62,8 +73,6 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
onSuccess?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create room')
} finally {
setIsLoading(false)
}
}
@@ -73,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
@@ -96,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"
disabled={isLoading}
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) => {
@@ -133,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={isLoading}
<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: isLoading ? '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={{
@@ -190,7 +357,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
<button
type="button"
onClick={handleClose}
disabled={isLoading}
disabled={isPending}
style={{
flex: 1,
padding: '12px',
@@ -200,17 +367,17 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
@@ -219,38 +386,38 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
</button>
<button
type="submit"
disabled={isLoading}
disabled={isPending}
style={{
flex: 1,
padding: '12px',
background: isLoading
background: isPending
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))',
color: 'rgba(255, 255, 255, 1)',
border: isLoading
border: isPending
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(34, 197, 94, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(34, 197, 94, 0.9), rgba(22, 163, 74, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))'
}
}}
>
{isLoading ? 'Creating...' : 'Create Room'}
{isPending ? 'Creating...' : 'Create Room'}
</button>
</div>
</form>

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import type { schema } from '@/db'
import { useRoomData } from '@/hooks/useRoomData'
export interface JoinRoomModalProps {
@@ -25,13 +26,23 @@ export interface JoinRoomModalProps {
export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps) {
const { getRoomByCode, joinRoom } = useRoomData()
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [roomInfo, setRoomInfo] = useState<schema.ArcadeRoom | null>(null)
const [needsPassword, setNeedsPassword] = useState(false)
const [needsApproval, setNeedsApproval] = useState(false)
const [approvalRequested, setApprovalRequested] = useState(false)
const handleClose = () => {
setCode('')
setPassword('')
setError('')
setIsLoading(false)
setRoomInfo(null)
setNeedsPassword(false)
setNeedsApproval(false)
setApprovalRequested(false)
onClose()
}
@@ -50,9 +61,50 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
try {
// Look up room by code
const room = await getRoomByCode(normalizedCode)
setRoomInfo(room)
// Join the room
await joinRoom(room.id)
// Check access mode
if (room.accessMode === 'retired') {
setError('This room has been retired and is no longer accepting members')
setIsLoading(false)
return
}
if (room.accessMode === 'locked') {
setError('This room is locked and not accepting new members')
setIsLoading(false)
return
}
if (room.accessMode === 'restricted') {
setError('This room is invitation-only. Please ask the host for an invitation.')
setIsLoading(false)
return
}
if (room.accessMode === 'approval-only') {
setNeedsApproval(true)
setIsLoading(false)
return
}
if (room.accessMode === 'password') {
// Check if password is provided
if (!needsPassword) {
setNeedsPassword(true)
setIsLoading(false)
return
}
if (!password) {
setError('Password is required')
setIsLoading(false)
return
}
}
// Join the room (with password if needed)
await joinRoom(room.id, password || undefined)
// Success! Close modal
handleClose()
@@ -64,6 +116,32 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
}
}
const handleRequestAccess = async () => {
if (!roomInfo) return
setIsLoading(true)
setError('')
try {
const res = await fetch(`/api/arcade/rooms/${roomInfo.id}/join-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to request access')
}
// Success! Show waiting state
setApprovalRequested(true)
setIsLoading(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to request access')
setIsLoading(false)
}
}
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<div
@@ -80,7 +158,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
color: 'rgba(196, 181, 253, 1)',
}}
>
Join Room by Code
{needsApproval ? 'Request to Join Room' : 'Join Room by Code'}
</h2>
<p
style={{
@@ -89,125 +167,354 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
marginBottom: '24px',
}}
>
Enter the 6-character room code
{needsApproval
? approvalRequested
? 'Your request has been sent to the room moderator.'
: 'This room requires host approval. Send a request to join?'
: needsPassword
? 'This room is password protected'
: 'Enter the 6-character room code'}
</p>
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => {
setCode(e.target.value.toUpperCase())
setError('')
}}
placeholder="ABC123"
maxLength={6}
disabled={isLoading}
style={{
width: '100%',
padding: '14px',
border: error
? '2px solid rgba(239, 68, 68, 0.6)'
: '2px solid rgba(139, 92, 246, 0.4)',
borderRadius: '10px',
fontSize: '18px',
fontWeight: 'bold',
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: '4px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(196, 181, 253, 1)',
outline: 'none',
marginBottom: '8px',
}}
/>
{needsApproval ? (
// Approval request UI
<div>
{approvalRequested ? (
// Waiting for approval state
<>
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h3
style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(96, 165, 250, 1)',
}}
>
Waiting for Approval
</h3>
</div>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div
style={{
padding: '16px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
marginBottom: '20px',
}}
>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '8px',
}}
>
<strong>{roomInfo?.name}</strong>
</p>
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
Code: {roomInfo?.code}
</p>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
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: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={code.trim().length !== 6 || isLoading}
style={{
flex: 1,
padding: '12px',
background:
code.trim().length === 6 && !isLoading
? 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
: 'rgba(75, 85, 99, 0.3)',
color:
code.trim().length === 6 && !isLoading
? 'rgba(255, 255, 255, 1)'
: 'rgba(156, 163, 175, 1)',
border:
code.trim().length === 6 && !isLoading
? '2px solid rgba(59, 130, 246, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: code.trim().length === 6 && !isLoading ? 'pointer' : 'not-allowed',
opacity: code.trim().length === 6 && !isLoading ? 1 : 0.5,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Joining...' : 'Join Room'}
</button>
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
textAlign: 'center',
marginBottom: '20px',
}}
>
You'll be able to join once the host approves your request. You can close this
dialog and check back later.
</p>
<button
type="button"
onClick={handleClose}
style={{
width: '100%',
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: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}}
>
Close
</button>
</>
) : (
// Initial request prompt
<>
<div
style={{
padding: '16px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
marginBottom: '20px',
}}
>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '8px',
}}
>
<strong>{roomInfo?.name}</strong>
</p>
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
Code: {roomInfo?.code}
</p>
</div>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
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: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRequestAccess}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: isLoading
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
border: isLoading
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Sending...' : 'Send Request'}
</button>
</div>
</>
)}
</div>
</form>
) : (
// Standard join form
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => {
setCode(e.target.value.toUpperCase())
setError('')
setNeedsPassword(false)
setNeedsApproval(false)
}}
placeholder="ABC123"
maxLength={6}
disabled={isLoading || needsPassword}
style={{
width: '100%',
padding: '14px',
border: error
? '2px solid rgba(239, 68, 68, 0.6)'
: '2px solid rgba(139, 92, 246, 0.4)',
borderRadius: '10px',
fontSize: '18px',
fontWeight: 'bold',
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: '4px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(196, 181, 253, 1)',
outline: 'none',
marginBottom: '8px',
}}
/>
{needsPassword && (
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setError('')
}}
placeholder="Enter password"
disabled={isLoading}
style={{
width: '100%',
padding: '14px',
border: error
? '2px solid rgba(239, 68, 68, 0.6)'
: '2px solid rgba(251, 191, 36, 0.4)',
borderRadius: '10px',
fontSize: '16px',
textAlign: 'center',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(251, 191, 36, 1)',
outline: 'none',
marginBottom: '8px',
marginTop: '12px',
}}
/>
)}
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
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: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={code.trim().length !== 6 || isLoading}
style={{
flex: 1,
padding: '12px',
background:
code.trim().length === 6 && !isLoading
? 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
: 'rgba(75, 85, 99, 0.3)',
color:
code.trim().length === 6 && !isLoading
? 'rgba(255, 255, 255, 1)'
: 'rgba(156, 163, 175, 1)',
border:
code.trim().length === 6 && !isLoading
? '2px solid rgba(59, 130, 246, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: code.trim().length === 6 && !isLoading ? 'pointer' : 'not-allowed',
opacity: code.trim().length === 6 && !isLoading ? 1 : 0.5,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Joining...' : needsPassword ? 'Join with Password' : 'Join Room'}
</button>
</div>
</form>
)}
</div>
</Modal>
)

View File

@@ -47,7 +47,7 @@ export interface ModerationPanelProps {
focusedUserId?: string
}
type Tab = 'members' | 'bans' | 'history'
type Tab = 'members' | 'bans' | 'history' | 'settings'
export interface HistoricalMemberWithStatus {
userId: string
@@ -86,6 +86,13 @@ export function ModerationPanel({
const [error, setError] = useState('')
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Settings state
const [accessMode, setAccessMode] = useState<string>('open')
const [roomPassword, setRoomPassword] = useState('')
const [showPasswordInput, setShowPasswordInput] = useState(false)
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('')
const [joinRequests, setJoinRequests] = useState<any[]>([])
// Ban modal state
const [showBanModal, setShowBanModal] = useState(false)
const [banTargetUserId, setBanTargetUserId] = useState<string | null>(null)
@@ -323,6 +330,146 @@ export function ModerationPanel({
}
}
// Load room settings and join requests when Settings tab is opened
useEffect(() => {
if (!isOpen || activeTab !== 'settings') return
const loadSettings = async () => {
try {
// Fetch current room data to get access mode
const roomRes = await fetch(`/api/arcade/rooms/${roomId}`)
if (roomRes.ok) {
const data = await roomRes.json()
setAccessMode(data.room?.accessMode || 'open')
}
// Fetch join requests if any
const requestsRes = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (requestsRes.ok) {
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
} catch (err) {
console.error('Failed to load settings:', err)
}
}
loadSettings()
}, [isOpen, activeTab, roomId])
// Handlers for Settings tab
const handleUpdateAccessMode = async () => {
setActionLoading('update-settings')
try {
const body: any = { accessMode }
if (accessMode === 'password' && roomPassword) {
body.password = roomPassword
}
const res = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to update settings')
}
alert('Room settings updated successfully!')
setShowPasswordInput(false)
setRoomPassword('')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update settings')
} finally {
setActionLoading(null)
}
}
const handleTransferOwnership = async () => {
if (!selectedNewOwner) return
const newOwner = members.find((m) => m.userId === selectedNewOwner)
if (!newOwner) return
if (!confirm(`Transfer ownership to ${newOwner.displayName}? You will no longer be the host.`))
return
setActionLoading('transfer-ownership')
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/transfer-ownership`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newOwnerId: selectedNewOwner }),
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to transfer ownership')
}
alert(`Ownership transferred to ${newOwner.displayName}!`)
onClose() // Close panel since user is no longer host
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to transfer ownership')
} finally {
setActionLoading(null)
}
}
const handleApproveJoinRequest = async (requestId: string) => {
setActionLoading(`approve-request-${requestId}`)
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/join-requests/${requestId}/approve`, {
method: 'POST',
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to approve request')
}
// Reload requests
const requestsRes = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (requestsRes.ok) {
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
alert('Join request approved!')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to approve request')
} finally {
setActionLoading(null)
}
}
const handleDenyJoinRequest = async (requestId: string) => {
setActionLoading(`deny-request-${requestId}`)
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/join-requests/${requestId}/deny`, {
method: 'POST',
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to deny request')
}
// Reload requests
const requestsRes = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (requestsRes.ok) {
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to deny request')
} finally {
setActionLoading(null)
}
}
const pendingReports = reports.filter((r) => r.status === 'pending')
const otherMembers = members.filter((m) => m.userId !== currentUserId)
@@ -375,7 +522,7 @@ export function ModerationPanel({
borderBottom: '1px solid rgba(75, 85, 99, 0.3)',
}}
>
{(['members', 'bans', 'history'] as Tab[]).map((tab) => (
{(['members', 'bans', 'history', 'settings'] as Tab[]).map((tab) => (
<button
key={tab}
type="button"
@@ -415,6 +562,26 @@ export function ModerationPanel({
)}
{tab === 'bans' && `Banned (${bans.length})`}
{tab === 'history' && `History (${historicalMembers.length})`}
{tab === 'settings' && (
<span>
Settings
{joinRequests.filter((r: any) => r.status === 'pending').length > 0 && (
<span
style={{
marginLeft: '6px',
padding: '2px 6px',
borderRadius: '10px',
background: 'rgba(59, 130, 246, 0.8)',
color: 'white',
fontSize: '11px',
fontWeight: '700',
}}
>
{joinRequests.filter((r: any) => r.status === 'pending').length} pending
</span>
)}
</span>
)}
</button>
))}
</div>
@@ -1139,6 +1306,357 @@ export function ModerationPanel({
)}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{/* Access Mode Section */}
<div>
<div
style={{
fontSize: '14px',
fontWeight: '700',
color: 'rgba(253, 186, 116, 1)',
marginBottom: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
🔒 Room Access Mode
</div>
<div
style={{
padding: '16px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.3)',
borderRadius: '8px',
}}
>
{/* Access mode button grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '12px',
}}
>
{[
{ 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',
},
{ value: 'locked', emoji: '🔒', label: 'Locked', desc: 'No members' },
{ value: 'retired', emoji: '🏁', label: 'Retired', desc: 'Closed' },
].map((mode) => (
<button
key={mode.value}
type="button"
disabled={actionLoading === 'update-settings'}
onClick={() => {
setAccessMode(mode.value)
setShowPasswordInput(mode.value === 'password')
}}
style={{
padding: '10px 12px',
background:
accessMode === mode.value
? 'rgba(253, 186, 116, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
border:
accessMode === mode.value
? '2px solid rgba(253, 186, 116, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '8px',
color:
accessMode === mode.value
? 'rgba(253, 186, 116, 1)'
: 'rgba(209, 213, 219, 0.8)',
fontSize: '13px',
fontWeight: '500',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
if (actionLoading !== 'update-settings' && accessMode !== mode.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 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>
{/* Password input (conditional) */}
{(accessMode === 'password' || showPasswordInput) && (
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter room password"
style={{
width: '100%',
padding: '10px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
marginBottom: '12px',
}}
/>
)}
<button
type="button"
onClick={handleUpdateAccessMode}
disabled={actionLoading === 'update-settings'}
style={{
width: '100%',
padding: '10px',
background:
actionLoading === 'update-settings'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border:
actionLoading === 'update-settings'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
}}
>
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
</button>
</div>
</div>
{/* Join Requests Section (for approval-only mode) */}
{joinRequests.filter((r: any) => r.status === 'pending').length > 0 && (
<div>
<div
style={{
fontSize: '14px',
fontWeight: '700',
color: 'rgba(59, 130, 246, 1)',
marginBottom: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
🙋 Pending Join Requests
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{joinRequests
.filter((r: any) => r.status === 'pending')
.map((request: any) => (
<div
key={request.id}
style={{
padding: '12px',
background: 'rgba(59, 130, 246, 0.08)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 1)',
}}
>
{request.userName || 'Anonymous User'}
</div>
<div
style={{
fontSize: '12px',
color: 'rgba(156, 163, 175, 1)',
marginTop: '2px',
}}
>
Requested {new Date(request.createdAt).toLocaleString()}
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={() => handleDenyJoinRequest(request.id)}
disabled={actionLoading === `deny-request-${request.id}`}
style={{
padding: '6px 12px',
background: 'rgba(239, 68, 68, 0.2)',
color: 'rgba(239, 68, 68, 1)',
border: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '6px',
fontSize: '13px',
fontWeight: '600',
cursor:
actionLoading === `deny-request-${request.id}`
? 'not-allowed'
: 'pointer',
opacity: actionLoading === `deny-request-${request.id}` ? 0.5 : 1,
}}
>
{actionLoading === `deny-request-${request.id}`
? 'Denying...'
: 'Deny'}
</button>
<button
type="button"
onClick={() => handleApproveJoinRequest(request.id)}
disabled={actionLoading === `approve-request-${request.id}`}
style={{
padding: '6px 12px',
background: 'rgba(34, 197, 94, 0.2)',
color: 'rgba(34, 197, 94, 1)',
border: '1px solid rgba(34, 197, 94, 0.4)',
borderRadius: '6px',
fontSize: '13px',
fontWeight: '600',
cursor:
actionLoading === `approve-request-${request.id}`
? 'not-allowed'
: 'pointer',
opacity:
actionLoading === `approve-request-${request.id}` ? 0.5 : 1,
}}
>
{actionLoading === `approve-request-${request.id}`
? 'Approving...'
: 'Approve'}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Transfer Ownership Section */}
<div>
<div
style={{
fontSize: '14px',
fontWeight: '700',
color: 'rgba(251, 146, 60, 1)',
marginBottom: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
👑 Transfer Ownership
</div>
<div
style={{
padding: '16px',
background: 'rgba(251, 146, 60, 0.08)',
border: '1px solid rgba(251, 146, 60, 0.3)',
borderRadius: '8px',
}}
>
<p
style={{
fontSize: '13px',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '12px',
}}
>
Transfer host privileges to another member. You will no longer be the host.
</p>
<select
value={selectedNewOwner}
onChange={(e) => setSelectedNewOwner(e.target.value)}
style={{
width: '100%',
padding: '10px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
marginBottom: '12px',
cursor: 'pointer',
}}
>
<option value="">Select new owner...</option>
{otherMembers.map((member) => (
<option key={member.userId} value={member.userId}>
{member.displayName}
{member.isOnline ? ' (Online)' : ' (Offline)'}
</option>
))}
</select>
<button
type="button"
onClick={handleTransferOwnership}
disabled={!selectedNewOwner || actionLoading === 'transfer-ownership'}
style={{
width: '100%',
padding: '10px',
background:
!selectedNewOwner || actionLoading === 'transfer-ownership'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))',
color: 'white',
border:
!selectedNewOwner || actionLoading === 'transfer-ownership'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(251, 146, 60, 0.6)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor:
!selectedNewOwner || actionLoading === 'transfer-ownership'
? 'not-allowed'
: 'pointer',
opacity:
!selectedNewOwner || actionLoading === 'transfer-ownership' ? 0.5 : 1,
}}
>
{actionLoading === 'transfer-ownership'
? 'Transferring...'
: 'Transfer Ownership'}
</button>
</div>
</div>
</div>
)}
</div>
)}

View File

@@ -1,8 +1,9 @@
import React from 'react'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
interface RecentRoom {
code: string
name: string
name: string | null
gameName: string
joinedAt: number
}
@@ -100,7 +101,13 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
}}
>
<span>🏟</span>
<span>{room.name}</span>
<span>
{getRoomDisplayWithEmoji({
name: room.name,
code: room.code,
gameName: room.gameName,
})}
</span>
</div>
<div
style={{
@@ -123,7 +130,11 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
}
// Helper function to add a room to recent rooms
export function addToRecentRooms(room: { code: string; name: string; gameName: string }): void {
export function addToRecentRooms(room: {
code: string
name: string | null
gameName: string
}): void {
try {
const stored = localStorage.getItem(STORAGE_KEY)
const rooms: RecentRoom[] = stored ? JSON.parse(stored) : []

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
import { CreateRoomModal } from './CreateRoomModal'
import { JoinRoomModal } from './JoinRoomModal'
import { ModerationPanel } from './ModerationPanel'
@@ -11,7 +12,7 @@ import { RoomShareButtons } from './RoomShareButtons'
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
interface RoomInfoProps {
roomName?: string
roomName?: string | null
gameName: string
playerCount: number
joinCode?: string
@@ -57,11 +58,15 @@ export function RoomInfo({
const [showModerationPanel, setShowModerationPanel] = useState(false)
const [focusedUserId, setFocusedUserId] = useState<string | undefined>(undefined)
const [pendingReportsCount, setPendingReportsCount] = useState(0)
const [pendingJoinRequestsCount, setPendingJoinRequestsCount] = useState(0)
const { getRoomShareUrl, roomData } = useRoomData()
const { data: currentUserId } = useViewerId()
const { mutateAsync: leaveRoom } = useLeaveRoom()
const displayName = roomName || gameName
// Use room display utility for consistent naming
const displayName = joinCode
? getRoomDisplayWithEmoji({ name: roomName || null, code: joinCode, gameName })
: roomName || gameName
const shareUrl = joinCode ? getRoomShareUrl(joinCode) : ''
// Determine ownership status
@@ -93,6 +98,29 @@ export function RoomInfo({
return () => clearInterval(interval)
}, [isCurrentUserCreator, roomId])
// Fetch pending join requests count if user is host
useEffect(() => {
if (!isCurrentUserCreator || !roomId) return
const fetchPendingJoinRequests = async () => {
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (res.ok) {
const data = await res.json()
const pending = data.requests?.filter((r: any) => r.status === 'pending') || []
setPendingJoinRequestsCount(pending.length)
}
} catch (error) {
console.error('[RoomInfo] Failed to fetch join requests:', error)
}
}
fetchPendingJoinRequests()
// Poll every 30 seconds
const interval = setInterval(fetchPendingJoinRequests, 30000)
return () => clearInterval(interval)
}, [isCurrentUserCreator, roomId])
// Listen for moderation events to update report count in real-time
const { moderationEvent } = useRoomData()
useEffect(() => {
@@ -235,8 +263,8 @@ export function RoomInfo({
>
<span style={{ fontSize: '10px', lineHeight: 1 }}>👑</span>
<span style={{ lineHeight: 1 }}>You are host</span>
{/* Pending reports badge */}
{pendingReportsCount > 0 && (
{/* Pending items badge (reports + join requests) */}
{(pendingReportsCount > 0 || pendingJoinRequestsCount > 0) && (
<span
style={{
display: 'inline-flex',
@@ -245,15 +273,24 @@ export function RoomInfo({
width: '16px',
height: '16px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 1)',
background:
pendingJoinRequestsCount > 0
? 'rgba(59, 130, 246, 1)'
: 'rgba(239, 68, 68, 1)',
color: 'white',
fontSize: '8px',
fontWeight: '700',
marginLeft: '2px',
}}
title={`${pendingReportsCount} pending report${pendingReportsCount > 1 ? 's' : ''}`}
title={
pendingJoinRequestsCount > 0 && pendingReportsCount > 0
? `${pendingJoinRequestsCount} join request${pendingJoinRequestsCount > 1 ? 's' : ''}, ${pendingReportsCount} report${pendingReportsCount > 1 ? 's' : ''}`
: pendingJoinRequestsCount > 0
? `${pendingJoinRequestsCount} join request${pendingJoinRequestsCount > 1 ? 's' : ''}`
: `${pendingReportsCount} report${pendingReportsCount > 1 ? 's' : ''}`
}
>
{pendingReportsCount}
{pendingReportsCount + pendingJoinRequestsCount}
</span>
)}
</div>

View File

@@ -8,7 +8,7 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
// Room identity
code: text('code', { length: 6 }).notNull().unique(), // e.g., "ABC123"
name: text('name', { length: 50 }).notNull(),
name: text('name', { length: 50 }), // Optional: auto-generates from code and game if null
// Creator info
createdBy: text('created_by').notNull(), // User/guest ID

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

View File

@@ -9,12 +9,14 @@ import { generateRoomCode } from './room-code'
import type { GameName } from './validation'
export interface CreateRoomOptions {
name: string
name: string | null
createdBy: string // User/guest ID
creatorName: string
gameName: GameName
gameConfig: unknown
ttlMinutes?: number // Default: 60
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
password?: string
}
export interface UpdateRoomOptions {
@@ -55,7 +57,8 @@ export async function createRoom(options: CreateRoomOptions): Promise<schema.Arc
createdAt: now,
lastActivity: now,
ttlMinutes: options.ttlMinutes || 60,
accessMode: 'open', // Default to open access
accessMode: options.accessMode || 'open', // Default to open access
password: options.password || null,
gameName: options.gameName,
gameConfig: options.gameConfig as any,
status: 'lobby',

View File

@@ -0,0 +1,126 @@
/**
* Utility for displaying room names consistently across the codebase
*/
export interface RoomDisplayData {
/**
* The room's custom name if provided
*/
name: string | null
/**
* The room's unique code (e.g., "ABC123")
*/
code: string
/**
* The game type (optional, for emoji selection)
*/
gameName?: string
}
export interface RoomDisplay {
/**
* Plain text representation - ALWAYS available
* Use this for: document titles, logs, notifications, plaintext contexts
*/
plaintext: string
/**
* Primary display text (without emoji)
*/
primary: string
/**
* Secondary/subtitle text (optional)
*/
secondary?: string
/**
* Emoji/icon for the room (optional)
*/
emoji?: string
/**
* Whether the name was auto-generated (vs. custom)
*/
isGenerated: boolean
}
const GAME_EMOJIS: Record<string, string> = {
matching: '🃏',
'memory-quiz': '🧠',
'complement-race': '⚡',
}
const DEFAULT_EMOJI = '🎮'
/**
* Get structured room display information
*
* @example
* // Custom named room
* const display = getRoomDisplay({ name: "Alice's Room", code: "ABC123" })
* // => { plaintext: "Alice's Room", primary: "Alice's Room", secondary: "ABC123", emoji: undefined, isGenerated: false }
*
* @example
* // Auto-generated (no name)
* const display = getRoomDisplay({ name: null, code: "ABC123", gameName: "matching" })
* // => { plaintext: "Room ABC123", primary: "ABC123", secondary: undefined, emoji: "🃏", isGenerated: true }
*/
export function getRoomDisplay(room: RoomDisplayData): RoomDisplay {
if (room.name) {
// Custom name provided
return {
plaintext: room.name,
primary: room.name,
secondary: room.code,
emoji: undefined,
isGenerated: false,
}
}
// Auto-generate display
const emoji = GAME_EMOJIS[room.gameName || ''] || DEFAULT_EMOJI
return {
plaintext: `Room ${room.code}`, // Always plaintext fallback
primary: room.code,
secondary: undefined,
emoji,
isGenerated: true,
}
}
/**
* Get plaintext room name (shorthand)
* Use this when you just need a string representation
*
* @example
* getRoomDisplayName({ name: "Alice's Room", code: "ABC123" })
* // => "Alice's Room"
*
* @example
* getRoomDisplayName({ name: null, code: "ABC123" })
* // => "Room ABC123"
*/
export function getRoomDisplayName(room: RoomDisplayData): string {
return getRoomDisplay(room).plaintext
}
/**
* Get room display with emoji (for rich contexts)
*
* @example
* getRoomDisplayWithEmoji({ name: "Alice's Room", code: "ABC123" })
* // => "Alice's Room"
*
* @example
* getRoomDisplayWithEmoji({ name: null, code: "ABC123", gameName: "matching" })
* // => "🃏 ABC123"
*/
export function getRoomDisplayWithEmoji(room: RoomDisplayData): string {
const display = getRoomDisplay(room)
if (display.emoji) {
return `${display.emoji} ${display.primary}`
}
return display.primary
}

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "3.1.0",
"version": "3.5.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [