Compare commits

..

18 Commits

Author SHA1 Message Date
semantic-release-bot
a4251e660d chore(release): 3.13.3 [skip ci]
## [3.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.2...v3.13.3) (2025-10-14)

### Bug Fixes

* **migrations:** add migration 0009 for display_password column ([040d749](040d7495a0))

### Code Refactoring

* replace browser alert() calls with toast notifications ([87ef356](87ef35682e))
2025-10-14 14:57:31 +00:00
Thomas Hallock
040d7495a0 fix(migrations): add migration 0009 for display_password column
- Create 0009_add_display_password.sql migration
- Add entry to drizzle journal
- This adds the display_password column that was missing in production

The plan is to nuke the production database and let all migrations
run from scratch on container restart.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 09:56:26 -05:00
Thomas Hallock
87ef35682e refactor: replace browser alert() calls with toast notifications
- Create ToastContext with useToast hook for app-wide toast management
- Add ToastProvider to ClientProviders for global toast access
- Replace all 13 alert() calls across arcade room pages and components
- Use consistent toast patterns: showError, showSuccess, showInfo
- Improve UX with dismissible, auto-timing toast notifications

Files updated:
- src/components/common/ToastContext.tsx (new)
- src/components/ClientProviders.tsx
- src/app/arcade-rooms/page.tsx
- src/app/arcade-rooms/[roomId]/page.tsx
- src/components/nav/ModerationNotifications.tsx
- src/components/nav/AddPlayerButton.tsx
- src/components/nav/PendingInvitations.tsx

Also removed invalid manually-created migration 0009 (will be regenerated properly)

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

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

### Bug Fixes

* **arcade:** only notify room creator of join requests ([bc571e3](bc571e3d0d))
2025-10-14 13:55:57 +00:00
Thomas Hallock
bc571e3d0d fix(arcade): only notify room creator of join requests
Fixes issue where ALL room members were seeing join request approval
toasts, but only the creator can approve them, leading to confusing
error messages when non-creators clicked approve/deny.

Changes:
- Join request notifications now sent only to room creator's user channel
- Changed from broadcasting to entire room to targeted user notification
- Uses `user:${room.createdBy}` channel instead of `room:${roomId}`

Non-host users will no longer see approval toasts they cannot act on.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:55:10 -05:00
semantic-release-bot
eed7c9b938 chore(release): 3.13.1 [skip ci]
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)

### Bug Fixes

* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](654ba19ccc))
2025-10-14 13:54:31 +00:00
Thomas Hallock
654ba19ccc fix(arcade): allow room creator to rejoin restricted/approval rooms
Fixes catch-22 where room creator who leaves their own approval-only
or restricted room cannot rejoin because:
- Approval-only: They need approval but can't approve themselves
- Restricted: They need an invitation but can't invite themselves

Changes:
- Room creator now bypasses invitation check for restricted rooms
- Room creator now bypasses approval check for approval-only rooms
- Other users still require proper authorization

This ensures hosts can always access their own rooms regardless of
access mode restrictions.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:53:39 -05:00
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
24 changed files with 1018 additions and 143 deletions

View File

