Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4251e660d | ||
|
|
040d7495a0 | ||
|
|
87ef35682e | ||
|
|
2fb6ead4f2 | ||
|
|
bc571e3d0d | ||
|
|
eed7c9b938 | ||
|
|
654ba19ccc | ||
|
|
f5469cda0c | ||
|
|
86e3d41996 | ||
|
|
cb11bec975 | ||
|
|
2580e474d0 | ||
|
|
55e0be8e42 | ||
|
|
dd9e657db8 | ||
|
|
51d9a37f9b | ||
|
|
07212e4df0 | ||
|
|
97daad9abb | ||
|
|
225104c3a7 | ||
|
|
249257c6c7 |
66
CHANGELOG.md
66
CHANGELOG.md
@@ -1,3 +1,69 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **moderation:** improve password input with copy button ([2580e47](https://github.com/antialias/soroban-abacus-flashcards/commit/2580e474d08bf91477339e998b2c70962a633f41))
|
||||
|
||||
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **moderation:** improve access mode settings UX ([dd9e657](https://github.com/antialias/soroban-abacus-flashcards/commit/dd9e657db85752b32ff91ae1b33a0bf7a7628e07))
|
||||
|
||||
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add name generator button and abacus emoji ([07212e4](https://github.com/antialias/soroban-abacus-flashcards/commit/07212e4df0c7fd4b8cccf935c48b14164df6961d))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* make player names abacus and arithmetic themed ([97daad9](https://github.com/antialias/soroban-abacus-flashcards/commit/97daad9abb40a6f4d59ca8a4d4b671822b7b0955))
|
||||
|
||||
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add fun automatic player naming system ([249257c](https://github.com/antialias/soroban-abacus-flashcards/commit/249257c6c77d503b48479065664c96c5de36a234))
|
||||
|
||||
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -88,10 +88,16 @@ export function ModerationPanel({
|
||||
|
||||
// Settings state
|
||||
const [accessMode, setAccessMode] = useState<string>('open')
|
||||
const [originalAccessMode, setOriginalAccessMode] = useState<string>('open')
|
||||
const [roomPassword, setRoomPassword] = useState('')
|
||||
const [showPasswordInput, setShowPasswordInput] = useState(false)
|
||||
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('')
|
||||
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)
|
||||
@@ -180,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)
|
||||
}
|
||||
@@ -218,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)
|
||||
@@ -255,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)
|
||||
}
|
||||
@@ -292,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)
|
||||
}
|
||||
@@ -322,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)
|
||||
}
|
||||
@@ -336,11 +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()
|
||||
setAccessMode(data.room?.accessMode || 'open')
|
||||
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
|
||||
@@ -377,11 +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)
|
||||
}
|
||||
@@ -409,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)
|
||||
}
|
||||
@@ -437,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)
|
||||
}
|
||||
@@ -463,16 +483,47 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCopyPassword = async () => {
|
||||
if (!roomPassword) return
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(roomPassword)
|
||||
setPasswordCopied(true)
|
||||
setTimeout(() => setPasswordCopied(false), 2000)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy password:', err)
|
||||
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)
|
||||
|
||||
// Check if there are unsaved changes in settings
|
||||
const hasUnsavedAccessModeChanges = accessMode !== originalAccessMode
|
||||
|
||||
// Group reports by reported user ID
|
||||
const reportsByUser = pendingReports.reduce(
|
||||
(acc, report) => {
|
||||
@@ -513,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={{
|
||||
@@ -1411,49 +1525,125 @@ export function ModerationPanel({
|
||||
|
||||
{/* Password input (conditional) */}
|
||||
{(accessMode === 'password' || showPasswordInput) && (
|
||||
<input
|
||||
type="text"
|
||||
value={roomPassword}
|
||||
onChange={(e) => setRoomPassword(e.target.value)}
|
||||
placeholder="Enter room password"
|
||||
<div style={{ marginBottom: '12px' }}>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
fontSize: '12px',
|
||||
fontWeight: '600',
|
||||
color: 'rgba(209, 213, 219, 0.8)',
|
||||
marginBottom: '6px',
|
||||
}}
|
||||
>
|
||||
Room Password
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={roomPassword}
|
||||
onChange={(e) => setRoomPassword(e.target.value)}
|
||||
placeholder="Enter password to share with guests"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 12px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
transition: 'border-color 0.2s ease',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.6)'
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyPassword}
|
||||
disabled={!roomPassword}
|
||||
title="Copy password to clipboard"
|
||||
style={{
|
||||
padding: '10px 16px',
|
||||
background: passwordCopied
|
||||
? 'rgba(34, 197, 94, 0.2)'
|
||||
: roomPassword
|
||||
? 'rgba(59, 130, 246, 0.2)'
|
||||
: 'rgba(75, 85, 99, 0.2)',
|
||||
color: passwordCopied
|
||||
? 'rgba(34, 197, 94, 1)'
|
||||
: roomPassword
|
||||
? 'rgba(59, 130, 246, 1)'
|
||||
: 'rgba(156, 163, 175, 1)',
|
||||
border: passwordCopied
|
||||
? '1px solid rgba(34, 197, 94, 0.4)'
|
||||
: roomPassword
|
||||
? '1px solid rgba(59, 130, 246, 0.4)'
|
||||
: '1px solid rgba(75, 85, 99, 0.3)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: roomPassword ? 'pointer' : 'not-allowed',
|
||||
opacity: roomPassword ? 1 : 0.5,
|
||||
transition: 'all 0.2s ease',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (roomPassword && !passwordCopied) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.3)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (roomPassword && !passwordCopied) {
|
||||
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{passwordCopied ? '✓ Copied!' : '📋 Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(156, 163, 175, 1)',
|
||||
marginTop: '4px',
|
||||
}}
|
||||
>
|
||||
Share this password with guests to allow them to join
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasUnsavedAccessModeChanges && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateAccessMode}
|
||||
disabled={actionLoading === 'update-settings'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
background:
|
||||
actionLoading === 'update-settings'
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: 'white',
|
||||
border:
|
||||
actionLoading === 'update-settings'
|
||||
? '1px solid rgba(75, 85, 99, 0.5)'
|
||||
: '1px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '6px',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
marginBottom: '12px',
|
||||
fontWeight: '600',
|
||||
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
>
|
||||
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleUpdateAccessMode}
|
||||
disabled={actionLoading === 'update-settings'}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '10px',
|
||||
background:
|
||||
actionLoading === 'update-settings'
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
|
||||
color: 'white',
|
||||
border:
|
||||
actionLoading === 'update-settings'
|
||||
? '1px solid rgba(75, 85, 99, 0.5)'
|
||||
: '1px solid rgba(59, 130, 246, 0.6)',
|
||||
borderRadius: '6px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
|
||||
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
|
||||
}}
|
||||
>
|
||||
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1663,23 +1853,44 @@ export function ModerationPanel({
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
onClick={hasUnsavedAccessModeChanges ? undefined : onClose}
|
||||
disabled={hasUnsavedAccessModeChanges}
|
||||
title={
|
||||
hasUnsavedAccessModeChanges
|
||||
? 'Please update access mode settings before closing'
|
||||
: undefined
|
||||
}
|
||||
style={{
|
||||
padding: '10px 20px',
|
||||
background: 'rgba(75, 85, 99, 0.3)',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
border: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
background: hasUnsavedAccessModeChanges
|
||||
? 'rgba(75, 85, 99, 0.2)'
|
||||
: 'rgba(75, 85, 99, 0.3)',
|
||||
color: hasUnsavedAccessModeChanges
|
||||
? 'rgba(156, 163, 175, 1)'
|
||||
: 'rgba(209, 213, 219, 1)',
|
||||
border: hasUnsavedAccessModeChanges
|
||||
? '1px solid rgba(251, 146, 60, 0.4)'
|
||||
: '1px solid rgba(75, 85, 99, 0.5)',
|
||||
borderRadius: '10px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
cursor: 'pointer',
|
||||
cursor: hasUnsavedAccessModeChanges ? 'not-allowed' : 'pointer',
|
||||
opacity: hasUnsavedAccessModeChanges ? 0.6 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
if (!hasUnsavedAccessModeChanges) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
|
||||
} else {
|
||||
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.8)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
if (!hasUnsavedAccessModeChanges) {
|
||||
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
|
||||
} else {
|
||||
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.4)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Close
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
|
||||
import { useGameMode } from '../../contexts/GameModeContext'
|
||||
import { generateUniquePlayerName } from '../../utils/playerNames'
|
||||
|
||||
interface PlayerConfigDialogProps {
|
||||
playerId: string
|
||||
@@ -48,6 +49,15 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
setShowEmojiPicker(false)
|
||||
}
|
||||
|
||||
const handleGenerateNewName = () => {
|
||||
const allPlayers = Array.from(players.values())
|
||||
const existingNames = allPlayers.filter((p) => p.id !== playerId).map((p) => p.name)
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
|
||||
setLocalName(newName)
|
||||
updatePlayer(playerId, { name: newName })
|
||||
}
|
||||
|
||||
// Get player number for UI theming (first 4 players get special colors)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => {
|
||||
const aTime =
|
||||
@@ -256,40 +266,79 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
fontSize: '16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontWeight: '500',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = gradientColor
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px 16px',
|
||||
fontSize: '16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
fontWeight: '500',
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.currentTarget.style.borderColor = gradientColor
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleGenerateNewName}
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
color: 'white',
|
||||
fontSize: '20px',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)'
|
||||
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.boxShadow = 'none'
|
||||
}}
|
||||
title="Generate random name"
|
||||
>
|
||||
🎲
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{localName.length}/20 characters
|
||||
<span>Click dice to generate a random name</span>
|
||||
<span>{localName.length}/20 characters</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Available character emojis for players
|
||||
export const PLAYER_EMOJIS = [
|
||||
// Abacus
|
||||
'🧮',
|
||||
|
||||
// People & Characters
|
||||
'😀',
|
||||
'😃',
|
||||
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { Player as DBPlayer } from '@/db/schema/players'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import {
|
||||
useCreatePlayer,
|
||||
useDeletePlayer,
|
||||
useUpdatePlayer,
|
||||
useUserPlayers,
|
||||
} from '@/hooks/useUserPlayers'
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getNextPlayerColor } from '../types/player'
|
||||
import { generateUniquePlayerName, generateUniquePlayerNames } from '../utils/playerNames'
|
||||
|
||||
// Client-side Player type (compatible with old type)
|
||||
export interface Player {
|
||||
@@ -44,11 +45,12 @@ export interface GameModeContextType {
|
||||
const GameModeContext = createContext<GameModeContextType | null>(null)
|
||||
|
||||
// Default players to create if none exist
|
||||
const DEFAULT_PLAYERS = [
|
||||
{ name: 'Player 1', emoji: '😀', color: '#3b82f6' },
|
||||
{ name: 'Player 2', emoji: '😎', color: '#8b5cf6' },
|
||||
{ name: 'Player 3', emoji: '🤠', color: '#10b981' },
|
||||
{ name: 'Player 4', emoji: '🚀', color: '#f59e0b' },
|
||||
// Names are generated randomly on first initialization
|
||||
const DEFAULT_PLAYER_CONFIGS = [
|
||||
{ emoji: '😀', color: '#3b82f6' },
|
||||
{ emoji: '😎', color: '#8b5cf6' },
|
||||
{ emoji: '🤠', color: '#10b981' },
|
||||
{ emoji: '🚀', color: '#f59e0b' },
|
||||
]
|
||||
|
||||
// Convert DB player to client Player type
|
||||
@@ -139,14 +141,19 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isInitialized) {
|
||||
if (dbPlayers.length === 0) {
|
||||
// Create default players
|
||||
DEFAULT_PLAYERS.forEach((data, index) => {
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
createPlayer({
|
||||
...data,
|
||||
name: generatedNames[index],
|
||||
emoji: config.emoji,
|
||||
color: config.color,
|
||||
isActive: index === 0, // First player active by default
|
||||
})
|
||||
})
|
||||
console.log('✅ Created default players via API')
|
||||
console.log('✅ Created default players via API with auto-generated names:', generatedNames)
|
||||
} else {
|
||||
console.log('✅ Loaded players from API', {
|
||||
playerCount: dbPlayers.length,
|
||||
@@ -159,9 +166,10 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const addPlayer = (playerData?: Partial<Player>) => {
|
||||
const playerList = Array.from(players.values())
|
||||
const existingNames = playerList.map((p) => p.name)
|
||||
|
||||
const newPlayer = {
|
||||
name: playerData?.name ?? `Player ${players.size + 1}`,
|
||||
name: playerData?.name ?? generateUniquePlayerName(existingNames),
|
||||
emoji: playerData?.emoji ?? '🎮',
|
||||
color: playerData?.color ?? getNextPlayerColor(playerList),
|
||||
isActive: playerData?.isActive ?? false,
|
||||
@@ -246,10 +254,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
deletePlayer(player.id)
|
||||
})
|
||||
|
||||
// Create default players
|
||||
DEFAULT_PLAYERS.forEach((data, index) => {
|
||||
// Generate unique names for default players
|
||||
const generatedNames = generateUniquePlayerNames(DEFAULT_PLAYER_CONFIGS.length)
|
||||
|
||||
// Create default players with generated names
|
||||
DEFAULT_PLAYER_CONFIGS.forEach((config, index) => {
|
||||
createPlayer({
|
||||
...data,
|
||||
name: generatedNames[index],
|
||||
emoji: config.emoji,
|
||||
color: config.color,
|
||||
isActive: index === 0,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
92
apps/web/src/utils/__tests__/playerNames.test.ts
Normal file
92
apps/web/src/utils/__tests__/playerNames.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
generatePlayerName,
|
||||
generateUniquePlayerName,
|
||||
generateUniquePlayerNames,
|
||||
} from '../playerNames'
|
||||
|
||||
describe('playerNames', () => {
|
||||
describe('generatePlayerName', () => {
|
||||
it('should generate a player name with adjective and noun', () => {
|
||||
const name = generatePlayerName()
|
||||
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/) // e.g., "Swift Ninja"
|
||||
expect(name.split(' ')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should generate different names on multiple calls', () => {
|
||||
const names = new Set()
|
||||
// Generate 50 names and expect at least some variety
|
||||
for (let i = 0; i < 50; i++) {
|
||||
names.add(generatePlayerName())
|
||||
}
|
||||
// With 50 adjectives and 50 nouns, we should get many unique combinations
|
||||
expect(names.size).toBeGreaterThan(30)
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateUniquePlayerName', () => {
|
||||
it('should generate a unique name not in existing names', () => {
|
||||
const existingNames = ['Swift Ninja', 'Cosmic Wizard', 'Radiant Dragon']
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
|
||||
expect(existingNames).not.toContain(newName)
|
||||
})
|
||||
|
||||
it('should be case-insensitive when checking uniqueness', () => {
|
||||
const existingNames = ['swift ninja', 'COSMIC WIZARD']
|
||||
const newName = generateUniquePlayerName(existingNames)
|
||||
|
||||
expect(existingNames.map((n) => n.toLowerCase())).not.toContain(newName.toLowerCase())
|
||||
})
|
||||
|
||||
it('should handle empty existing names array', () => {
|
||||
const name = generateUniquePlayerName([])
|
||||
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+$/)
|
||||
})
|
||||
|
||||
it('should append number if all combinations are exhausted', () => {
|
||||
// Create a mock with limited attempts
|
||||
const existingNames = ['Swift Ninja']
|
||||
const name = generateUniquePlayerName(existingNames, 1)
|
||||
|
||||
// Should either be unique or have a number appended
|
||||
expect(name).toBeTruthy()
|
||||
expect(name).not.toBe('Swift Ninja')
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateUniquePlayerNames', () => {
|
||||
it('should generate the requested number of unique names', () => {
|
||||
const names = generateUniquePlayerNames(4)
|
||||
expect(names).toHaveLength(4)
|
||||
|
||||
// All names should be unique
|
||||
const uniqueNames = new Set(names)
|
||||
expect(uniqueNames.size).toBe(4)
|
||||
})
|
||||
|
||||
it('should generate unique names across all entries', () => {
|
||||
const names = generateUniquePlayerNames(10)
|
||||
expect(names).toHaveLength(10)
|
||||
|
||||
// Check uniqueness (case-insensitive)
|
||||
const uniqueNames = new Set(names.map((n) => n.toLowerCase()))
|
||||
expect(uniqueNames.size).toBe(10)
|
||||
})
|
||||
|
||||
it('should handle generating zero names', () => {
|
||||
const names = generateUniquePlayerNames(0)
|
||||
expect(names).toHaveLength(0)
|
||||
expect(names).toEqual([])
|
||||
})
|
||||
|
||||
it('should generate names with expected format', () => {
|
||||
const names = generateUniquePlayerNames(5)
|
||||
|
||||
for (const name of names) {
|
||||
expect(name).toMatch(/^[A-Z][a-z]+ [A-Z][a-z]+( \d+)?$/)
|
||||
expect(name.split(' ').length).toBeGreaterThanOrEqual(2)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
162
apps/web/src/utils/playerNames.ts
Normal file
162
apps/web/src/utils/playerNames.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* Fun automatic player name generation system
|
||||
* Generates creative names by combining adjectives with nouns/roles
|
||||
*/
|
||||
|
||||
const ADJECTIVES = [
|
||||
// Abacus-themed adjectives
|
||||
'Ancient',
|
||||
'Wooden',
|
||||
'Sliding',
|
||||
'Decimal',
|
||||
'Binary',
|
||||
'Counting',
|
||||
'Soroban',
|
||||
'Chinese',
|
||||
'Japanese',
|
||||
'Nimble',
|
||||
'Clicking',
|
||||
'Beaded',
|
||||
'Columnar',
|
||||
'Vertical',
|
||||
'Horizontal',
|
||||
'Upper',
|
||||
'Lower',
|
||||
'Heaven',
|
||||
'Earth',
|
||||
'Golden',
|
||||
'Jade',
|
||||
'Bamboo',
|
||||
'Polished',
|
||||
'Skilled',
|
||||
'Master',
|
||||
// Arithmetic/calculation adjectives
|
||||
'Adding',
|
||||
'Subtracting',
|
||||
'Multiplying',
|
||||
'Dividing',
|
||||
'Calculating',
|
||||
'Computing',
|
||||
'Estimating',
|
||||
'Rounding',
|
||||
'Summing',
|
||||
'Tallying',
|
||||
'Decimal',
|
||||
'Fractional',
|
||||
'Exponential',
|
||||
'Algebraic',
|
||||
'Geometric',
|
||||
'Prime',
|
||||
'Composite',
|
||||
'Rational',
|
||||
'Digital',
|
||||
'Numeric',
|
||||
'Precise',
|
||||
'Accurate',
|
||||
'Lightning',
|
||||
'Rapid',
|
||||
'Mental',
|
||||
]
|
||||
|
||||
const NOUNS = [
|
||||
// Abacus-themed nouns
|
||||
'Counter',
|
||||
'Abacist',
|
||||
'Calculator',
|
||||
'Bead',
|
||||
'Rod',
|
||||
'Frame',
|
||||
'Slider',
|
||||
'Merchant',
|
||||
'Trader',
|
||||
'Accountant',
|
||||
'Bookkeeper',
|
||||
'Clerk',
|
||||
'Scribe',
|
||||
'Master',
|
||||
'Apprentice',
|
||||
'Scholar',
|
||||
'Student',
|
||||
'Teacher',
|
||||
'Sensei',
|
||||
'Guru',
|
||||
'Expert',
|
||||
'Virtuoso',
|
||||
'Prodigy',
|
||||
'Wizard',
|
||||
'Sage',
|
||||
// Arithmetic/calculation nouns
|
||||
'Adder',
|
||||
'Multiplier',
|
||||
'Divider',
|
||||
'Solver',
|
||||
'Mathematician',
|
||||
'Arithmetician',
|
||||
'Analyst',
|
||||
'Computer',
|
||||
'Estimator',
|
||||
'Logician',
|
||||
'Statistician',
|
||||
'Numerologist',
|
||||
'Quantifier',
|
||||
'Tallier',
|
||||
'Sumner',
|
||||
'Keeper',
|
||||
'Reckoner',
|
||||
'Cipher',
|
||||
'Digit',
|
||||
'Figure',
|
||||
'Number',
|
||||
'Brain',
|
||||
'Thinker',
|
||||
'Genius',
|
||||
'Whiz',
|
||||
]
|
||||
|
||||
/**
|
||||
* Generate a random player name by combining an adjective and noun
|
||||
*/
|
||||
export function generatePlayerName(): string {
|
||||
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
||||
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
||||
return `${adjective} ${noun}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique player name that doesn't conflict with existing players
|
||||
* @param existingNames - Array of names already in use
|
||||
* @param maxAttempts - Maximum attempts to find a unique name (default: 50)
|
||||
* @returns A unique player name
|
||||
*/
|
||||
export function generateUniquePlayerName(existingNames: string[], maxAttempts = 50): string {
|
||||
const existingNamesSet = new Set(existingNames.map((name) => name.toLowerCase()))
|
||||
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
const name = generatePlayerName()
|
||||
if (!existingNamesSet.has(name.toLowerCase())) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if we can't find a unique name, append a number
|
||||
const baseName = generatePlayerName()
|
||||
let counter = 1
|
||||
while (existingNamesSet.has(`${baseName} ${counter}`.toLowerCase())) {
|
||||
counter++
|
||||
}
|
||||
return `${baseName} ${counter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a batch of unique player names
|
||||
* @param count - Number of names to generate
|
||||
* @returns Array of unique player names
|
||||
*/
|
||||
export function generateUniquePlayerNames(count: number): string[] {
|
||||
const names: string[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const name = generateUniquePlayerName(names)
|
||||
names.push(name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.9.2",
|
||||
"version": "3.13.3",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user