Compare commits

...

23 Commits

Author SHA1 Message Date
semantic-release-bot
f5469cda0c chore(release): 3.13.0 [skip ci]
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)

### Features

* **moderation:** add inline feedback and persistent password display ([86e3d41](86e3d41996))
2025-10-14 13:53:19 +00:00
Thomas Hallock
86e3d41996 feat(moderation): add inline feedback and persistent password display
- Add success/error message UI component to ModerationPanel
- Replace all browser alert() calls with inline React-based feedback
- Add displayPassword field to arcade_rooms schema for plain text storage
- Create migration to add display_password column
- Update settings PATCH route to store both hashed and display passwords
- Update room GET route to return displayPassword only to room creator
- Update ModerationPanel to populate password field when loading settings
- Fix room-manager test to include displayPassword field

Password field now persists and displays correctly when reloading the page
for room owners in password-protected rooms.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:52:19 -05:00
semantic-release-bot
cb11bec975 chore(release): 3.12.0 [skip ci]
## [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](2580e474d0))
2025-10-14 13:40:37 +00:00
Thomas Hallock
2580e474d0 feat(moderation): improve password input with copy button
Enhances the password-protected room settings UX:

Changes:
1. Password input now stays visible and editable
   - Plain text input (not hidden) for easy viewing
   - Focus state with orange border
   - Clear placeholder text

2. Copy button next to password input
   - 📋 Copy icon with text
   - Visual feedback: changes to "✓ Copied!" for 2 seconds
   - Disabled state when no password entered
   - Green success color after copying

3. Better labeling and hints
   - "Room Password" label above input
   - Helper text: "Share this password with guests to allow them to join"
   - More descriptive placeholder

Note: Passwords are hashed in the database for security, so existing
passwords cannot be retrieved. This UI is for setting/changing passwords
and easily copying them to share with guests.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:39:42 -05:00
semantic-release-bot
55e0be8e42 chore(release): 3.11.1 [skip ci]
## [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](dd9e657db8))
2025-10-14 13:36:29 +00:00
Thomas Hallock
dd9e657db8 fix(moderation): improve access mode settings UX
Enhances the room moderation settings UX to prevent accidental closure
with unsaved changes:

Changes:
1. "Update Access Mode" button now only appears when there are changes
   - Tracks original access mode on load
   - Compares current selection to detect changes
   - Button hidden when no changes made

2. "Close" button disabled when there are unsaved access mode changes
   - Prevents accidentally losing changes
   - Visual feedback: dimmed appearance, orange border
   - Hover state brightens orange border to draw attention

3. Tooltip on disabled "Close" button
   - Shows "Please update access mode settings before closing"
   - Helps users understand why close is disabled

This prevents the frustrating UX issue where users want to close but
are forced to update settings they didn't intend to change.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:35:36 -05:00
semantic-release-bot
51d9a37f9b chore(release): 3.11.0 [skip ci]
## [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](07212e4df0))

### Code Refactoring

* make player names abacus and arithmetic themed ([97daad9](97daad9abb))
2025-10-14 13:31:03 +00:00
Thomas Hallock
07212e4df0 feat: add name generator button and abacus emoji
Adds two enhancements to player customization:

1. Name generator button in PlayerConfigDialog
   - Dice emoji (🎲) button next to name input
   - Generates new themed names on click
   - Excludes current player from collision check
   - Maintains auto-save behavior

2. Abacus emoji option
   - Added 🧮 (abacus) as first emoji choice
   - Perfect thematic fit for the application

Now players can easily try different generated names without leaving
the settings dialog.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:30:14 -05:00
Thomas Hallock
97daad9abb refactor: make player names abacus and arithmetic themed
Replaces generic fantasy/gaming words with abacus and math-themed vocabulary.

Examples of new names:
- "Ancient Abacist", "Sliding Counter", "Soroban Master"
- "Calculating Mathematician", "Rapid Solver", "Precise Adder"
- "Bamboo Scholar", "Golden Merchant", "Mental Genius"

