feat: add moderation panel with unban & invite feature

Add comprehensive moderation UI:
- ModerationPanel: Full-featured moderation interface for room hosts
  - Members tab: View active members, reports, kick/ban actions
  - Bans tab: View and manage banned users with inline unban confirmation
  - History tab: View all historical members with status tracking
  - Real-time updates when members join/leave
  - Unban & Invite feature: Banned users can be unbanned and auto-invited
    from the History tab with inline confirmation
- ModerationNotifications: Toast/modal notifications for:
  - Kicked from room
  - Banned from room
  - Player reported (clickable to open moderation panel)
  - Room invitation received (with accept & join)
- ReportPlayerModal: Form for reporting player misconduct

Key features:
- Real-time member status updates across all tabs
- Focus/highlight system for reported players
- Auto-invite on unban workflow
- Inline confirmations for destructive actions
- Accessible UI with Radix primitives

🤖 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:02 -05:00
parent fd3a2d1f76
commit a2d0169f80
3 changed files with 2250 additions and 0 deletions

View File

@@ -0,0 +1,530 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import * as Toast from '@radix-ui/react-toast'
import { Modal } from '@/components/common/Modal'
import type { ModerationEvent } from '@/hooks/useRoomData'
import { useJoinRoom } from '@/hooks/useRoomData'
export interface ModerationNotificationsProps {
/**
* The moderation event to display
*/
moderationEvent: ModerationEvent | null
/**
* Callback when the user acknowledges the event
*/
onClose: () => void
}
/**
* Displays moderation notifications (kicked, banned, report submitted)
*/
export function ModerationNotifications({
moderationEvent,
onClose,
}: ModerationNotificationsProps) {
const router = useRouter()
const [showToast, setShowToast] = useState(false)
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
const { mutateAsync: joinRoom } = useJoinRoom()
// Handle report toast (for hosts)
useEffect(() => {
if (moderationEvent?.type === 'report') {
setShowToast(true)
// Auto-dismiss after 5 seconds
const timer = setTimeout(() => {
setShowToast(false)
onClose()
}, 5000)
return () => clearTimeout(timer)
}
}, [moderationEvent, onClose])
// Kicked modal
if (moderationEvent?.type === 'kicked') {
return (
<Modal isOpen={true} onClose={() => {}}>
<div
style={{
border: '2px solid rgba(251, 146, 60, 0.3)',
borderRadius: '16px',
textAlign: 'center',
minWidth: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: 'rgba(253, 186, 116, 1)',
}}
>
Kicked from Room
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.9)',
marginBottom: '8px',
}}
>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
</p>
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
marginBottom: '24px',
}}
>
You can rejoin if the host sends you a new invite
</p>
<button
type="button"
onClick={() => {
onClose()
router.push('/arcade')
}}
style={{
width: '100%',
padding: '12px',
background:
'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))',
color: 'white',
border: '2px solid rgba(251, 146, 60, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(251, 146, 60, 0.9), rgba(249, 115, 22, 0.9))'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))'
}}
>
Return to Arcade
</button>
</div>
</Modal>
)
}
// Banned modal
if (moderationEvent?.type === 'banned') {
const reasonLabel = moderationEvent.data.reason?.replace(/-/g, ' ') || 'unspecified'
return (
<Modal isOpen={true} onClose={() => {}}>
<div
style={{
border: '2px solid rgba(239, 68, 68, 0.3)',
borderRadius: '16px',
textAlign: 'center',
minWidth: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>🚫</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: 'rgba(252, 165, 165, 1)',
}}
>
Banned from Room
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.9)',
marginBottom: '8px',
}}
>
You were banned from the room by{' '}
<strong style={{ color: 'rgba(252, 165, 165, 1)' }}>
{moderationEvent.data.bannedBy}
</strong>
</p>
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
marginBottom: '4px',
}}
>
Reason: <strong>{reasonLabel}</strong>
</p>
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
marginBottom: '24px',
}}
>
You cannot rejoin this room
</p>
<button
type="button"
onClick={() => {
onClose()
router.push('/arcade')
}}
style={{
width: '100%',
padding: '12px',
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.8), rgba(220, 38, 38, 0.8))',
color: 'white',
border: '2px solid rgba(239, 68, 68, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(239, 68, 68, 0.9), rgba(220, 38, 38, 0.9))'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(239, 68, 68, 0.8), rgba(220, 38, 38, 0.8))'
}}
>
Return to Arcade
</button>
</div>
</Modal>
)
}
// Report toast (for hosts)
if (moderationEvent?.type === 'report') {
return (
<Toast.Provider swipeDirection="right" duration={5000}>
<Toast.Root
open={showToast}
onOpenChange={(open) => {
if (!open) {
setShowToast(false)
onClose()
}
}}
onClick={() => {
// Open moderation panel focused on reported player
const reportedUserId = moderationEvent.data.reportedUserId
if (reportedUserId && (window as any).__openModerationWithFocus) {
;(window as any).__openModerationWithFocus(reportedUserId)
setShowToast(false)
onClose()
}
}}
style={{
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.97), rgba(220, 38, 38, 0.97))',
border: '2px solid rgba(239, 68, 68, 0.6)',
borderRadius: '12px',
padding: '16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
minWidth: '350px',
maxWidth: '450px',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.02)'
e.currentTarget.style.boxShadow = '0 12px 32px rgba(0, 0, 0, 0.5)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = '0 8px 24px rgba(0, 0, 0, 0.4)'
}}
>
<div style={{ fontSize: '24px', flexShrink: 0 }}>🚩</div>
<div style={{ flex: 1 }}>
<Toast.Title
style={{
fontSize: '15px',
fontWeight: 'bold',
color: 'white',
marginBottom: '4px',
}}
>
New Player Report
</Toast.Title>
<Toast.Description
style={{
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
marginBottom: '4px',
}}
>
<strong>{moderationEvent.data.reporterName}</strong> reported{' '}
<strong>{moderationEvent.data.reportedUserName}</strong>
</Toast.Description>
<Toast.Description
style={{
fontSize: '12px',
color: 'rgba(255, 255, 255, 0.8)',
}}
>
Reason: {moderationEvent.data.reason?.replace(/-/g, ' ')}
</Toast.Description>
<Toast.Description
style={{
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.7)',
marginTop: '6px',
fontStyle: 'italic',
}}
>
👆 Click to view in moderation panel
</Toast.Description>
</div>
<Toast.Close
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
fontSize: '16px',
lineHeight: 1,
flexShrink: 0,
}}
>
×
</Toast.Close>
</Toast.Root>
<Toast.Viewport
style={{
position: 'fixed',
top: '80px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
zIndex: 10001,
maxWidth: '100vw',
margin: 0,
listStyle: 'none',
outline: 'none',
}}
/>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes slideIn {
from {
transform: translateX(calc(100% + 25px));
}
to {
transform: translateX(0);
}
}
@keyframes slideOut {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(100% + 25px));
}
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-state='closed'] {
animation: hide 100ms ease-in, slideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
}
[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
[data-swipe='end'] {
animation: slideOut 100ms ease-out;
}
`,
}}
/>
</Toast.Provider>
)
}
// Invitation modal
if (moderationEvent?.type === 'invitation') {
const invitationType = moderationEvent.data.invitationType
const isAutoUnban = invitationType === 'auto-unban'
return (
<Modal isOpen={true} onClose={() => {}}>
<div
style={{
border: '2px solid rgba(59, 130, 246, 0.3)',
borderRadius: '16px',
textAlign: 'center',
minWidth: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isAutoUnban ? '🎉' : '✉️'}</div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '12px',
color: 'rgba(147, 197, 253, 1)',
}}
>
{isAutoUnban ? 'You have been unbanned!' : 'Room Invitation'}
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.9)',
marginBottom: '8px',
}}
>
<strong style={{ color: 'rgba(147, 197, 253, 1)' }}>
{moderationEvent.data.invitedByName}
</strong>{' '}
has invited you to rejoin the room
</p>
{moderationEvent.data.message && (
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
marginBottom: '24px',
fontStyle: 'italic',
}}
>
"{moderationEvent.data.message}"
</p>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={() => {
onClose()
}}
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: '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)'
}}
>
Decline
</button>
<button
type="button"
disabled={isAcceptingInvitation}
onClick={async () => {
const roomId = moderationEvent.data.roomId
if (!roomId) return
setIsAcceptingInvitation(true)
try {
// Join the room
await joinRoom({ roomId })
// Close the modal
onClose()
// Navigate to the room
router.push('/arcade/room')
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
setIsAcceptingInvitation(false)
}
}}
style={{
flex: 1,
padding: '12px',
background: isAcceptingInvitation
? 'rgba(75, 85, 99, 0.5)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isAcceptingInvitation ? 'not-allowed' : 'pointer',
transition: 'all 0.2s ease',
opacity: isAcceptingInvitation ? 0.6 : 1,
}}
onMouseEnter={(e) => {
if (!isAcceptingInvitation) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isAcceptingInvitation) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isAcceptingInvitation ? 'Joining...' : 'Accept & Join'}
</button>
</div>
</div>
</Modal>
)
}
return null
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,383 @@
import { useState } from 'react'
import { Modal } from '@/components/common/Modal'
export interface ReportPlayerModalProps {
/**
* Whether the modal is open
*/
isOpen: boolean
/**
* Callback when modal should close
*/
onClose: () => void
/**
* The room ID
*/
roomId: string
/**
* The user being reported
*/
reportedUser: {
id: string
name: string
}
/**
* Optional callback when report is successfully submitted
*/
onSuccess?: () => void
}
const REPORT_REASONS = [
{ value: 'harassment', label: 'Harassment or bullying' },
{ value: 'cheating', label: 'Cheating or exploiting' },
{ value: 'inappropriate-name', label: 'Inappropriate name' },
{ value: 'spam', label: 'Spamming' },
{ value: 'afk', label: 'Away from keyboard (AFK)' },
{ value: 'other', label: 'Other' },
] as const
/**
* Modal for reporting a player to the host
*/
export function ReportPlayerModal({
isOpen,
onClose,
roomId,
reportedUser,
onSuccess,
}: ReportPlayerModalProps) {
const [reason, setReason] = useState<string>('')
const [details, setDetails] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
const handleClose = () => {
setReason('')
setDetails('')
setError('')
setIsLoading(false)
setIsSuccess(false)
onClose()
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!reason) {
setError('Please select a reason')
return
}
setIsLoading(true)
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/report`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reportedUserId: reportedUser.id,
reason,
details: details.trim() || undefined,
}),
})
if (!response.ok) {
const data = await response.json().catch(() => ({}))
throw new Error(data.error || 'Failed to submit report')
}
// Success!
setIsSuccess(true)
onSuccess?.()
// Auto-close after 2 seconds
setTimeout(() => {
handleClose()
}, 2000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to submit report')
} finally {
setIsLoading(false)
}
}
if (isSuccess) {
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<div
style={{
border: '2px solid rgba(34, 197, 94, 0.3)',
borderRadius: '16px',
textAlign: 'center',
padding: '40px 24px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(134, 239, 172, 1)',
}}
>
Report Submitted
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.8)',
}}
>
The host has been notified
</p>
</div>
</Modal>
)
}
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<div
style={{
border: '2px solid rgba(239, 68, 68, 0.3)',
borderRadius: '16px',
}}
>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(252, 165, 165, 1)',
}}
>
Report Player
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '24px',
}}
>
Report <strong>{reportedUser.name}</strong> to the host
</p>
<form onSubmit={handleSubmit}>
{/* Reason selector */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
fontSize: '13px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 0.9)',
marginBottom: '8px',
}}
>
Reason *
</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{REPORT_REASONS.map((option) => (
<label
key={option.value}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 12px',
background:
reason === option.value
? 'rgba(239, 68, 68, 0.2)'
: 'rgba(255, 255, 255, 0.05)',
border:
reason === option.value
? '2px solid rgba(239, 68, 68, 0.5)'
: '2px solid rgba(75, 85, 99, 0.3)',
borderRadius: '8px',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (reason !== option.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}
}}
onMouseLeave={(e) => {
if (reason !== option.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.3)'
}
}}
>
<input
type="radio"
name="reason"
value={option.value}
checked={reason === option.value}
onChange={(e) => {
setReason(e.target.value)
setError('')
}}
disabled={isLoading}
style={{
width: '18px',
height: '18px',
accentColor: '#ef4444',
cursor: 'pointer',
}}
/>
<span
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.9)',
fontWeight: reason === option.value ? '600' : '500',
}}
>
{option.label}
</span>
</label>
))}
</div>
</div>
{/* Optional details */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
fontSize: '13px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 0.9)',
marginBottom: '8px',
}}
>
Additional details (optional)
</label>
<textarea
value={details}
onChange={(e) => setDetails(e.target.value)}
placeholder="Provide any additional context..."
maxLength={500}
rows={3}
disabled={isLoading}
style={{
width: '100%',
padding: '12px',
border: '2px solid rgba(75, 85, 99, 0.4)',
borderRadius: '10px',
fontSize: '14px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(209, 213, 219, 1)',
outline: 'none',
resize: 'vertical',
fontFamily: 'inherit',
}}
/>
<div
style={{
fontSize: '12px',
color: 'rgba(156, 163, 175, 1)',
textAlign: 'right',
marginTop: '4px',
}}
>
{details.length}/500
</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', 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={!reason || isLoading}
style={{
flex: 1,
padding: '12px',
background:
reason && !isLoading
? 'linear-gradient(135deg, rgba(239, 68, 68, 0.8), rgba(220, 38, 38, 0.8))'
: 'rgba(75, 85, 99, 0.3)',
color: reason && !isLoading ? 'rgba(255, 255, 255, 1)' : 'rgba(156, 163, 175, 1)',
border:
reason && !isLoading
? '2px solid rgba(239, 68, 68, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: reason && !isLoading ? 'pointer' : 'not-allowed',
opacity: reason && !isLoading ? 1 : 0.5,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (reason && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(239, 68, 68, 0.9), rgba(220, 38, 38, 0.9))'
}
}}
onMouseLeave={(e) => {
if (reason && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(239, 68, 68, 0.8), rgba(220, 38, 38, 0.8))'
}
}}
>
{isLoading ? 'Submitting...' : 'Submit Report'}
</button>
</div>
</form>
</div>
</Modal>
)
}