Compare commits

...

25 Commits

Author SHA1 Message Date
semantic-release-bot
2b68ddc732 chore(release): 3.13.6 [skip ci]
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)

### Bug Fixes

* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](1c55f3630c))
2025-10-14 15:40:44 +00:00
Thomas Hallock
1c55f3630c fix(nav): navigate to /arcade/room (not /arcade/rooms/{id})
Rooms are modal and use a single route /arcade/room that fetches
the user's current room. Fixed navigation for both:
- Creating a new room
- Joining an existing room

Both now navigate to /arcade/room instead of /arcade/rooms/{id}

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:39:50 -05:00
semantic-release-bot
1e34d57ad6 chore(release): 3.13.5 [skip ci]
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)

### Bug Fixes

* **nav:** navigate to room after creation from (+) menu ([21e6e33](21e6e33173))

### Documentation

* add production deployment guide ([6d16436](6d16436133))
2025-10-14 15:29:11 +00:00
Thomas Hallock
21e6e33173 fix(nav): navigate to room after creation from (+) menu
When creating a room from the /arcade page using the (+) button:
- Add room to recent rooms list
- Close the popover
- Navigate to the room page immediately

This fixes the UX issue where users would create a room but
remain on the /arcade page without any clear indication of
how to access their new room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:28:14 -05:00
Thomas Hallock
6d16436133 docs: add production deployment guide
Add comprehensive deployment documentation including:
- Production server infrastructure details
- Docker configuration and paths
- Database management and migration procedures
- CI/CD pipeline explanation
- Manual deployment procedures
- Troubleshooting guide

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:26:30 -05:00
semantic-release-bot
6b489238c8 chore(release): 3.13.4 [skip ci]
## [3.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.3...v3.13.4) (2025-10-14)

### Bug Fixes

* **api:** include members and memberPlayers in room creation response ([8320d9e](8320d9e730))
2025-10-14 15:14:53 +00:00
Thomas Hallock
8320d9e730 fix(api): include members and memberPlayers in room creation response
The client expects the POST /api/arcade/rooms response to include
members and memberPlayers fields, but the API was only returning
room and joinUrl. This caused room creation to fail on the client.

Fixes the "failed to create room" error on production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:13:57 -05:00
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
26 changed files with 1259 additions and 145 deletions

View File

