Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fb6ead4f2 | ||
|
|
bc571e3d0d | ||
|
|
eed7c9b938 | ||
|
|
654ba19ccc | ||
|
|
f5469cda0c | ||
|
|
86e3d41996 | ||
|
|
cb11bec975 | ||
|
|
2580e474d0 | ||
|
|
55e0be8e42 | ||
|
|
dd9e657db8 | ||
|
|
51d9a37f9b | ||
|
|
07212e4df0 | ||
|
|
97daad9abb | ||
|
|
225104c3a7 | ||
|
|
249257c6c7 | ||
|
|
b37e29e53e | ||
|
|
c6886a0e59 | ||
|
|
cb2fec1da5 | ||
|
|
6beb58a7b8 | ||
|
|
544b06e290 | ||
|
|
a7c3c1f4cd | ||
|
|
090d4dac2b | ||
|
|
f865ce16ec |
82
CHANGELOG.md
82
CHANGELOG.md
@@ -1,3 +1,85 @@
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove duplicate ModerationNotifications causing double toasts ([c6886a0](https://github.com/antialias/soroban-abacus-flashcards/commit/c6886a0e59b3cbf051a828e0157495101cd8c823))
|
||||
|
||||
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
|
||||
|
||||
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
|
||||
|
||||
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
|
||||
|
||||
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.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);
|
||||
@@ -1,13 +1,14 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import {
|
||||
createInvitation,
|
||||
declineInvitation,
|
||||
getInvitation,
|
||||
getRoomInvitations,
|
||||
} from '@/lib/arcade/room-invitations'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
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 = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -35,6 +36,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get room to check access mode
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Cannot invite to retired rooms
|
||||
if (room.accessMode === 'retired') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot send invitations to retired rooms' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { ModerationNotifications } from '@/components/nav/ModerationNotifications'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
|
||||
|
||||
@@ -11,60 +10,57 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const { roomData, isLoading, moderationEvent, clearModerationEvent } = useRoomData()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
|
||||
</>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
|
||||
</>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,38 +68,26 @@ export default function RoomPage() {
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
<ModerationNotifications
|
||||
moderationEvent={moderationEvent}
|
||||
onClose={clearModerationEvent}
|
||||
/>
|
||||
</>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
<ModerationNotifications
|
||||
moderationEvent={moderationEvent}
|
||||
onClose={clearModerationEvent}
|
||||
/>
|
||||
</>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ export function ModerationNotifications({
|
||||
if (moderationEvent?.type === 'join-request') {
|
||||
setShowJoinRequestToast(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
} else {
|
||||
// Reset toast state when event is cleared or changes type
|
||||
setShowJoinRequestToast(false)
|
||||
setRequestError(null)
|
||||
}
|
||||
}, [moderationEvent])
|
||||
|
||||
@@ -129,6 +133,8 @@ export function ModerationNotifications({
|
||||
|
||||
// Kicked modal
|
||||
if (moderationEvent?.type === 'kicked') {
|
||||
const isRetired = moderationEvent.data.reason?.includes('retired')
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={() => {}}>
|
||||
<div
|
||||
@@ -139,7 +145,7 @@ export function ModerationNotifications({
|
||||
minWidth: '400px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isRetired ? '🏁' : '⚠️'}</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
@@ -148,7 +154,7 @@ export function ModerationNotifications({
|
||||
color: 'rgba(253, 186, 116, 1)',
|
||||
}}
|
||||
>
|
||||
Kicked from Room
|
||||
{isRetired ? 'Room Retired' : 'Kicked from Room'}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
@@ -157,10 +163,16 @@ export function ModerationNotifications({
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
You were kicked from the room by{' '}
|
||||
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
|
||||
{moderationEvent.data.kickedBy}
|
||||
</strong>
|
||||
{isRetired ? (
|
||||
<>The room owner has retired this room and access has been closed</>
|
||||
) : (
|
||||
<>
|
||||
You were kicked from the room by{' '}
|
||||
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
|
||||
{moderationEvent.data.kickedBy}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
@@ -169,7 +181,9 @@ export function ModerationNotifications({
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
You can rejoin if the host sends you a new invite
|
||||
{isRetired
|
||||
? 'Only the room owner can access retired rooms'
|
||||
: 'You can rejoin if the host sends you a new invite'}
|
||||
</p>
|
||||
|
||||
<button
|
||||
|
||||
@@ -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,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', {
|
||||
|
||||
@@ -347,13 +347,14 @@ export function useRoomData() {
|
||||
}
|
||||
|
||||
// Moderation event handlers
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
|
||||
console.log('[useRoomData] User was kicked from room:', data)
|
||||
setModerationEvent({
|
||||
type: 'kicked',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
kickedBy: data.kickedBy,
|
||||
reason: data.reason,
|
||||
},
|
||||
})
|
||||
// Clear room data since user was kicked
|
||||
|
||||
@@ -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.8.0",
|
||||
"version": "3.13.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user