feat: integrate moderation system into arcade pages

Update arcade pages to use new moderation features:
- /arcade: Add PendingInvitations and PlayOnlineTab with room management
- /arcade/room: Add ModerationNotifications to all render paths
- RoomInfo: Add moderation panel with focus capability for reported players
- join API: Enhanced with better error handling

Users can now:
- See and respond to invitations from the arcade lobby
- Receive real-time moderation notifications in rooms
- Access full moderation panel as room hosts
- Click on report toasts to view reported players

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Thomas Hallock
2025-10-13 11:24:19 -05:00
parent a2d0169f80
commit 087652f9e7
4 changed files with 642 additions and 257 deletions

View File

@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
@@ -32,6 +33,12 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Room is locked' }, { status: 403 })
}
// Check if user is banned
const banned = await isUserBanned(roomId, viewerId)
if (banned) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`

View File

@@ -2,14 +2,16 @@
import { useEffect, useRef } from 'react'
import { PageWithNav } from '@/components/PageWithNav'
import { useArcadeRedirect } from '@/hooks/useArcadeRedirect'
import { css } from '../../../styled-system/css'
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
import { PendingInvitations } from '@/components/nav/PendingInvitations'
import { useRoomData } from '@/hooks/useRoomData'
function ArcadeContent() {
const { setFullscreenElement } = useFullscreen()
const arcadeRef = useRef<HTMLDivElement>(null)
const { refetch: refetchRoomData } = useRoomData()
useEffect(() => {
// Register this component's main div as the fullscreen element
@@ -48,6 +50,17 @@ function ArcadeContent() {
})}
/>
{/* Pending Invitations */}
<div
className={css({
px: { base: '4', md: '6' },
position: 'relative',
zIndex: 1,
})}
>
<PendingInvitations onInvitationChange={() => refetchRoomData()} />
</div>
{/* Main Champion Arena - takes remaining space */}
<div
className={css({
@@ -78,15 +91,8 @@ function ArcadeContent() {
}
function ArcadePageWithRedirect() {
const { canModifyPlayers } = useArcadeRedirect({ currentGame: null })
return (
<PageWithNav
navTitle="Champion Arena"
navEmoji="🏟️"
emphasizeGameContext={true}
canModifyPlayers={canModifyPlayers}
>
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizeGameContext={true}>
<ArcadeContent />
</PageWithNav>
)

View File

@@ -1,6 +1,7 @@
'use client'
import { useRoomData } from '@/hooks/useRoomData'
import { ModerationNotifications } from '@/components/nav/ModerationNotifications'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
@@ -8,74 +9,16 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
* /arcade/room - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Note: We don't redirect to /arcade if no room exists because:
* - It would conflict with arcade session redirects and create loops
* - useArcadeRedirect on /arcade page handles redirecting to active sessions
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
* Instead, we show a friendly message with a link back to the Champion Arena.
*/
export default function RoomPage() {
const { roomData, isLoading } = useRoomData()
const { roomData, isLoading, moderationEvent, clearModerationEvent } = useRoomData()
// Show loading state
if (isLoading) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
)
}
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}
// Render the appropriate game based on room's gameName
// Note: We don't use ArcadeGuardedPage here because room-based games
// have their own navigation logic via useRoomData
switch (roomData.gameName) {
case 'matching':
return (
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)
default:
return (
<>
<div
style={{
display: 'flex',
@@ -86,8 +29,81 @@ export default function RoomPage() {
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
Loading room...
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
)
}
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
)
}
// Render the appropriate game based on room's gameName
switch (roomData.gameName) {
case 'matching':
return (
<>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)
default:
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
)
}
}

View File

@@ -1,5 +1,12 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { CreateRoomModal } from './CreateRoomModal'
import { JoinRoomModal } from './JoinRoomModal'
import { ModerationPanel } from './ModerationPanel'
import { RoomShareButtons } from './RoomShareButtons'
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
@@ -8,6 +15,7 @@ interface RoomInfoProps {
gameName: string
playerCount: number
joinCode?: string
roomId?: string
shouldEmphasize: boolean
gameMode: GameMode
modeColor: string
@@ -18,6 +26,7 @@ interface RoomInfoProps {
onSetup?: () => void
onNewGame?: () => void
onQuit?: () => void
onOpenModerationWithFocus?: (userId: string) => void
}
/**
@@ -28,6 +37,7 @@ export function RoomInfo({
gameName,
playerCount,
joinCode,
roomId,
shouldEmphasize,
gameMode,
modeColor,
@@ -38,35 +48,102 @@ export function RoomInfo({
onSetup,
onNewGame,
onQuit,
onOpenModerationWithFocus,
}: RoomInfoProps) {
const [copied, setCopied] = useState(false)
const router = useRouter()
const [open, setOpen] = useState(false)
const handleCodeClick = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!joinCode) return
navigator.clipboard.writeText(joinCode)
setCopied(true)
setTimeout(() => {
setCopied(false)
}, 1500)
}
const [showJoinModal, setShowJoinModal] = useState(false)
const [showCreateModal, setShowCreateModal] = useState(false)
const [showModerationPanel, setShowModerationPanel] = useState(false)
const [focusedUserId, setFocusedUserId] = useState<string | undefined>(undefined)
const [pendingReportsCount, setPendingReportsCount] = useState(0)
const { getRoomShareUrl, roomData } = useRoomData()
const { data: currentUserId } = useViewerId()
const { mutateAsync: leaveRoom } = useLeaveRoom()
const displayName = roomName || gameName
const shareUrl = joinCode ? getRoomShareUrl(joinCode) : ''
// Determine ownership status
const currentMember = roomData?.members.find((m) => m.userId === currentUserId)
const isCurrentUserCreator = currentMember?.isCreator ?? false
const creatorMember = roomData?.members.find((m) => m.isCreator)
const creatorName = creatorMember?.displayName
// Fetch pending reports count if user is host
useEffect(() => {
if (!isCurrentUserCreator || !roomId) return
const fetchPendingReports = async () => {
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/reports`)
if (res.ok) {
const data = await res.json()
const pending = data.reports?.filter((r: any) => r.status === 'pending') || []
setPendingReportsCount(pending.length)
}
} catch (error) {
console.error('[RoomInfo] Failed to fetch reports:', error)
}
}
fetchPendingReports()
// Poll every 30 seconds
const interval = setInterval(fetchPendingReports, 30000)
return () => clearInterval(interval)
}, [isCurrentUserCreator, roomId])
// Listen for moderation events to update report count in real-time
const { moderationEvent } = useRoomData()
useEffect(() => {
if (moderationEvent?.type === 'report' && isCurrentUserCreator) {
// Increment count immediately when new report comes in
setPendingReportsCount((prev) => prev + 1)
}
}, [moderationEvent, isCurrentUserCreator])
// Expose a way to open moderation panel with focused user
const handleOpenModerationWithFocus = (userId: string) => {
setFocusedUserId(userId)
setShowModerationPanel(true)
}
// Call the callback prop if provided (so parent can trigger this)
useEffect(() => {
if (onOpenModerationWithFocus) {
// Store reference so parent can call it
;(window as any).__openModerationWithFocus = handleOpenModerationWithFocus
}
return () => {
delete (window as any).__openModerationWithFocus
}
}, [onOpenModerationWithFocus])
const handleLeaveRoom = async () => {
if (!roomId) return
try {
await leaveRoom(roomId)
// Navigate to arcade lobby after leaving room
router.push('/arcade')
} catch (error) {
console.error('[RoomInfo] Failed to leave room:', error)
}
}
return (
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger asChild>
<button
type="button"
style={{
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
<>
<DropdownMenu.Root open={open} onOpenChange={setOpen}>
<DropdownMenu.Trigger asChild>
<button
type="button"
style={{
background: 'transparent',
border: 'none',
padding: 0,
cursor: 'pointer',
}}
>
<div
style={{
display: 'inline-flex',
@@ -134,180 +211,439 @@ export function RoomInfo({
>
{displayName}
</div>
{/* Host indicator badge */}
{isCurrentUserCreator ? (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '2px 6px',
background:
'linear-gradient(135deg, rgba(251, 191, 36, 0.3), rgba(245, 158, 11, 0.2))',
border: '1.5px solid rgba(251, 191, 36, 0.6)',
borderRadius: '4px',
fontSize: '9px',
fontWeight: '700',
color: 'rgba(146, 64, 14, 1)',
lineHeight: 1,
marginTop: '2px',
position: 'relative',
}}
title="You're the host"
>
<span style={{ fontSize: '10px', lineHeight: 1 }}>👑</span>
<span style={{ lineHeight: 1 }}>You are host</span>
{/* Pending reports badge */}
{pendingReportsCount > 0 && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: '16px',
height: '16px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 1)',
color: 'white',
fontSize: '8px',
fontWeight: '700',
marginLeft: '2px',
}}
title={`${pendingReportsCount} pending report${pendingReportsCount > 1 ? 's' : ''}`}
>
{pendingReportsCount}
</span>
)}
</div>
) : (
creatorName && (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '3px',
padding: '2px 6px',
background: 'rgba(75, 85, 99, 0.15)',
border: '1px solid rgba(75, 85, 99, 0.3)',
borderRadius: '4px',
fontSize: '8px',
fontWeight: '600',
color: 'rgba(75, 85, 99, 0.8)',
lineHeight: 1,
marginTop: '2px',
}}
title={`Host: ${creatorName}`}
>
<span style={{ fontSize: '9px', lineHeight: 1 }}>👑</span>
<span style={{ lineHeight: 1 }}>Host: {creatorName}</span>
</div>
)
)}
</div>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
side="bottom"
align="start"
sideOffset={8}
style={{
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.97), rgba(31, 41, 55, 0.97))',
backdropFilter: 'blur(12px)',
borderRadius: '12px',
padding: '8px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(139, 92, 246, 0.3)',
minWidth: '200px',
zIndex: 9999,
animation: 'dropdownFadeIn 0.2s ease-out',
}}
>
{/* Join code section */}
{joinCode && (
<>
<div
<DropdownMenu.Portal>
<DropdownMenu.Content
side="bottom"
align="start"
sideOffset={8}
style={{
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.97), rgba(31, 41, 55, 0.97))',
backdropFilter: 'blur(12px)',
borderRadius: '12px',
padding: '8px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(139, 92, 246, 0.3)',
minWidth: '200px',
zIndex: 9999,
animation: 'dropdownFadeIn 0.2s ease-out',
}}
>
{/* Game menu items */}
{onSetup && (
<DropdownMenu.Item
onSelect={onSetup}
style={{
fontSize: '10px',
fontWeight: '600',
color: 'rgba(196, 181, 253, 0.7)',
marginBottom: '6px',
marginLeft: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
Room Join Code
</div>
<button
type="button"
onClick={handleCodeClick}
style={{
width: '100%',
background: copied
? 'linear-gradient(135deg, rgba(34, 197, 94, 0.2), rgba(34, 197, 94, 0.3))'
: 'linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(139, 92, 246, 0.3))',
border: copied
? '2px solid rgba(34, 197, 94, 0.5)'
: '2px solid rgba(139, 92, 246, 0.4)',
borderRadius: '8px',
padding: '10px 16px',
fontFamily: 'monospace',
fontSize: '16px',
fontWeight: 'bold',
color: copied ? 'rgba(134, 239, 172, 1)' : 'rgba(196, 181, 253, 1)',
cursor: 'pointer',
transition: 'all 0.2s ease',
letterSpacing: '2px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '8px',
marginBottom: '6px',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!copied) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(139, 92, 246, 0.3), rgba(139, 92, 246, 0.4))'
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.6)'
}
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.2)'
e.currentTarget.style.color = 'rgba(196, 181, 253, 1)'
}}
onMouseLeave={(e) => {
if (!copied) {
e.currentTarget.style.background = 'linear-gradient(135deg, rgba(139, 92, 246, 0.2), rgba(139, 92, 246, 0.3))'
e.currentTarget.style.borderColor = 'rgba(139, 92, 246, 0.4)'
}
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
{copied ? (
<>
<span style={{ fontSize: '14px' }}></span>
<span>Copied!</span>
</>
) : (
<>
<span>{joinCode}</span>
<span style={{ fontSize: '12px', opacity: 0.7 }}>📋</span>
</>
)}
</button>
<span style={{ fontSize: '16px' }}></span>
<span>Setup</span>
</DropdownMenu.Item>
)}
{onNewGame && (
<DropdownMenu.Item
onSelect={onNewGame}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
e.currentTarget.style.color = 'rgba(147, 197, 253, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🎮</span>
<span>New Game</span>
</DropdownMenu.Item>
)}
{/* Moderation - only show for host */}
{isCurrentUserCreator && roomId && (
<DropdownMenu.Item
onSelect={() => {
setOpen(false)
setShowModerationPanel(true)
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: pendingReportsCount > 0 ? 'rgba(239, 68, 68, 0.15)' : 'transparent',
color:
pendingReportsCount > 0 ? 'rgba(252, 165, 165, 1)' : 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: pendingReportsCount > 0 ? '600' : '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
position: 'relative',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
pendingReportsCount > 0 ? 'rgba(239, 68, 68, 0.25)' : 'rgba(251, 146, 60, 0.2)'
e.currentTarget.style.color = 'rgba(253, 186, 116, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background =
pendingReportsCount > 0 ? 'rgba(239, 68, 68, 0.15)' : 'transparent'
e.currentTarget.style.color =
pendingReportsCount > 0 ? 'rgba(252, 165, 165, 1)' : 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🛡</span>
<span>Moderation</span>
{pendingReportsCount > 0 && (
<span
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '18px',
height: '18px',
padding: '0 5px',
borderRadius: '9px',
background: 'rgba(239, 68, 68, 1)',
color: 'white',
fontSize: '11px',
fontWeight: '700',
marginLeft: 'auto',
}}
>
{pendingReportsCount}
</span>
)}
</DropdownMenu.Item>
)}
{/* Room Navigation Submenu */}
{(onSetup || onNewGame || onQuit || isCurrentUserCreator) && (
<DropdownMenu.Separator
style={{
height: '1px',
background: 'rgba(75, 85, 99, 0.5)',
margin: '6px 0',
margin: '4px 0',
}}
/>
</>
)}
)}
{/* Game menu items */}
{onSetup && (
<DropdownMenu.Item
onSelect={onSetup}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.2)'
e.currentTarget.style.color = 'rgba(196, 181, 253, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}></span>
<span>Setup</span>
</DropdownMenu.Item>
)}
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'rgba(139, 92, 246, 0.05)',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.15)'
e.currentTarget.style.color = 'rgba(196, 181, 253, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(139, 92, 246, 0.05)'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<span style={{ fontSize: '16px' }}>🏠</span>
<span>Room: {displayName}</span>
</div>
<span style={{ fontSize: '10px', opacity: 0.7 }}></span>
</DropdownMenu.SubTrigger>
{onNewGame && (
<DropdownMenu.Item
onSelect={onNewGame}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
e.currentTarget.style.color = 'rgba(147, 197, 253, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🎮</span>
<span>New Game</span>
</DropdownMenu.Item>
)}
{onQuit && (
<>
{(onSetup || onNewGame) && (
<DropdownMenu.Separator
<DropdownMenu.Portal>
<DropdownMenu.SubContent
sideOffset={8}
alignOffset={-8}
style={{
height: '1px',
background: 'rgba(75, 85, 99, 0.5)',
margin: '4px 0',
background:
'linear-gradient(135deg, rgba(17, 24, 39, 0.97), rgba(31, 41, 55, 0.97))',
backdropFilter: 'blur(12px)',
borderRadius: '12px',
padding: '8px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(139, 92, 246, 0.3)',
minWidth: '220px',
zIndex: 10000,
animation: 'dropdownFadeIn 0.2s ease-out',
}}
/>
)}
>
{/* Current Room Section */}
{joinCode && roomId && (
<>
<div
style={{
fontSize: '10px',
fontWeight: '700',
color: 'rgba(139, 92, 246, 0.7)',
marginBottom: '8px',
marginLeft: '12px',
textTransform: 'uppercase',
letterSpacing: '1px',
}}
>
Current Room
</div>
<RoomShareButtons joinCode={joinCode} shareUrl={shareUrl} />
<DropdownMenu.Separator
style={{
height: '1px',
background: 'rgba(75, 85, 99, 0.5)',
margin: '8px 0',
}}
/>
</>
)}
{/* Switch Rooms Section */}
<div
style={{
fontSize: '10px',
fontWeight: '700',
color: 'rgba(139, 92, 246, 0.7)',
marginBottom: '8px',
marginLeft: '12px',
textTransform: 'uppercase',
letterSpacing: '1px',
}}
>
Switch Rooms
</div>
<DropdownMenu.Item
onSelect={() => {
setOpen(false)
setShowCreateModal(true)
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.2)'
e.currentTarget.style.color = 'rgba(134, 239, 172, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🆕</span>
<span>Create New</span>
</DropdownMenu.Item>
<DropdownMenu.Item
onSelect={() => {
setOpen(false)
setShowJoinModal(true)
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
e.currentTarget.style.color = 'rgba(147, 197, 253, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🚪</span>
<span>Join Another</span>
</DropdownMenu.Item>
{/* Leave Room - only show when in a room */}
{roomId && (
<>
<DropdownMenu.Separator
style={{
height: '1px',
background: 'rgba(75, 85, 99, 0.5)',
margin: '8px 0',
}}
/>
<DropdownMenu.Item
onSelect={handleLeaveRoom}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(239, 68, 68, 0.2)'
e.currentTarget.style.color = 'rgba(252, 165, 165, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🚫</span>
<span>Leave This Room</span>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
{onQuit && (
<DropdownMenu.Item
onSelect={onQuit}
style={{
@@ -335,16 +671,15 @@ export function RoomInfo({
}}
>
<span style={{ fontSize: '16px' }}>🏟</span>
<span>Quit to Arcade</span>
<span>Room Lobby</span>
</DropdownMenu.Item>
</>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
<style
dangerouslySetInnerHTML={{
__html: `
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes dropdownFadeIn {
from {
opacity: 0;
@@ -356,8 +691,29 @@ export function RoomInfo({
}
}
`,
}}
/>
</DropdownMenu.Root>
}}
/>
</DropdownMenu.Root>
{/* Modals */}
<JoinRoomModal isOpen={showJoinModal} onClose={() => setShowJoinModal(false)} />
<CreateRoomModal isOpen={showCreateModal} onClose={() => setShowCreateModal(false)} />
{/* Moderation Panel - only render if host */}
{isCurrentUserCreator && roomId && roomData && currentUserId && (
<ModerationPanel
isOpen={showModerationPanel}
onClose={() => {
setShowModerationPanel(false)
setFocusedUserId(undefined)
}}
roomId={roomId}
members={roomData.members}
memberPlayers={roomData.memberPlayers}
currentUserId={currentUserId}
focusedUserId={focusedUserId}
/>
)}
</>
)
}