Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9e7267f15 | ||
|
|
57bf8460c8 | ||
|
|
059a9fe750 | ||
|
|
036da6de66 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,3 +1,17 @@
|
||||
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve join request approval error handling with actionable messages ([57bf846](https://github.com/antialias/soroban-abacus-flashcards/commit/57bf8460c8ecff374355bfb93f4b06dfbb148273))
|
||||
|
||||
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add prominent join request approval notifications for room moderators ([036da6d](https://github.com/antialias/soroban-abacus-flashcards/commit/036da6de66ca7d3f459c55df657b04a9e88d9cd3))
|
||||
|
||||
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createJoinRequest, getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
@@ -87,6 +88,29 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
|
||||
)
|
||||
|
||||
// Broadcast to all members in the room (particularly the host) via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
io.to(`room:${roomId}`).emit('join-request-submitted', {
|
||||
roomId,
|
||||
request: {
|
||||
id: request.id,
|
||||
userId: request.userId,
|
||||
userName: request.userName,
|
||||
createdAt: request.requestedAt,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Join Requests] Broadcasted join-request-submitted for user ${viewerId} to room ${roomId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error('[Join Requests] Failed to broadcast join-request-submitted:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ request }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create join request:', error)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as Toast from '@radix-ui/react-toast'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import type { ModerationEvent } from '@/hooks/useRoomData'
|
||||
import { useJoinRoom } from '@/hooks/useRoomData'
|
||||
@@ -25,8 +26,12 @@ export function ModerationNotifications({
|
||||
onClose,
|
||||
}: ModerationNotificationsProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
|
||||
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
|
||||
const [isProcessingRequest, setIsProcessingRequest] = useState(false)
|
||||
const [requestError, setRequestError] = useState<string | null>(null)
|
||||
const { mutateAsync: joinRoom } = useJoinRoom()
|
||||
|
||||
// Handle report toast (for hosts)
|
||||
@@ -42,6 +47,86 @@ export function ModerationNotifications({
|
||||
}
|
||||
}, [moderationEvent, onClose])
|
||||
|
||||
// Handle join request toast (for hosts)
|
||||
useEffect(() => {
|
||||
if (moderationEvent?.type === 'join-request') {
|
||||
setShowJoinRequestToast(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
}
|
||||
}, [moderationEvent])
|
||||
|
||||
// Handle approve join request
|
||||
const handleApprove = async () => {
|
||||
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
|
||||
|
||||
setIsProcessingRequest(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to approve join request')
|
||||
}
|
||||
|
||||
// Close toast and event on success
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
|
||||
// Invalidate join requests query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
|
||||
} catch (error) {
|
||||
console.error('Failed to approve join request:', error)
|
||||
// Keep toast visible and show error message
|
||||
setRequestError(error instanceof Error ? error.message : 'Failed to approve request')
|
||||
} finally {
|
||||
setIsProcessingRequest(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deny join request
|
||||
const handleDeny = async () => {
|
||||
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
|
||||
|
||||
setIsProcessingRequest(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to deny join request')
|
||||
}
|
||||
|
||||
// Close toast and event on success
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
|
||||
// Invalidate join requests query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
|
||||
} catch (error) {
|
||||
console.error('Failed to deny join request:', error)
|
||||
// Keep toast visible and show error message
|
||||
setRequestError(error instanceof Error ? error.message : 'Failed to deny request')
|
||||
} finally {
|
||||
setIsProcessingRequest(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Kicked modal
|
||||
if (moderationEvent?.type === 'kicked') {
|
||||
return (
|
||||
@@ -391,6 +476,255 @@ export function ModerationNotifications({
|
||||
)
|
||||
}
|
||||
|
||||
// Join request toast (for hosts)
|
||||
if (moderationEvent?.type === 'join-request') {
|
||||
return (
|
||||
<Toast.Provider swipeDirection="right" duration={Infinity}>
|
||||
<Toast.Root
|
||||
open={showJoinRequestToast}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
|
||||
border: '2px solid rgba(59, 130, 246, 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',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', flexShrink: 0 }}>✋</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Toast.Title
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Join Request
|
||||
</Toast.Title>
|
||||
<Toast.Description
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>{moderationEvent.data.requesterName}</strong> wants to join your room
|
||||
</Toast.Description>
|
||||
|
||||
{/* Error message */}
|
||||
{requestError && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: 'rgba(239, 68, 68, 0.2)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(254, 202, 202, 1)',
|
||||
fontWeight: '600',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
⚠️ Error
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
{requestError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isProcessingRequest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeny()
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: isProcessingRequest
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isProcessingRequest ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isProcessingRequest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleApprove()
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: isProcessingRequest
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'rgba(34, 197, 94, 0.9)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(34, 197, 94, 0.8)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isProcessingRequest ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 1)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isProcessingRequest ? 'Processing...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useViewerId } from './useViewerId'
|
||||
|
||||
@@ -190,7 +190,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
|
||||
}
|
||||
|
||||
export interface ModerationEvent {
|
||||
type: 'kicked' | 'banned' | 'report' | 'invitation'
|
||||
type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request'
|
||||
data: {
|
||||
roomId?: string
|
||||
kickedBy?: string
|
||||
@@ -206,6 +206,10 @@ export interface ModerationEvent {
|
||||
invitedByName?: string
|
||||
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
|
||||
message?: string
|
||||
// Join request fields
|
||||
requestId?: string
|
||||
requesterId?: string
|
||||
requesterName?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +424,27 @@ export function useRoomData() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleJoinRequestSubmitted = (data: {
|
||||
roomId: string
|
||||
request: {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] New join request submitted:', data)
|
||||
setModerationEvent({
|
||||
type: 'join-request',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
requestId: data.request.id,
|
||||
requesterId: data.request.userId,
|
||||
requesterName: data.request.userName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
@@ -428,6 +453,7 @@ export function useRoomData() {
|
||||
socket.on('banned-from-room', handleBannedFromRoom)
|
||||
socket.on('report-submitted', handleReportSubmitted)
|
||||
socket.on('room-invitation-received', handleInvitationReceived)
|
||||
socket.on('join-request-submitted', handleJoinRequestSubmitted)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
@@ -438,6 +464,7 @@ export function useRoomData() {
|
||||
socket.off('banned-from-room', handleBannedFromRoom)
|
||||
socket.off('report-submitted', handleReportSubmitted)
|
||||
socket.off('room-invitation-received', handleInvitationReceived)
|
||||
socket.off('join-request-submitted', handleJoinRequestSubmitted)
|
||||
}
|
||||
}, [socket, roomData?.id, queryClient])
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.6.3",
|
||||
"version": "3.7.1",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user