Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4251e660d | ||
|
|
040d7495a0 | ||
|
|
87ef35682e | ||
|
|
2fb6ead4f2 | ||
|
|
bc571e3d0d | ||
|
|
eed7c9b938 | ||
|
|
654ba19ccc | ||
|
|
f5469cda0c | ||
|
|
86e3d41996 |
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,3 +1,36 @@
|
||||
## [3.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.2...v3.13.3) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **migrations:** add migration 0009 for display_password column ([040d749](https://github.com/antialias/soroban-abacus-flashcards/commit/040d7495a0801076b252d2574023f5323540db1a))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* replace browser alert() calls with toast notifications ([87ef356](https://github.com/antialias/soroban-abacus-flashcards/commit/87ef35682e5c129033f21b91987fc84a45f43ad3))
|
||||
|
||||
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** only notify room creator of join requests ([bc571e3](https://github.com/antialias/soroban-abacus-flashcards/commit/bc571e3d0d11fe4142680132d551e25ca626d950))
|
||||
|
||||
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](https://github.com/antialias/soroban-abacus-flashcards/commit/654ba19ccca595d34ad205c036c18afb99a494c7))
|
||||
|
||||
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **moderation:** add inline feedback and persistent password display ([86e3d41](https://github.com/antialias/soroban-abacus-flashcards/commit/86e3d4199628f95048b9265c9de0adfdc2934f93))
|
||||
|
||||
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)
|
||||
|
||||
|
||||
|
||||
@@ -57,7 +57,10 @@
|
||||
"Bash(fi)",
|
||||
"Bash(then echo \"TypeScript errors found\")",
|
||||
"Bash(else echo \"✓ No TypeScript errors in join page\")",
|
||||
"Bash(npx @biomejs/biome format:*)"
|
||||
"Bash(npx @biomejs/biome format:*)",
|
||||
"Bash(npx drizzle-kit generate:*)",
|
||||
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
|
||||
"Bash(ssh:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
2
apps/web/drizzle/0009_add_display_password.sql
Normal file
2
apps/web/drizzle/0009_add_display_password.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- Add display_password column to arcade_rooms for showing plain text passwords to room owners
|
||||
ALTER TABLE `arcade_rooms` ADD `display_password` text(100);
|
||||
@@ -64,6 +64,13 @@
|
||||
"when": 1760548800000,
|
||||
"tag": "0008_make_room_name_nullable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1760600000000,
|
||||
"tag": "0009_add_display_password",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -88,11 +88,12 @@ 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
|
||||
// Broadcast to the room host (creator) only via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
io.to(`room:${roomId}`).emit('join-request-submitted', {
|
||||
// Send notification only to the room creator's user channel
|
||||
io.to(`user:${room.createdBy}`).emit('join-request-submitted', {
|
||||
roomId,
|
||||
request: {
|
||||
id: request.id,
|
||||
@@ -103,7 +104,7 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Join Requests] Broadcasted join-request-submitted for user ${viewerId} to room ${roomId}`
|
||||
`[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
|
||||
@@ -83,25 +83,31 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
}
|
||||
|
||||
case 'restricted': {
|
||||
// Check for valid pending invitation
|
||||
const invitation = await getInvitation(roomId, viewerId)
|
||||
if (!invitation || invitation.status !== 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: 'You need a valid invitation to join this room' },
|
||||
{ status: 403 }
|
||||
)
|
||||
// Room creator can always rejoin their own room
|
||||
if (!isRoomCreator) {
|
||||
// Check for valid pending invitation
|
||||
const invitation = await getInvitation(roomId, viewerId)
|
||||
if (!invitation || invitation.status !== 'pending') {
|
||||
return NextResponse.json(
|
||||
{ error: 'You need a valid invitation to join this room' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'approval-only': {
|
||||
// Check for approved join request
|
||||
const joinRequest = await getJoinRequest(roomId, viewerId)
|
||||
if (!joinRequest || joinRequest.status !== 'approved') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Your join request must be approved by the host' },
|
||||
{ status: 403 }
|
||||
)
|
||||
// Room creator can always rejoin their own room without approval
|
||||
if (!isRoomCreator) {
|
||||
// Check for approved join request
|
||||
const joinRequest = await getJoinRequest(roomId, viewerId)
|
||||
if (!joinRequest || joinRequest.status !== 'approved') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Your join request must be approved by the host' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
@@ -42,8 +42,13 @@ export async function GET(_req: NextRequest, context: RouteContext) {
|
||||
// Update room activity when viewing (keeps active rooms fresh)
|
||||
await touchRoom(roomId)
|
||||
|
||||
// Prepare room data - include displayPassword only for room creator
|
||||
const roomData = canModerate
|
||||
? room // Creator gets full room data including displayPassword
|
||||
: { ...room, displayPassword: undefined } // Others don't see displayPassword
|
||||
|
||||
return NextResponse.json({
|
||||
room,
|
||||
room: roomData,
|
||||
members,
|
||||
memberPlayers, // Map of userId -> active Player[] for each member
|
||||
canModerate,
|
||||
|
||||
@@ -69,9 +69,11 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
if (body.password !== undefined) {
|
||||
if (body.password === null || body.password === '') {
|
||||
updateData.password = null // Clear password
|
||||
updateData.displayPassword = null // Also clear display password
|
||||
} else {
|
||||
const hashedPassword = await bcrypt.hash(body.password, 10)
|
||||
updateData.password = hashedPassword
|
||||
updateData.displayPassword = body.password // Store plain text for display
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
|
||||
import { useParams, useRouter } from 'next/navigation'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
@@ -40,6 +41,7 @@ interface Player {
|
||||
export default function RoomDetailPage() {
|
||||
const params = useParams()
|
||||
const router = useRouter()
|
||||
const { showError } = useToast()
|
||||
const roomId = params.roomId as string
|
||||
const { data: guestId } = useViewerId()
|
||||
|
||||
@@ -172,7 +174,7 @@ export default function RoomDetailPage() {
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
|
||||
alert(errorData.userMessage || errorData.message)
|
||||
showError('Already in Another Room', errorData.userMessage || errorData.message)
|
||||
// Refresh the page to update room state
|
||||
await fetchRoom()
|
||||
return
|
||||
@@ -193,7 +195,7 @@ export default function RoomDetailPage() {
|
||||
await fetchRoom()
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
showError('Failed to join room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,7 +215,7 @@ export default function RoomDetailPage() {
|
||||
router.push('/arcade')
|
||||
} catch (err) {
|
||||
console.error('Failed to leave room:', err)
|
||||
alert('Failed to leave room')
|
||||
showError('Failed to leave room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
|
||||
@@ -23,6 +24,7 @@ interface Room {
|
||||
|
||||
export default function RoomBrowserPage() {
|
||||
const router = useRouter()
|
||||
const { showError, showInfo } = useToast()
|
||||
const [rooms, setRooms] = useState<Room[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@@ -71,7 +73,7 @@ export default function RoomBrowserPage() {
|
||||
router.push(`/arcade-rooms/${data.room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to create room:', err)
|
||||
alert('Failed to create room')
|
||||
showError('Failed to create room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,7 +92,7 @@ export default function RoomBrowserPage() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
alert(errorData.error || 'Failed to join room')
|
||||
showError('Failed to join room', errorData.error)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -99,12 +101,15 @@ export default function RoomBrowserPage() {
|
||||
}
|
||||
|
||||
if (room.accessMode === 'approval-only') {
|
||||
alert('This room requires host approval. Please use the Join Room modal to request access.')
|
||||
showInfo(
|
||||
'Approval Required',
|
||||
'This room requires host approval. Please use the Join Room modal to request access.'
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (room.accessMode === 'restricted') {
|
||||
alert('This room is invitation-only. Please ask the host for an invitation.')
|
||||
showInfo('Invitation Only', 'This room is invitation-only. Please ask the host for an invitation.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +125,7 @@ export default function RoomBrowserPage() {
|
||||
|
||||
// Handle specific room membership conflict
|
||||
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
|
||||
alert(errorData.userMessage || errorData.message)
|
||||
showError('Already in Another Room', errorData.userMessage || errorData.message)
|
||||
// Refresh the page to update room list state
|
||||
await fetchRooms()
|
||||
return
|
||||
@@ -140,7 +145,7 @@ export default function RoomBrowserPage() {
|
||||
router.push(`/arcade-rooms/${room.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to join room:', err)
|
||||
alert('Failed to join room')
|
||||
showError('Failed to join room', err instanceof Error ? err.message : undefined)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { AbacusDisplayProvider } from '@soroban/abacus-react'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { type ReactNode, useState } from 'react'
|
||||
import { ToastProvider } from '@/components/common/ToastContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
@@ -20,17 +21,19 @@ export function ClientProviders({ children }: ClientProvidersProps) {
|
||||
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AbacusDisplayProvider>
|
||||
<AbacusSettingsSync />
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
{children}
|
||||
<DeploymentInfo />
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
<ToastProvider>
|
||||
<AbacusDisplayProvider>
|
||||
<AbacusSettingsSync />
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
{children}
|
||||
<DeploymentInfo />
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
</ToastProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
235
apps/web/src/components/common/ToastContext.tsx
Normal file
235
apps/web/src/components/common/ToastContext.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
'use client'
|
||||
|
||||
import * as Toast from '@radix-ui/react-toast'
|
||||
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
export interface ToastMessage {
|
||||
id: string
|
||||
type: 'success' | 'error' | 'info'
|
||||
title: string
|
||||
description?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
interface ToastContextValue {
|
||||
showToast: (toast: Omit<ToastMessage, 'id'>) => void
|
||||
showSuccess: (title: string, description?: string) => void
|
||||
showError: (title: string, description?: string) => void
|
||||
showInfo: (title: string, description?: string) => void
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextValue | null>(null)
|
||||
|
||||
export function useToast() {
|
||||
const context = useContext(ToastContext)
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastMessage[]>([])
|
||||
|
||||
const showToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
|
||||
const id = Math.random().toString(36).substring(7)
|
||||
setToasts((prev) => [...prev, { ...toast, id }])
|
||||
}, [])
|
||||
|
||||
const showSuccess = useCallback(
|
||||
(title: string, description?: string) => {
|
||||
showToast({ type: 'success', title, description, duration: 5000 })
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const showError = useCallback(
|
||||
(title: string, description?: string) => {
|
||||
showToast({ type: 'error', title, description, duration: 7000 })
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const showInfo = useCallback(
|
||||
(title: string, description?: string) => {
|
||||
showToast({ type: 'info', title, description, duration: 5000 })
|
||||
},
|
||||
[showToast]
|
||||
)
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts((prev) => prev.filter((t) => t.id !== id))
|
||||
}, [])
|
||||
|
||||
const getToastStyles = (type: ToastMessage['type']) => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return {
|
||||
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.97), rgba(22, 163, 74, 0.97))',
|
||||
border: '2px solid rgba(34, 197, 94, 0.6)',
|
||||
icon: '✓',
|
||||
}
|
||||
case 'error':
|
||||
return {
|
||||
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)',
|
||||
icon: '⚠',
|
||||
}
|
||||
case 'info':
|
||||
return {
|
||||
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)',
|
||||
icon: 'ℹ',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={{ showToast, showSuccess, showError, showInfo }}>
|
||||
{children}
|
||||
<Toast.Provider swipeDirection="right">
|
||||
{toasts.map((toast) => {
|
||||
const styles = getToastStyles(toast.type)
|
||||
return (
|
||||
<Toast.Root
|
||||
key={toast.id}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
removeToast(toast.id)
|
||||
}
|
||||
}}
|
||||
duration={toast.duration}
|
||||
style={{
|
||||
background: styles.background,
|
||||
border: styles.border,
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
minWidth: '300px',
|
||||
maxWidth: '450px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '20px', flexShrink: 0 }}>{styles.icon}</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Toast.Title
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: toast.description ? '4px' : 0,
|
||||
}}
|
||||
>
|
||||
{toast.title}
|
||||
</Toast.Title>
|
||||
{toast.description && (
|
||||
<Toast.Description
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
{toast.description}
|
||||
</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>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import { InvitePlayersTab } from './InvitePlayersTab'
|
||||
import { PlayOnlineTab } from './PlayOnlineTab'
|
||||
import { addToRecentRooms } from './RecentRoomsList'
|
||||
@@ -41,6 +42,7 @@ export function AddPlayerButton({
|
||||
}: AddPlayerButtonProps) {
|
||||
const popoverRef = React.useRef<HTMLDivElement>(null)
|
||||
const router = useRouter()
|
||||
const { showError } = useToast()
|
||||
|
||||
// Use lifted state if provided, otherwise fallback to internal state
|
||||
const [internalShowPopover, setInternalShowPopover] = React.useState(false)
|
||||
@@ -75,7 +77,7 @@ export function AddPlayerButton({
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create room:', error)
|
||||
alert(`Failed to create room: ${error.message}`)
|
||||
showError('Failed to create room', error.message)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Modal } from '@/components/common/Modal'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import type { ModerationEvent } from '@/hooks/useRoomData'
|
||||
import { useJoinRoom } from '@/hooks/useRoomData'
|
||||
|
||||
@@ -27,6 +28,7 @@ export function ModerationNotifications({
|
||||
}: ModerationNotificationsProps) {
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
const { showError } = useToast()
|
||||
const [showToast, setShowToast] = useState(false)
|
||||
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
|
||||
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
|
||||
@@ -834,7 +836,7 @@ export function ModerationNotifications({
|
||||
router.push('/arcade/room')
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
alert(error instanceof Error ? error.message : 'Failed to join room')
|
||||
showError('Failed to join room', error instanceof Error ? error.message : undefined)
|
||||
setIsAcceptingInvitation(false)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -95,6 +95,10 @@ export function ModerationPanel({
|
||||
const [joinRequests, setJoinRequests] = useState<any[]>([])
|
||||
const [passwordCopied, setPasswordCopied] = useState(false)
|
||||
|
||||
// Inline feedback state
|
||||
const [successMessage, setSuccessMessage] = useState<string>('')
|
||||
const [errorMessage, setErrorMessage] = useState<string>('')
|
||||
|
||||
// Ban modal state
|
||||
const [showBanModal, setShowBanModal] = useState(false)
|
||||
const [banTargetUserId, setBanTargetUserId] = useState<string | null>(null)
|
||||
@@ -182,8 +186,9 @@ export function ModerationPanel({
|
||||
}
|
||||
|
||||
// Success - member will be removed via socket update
|
||||
showSuccess('Player kicked from room')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to kick player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to kick player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -220,8 +225,10 @@ export function ModerationPanel({
|
||||
const data = await bansRes.json()
|
||||
setBans(data.bans || [])
|
||||
}
|
||||
|
||||
showSuccess('Player banned from room')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to ban player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to ban player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
setBanTargetUserId(null)
|
||||
@@ -257,8 +264,10 @@ export function ModerationPanel({
|
||||
const data = await historyRes.json()
|
||||
setHistoricalMembers(data.historicalMembers || [])
|
||||
}
|
||||
|
||||
showSuccess('Player unbanned')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -294,9 +303,9 @@ export function ModerationPanel({
|
||||
setHistoricalMembers(data.historicalMembers || [])
|
||||
}
|
||||
|
||||
alert(`${userName} has been unbanned and invited back to the room!`)
|
||||
showSuccess(`${userName} has been unbanned and invited back to the room`)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
showError(err instanceof Error ? err.message : 'Failed to unban player')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -324,9 +333,9 @@ export function ModerationPanel({
|
||||
setHistoricalMembers(data.historicalMembers || [])
|
||||
}
|
||||
|
||||
alert(`Invitation sent to ${userName}!`)
|
||||
showSuccess(`Invitation sent to ${userName}`)
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to send invitation')
|
||||
showError(err instanceof Error ? err.message : 'Failed to send invitation')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -338,13 +347,19 @@ export function ModerationPanel({
|
||||
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
// Fetch current room data to get access mode
|
||||
// Fetch current room data to get access mode and password
|
||||
const roomRes = await fetch(`/api/arcade/rooms/${roomId}`)
|
||||
if (roomRes.ok) {
|
||||
const data = await roomRes.json()
|
||||
const currentAccessMode = data.room?.accessMode || 'open'
|
||||
setAccessMode(currentAccessMode)
|
||||
setOriginalAccessMode(currentAccessMode)
|
||||
|
||||
// Set password field if room has a password and user is the creator
|
||||
if (currentAccessMode === 'password' && data.room?.displayPassword) {
|
||||
setRoomPassword(data.room.displayPassword)
|
||||
setShowPasswordInput(true)
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch join requests if any
|
||||
@@ -381,12 +396,12 @@ export function ModerationPanel({
|
||||
throw new Error(errorData.error || 'Failed to update settings')
|
||||
}
|
||||
|
||||
alert('Room settings updated successfully!')
|
||||
showSuccess('Room settings updated successfully')
|
||||
setOriginalAccessMode(accessMode) // Update original to current
|
||||
setShowPasswordInput(false)
|
||||
setRoomPassword('')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to update settings')
|
||||
showError(err instanceof Error ? err.message : 'Failed to update settings')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -414,10 +429,10 @@ export function ModerationPanel({
|
||||
throw new Error(errorData.error || 'Failed to transfer ownership')
|
||||
}
|
||||
|
||||
alert(`Ownership transferred to ${newOwner.displayName}!`)
|
||||
onClose() // Close panel since user is no longer host
|
||||
showSuccess(`Ownership transferred to ${newOwner.displayName}`)
|
||||
setTimeout(() => onClose(), 2000) // Close panel after showing message
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to transfer ownership')
|
||||
showError(err instanceof Error ? err.message : 'Failed to transfer ownership')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -442,9 +457,9 @@ export function ModerationPanel({
|
||||
setJoinRequests(data.requests || [])
|
||||
}
|
||||
|
||||
alert('Join request approved!')
|
||||
showSuccess('Join request approved')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to approve request')
|
||||
showError(err instanceof Error ? err.message : 'Failed to approve request')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -468,8 +483,10 @@ export function ModerationPanel({
|
||||
const data = await requestsRes.json()
|
||||
setJoinRequests(data.requests || [])
|
||||
}
|
||||
|
||||
showSuccess('Join request denied')
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to deny request')
|
||||
showError(err instanceof Error ? err.message : 'Failed to deny request')
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -484,10 +501,23 @@ export function ModerationPanel({
|
||||
setTimeout(() => setPasswordCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy password:', err)
|
||||
alert('Failed to copy password to clipboard')
|
||||
showError('Failed to copy password to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
// Utility functions for showing feedback
|
||||
const showSuccess = (message: string) => {
|
||||
setSuccessMessage(message)
|
||||
setErrorMessage('')
|
||||
setTimeout(() => setSuccessMessage(''), 5000)
|
||||
}
|
||||
|
||||
const showError = (message: string) => {
|
||||
setErrorMessage(message)
|
||||
setSuccessMessage('')
|
||||
setTimeout(() => setErrorMessage(''), 5000)
|
||||
}
|
||||
|
||||
const pendingReports = reports.filter((r) => r.status === 'pending')
|
||||
const otherMembers = members.filter((m) => m.userId !== currentUserId)
|
||||
|
||||
@@ -534,6 +564,69 @@ export function ModerationPanel({
|
||||
Manage members, reports, and bans
|
||||
</p>
|
||||
|
||||
{/* Success/Error Messages */}
|
||||
{(successMessage || errorMessage) && (
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: successMessage ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
|
||||
border: successMessage
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
borderRadius: '8px',
|
||||
marginBottom: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
animation: 'fadeIn 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>{successMessage ? '✓' : '⚠'}</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: successMessage ? 'rgba(34, 197, 94, 1)' : 'rgba(239, 68, 68, 1)',
|
||||
}}
|
||||
>
|
||||
{successMessage || errorMessage}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSuccessMessage('')
|
||||
setErrorMessage('')
|
||||
}}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: successMessage ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)',
|
||||
fontSize: '18px',
|
||||
cursor: 'pointer',
|
||||
padding: '0 4px',
|
||||
lineHeight: 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.opacity = '1'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.opacity = '0.8'
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
style={{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useToast } from '@/components/common/ToastContext'
|
||||
import { useJoinRoom } from '@/hooks/useRoomData'
|
||||
|
||||
interface PendingInvitation {
|
||||
@@ -32,6 +33,7 @@ export interface PendingInvitationsProps {
|
||||
*/
|
||||
export function PendingInvitations({ onInvitationChange, currentRoomId }: PendingInvitationsProps) {
|
||||
const router = useRouter()
|
||||
const { showError } = useToast()
|
||||
const [invitations, setInvitations] = useState<PendingInvitation[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
@@ -72,7 +74,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
|
||||
onInvitationChange?.()
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
alert(error instanceof Error ? error.message : 'Failed to join room')
|
||||
showError('Failed to join room', error instanceof Error ? error.message : undefined)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
@@ -97,7 +99,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
|
||||
onInvitationChange?.()
|
||||
} catch (error) {
|
||||
console.error('Failed to decline invitation:', error)
|
||||
alert(error instanceof Error ? error.message : 'Failed to decline invitation')
|
||||
showError('Failed to decline invitation', error instanceof Error ? error.message : undefined)
|
||||
} finally {
|
||||
setActionLoading(null)
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
|
||||
.notNull()
|
||||
.default('open'),
|
||||
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
|
||||
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
|
||||
|
||||
// Game configuration
|
||||
gameName: text('game_name', {
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('Room Manager', () => {
|
||||
ttlMinutes: 60,
|
||||
accessMode: 'open',
|
||||
password: null,
|
||||
displayPassword: null,
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
status: 'lobby',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.12.0",
|
||||
"version": "3.13.3",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user