Changes:
- 25 abacus-specific adjectives (Ancient, Wooden, Soroban, etc.)
- 25 arithmetic adjectives (Adding, Calculating, Prime, etc.)
- 25 abacus-specific nouns (Counter, Abacist, Bead, Rod, etc.)
- 25 arithmetic nouns (Mathematician, Solver, Adder, etc.)
- Still maintains 2,500 unique combinations
- All tests pass with new vocabulary

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:29:13 -05:00
semantic-release-bot
225104c3a7 chore(release): 3.10.0 [skip ci]
## [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](249257c6c7))
2025-10-14 13:26:03 +00:00
Thomas Hallock
249257c6c7 feat: add fun automatic player naming system
Implements automatic generation of creative player names combining
adjectives with nouns/roles (e.g., "Swift Ninja", "Cosmic Wizard").

Changes:
- Created playerNames utility with 50 adjectives and 50 nouns
- Generates unique names with collision detection
- Applied to default player creation and addPlayer function
- Replaces generic "Player 1", "Player 2" with fun names
- Manual override still available via PlayerConfigDialog
- Added comprehensive unit tests (10 passing tests)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:25:05 -05:00
semantic-release-bot
b37e29e53e chore(release): 3.9.2 [skip ci]
## [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](c6886a0e59))
2025-10-14 13:19:07 +00:00
Thomas Hallock
c6886a0e59 fix: remove duplicate ModerationNotifications causing double toasts
**Root Cause:**
- ModerationNotifications was rendered in BOTH /arcade/room/page.tsx AND PageWithNav
- Both had separate useRoomData hooks, creating two socket listeners
- When join-request-submitted event fired, BOTH instances showed a toast
- Clicking "Approve" only closed one toast, leaving the other visible

**Fix:**
- Removed ModerationNotifications from room page entirely
- PageWithNav (inside MemoryPairsGame) already handles all moderation events
- Now only ONE instance listens and renders, so approvals properly dismiss

**Files Changed:**
- Removed ModerationNotifications import from room page
- Removed all 4 instances of <ModerationNotifications /> from room page
- Removed moderationEvent and clearModerationEvent from useRoomData destructuring
- Added comment explaining PageWithNav handles notifications

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:18:12 -05:00
semantic-release-bot
cb2fec1da5 chore(release): 3.9.1 [skip ci]
## [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](6beb58a7b8))
2025-10-14 13:14:31 +00:00
Thomas Hallock
6beb58a7b8 fix: reset join request toast state when moderation event cleared
**Issue:**
Toast notification was persisting after successful approval because
showJoinRequestToast state was never reset to false.

**Fix:**
- Add else clause to useEffect that resets toast state when event cleared
- Reset both showJoinRequestToast and requestError when event changes type
- Ensures toast properly dismisses after approval/denial

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:13:40 -05:00
semantic-release-bot
544b06e290 chore(release): 3.9.0 [skip ci]
## [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](a7c3c1f4cd))
2025-10-14 13:13:04 +00:00
Thomas Hallock
a7c3c1f4cd feat: prevent invitations to retired rooms
- Add room access mode check in invite POST endpoint
- Block invitation creation if room is retired (403 status)
- Clear error message: "Cannot send invitations to retired rooms"
- Check happens before host validation to catch retired rooms early

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:12:10 -05:00
semantic-release-bot
090d4dac2b chore(release): 3.8.1 [skip ci]
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)

### Bug Fixes

* improve kicked modal message for retired room ejections ([f865ce1](f865ce16ec))
2025-10-14 13:10:33 +00:00
Thomas Hallock
f865ce16ec fix: improve kicked modal message for retired room ejections
**Socket handler update:**
- Capture reason field from kicked-from-room socket event

**Modal UI improvements:**
- Detect when reason contains "retired"
- Show 🏁 emoji instead of ⚠️ for retired rooms
- Title changes from "Kicked from Room" to "Room Retired"
- Message explains room owner retired the room and access is closed
- Clarify only room owner can access retired rooms

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:09:41 -05:00
semantic-release-bot
50f45ab08e chore(release): 3.8.0 [skip ci]
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)