@@ -1,3 +1,95 @@
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)
### Bug Fixes
* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](https://github.com/antialias/soroban-abacus-flashcards/commit/1c55f3630cb5f07b685555e41baa5a49314f15a3))
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)
### Bug Fixes
* **nav:** navigate to room after creation from (+) menu ([21e6e33](https://github.com/antialias/soroban-abacus-flashcards/commit/21e6e33173e7939102a7e6d6a7bd5168a97a49d6))
### Documentation
* add production deployment guide ([6d16436](https://github.com/antialias/soroban-abacus-flashcards/commit/6d164361331fae2135afd84ab6e6f38a241b9170))
## [3.13.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.3...v3.13.4) (2025-10-14)
### Bug Fixes
* **api:** include members and memberPlayers in room creation response ([8320d9e](https://github.com/antialias/soroban-abacus-flashcards/commit/8320d9e730e2b9964e509847dfa504a78b721b5a))
## [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

@@ -0,0 +1,191 @@
# Production Deployment Guide
This document describes the production deployment infrastructure and procedures for the abaci.one web application.
## Infrastructure Overview
### Production Server
- **Host**: `nas.home.network` (Synology NAS DS923+)
- **Access**: SSH access required
- Must be connected to network at **730 N. Oak Park Ave**
- Server is not accessible from external networks
- **Project Directory**: `/volume1/homes/antialias/projects/abaci.one`
### Docker Configuration
- **Docker binary**: `/usr/local/bin/docker`
- **Docker Compose binary**: `/usr/local/bin/docker-compose`
- **Container name**: `soroban-abacus-flashcards`
- **Image**: `ghcr.io/antialias/soroban-abacus-flashcards:latest`
### Auto-Deployment
- **Watchtower** monitors and auto-updates containers
- **Update frequency**: Every **5 minutes**
- Watchtower pulls latest images and restarts containers automatically
- No manual intervention required for deployments after pushing to main
## Database Management
### Location
- **Database path**: `data/sqlite.db` (relative to project directory)
- **WAL files**: `data/sqlite.db-shm` and `data/sqlite.db-wal`
### Migrations
- **Automatic**: Migrations run on server startup via `server.js`
- **Migration folder**: `./drizzle`
- **Process**:
1. Server starts
2. Logs: `🔄 Running database migrations...`
3. Drizzle migrator runs all pending migrations
4. Logs: `✅ Migrations complete` (on success)
5. Logs: `❌ Migration failed: [error]` (on failure, process exits)
### Nuke and Rebuild Database
If you need to completely reset the production database:
```bash
# SSH into the server
ssh nas.home.network
# Navigate to project directory
cd /volume1/homes/antialias/projects/abaci.one
# Stop the container
/usr/local/bin/docker-compose down
# Remove database files
rm -f data/sqlite.db data/sqlite.db-shm data/sqlite.db-wal
# Restart container (migrations will rebuild DB)
/usr/local/bin/docker-compose up -d
# Check logs to verify migration success
/usr/local/bin/docker logs soroban-abacus-flashcards | grep -E '(Migration|Starting)'
```
## CI/CD Pipeline
### GitHub Actions
When code is pushed to `main` branch:
1. **Workflows triggered**:
- `Build and Deploy` - Builds Docker image and pushes to GHCR
- `Release` - Manages semantic versioning and releases
- `Verify Examples` - Runs example tests
- `Deploy Storybooks to GitHub Pages` - Publishes Storybook
2. **Image build**:
- Built image is tagged as `latest`
- Pushed to GitHub Container Registry (ghcr.io)
- Typically completes within 1-2 minutes
3. **Deployment**:
- Watchtower detects new image (within 5 minutes)
- Pulls latest image
- Recreates and restarts container
- Total deployment time: ~5-7 minutes from push to production
## Manual Deployment Procedures
### Force Pull Latest Image
If you need to immediately deploy without waiting for Watchtower:
```bash
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
```
### Check Container Status
```bash
ssh nas.home.network "/usr/local/bin/docker ps | grep -E '(soroban|abaci)'"
```
### View Logs
```bash
# Recent logs
ssh nas.home.network "/usr/local/bin/docker logs --tail 100 soroban-abacus-flashcards"
# Follow logs in real-time
ssh nas.home.network "/usr/local/bin/docker logs -f soroban-abacus-flashcards"
# Search for specific patterns
ssh nas.home.network "/usr/local/bin/docker logs soroban-abacus-flashcards" | grep -i "error"
```
### Restart Container
```bash
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose restart"
```
## Deployment Script
The project includes a deployment script at `nas-deployment/deploy.sh` for manual deployments.
## Troubleshooting
### Common Issues
#### 1. Migration Failures
**Symptom**: Container keeps restarting, logs show migration errors
**Solution**:
1. Check migration files in `drizzle/` directory
2. Verify `drizzle/meta/_journal.json` is up to date
3. If migrations are corrupted, may need to nuke database (see above)
#### 2. Container Not Updating
**Symptom**: Changes pushed but production still shows old code
**Possible causes**:
- GitHub Actions build failed - check workflow status with `gh run list`
- Watchtower not running - check with `docker ps | grep watchtower`
- Image not pulled - manually pull with `docker-compose pull`
**Solution**:
```bash
# Force pull and restart
ssh nas.home.network "cd /volume1/homes/antialias/projects/abaci.one && /usr/local/bin/docker-compose pull && /usr/local/bin/docker-compose up -d"
```
#### 3. Missing Database Columns
**Symptom**: Errors like `SqliteError: no such column: "column_name"`
**Cause**: Migration not registered or not run
**Solution**:
1. Verify migration exists in `drizzle/` directory
2. Check migration is registered in `drizzle/meta/_journal.json`
3. If migration is new, restart container to run migrations
4. If migration is malformed, fix it and nuke database
#### 4. API Returns Unexpected Response
**Symptom**: Client shows errors but API appears to work
**Debugging**:
1. Test API directly with curl: `curl -X POST 'https://abaci.one/api/arcade/rooms' -H 'Content-Type: application/json' -d '...'`
2. Check production logs for errors
3. Verify container is running latest image:
```bash
ssh nas.home.network "/usr/local/bin/docker inspect soroban-abacus-flashcards --format '{{.Created}}'"
```
4. Compare with commit timestamp: `git log --format="%ci" -1`
## Environment Variables
Production environment variables are configured in the docker-compose.yml file on the server. Common variables:
- `NEXT_PUBLIC_URL` - Base URL for the application
- `DATABASE_URL` - SQLite database path
- Additional variables may be set in `.env.production` or docker-compose.yml
## Network Configuration
- **Reverse Proxy**: Traefik
- **HTTPS**: Automatic via Traefik with Let's Encrypt
- **Domain**: abaci.one
- **Exposed Port**: 3000 (internal to Docker network)
## Security Notes
- Production database contains user data and should be handled carefully
- SSH access is restricted to local network only
- Docker container runs with appropriate user permissions
- Secrets are managed via environment variables, not committed to repo

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

@@ -135,6 +135,16 @@ export async function POST(req: NextRequest) {
isCreator: true,
})
// Get members and active players for the response
const members = await getRoomMembers(room.id)
const memberPlayers = await getRoomActivePlayers(room.id)
// Convert Map to object for JSON serialization
const memberPlayersObj: Record<string, any[]> = {}
for (const [uid, players] of memberPlayers.entries()) {
memberPlayersObj[uid] = players
}
// Generate join URL
const baseUrl = process.env.NEXT_PUBLIC_URL || 'http://localhost:3000'
const joinUrl = `${baseUrl}/arcade/rooms/${room.id}`
@@ -142,6 +152,8 @@ export async function POST(req: NextRequest) {
return NextResponse.json(
{
room,
members,
memberPlayers: memberPlayersObj,
joinUrl,
},
{ status: 201 }

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)
@@ -70,12 +72,20 @@ export function AddPlayerButton({
},
{
onSuccess: (data) => {
// Popover stays open, switch to invite tab to share room code
setActiveTab('invite')
// Add to recent rooms
addToRecentRooms({
code: data.code,
name: data.name,
gameName: data.gameName,
})
// Close popover
setShowPopover(false)
// Navigate to the room page (singular - fetches current room)
router.push('/arcade/room')
},
onError: (error) => {
console.error('Failed to create room:', error)
alert(`Failed to create room: ${error.message}`)
showError('Failed to create room', error.message)
},
}
)
@@ -102,6 +112,8 @@ export function AddPlayerButton({
}
// Close popover
setShowPopover(false)
// Navigate to the room page (singular - fetches current room)
router.push('/arcade/room')
},
}
)

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