Compare commits

...

20 Commits

Author SHA1 Message Date
semantic-release-bot
0543377bda chore(release): 2.10.0 [skip ci]
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)

### Features

* implement rich Radix UI tooltips for player avatars ([d03c789](d03c789879))
2025-10-09 12:57:22 +00:00
Thomas Hallock
d03c789879 feat: implement rich Radix UI tooltips for player avatars
Replace basic HTML title tooltips with Radix UI Tooltip components
that display comprehensive player information:

New PlayerTooltip component features:
- Player name with color accent dot
- Visual badge distinguishing local vs network players
- Player color indicator with glow effect
- "Joined X ago" timestamp (formatted as days/hours/minutes)
- For network players: shows controlling member name
- Glassmorphic dark design with backdrop blur
- Smooth fade-in animation

Updated components:
- ActivePlayersList: Wraps player avatars with PlayerTooltip
- NetworkPlayerIndicator: Shows "Controlled by [member]" in tooltip
- PageWithNav: Passes full player objects (including color, createdAt)
  instead of just {id, name, emoji}

Technical improvements:
- Added @radix-ui/react-tooltip dependency
- Removed all basic `title` attributes
- Type-safe player data passing through component tree
- Handles Date | number types for createdAt properly

The new tooltips provide much richer context for understanding
network opponents and collaborators in multiplayer games.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:56:22 -05:00
semantic-release-bot
15029ae52f chore(release): 2.9.0 [skip ci]
## [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](a83dc097e4))
2025-10-09 12:48:28 +00:00
Thomas Hallock
a83dc097e4 feat: implement auto-save for player settings modal
Convert PlayerConfigDialog from explicit save button to auto-save UX:
- Add debounced name updates (500ms delay after typing stops)
- Add visual save status indicator ("Saving..." / "Changes saved automatically")
- Remove "Cancel" and "Save Changes" buttons
- Change modal title to "Player Settings"

Fix type errors from previous commits:
- Add notifyRoomOfPlayerUpdate to all useRoomData test mocks
- Fix type predicate for player filtering in PageWithNav
- Fix createdAt comparison to handle Date | number type
- Remove autoFocus attribute (accessibility linting rule)

All player settings (name and emoji) now save automatically without
requiring an explicit save button, providing a smoother user experience.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:47:36 -05:00
semantic-release-bot
0abec1a3bb chore(release): 2.8.7 [skip ci]
## [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](5171be3d37))
2025-10-09 12:39:34 +00:00
Thomas Hallock
5171be3d37 fix: enable real-time player name updates across room members
When a player's name (or other properties) is updated, emit a
'players-updated' socket event to notify all room members. This triggers
the server to broadcast the updated player data via 'room-players-updated',
which all room members receive and use to update their local state.

Changes:
- Add notifyRoomOfPlayerUpdate() function to useRoomData hook
- Call it after successful player mutations (create, update, delete, setActive)
- This ensures all room members see player changes immediately without page reload

Fixes real-time synchronization of player names in the mini app nav.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:38:43 -05:00
semantic-release-bot
fc9eb253ad chore(release): 2.8.6 [skip ci]
## [2.8.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.5...v2.8.6) (2025-10-09)

### Bug Fixes

* prevent duplicate display of network avatars in nav ([d474ef0](d474ef07d6))
2025-10-09 12:27:57 +00:00
Thomas Hallock
d474ef07d6 fix: prevent duplicate display of network avatars in nav
Filter out remote players (isLocal: false) from active/inactive player
lists in PageWithNav to prevent them from appearing twice:
1. Once in the main player list (incorrect)
2. Once in the network players section (correct)

Now:
- Active/inactive player lists show only local players
- Network players section shows remote players separately

This provides a clear visual distinction between "your avatars" and
"network avatars" in the mini app game nav.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-09 07:27:03 -05:00
semantic-release-bot
3cdc0695f4 chore(release): 2.8.5 [skip ci]
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)

### Bug Fixes

* remove redirect loop by not redirecting from room page ([10cf715](10cf71527f))
2025-10-09 01:17:27 +00:00
Thomas Hallock
10cf71527f fix: remove redirect loop by not redirecting from room page
The infinite redirect loop was caused by:
1. /arcade/room redirecting to /arcade when roomData is null
2. /arcade (via useArcadeRedirect) redirecting back to /arcade/room for active session
3. Loop repeats