@@ -1,3 +1,69 @@
## [3.13.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.2...v3.13.3) (2025-10-14)
### Bug Fixes
* **migrations:** add migration 0009 for display_password column ([040d749](https://github.com/antialias/soroban-abacus-flashcards/commit/040d7495a0801076b252d2574023f5323540db1a))
### Code Refactoring
* replace browser alert() calls with toast notifications ([87ef356](https://github.com/antialias/soroban-abacus-flashcards/commit/87ef35682e5c129033f21b91987fc84a45f43ad3))
## [3.13.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.1...v3.13.2) (2025-10-14)
### Bug Fixes
* **arcade:** only notify room creator of join requests ([bc571e3](https://github.com/antialias/soroban-abacus-flashcards/commit/bc571e3d0d11fe4142680132d551e25ca626d950))
## [3.13.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.0...v3.13.1) (2025-10-14)
### Bug Fixes
* **arcade:** allow room creator to rejoin restricted/approval rooms ([654ba19](https://github.com/antialias/soroban-abacus-flashcards/commit/654ba19ccca595d34ad205c036c18afb99a494c7))
## [3.13.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.12.0...v3.13.0) (2025-10-14)
### Features
* **moderation:** add inline feedback and persistent password display ([86e3d41](https://github.com/antialias/soroban-abacus-flashcards/commit/86e3d4199628f95048b9265c9de0adfdc2934f93))
## [3.12.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.1...v3.12.0) (2025-10-14)
### Features
* **moderation:** improve password input with copy button ([2580e47](https://github.com/antialias/soroban-abacus-flashcards/commit/2580e474d08bf91477339e998b2c70962a633f41))
## [3.11.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.11.0...v3.11.1) (2025-10-14)
### Bug Fixes
* **moderation:** improve access mode settings UX ([dd9e657](https://github.com/antialias/soroban-abacus-flashcards/commit/dd9e657db85752b32ff91ae1b33a0bf7a7628e07))
## [3.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.10.0...v3.11.0) (2025-10-14)
### Features
* add name generator button and abacus emoji ([07212e4](https://github.com/antialias/soroban-abacus-flashcards/commit/07212e4df0c7fd4b8cccf935c48b14164df6961d))
### Code Refactoring
* make player names abacus and arithmetic themed ([97daad9](https://github.com/antialias/soroban-abacus-flashcards/commit/97daad9abb40a6f4d59ca8a4d4b671822b7b0955))
## [3.10.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.2...v3.10.0) (2025-10-14)
### Features
* add fun automatic player naming system ([249257c](https://github.com/antialias/soroban-abacus-flashcards/commit/249257c6c77d503b48479065664c96c5de36a234))
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)

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

@@ -64,6 +64,13 @@
"when": 1760548800000,
"tag": "0008_make_room_name_nullable",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1760600000000,
"tag": "0009_add_display_password",
"breakpoints": true
}
]
}

View File

@@ -88,11 +88,12 @@ export async function POST(req: NextRequest, context: RouteContext) {
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
)
// Broadcast to all members in the room (particularly the host) via socket
// Broadcast to the room host (creator) only via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`room:${roomId}`).emit('join-request-submitted', {
// Send notification only to the room creator's user channel
io.to(`user:${room.createdBy}`).emit('join-request-submitted', {
roomId,
request: {
id: request.id,
@@ -103,7 +104,7 @@ export async function POST(req: NextRequest, context: RouteContext) {
})
console.log(
`[Join Requests] Broadcasted join-request-submitted for user ${viewerId} to room ${roomId}`
`[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}`
)
} catch (socketError) {
// Log but don't fail the request if socket broadcast fails

View File

@@ -83,25 +83,31 @@ export async function POST(req: NextRequest, context: RouteContext) {
}
case 'restricted': {
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
// Room creator can always rejoin their own room
if (!isRoomCreator) {
// Check for valid pending invitation
const invitation = await getInvitation(roomId, viewerId)
if (!invitation || invitation.status !== 'pending') {
return NextResponse.json(
{ error: 'You need a valid invitation to join this room' },
{ status: 403 }
)
}
}
break
}
case 'approval-only': {
// Check for approved join request
const joinRequest = await getJoinRequest(roomId, viewerId)
if (!joinRequest || joinRequest.status !== 'approved') {
return NextResponse.json(
{ error: 'Your join request must be approved by the host' },
{ status: 403 }
)
// Room creator can always rejoin their own room without approval
if (!isRoomCreator) {
// Check for approved join request
const joinRequest = await getJoinRequest(roomId, viewerId)
if (!joinRequest || joinRequest.status !== 'approved') {
return NextResponse.json(
{ error: 'Your join request must be approved by the host' },
{ status: 403 }
)
}
}
break
}

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

@@ -69,9 +69,11 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
if (body.password !== undefined) {
if (body.password === null || body.password === '') {
updateData.password = null // Clear password
updateData.displayPassword = null // Also clear display password
} else {
const hashedPassword = await bcrypt.hash(body.password, 10)
updateData.password = hashedPassword
updateData.displayPassword = body.password // Store plain text for display
}
}

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { io, type Socket } from 'socket.io-client'
import { css } from '../../../../styled-system/css'
import { useToast } from '@/components/common/ToastContext'
import { PageWithNav } from '@/components/PageWithNav'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -40,6 +41,7 @@ interface Player {
export default function RoomDetailPage() {
const params = useParams()
const router = useRouter()
const { showError } = useToast()
const roomId = params.roomId as string
const { data: guestId } = useViewerId()
@@ -172,7 +174,7 @@ export default function RoomDetailPage() {
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
showError('Already in Another Room', errorData.userMessage || errorData.message)
// Refresh the page to update room state
await fetchRoom()
return
@@ -193,7 +195,7 @@ export default function RoomDetailPage() {
await fetchRoom()
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
showError('Failed to join room', err instanceof Error ? err.message : undefined)
}
}
@@ -213,7 +215,7 @@ export default function RoomDetailPage() {
router.push('/arcade')
} catch (err) {
console.error('Failed to leave room:', err)
alert('Failed to leave room')
showError('Failed to leave room', err instanceof Error ? err.message : undefined)
}
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../styled-system/css'
import { useToast } from '@/components/common/ToastContext'
import { PageWithNav } from '@/components/PageWithNav'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
@@ -23,6 +24,7 @@ interface Room {
export default function RoomBrowserPage() {
const router = useRouter()
const { showError, showInfo } = useToast()
const [rooms, setRooms] = useState<Room[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@@ -71,7 +73,7 @@ export default function RoomBrowserPage() {
router.push(`/arcade-rooms/${data.room.id}`)
} catch (err) {
console.error('Failed to create room:', err)
alert('Failed to create room')
showError('Failed to create room', err instanceof Error ? err.message : undefined)
}
}
@@ -90,7 +92,7 @@ export default function RoomBrowserPage() {
if (!response.ok) {
const errorData = await response.json()
alert(errorData.error || 'Failed to join room')
showError('Failed to join room', errorData.error)
return
}
@@ -99,12 +101,15 @@ export default function RoomBrowserPage() {
}
if (room.accessMode === 'approval-only') {
alert('This room requires host approval. Please use the Join Room modal to request access.')
showInfo(
'Approval Required',
'This room requires host approval. Please use the Join Room modal to request access.'
)
return
}
if (room.accessMode === 'restricted') {
alert('This room is invitation-only. Please ask the host for an invitation.')
showInfo('Invitation Only', 'This room is invitation-only. Please ask the host for an invitation.')
return
}
@@ -120,7 +125,7 @@ export default function RoomBrowserPage() {
// Handle specific room membership conflict
if (errorData.code === 'ROOM_MEMBERSHIP_CONFLICT') {
alert(errorData.userMessage || errorData.message)
showError('Already in Another Room', errorData.userMessage || errorData.message)
// Refresh the page to update room list state
await fetchRooms()
return
@@ -140,7 +145,7 @@ export default function RoomBrowserPage() {
router.push(`/arcade-rooms/${room.id}`)
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
showError('Failed to join room', err instanceof Error ? err.message : undefined)
}
}

View File

@@ -3,6 +3,7 @@
import { AbacusDisplayProvider } from '@soroban/abacus-react'
import { QueryClientProvider } from '@tanstack/react-query'
import { type ReactNode, useState } from 'react'
import { ToastProvider } from '@/components/common/ToastContext'
import { FullscreenProvider } from '@/contexts/FullscreenContext'
import { GameModeProvider } from '@/contexts/GameModeContext'
import { UserProfileProvider } from '@/contexts/UserProfileContext'
@@ -20,17 +21,19 @@ export function ClientProviders({ children }: ClientProvidersProps) {
return (
<QueryClientProvider client={queryClient}>
<AbacusDisplayProvider>
<AbacusSettingsSync />
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
<ToastProvider>
<AbacusDisplayProvider>
<AbacusSettingsSync />
<UserProfileProvider>
<GameModeProvider>
<FullscreenProvider>
{children}
<DeploymentInfo />
</FullscreenProvider>
</GameModeProvider>
</UserProfileProvider>
</AbacusDisplayProvider>
</ToastProvider>
</QueryClientProvider>
)
}

View File

@@ -0,0 +1,235 @@
'use client'
import * as Toast from '@radix-ui/react-toast'
import { createContext, useCallback, useContext, useState, type ReactNode } from 'react'
export interface ToastMessage {
id: string
type: 'success' | 'error' | 'info'
title: string
description?: string
duration?: number
}
interface ToastContextValue {
showToast: (toast: Omit<ToastMessage, 'id'>) => void
showSuccess: (title: string, description?: string) => void
showError: (title: string, description?: string) => void
showInfo: (title: string, description?: string) => void
}
const ToastContext = createContext<ToastContextValue | null>(null)
export function useToast() {
const context = useContext(ToastContext)
if (!context) {
throw new Error('useToast must be used within ToastProvider')
}
return context
}
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<ToastMessage[]>([])
const showToast = useCallback((toast: Omit<ToastMessage, 'id'>) => {
const id = Math.random().toString(36).substring(7)
setToasts((prev) => [...prev, { ...toast, id }])
}, [])
const showSuccess = useCallback(
(title: string, description?: string) => {
showToast({ type: 'success', title, description, duration: 5000 })
},
[showToast]
)
const showError = useCallback(
(title: string, description?: string) => {
showToast({ type: 'error', title, description, duration: 7000 })
},
[showToast]
)
const showInfo = useCallback(
(title: string, description?: string) => {
showToast({ type: 'info', title, description, duration: 5000 })
},
[showToast]
)
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
}, [])
const getToastStyles = (type: ToastMessage['type']) => {
switch (type) {
case 'success':
return {
background: 'linear-gradient(135deg, rgba(34, 197, 94, 0.97), rgba(22, 163, 74, 0.97))',
border: '2px solid rgba(34, 197, 94, 0.6)',
icon: '✓',
}
case 'error':
return {
background: 'linear-gradient(135deg, rgba(239, 68, 68, 0.97), rgba(220, 38, 38, 0.97))',
border: '2px solid rgba(239, 68, 68, 0.6)',
icon: '⚠',
}
case 'info':
return {
background: 'linear-gradient(135deg, rgba(59, 130, 246, 0.97), rgba(37, 99, 235, 0.97))',
border: '2px solid rgba(59, 130, 246, 0.6)',
icon: '',
}
}
}
return (
<ToastContext.Provider value={{ showToast, showSuccess, showError, showInfo }}>
{children}
<Toast.Provider swipeDirection="right">
{toasts.map((toast) => {
const styles = getToastStyles(toast.type)
return (
<Toast.Root
key={toast.id}
open={true}
onOpenChange={(open) => {
if (!open) {
removeToast(toast.id)
}
}}
duration={toast.duration}
style={{
background: styles.background,
border: styles.border,
borderRadius: '12px',
padding: '16px',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
display: 'flex',
gap: '12px',
alignItems: 'flex-start',
minWidth: '300px',
maxWidth: '450px',
transition: 'all 0.2s ease',
}}
>
<div style={{ fontSize: '20px', flexShrink: 0 }}>{styles.icon}</div>
<div style={{ flex: 1 }}>
<Toast.Title
style={{
fontSize: '14px',
fontWeight: 'bold',
color: 'white',
marginBottom: toast.description ? '4px' : 0,
}}
>
{toast.title}
</Toast.Title>
{toast.description && (
<Toast.Description
style={{
fontSize: '13px',
color: 'rgba(255, 255, 255, 0.9)',
}}
>
{toast.description}
</Toast.Description>
)}
</div>
<Toast.Close
style={{
background: 'rgba(255, 255, 255, 0.2)',
border: 'none',
borderRadius: '50%',
width: '24px',
height: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
color: 'white',
fontSize: '16px',
lineHeight: 1,
flexShrink: 0,
}}
>
×
</Toast.Close>
</Toast.Root>
)
})}
<Toast.Viewport
style={{
position: 'fixed',
top: '80px',
right: '20px',
display: 'flex',
flexDirection: 'column',
gap: '10px',
zIndex: 10001,
maxWidth: '100vw',
margin: 0,
listStyle: 'none',
outline: 'none',
}}
/>
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes slideIn {
from {
transform: translateX(calc(100% + 25px));
}
to {
transform: translateX(0);
}
}
@keyframes slideOut {
from {
transform: translateX(0);
}
to {
transform: translateX(calc(100% + 25px));
}
}
@keyframes hide {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
[data-state='closed'] {
animation: hide 100ms ease-in, slideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
}
[data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
[data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
[data-swipe='end'] {
animation: slideOut 100ms ease-out;
}
`,
}}
/>
</Toast.Provider>
</ToastContext.Provider>
)
}

View File

@@ -1,5 +1,6 @@
import React from 'react'
import { useRouter } from 'next/navigation'
import { useToast } from '@/components/common/ToastContext'
import { InvitePlayersTab } from './InvitePlayersTab'
import { PlayOnlineTab } from './PlayOnlineTab'
import { addToRecentRooms } from './RecentRoomsList'
@@ -41,6 +42,7 @@ export function AddPlayerButton({
}: AddPlayerButtonProps) {
const popoverRef = React.useRef<HTMLDivElement>(null)
const router = useRouter()
const { showError } = useToast()
// Use lifted state if provided, otherwise fallback to internal state
const [internalShowPopover, setInternalShowPopover] = React.useState(false)
@@ -75,7 +77,7 @@ export function AddPlayerButton({
},
onError: (error) => {
console.error('Failed to create room:', error)
alert(`Failed to create room: ${error.message}`)
showError('Failed to create room', error.message)
},
}
)

View File

@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { useToast } from '@/components/common/ToastContext'
import type { ModerationEvent } from '@/hooks/useRoomData'
import { useJoinRoom } from '@/hooks/useRoomData'
@@ -27,6 +28,7 @@ export function ModerationNotifications({
}: ModerationNotificationsProps) {
const router = useRouter()
const queryClient = useQueryClient()
const { showError } = useToast()
const [showToast, setShowToast] = useState(false)
const [showJoinRequestToast, setShowJoinRequestToast] = useState(false)
const [isAcceptingInvitation, setIsAcceptingInvitation] = useState(false)
@@ -834,7 +836,7 @@ export function ModerationNotifications({
router.push('/arcade/room')
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
showError('Failed to join room', error instanceof Error ? error.message : undefined)
setIsAcceptingInvitation(false)
}
}}

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,5 +1,6 @@
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useToast } from '@/components/common/ToastContext'
import { useJoinRoom } from '@/hooks/useRoomData'
interface PendingInvitation {
@@ -32,6 +33,7 @@ export interface PendingInvitationsProps {
*/
export function PendingInvitations({ onInvitationChange, currentRoomId }: PendingInvitationsProps) {
const router = useRouter()
const { showError } = useToast()
const [invitations, setInvitations] = useState<PendingInvitation[]>([])
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState('')
@@ -72,7 +74,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
onInvitationChange?.()
} catch (error) {
console.error('Failed to join room:', error)
alert(error instanceof Error ? error.message : 'Failed to join room')
showError('Failed to join room', error instanceof Error ? error.message : undefined)
} finally {
setActionLoading(null)
}
@@ -97,7 +99,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
onInvitationChange?.()
} catch (error) {
console.error('Failed to decline invitation:', error)
alert(error instanceof Error ? error.message : 'Failed to decline invitation')
showError('Failed to decline invitation', error instanceof Error ? error.message : undefined)
} finally {
setActionLoading(null)
}

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

@@ -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.9.2",
"version": "3.13.3",
"private": true,
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
"workspaces": [