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:
@@ -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)}`
|
||||
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user