Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb11bec975 | ||
|
|
2580e474d0 | ||
|
|
55e0be8e42 | ||
|
|
dd9e657db8 | ||
|
|
51d9a37f9b | ||
|
|
07212e4df0 | ||
|
|
97daad9abb | ||
|
|
225104c3a7 | ||
|
|
249257c6c7 | ||
|
|
b37e29e53e | ||
|
|
c6886a0e59 | ||
|
|
cb2fec1da5 | ||
|
|
6beb58a7b8 | ||
|
|
544b06e290 | ||
|
|
a7c3c1f4cd |
54
CHANGELOG.md
54
CHANGELOG.md
@@ -1,3 +1,57 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
|
||||
@@ -88,10 +88,12 @@ 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)
|
||||
|
||||
// Ban modal state
|
||||
const [showBanModal, setShowBanModal] = useState(false)
|
||||
@@ -340,7 +342,9 @@ export function ModerationPanel({
|
||||
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)
|
||||
}
|
||||
|
||||
// Fetch join requests if any
|
||||
@@ -378,6 +382,7 @@ export function ModerationPanel({
|
||||
}
|
||||
|
||||
alert('Room settings updated successfully!')
|
||||
setOriginalAccessMode(accessMode) // Update original to current
|
||||
setShowPasswordInput(false)
|
||||
setRoomPassword('')
|
||||
} catch (err) {
|
||||
@@ -470,9 +475,25 @@ export function ModerationPanel({
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
alert('Failed to copy password to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -1411,49 +1432,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 +1760,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,
|
||||
})
|
||||
})
|
||||
|
||||
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.1",
|
||||
"version": "3.12.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user