Fix: Remove the redirect from room page entirely. Instead:
- Show loading state while fetching roomData
- Show error message with link if no room found (no automatic redirect)
- Let useArcadeRedirect on /arcade handle active session redirects

This prevents the redirect conflict and allows proper navigation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 20:16:27 -05:00
semantic-release-bot
678f4423b6 chore(release): 2.8.4 [skip ci]
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)

### Bug Fixes

* prevent redirect loops by checking if already at target URL ([c5268b7](c5268b79de))
2025-10-09 01:11:33 +00:00
Thomas Hallock
c5268b79de fix: prevent redirect loops by checking if already at target URL
Both useArcadeRedirect and useArcadeGuard were causing infinite loops:
1. User navigates to /arcade/room
2. Room page redirects to /arcade (if roomData null during loading)
3. /arcade sees active session at /arcade/room, redirects back
4. Loop repeats

Fix: Check if pathname already matches target URL before redirecting.
If already at target, skip the redirect and log that we're already there.

This fixes the infinite redirect loop when navigating to /arcade/room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 20:10:40 -05:00
semantic-release-bot
d9aadd1f81 chore(release): 2.8.3 [skip ci]
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)

### Bug Fixes

* remove ArcadeGuardedPage from room page to prevent redirect loop ([4686f59](4686f59d24))
2025-10-08 19:25:16 +00:00
Thomas Hallock
4686f59d24 fix: remove ArcadeGuardedPage from room page to prevent redirect loop
ArcadeGuardedPage's redirect logic was conflicting with the room page's
own redirect logic, causing an infinite loop:
1. Room page would redirect to /arcade if no roomData
2. /arcade would see active session and redirect back to /arcade/room
3. Loop repeats

Room-based games don't need ArcadeGuardedPage because:
- They have their own navigation logic via useRoomData
- Arcade sessions are created dynamically when starting games in rooms
- We don't want to redirect away from the room page

This fixes the infinite redirect loop when navigating to /arcade/room.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 14:24:29 -05:00
semantic-release-bot
1219539585 chore(release): 2.8.2 [skip ci]
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.1...v2.8.2) (2025-10-08)

### Bug Fixes

* revert to showing only active players in room games ([87cc0b6](87cc0b64fb))
2025-10-08 16:55:40 +00:00
Thomas Hallock
87cc0b64fb fix: revert to showing only active players in room games
Reverted getRoomActivePlayers() to use getActivePlayers() instead of
getAllPlayers(). Room games should show only players marked isActive=true
from each room member, not all players regardless of status.

Behavior:
- Room mode: Active players from all room members
- Solo mode: Active players from current user

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:54:43 -05:00
semantic-release-bot
c640a79a44 chore(release): 2.8.1 [skip ci]
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.0...v2.8.1) (2025-10-08)

### Bug Fixes

* include all players from room members in room games ([28a2e7d](28a2e7d651))
2025-10-08 16:41:59 +00:00
Thomas Hallock
0d85331652 chore: remove debug logging after fixing player sync issue
Clean up verbose console.log statements that were added to diagnose
the player synchronization issue. The root cause has been identified
and fixed in player-manager.ts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:41:02 -05:00
Thomas Hallock
28a2e7d651 fix: include all players from room members in room games
Change getRoomActivePlayers() to fetch ALL players from room members,
not just those marked with isActive=true. In room mode, the concept of
"active" means "all players from all room members participate", not
"players individually marked as active".

This fixes the issue where users with no active players (activeCount: 0)
would have empty player arrays in memberPlayers, causing inconsistent
player lists across room members.

Changes:
- Add getAllPlayers(viewerId) function (no isActive filter)
- Update getRoomActivePlayers() to use getAllPlayers()
- Keep getActivePlayers() for solo mode (with isActive filter)
- Update comments to clarify room vs solo mode behavior