### Features

* implement proper retired room behavior with member expulsion ([a2d5368](a2d53680f2))
2025-10-14 13:08:48 +00:00
Thomas Hallock
a2d53680f2 feat: implement proper retired room behavior with member expulsion
**Join endpoint changes:**
- Only room creator can access retired rooms
- All other users blocked with 410 status and clear message

**Settings endpoint changes:**
- When room set to 'retired', all non-owner members immediately expelled
- Expelled members removed from database
- Each expulsion recorded in member history
- Socket notifications sent to all expelled members (kicked-from-room event)
- Owner notified of member expulsions via member-left event

**Member expulsion flow:**
- Similar pattern to ban ejection
- Expelled members see "kicked from room" modal with reason "Room has been retired"
- All expelled members logged in history with 'left' action

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:07:49 -05:00
semantic-release-bot
b9e7267f15 chore(release): 3.7.1 [skip ci]
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)

### Bug Fixes

* improve join request approval error handling with actionable messages ([57bf846](57bf8460c8))
2025-10-14 13:03:49 +00:00
Thomas Hallock
57bf8460c8 fix: improve join request approval error handling with actionable messages
- Add requestError state to track approval/deny errors
- Toast dismisses only on successful approval/deny
- On error, toast remains visible and displays error message inline
- Parse API error response to show meaningful error messages
- User can retry approval/deny action after error
- Replace generic alert() with styled error message within toast

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:02:56 -05:00
19 changed files with 948 additions and 189 deletions

View File

