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:
530
apps/web/src/components/nav/ModerationNotifications.tsx
Normal file
530
apps/web/src/components/nav/ModerationNotifications.tsx
Normal 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
|
||||
}
|
||||
1337
apps/web/src/components/nav/ModerationPanel.tsx
Normal file
1337
apps/web/src/components/nav/ModerationPanel.tsx
Normal file
File diff suppressed because it is too large
Load Diff
383
apps/web/src/components/nav/ReportPlayerModal.tsx
Normal file
383
apps/web/src/components/nav/ReportPlayerModal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user