Note: Committing despite pre-existing TypeScript errors in unrelated
files (@soroban/abacus-react imports, tutorial components, tests).
player-manager.ts changes are type-safe and don't introduce new errors.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:40:15 -05:00
Thomas Hallock
a27c36193e debug: add logging to diagnose player sync issue
Add console logging to track:
- When GameModeContext computes activePlayers from room data
- Which players are being added to the active set
- What activePlayers are sent when starting the game

This will help diagnose why different room members see different
player sets when the game starts.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-08 11:22:27 -05:00
17 changed files with 1072 additions and 294 deletions

View File

@@ -1,3 +1,66 @@
## [2.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.9.0...v2.10.0) (2025-10-09)
### Features
* implement rich Radix UI tooltips for player avatars ([d03c789](https://github.com/antialias/soroban-abacus-flashcards/commit/d03c7898799b378f912f47d7267a00bc7ce3d580))
## [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)
### Bug Fixes
* prevent duplicate display of network avatars in nav ([d474ef0](https://github.com/antialias/soroban-abacus-flashcards/commit/d474ef07d69cf0b4f5dedd404616e3bbee7289fe))
## [2.8.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.4...v2.8.5) (2025-10-09)
### Bug Fixes
* remove redirect loop by not redirecting from room page ([10cf715](https://github.com/antialias/soroban-abacus-flashcards/commit/10cf71527f7cede7fd93e502dbfc59df99b5a524))
## [2.8.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.3...v2.8.4) (2025-10-09)
### Bug Fixes
* prevent redirect loops by checking if already at target URL ([c5268b7](https://github.com/antialias/soroban-abacus-flashcards/commit/c5268b79dee66aa02e14e2024fe1c6242a172ed3))
## [2.8.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.2...v2.8.3) (2025-10-08)
### Bug Fixes
* remove ArcadeGuardedPage from room page to prevent redirect loop ([4686f59](https://github.com/antialias/soroban-abacus-flashcards/commit/4686f59d245b2b502dc0764c223a5ce84bf1af44))
## [2.8.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.1...v2.8.2) (2025-10-08)
### Bug Fixes
* revert to showing only active players in room games ([87cc0b6](https://github.com/antialias/soroban-abacus-flashcards/commit/87cc0b64fb5f3debaf1d2f122aecfefc62922fed))
## [2.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.8.0...v2.8.1) (2025-10-08)
### Bug Fixes
* include all players from room members in room games ([28a2e7d](https://github.com/antialias/soroban-abacus-flashcards/commit/28a2e7d6511e70b83adf7d0465789a91026bc1f7))
## [2.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.7.4...v2.8.0) (2025-10-08)

View File

@@ -1,6 +1,6 @@
{
"permissions": {
"allow": ["Bash(npm test:*)"],
"allow": ["Bash(npm test:*)", "Read(//Users/antialias/projects/**)"],
"deny": [],
"ask": []
}

445
apps/web/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,445 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@soroban/abacus-react':
specifier: workspace:*
version: link:../../packages/abacus-react
'@soroban/client':
specifier: workspace:*
version: link:../../packages/core/client/typescript
'@soroban/core':
specifier: workspace:*
version: link:../../packages/core/client/node
'@soroban/templates':
specifier: workspace:*
version: link:../../packages/templates
react:
specifier: ^18.2.0
version: 18.3.1
react-dom:
specifier: ^18.2.0
version: 18.3.1(react@18.3.1)
packages:
'@floating-ui/core@1.7.3':
resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==}
'@floating-ui/dom@1.7.4':
resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==}
'@floating-ui/react-dom@2.1.6':
resolution: {integrity: sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw==}
peerDependencies:
react: '>=16.8.0'
react-dom: '>=16.8.0'
'@floating-ui/utils@0.2.10':
resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==}
'@radix-ui/primitive@1.1.3':
resolution: {integrity: sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==}
'@radix-ui/react-arrow@1.1.7':
resolution: {integrity: sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-context@1.1.2':
resolution: {integrity: sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-dismissable-layer@1.1.11':
resolution: {integrity: sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-id@1.1.1':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-popper@1.2.8':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-portal@1.1.9':
resolution: {integrity: sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-presence@1.1.5':
resolution: {integrity: sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-primitive@2.1.3':
resolution: {integrity: sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-slot@1.2.3':
resolution: {integrity: sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/react-use-callback-ref@1.1.1':
resolution: {integrity: sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-controllable-state@1.2.2':
resolution: {integrity: sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-effect-event@0.0.2':
resolution: {integrity: sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-escape-keydown@1.1.1':
resolution: {integrity: sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-layout-effect@1.1.1':
resolution: {integrity: sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-rect@1.1.1':
resolution: {integrity: sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-use-size@1.1.1':
resolution: {integrity: sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-visually-hidden@1.2.3':
resolution: {integrity: sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
loose-envify@1.4.0:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true
react-dom@18.3.1:
resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==}
peerDependencies:
react: ^18.3.1
react@18.3.1:
resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==}
engines: {node: '>=0.10.0'}
scheduler@0.23.2:
resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==}
snapshots:
'@floating-ui/core@1.7.3':
dependencies:
'@floating-ui/utils': 0.2.10
'@floating-ui/dom@1.7.4':
dependencies:
'@floating-ui/core': 1.7.3
'@floating-ui/utils': 0.2.10
'@floating-ui/react-dom@2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/dom': 1.7.4
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@floating-ui/utils@0.2.10': {}
'@radix-ui/primitive@1.1.3': {}
'@radix-ui/react-arrow@1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-compose-refs@1.1.2(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-context@1.1.2(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-dismissable-layer@1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
'@radix-ui/react-use-escape-keydown': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-id@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-popper@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@floating-ui/react-dom': 2.1.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-arrow': 1.1.7(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-context': 1.1.2(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
'@radix-ui/react-use-rect': 1.1.1(react@18.3.1)
'@radix-ui/react-use-size': 1.1.1(react@18.3.1)
'@radix-ui/rect': 1.1.1
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-portal@1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-presence@1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-primitive@2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-slot@1.2.3(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
react: 18.3.1
'@radix-ui/react-tooltip@1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-compose-refs': 1.1.2(react@18.3.1)
'@radix-ui/react-context': 1.1.2(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.1.11(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-id': 1.1.1(react@18.3.1)
'@radix-ui/react-popper': 1.2.8(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-portal': 1.1.9(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-presence': 1.1.5(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@radix-ui/react-slot': 1.2.3(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.2.2(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/react-use-callback-ref@1.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-use-controllable-state@1.2.2(react@18.3.1)':
dependencies:
'@radix-ui/react-use-effect-event': 0.0.2(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-effect-event@0.0.2(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-escape-keydown@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-callback-ref': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-use-layout-effect@1.1.1(react@18.3.1)':
dependencies:
react: 18.3.1
'@radix-ui/react-use-rect@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/rect': 1.1.1
react: 18.3.1
'@radix-ui/react-use-size@1.1.1(react@18.3.1)':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(react@18.3.1)
react: 18.3.1
'@radix-ui/react-visually-hidden@1.2.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
dependencies:
'@radix-ui/react-primitive': 2.1.3(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
'@radix-ui/rect@1.1.1': {}
js-tokens@4.0.0: {}
loose-envify@1.4.0:
dependencies:
js-tokens: 4.0.0
react-dom@18.3.1(react@18.3.1):
dependencies:
loose-envify: 1.4.0
react: 18.3.1
scheduler: 0.23.2
react@18.3.1:
dependencies:
loose-envify: 1.4.0
scheduler@0.23.2:
dependencies:
loose-envify: 1.4.0

View File

@@ -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({

View File

@@ -198,7 +198,7 @@ export function ArcadeMemoryPairsProvider({ children }: { children: ReactNode })
activePlayers,
},
})
}, [state.gameType, state.difficulty, activePlayers, sendMove])
}, [state.gameType, state.difficulty, activePlayers, sendMove, roomData])
const flipCard = useCallback(
(cardId: string) => {

View File

@@ -1,8 +1,5 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
import { ArcadeGuardedPage } from '@/components/ArcadeGuardedPage'
import { useRoomData } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairsContext'
@@ -10,18 +7,14 @@ import { ArcadeMemoryPairsProvider } from '../matching/context/ArcadeMemoryPairs
/**
* /arcade/room - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Note: We don't redirect to /arcade if no room exists because:
* - It would conflict with arcade session redirects and create loops
* - useArcadeRedirect on /arcade page handles redirecting to active sessions
*/
export default function RoomPage() {
const router = useRouter()
const { roomData, isLoading } = useRoomData()
// Redirect to arcade if no room
useEffect(() => {
if (!isLoading && !roomData) {
router.push('/arcade')
}
}, [isLoading, roomData, router])
// Show loading state
if (isLoading) {
return (
@@ -40,20 +33,44 @@ export default function RoomPage() {
)
}
// Show nothing while redirecting
// Show error if no room (instead of redirecting)
if (!roomData) {
return null
return (
<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={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
)
}
// Render the appropriate game based on room's gameName
// Note: We don't use ArcadeGuardedPage here because room-based games
// have their own navigation logic via useRoomData
switch (roomData.gameName) {
case 'matching':
return (
<ArcadeGuardedPage>
<ArcadeMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
</ArcadeGuardedPage>
<ArcadeMemoryPairsProvider>
<MemoryPairsGame />
</ArcadeMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)

View File

@@ -58,14 +58,14 @@ export function PageWithNav({
}
// Get active and inactive players as arrays
// 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)
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
.filter((p): p is NonNullable<typeof p> => p !== undefined && p.isLocal !== false) // Filter out remote players
const inactivePlayerList = Array.from(players.values())
.filter((p) => !activePlayers.has(p.id))
.map((p) => ({ id: p.id, name: p.name, emoji: p.emoji }))
const inactivePlayerList = Array.from(players.values()).filter(
(p) => !activePlayers.has(p.id) && p.isLocal !== false
) // Filter out remote players
// Compute game mode from active player count
const gameMode =
@@ -98,7 +98,13 @@ export function PageWithNav({
: undefined
// Compute network players (other players in the room, excluding current user)
const networkPlayers: Array<{ id: string; emoji?: string; name?: string }> =
const networkPlayers: Array<{
id: string
emoji?: string
name?: string
color?: string
memberName?: string
}> =
isInRoom && roomData
? roomData.members
.filter((member) => member.userId !== viewerId)
@@ -107,7 +113,9 @@ export function PageWithNav({
return memberPlayerList.map((player) => ({
id: player.id,
emoji: player.emoji,
name: `${player.name} (${member.displayName})`,
name: player.name,
color: player.color,
memberName: member.displayName,
}))
})
: []

View File

@@ -1,9 +1,13 @@
import React from 'react'
import { PlayerTooltip } from './PlayerTooltip'
interface Player {
id: string
name: string
emoji: string
color?: string
createdAt?: Date | number
isLocal?: boolean
}
interface ActivePlayersListProps {
@@ -24,105 +28,111 @@ export function ActivePlayersList({
return (
<>
{activePlayers.map((player) => (
<div
<PlayerTooltip
key={player.id}
style={{
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
cursor: shouldEmphasize ? 'pointer' : 'default',
}}
title={player.name}
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
playerName={player.name}
playerColor={player.color}
isLocal={player.isLocal !== false}
createdAt={player.createdAt}
>
{player.emoji}
{shouldEmphasize && hoveredPlayerId === player.id && (
<>
{/* Configure button - bottom left */}
<button
onClick={(e) => {
e.stopPropagation()
onConfigurePlayer(player.id)
}}
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '18px',
height: '18px',
borderRadius: '50%',
border: '2px solid white',
background: '#6b7280',
color: 'white',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#3b82f6'
e.currentTarget.style.transform = 'scale(1.15)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6b7280'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Configure ${player.name}`}
>
</button>
<div
style={{
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'font-size 0.4s cubic-bezier(0.4, 0, 0.2, 1), filter 0.4s ease',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
cursor: shouldEmphasize ? 'pointer' : 'default',
}}
onClick={() => shouldEmphasize && onConfigurePlayer(player.id)}
onMouseEnter={() => shouldEmphasize && setHoveredPlayerId(player.id)}
onMouseLeave={() => shouldEmphasize && setHoveredPlayerId(null)}
>
{player.emoji}
{shouldEmphasize && hoveredPlayerId === player.id && (
<>
{/* Configure button - bottom left */}
<button
onClick={(e) => {
e.stopPropagation()
onConfigurePlayer(player.id)
}}
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '18px',
height: '18px',
borderRadius: '50%',
border: '2px solid white',
background: '#6b7280',
color: 'white',
fontSize: '10px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#3b82f6'
e.currentTarget.style.transform = 'scale(1.15)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#6b7280'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Configure ${player.name}`}
>
</button>
{/* Remove button - top right */}
<button
onClick={(e) => {
e.stopPropagation()
onRemovePlayer(player.id)
}}
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '20px',
height: '20px',
borderRadius: '50%',
border: '2px solid white',
background: '#ef4444',
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626'
e.currentTarget.style.transform = 'scale(1.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ef4444'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Remove ${player.name}`}
>
×
</button>
</>
)}
</div>
{/* Remove button - top right */}
<button
onClick={(e) => {
e.stopPropagation()
onRemovePlayer(player.id)
}}
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
width: '20px',
height: '20px',
borderRadius: '50%',
border: '2px solid white',
background: '#ef4444',
color: 'white',
fontSize: '12px',
fontWeight: 'bold',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
transition: 'all 0.2s ease',
padding: 0,
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#dc2626'
e.currentTarget.style.transform = 'scale(1.1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ef4444'
e.currentTarget.style.transform = 'scale(1)'
}}
aria-label={`Remove ${player.name}`}
>
×
</button>
</>
)}
</div>
</PlayerTooltip>
))}
</>
)

View File

@@ -1,9 +1,12 @@
import React from 'react'
import { PlayerTooltip } from './PlayerTooltip'
interface NetworkPlayer {
id: string
emoji?: string
name?: string
color?: string
memberName?: string
}
interface NetworkPlayerIndicatorProps {
@@ -17,92 +20,97 @@ interface NetworkPlayerIndicatorProps {
*/
export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlayerIndicatorProps) {
const [isHovered, setIsHovered] = React.useState(false)
const playerName = player.name || `Network Player ${player.id.slice(0, 8)}`
const extraInfo = player.memberName ? `Controlled by ${player.memberName}` : undefined
return (
<div
style={{
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'default',
}}
title={player.name || `Network Player ${player.id.slice(0, 8)}`}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
<PlayerTooltip
playerName={playerName}
playerColor={player.color}
isLocal={false}
extraInfo={extraInfo}
>
{/* Network frame border */}
<div
style={{
position: 'absolute',
inset: '-6px',
borderRadius: '8px',
background: `
position: 'relative',
fontSize: shouldEmphasize ? '48px' : '20px',
lineHeight: 1,
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'default',
}}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Network frame border */}
<div
style={{
position: 'absolute',
inset: '-6px',
borderRadius: '8px',
background: `
linear-gradient(135deg,
rgba(59, 130, 246, 0.4),
rgba(147, 51, 234, 0.4),
rgba(236, 72, 153, 0.4))
`,
opacity: isHovered ? 1 : 0.7,
transition: 'opacity 0.2s ease',
zIndex: -1,
}}
/>
opacity: isHovered ? 1 : 0.7,
transition: 'opacity 0.2s ease',
zIndex: -1,
}}
/>
{/* Animated network signal indicator */}
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
width: '12px',
height: '12px',
borderRadius: '50%',
background: 'rgba(34, 197, 94, 0.9)',
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
animation: 'networkPulse 2s ease-in-out infinite',
zIndex: 1,
}}
title="Connected"
/>
{/* Animated network signal indicator */}
<div
style={{
position: 'absolute',
top: '-8px',
right: '-8px',
width: '12px',
height: '12px',
borderRadius: '50%',
background: 'rgba(34, 197, 94, 0.9)',
boxShadow: '0 0 8px rgba(34, 197, 94, 0.6)',
animation: 'networkPulse 2s ease-in-out infinite',
zIndex: 1,
}}
/>
{/* Player emoji or fallback */}
<div
style={{
position: 'relative',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
}}
>
{player.emoji || '🌐'}
</div>
{/* Player emoji or fallback */}
<div
style={{
position: 'relative',
filter: shouldEmphasize ? 'drop-shadow(0 4px 8px rgba(0,0,0,0.25))' : 'none',
}}
>
{player.emoji || '🌐'}
</div>
{/* Network icon badge */}
<div
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '16px',
height: '16px',
borderRadius: '50%',
border: '2px solid white',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
fontSize: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
zIndex: 1,
}}
title="Network Player"
>
📡
</div>
{/* Network icon badge */}
<div
style={{
position: 'absolute',
bottom: '-4px',
left: '-4px',
width: '16px',
height: '16px',
borderRadius: '50%',
border: '2px solid white',
background: 'linear-gradient(135deg, #3b82f6, #8b5cf6)',
color: 'white',
fontSize: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: '0 2px 6px rgba(0,0,0,0.3)',
zIndex: 1,
}}
>
📡
</div>
<style
dangerouslySetInnerHTML={{
__html: `
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes networkPulse {
0%, 100% {
opacity: 1;
@@ -114,8 +122,9 @@ export function NetworkPlayerIndicator({ player, shouldEmphasize }: NetworkPlaye
}
}
`,
}}
/>
</div>
}}
/>
</div>
</PlayerTooltip>
)
}

View File

@@ -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,21 @@ 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 +114,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 +244,7 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
</div>
{/* Name Input */}
<div style={{ marginBottom: '24px' }}>
<div>
<label
style={{
display: 'block',
@@ -212,8 +258,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 +289,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>
)

View File

@@ -0,0 +1,164 @@
import * as Tooltip from '@radix-ui/react-tooltip'
import type React from 'react'
interface PlayerTooltipProps {
children: React.ReactNode
playerName: string
playerColor?: string
isLocal?: boolean
createdAt?: Date | number
extraInfo?: string
}
/**
* Radix-based tooltip for displaying rich player information
* Shows player name, type (local/network), color, and other details
*/
export function PlayerTooltip({
children,
playerName,
playerColor,
isLocal = true,
createdAt,
extraInfo,
}: PlayerTooltipProps) {
// Format creation time
const getCreatedTimeAgo = () => {
if (!createdAt) return null
const now = Date.now()
const created =
typeof createdAt === 'number'
? createdAt
: createdAt instanceof Date
? createdAt.getTime()
: 0
const diff = now - created
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (days > 0) return `${days}d ago`
if (hours > 0) return `${hours}h ago`
if (minutes > 0) return `${minutes}m ago`
return 'just now'
}
return (
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content
side="bottom"
sideOffset={8}
style={{
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.97), rgba(31, 41, 55, 0.97))',
backdropFilter: 'blur(8px)',
borderRadius: '12px',
padding: '12px 16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(255, 255, 255, 0.1)',
maxWidth: '280px',
zIndex: 9999,
animation: 'tooltipFadeIn 0.2s ease-out',
}}
>
{/* Player name with color accent */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '8px',
}}
>
{playerColor && (
<div
style={{
width: '12px',
height: '12px',
borderRadius: '50%',
background: playerColor,
boxShadow: `0 0 8px ${playerColor}50`,
flexShrink: 0,
}}
/>
)}
<div
style={{
fontSize: '15px',
fontWeight: '600',
color: 'white',
lineHeight: 1.3,
}}
>
{playerName}
</div>
</div>
{/* Player type badge */}
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '4px',
padding: '4px 8px',
borderRadius: '6px',
background: isLocal
? 'rgba(16, 185, 129, 0.15)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.15), rgba(147, 51, 234, 0.15))',
border: `1px solid ${isLocal ? 'rgba(16, 185, 129, 0.3)' : 'rgba(147, 51, 234, 0.3)'}`,
fontSize: '11px',
fontWeight: '600',
color: isLocal ? 'rgba(167, 243, 208, 1)' : 'rgba(196, 181, 253, 1)',
marginBottom: extraInfo || createdAt ? '8px' : 0,
}}
>
<span style={{ fontSize: '10px' }}>{isLocal ? '●' : '📡'}</span>
{isLocal ? 'Your Player' : 'Network Player'}
</div>
{/* Additional info */}
{(extraInfo || createdAt) && (
<div
style={{
fontSize: '12px',
color: 'rgba(209, 213, 219, 0.9)',
lineHeight: 1.4,
marginTop: '4px',
}}
>
{extraInfo && <div>{extraInfo}</div>}
{createdAt && <div style={{ opacity: 0.7 }}>Joined {getCreatedTimeAgo()}</div>}
</div>
)}
<Tooltip.Arrow
style={{
fill: 'rgba(17, 24, 39, 0.97)',
}}
/>
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`,
}}
/>
</Tooltip.Provider>
)
}

View File

@@ -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

View File

@@ -74,10 +74,13 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
})
// Redirect if we're not already on the active game page (only if enabled)
if (enabled && pathname !== data.gameUrl) {
const isAlreadyAtTarget = pathname === data.gameUrl
if (enabled && !isAlreadyAtTarget) {
console.log('[ArcadeGuard] Redirecting to active session:', data.gameUrl)
onRedirect?.(data.gameUrl)
router.push(data.gameUrl)
} else if (isAlreadyAtTarget) {
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
}
},
@@ -127,10 +130,13 @@ export function useArcadeGuard(options: UseArcadeGuardOptions = {}): UseArcadeGu
})
// Redirect if we're not already on the active game page (only if enabled)
if (enabled && pathname !== session.gameUrl) {
const isAlreadyAtTarget = pathname === session.gameUrl
if (enabled && !isAlreadyAtTarget) {
console.log('[ArcadeGuard] Redirecting to active session:', session.gameUrl)
onRedirect?.(session.gameUrl)
router.push(session.gameUrl)
} else if (isAlreadyAtTarget) {
console.log('[ArcadeGuard] Already at target URL, no redirect needed')
}
} else if (response.status === 404) {
// No active session

View File

@@ -71,10 +71,13 @@ export function useArcadeRedirect(options: UseArcadeRedirectOptions = {}): UseAr
// Determine if we need to redirect
const isArcadeLobby = currentGame === null || currentGame === undefined
const isWrongGame = currentGame && currentGame !== data.currentGame
const isAlreadyAtTarget = _pathname === data.gameUrl
if (isArcadeLobby || isWrongGame) {
if ((isArcadeLobby || isWrongGame) && !isAlreadyAtTarget) {
console.log('[ArcadeRedirect] Redirecting to active game:', data.gameUrl)
router.push(data.gameUrl)
} else if (isAlreadyAtTarget) {
console.log('[ArcadeRedirect] Already at target URL, no redirect needed')
}
},

View File

@@ -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,
}
}

View File

@@ -8,8 +8,29 @@ import { db, schema } from '@/db'
import type { Player } from '@/db/schema/players'
/**
* Get a user's active players
* These are the players that will participate when the user joins a game
* Get all players for a user (regardless of isActive status)
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
*/
export async function getAllPlayers(viewerId: string): Promise<Player[]> {
// First get the user record by guestId
const user = await db.query.users.findFirst({
where: eq(schema.users.guestId, viewerId),
})
if (!user) {
return []
}
// Now query all players by the actual user.id (no isActive filter)
return await db.query.players.findMany({
where: eq(schema.players.userId, user.id),
orderBy: schema.players.createdAt,
})
}
/**
* Get a user's active players (solo mode)
* These are the players that will participate when the user joins a solo game
* @param viewerId - The guestId from the cookie (same as what getViewerId() returns)
*/
export async function getActivePlayers(viewerId: string): Promise<Player[]> {
@@ -30,7 +51,8 @@ export async function getActivePlayers(viewerId: string): Promise<Player[]> {
}
/**
* Get all active players for all members in a room
* Get active players for all members in a room
* Returns only players marked isActive=true from each room member
* Returns a map of userId -> Player[]
*/
export async function getRoomActivePlayers(roomId: string): Promise<Map<string, Player[]>> {
@@ -39,7 +61,7 @@ export async function getRoomActivePlayers(roomId: string): Promise<Map<string,
where: eq(schema.roomMembers.roomId, roomId),
})
// Fetch active players for each member
// Fetch active players for each member (respects isActive flag)
const playerMap = new Map<string, Player[]>()
for (const member of members) {
const players = await getActivePlayers(member.userId)

View File

@@ -1,6 +1,6 @@
{
"name": "soroban-monorepo",
"version": "2.8.0",
"version": "2.10.0",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [