Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d8b5201af9 | ||
|
|
554cc4063b | ||
|
|
6bb7016eea | ||
|
|
4124f1cc08 | ||
|
|
ee39241e3c | ||
|
|
f07b96d26e | ||
|
|
a9a6cefafc | ||
|
|
710e93c997 | ||
|
|
b419e5e3ad | ||
|
|
245ed8a625 | ||
|
|
2b68ddc732 | ||
|
|
1c55f3630c | ||
|
|
1e34d57ad6 | ||
|
|
21e6e33173 | ||
|
|
6d16436133 | ||
|
|
6b489238c8 | ||
|
|
8320d9e730 | ||
|
|
a4251e660d | ||
|
|
040d7495a0 | ||
|
|
87ef35682e | ||
|
|
2fb6ead4f2 | ||
|
|
bc571e3d0d | ||
|
|
eed7c9b938 | ||
|
|
654ba19ccc | ||
|
|
f5469cda0c | ||
|
|
86e3d41996 | ||
|
|
cb11bec975 | ||
|
|
2580e474d0 | ||
|
|
55e0be8e42 | ||
|
|
dd9e657db8 | ||
|
|
51d9a37f9b | ||
|
|
07212e4df0 | ||
|
|
97daad9abb | ||
|
|
225104c3a7 | ||
|
|
249257c6c7 | ||
|
|
b37e29e53e | ||
|
|
c6886a0e59 | ||
|
|
cb2fec1da5 | ||
|
|
6beb58a7b8 | ||
|
|
544b06e290 | ||
|
|
a7c3c1f4cd | ||
|
|
090d4dac2b | ||
|
|
f865ce16ec | ||
|
|
50f45ab08e | ||
|
|
a2d53680f2 | ||
|
|
b9e7267f15 | ||
|
|
57bf8460c8 | ||
|
|
059a9fe750 | ||
|
|
036da6de66 |
168
CHANGELOG.md
168
CHANGELOG.md
@@ -1,3 +1,171 @@
|
||||
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **arcade:** add Change Game functionality for room hosts ([ee39241](https://github.com/antialias/soroban-abacus-flashcards/commit/ee39241e3c9e04202592497d9987eafcb89c00c9))
|
||||
* **arcade:** add game selection screen with navigation to room page ([4124f1c](https://github.com/antialias/soroban-abacus-flashcards/commit/4124f1cc081f5cb9d6f450f3c2e0cca8a247deba))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **player-config:** correct label positioning in player settings dialog ([554cc40](https://github.com/antialias/soroban-abacus-flashcards/commit/554cc4063bc756c9c9cd1adf0c1964d3f2f6151b))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* implement in-room game selection UI ([f07b96d](https://github.com/antialias/soroban-abacus-flashcards/commit/f07b96d26eb9f63f3ee55f721139c37ccc34c3df))
|
||||
* make game_name nullable to support in-room game selection ([a9a6cef](https://github.com/antialias/soroban-abacus-flashcards/commit/a9a6cefafcaf7340902328ef1cb02eb3fdd3aa84))
|
||||
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](https://github.com/antialias/soroban-abacus-flashcards/commit/6bb7016eea1e8ca40204a921db4a8b8fb9a06f73))
|
||||
|
||||
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](https://github.com/antialias/soroban-abacus-flashcards/commit/245ed8a625ba848f8cd79d51bfd88600cd77f0b9))
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* remove duplicate ModerationNotifications causing double toasts ([c6886a0](https://github.com/antialias/soroban-abacus-flashcards/commit/c6886a0e59b3cbf051a828e0157495101cd8c823))
|
||||
|
||||
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
|
||||
|
||||
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
|
||||
|
||||
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
|
||||
|
||||
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement proper retired room behavior with member expulsion ([a2d5368](https://github.com/antialias/soroban-abacus-flashcards/commit/a2d53680f27db04b2cd09973e62a76c5a7d4ce06))
|
||||
|
||||
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* improve join request approval error handling with actionable messages ([57bf846](https://github.com/antialias/soroban-abacus-flashcards/commit/57bf8460c8ecff374355bfb93f4b06dfbb148273))
|
||||
|
||||
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add prominent join request approval notifications for room moderators ([036da6d](https://github.com/antialias/soroban-abacus-flashcards/commit/036da6de66ca7d3f459c55df657b04a9e88d9cd3))
|
||||
|
||||
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)
|
||||
|
||||
|
||||
|
||||
191
apps/web/.claude/DEPLOYMENT.md
Normal file
191
apps/web/.claude/DEPLOYMENT.md
Normal 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
|
||||
@@ -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": []
|
||||
|
||||
2
apps/web/drizzle/0009_add_display_password.sql
Normal file
2
apps/web/drizzle/0009_add_display_password.sql
Normal 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);
|
||||
42
apps/web/drizzle/0010_make_game_name_nullable.sql
Normal file
42
apps/web/drizzle/0010_make_game_name_nullable.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- Make game_name and game_config nullable to support game selection in room
|
||||
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
|
||||
|
||||
PRAGMA foreign_keys=OFF;--> statement-breakpoint
|
||||
|
||||
-- Create temporary table with correct schema
|
||||
CREATE TABLE `arcade_rooms_new` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`code` text(6) NOT NULL,
|
||||
`name` text(50),
|
||||
`created_by` text NOT NULL,
|
||||
`creator_name` text(50) NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`last_activity` integer NOT NULL,
|
||||
`ttl_minutes` integer DEFAULT 60 NOT NULL,
|
||||
`access_mode` text DEFAULT 'open' NOT NULL,
|
||||
`password` text(255),
|
||||
`display_password` text(100),
|
||||
`game_name` text,
|
||||
`game_config` text,
|
||||
`status` text DEFAULT 'lobby' NOT NULL,
|
||||
`current_session_id` text,
|
||||
`total_games_played` integer DEFAULT 0 NOT NULL
|
||||
);--> statement-breakpoint
|
||||
|
||||
-- Copy all data
|
||||
INSERT INTO `arcade_rooms_new`
|
||||
SELECT `id`, `code`, `name`, `created_by`, `creator_name`, `created_at`,
|
||||
`last_activity`, `ttl_minutes`, `access_mode`, `password`, `display_password`,
|
||||
`game_name`, `game_config`, `status`, `current_session_id`, `total_games_played`
|
||||
FROM `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Rename new table
|
||||
ALTER TABLE `arcade_rooms_new` RENAME TO `arcade_rooms`;--> statement-breakpoint
|
||||
|
||||
-- Recreate index
|
||||
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -64,6 +64,20 @@
|
||||
"when": 1760548800000,
|
||||
"tag": "0008_make_room_name_nullable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1760600000000,
|
||||
"tag": "0009_add_display_password",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1760700000000,
|
||||
"tag": "0010_make_game_name_nullable",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import {
|
||||
createInvitation,
|
||||
declineInvitation,
|
||||
getInvitation,
|
||||
getRoomInvitations,
|
||||
} from '@/lib/arcade/room-invitations'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -35,6 +36,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
// Get room to check access mode
|
||||
const room = await getRoomById(roomId)
|
||||
if (!room) {
|
||||
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// Cannot invite to retired rooms
|
||||
if (room.accessMode === 'retired') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Cannot send invitations to retired rooms' },
|
||||
{ status: 403 }
|
||||
)
|
||||
}
|
||||
|
||||
// Check if user is the host
|
||||
const members = await getRoomMembers(roomId)
|
||||
const currentMember = members.find((m) => m.userId === viewerId)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { createJoinRequest, getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
|
||||
import { getRoomById } from '@/lib/arcade/room-manager'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
|
||||
type RouteContext = {
|
||||
@@ -87,6 +88,30 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
|
||||
)
|
||||
|
||||
// Broadcast to the room host (creator) only via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Send notification only to the room creator's user channel
|
||||
io.to(`user:${room.createdBy}`).emit('join-request-submitted', {
|
||||
roomId,
|
||||
request: {
|
||||
id: request.id,
|
||||
userId: request.userId,
|
||||
userName: request.userName,
|
||||
createdAt: request.requestedAt,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Join Requests] Broadcasted join-request-submitted to room creator ${room.createdBy}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
// Log but don't fail the request if socket broadcast fails
|
||||
console.error('[Join Requests] Failed to broadcast join-request-submitted:', socketError)
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ request }, { status: 201 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create join request:', error)
|
||||
|
||||
@@ -38,9 +38,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
|
||||
}
|
||||
|
||||
// Check if user is already a member (for locked room access)
|
||||
// Check if user is already a member (for locked/retired room access)
|
||||
const members = await getRoomMembers(roomId)
|
||||
const isExistingMember = members.some((m) => m.userId === viewerId)
|
||||
const isRoomCreator = room.createdBy === viewerId
|
||||
|
||||
// Validate access mode
|
||||
switch (room.accessMode) {
|
||||
@@ -55,7 +56,14 @@ export async function POST(req: NextRequest, context: RouteContext) {
|
||||
break
|
||||
|
||||
case 'retired':
|
||||
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
|
||||
// Only the room creator can access retired rooms
|
||||
if (!isRoomCreator) {
|
||||
return NextResponse.json(
|
||||
{ error: 'This room has been retired and is only accessible to the owner' },
|
||||
{ status: 410 }
|
||||
)
|
||||
}
|
||||
break
|
||||
|
||||
case 'password': {
|
||||
if (!body.password) {
|
||||
@@ -75,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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { db, schema } from '@/db'
|
||||
import { getRoomActivePlayers } from '@/lib/arcade/player-manager'
|
||||
import { recordRoomMemberHistory } from '@/lib/arcade/room-member-history'
|
||||
import { getRoomMembers } from '@/lib/arcade/room-membership'
|
||||
import { getSocketIO } from '@/lib/socket-io'
|
||||
import { getViewerId } from '@/lib/viewer'
|
||||
import bcrypt from 'bcryptjs'
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ roomId: string }>
|
||||
@@ -15,6 +18,8 @@ type RouteContext = {
|
||||
* Body:
|
||||
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
|
||||
* - password?: string (plain text, will be hashed)
|
||||
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
|
||||
* - gameConfig?: object (game-specific settings)
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
try {
|
||||
@@ -55,6 +60,14 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
)
|
||||
}
|
||||
|
||||
// Validate gameName if provided
|
||||
if (body.gameName !== undefined && body.gameName !== null) {
|
||||
const validGames = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: Record<string, any> = {}
|
||||
|
||||
@@ -66,12 +79,24 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
// Update game selection if provided
|
||||
if (body.gameName !== undefined) {
|
||||
updateData.gameName = body.gameName
|
||||
}
|
||||
|
||||
// Update game config if provided
|
||||
if (body.gameConfig !== undefined) {
|
||||
updateData.gameConfig = body.gameConfig
|
||||
}
|
||||
|
||||
// Update room settings
|
||||
const [updatedRoom] = await db
|
||||
.update(schema.arcadeRooms)
|
||||
@@ -79,6 +104,72 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
|
||||
.where(eq(schema.arcadeRooms.id, roomId))
|
||||
.returning()
|
||||
|
||||
// If setting to retired, expel all non-owner members
|
||||
if (body.accessMode === 'retired') {
|
||||
const nonOwnerMembers = members.filter((m) => !m.isCreator)
|
||||
|
||||
if (nonOwnerMembers.length > 0) {
|
||||
// Remove all non-owner members from the room
|
||||
await db.delete(schema.roomMembers).where(
|
||||
and(
|
||||
eq(schema.roomMembers.roomId, roomId),
|
||||
// Delete all members except the creator
|
||||
eq(schema.roomMembers.isCreator, false)
|
||||
)
|
||||
)
|
||||
|
||||
// Record in history for each expelled member
|
||||
for (const member of nonOwnerMembers) {
|
||||
await recordRoomMemberHistory({
|
||||
roomId,
|
||||
userId: member.userId,
|
||||
displayName: member.displayName,
|
||||
action: 'left',
|
||||
})
|
||||
}
|
||||
|
||||
// Broadcast updates via socket
|
||||
const io = await getSocketIO()
|
||||
if (io) {
|
||||
try {
|
||||
// Get updated member list (should only be the owner now)
|
||||
const updatedMembers = await getRoomMembers(roomId)
|
||||
const memberPlayers = await getRoomActivePlayers(roomId)
|
||||
|
||||
// Convert memberPlayers Map to object for JSON serialization
|
||||
const memberPlayersObj: Record<string, any[]> = {}
|
||||
for (const [uid, players] of memberPlayers.entries()) {
|
||||
memberPlayersObj[uid] = players
|
||||
}
|
||||
|
||||
// Notify each expelled member
|
||||
for (const member of nonOwnerMembers) {
|
||||
io.to(`user:${member.userId}`).emit('kicked-from-room', {
|
||||
roomId,
|
||||
kickedBy: currentMember.displayName,
|
||||
reason: 'Room has been retired',
|
||||
})
|
||||
}
|
||||
|
||||
// Notify the owner that members were expelled
|
||||
io.to(`room:${roomId}`).emit('member-left', {
|
||||
roomId,
|
||||
userId: nonOwnerMembers.map((m) => m.userId),
|
||||
members: updatedMembers,
|
||||
memberPlayers: memberPlayersObj,
|
||||
reason: 'room-retired',
|
||||
})
|
||||
|
||||
console.log(
|
||||
`[Settings API] Expelled ${nonOwnerMembers.length} members from retired room ${roomId}`
|
||||
)
|
||||
} catch (socketError) {
|
||||
console.error('[Settings API] Failed to broadcast member expulsion:', socketError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ room: updatedRoom }, { status: 200 })
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update room settings:', error)
|
||||
|
||||
@@ -70,15 +70,12 @@ export async function POST(req: NextRequest) {
|
||||
const viewerId = await getViewerId()
|
||||
const body = await req.json()
|
||||
|
||||
// Validate required fields (name is optional, gameName is required)
|
||||
if (!body.gameName) {
|
||||
return NextResponse.json({ error: 'Missing required field: gameName' }, { status: 400 })
|
||||
}
|
||||
|
||||
// Validate game name
|
||||
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
// Validate game name if provided (gameName is now optional)
|
||||
if (body.gameName) {
|
||||
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
|
||||
if (!validGames.includes(body.gameName)) {
|
||||
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
// Validate name length (if provided)
|
||||
@@ -120,8 +117,8 @@ export async function POST(req: NextRequest) {
|
||||
name: roomName,
|
||||
createdBy: viewerId,
|
||||
creatorName: displayName,
|
||||
gameName: body.gameName,
|
||||
gameConfig: body.gameConfig || {},
|
||||
gameName: body.gameName || null,
|
||||
gameConfig: body.gameConfig || null,
|
||||
ttlMinutes: body.ttlMinutes,
|
||||
accessMode: body.accessMode,
|
||||
password: body.password,
|
||||
@@ -135,6 +132,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 +149,8 @@ export async function POST(req: NextRequest) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
room,
|
||||
members,
|
||||
memberPlayers: memberPlayersObj,
|
||||
joinUrl,
|
||||
},
|
||||
{ status: 201 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ export function MemoryPairsGame() {
|
||||
<PageWithNav
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
onExitSession={() => {
|
||||
exitSession()
|
||||
router.push('/arcade')
|
||||
|
||||
@@ -78,7 +78,7 @@ function ArcadeContent() {
|
||||
|
||||
function ArcadePageWithRedirect() {
|
||||
return (
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizeGameContext={true}>
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
|
||||
@@ -1,70 +1,198 @@
|
||||
'use client'
|
||||
|
||||
import { useRoomData } from '@/hooks/useRoomData'
|
||||
import { ModerationNotifications } from '@/components/nav/ModerationNotifications'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
|
||||
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
'battle-arena': 'matching',
|
||||
'memory-lightning': 'memory-quiz',
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const { roomData, isLoading, moderationEvent, clearModerationEvent } = useRoomData()
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
|
||||
</>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
const gameConfig = GAMES_CONFIG[gameType]
|
||||
if (gameConfig.available === false) {
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
|
||||
// Map GameType to internal game name
|
||||
const internalGameName = GAME_TYPE_TO_NAME[gameType]
|
||||
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
gameConfig: {},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
padding: '4',
|
||||
})}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={config.available === false}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: config.available === false ? 'not-allowed' : 'pointer',
|
||||
opacity: config.available === false ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover:
|
||||
config.available === false
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{config.name}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{config.description}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
|
||||
</>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -72,21 +200,20 @@ export default function RoomPage() {
|
||||
switch (roomData.gameName) {
|
||||
case 'matching':
|
||||
return (
|
||||
<>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
<ModerationNotifications
|
||||
moderationEvent={moderationEvent}
|
||||
onClose={clearModerationEvent}
|
||||
/>
|
||||
</>
|
||||
<RoomMemoryPairsProvider>
|
||||
<MemoryPairsGame />
|
||||
</RoomMemoryPairsProvider>
|
||||
)
|
||||
|
||||
// TODO: Add other games (complement-race, memory-quiz, etc.)
|
||||
default:
|
||||
return (
|
||||
<>
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -99,11 +226,7 @@ export default function RoomPage() {
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
<ModerationNotifications
|
||||
moderationEvent={moderationEvent}
|
||||
onClose={clearModerationEvent}
|
||||
/>
|
||||
</>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ export function MemoryPairsGame() {
|
||||
navTitle={navTitle}
|
||||
navEmoji={navEmoji}
|
||||
gameName="matching"
|
||||
emphasizeGameContext={state.gamePhase === 'setup'}
|
||||
emphasizePlayerSelection={state.gamePhase === 'setup'}
|
||||
currentPlayerId={state.currentPlayer}
|
||||
playerScores={state.scores}
|
||||
playerStreaks={state.consecutiveMatches}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ interface PageWithNavProps {
|
||||
navTitle?: string
|
||||
navEmoji?: string
|
||||
gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API
|
||||
emphasizeGameContext?: boolean
|
||||
emphasizePlayerSelection?: boolean
|
||||
onExitSession?: () => void
|
||||
onSetup?: () => void
|
||||
onNewGame?: () => void
|
||||
@@ -28,7 +28,7 @@ export function PageWithNav({
|
||||
navTitle,
|
||||
navEmoji,
|
||||
gameName,
|
||||
emphasizeGameContext = false,
|
||||
emphasizePlayerSelection = false,
|
||||
onExitSession,
|
||||
onSetup,
|
||||
onNewGame,
|
||||
@@ -103,7 +103,7 @@ export function PageWithNav({
|
||||
? 'tournament'
|
||||
: 'none'
|
||||
|
||||
const shouldEmphasize = emphasizeGameContext && mounted
|
||||
const shouldEmphasize = emphasizePlayerSelection && mounted
|
||||
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
|
||||
|
||||
// Compute arcade session info for display
|
||||
|
||||
235
apps/web/src/components/common/ToastContext.tsx
Normal file
235
apps/web/src/components/common/ToastContext.tsx
Normal 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 toastSlideIn {
|
||||
from {
|
||||
transform: translateX(calc(100% + 25px));
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastSlideOut {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(calc(100% + 25px));
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastHide {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-state='open'] {
|
||||
animation: toastSlideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-state='closed'] {
|
||||
animation: toastHide 100ms ease-in, toastSlideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-swipe='move'] {
|
||||
transform: translateX(var(--radix-toast-swipe-move-x));
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-swipe='cancel'] {
|
||||
transform: translateX(0);
|
||||
transition: transform 200ms ease-out;
|
||||
}
|
||||
|
||||
[data-radix-toast-viewport] [data-swipe='end'] {
|
||||
animation: toastSlideOut 100ms ease-out;
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Toast.Provider>
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -60,22 +62,29 @@ export function AddPlayerButton({
|
||||
const { mutate: joinRoom } = useJoinRoom()
|
||||
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
|
||||
|
||||
// Handler for creating a new room
|
||||
// Handler for creating a new room (without a game - game will be selected in room)
|
||||
const handleCreateRoom = () => {
|
||||
createRoom(
|
||||
{
|
||||
name: `${gameName} Room`,
|
||||
gameName: gameName,
|
||||
name: null, // Auto-generated from code
|
||||
gameName: null, // No game selected yet - will be chosen in room
|
||||
creatorName: 'Player',
|
||||
},
|
||||
{
|
||||
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 and navigate to room to choose game
|
||||
setShowPopover(false)
|
||||
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)
|
||||
},
|
||||
}
|
||||
)
|
||||
@@ -100,8 +109,9 @@ export function AddPlayerButton({
|
||||
gameName: data.room.gameName,
|
||||
})
|
||||
}
|
||||
// Close popover
|
||||
// Close popover and navigate to room
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import * as Toast from '@radix-ui/react-toast'
|
||||
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'
|
||||
|
||||
@@ -25,8 +27,13 @@ export function ModerationNotifications({
|
||||
onClose,
|
||||
}: 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)
|
||||
const [isProcessingRequest, setIsProcessingRequest] = useState(false)
|
||||
const [requestError, setRequestError] = useState<string | null>(null)
|
||||
const { mutateAsync: joinRoom } = useJoinRoom()
|
||||
|
||||
// Handle report toast (for hosts)
|
||||
@@ -42,8 +49,94 @@ export function ModerationNotifications({
|
||||
}
|
||||
}, [moderationEvent, onClose])
|
||||
|
||||
// Handle join request toast (for hosts)
|
||||
useEffect(() => {
|
||||
if (moderationEvent?.type === 'join-request') {
|
||||
setShowJoinRequestToast(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
} else {
|
||||
// Reset toast state when event is cleared or changes type
|
||||
setShowJoinRequestToast(false)
|
||||
setRequestError(null)
|
||||
}
|
||||
}, [moderationEvent])
|
||||
|
||||
// Handle approve join request
|
||||
const handleApprove = async () => {
|
||||
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
|
||||
|
||||
setIsProcessingRequest(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to approve join request')
|
||||
}
|
||||
|
||||
// Close toast and event on success
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
|
||||
// Invalidate join requests query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
|
||||
} catch (error) {
|
||||
console.error('Failed to approve join request:', error)
|
||||
// Keep toast visible and show error message
|
||||
setRequestError(error instanceof Error ? error.message : 'Failed to approve request')
|
||||
} finally {
|
||||
setIsProcessingRequest(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deny join request
|
||||
const handleDeny = async () => {
|
||||
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
|
||||
|
||||
setIsProcessingRequest(true)
|
||||
setRequestError(null) // Clear any previous errors
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.error || 'Failed to deny join request')
|
||||
}
|
||||
|
||||
// Close toast and event on success
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
|
||||
// Invalidate join requests query to refresh the list
|
||||
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
|
||||
} catch (error) {
|
||||
console.error('Failed to deny join request:', error)
|
||||
// Keep toast visible and show error message
|
||||
setRequestError(error instanceof Error ? error.message : 'Failed to deny request')
|
||||
} finally {
|
||||
setIsProcessingRequest(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Kicked modal
|
||||
if (moderationEvent?.type === 'kicked') {
|
||||
const isRetired = moderationEvent.data.reason?.includes('retired')
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={() => {}}>
|
||||
<div
|
||||
@@ -54,7 +147,7 @@ export function ModerationNotifications({
|
||||
minWidth: '400px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>⚠️</div>
|
||||
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isRetired ? '🏁' : '⚠️'}</div>
|
||||
<h2
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
@@ -63,7 +156,7 @@ export function ModerationNotifications({
|
||||
color: 'rgba(253, 186, 116, 1)',
|
||||
}}
|
||||
>
|
||||
Kicked from Room
|
||||
{isRetired ? 'Room Retired' : 'Kicked from Room'}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
@@ -72,10 +165,16 @@ export function ModerationNotifications({
|
||||
marginBottom: '8px',
|
||||
}}
|
||||
>
|
||||
You were kicked from the room by{' '}
|
||||
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
|
||||
{moderationEvent.data.kickedBy}
|
||||
</strong>
|
||||
{isRetired ? (
|
||||
<>The room owner has retired this room and access has been closed</>
|
||||
) : (
|
||||
<>
|
||||
You were kicked from the room by{' '}
|
||||
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
|
||||
{moderationEvent.data.kickedBy}
|
||||
</strong>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
<p
|
||||
style={{
|
||||
@@ -84,7 +183,9 @@ export function ModerationNotifications({
|
||||
marginBottom: '24px',
|
||||
}}
|
||||
>
|
||||
You can rejoin if the host sends you a new invite
|
||||
{isRetired
|
||||
? 'Only the room owner can access retired rooms'
|
||||
: 'You can rejoin if the host sends you a new invite'}
|
||||
</p>
|
||||
|
||||
<button
|
||||
@@ -391,6 +492,255 @@ export function ModerationNotifications({
|
||||
)
|
||||
}
|
||||
|
||||
// Join request toast (for hosts)
|
||||
if (moderationEvent?.type === 'join-request') {
|
||||
return (
|
||||
<Toast.Provider swipeDirection="right" duration={Infinity}>
|
||||
<Toast.Root
|
||||
open={showJoinRequestToast}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setShowJoinRequestToast(false)
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
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)',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.4)',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
minWidth: '350px',
|
||||
maxWidth: '450px',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: '24px', flexShrink: 0 }}>✋</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Toast.Title
|
||||
style={{
|
||||
fontSize: '15px',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
Join Request
|
||||
</Toast.Title>
|
||||
<Toast.Description
|
||||
style={{
|
||||
fontSize: '13px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<strong>{moderationEvent.data.requesterName}</strong> wants to join your room
|
||||
</Toast.Description>
|
||||
|
||||
{/* Error message */}
|
||||
{requestError && (
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 10px',
|
||||
background: 'rgba(239, 68, 68, 0.2)',
|
||||
border: '1px solid rgba(239, 68, 68, 0.4)',
|
||||
borderRadius: '6px',
|
||||
marginBottom: '12px',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'rgba(254, 202, 202, 1)',
|
||||
fontWeight: '600',
|
||||
marginBottom: '2px',
|
||||
}}
|
||||
>
|
||||
⚠️ Error
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: 'rgba(255, 255, 255, 0.9)',
|
||||
}}
|
||||
>
|
||||
{requestError}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isProcessingRequest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeny()
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: isProcessingRequest
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'rgba(255, 255, 255, 0.2)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isProcessingRequest ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.25)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.2)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isProcessingRequest}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleApprove()
|
||||
}}
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '8px 12px',
|
||||
background: isProcessingRequest
|
||||
? 'rgba(75, 85, 99, 0.3)'
|
||||
: 'rgba(34, 197, 94, 0.9)',
|
||||
color: 'white',
|
||||
border: '1px solid rgba(34, 197, 94, 0.8)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '13px',
|
||||
fontWeight: '600',
|
||||
cursor: isProcessingRequest ? 'not-allowed' : 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: isProcessingRequest ? 0.5 : 1,
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 1)'
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isProcessingRequest) {
|
||||
e.currentTarget.style.background = 'rgba(34, 197, 94, 0.9)'
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isProcessingRequest ? 'Processing...' : 'Approve'}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
// Invitation modal
|
||||
if (moderationEvent?.type === 'invitation') {
|
||||
const invitationType = moderationEvent.data.invitationType
|
||||
@@ -486,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)
|
||||
}
|
||||
}}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,89 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
width: '100%',
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '4px',
|
||||
textAlign: 'right',
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
{localName.length}/20 characters
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="text"
|
||||
value={localName}
|
||||
onChange={(e) => handleNameChange(e.target.value)}
|
||||
placeholder="Player Name"
|
||||
maxLength={20}
|
||||
style={{
|
||||
width: '100%',
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '6px',
|
||||
}}
|
||||
>
|
||||
{localName.length}/20 characters
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flexShrink: 0 }}>
|
||||
<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',
|
||||
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
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: '#6b7280',
|
||||
marginTop: '6px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Random name
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { useClearRoomGame, useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
|
||||
import { CreateRoomModal } from './CreateRoomModal'
|
||||
@@ -62,6 +62,7 @@ export function RoomInfo({
|
||||
const { getRoomShareUrl, roomData } = useRoomData()
|
||||
const { data: currentUserId } = useViewerId()
|
||||
const { mutateAsync: leaveRoom } = useLeaveRoom()
|
||||
const { mutate: clearRoomGame } = useClearRoomGame()
|
||||
|
||||
// Use room display utility for consistent naming
|
||||
const displayName = joinCode
|
||||
@@ -403,6 +404,43 @@ export function RoomInfo({
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{/* Change Game - only show for host and only when a game is selected */}
|
||||
{isCurrentUserCreator && roomId && roomData?.gameName && (
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => {
|
||||
if (roomId) {
|
||||
clearRoomGame(roomId)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '10px 14px',
|
||||
borderRadius: '8px',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
color: 'rgba(209, 213, 219, 1)',
|
||||
fontSize: '14px',
|
||||
fontWeight: '500',
|
||||
cursor: 'pointer',
|
||||
outline: 'none',
|
||||
transition: 'all 0.2s ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'rgba(236, 72, 153, 0.2)'
|
||||
e.currentTarget.style.color = 'rgba(249, 168, 212, 1)'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent'
|
||||
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '16px' }}>🔄</span>
|
||||
<span>Change Game</span>
|
||||
</DropdownMenu.Item>
|
||||
)}
|
||||
|
||||
{/* Moderation - only show for host */}
|
||||
{isCurrentUserCreator && roomId && (
|
||||
<DropdownMenu.Item
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Available character emojis for players
|
||||
export const PLAYER_EMOJIS = [
|
||||
// Abacus
|
||||
'🧮',
|
||||
|
||||
// People & Characters
|
||||
'😀',
|
||||
'😃',
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,12 +30,13 @@ 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
|
||||
// Game configuration (nullable to support game selection in room)
|
||||
gameName: text('game_name', {
|
||||
enum: ['matching', 'memory-quiz', 'complement-race'],
|
||||
}).notNull(),
|
||||
gameConfig: text('game_config', { mode: 'json' }).notNull(), // Game-specific settings
|
||||
}),
|
||||
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
|
||||
|
||||
// Current state
|
||||
status: text('status', {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { io, type Socket } from 'socket.io-client'
|
||||
import { useViewerId } from './useViewerId'
|
||||
|
||||
@@ -22,7 +22,7 @@ export interface RoomData {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
gameName: string
|
||||
gameName: string | null // Nullable to support game selection in room
|
||||
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
members: RoomMember[]
|
||||
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
|
||||
@@ -30,7 +30,7 @@ export interface RoomData {
|
||||
|
||||
export interface CreateRoomParams {
|
||||
name: string | null
|
||||
gameName: string
|
||||
gameName?: string | null // Optional - rooms can be created without a game
|
||||
creatorName?: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
|
||||
@@ -86,9 +86,9 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: params.name,
|
||||
gameName: params.gameName,
|
||||
gameName: params.gameName || null,
|
||||
creatorName: params.creatorName || 'Player',
|
||||
gameConfig: params.gameConfig || { difficulty: 6 },
|
||||
gameConfig: params.gameConfig || null,
|
||||
accessMode: params.accessMode,
|
||||
password: params.password,
|
||||
}),
|
||||
@@ -190,7 +190,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
|
||||
}
|
||||
|
||||
export interface ModerationEvent {
|
||||
type: 'kicked' | 'banned' | 'report' | 'invitation'
|
||||
type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request'
|
||||
data: {
|
||||
roomId?: string
|
||||
kickedBy?: string
|
||||
@@ -206,6 +206,10 @@ export interface ModerationEvent {
|
||||
invitedByName?: string
|
||||
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
|
||||
message?: string
|
||||
// Join request fields
|
||||
requestId?: string
|
||||
requesterId?: string
|
||||
requesterName?: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,13 +347,14 @@ export function useRoomData() {
|
||||
}
|
||||
|
||||
// Moderation event handlers
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
|
||||
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
|
||||
console.log('[useRoomData] User was kicked from room:', data)
|
||||
setModerationEvent({
|
||||
type: 'kicked',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
kickedBy: data.kickedBy,
|
||||
reason: data.reason,
|
||||
},
|
||||
})
|
||||
// Clear room data since user was kicked
|
||||
@@ -420,6 +425,27 @@ export function useRoomData() {
|
||||
})
|
||||
}
|
||||
|
||||
const handleJoinRequestSubmitted = (data: {
|
||||
roomId: string
|
||||
request: {
|
||||
id: string
|
||||
userId: string
|
||||
userName: string
|
||||
createdAt: Date
|
||||
}
|
||||
}) => {
|
||||
console.log('[useRoomData] New join request submitted:', data)
|
||||
setModerationEvent({
|
||||
type: 'join-request',
|
||||
data: {
|
||||
roomId: data.roomId,
|
||||
requestId: data.request.id,
|
||||
requesterId: data.request.userId,
|
||||
requesterName: data.request.userName,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
socket.on('room-joined', handleRoomJoined)
|
||||
socket.on('member-joined', handleMemberJoined)
|
||||
socket.on('member-left', handleMemberLeft)
|
||||
@@ -428,6 +454,7 @@ export function useRoomData() {
|
||||
socket.on('banned-from-room', handleBannedFromRoom)
|
||||
socket.on('report-submitted', handleReportSubmitted)
|
||||
socket.on('room-invitation-received', handleInvitationReceived)
|
||||
socket.on('join-request-submitted', handleJoinRequestSubmitted)
|
||||
|
||||
return () => {
|
||||
socket.off('room-joined', handleRoomJoined)
|
||||
@@ -438,6 +465,7 @@ export function useRoomData() {
|
||||
socket.off('banned-from-room', handleBannedFromRoom)
|
||||
socket.off('report-submitted', handleReportSubmitted)
|
||||
socket.off('room-invitation-received', handleInvitationReceived)
|
||||
socket.off('join-request-submitted', handleJoinRequestSubmitted)
|
||||
}
|
||||
}, [socket, roomData?.id, queryClient])
|
||||
|
||||
@@ -530,3 +558,91 @@ export function useGetRoomByCode() {
|
||||
mutationFn: getRoomByCodeApi,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Set game for a room
|
||||
*/
|
||||
async function setRoomGameApi(params: {
|
||||
roomId: string
|
||||
gameName: string
|
||||
gameConfig?: Record<string, unknown>
|
||||
}): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameName: params.gameName,
|
||||
gameConfig: params.gameConfig || {},
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to set room game')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Set game for a room
|
||||
*/
|
||||
export function useSetRoomGame() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: setRoomGameApi,
|
||||
onSuccess: (_, variables) => {
|
||||
// Update the cache with the new game
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: variables.gameName,
|
||||
}
|
||||
})
|
||||
// Refetch to get the full updated room data
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear/reset game for a room (host only)
|
||||
*/
|
||||
async function clearRoomGameApi(roomId: string): Promise<void> {
|
||||
const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
gameName: null,
|
||||
gameConfig: null,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || 'Failed to clear room game')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: Clear/reset game for a room (returns to game selection screen)
|
||||
*/
|
||||
export function useClearRoomGame() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation({
|
||||
mutationFn: clearRoomGameApi,
|
||||
onSuccess: () => {
|
||||
// Update the cache to clear the game
|
||||
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
|
||||
if (!prev) return null
|
||||
return {
|
||||
...prev,
|
||||
gameName: null,
|
||||
}
|
||||
})
|
||||
// Refetch to get the full updated room data
|
||||
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,6 +62,7 @@ describe('Room Manager', () => {
|
||||
ttlMinutes: 60,
|
||||
accessMode: 'open',
|
||||
password: null,
|
||||
displayPassword: null,
|
||||
gameName: 'matching',
|
||||
gameConfig: { difficulty: 6 },
|
||||
status: 'lobby',
|
||||
|
||||
92
apps/web/src/utils/__tests__/playerNames.test.ts
Normal file
92
apps/web/src/utils/__tests__/playerNames.test.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
162
apps/web/src/utils/playerNames.ts
Normal file
162
apps/web/src/utils/playerNames.ts
Normal 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
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "3.6.3",
|
||||
"version": "3.14.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user