Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15029ae52f | ||
|
|
a83dc097e4 | ||
|
|
0abec1a3bb | ||
|
|
5171be3d37 |
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,3 +1,17 @@
|
||||
## [2.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.7...v2.9.0) (2025-10-09)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement auto-save for player settings modal ([a83dc09](https://github.com/antialias/soroban-abacus-flashcards/commit/a83dc097e43c265a297281da54754f58ac831754))
|
||||
|
||||
## [2.8.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.6...v2.8.7) (2025-10-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* enable real-time player name updates across room members ([5171be3](https://github.com/antialias/soroban-abacus-flashcards/commit/5171be3d37980eb1c98aa0d1e1d6e06f589763d1))
|
||||
|
||||
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)
|
||||
|
||||
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('Room Navigation with Active Sessions', () => {
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
// Mock rooms API
|
||||
@@ -140,6 +141,7 @@ describe('Room Navigation with Active Sessions', () => {
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -172,6 +174,7 @@ describe('Room Navigation with Active Sessions', () => {
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -225,6 +228,7 @@ describe('Room Navigation with Active Sessions', () => {
|
||||
roomData: null,
|
||||
isLoading: false,
|
||||
isInRoom: false,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
@@ -271,6 +275,7 @@ describe('Room Navigation with Active Sessions', () => {
|
||||
},
|
||||
isLoading: false,
|
||||
isInRoom: true,
|
||||
notifyRoomOfPlayerUpdate: vi.fn(),
|
||||
})
|
||||
|
||||
;(global.fetch as any).mockResolvedValue({
|
||||
|
||||
@@ -61,7 +61,7 @@ export function PageWithNav({
|
||||
// Only show LOCAL players in the active/inactive lists (remote players shown separately in networkPlayers)
|
||||
const activePlayerList = Array.from(activePlayers)
|
||||
.map((id) => players.get(id))
|
||||
.filter((p) => p !== undefined && p.isLocal !== false) // Filter out remote players
|
||||
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false) // Filter out remote players
|
||||
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
|
||||
|
||||
const inactivePlayerList = Array.from(players.values())
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { EmojiPicker } from '../../app/games/matching/components/EmojiPicker'
|
||||
import { useGameMode } from '../../contexts/GameModeContext'
|
||||
|
||||
@@ -11,17 +11,36 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
// All hooks must be called before early return
|
||||
const { getPlayer, updatePlayer, players } = useGameMode()
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
|
||||
const [localName, setLocalName] = useState('')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const debounceTimerRef = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
const player = getPlayer(playerId)
|
||||
const [tempName, setTempName] = useState(player?.name || '')
|
||||
|
||||
// Initialize local name from player
|
||||
useEffect(() => {
|
||||
if (player) {
|
||||
setLocalName(player.name)
|
||||
}
|
||||
}, [player])
|
||||
|
||||
if (!player) {
|
||||
return null
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
updatePlayer(playerId, { name: tempName })
|
||||
onClose()
|
||||
const handleNameChange = (newName: string) => {
|
||||
setLocalName(newName)
|
||||
|
||||
// Debounce the update to avoid too many API calls
|
||||
if (debounceTimerRef.current) {
|
||||
clearTimeout(debounceTimerRef.current)
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
debounceTimerRef.current = setTimeout(() => {
|
||||
updatePlayer(playerId, { name: newName })
|
||||
setIsSaving(false)
|
||||
}, 500) // Wait 500ms after user stops typing
|
||||
}
|
||||
|
||||
const handleEmojiSelect = (emoji: string) => {
|
||||
@@ -30,7 +49,13 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
}
|
||||
|
||||
// Get player number for UI theming (first 4 players get special colors)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => a.createdAt - b.createdAt)
|
||||
const allPlayers = Array.from(players.values()).sort((a, b) => {
|
||||
const aTime = typeof a.createdAt === 'number' ? a.createdAt :
|
||||
a.createdAt instanceof Date ? a.createdAt.getTime() : 0
|
||||
const bTime = typeof b.createdAt === 'number' ? b.createdAt :
|
||||
b.createdAt instanceof Date ? b.createdAt.getTime() : 0
|
||||
return aTime - bTime
|
||||
})
|
||||
const playerIndex = allPlayers.findIndex((p) => p.id === playerId)
|
||||
const displayNumber = playerIndex + 1
|
||||
|
||||
@@ -81,22 +106,35 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0,
|
||||
}}
|
||||
>
|
||||
Configure Player
|
||||
</h2>
|
||||
<div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0,
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Player Settings
|
||||
</h2>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: isSaving ? '#f59e0b' : '#10b981',
|
||||
fontWeight: '500',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
{isSaving ? '💾 Saving...' : '✓ Changes saved automatically'}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
@@ -198,7 +236,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
</div>
|
||||
|
||||
{/* Name Input */}
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<div>
|
||||
<label
|
||||
style={{
|
||||
display: 'block',
|
||||
@@ -212,8 +250,8 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tempName}
|
||||
onChange={(e) => setTempName(e.target.value)}
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
@@ -243,69 +281,9 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
textAlign: 'right',
|
||||
}}
|
||||
>
|
||||
{tempName.length}/20 characters
|
||||
{localName.length}/20 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={onClose}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: 'white',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = '#f9fafb'
|
||||
e.currentTarget.style.borderColor = '#d1d5db'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '12px',
|
||||
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
|
||||
border: 'none',
|
||||
borderRadius: '12px',
|
||||
fontSize: '14px',
|
||||
fontWeight: '600',
|
||||
color: 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(-2px)'
|
||||
e.currentTarget.style.boxShadow = '0 6px 16px rgba(0,0,0,0.2)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'translateY(0)'
|
||||
e.currentTarget.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)'
|
||||
}}
|
||||
>
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -68,7 +68,7 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const { mutate: createPlayer } = useCreatePlayer()
|
||||
const { mutate: updatePlayerMutation } = useUpdatePlayer()
|
||||
const { mutate: deletePlayer } = useDeletePlayer()
|
||||
const { roomData } = useRoomData()
|
||||
const { roomData, notifyRoomOfPlayerUpdate } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
@@ -167,14 +167,27 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
isActive: playerData?.isActive ?? false,
|
||||
}
|
||||
|
||||
createPlayer(newPlayer)
|
||||
createPlayer(newPlayer, {
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const updatePlayer = (id: string, updates: Partial<Player>) => {
|
||||
const player = players.get(id)
|
||||
// Only allow updating local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates })
|
||||
updatePlayerMutation(
|
||||
{ id, updates },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot update remote player:', id)
|
||||
}
|
||||
@@ -184,7 +197,12 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const player = players.get(id)
|
||||
// Only allow removing local players
|
||||
if (player?.isLocal) {
|
||||
deletePlayer(id)
|
||||
deletePlayer(id, {
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
})
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot remove remote player:', id)
|
||||
}
|
||||
@@ -194,7 +212,15 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
const player = players.get(id)
|
||||
// Only allow changing active status of local players
|
||||
if (player?.isLocal) {
|
||||
updatePlayerMutation({ id, updates: { isActive: active } })
|
||||
updatePlayerMutation(
|
||||
{ id, updates: { isActive: active } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
// Notify room members if in a room
|
||||
notifyRoomOfPlayerUpdate()
|
||||
},
|
||||
}
|
||||
)
|
||||
} else {
|
||||
console.warn('[GameModeContext] Cannot change active status of remote player:', id)
|
||||
}
|
||||
@@ -227,6 +253,11 @@ export function GameModeProvider({ children }: { children: ReactNode }) {
|
||||
isActive: index === 0,
|
||||
})
|
||||
})
|
||||
|
||||
// Notify room members after reset (slight delay to ensure mutations complete)
|
||||
setTimeout(() => {
|
||||
notifyRoomOfPlayerUpdate()
|
||||
}, 100)
|
||||
}
|
||||
|
||||
const activePlayerCount = activePlayers.size
|
||||
|
||||
@@ -196,10 +196,19 @@ export function useRoomData() {
|
||||
}
|
||||
}, [socket, roomData?.id])
|
||||
|
||||
// Function to notify room members of player updates
|
||||
const notifyRoomOfPlayerUpdate = () => {
|
||||
if (socket && roomData?.id && userId) {
|
||||
console.log('[useRoomData] Notifying room of player update')
|
||||
socket.emit('players-updated', { roomId: roomData.id, userId })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
roomData,
|
||||
// Loading if: userId is pending, currently fetching, or have userId but haven't tried fetching yet
|
||||
isLoading: isUserIdPending || isLoading || (!!userId && !hasAttemptedFetch),
|
||||
isInRoom: !!roomData,
|
||||
notifyRoomOfPlayerUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "2.8.6",
|
||||
"version": "2.9.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user