Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c851462de | ||
|
|
85b2cf9816 | ||
|
|
4c6eb01f1e | ||
|
|
7d08fdd906 | ||
|
|
0d4f400dca | ||
|
|
396b6c07c7 | ||
|
|
35b4a72c8b | ||
|
|
ba916e0f65 | ||
|
|
e5d0672059 | ||
|
|
5b4c69693d | ||
|
|
f9b0429a2e | ||
|
|
34998d6b27 | ||
|
|
d3e5cdfc54 | ||
|
|
f949003870 | ||
|
|
4a6b3cabe5 | ||
|
|
2cb6a512fe | ||
|
|
e469363699 | ||
|
|
b230cd7a1f | ||
|
|
dcbb5072d8 | ||
|
|
f9ec5d32c5 | ||
|
|
85d13cc552 |
75
CHANGELOG.md
75
CHANGELOG.md
@@ -1,3 +1,78 @@
|
||||
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow join with pending invitation for restricted rooms ([85b2cf9](https://github.com/antialias/soroban-abacus-flashcards/commit/85b2cf98167ccf632ab634a94eb436e1eb584614))
|
||||
|
||||
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* join user socket channel to receive approval notifications ([7d08fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/7d08fdd90643920857eda09998ac01afbae74154))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove redundant polling from approval notifications ([0d4f400](https://github.com/antialias/soroban-abacus-flashcards/commit/0d4f400dca02ad9497522c24fded8b6d07d85fd2))
|
||||
|
||||
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add socket listener and polling for approval notifications ([35b4a72](https://github.com/antialias/soroban-abacus-flashcards/commit/35b4a72c8b2f80a74b5d2fe02b048d4ec4d1d6f2))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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": []
|
||||
|
||||
41
apps/web/drizzle/0008_make_room_name_nullable.sql
Normal file
41
apps/web/drizzle/0008_make_room_name_nullable.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- Make room name nullable to support auto-generated names
|
||||
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
||||
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
|
||||
-- Create temporary table with correct schema
|
||||
CREATE TABLE `arcade_rooms_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`code` text(6) NOT NULL,
|
||||
`name` text(50),
|
||||
`created_by` text NOT NULL,
|
||||
`creator_name` text(50) NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_activity` integer NOT NULL,
|
||||
`ttl_minutes` integer DEFAULT 60 NOT NULL,
|
||||
`access_mode` text DEFAULT 'open' NOT NULL,
|
||||
`password` text(255),
|
||||
`game_name` text NOT NULL,
|
||||
`game_config` text NOT NULL,
|
||||
`status` text DEFAULT 'lobby' NOT NULL,
|
||||
`current_session_id` text,
|
||||
`total_games_played` integer DEFAULT 0 NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- Copy all data
|
||||
INSERT INTO `arcade_rooms_new`
|
||||
SELECT `id`, `code`, `name`, `created_by`, `creator_name`, `created_at`,
|
||||
`last_activity`, `ttl_minutes`, `access_mode`, `password`,
|
||||
`game_name`, `game_config`, `status`, `current_session_id`, `total_games_played`
|
||||
FROM `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE `arcade_rooms_new` RENAME TO `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Recreate index
|
||||
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1760527200000,
|
||||
"tag": "0007_access_modes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1760548800000,
|
||||
"tag": "0008_make_room_name_nullable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
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 +86,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 +122,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 +205,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 +255,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 +265,29 @@ 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 === 'approval-only') {
|
||||
setShowApprovalPrompt(true)
|
||||
return
|
||||
}
|
||||
|
||||
// For restricted rooms, try to join - the API will check for invitation
|
||||
// If user is in a different room, show confirmation
|
||||
if (roomData) {
|
||||
setShowConfirmation(true)
|
||||
} else {
|
||||
// Otherwise, auto-join
|
||||
// Otherwise, auto-join (for open rooms and restricted rooms with invitation)
|
||||
handleJoin(room.id)
|
||||
}
|
||||
})
|
||||
@@ -264,7 +303,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 +316,94 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Socket listener for approval notifications
|
||||
useEffect(() => {
|
||||
if (!approvalRequested || !targetRoomData) return
|
||||
|
||||
console.log('[Join Page] Setting up approval listener for room:', targetRoomData.id)
|
||||
|
||||
let socket: ReturnType<typeof io> | null = null
|
||||
|
||||
// Fetch viewer ID and set up socket
|
||||
const setupSocket = async () => {
|
||||
try {
|
||||
// Get current user's viewer ID
|
||||
const res = await fetch('/api/viewer')
|
||||
if (!res.ok) {
|
||||
console.error('[Join Page] Failed to get viewer ID')
|
||||
return
|
||||
}
|
||||
|
||||
const { viewerId } = await res.json()
|
||||
console.log('[Join Page] Got viewer ID:', viewerId)
|
||||
|
||||
// Connect socket
|
||||
socket = io({ path: '/api/socket' })
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[Join Page] Socket connected, joining user channel')
|
||||
// Join user-specific channel to receive moderation events
|
||||
socket?.emit('join-user-channel', { userId: viewerId })
|
||||
})
|
||||
|
||||
socket.on('join-request-approved', (data: { roomId: string; requestId: string }) => {
|
||||
console.log('[Join Page] Request approved via socket!', data)
|
||||
if (data.roomId === targetRoomData.id) {
|
||||
console.log('[Join Page] Joining room automatically...')
|
||||
handleJoin(targetRoomData.id)
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[Join Page] Socket connection error:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[Join Page] Error setting up socket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setupSocket()
|
||||
|
||||
return () => {
|
||||
console.log('[Join Page] Cleaning up approval listener')
|
||||
socket?.disconnect()
|
||||
}
|
||||
}, [approvalRequested, targetRoomData, handleJoin])
|
||||
|
||||
// Only show error page for non-password and non-approval errors
|
||||
if (error && !showPasswordPrompt && !showApprovalPrompt) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
@@ -316,16 +447,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
|
||||
}
|
||||
|
||||
@@ -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: 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={{
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { io } from 'socket.io-client'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import type { schema } from '@/db'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
|
||||
export interface JoinRoomModalProps {
|
||||
@@ -25,13 +27,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 +62,46 @@ 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 === 'approval-only') {
|
||||
setNeedsApproval(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// For restricted rooms, try to join - the API will check for invitation
|
||||
|
||||
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 +113,93 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Socket listener for approval notifications
|
||||
useEffect(() => {
|
||||
if (!approvalRequested || !roomInfo) return
|
||||
|
||||
console.log('[JoinRoomModal] Setting up approval listener for room:', roomInfo.id)
|
||||
|
||||
let socket: ReturnType<typeof io> | null = null
|
||||
|
||||
// Fetch viewer ID and set up socket
|
||||
const setupSocket = async () => {
|
||||
try {
|
||||
// Get current user's viewer ID
|
||||
const res = await fetch('/api/viewer')
|
||||
if (!res.ok) {
|
||||
console.error('[JoinRoomModal] Failed to get viewer ID')
|
||||
return
|
||||
}
|
||||
|
||||
const { viewerId } = await res.json()
|
||||
console.log('[JoinRoomModal] Got viewer ID:', viewerId)
|
||||
|
||||
// Connect socket
|
||||
socket = io({ path: '/api/socket' })
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('[JoinRoomModal] Socket connected, joining user channel')
|
||||
// Join user-specific channel to receive moderation events
|
||||
socket?.emit('join-user-channel', { userId: viewerId })
|
||||
})
|
||||
|
||||
socket.on('join-request-approved', async (data: { roomId: string; requestId: string }) => {
|
||||
console.log('[JoinRoomModal] Request approved via socket!', data)
|
||||
if (data.roomId === roomInfo.id) {
|
||||
console.log('[JoinRoomModal] Joining room automatically...')
|
||||
try {
|
||||
await joinRoom(roomInfo.id)
|
||||
handleClose()
|
||||
onSuccess?.()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
socket.on('connect_error', (error) => {
|
||||
console.error('[JoinRoomModal] Socket connection error:', error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('[JoinRoomModal] Error setting up socket:', error)
|
||||
}
|
||||
}
|
||||
|
||||
setupSocket()
|
||||
|
||||
return () => {
|
||||
console.log('[JoinRoomModal] Cleaning up approval listener')
|
||||
socket?.disconnect()
|
||||
}
|
||||
}, [approvalRequested, roomInfo, joinRoom, handleClose, onSuccess])
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose}>
|
||||
<div
|
||||
@@ -80,7 +216,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 +225,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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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) : []
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 || {},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
126
apps/web/src/utils/room-display.ts
Normal file
126
apps/web/src/utils/room-display.ts
Normal 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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.1.1",
|
||||
"version": "3.6.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user