@@ -1,3 +1,85 @@
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)
### Features
* **moderation:** add inline feedback and persistent password display ([86e3d41](https://github.com/antialias/soroban-abacus-flashcards/commit/86e3d4199628f95048b9265c9de0adfdc2934f93))
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)
### Features
* **moderation:** improve password input with copy button ([2580e47](https://github.com/antialias/soroban-abacus-flashcards/commit/2580e474d08bf91477339e998b2c70962a633f41))
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)
### Bug Fixes
* **moderation:** improve access mode settings UX ([dd9e657](https://github.com/antialias/soroban-abacus-flashcards/commit/dd9e657db85752b32ff91ae1b33a0bf7a7628e07))
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)
### Features
* add name generator button and abacus emoji ([07212e4](https://github.com/antialias/soroban-abacus-flashcards/commit/07212e4df0c7fd4b8cccf935c48b14164df6961d))
### Code Refactoring
* make player names abacus and arithmetic themed ([97daad9](https://github.com/antialias/soroban-abacus-flashcards/commit/97daad9abb40a6f4d59ca8a4d4b671822b7b0955))
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)
### Features
* add fun automatic player naming system ([249257c](https://github.com/antialias/soroban-abacus-flashcards/commit/249257c6c77d503b48479065664c96c5de36a234))
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)
### Bug Fixes
* remove duplicate ModerationNotifications causing double toasts ([c6886a0](https://github.com/antialias/soroban-abacus-flashcards/commit/c6886a0e59b3cbf051a828e0157495101cd8c823))
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
### Bug Fixes
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
### Features
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
### Bug Fixes
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)
### Features
* implement proper retired room behavior with member expulsion ([a2d5368](https://github.com/antialias/soroban-abacus-flashcards/commit/a2d53680f27db04b2cd09973e62a76c5a7d4ce06))
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)
### Bug Fixes
* improve join request approval error handling with actionable messages ([57bf846](https://github.com/antialias/soroban-abacus-flashcards/commit/57bf8460c8ecff374355bfb93f4b06dfbb148273))
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)

View File

@@ -57,7 +57,10 @@
"Bash(fi)",
"Bash(then echo \"TypeScript errors found\")",
"Bash(else echo \"✓ No TypeScript errors in join page\")",
"Bash(npx @biomejs/biome format:*)"
"Bash(npx @biomejs/biome format:*)",
"Bash(npx drizzle-kit generate:*)",
"Bash(ssh nas.home.network \"docker ps | grep -E ''soroban|abaci|web''\")",
"Bash(ssh:*)"
],
"deny": [],
"ask": []

View File

@@ -0,0 +1,2 @@
-- Add display_password column to arcade_rooms for showing plain text passwords to room owners
ALTER TABLE `arcade_rooms` ADD `display_password` text(100);

View File

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

View File

@@ -38,9 +38,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Check if user is already a member (for locked room access)
// Check if user is already a member (for locked/retired room access)
const members = await getRoomMembers(roomId)
const isExistingMember = members.some((m) => m.userId === viewerId)
const isRoomCreator = room.createdBy === viewerId
// Validate access mode
switch (room.accessMode) {
@@ -55,7 +56,14 @@ export async function POST(req: NextRequest, context: RouteContext) {
break
case 'retired':
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
// Only the room creator can access retired rooms
if (!isRoomCreator) {
return NextResponse.json(
{ error: 'This room has been retired and is only accessible to the owner' },
{ status: 410 }
)
}
break
case 'password': {
if (!body.password) {

View File

@@ -42,8 +42,13 @@ export async function GET(_req: NextRequest, context: RouteContext) {
// Update room activity when viewing (keeps active rooms fresh)
await touchRoom(roomId)
// Prepare room data - include displayPassword only for room creator
const roomData = canModerate
? room // Creator gets full room data including displayPassword
: { ...room, displayPassword: undefined } // Others don't see displayPassword
return NextResponse.json({
room,
room: roomData,
members,
memberPlayers, // Map of userId -> active Player[] for each member
canModerate,

View File

@@ -1,9 +1,12 @@
import { eq } from 'drizzle-orm'
import bcrypt from 'bcryptjs'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
import bcrypt from 'bcryptjs'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -66,9 +69,11 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
if (body.password !== undefined) {
if (body.password === null || body.password === '') {
updateData.password = null // Clear password
updateData.displayPassword = null // Also clear display password
} else {
const hashedPassword = await bcrypt.hash(body.password, 10)
updateData.password = hashedPassword
updateData.displayPassword = body.password // Store plain text for display
}
}
@@ -79,6 +84,72 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
// If setting to retired, expel all non-owner members
if (body.accessMode === 'retired') {
const nonOwnerMembers = members.filter((m) => !m.isCreator)
if (nonOwnerMembers.length > 0) {
// Remove all non-owner members from the room
await db.delete(schema.roomMembers).where(
and(
eq(schema.roomMembers.roomId, roomId),
// Delete all members except the creator
eq(schema.roomMembers.isCreator, false)
)
)
// Record in history for each expelled member
for (const member of nonOwnerMembers) {
await recordRoomMemberHistory({
roomId,
userId: member.userId,
displayName: member.displayName,
action: 'left',
})
}
// Broadcast updates via socket
const io = await getSocketIO()
if (io) {
try {
// Get updated member list (should only be the owner now)
const updatedMembers = await getRoomMembers(roomId)
const memberPlayers = await getRoomActivePlayers(roomId)
// Convert memberPlayers Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Notify each expelled member
for (const member of nonOwnerMembers) {
io.to(`user:${member.userId}`).emit('kicked-from-room', {
roomId,
kickedBy: currentMember.displayName,
reason: 'Room has been retired',
})
}
// Notify the owner that members were expelled
io.to(`room:${roomId}`).emit('member-left', {
roomId,
userId: nonOwnerMembers.map((m) => m.userId),
members: updatedMembers,
memberPlayers: memberPlayersObj,
reason: 'room-retired',
})
console.log(
`[Settings API] Expelled ${nonOwnerMembers.length} members from retired room ${roomId}`
)
} catch (socketError) {
console.error('[Settings API] Failed to broadcast member expulsion:', socketError)
}
}
}
}
return NextResponse.json({ room: updatedRoom }, { status: 200 })
} catch (error: any) {
console.error('Failed to update room settings:', error)

View File

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

View File

@@ -31,6 +31,7 @@ export function ModerationNotifications({
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
const [isProcessingRequest, setIsProcessingRequest] = useState(false)
const [requestError, setRequestError] = useState<string | null>(null)
const { mutateAsync: joinRoom } = useJoinRoom()
// Handle report toast (for hosts)
@@ -50,6 +51,11 @@ export function ModerationNotifications({
useEffect(() => {
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])
@@ -58,6 +64,8 @@ export function ModerationNotifications({
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
@@ -68,10 +76,11 @@ export function ModerationNotifications({
)
if (!response.ok) {
throw new Error('Failed to approve join request')
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to approve join request')
}
// Close toast and event
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
@@ -79,7 +88,8 @@ export function ModerationNotifications({
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to approve join request:', error)
alert(error instanceof Error ? error.message : 'Failed to approve request')
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to approve request')
} finally {
setIsProcessingRequest(false)
}
@@ -90,6 +100,8 @@ export function ModerationNotifications({
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
@@ -100,10 +112,11 @@ export function ModerationNotifications({
)
if (!response.ok) {
throw new Error('Failed to deny join request')
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to deny join request')
}
// Close toast and event
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
@@ -111,7 +124,8 @@ export function ModerationNotifications({
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to deny join request:', error)
alert(error instanceof Error ? error.message : 'Failed to deny request')
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to deny request')
} finally {
setIsProcessingRequest(false)
}
@@ -119,6 +133,8 @@ export function ModerationNotifications({
// Kicked modal
if (moderationEvent?.type === 'kicked') {
const isRetired = moderationEvent.data.reason?.includes('retired')
return (
<Modal isOpen={true} onClose={() => {}}>
<div
@@ -129,7 +145,7 @@ export function ModerationNotifications({
minWidth: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isRetired ? '🏁' : '⚠️'}</div>
<h2
style={{
fontSize: '24px',
@@ -138,7 +154,7 @@ export function ModerationNotifications({
color: 'rgba(253, 186, 116, 1)',
}}
>
Kicked from Room
{isRetired ? 'Room Retired' : 'Kicked from Room'}
</h2>
<p
style={{
@@ -147,10 +163,16 @@ export function ModerationNotifications({
marginBottom: '8px',
}}
>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
{isRetired ? (
<>The room owner has retired this room and access has been closed</>
) : (
<>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
</>
)}
</p>
<p
style={{
@@ -159,7 +181,9 @@ export function ModerationNotifications({
marginBottom: '24px',
}}
>
You can rejoin if the host sends you a new invite
{isRetired
? 'Only the room owner can access retired rooms'
: 'You can rejoin if the host sends you a new invite'}
</p>
<button
@@ -515,6 +539,38 @@ export function ModerationNotifications({
<strong>{moderationEvent.data.requesterName}</strong> wants to join your room
</Toast.Description>
{/* Error message */}
{requestError && (
<div
style={{
padding: '8px 10px',
background: 'rgba(239, 68, 68, 0.2)',
border: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '6px',
marginBottom: '12px',
}}
>
<div
style={{
fontSize: '12px',
color: 'rgba(254, 202, 202, 1)',
fontWeight: '600',
marginBottom: '2px',
}}
>
Error
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(255, 255, 255, 0.9)',
}}
>
{requestError}
</div>
</div>
)}
{/* Action buttons */}
<div style={{ display: 'flex', gap: '8px' }}>
<button

View File

@@ -88,10 +88,16 @@ export function ModerationPanel({
// Settings state
const [accessMode, setAccessMode] = useState<string>('open')
const [originalAccessMode, setOriginalAccessMode] = useState<string>('open')
const [roomPassword, setRoomPassword] = useState('')
const [showPasswordInput, setShowPasswordInput] = useState(false)
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('')
const [joinRequests, setJoinRequests] = useState<any[]>([])
const [passwordCopied, setPasswordCopied] = useState(false)
// Inline feedback state
const [successMessage, setSuccessMessage] = useState<string>('')
const [errorMessage, setErrorMessage] = useState<string>('')
// Ban modal state
const [showBanModal, setShowBanModal] = useState(false)
@@ -180,8 +186,9 @@ export function ModerationPanel({
}
// Success - member will be removed via socket update
showSuccess('Player kicked from room')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to kick player')
showError(err instanceof Error ? err.message : 'Failed to kick player')
} finally {
setActionLoading(null)
}
@@ -218,8 +225,10 @@ export function ModerationPanel({
const data = await bansRes.json()
setBans(data.bans || [])
}
showSuccess('Player banned from room')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to ban player')
showError(err instanceof Error ? err.message : 'Failed to ban player')
} finally {
setActionLoading(null)
setBanTargetUserId(null)
@@ -255,8 +264,10 @@ export function ModerationPanel({
const data = await historyRes.json()
setHistoricalMembers(data.historicalMembers || [])
}
showSuccess('Player unbanned')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to unban player')
showError(err instanceof Error ? err.message : 'Failed to unban player')
} finally {
setActionLoading(null)
}
@@ -292,9 +303,9 @@ export function ModerationPanel({
setHistoricalMembers(data.historicalMembers || [])
}
alert(`${userName} has been unbanned and invited back to the room!`)
showSuccess(`${userName} has been unbanned and invited back to the room`)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to unban player')
showError(err instanceof Error ? err.message : 'Failed to unban player')
} finally {
setActionLoading(null)
}
@@ -322,9 +333,9 @@ export function ModerationPanel({
setHistoricalMembers(data.historicalMembers || [])
}
alert(`Invitation sent to ${userName}!`)
showSuccess(`Invitation sent to ${userName}`)
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to send invitation')
showError(err instanceof Error ? err.message : 'Failed to send invitation')
} finally {
setActionLoading(null)
}
@@ -336,11 +347,19 @@ export function ModerationPanel({
const loadSettings = async () => {
try {
// Fetch current room data to get access mode
// Fetch current room data to get access mode and password
const roomRes = await fetch(`/api/arcade/rooms/${roomId}`)
if (roomRes.ok) {
const data = await roomRes.json()
setAccessMode(data.room?.accessMode || 'open')
const currentAccessMode = data.room?.accessMode || 'open'
setAccessMode(currentAccessMode)
setOriginalAccessMode(currentAccessMode)
// Set password field if room has a password and user is the creator
if (currentAccessMode === 'password' && data.room?.displayPassword) {
setRoomPassword(data.room.displayPassword)
setShowPasswordInput(true)
}
}
// Fetch join requests if any
@@ -377,11 +396,12 @@ export function ModerationPanel({
throw new Error(errorData.error || 'Failed to update settings')
}
alert('Room settings updated successfully!')
showSuccess('Room settings updated successfully')
setOriginalAccessMode(accessMode) // Update original to current
setShowPasswordInput(false)
setRoomPassword('')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update settings')
showError(err instanceof Error ? err.message : 'Failed to update settings')
} finally {
setActionLoading(null)
}
@@ -409,10 +429,10 @@ export function ModerationPanel({
throw new Error(errorData.error || 'Failed to transfer ownership')
}
alert(`Ownership transferred to ${newOwner.displayName}!`)
onClose() // Close panel since user is no longer host
showSuccess(`Ownership transferred to ${newOwner.displayName}`)
setTimeout(() => onClose(), 2000) // Close panel after showing message
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to transfer ownership')
showError(err instanceof Error ? err.message : 'Failed to transfer ownership')
} finally {
setActionLoading(null)
}
@@ -437,9 +457,9 @@ export function ModerationPanel({
setJoinRequests(data.requests || [])
}
alert('Join request approved!')
showSuccess('Join request approved')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to approve request')
showError(err instanceof Error ? err.message : 'Failed to approve request')
} finally {
setActionLoading(null)
}
@@ -463,16 +483,47 @@ export function ModerationPanel({
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
showSuccess('Join request denied')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to deny request')
showError(err instanceof Error ? err.message : 'Failed to deny request')
} finally {
setActionLoading(null)
}
}
const handleCopyPassword = async () => {
if (!roomPassword) return
try {
await navigator.clipboard.writeText(roomPassword)
setPasswordCopied(true)
setTimeout(() => setPasswordCopied(false), 2000)
} catch (err) {
console.error('Failed to copy password:', err)
showError('Failed to copy password to clipboard')
}
}
// Utility functions for showing feedback
const showSuccess = (message: string) => {
setSuccessMessage(message)
setErrorMessage('')
setTimeout(() => setSuccessMessage(''), 5000)
}
const showError = (message: string) => {
setErrorMessage(message)
setSuccessMessage('')
setTimeout(() => setErrorMessage(''), 5000)
}
const pendingReports = reports.filter((r) => r.status === 'pending')
const otherMembers = members.filter((m) => m.userId !== currentUserId)
// Check if there are unsaved changes in settings
const hasUnsavedAccessModeChanges = accessMode !== originalAccessMode
// Group reports by reported user ID
const reportsByUser = pendingReports.reduce(
(acc, report) => {
@@ -513,6 +564,69 @@ export function ModerationPanel({
Manage members, reports, and bans
</p>
{/* Success/Error Messages */}
{(successMessage || errorMessage) && (
<div
style={{
padding: '12px 16px',
background: successMessage ? 'rgba(34, 197, 94, 0.1)' : 'rgba(239, 68, 68, 0.1)',
border: successMessage
? '1px solid rgba(34, 197, 94, 0.4)'
: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '8px',
marginBottom: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
animation: 'fadeIn 0.2s ease',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
flex: 1,
}}
>
<span style={{ fontSize: '16px' }}>{successMessage ? '✓' : '⚠'}</span>
<span
style={{
fontSize: '14px',
fontWeight: '600',
color: successMessage ? 'rgba(34, 197, 94, 1)' : 'rgba(239, 68, 68, 1)',
}}
>
{successMessage || errorMessage}
</span>
</div>
<button
type="button"
onClick={() => {
setSuccessMessage('')
setErrorMessage('')
}}
style={{
background: 'none',
border: 'none',
color: successMessage ? 'rgba(34, 197, 94, 0.8)' : 'rgba(239, 68, 68, 0.8)',
fontSize: '18px',
cursor: 'pointer',
padding: '0 4px',
lineHeight: 1,
}}
onMouseEnter={(e) => {
e.currentTarget.style.opacity = '1'
}}
onMouseLeave={(e) => {
e.currentTarget.style.opacity = '0.8'
}}
>
</button>
</div>
)}
{/* Tabs */}
<div
style={{
@@ -1411,49 +1525,125 @@ export function ModerationPanel({
{/* Password input (conditional) */}
{(accessMode === 'password' || showPasswordInput) && (
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter room password"
<div style={{ marginBottom: '12px' }}>
<label
style={{
display: 'block',
fontSize: '12px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '6px',
}}
>
Room Password
</label>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter password to share with guests"
style={{
flex: 1,
padding: '10px 12px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
outline: 'none',
transition: 'border-color 0.2s ease',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.6)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}}
/>
<button
type="button"
onClick={handleCopyPassword}
disabled={!roomPassword}
title="Copy password to clipboard"
style={{
padding: '10px 16px',
background: passwordCopied
? 'rgba(34, 197, 94, 0.2)'
: roomPassword
? 'rgba(59, 130, 246, 0.2)'
: 'rgba(75, 85, 99, 0.2)',
color: passwordCopied
? 'rgba(34, 197, 94, 1)'
: roomPassword
? 'rgba(59, 130, 246, 1)'
: 'rgba(156, 163, 175, 1)',
border: passwordCopied
? '1px solid rgba(34, 197, 94, 0.4)'
: roomPassword
? '1px solid rgba(59, 130, 246, 0.4)'
: '1px solid rgba(75, 85, 99, 0.3)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: roomPassword ? 'pointer' : 'not-allowed',
opacity: roomPassword ? 1 : 0.5,
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
}}
onMouseEnter={(e) => {
if (roomPassword && !passwordCopied) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.3)'
}
}}
onMouseLeave={(e) => {
if (roomPassword && !passwordCopied) {
e.currentTarget.style.background = 'rgba(59, 130, 246, 0.2)'
}
}}
>
{passwordCopied ? '✓ Copied!' : '📋 Copy'}
</button>
</div>
<div
style={{
fontSize: '11px',
color: 'rgba(156, 163, 175, 1)',
marginTop: '4px',
}}
>
Share this password with guests to allow them to join
</div>
</div>
)}
{hasUnsavedAccessModeChanges && (
<button
type="button"
onClick={handleUpdateAccessMode}
disabled={actionLoading === 'update-settings'}
style={{
width: '100%',
padding: '10px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.5)',
background:
actionLoading === 'update-settings'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border:
actionLoading === 'update-settings'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '6px',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
marginBottom: '12px',
fontWeight: '600',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
}}
/>
>
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
</button>
)}
<button
type="button"
onClick={handleUpdateAccessMode}
disabled={actionLoading === 'update-settings'}
style={{
width: '100%',
padding: '10px',
background:
actionLoading === 'update-settings'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: 'white',
border:
actionLoading === 'update-settings'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(59, 130, 246, 0.6)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
}}
>
{actionLoading === 'update-settings' ? 'Updating...' : 'Update Access Mode'}
</button>
</div>
</div>
@@ -1663,23 +1853,44 @@ export function ModerationPanel({
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '20px' }}>
<button
type="button"
onClick={onClose}
onClick={hasUnsavedAccessModeChanges ? undefined : onClose}
disabled={hasUnsavedAccessModeChanges}
title={
hasUnsavedAccessModeChanges
? 'Please update access mode settings before closing'
: undefined
}
style={{
padding: '10px 20px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '1px solid rgba(75, 85, 99, 0.5)',
background: hasUnsavedAccessModeChanges
? 'rgba(75, 85, 99, 0.2)'
: 'rgba(75, 85, 99, 0.3)',
color: hasUnsavedAccessModeChanges
? 'rgba(156, 163, 175, 1)'
: 'rgba(209, 213, 219, 1)',
border: hasUnsavedAccessModeChanges
? '1px solid rgba(251, 146, 60, 0.4)'
: '1px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '14px',
fontWeight: '600',
cursor: 'pointer',
cursor: hasUnsavedAccessModeChanges ? 'not-allowed' : 'pointer',
opacity: hasUnsavedAccessModeChanges ? 0.6 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
if (!hasUnsavedAccessModeChanges) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
} else {
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.8)'
}
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
if (!hasUnsavedAccessModeChanges) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
} else {
e.currentTarget.style.borderColor = 'rgba(251, 146, 60, 0.4)'
}
}}
>
Close

View File

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

View File

@@ -1,5 +1,8 @@
// Available character emojis for players
export const PLAYER_EMOJIS = [
// Abacus
'🧮',
// People & Characters
'😀',
'😃',

View File

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

View File

@@ -30,6 +30,7 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
.notNull()
.default('open'),
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
// Game configuration
gameName: text('game_name', {

View File

@@ -347,13 +347,14 @@ export function useRoomData() {
}
// Moderation event handlers
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
console.log('[useRoomData] User was kicked from room:', data)
setModerationEvent({
type: 'kicked',
data: {
roomId: data.roomId,
kickedBy: data.kickedBy,
reason: data.reason,
},
})
// Clear room data since user was kicked

View File

@@ -62,6 +62,7 @@ describe('Room Manager', () => {
ttlMinutes: 60,
accessMode: 'open',
password: null,
displayPassword: null,
gameName: 'matching',
gameConfig: { difficulty: 6 },
status: 'lobby',

View 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)
}
})
})
})

View 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
}

View File

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