Compare commits

...

32 Commits

Author SHA1 Message Date
semantic-release-bot
b9e7267f15 chore(release): 3.7.1 [skip ci]
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)

### Bug Fixes

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:02:56 -05:00
semantic-release-bot
059a9fe750 chore(release): 3.7.0 [skip ci]
## [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](036da6de66))
2025-10-14 13:00:09 +00:00
Thomas Hallock
036da6de66 feat: add prominent join request approval notifications for room moderators
- Add 'join-request' type to ModerationEvent interface
- Add socket listener for 'join-request-submitted' event in useRoomData
- Update join request POST endpoint to broadcast socket event to room members
- Add prominent toast notification with inline approve/deny buttons in ModerationNotifications
- Toast appears immediately when host receives join request (not buried in settings)
- Approve/deny actions handled directly from toast with API calls

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:59:10 -05:00
semantic-release-bot
556e5e4ca0 chore(release): 3.6.3 [skip ci]
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)

### Bug Fixes

* update locked room terminology and allow existing members ([1ddf985](1ddf985938))
2025-10-14 12:50:45 +00:00
Thomas Hallock
1ddf985938 fix: update locked room terminology and allow existing members
Update locked room terminology and implementation:
- Change description from "No members" to "No new members"
- Allow existing members to continue using locked rooms
- Only block new members from joining locked rooms
- Update join API to check membership before rejecting

This clarifies that "locked" means no NEW members, but existing members
can continue to participate in the room.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:49:52 -05:00
semantic-release-bot
8c851462de chore(release): 3.6.2 [skip ci]
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)

### Bug Fixes

* allow join with pending invitation for restricted rooms ([85b2cf9](85b2cf9816))
2025-10-14 12:48:24 +00:00
Thomas Hallock
85b2cf9816 fix: allow join with pending invitation for restricted rooms
Remove premature check that blocked access to restricted rooms. Now:
- Frontend no longer blocks restricted room access upfront
- Backend API checks for pending invitation
- Users with valid invitations can join successfully
- Users without invitations get appropriate error message

This fixes the issue where users with pending invitations couldn't join
restricted rooms via the join link.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:47:28 -05:00
semantic-release-bot
4c6eb01f1e chore(release): 3.6.1 [skip ci]
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)

### Bug Fixes

* join user socket channel to receive approval notifications ([7d08fdd](7d08fdd906))

### Code Refactoring

* remove redundant polling from approval notifications ([0d4f400](0d4f400dca))
2025-10-14 12:44:53 +00:00
Thomas Hallock
7d08fdd906 fix: join user socket channel to receive approval notifications
The socket wasn't receiving join-request-approved events because it hadn't
joined the user-specific channel. Now:

- Fetch viewer ID from /api/viewer endpoint
- Emit 'join-user-channel' with userId on socket connect
- Socket joins `user:${userId}` room to receive moderation events
- Approval notifications now trigger automatic room join

This completes the real-time approval notification flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:43:49 -05:00
Thomas Hallock
0d4f400dca refactor: remove redundant polling from approval notifications
Remove polling interval that checked every 5 seconds for approval status.
The socket.io listener provides real-time notifications, making polling
unnecessary and wasteful.

Now relies solely on socket.io for instant approval notifications, which:
- Reduces network traffic
- Simplifies code
- Provides faster response time

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:40:02 -05:00
semantic-release-bot
396b6c07c7 chore(release): 3.6.0 [skip ci]
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)

### Features

* add socket listener and polling for approval notifications ([35b4a72](35b4a72c8b))
2025-10-14 12:38:33 +00:00
Thomas Hallock
35b4a72c8b feat: add socket listener and polling for approval notifications
When users request to join an approval-only room, they now receive real-time
notifications when their request is approved:

- Add socket.io-client listener for 'join-request-approved' events
- Implement polling fallback (every 5 seconds) to check approval status
- Automatically join room when approval is detected via socket or polling
- Apply to both share link page and JoinRoomModal

This completes the approval flow - users no longer need to reload the page
to see if their join request was approved.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:37:28 -05:00
semantic-release-bot
ba916e0f65 chore(release): 3.5.0 [skip ci]
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)

### Features

* replace access mode dropdown with visual button grid ([e5d0672](e5d0672059))
2025-10-14 12:31:26 +00:00
Thomas Hallock
e5d0672059 feat: replace access mode dropdown with visual button grid
Updated the ModerationPanel Settings tab to use a visual button grid
for access mode selection, matching the CreateRoomModal UX.

Changes:
- Replaced <select> dropdown with 3x2 grid of buttons
- Each button shows emoji + label + description
- Visual feedback for selected state and hover
- Includes all 6 access modes: open, password, approval-only,
  restricted, locked, retired
- Maintains same functionality with improved UX

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:30:26 -05:00
semantic-release-bot
5b4c69693d chore(release): 3.4.0 [skip ci]
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)

### Features

* add waiting state for approval requests in JoinRoomModal ([f9b0429](f9b0429a2e))
2025-10-14 12:29:42 +00:00
Thomas Hallock
f9b0429a2e feat: add waiting state for approval requests in JoinRoomModal
When users enter an approval-only room code in the JoinRoomModal, they now:
- See a prompt to send a join request
- After sending, see a "Waiting for Approval" screen
- Can close the modal and check back later

This matches the UX flow from the share link approval flow.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:28:41 -05:00
semantic-release-bot
34998d6b27 chore(release): 3.3.1 [skip ci]
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)

### Bug Fixes

* add POST handler for join requests API endpoint ([d3e5cdf](d3e5cdfc54))
2025-10-14 12:27:05 +00:00
Thomas Hallock
d3e5cdfc54 fix: add POST handler for join requests API endpoint
Previously the endpoint only had a GET handler, causing a 405 error
when users tried to request approval for approval-only rooms.

Now users can POST to create join requests with optional displayName.

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

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

### Features

* implement approval request flow for share links ([4a6b3ca](4a6b3cabe5))
2025-10-14 12:20:01 +00:00
Thomas Hallock
4a6b3cabe5 feat: implement approval request flow for share links
When users click share links for approval-only rooms, they now:
- See a prompt to request approval from the room moderator
- Can send a join request with one click
- Get a waiting screen showing their request is pending

Room moderators now see:
- A prominent blue badge showing pending join request count
- Combined count of join requests + reports in the badge
- Badge turns blue when join requests exist (vs red for reports only)
- Detailed tooltip showing breakdown of pending items
- Real-time polling (30s intervals) for new join requests

Also includes improvements to room display names using emoji prefixes.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:19:05 -05:00
semantic-release-bot
2cb6a512fe chore(release): 3.2.1 [skip ci]
## [3.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.0...v3.2.1) (2025-10-14)

### Bug Fixes

* allow password retry when joining via share link ([e469363](e469363699))
2025-10-14 12:13:25 +00:00
Thomas Hallock
e469363699 fix: allow password retry when joining via share link
- Password errors now stay in password prompt UI instead of redirecting to error page
- Error message clears when user starts typing new password
- Users can now retry incorrect passwords without losing the join flow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:12:26 -05:00
semantic-release-bot
b230cd7a1f chore(release): 3.2.0 [skip ci]
## [3.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.2...v3.2.0) (2025-10-14)

### Features

* improve room creation UX and add password support for share links ([dcbb507](dcbb5072d8))
2025-10-14 12:10:25 +00:00
Thomas Hallock
dcbb5072d8 feat: improve room creation UX and add password support for share links
- Update placeholder text in room creation forms to show auto-generated format
- Make room.name nullable in database schema (migration 0008)
- Add accessMode field to RoomData interface
- Implement password prompt UI for password-protected rooms via share links
- Add password support to room browser join flow
- Remove autoFocus attribute for accessibility compliance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 07:09:22 -05:00
semantic-release-bot
f9ec5d32c5 chore(release): 3.1.2 [skip ci]
## [3.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.1...v3.1.2) (2025-10-14)

### Bug Fixes

* replace last remaining isLoading with isPending in CreateRoomModal ([85d13cc](85d13cc552))
2025-10-14 01:14:40 +00:00
Thomas Hallock
85d13cc552 fix: replace last remaining isLoading with isPending in CreateRoomModal
Missed one instance in the select dropdown cursor style.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 20:13:44 -05:00
semantic-release-bot
ef8a29e8ef chore(release): 3.1.1 [skip ci]
## [3.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.0...v3.1.1) (2025-10-14)

### Bug Fixes

* use useCreateRoom hook instead of nonexistent createRoom from useRoomData ([f7d63b3](f7d63b30ac))
2025-10-14 00:54:35 +00:00
Thomas Hallock
f7d63b30ac fix: use useCreateRoom hook instead of nonexistent createRoom from useRoomData
The CreateRoomModal was trying to destructure createRoom from useRoomData(),
but that hook doesn't export it. Changed to use the proper useCreateRoom() hook.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:53:39 -05:00
semantic-release-bot
441c04f9e6 chore(release): 3.1.0 [skip ci]
## [3.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.0.0...v3.1.0) (2025-10-14)

### Features

* add room access modes and ownership transfer ([6ff21c4](6ff21c4f1d))

### Bug Fixes

* replace isLocked with accessMode and add bcryptjs ([a74b96b](a74b96bb6f))
2025-10-14 00:45:23 +00:00
Thomas Hallock
a74b96bb6f fix: replace isLocked with accessMode and add bcryptjs
- Updated all test files to use accessMode instead of isLocked field
- Fixed room-manager tests to reflect new access control schema
- Installed bcryptjs dependency for password hashing
- All access mode TypeScript compilation errors resolved

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:44:14 -05:00
Thomas Hallock
6ff21c4f1d feat: add room access modes and ownership transfer
Add comprehensive access control system for arcade rooms with 6 modes:
- open: Anyone can join (default)
- locked: Only current members allowed
- retired: Room no longer functions
- password: Requires password to join
- restricted: Only users with pending invitations can join
- approval-only: Requires host approval via join request system

Database Changes:
- Add accessMode field to arcade_rooms (replaces isLocked boolean with enum)
- Add password field to arcade_rooms (hashed with bcrypt)
- Create room_join_requests table for approval-only mode

New API Endpoints:
- PATCH /api/arcade/rooms/:roomId/settings - Update room access mode and password (host only)
- POST /api/arcade/rooms/:roomId/transfer-ownership - Transfer ownership to another member (host only)
- POST /api/arcade/rooms/:roomId/join-request - Request to join approval-only room
- GET /api/arcade/rooms/:roomId/join-requests - Get pending join requests (host only)
- POST /api/arcade/rooms/:roomId/join-requests/:requestId/approve - Approve join request (host only)
- POST /api/arcade/rooms/:roomId/join-requests/:requestId/deny - Deny join request (host only)

Updated Endpoints:
- POST /api/arcade/rooms/:roomId/join - Now validates access modes before allowing join:
  * locked: Rejects all joins
  * retired: Rejects all joins (410 Gone)
  * password: Requires password validation
  * restricted: Requires valid pending invitation
  * approval-only: Requires approved join request
  * open: Allows anyone (existing behavior)

Libraries:
- Add room-join-requests.ts for managing join request lifecycle
- Ownership transfer updates room.createdBy and member.isCreator flags
- Socket.io events for join request notifications and ownership transfers

Migration: 0007_access_modes.sql

Next Steps (UI not included in this commit):
- RoomSettingsModal for configuring access mode and password
- Join request approval UI in ModerationPanel
- Ownership transfer UI in ModerationPanel
- Password input in join flow

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-13 19:19:49 -05:00
35 changed files with 10340 additions and 12748 deletions

View File

@@ -1,3 +1,118 @@
## [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)
### Bug Fixes
* update locked room terminology and allow existing members ([1ddf985](https://github.com/antialias/soroban-abacus-flashcards/commit/1ddf985938d9542fe26e44da58234f3d4e3c9543))
## [3.6.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.1...v3.6.2) (2025-10-14)
### Bug Fixes
* allow join with pending invitation for restricted rooms ([85b2cf9](https://github.com/antialias/soroban-abacus-flashcards/commit/85b2cf98167ccf632ab634a94eb436e1eb584614))
## [3.6.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.0...v3.6.1) (2025-10-14)
### Bug Fixes
* join user socket channel to receive approval notifications ([7d08fdd](https://github.com/antialias/soroban-abacus-flashcards/commit/7d08fdd90643920857eda09998ac01afbae74154))
### Code Refactoring
* remove redundant polling from approval notifications ([0d4f400](https://github.com/antialias/soroban-abacus-flashcards/commit/0d4f400dca02ad9497522c24fded8b6d07d85fd2))
## [3.6.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.5.0...v3.6.0) (2025-10-14)
### Features
* add socket listener and polling for approval notifications ([35b4a72](https://github.com/antialias/soroban-abacus-flashcards/commit/35b4a72c8b2f80a74b5d2fe02b048d4ec4d1d6f2))
## [3.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.4.0...v3.5.0) (2025-10-14)
### Features
* replace access mode dropdown with visual button grid ([e5d0672](https://github.com/antialias/soroban-abacus-flashcards/commit/e5d067205989d7c3105998dcd7d67fd0408f332c))
## [3.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.1...v3.4.0) (2025-10-14)
### Features
* add waiting state for approval requests in JoinRoomModal ([f9b0429](https://github.com/antialias/soroban-abacus-flashcards/commit/f9b0429a2e2d22944acba66009dd87a9d9eb28c2))
## [3.3.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.3.0...v3.3.1) (2025-10-14)
### Bug Fixes
* add POST handler for join requests API endpoint ([d3e5cdf](https://github.com/antialias/soroban-abacus-flashcards/commit/d3e5cdfc54f2749f27c6f8b8db854a8d0b6029f8))
## [3.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.1...v3.3.0) (2025-10-14)
### Features
* implement approval request flow for share links ([4a6b3ca](https://github.com/antialias/soroban-abacus-flashcards/commit/4a6b3cabe5c6aa42f4fa00ed09f9b3713f097539))
## [3.2.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.2.0...v3.2.1) (2025-10-14)
### Bug Fixes
* allow password retry when joining via share link ([e469363](https://github.com/antialias/soroban-abacus-flashcards/commit/e469363699071610a35e0b5c507d0e15e29daa44))
## [3.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.2...v3.2.0) (2025-10-14)
### Features
* improve room creation UX and add password support for share links ([dcbb507](https://github.com/antialias/soroban-abacus-flashcards/commit/dcbb5072d8e0a12838fe70e3faa85f94cd63b0c1))
## [3.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.1...v3.1.2) (2025-10-14)
### Bug Fixes
* replace last remaining isLoading with isPending in CreateRoomModal ([85d13cc](https://github.com/antialias/soroban-abacus-flashcards/commit/85d13cc552cfe2e825f8ea20c7db00d666599134))
## [3.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.1.0...v3.1.1) (2025-10-14)
### Bug Fixes
* use useCreateRoom hook instead of nonexistent createRoom from useRoomData ([f7d63b3](https://github.com/antialias/soroban-abacus-flashcards/commit/f7d63b30ac498b63797ae8683a0beb435a1c97b3))
## [3.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.0.0...v3.1.0) (2025-10-14)
### Features
* add room access modes and ownership transfer ([6ff21c4](https://github.com/antialias/soroban-abacus-flashcards/commit/6ff21c4f1dd0dd1db14257612809b4d40512689a))
### Bug Fixes
* replace isLocked with accessMode and add bcryptjs ([a74b96b](https://github.com/antialias/soroban-abacus-flashcards/commit/a74b96bb6fe331d27f3d27b8f77a3ce32b254bce))
## [3.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v2.17.2...v3.0.0) (2025-10-13)

View File

@@ -43,7 +43,21 @@
"Bash(npx playwright test:*)",
"Bash(npm run:*)",
"Bash(\"\")",
"Bash(npx @biomejs/biome check:*)"
"Bash(npx @biomejs/biome check:*)",
"Bash(printf '\\n')",
"Bash(npm install bcryptjs)",
"Bash(npm install:*)",
"Bash(pnpm add:*)",
"Bash(sqlite3:*)",
"Bash(shasum:*)",
"Bash(awk:*)",
"Bash(if npx tsc --noEmit)",
"Bash(then echo \"TypeScript errors found in our files\")",
"Bash(else echo \"✓ No TypeScript errors in our modified files\")",
"Bash(fi)",
"Bash(then echo \"TypeScript errors found\")",
"Bash(else echo \"✓ No TypeScript errors in join page\")",
"Bash(npx @biomejs/biome format:*)"
],
"deny": [],
"ask": []

View File

@@ -65,7 +65,7 @@ describe('Arcade Rooms API', () => {
expect(room.createdBy).toBe(testGuestId1)
expect(room.gameName).toBe('matching')
expect(room.status).toBe('lobby')
expect(room.isLocked).toBe(false)
expect(room.accessMode).toBe('open')
expect(room.ttlMinutes).toBe(60)
expect(room.code).toMatch(/^[A-Z0-9]{6}$/)
})
@@ -180,11 +180,11 @@ describe('Arcade Rooms API', () => {
it('locks room', async () => {
const [updated] = await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.set({ accessMode: 'locked' })
.where(eq(schema.arcadeRooms.id, testRoomId))
.returning()
expect(updated.isLocked).toBe(true)
expect(updated.accessMode).toBe('locked')
})
it('updates room status', async () => {
@@ -442,14 +442,14 @@ describe('Arcade Rooms API', () => {
// Lock one room
await db
.update(schema.arcadeRooms)
.set({ isLocked: true })
.set({ accessMode: 'locked' })
.where(eq(schema.arcadeRooms.id, testRoomId))
const unlockedRooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.isLocked, false),
const openRooms = await db.query.arcadeRooms.findMany({
where: eq(schema.arcadeRooms.accessMode, 'open'),
})
expect(unlockedRooms.every((r) => !r.isLocked)).toBe(true)
expect(openRooms.every((r) => r.accessMode === 'open')).toBe(true)
})
})
})

View File

@@ -0,0 +1,18 @@
-- Add access control columns to arcade_rooms
ALTER TABLE `arcade_rooms` ADD `access_mode` text DEFAULT 'open' NOT NULL;--> statement-breakpoint
ALTER TABLE `arcade_rooms` ADD `password` text(255);--> statement-breakpoint
-- Create room_join_requests table for approval-only mode
CREATE TABLE `room_join_requests` (
`id` text PRIMARY KEY NOT NULL,
`room_id` text NOT NULL,
`user_id` text NOT NULL,
`user_name` text(50) NOT NULL,
`status` text DEFAULT 'pending' NOT NULL,
`requested_at` integer NOT NULL,
`reviewed_at` integer,
`reviewed_by` text,
`reviewed_by_name` text(50),
FOREIGN KEY (`room_id`) REFERENCES `arcade_rooms`(`id`) ON UPDATE no action ON DELETE cascade
);--> statement-breakpoint
CREATE UNIQUE INDEX `idx_room_join_requests_user_room` ON `room_join_requests` (`user_id`,`room_id`);

View File

@@ -0,0 +1,41 @@
-- Make room name nullable to support auto-generated names
-- 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),
`game_name` text NOT NULL,
`game_config` text NOT NULL,
`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`,
`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;

View File

@@ -50,6 +50,20 @@
"when": 1760365860888,
"tag": "0006_pretty_invaders",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1760527200000,
"tag": "0007_access_modes",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1760548800000,
"tag": "0008_make_room_name_nullable",
"breakpoints": true
}
]
}

View File

@@ -54,6 +54,7 @@
"@tanstack/react-form": "^0.19.0",
"@tanstack/react-query": "^5.90.2",
"@types/jsdom": "^21.1.7",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^12.4.1",
"drizzle-orm": "^0.44.6",
"emojibase-data": "^16.0.3",
@@ -77,6 +78,7 @@
"@storybook/nextjs": "^9.1.7",
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",

View File

@@ -0,0 +1,101 @@
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { eq } from 'drizzle-orm'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { createJoinRequest, getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join-request
* Request to join an approval-only room
* Body:
* - userName: string
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.userName) {
return NextResponse.json({ error: 'Missing required field: userName' }, { status: 400 })
}
// Get room details
const [room] = await db
.select()
.from(schema.arcadeRooms)
.where(eq(schema.arcadeRooms.id, roomId))
.limit(1)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if room is approval-only
if (room.accessMode !== 'approval-only') {
return NextResponse.json(
{ error: 'This room does not require approval to join' },
{ status: 400 }
)
}
// Check if user is already in the room
const members = await getRoomMembers(roomId)
const existingMember = members.find((m) => m.userId === viewerId)
if (existingMember) {
return NextResponse.json({ error: 'You are already in this room' }, { status: 400 })
}
// Check if user already has a pending request
const existingRequest = await getJoinRequest(roomId, viewerId)
if (existingRequest && existingRequest.status === 'pending') {
return NextResponse.json(
{ error: 'You already have a pending join request' },
{ status: 400 }
)
}
// Create join request
const request = await createJoinRequest({
roomId,
userId: viewerId,
userName: body.userName,
})
// Broadcast to host via socket
const io = await getSocketIO()
if (io) {
try {
// Get host user ID
const host = members.find((m) => m.isCreator)
if (host) {
io.to(`user:${host.userId}`).emit('join-request-received', {
roomId,
request: {
id: request.id,
userId: request.userId,
userName: request.userName,
requestedAt: request.requestedAt,
},
})
}
console.log(`[Join Request API] User ${viewerId} requested to join room ${roomId}`)
} catch (socketError) {
console.error('[Join Request API] Failed to broadcast request:', socketError)
}
}
return NextResponse.json({ request }, { status: 200 })
} catch (error: any) {
console.error('Failed to create join request:', error)
return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,78 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { approveJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string; requestId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join-requests/:requestId/approve
* Approve a join request (host only)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId, requestId } = await context.params
const viewerId = await getViewerId()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json(
{ error: 'Only the host can approve join requests' },
{ status: 403 }
)
}
// Get the request
const [request] = await db
.select()
.from(schema.roomJoinRequests)
.where(eq(schema.roomJoinRequests.id, requestId))
.limit(1)
if (!request) {
return NextResponse.json({ error: 'Join request not found' }, { status: 404 })
}
if (request.status !== 'pending') {
return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 })
}
// Approve the request
const approvedRequest = await approveJoinRequest(requestId, viewerId, currentMember.displayName)
// Notify the requesting user via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`user:${request.userId}`).emit('join-request-approved', {
roomId,
requestId,
approvedBy: currentMember.displayName,
})
console.log(
`[Approve Join Request API] Request ${requestId} approved for user ${request.userId} to join room ${roomId}`
)
} catch (socketError) {
console.error('[Approve Join Request API] Failed to broadcast approval:', socketError)
}
}
return NextResponse.json({ request: approvedRequest }, { status: 200 })
} catch (error: any) {
console.error('Failed to approve join request:', error)
return NextResponse.json({ error: 'Failed to approve join request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,75 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { denyJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string; requestId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/join-requests/:requestId/deny
* Deny a join request (host only)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId, requestId } = await context.params
const viewerId = await getViewerId()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can deny join requests' }, { status: 403 })
}
// Get the request
const [request] = await db
.select()
.from(schema.roomJoinRequests)
.where(eq(schema.roomJoinRequests.id, requestId))
.limit(1)
if (!request) {
return NextResponse.json({ error: 'Join request not found' }, { status: 404 })
}
if (request.status !== 'pending') {
return NextResponse.json({ error: 'Join request is not pending' }, { status: 400 })
}
// Deny the request
const deniedRequest = await denyJoinRequest(requestId, viewerId, currentMember.displayName)
// Notify the requesting user via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`user:${request.userId}`).emit('join-request-denied', {
roomId,
requestId,
deniedBy: currentMember.displayName,
})
console.log(
`[Deny Join Request API] Request ${requestId} denied for user ${request.userId} to join room ${roomId}`
)
} catch (socketError) {
console.error('[Deny Join Request API] Failed to broadcast denial:', socketError)
}
}
return NextResponse.json({ request: deniedRequest }, { status: 200 })
} catch (error: any) {
console.error('Failed to deny join request:', error)
return NextResponse.json({ error: 'Failed to deny join request' }, { status: 500 })
}
}

View File

@@ -0,0 +1,119 @@
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 = {
params: Promise<{ roomId: string }>
}
/**
* GET /api/arcade/rooms/:roomId/join-requests
* Get all pending join requests for a room (host only)
*/
export async function GET(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can view join requests' }, { status: 403 })
}
// Get all pending requests
const requests = await getPendingJoinRequests(roomId)
return NextResponse.json({ requests }, { status: 200 })
} catch (error: any) {
console.error('Failed to get join requests:', error)
return NextResponse.json({ error: 'Failed to get join requests' }, { status: 500 })
}
}
/**
* POST /api/arcade/rooms/:roomId/join-requests
* Create a join request for an approval-only room
* Body:
* - displayName?: string (optional, will generate from viewerId if not provided)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json().catch(() => ({}))
// Get room to verify it exists
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Verify room is approval-only
if (room.accessMode !== 'approval-only') {
return NextResponse.json(
{ error: 'This room does not require approval to join' },
{ status: 400 }
)
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`
// Validate display name length
if (displayName.length > 50) {
return NextResponse.json(
{ error: 'Display name too long (max 50 characters)' },
{ status: 400 }
)
}
// Create join request
const request = await createJoinRequest({
roomId,
userId: viewerId,
userName: displayName,
})
console.log(
`[Join Requests] Created request for user ${viewerId} (${displayName}) to join room ${roomId}`
)
// Broadcast to all members in the room (particularly the host) via socket
const io = await getSocketIO()
if (io) {
try {
io.to(`room:${roomId}`).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 for user ${viewerId} to room ${roomId}`
)
} 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)
return NextResponse.json({ error: 'Failed to create join request' }, { status: 500 })
}
}

View File

@@ -1,10 +1,13 @@
import bcrypt from 'bcryptjs'
import { type NextRequest, NextResponse } from 'next/server'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { getInvitation } from '@/lib/arcade/room-invitations'
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { getActivePlayers, getRoomActivePlayers } from '@/lib/arcade/player-manager'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -15,6 +18,7 @@ type RouteContext = {
* Join a room
* Body:
* - displayName?: string (optional, will generate from viewerId if not provided)
* - password?: string (required for password-protected rooms)
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
@@ -28,17 +32,76 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Check if room is locked
if (room.isLocked) {
return NextResponse.json({ error: 'Room is locked' }, { status: 403 })
}
// Check if user is banned
const banned = await isUserBanned(roomId, viewerId)
if (banned) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Check if user is already a member (for locked room access)
const members = await getRoomMembers(roomId)
const isExistingMember = members.some((m) => m.userId === viewerId)
// Validate access mode
switch (room.accessMode) {
case 'locked':
// Allow existing members to continue using the room, but block new members
if (!isExistingMember) {
return NextResponse.json(
{ error: 'This room is locked and not accepting new members' },
{ status: 403 }
)
}
break
case 'retired':
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
case 'password': {
if (!body.password) {
return NextResponse.json(
{ error: 'Password required to join this room' },
{ status: 401 }
)
}
if (!room.password) {
return NextResponse.json({ error: 'Room password not configured' }, { status: 500 })
}
const passwordMatch = await bcrypt.compare(body.password, room.password)
if (!passwordMatch) {
return NextResponse.json({ error: 'Incorrect password' }, { status: 401 })
}
break
}
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 }
)
}
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 }
)
}
break
}
default:
// No additional checks needed
break
}
// Get or generate display name
const displayName = body.displayName || `Guest ${viewerId.slice(-4)}`

View File

@@ -59,8 +59,9 @@ export async function GET(_req: NextRequest, context: RouteContext) {
* Update room (creator only)
* Body:
* - name?: string
* - isLocked?: boolean
* - status?: 'lobby' | 'playing' | 'finished'
*
* Note: For access control (accessMode, password), use PATCH /api/arcade/rooms/:roomId/settings
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
@@ -86,12 +87,10 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
const updates: {
name?: string
isLocked?: boolean
status?: 'lobby' | 'playing' | 'finished'
} = {}
if (body.name !== undefined) updates.name = body.name
if (body.isLocked !== undefined) updates.isLocked = body.isLocked
if (body.status !== undefined) updates.status = body.status
const room = await updateRoom(roomId, updates)

View File

@@ -0,0 +1,87 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getViewerId } from '@/lib/viewer'
import bcrypt from 'bcryptjs'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* PATCH /api/arcade/rooms/:roomId/settings
* Update room settings (host only)
* Body:
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
* - password?: string (plain text, will be hashed)
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json({ error: 'Only the host can change room settings' }, { status: 403 })
}
// Validate accessMode if provided
const validAccessModes = [
'open',
'locked',
'retired',
'password',
'restricted',
'approval-only',
]
if (body.accessMode && !validAccessModes.includes(body.accessMode)) {
return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 })
}
// Validate password requirements
if (body.accessMode === 'password' && !body.password) {
return NextResponse.json(
{ error: 'Password is required for password-protected rooms' },
{ status: 400 }
)
}
// Prepare update data
const updateData: Record<string, any> = {}
if (body.accessMode !== undefined) {
updateData.accessMode = body.accessMode
}
// Hash password if provided
if (body.password !== undefined) {
if (body.password === null || body.password === '') {
updateData.password = null // Clear password
} else {
const hashedPassword = await bcrypt.hash(body.password, 10)
updateData.password = hashedPassword
}
}
// Update room settings
const [updatedRoom] = await db
.update(schema.arcadeRooms)
.set(updateData)
.where(eq(schema.arcadeRooms.id, roomId))
.returning()
return NextResponse.json({ room: updatedRoom }, { status: 200 })
} catch (error: any) {
console.error('Failed to update room settings:', error)
return NextResponse.json({ error: 'Failed to update room settings' }, { status: 500 })
}
}

View File

@@ -0,0 +1,103 @@
import { eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { db, schema } from '@/db'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getViewerId } from '@/lib/viewer'
import { getSocketIO } from '@/lib/socket-io'
type RouteContext = {
params: Promise<{ roomId: string }>
}
/**
* POST /api/arcade/rooms/:roomId/transfer-ownership
* Transfer room ownership to another member (host only)
* Body:
* - newOwnerId: string
*/
export async function POST(req: NextRequest, context: RouteContext) {
try {
const { roomId } = await context.params
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.newOwnerId) {
return NextResponse.json({ error: 'Missing required field: newOwnerId' }, { status: 400 })
}
// Check if user is the current host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)
if (!currentMember) {
return NextResponse.json({ error: 'You are not in this room' }, { status: 403 })
}
if (!currentMember.isCreator) {
return NextResponse.json(
{ error: 'Only the current host can transfer ownership' },
{ status: 403 }
)
}
// Can't transfer to yourself
if (body.newOwnerId === viewerId) {
return NextResponse.json({ error: 'You are already the owner' }, { status: 400 })
}
// Verify new owner is in the room
const newOwner = members.find((m) => m.userId === body.newOwnerId)
if (!newOwner) {
return NextResponse.json({ error: 'New owner must be a member of the room' }, { status: 404 })
}
// Remove isCreator from current owner
await db
.update(schema.roomMembers)
.set({ isCreator: false })
.where(eq(schema.roomMembers.id, currentMember.id))
// Set isCreator on new owner
await db
.update(schema.roomMembers)
.set({ isCreator: true })
.where(eq(schema.roomMembers.id, newOwner.id))
// Update room createdBy field
await db
.update(schema.arcadeRooms)
.set({
createdBy: body.newOwnerId,
creatorName: newOwner.displayName,
})
.where(eq(schema.arcadeRooms.id, roomId))
// Broadcast ownership transfer via socket
const io = await getSocketIO()
if (io) {
try {
const updatedMembers = await getRoomMembers(roomId)
io.to(`room:${roomId}`).emit('ownership-transferred', {
roomId,
oldOwnerId: viewerId,
newOwnerId: body.newOwnerId,
newOwnerName: newOwner.displayName,
members: updatedMembers,
})
console.log(
`[Ownership Transfer] Room ${roomId} ownership transferred from ${viewerId} to ${body.newOwnerId}`
)
} catch (socketError) {
console.error('[Ownership Transfer] Failed to broadcast transfer:', socketError)
}
}
return NextResponse.json({ success: true }, { status: 200 })
} catch (error: any) {
console.error('Failed to transfer ownership:', error)
return NextResponse.json({ error: 'Failed to transfer ownership' }, { status: 500 })
}
}

View File

@@ -39,7 +39,7 @@ export async function GET(req: NextRequest) {
status: room.status,
createdAt: room.createdAt,
creatorName: room.creatorName,
isLocked: room.isLocked,
accessMode: room.accessMode,
memberCount: members.length,
playerCount: totalPlayers,
isMember: userIsMember,
@@ -62,18 +62,17 @@ export async function GET(req: NextRequest) {
* - gameName: string
* - gameConfig?: object
* - ttlMinutes?: number
* - accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
* - password?: string
*/
export async function POST(req: NextRequest) {
try {
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields
if (!body.name || !body.gameName) {
return NextResponse.json(
{ error: 'Missing required fields: name, gameName' },
{ status: 400 }
)
// 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
@@ -82,22 +81,50 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
// Validate name length
if (body.name.length > 50) {
// Validate name length (if provided)
if (body.name && body.name.length > 50) {
return NextResponse.json({ error: 'Room name too long (max 50 characters)' }, { status: 400 })
}
// Normalize empty name to null
const roomName = body.name?.trim() || null
// Validate access mode
if (body.accessMode) {
const validAccessModes = [
'open',
'password',
'approval-only',
'restricted',
'locked',
'retired',
]
if (!validAccessModes.includes(body.accessMode)) {
return NextResponse.json({ error: 'Invalid access mode' }, { status: 400 })
}
}
// Validate password if provided
if (body.accessMode === 'password' && !body.password) {
return NextResponse.json(
{ error: 'Password is required for password-protected rooms' },
{ status: 400 }
)
}
// Get display name from body or generate from viewerId
const displayName = body.creatorName || `Guest ${viewerId.slice(-4)}`
// Create room
const room = await createRoom({
name: body.name,
name: roomName,
createdBy: viewerId,
creatorName: displayName,
gameName: body.gameName,
gameConfig: body.gameConfig || {},
ttlMinutes: body.ttlMinutes,
accessMode: body.accessMode,
password: body.password,
})
// Add creator as first member

View File

@@ -6,11 +6,12 @@ import { io, type Socket } from 'socket.io-client'
import { css } from '../../../../styled-system/css'
import { PageWithNav } from '@/components/PageWithNav'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
interface Room {
id: string
code: string
name: string
name: string | null
gameName: string
status: 'lobby' | 'playing' | 'finished'
createdBy: string
@@ -357,7 +358,11 @@ export default function RoomDetailPage() {
mb: '2',
})}
>
{room.name}
{getRoomDisplayWithEmoji({
name: room.name,
code: room.code,
gameName: room.gameName,
})}
</h1>
<div
className={css({

View File

@@ -4,16 +4,18 @@ import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { css } from '../../../styled-system/css'
import { PageWithNav } from '@/components/PageWithNav'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
interface Room {
id: string
code: string
name: string
name: string | null
gameName: string
status: 'lobby' | 'playing' | 'finished'
createdAt: Date
creatorName: string
isLocked: boolean
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
memberCount?: number
playerCount?: number
isMember?: boolean
@@ -48,7 +50,7 @@ export default function RoomBrowserPage() {
}
}
const createRoom = async (name: string, gameName: string) => {
const createRoom = async (name: string | null, gameName: string) => {
try {
const response = await fetch('/api/arcade/rooms', {
method: 'POST',
@@ -73,9 +75,41 @@ export default function RoomBrowserPage() {
}
}
const joinRoom = async (roomId: string) => {
const joinRoom = async (room: Room) => {
try {
const response = await fetch(`/api/arcade/rooms/${roomId}/join`, {
// Check access mode
if (room.accessMode === 'password') {
const password = prompt(`Enter password for ${room.name || `Room ${room.code}`}:`)
if (!password) return // User cancelled
const response = await fetch(`/api/arcade/rooms/${room.id}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: 'Player', password }),
})
if (!response.ok) {
const errorData = await response.json()
alert(errorData.error || 'Failed to join room')
return
}
router.push(`/arcade-rooms/${room.id}`)
return
}
if (room.accessMode === 'approval-only') {
alert('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.')
return
}
// For open rooms
const response = await fetch(`/api/arcade/rooms/${room.id}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: 'Player' }),
@@ -103,7 +137,7 @@ export default function RoomBrowserPage() {
// Could show a toast notification here in the future
}
router.push(`/arcade-rooms/${roomId}`)
router.push(`/arcade-rooms/${room.id}`)
} catch (err) {
console.error('Failed to join room:', err)
alert('Failed to join room')
@@ -237,7 +271,11 @@ export default function RoomBrowserPage() {
color: 'white',
})}
>
{room.name}
{getRoomDisplayWithEmoji({
name: room.name,
code: room.code,
gameName: room.gameName,
})}
</h3>
<span
className={css({
@@ -325,23 +363,51 @@ export default function RoomBrowserPage() {
<button
onClick={(e) => {
e.stopPropagation()
joinRoom(room.id)
joinRoom(room)
}}
disabled={room.isLocked}
disabled={
room.isLocked ||
room.accessMode === 'locked' ||
room.accessMode === 'retired'
}
className={css({
px: '6',
py: '3',
bg: room.isLocked ? '#6b7280' : '#3b82f6',
bg:
room.isLocked ||
room.accessMode === 'locked' ||
room.accessMode === 'retired'
? '#6b7280'
: room.accessMode === 'password'
? '#f59e0b'
: '#3b82f6',
color: 'white',
rounded: 'lg',
fontWeight: '600',
cursor: room.isLocked ? 'not-allowed' : 'pointer',
opacity: room.isLocked ? 0.5 : 1,
_hover: room.isLocked ? {} : { bg: '#2563eb' },
cursor:
room.isLocked ||
room.accessMode === 'locked' ||
room.accessMode === 'retired'
? 'not-allowed'
: 'pointer',
opacity:
room.isLocked ||
room.accessMode === 'locked' ||
room.accessMode === 'retired'
? 0.5
: 1,
_hover:
room.isLocked ||
room.accessMode === 'locked' ||
room.accessMode === 'retired'
? {}
: room.accessMode === 'password'
? { bg: '#d97706' }
: { bg: '#2563eb' },
transition: 'all 0.2s',
})}
>
Join Room
{room.accessMode === 'password' ? '🔑 Join with Password' : 'Join Room'}
</button>
)}
</div>
@@ -393,9 +459,11 @@ export default function RoomBrowserPage() {
onSubmit={(e) => {
e.preventDefault()
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const nameValue = formData.get('name') as string
const gameName = formData.get('gameName') as string
if (name && gameName) {
// Treat empty name as null
const name = nameValue?.trim() || null
if (gameName) {
createRoom(name, gameName)
}
}}
@@ -408,13 +476,13 @@ export default function RoomBrowserPage() {
fontWeight: '600',
})}
>
Room Name
Room Name{' '}
<span className={css({ fontWeight: '400', color: '#9ca3af' })}>(optional)</span>
</label>
<input
name="name"
type="text"
required
placeholder="My Awesome Room"
placeholder="e.g., Friday Night Games (defaults to: 🎮 CODE)"
className={css({
w: 'full',
px: '4',

View File

@@ -1,12 +1,14 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useState } from 'react'
import { io } from 'socket.io-client'
import { useGetRoomByCode, useJoinRoom, useRoomData } from '@/hooks/useRoomData'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
interface RoomSwitchConfirmationProps {
currentRoom: { name: string; code: string }
targetRoom: { name: string; code: string }
currentRoom: { name: string | null; code: string; gameName: string }
targetRoom: { name: string | null; code: string; gameName: string }
onConfirm: () => void
onCancel: () => void
}
@@ -84,7 +86,11 @@ function RoomSwitchConfirmation({
Current Room
</div>
<div style={{ color: 'rgba(253, 186, 116, 1)', fontWeight: '600' }}>
{currentRoom.name}
{getRoomDisplayWithEmoji({
name: currentRoom.name,
code: currentRoom.code,
gameName: currentRoom.gameName,
})}
</div>
<div
style={{
@@ -116,7 +122,11 @@ function RoomSwitchConfirmation({
New Room
</div>
<div style={{ color: 'rgba(134, 239, 172, 1)', fontWeight: '600' }}>
{targetRoom.name}
{getRoomDisplayWithEmoji({
name: targetRoom.name,
code: targetRoom.code,
gameName: targetRoom.gameName,
})}
</div>
<div
style={{
@@ -195,26 +205,35 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
const { mutateAsync: joinRoom } = useJoinRoom()
const [targetRoomData, setTargetRoomData] = useState<{
id: string
name: string
name: string | null
code: string
gameName: string
accessMode: string
} | null>(null)
const [showConfirmation, setShowConfirmation] = useState(false)
const [showPasswordPrompt, setShowPasswordPrompt] = useState(false)
const [showApprovalPrompt, setShowApprovalPrompt] = useState(false)
const [approvalRequested, setApprovalRequested] = useState(false)
const [password, setPassword] = useState('')
const [error, setError] = useState<string | null>(null)
const [isJoining, setIsJoining] = useState(false)
const code = params.code.toUpperCase()
const handleJoin = useCallback(
async (targetRoomId: string) => {
async (targetRoomId: string, roomPassword?: string) => {
setIsJoining(true)
setError(null)
try {
await joinRoom({ roomId: targetRoomId, displayName: 'Player' })
await joinRoom({
roomId: targetRoomId,
displayName: 'Player',
password: roomPassword,
})
// Navigate to the game
router.push('/arcade/room')
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join room')
} finally {
setIsJoining(false)
}
},
@@ -236,6 +255,8 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
id: room.id,
name: room.name,
code: room.code,
gameName: room.gameName,
accessMode: room.accessMode,
})
// If user is already in this exact room, just navigate to game
@@ -244,11 +265,29 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
return
}
// Check if room needs password
if (room.accessMode === 'password') {
setShowPasswordPrompt(true)
return
}
// Check for other access modes
if (room.accessMode === 'locked' || room.accessMode === 'retired') {
setError('This room is no longer accepting new members')
return
}
if (room.accessMode === 'approval-only') {
setShowApprovalPrompt(true)
return
}
// For restricted rooms, try to join - the API will check for invitation
// If user is in a different room, show confirmation
if (roomData) {
setShowConfirmation(true)
} else {
// Otherwise, auto-join
// Otherwise, auto-join (for open rooms and restricted rooms with invitation)
handleJoin(room.id)
}
})
@@ -264,7 +303,12 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
const handleConfirm = () => {
if (targetRoomData) {
handleJoin(targetRoomData.id)
if (targetRoomData.accessMode === 'password') {
setShowConfirmation(false)
setShowPasswordPrompt(true)
} else {
handleJoin(targetRoomData.id)
}
}
}
@@ -272,7 +316,94 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
router.push('/arcade/room') // Stay in current room
}
if (error) {
const handlePasswordSubmit = () => {
if (targetRoomData && password) {
handleJoin(targetRoomData.id, password)
}
}
const handleRequestApproval = async () => {
if (!targetRoomData) return
setIsJoining(true)
setError(null)
try {
const res = await fetch(`/api/arcade/rooms/${targetRoomData.id}/join-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to request approval')
}
// Request sent successfully - show waiting state
setApprovalRequested(true)
setIsJoining(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to request approval')
setIsJoining(false)
}
}
// Socket listener for approval notifications
useEffect(() => {
if (!approvalRequested || !targetRoomData) return
console.log('[Join Page] Setting up approval listener for room:', targetRoomData.id)
let socket: ReturnType<typeof io> | null = null
// Fetch viewer ID and set up socket
const setupSocket = async () => {
try {
// Get current user's viewer ID
const res = await fetch('/api/viewer')
if (!res.ok) {
console.error('[Join Page] Failed to get viewer ID')
return
}
const { viewerId } = await res.json()
console.log('[Join Page] Got viewer ID:', viewerId)
// Connect socket
socket = io({ path: '/api/socket' })
socket.on('connect', () => {
console.log('[Join Page] Socket connected, joining user channel')
// Join user-specific channel to receive moderation events
socket?.emit('join-user-channel', { userId: viewerId })
})
socket.on('join-request-approved', (data: { roomId: string; requestId: string }) => {
console.log('[Join Page] Request approved via socket!', data)
if (data.roomId === targetRoomData.id) {
console.log('[Join Page] Joining room automatically...')
handleJoin(targetRoomData.id)
}
})
socket.on('connect_error', (error) => {
console.error('[Join Page] Socket connection error:', error)
})
} catch (error) {
console.error('[Join Page] Error setting up socket:', error)
}
}
setupSocket()
return () => {
console.log('[Join Page] Cleaning up approval listener')
socket?.disconnect()
}
}, [approvalRequested, targetRoomData, handleJoin])
// Only show error page for non-password and non-approval errors
if (error && !showPasswordPrompt && !showApprovalPrompt) {
return (
<div
style={{
@@ -316,16 +447,438 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
)
}
if (showConfirmation && roomData) {
if (showConfirmation && roomData && targetRoomData) {
return (
<RoomSwitchConfirmation
currentRoom={{ name: roomData.name, code: roomData.code }}
targetRoom={{ name: targetRoomData.name, code: targetRoomData.code }}
currentRoom={{ name: roomData.name, code: roomData.code, gameName: roomData.gameName }}
targetRoom={{
name: targetRoomData.name,
code: targetRoomData.code,
gameName: targetRoomData.gameName,
}}
onConfirm={handleConfirm}
onCancel={handleCancel}
/>
)
}
if (showPasswordPrompt && targetRoomData) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
}}
>
<div
style={{
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.98), rgba(31, 41, 55, 0.98))',
borderRadius: '16px',
padding: '32px',
maxWidth: '450px',
width: '90%',
border: '2px solid rgba(251, 191, 36, 0.3)',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
}}
>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(251, 191, 36, 1)',
}}
>
🔑 Password Required
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '20px',
}}
>
This room is password protected. Enter the password to join.
</p>
<div
style={{
background: 'rgba(251, 191, 36, 0.1)',
border: '1px solid rgba(251, 191, 36, 0.3)',
borderRadius: '12px',
padding: '16px',
marginBottom: '20px',
}}
>
<div style={{ fontSize: '14px', fontWeight: '600', color: 'rgba(251, 191, 36, 1)' }}>
{getRoomDisplayWithEmoji({
name: targetRoomData.name,
code: targetRoomData.code,
gameName: targetRoomData.gameName,
})}
</div>
<div
style={{
fontSize: '13px',
color: 'rgba(209, 213, 219, 0.7)',
fontFamily: 'monospace',
marginTop: '4px',
}}
>
Code: {targetRoomData.code}
</div>
</div>
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setError(null) // Clear error when user starts typing
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && password) {
handlePasswordSubmit()
}
}}
placeholder="Enter password"
disabled={isJoining}
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid rgba(251, 191, 36, 0.4)',
borderRadius: '10px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(251, 191, 36, 1)',
fontSize: '16px',
outline: 'none',
marginBottom: '8px',
}}
/>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px', marginTop: '20px' }}>
<button
type="button"
onClick={() => router.push('/arcade')}
disabled={isJoining}
style={{
flex: 1,
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isJoining ? 'not-allowed' : 'pointer',
opacity: isJoining ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
>
Cancel
</button>
<button
type="button"
onClick={handlePasswordSubmit}
disabled={!password || isJoining}
style={{
flex: 1,
padding: '12px',
background:
password && !isJoining
? 'linear-gradient(135deg, rgba(251, 191, 36, 0.8), rgba(245, 158, 11, 0.8))'
: 'rgba(75, 85, 99, 0.3)',
color: password && !isJoining ? 'rgba(255, 255, 255, 1)' : 'rgba(156, 163, 175, 1)',
border:
password && !isJoining
? '2px solid rgba(251, 191, 36, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: password && !isJoining ? 'pointer' : 'not-allowed',
opacity: password && !isJoining ? 1 : 0.5,
transition: 'all 0.2s ease',
}}
>
{isJoining ? 'Joining...' : 'Join Room'}
</button>
</div>
</div>
</div>
)
}
if (showApprovalPrompt && targetRoomData) {
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
}}
>
<div
style={{
background: 'linear-gradient(135deg, rgba(17, 24, 39, 0.98), rgba(31, 41, 55, 0.98))',
borderRadius: '16px',
padding: '32px',
maxWidth: '450px',
width: '90%',
border: '2px solid rgba(59, 130, 246, 0.3)',
boxShadow: '0 20px 60px rgba(0, 0, 0, 0.5)',
}}
>
{approvalRequested ? (
// Waiting for approval state
<>
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(96, 165, 250, 1)',
}}
>
Waiting for Approval
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.8)',
}}
>
Your request has been sent to the room moderator.
</p>
</div>
<div
style={{
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '12px',
padding: '16px',
marginBottom: '20px',
}}
>
<div
style={{ fontSize: '14px', fontWeight: '600', color: 'rgba(96, 165, 250, 1)' }}
>
{getRoomDisplayWithEmoji({
name: targetRoomData.name,
code: targetRoomData.code,
gameName: targetRoomData.gameName,
})}
</div>
<div
style={{
fontSize: '13px',
color: 'rgba(209, 213, 219, 0.7)',
fontFamily: 'monospace',
marginTop: '4px',
}}
>
Code: {targetRoomData.code}
</div>
</div>
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
textAlign: 'center',
marginBottom: '20px',
}}
>
You'll be able to join once the host approves your request. You can close this page
and check back later.
</p>
<button
type="button"
onClick={() => router.push('/arcade')}
style={{
width: '100%',
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}}
>
Go to Champion Arena
</button>
</>
) : (
// Request approval prompt
<>
<h2
style={{
fontSize: '24px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(96, 165, 250, 1)',
}}
>
✋ Approval Required
</h2>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '20px',
}}
>
This room requires host approval to join. Send a request?
</p>
<div
style={{
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '12px',
padding: '16px',
marginBottom: '20px',
}}
>
<div
style={{ fontSize: '14px', fontWeight: '600', color: 'rgba(96, 165, 250, 1)' }}
>
{getRoomDisplayWithEmoji({
name: targetRoomData.name,
code: targetRoomData.code,
gameName: targetRoomData.gameName,
})}
</div>
<div
style={{
fontSize: '13px',
color: 'rgba(209, 213, 219, 0.7)',
fontFamily: 'monospace',
marginTop: '4px',
}}
>
Code: {targetRoomData.code}
</div>
</div>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={() => router.push('/arcade')}
disabled={isJoining}
style={{
flex: 1,
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isJoining ? 'not-allowed' : 'pointer',
opacity: isJoining ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isJoining) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isJoining) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRequestApproval}
disabled={isJoining}
style={{
flex: 1,
padding: '12px',
background: isJoining
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: isJoining ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
border: isJoining
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isJoining ? 'not-allowed' : 'pointer',
opacity: isJoining ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isJoining) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isJoining) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isJoining ? 'Sending...' : 'Request to Join'}
</button>
</div>
</>
)}
</div>
</div>
)
}
return null
}

View File

@@ -1,6 +1,6 @@
import { useState } from 'react'
import { Modal } from '@/components/common/Modal'
import { useRoomData } from '@/hooks/useRoomData'
import { useCreateRoom } from '@/hooks/useRoomData'
export interface CreateRoomModalProps {
/**
@@ -23,13 +23,21 @@ export interface CreateRoomModalProps {
* Modal for creating a new multiplayer room
*/
export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalProps) {
const { createRoom } = useRoomData()
const { mutateAsync: createRoom, isPending } = useCreateRoom()
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [gameName, setGameName] = useState<'matching' | 'memory-quiz' | 'complement-race'>(
'matching'
)
const [accessMode, setAccessMode] = useState<
'open' | 'password' | 'approval-only' | 'restricted'
>('open')
const [password, setPassword] = useState('')
const handleClose = () => {
setError('')
setIsLoading(false)
setGameName('matching')
setAccessMode('open')
setPassword('')
onClose()
}
@@ -38,16 +46,17 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
setError('')
const formData = new FormData(e.currentTarget)
const name = formData.get('name') as string
const gameName = formData.get('gameName') as string
const nameValue = formData.get('name') as string
if (!name || !gameName) {
setError('Please fill in all fields')
// Treat empty name as null
const name = nameValue?.trim() || null
// Validate password for password-protected rooms
if (accessMode === 'password' && !password) {
setError('Password is required for password-protected rooms')
return
}
setIsLoading(true)
try {
// Create the room (creator is auto-added as first member)
await createRoom({
@@ -55,6 +64,8 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
gameName,
creatorName: 'Player',
gameConfig: { difficulty: 6 },
accessMode,
password: accessMode === 'password' ? password : undefined,
})
// Success! Close modal
@@ -62,8 +73,6 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
onSuccess?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create room')
} finally {
setIsLoading(false)
}
}
@@ -73,6 +82,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
style={{
border: '2px solid rgba(34, 197, 94, 0.3)',
borderRadius: '16px',
padding: '24px',
}}
>
<h2
@@ -96,32 +106,37 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
</p>
<form onSubmit={handleSubmit}>
<div style={{ marginBottom: '20px' }}>
{/* Room Name */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
marginBottom: '8px',
marginBottom: '6px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontSize: '13px',
}}
>
Room Name
Room Name{' '}
<span
style={{ fontWeight: '400', color: 'rgba(156, 163, 175, 1)', fontSize: '12px' }}
>
(optional)
</span>
</label>
<input
name="name"
type="text"
required
placeholder="My Awesome Room"
disabled={isLoading}
placeholder="e.g., Friday Night Games (defaults to: 🎮 CODE)"
disabled={isPending}
style={{
width: '100%',
padding: '12px',
padding: '10px 12px',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
borderRadius: '8px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(209, 213, 219, 1)',
fontSize: '15px',
fontSize: '14px',
outline: 'none',
}}
onFocus={(e) => {
@@ -133,46 +148,198 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
/>
</div>
<div style={{ marginBottom: '24px' }}>
{/* Game Selection */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
display: 'block',
marginBottom: '8px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontSize: '13px',
}}
>
Game
Choose Game
</label>
<select
name="gameName"
required
disabled={isLoading}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '8px' }}>
{[
{ value: 'matching' as const, emoji: '🃏', label: 'Memory', desc: 'Matching' },
{ value: 'memory-quiz' as const, emoji: '🧠', label: 'Memory', desc: 'Quiz' },
{
value: 'complement-race' as const,
emoji: '',
label: 'Complement',
desc: 'Race',
},
].map((game) => (
<button
key={game.value}
type="button"
disabled={isPending}
onClick={() => setGameName(game.value)}
style={{
padding: '12px 8px',
background:
gameName === game.value
? 'rgba(34, 197, 94, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
border:
gameName === game.value
? '2px solid rgba(34, 197, 94, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '8px',
color:
gameName === game.value
? 'rgba(134, 239, 172, 1)'
: 'rgba(209, 213, 219, 0.8)',
fontSize: '13px',
fontWeight: '500',
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
textAlign: 'center',
transition: 'all 0.2s ease',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '4px',
}}
onMouseEnter={(e) => {
if (!isPending && gameName !== game.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.4)'
}
}}
onMouseLeave={(e) => {
if (gameName !== game.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}
}}
>
<span style={{ fontSize: '24px' }}>{game.emoji}</span>
<div style={{ lineHeight: '1.2' }}>
<div style={{ fontSize: '12px', fontWeight: '600' }}>{game.label}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>{game.desc}</div>
</div>
</button>
))}
</div>
</div>
{/* Access Mode Selection */}
<div style={{ marginBottom: '16px' }}>
<label
style={{
width: '100%',
padding: '12px',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
background: 'rgba(255, 255, 255, 0.05)',
display: 'block',
marginBottom: '8px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 1)',
fontSize: '15px',
outline: 'none',
cursor: isLoading ? 'not-allowed' : 'pointer',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
fontSize: '13px',
}}
>
<option value="matching">Memory Matching</option>
<option value="memory-quiz">Memory Quiz</option>
<option value="complement-race">Complement Race</option>
</select>
Who Can Join
</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
{[
{ value: 'open', emoji: '🌐', label: 'Open', desc: 'Anyone' },
{ value: 'password', emoji: '🔑', label: 'Password', desc: 'With key' },
{ value: 'approval-only', emoji: '', label: 'Approval', desc: 'Request' },
{ value: 'restricted', emoji: '🚫', label: 'Restricted', desc: 'Invite only' },
].map((mode) => (
<button
key={mode.value}
type="button"
disabled={isPending}
onClick={() => {
setAccessMode(mode.value as typeof accessMode)
if (mode.value !== 'password') setPassword('')
}}
style={{
padding: '10px 12px',
background:
accessMode === mode.value
? 'rgba(34, 197, 94, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
border:
accessMode === mode.value
? '2px solid rgba(34, 197, 94, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '8px',
color:
accessMode === mode.value
? 'rgba(134, 239, 172, 1)'
: 'rgba(209, 213, 219, 0.8)',
fontSize: '13px',
fontWeight: '500',
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
if (!isPending && accessMode !== mode.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.4)'
}
}}
onMouseLeave={(e) => {
if (accessMode !== mode.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}
}}
>
<span style={{ fontSize: '18px' }}>{mode.emoji}</span>
<div style={{ textAlign: 'left', flex: 1, lineHeight: '1.2' }}>
<div style={{ fontSize: '13px', fontWeight: '600' }}>{mode.label}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>{mode.desc}</div>
</div>
</button>
))}
</div>
</div>
{accessMode === 'password' && (
<div style={{ marginBottom: '20px' }}>
<label
style={{
display: 'block',
marginBottom: '8px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
}}
>
Room Password
</label>
<input
type="text"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter a password"
disabled={isPending}
style={{
width: '100%',
padding: '12px',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(209, 213, 219, 1)',
fontSize: '15px',
outline: 'none',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = 'rgba(34, 197, 94, 0.6)'
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}}
/>
</div>
)}
{error && (
<p
style={{
@@ -190,7 +357,7 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
<button
type="button"
onClick={handleClose}
disabled={isLoading}
disabled={isPending}
style={{
flex: 1,
padding: '12px',
@@ -200,17 +367,17 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
@@ -219,38 +386,38 @@ export function CreateRoomModal({ isOpen, onClose, onSuccess }: CreateRoomModalP
</button>
<button
type="submit"
disabled={isLoading}
disabled={isPending}
style={{
flex: 1,
padding: '12px',
background: isLoading
background: isPending
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))',
color: 'rgba(255, 255, 255, 1)',
border: isLoading
border: isPending
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(34, 197, 94, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
cursor: isPending ? 'not-allowed' : 'pointer',
opacity: isPending ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(34, 197, 94, 0.9), rgba(22, 163, 74, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
if (!isPending) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(34, 197, 94, 0.8), rgba(22, 163, 74, 0.8))'
}
}}
>
{isLoading ? 'Creating...' : 'Create Room'}
{isPending ? 'Creating...' : 'Create Room'}
</button>
</div>
</form>

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { io } from 'socket.io-client'
import { Modal } from '@/components/common/Modal'
import type { schema } from '@/db'
import { useRoomData } from '@/hooks/useRoomData'
export interface JoinRoomModalProps {
@@ -25,13 +27,23 @@ export interface JoinRoomModalProps {
export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps) {
const { getRoomByCode, joinRoom } = useRoomData()
const [code, setCode] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const [roomInfo, setRoomInfo] = useState<schema.ArcadeRoom | null>(null)
const [needsPassword, setNeedsPassword] = useState(false)
const [needsApproval, setNeedsApproval] = useState(false)
const [approvalRequested, setApprovalRequested] = useState(false)
const handleClose = () => {
setCode('')
setPassword('')
setError('')
setIsLoading(false)
setRoomInfo(null)
setNeedsPassword(false)
setNeedsApproval(false)
setApprovalRequested(false)
onClose()
}
@@ -50,9 +62,46 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
try {
// Look up room by code
const room = await getRoomByCode(normalizedCode)
setRoomInfo(room)
// Join the room
await joinRoom(room.id)
// Check access mode
if (room.accessMode === 'retired') {
setError('This room has been retired and is no longer accepting members')
setIsLoading(false)
return
}
if (room.accessMode === 'locked') {
setError('This room is locked and not accepting new members')
setIsLoading(false)
return
}
if (room.accessMode === 'approval-only') {
setNeedsApproval(true)
setIsLoading(false)
return
}
// For restricted rooms, try to join - the API will check for invitation
if (room.accessMode === 'password') {
// Check if password is provided
if (!needsPassword) {
setNeedsPassword(true)
setIsLoading(false)
return
}
if (!password) {
setError('Password is required')
setIsLoading(false)
return
}
}
// Join the room (with password if needed)
await joinRoom(room.id, password || undefined)
// Success! Close modal
handleClose()
@@ -64,6 +113,93 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
}
}
const handleRequestAccess = async () => {
if (!roomInfo) return
setIsLoading(true)
setError('')
try {
const res = await fetch(`/api/arcade/rooms/${roomInfo.id}/join-requests`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to request access')
}
// Success! Show waiting state
setApprovalRequested(true)
setIsLoading(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to request access')
setIsLoading(false)
}
}
// Socket listener for approval notifications
useEffect(() => {
if (!approvalRequested || !roomInfo) return
console.log('[JoinRoomModal] Setting up approval listener for room:', roomInfo.id)
let socket: ReturnType<typeof io> | null = null
// Fetch viewer ID and set up socket
const setupSocket = async () => {
try {
// Get current user's viewer ID
const res = await fetch('/api/viewer')
if (!res.ok) {
console.error('[JoinRoomModal] Failed to get viewer ID')
return
}
const { viewerId } = await res.json()
console.log('[JoinRoomModal] Got viewer ID:', viewerId)
// Connect socket
socket = io({ path: '/api/socket' })
socket.on('connect', () => {
console.log('[JoinRoomModal] Socket connected, joining user channel')
// Join user-specific channel to receive moderation events
socket?.emit('join-user-channel', { userId: viewerId })
})
socket.on('join-request-approved', async (data: { roomId: string; requestId: string }) => {
console.log('[JoinRoomModal] Request approved via socket!', data)
if (data.roomId === roomInfo.id) {
console.log('[JoinRoomModal] Joining room automatically...')
try {
await joinRoom(roomInfo.id)
handleClose()
onSuccess?.()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to join room')
setIsLoading(false)
}
}
})
socket.on('connect_error', (error) => {
console.error('[JoinRoomModal] Socket connection error:', error)
})
} catch (error) {
console.error('[JoinRoomModal] Error setting up socket:', error)
}
}
setupSocket()
return () => {
console.log('[JoinRoomModal] Cleaning up approval listener')
socket?.disconnect()
}
}, [approvalRequested, roomInfo, joinRoom, handleClose, onSuccess])
return (
<Modal isOpen={isOpen} onClose={handleClose}>
<div
@@ -80,7 +216,7 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
color: 'rgba(196, 181, 253, 1)',
}}
>
Join Room by Code
{needsApproval ? 'Request to Join Room' : 'Join Room by Code'}
</h2>
<p
style={{
@@ -89,125 +225,354 @@ export function JoinRoomModal({ isOpen, onClose, onSuccess }: JoinRoomModalProps
marginBottom: '24px',
}}
>
Enter the 6-character room code
{needsApproval
? approvalRequested
? 'Your request has been sent to the room moderator.'
: 'This room requires host approval. Send a request to join?'
: needsPassword
? 'This room is password protected'
: 'Enter the 6-character room code'}
</p>
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => {
setCode(e.target.value.toUpperCase())
setError('')
}}
placeholder="ABC123"
maxLength={6}
disabled={isLoading}
style={{
width: '100%',
padding: '14px',
border: error
? '2px solid rgba(239, 68, 68, 0.6)'
: '2px solid rgba(139, 92, 246, 0.4)',
borderRadius: '10px',
fontSize: '18px',
fontWeight: 'bold',
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: '4px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(196, 181, 253, 1)',
outline: 'none',
marginBottom: '8px',
}}
/>
{needsApproval ? (
// Approval request UI
<div>
{approvalRequested ? (
// Waiting for approval state
<>
<div style={{ textAlign: 'center', marginBottom: '20px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<h3
style={{
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '8px',
color: 'rgba(96, 165, 250, 1)',
}}
>
Waiting for Approval
</h3>
</div>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div
style={{
padding: '16px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
marginBottom: '20px',
}}
>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '8px',
}}
>
<strong>{roomInfo?.name}</strong>
</p>
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
Code: {roomInfo?.code}
</p>
</div>
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={code.trim().length !== 6 || isLoading}
style={{
flex: 1,
padding: '12px',
background:
code.trim().length === 6 && !isLoading
? 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
: 'rgba(75, 85, 99, 0.3)',
color:
code.trim().length === 6 && !isLoading
? 'rgba(255, 255, 255, 1)'
: 'rgba(156, 163, 175, 1)',
border:
code.trim().length === 6 && !isLoading
? '2px solid rgba(59, 130, 246, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: code.trim().length === 6 && !isLoading ? 'pointer' : 'not-allowed',
opacity: code.trim().length === 6 && !isLoading ? 1 : 0.5,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Joining...' : 'Join Room'}
</button>
<p
style={{
fontSize: '13px',
color: 'rgba(156, 163, 175, 1)',
textAlign: 'center',
marginBottom: '20px',
}}
>
You'll be able to join once the host approves your request. You can close this
dialog and check back later.
</p>
<button
type="button"
onClick={handleClose}
style={{
width: '100%',
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: 'pointer',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}}
>
Close
</button>
</>
) : (
// Initial request prompt
<>
<div
style={{
padding: '16px',
background: 'rgba(59, 130, 246, 0.1)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
marginBottom: '20px',
}}
>
<p
style={{
fontSize: '14px',
color: 'rgba(209, 213, 219, 1)',
marginBottom: '8px',
}}
>
<strong>{roomInfo?.name}</strong>
</p>
<p style={{ fontSize: '13px', color: 'rgba(156, 163, 175, 1)' }}>
Code: {roomInfo?.code}
</p>
</div>
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="button"
onClick={handleRequestAccess}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: isLoading
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))',
color: isLoading ? 'rgba(156, 163, 175, 1)' : 'rgba(255, 255, 255, 1)',
border: isLoading
? '2px solid rgba(75, 85, 99, 0.5)'
: '2px solid rgba(59, 130, 246, 0.6)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Sending...' : 'Send Request'}
</button>
</div>
</>
)}
</div>
</form>
) : (
// Standard join form
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => {
setCode(e.target.value.toUpperCase())
setError('')
setNeedsPassword(false)
setNeedsApproval(false)
}}
placeholder="ABC123"
maxLength={6}
disabled={isLoading || needsPassword}
style={{
width: '100%',
padding: '14px',
border: error
? '2px solid rgba(239, 68, 68, 0.6)'
: '2px solid rgba(139, 92, 246, 0.4)',
borderRadius: '10px',
fontSize: '18px',
fontWeight: 'bold',
fontFamily: 'monospace',
textAlign: 'center',
letterSpacing: '4px',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(196, 181, 253, 1)',
outline: 'none',
marginBottom: '8px',
}}
/>
{needsPassword && (
<input
type="password"
value={password}
onChange={(e) => {
setPassword(e.target.value)
setError('')
}}
placeholder="Enter password"
disabled={isLoading}
style={{
width: '100%',
padding: '14px',
border: error
? '2px solid rgba(239, 68, 68, 0.6)'
: '2px solid rgba(251, 191, 36, 0.4)',
borderRadius: '10px',
fontSize: '16px',
textAlign: 'center',
background: 'rgba(255, 255, 255, 0.05)',
color: 'rgba(251, 191, 36, 1)',
outline: 'none',
marginBottom: '8px',
marginTop: '12px',
}}
/>
)}
{error && (
<p
style={{
fontSize: '13px',
color: 'rgba(248, 113, 113, 1)',
marginBottom: '16px',
textAlign: 'center',
}}
>
{error}
</p>
)}
<div style={{ display: 'flex', gap: '12px', marginTop: '24px' }}>
<button
type="button"
onClick={handleClose}
disabled={isLoading}
style={{
flex: 1,
padding: '12px',
background: 'rgba(75, 85, 99, 0.3)',
color: 'rgba(209, 213, 219, 1)',
border: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: isLoading ? 'not-allowed' : 'pointer',
opacity: isLoading ? 0.5 : 1,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.4)'
}
}}
onMouseLeave={(e) => {
if (!isLoading) {
e.currentTarget.style.background = 'rgba(75, 85, 99, 0.3)'
}
}}
>
Cancel
</button>
<button
type="submit"
disabled={code.trim().length !== 6 || isLoading}
style={{
flex: 1,
padding: '12px',
background:
code.trim().length === 6 && !isLoading
? 'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
: 'rgba(75, 85, 99, 0.3)',
color:
code.trim().length === 6 && !isLoading
? 'rgba(255, 255, 255, 1)'
: 'rgba(156, 163, 175, 1)',
border:
code.trim().length === 6 && !isLoading
? '2px solid rgba(59, 130, 246, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '10px',
fontSize: '15px',
fontWeight: '600',
cursor: code.trim().length === 6 && !isLoading ? 'pointer' : 'not-allowed',
opacity: code.trim().length === 6 && !isLoading ? 1 : 0.5,
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.9), rgba(37, 99, 235, 0.9))'
}
}}
onMouseLeave={(e) => {
if (code.trim().length === 6 && !isLoading) {
e.currentTarget.style.background =
'linear-gradient(135deg, rgba(59, 130, 246, 0.8), rgba(37, 99, 235, 0.8))'
}
}}
>
{isLoading ? 'Joining...' : needsPassword ? 'Join with Password' : 'Join Room'}
</button>
</div>
</form>
)}
</div>
</Modal>
)

View File

@@ -1,6 +1,7 @@
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 type { ModerationEvent } from '@/hooks/useRoomData'
import { useJoinRoom } from '@/hooks/useRoomData'
@@ -25,8 +26,12 @@ export function ModerationNotifications({
onClose,
}: ModerationNotificationsProps) {
const router = useRouter()
const queryClient = useQueryClient()
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,6 +47,86 @@ 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
}
}, [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') {
return (
@@ -391,6 +476,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

View File

@@ -47,7 +47,7 @@ export interface ModerationPanelProps {
focusedUserId?: string
}
type Tab = 'members' | 'bans' | 'history'
type Tab = 'members' | 'bans' | 'history' | 'settings'
export interface HistoricalMemberWithStatus {
userId: string
@@ -86,6 +86,13 @@ export function ModerationPanel({
const [error, setError] = useState('')
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Settings state
const [accessMode, setAccessMode] = useState<string>('open')
const [roomPassword, setRoomPassword] = useState('')
const [showPasswordInput, setShowPasswordInput] = useState(false)
const [selectedNewOwner, setSelectedNewOwner] = useState<string>('')
const [joinRequests, setJoinRequests] = useState<any[]>([])
// Ban modal state
const [showBanModal, setShowBanModal] = useState(false)
const [banTargetUserId, setBanTargetUserId] = useState<string | null>(null)
@@ -323,6 +330,146 @@ export function ModerationPanel({
}
}
// Load room settings and join requests when Settings tab is opened
useEffect(() => {
if (!isOpen || activeTab !== 'settings') return
const loadSettings = async () => {
try {
// Fetch current room data to get access mode
const roomRes = await fetch(`/api/arcade/rooms/${roomId}`)
if (roomRes.ok) {
const data = await roomRes.json()
setAccessMode(data.room?.accessMode || 'open')
}
// Fetch join requests if any
const requestsRes = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (requestsRes.ok) {
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
} catch (err) {
console.error('Failed to load settings:', err)
}
}
loadSettings()
}, [isOpen, activeTab, roomId])
// Handlers for Settings tab
const handleUpdateAccessMode = async () => {
setActionLoading('update-settings')
try {
const body: any = { accessMode }
if (accessMode === 'password' && roomPassword) {
body.password = roomPassword
}
const res = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to update settings')
}
alert('Room settings updated successfully!')
setShowPasswordInput(false)
setRoomPassword('')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to update settings')
} finally {
setActionLoading(null)
}
}
const handleTransferOwnership = async () => {
if (!selectedNewOwner) return
const newOwner = members.find((m) => m.userId === selectedNewOwner)
if (!newOwner) return
if (!confirm(`Transfer ownership to ${newOwner.displayName}? You will no longer be the host.`))
return
setActionLoading('transfer-ownership')
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/transfer-ownership`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ newOwnerId: selectedNewOwner }),
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to transfer ownership')
}
alert(`Ownership transferred to ${newOwner.displayName}!`)
onClose() // Close panel since user is no longer host
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to transfer ownership')
} finally {
setActionLoading(null)
}
}
const handleApproveJoinRequest = async (requestId: string) => {
setActionLoading(`approve-request-${requestId}`)
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/join-requests/${requestId}/approve`, {
method: 'POST',
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to approve request')
}
// Reload requests
const requestsRes = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (requestsRes.ok) {
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
alert('Join request approved!')
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to approve request')
} finally {
setActionLoading(null)
}
}
const handleDenyJoinRequest = async (requestId: string) => {
setActionLoading(`deny-request-${requestId}`)
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/join-requests/${requestId}/deny`, {
method: 'POST',
})
if (!res.ok) {
const errorData = await res.json()
throw new Error(errorData.error || 'Failed to deny request')
}
// Reload requests
const requestsRes = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (requestsRes.ok) {
const data = await requestsRes.json()
setJoinRequests(data.requests || [])
}
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to deny request')
} finally {
setActionLoading(null)
}
}
const pendingReports = reports.filter((r) => r.status === 'pending')
const otherMembers = members.filter((m) => m.userId !== currentUserId)
@@ -375,7 +522,7 @@ export function ModerationPanel({
borderBottom: '1px solid rgba(75, 85, 99, 0.3)',
}}
>
{(['members', 'bans', 'history'] as Tab[]).map((tab) => (
{(['members', 'bans', 'history', 'settings'] as Tab[]).map((tab) => (
<button
key={tab}
type="button"
@@ -415,6 +562,26 @@ export function ModerationPanel({
)}
{tab === 'bans' && `Banned (${bans.length})`}
{tab === 'history' && `History (${historicalMembers.length})`}
{tab === 'settings' && (
<span>
Settings
{joinRequests.filter((r: any) => r.status === 'pending').length > 0 && (
<span
style={{
marginLeft: '6px',
padding: '2px 6px',
borderRadius: '10px',
background: 'rgba(59, 130, 246, 0.8)',
color: 'white',
fontSize: '11px',
fontWeight: '700',
}}
>
{joinRequests.filter((r: any) => r.status === 'pending').length} pending
</span>
)}
</span>
)}
</button>
))}
</div>
@@ -1139,6 +1306,357 @@ export function ModerationPanel({
)}
</div>
)}
{/* Settings Tab */}
{activeTab === 'settings' && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
{/* Access Mode Section */}
<div>
<div
style={{
fontSize: '14px',
fontWeight: '700',
color: 'rgba(253, 186, 116, 1)',
marginBottom: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
🔒 Room Access Mode
</div>
<div
style={{
padding: '16px',
background: 'rgba(255, 255, 255, 0.05)',
border: '1px solid rgba(75, 85, 99, 0.3)',
borderRadius: '8px',
}}
>
{/* Access mode button grid */}
<div
style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '12px',
}}
>
{[
{ value: 'open', emoji: '🌐', label: 'Open', desc: 'Anyone' },
{ value: 'password', emoji: '🔑', label: 'Password', desc: 'With key' },
{ value: 'approval-only', emoji: '✋', label: 'Approval', desc: 'Request' },
{
value: 'restricted',
emoji: '🚫',
label: 'Restricted',
desc: 'Invite only',
},
{ value: 'locked', emoji: '🔒', label: 'Locked', desc: 'No new members' },
{ value: 'retired', emoji: '🏁', label: 'Retired', desc: 'Closed' },
].map((mode) => (
<button
key={mode.value}
type="button"
disabled={actionLoading === 'update-settings'}
onClick={() => {
setAccessMode(mode.value)
setShowPasswordInput(mode.value === 'password')
}}
style={{
padding: '10px 12px',
background:
accessMode === mode.value
? 'rgba(253, 186, 116, 0.15)'
: 'rgba(255, 255, 255, 0.05)',
border:
accessMode === mode.value
? '2px solid rgba(253, 186, 116, 0.6)'
: '2px solid rgba(75, 85, 99, 0.5)',
borderRadius: '8px',
color:
accessMode === mode.value
? 'rgba(253, 186, 116, 1)'
: 'rgba(209, 213, 219, 0.8)',
fontSize: '13px',
fontWeight: '500',
cursor: actionLoading === 'update-settings' ? 'not-allowed' : 'pointer',
opacity: actionLoading === 'update-settings' ? 0.5 : 1,
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
onMouseEnter={(e) => {
if (actionLoading !== 'update-settings' && accessMode !== mode.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.08)'
e.currentTarget.style.borderColor = 'rgba(253, 186, 116, 0.4)'
}
}}
onMouseLeave={(e) => {
if (accessMode !== mode.value) {
e.currentTarget.style.background = 'rgba(255, 255, 255, 0.05)'
e.currentTarget.style.borderColor = 'rgba(75, 85, 99, 0.5)'
}
}}
>
<span style={{ fontSize: '18px' }}>{mode.emoji}</span>
<div style={{ textAlign: 'left', flex: 1, lineHeight: '1.2' }}>
<div style={{ fontSize: '13px', fontWeight: '600' }}>{mode.label}</div>
<div style={{ fontSize: '11px', opacity: 0.7 }}>{mode.desc}</div>
</div>
</button>
))}
</div>
{/* Password input (conditional) */}
{(accessMode === 'password' || showPasswordInput) && (
<input
type="text"
value={roomPassword}
onChange={(e) => setRoomPassword(e.target.value)}
placeholder="Enter room password"
style={{
width: '100%',
padding: '10px',
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',
marginBottom: '12px',
}}
/>
)}
<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>
{/* Join Requests Section (for approval-only mode) */}
{joinRequests.filter((r: any) => r.status === 'pending').length > 0 && (
<div>
<div
style={{
fontSize: '14px',
fontWeight: '700',
color: 'rgba(59, 130, 246, 1)',
marginBottom: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
🙋 Pending Join Requests
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{joinRequests
.filter((r: any) => r.status === 'pending')
.map((request: any) => (
<div
key={request.id}
style={{
padding: '12px',
background: 'rgba(59, 130, 246, 0.08)',
border: '1px solid rgba(59, 130, 246, 0.3)',
borderRadius: '8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<div>
<div
style={{
fontSize: '14px',
fontWeight: '600',
color: 'rgba(209, 213, 219, 1)',
}}
>
{request.userName || 'Anonymous User'}
</div>
<div
style={{
fontSize: '12px',
color: 'rgba(156, 163, 175, 1)',
marginTop: '2px',
}}
>
Requested {new Date(request.createdAt).toLocaleString()}
</div>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
type="button"
onClick={() => handleDenyJoinRequest(request.id)}
disabled={actionLoading === `deny-request-${request.id}`}
style={{
padding: '6px 12px',
background: 'rgba(239, 68, 68, 0.2)',
color: 'rgba(239, 68, 68, 1)',
border: '1px solid rgba(239, 68, 68, 0.4)',
borderRadius: '6px',
fontSize: '13px',
fontWeight: '600',
cursor:
actionLoading === `deny-request-${request.id}`
? 'not-allowed'
: 'pointer',
opacity: actionLoading === `deny-request-${request.id}` ? 0.5 : 1,
}}
>
{actionLoading === `deny-request-${request.id}`
? 'Denying...'
: 'Deny'}
</button>
<button
type="button"
onClick={() => handleApproveJoinRequest(request.id)}
disabled={actionLoading === `approve-request-${request.id}`}
style={{
padding: '6px 12px',
background: 'rgba(34, 197, 94, 0.2)',
color: 'rgba(34, 197, 94, 1)',
border: '1px solid rgba(34, 197, 94, 0.4)',
borderRadius: '6px',
fontSize: '13px',
fontWeight: '600',
cursor:
actionLoading === `approve-request-${request.id}`
? 'not-allowed'
: 'pointer',
opacity:
actionLoading === `approve-request-${request.id}` ? 0.5 : 1,
}}
>
{actionLoading === `approve-request-${request.id}`
? 'Approving...'
: 'Approve'}
</button>
</div>
</div>
))}
</div>
</div>
)}
{/* Transfer Ownership Section */}
<div>
<div
style={{
fontSize: '14px',
fontWeight: '700',
color: 'rgba(251, 146, 60, 1)',
marginBottom: '12px',
textTransform: 'uppercase',
letterSpacing: '0.5px',
}}
>
👑 Transfer Ownership
</div>
<div
style={{
padding: '16px',
background: 'rgba(251, 146, 60, 0.08)',
border: '1px solid rgba(251, 146, 60, 0.3)',
borderRadius: '8px',
}}
>
<p
style={{
fontSize: '13px',
color: 'rgba(209, 213, 219, 0.8)',
marginBottom: '12px',
}}
>
Transfer host privileges to another member. You will no longer be the host.
</p>
<select
value={selectedNewOwner}
onChange={(e) => setSelectedNewOwner(e.target.value)}
style={{
width: '100%',
padding: '10px',
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',
marginBottom: '12px',
cursor: 'pointer',
}}
>
<option value="">Select new owner...</option>
{otherMembers.map((member) => (
<option key={member.userId} value={member.userId}>
{member.displayName}
{member.isOnline ? ' (Online)' : ' (Offline)'}
</option>
))}
</select>
<button
type="button"
onClick={handleTransferOwnership}
disabled={!selectedNewOwner || actionLoading === 'transfer-ownership'}
style={{
width: '100%',
padding: '10px',
background:
!selectedNewOwner || actionLoading === 'transfer-ownership'
? 'rgba(75, 85, 99, 0.3)'
: 'linear-gradient(135deg, rgba(251, 146, 60, 0.8), rgba(249, 115, 22, 0.8))',
color: 'white',
border:
!selectedNewOwner || actionLoading === 'transfer-ownership'
? '1px solid rgba(75, 85, 99, 0.5)'
: '1px solid rgba(251, 146, 60, 0.6)',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '600',
cursor:
!selectedNewOwner || actionLoading === 'transfer-ownership'
? 'not-allowed'
: 'pointer',
opacity:
!selectedNewOwner || actionLoading === 'transfer-ownership' ? 0.5 : 1,
}}
>
{actionLoading === 'transfer-ownership'
? 'Transferring...'
: 'Transfer Ownership'}
</button>
</div>
</div>
</div>
)}
</div>
)}

View File

@@ -1,8 +1,9 @@
import React from 'react'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
interface RecentRoom {
code: string
name: string
name: string | null
gameName: string
joinedAt: number
}
@@ -100,7 +101,13 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
}}
>
<span>🏟</span>
<span>{room.name}</span>
<span>
{getRoomDisplayWithEmoji({
name: room.name,
code: room.code,
gameName: room.gameName,
})}
</span>
</div>
<div
style={{
@@ -123,7 +130,11 @@ export function RecentRoomsList({ onSelectRoom }: RecentRoomsListProps) {
}
// Helper function to add a room to recent rooms
export function addToRecentRooms(room: { code: string; name: string; gameName: string }): void {
export function addToRecentRooms(room: {
code: string
name: string | null
gameName: string
}): void {
try {
const stored = localStorage.getItem(STORAGE_KEY)
const rooms: RecentRoom[] = stored ? JSON.parse(stored) : []

View File

@@ -3,6 +3,7 @@ import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
import { CreateRoomModal } from './CreateRoomModal'
import { JoinRoomModal } from './JoinRoomModal'
import { ModerationPanel } from './ModerationPanel'
@@ -11,7 +12,7 @@ import { RoomShareButtons } from './RoomShareButtons'
type GameMode = 'none' | 'single' | 'battle' | 'tournament'
interface RoomInfoProps {
roomName?: string
roomName?: string | null
gameName: string
playerCount: number
joinCode?: string
@@ -57,11 +58,15 @@ export function RoomInfo({
const [showModerationPanel, setShowModerationPanel] = useState(false)
const [focusedUserId, setFocusedUserId] = useState<string | undefined>(undefined)
const [pendingReportsCount, setPendingReportsCount] = useState(0)
const [pendingJoinRequestsCount, setPendingJoinRequestsCount] = useState(0)
const { getRoomShareUrl, roomData } = useRoomData()
const { data: currentUserId } = useViewerId()
const { mutateAsync: leaveRoom } = useLeaveRoom()
const displayName = roomName || gameName
// Use room display utility for consistent naming
const displayName = joinCode
? getRoomDisplayWithEmoji({ name: roomName || null, code: joinCode, gameName })
: roomName || gameName
const shareUrl = joinCode ? getRoomShareUrl(joinCode) : ''
// Determine ownership status
@@ -93,6 +98,29 @@ export function RoomInfo({
return () => clearInterval(interval)
}, [isCurrentUserCreator, roomId])
// Fetch pending join requests count if user is host
useEffect(() => {
if (!isCurrentUserCreator || !roomId) return
const fetchPendingJoinRequests = async () => {
try {
const res = await fetch(`/api/arcade/rooms/${roomId}/join-requests`)
if (res.ok) {
const data = await res.json()
const pending = data.requests?.filter((r: any) => r.status === 'pending') || []
setPendingJoinRequestsCount(pending.length)
}
} catch (error) {
console.error('[RoomInfo] Failed to fetch join requests:', error)
}
}
fetchPendingJoinRequests()
// Poll every 30 seconds
const interval = setInterval(fetchPendingJoinRequests, 30000)
return () => clearInterval(interval)
}, [isCurrentUserCreator, roomId])
// Listen for moderation events to update report count in real-time
const { moderationEvent } = useRoomData()
useEffect(() => {
@@ -235,8 +263,8 @@ export function RoomInfo({
>
<span style={{ fontSize: '10px', lineHeight: 1 }}>👑</span>
<span style={{ lineHeight: 1 }}>You are host</span>
{/* Pending reports badge */}
{pendingReportsCount > 0 && (
{/* Pending items badge (reports + join requests) */}
{(pendingReportsCount > 0 || pendingJoinRequestsCount > 0) && (
<span
style={{
display: 'inline-flex',
@@ -245,15 +273,24 @@ export function RoomInfo({
width: '16px',
height: '16px',
borderRadius: '50%',
background: 'rgba(239, 68, 68, 1)',
background:
pendingJoinRequestsCount > 0
? 'rgba(59, 130, 246, 1)'
: 'rgba(239, 68, 68, 1)',
color: 'white',
fontSize: '8px',
fontWeight: '700',
marginLeft: '2px',
}}
title={`${pendingReportsCount} pending report${pendingReportsCount > 1 ? 's' : ''}`}
title={
pendingJoinRequestsCount > 0 && pendingReportsCount > 0
? `${pendingJoinRequestsCount} join request${pendingJoinRequestsCount > 1 ? 's' : ''}, ${pendingReportsCount} report${pendingReportsCount > 1 ? 's' : ''}`
: pendingJoinRequestsCount > 0
? `${pendingJoinRequestsCount} join request${pendingJoinRequestsCount > 1 ? 's' : ''}`
: `${pendingReportsCount} report${pendingReportsCount > 1 ? 's' : ''}`
}
>
{pendingReportsCount}
{pendingReportsCount + pendingJoinRequestsCount}
</span>
)}
</div>

View File

@@ -8,7 +8,7 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
// Room identity
code: text('code', { length: 6 }).notNull().unique(), // e.g., "ABC123"
name: text('name', { length: 50 }).notNull(),
name: text('name', { length: 50 }), // Optional: auto-generates from code and game if null
// Creator info
createdBy: text('created_by').notNull(), // User/guest ID
@@ -22,7 +22,14 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
.notNull()
.$defaultFn(() => new Date()),
ttlMinutes: integer('ttl_minutes').notNull().default(60), // Time to live
isLocked: integer('is_locked', { mode: 'boolean' }).notNull().default(false),
// Access control
accessMode: text('access_mode', {
enum: ['open', 'locked', 'retired', 'password', 'restricted', 'approval-only'],
})
.notNull()
.default('open'),
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
// Game configuration
gameName: text('game_name', {

View File

@@ -14,5 +14,6 @@ export * from './room-member-history'
export * from './room-invitations'
export * from './room-reports'
export * from './room-bans'
export * from './room-join-requests'
export * from './user-stats'
export * from './users'

View File

@@ -0,0 +1,45 @@
import { createId } from '@paralleldrive/cuid2'
import { integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'
import { arcadeRooms } from './arcade-rooms'
/**
* Join requests for approval-only rooms
*/
export const roomJoinRequests = sqliteTable(
'room_join_requests',
{
id: text('id')
.primaryKey()
.$defaultFn(() => createId()),
roomId: text('room_id')
.notNull()
.references(() => arcadeRooms.id, { onDelete: 'cascade' }),
// Requesting user
userId: text('user_id').notNull(),
userName: text('user_name', { length: 50 }).notNull(),
// Request status
status: text('status', {
enum: ['pending', 'approved', 'denied'],
})
.notNull()
.default('pending'),
// Timestamps
requestedAt: integer('requested_at', { mode: 'timestamp' })
.notNull()
.$defaultFn(() => new Date()),
reviewedAt: integer('reviewed_at', { mode: 'timestamp' }),
reviewedBy: text('reviewed_by'), // Host user ID who reviewed
reviewedByName: text('reviewed_by_name', { length: 50 }),
},
(table) => ({
// One pending request per user per room
userRoomIdx: uniqueIndex('idx_room_join_requests_user_room').on(table.userId, table.roomId),
})
)
export type RoomJoinRequest = typeof roomJoinRequests.$inferSelect
export type NewRoomJoinRequest = typeof roomJoinRequests.$inferInsert

View File

@@ -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'
@@ -23,15 +23,18 @@ export interface RoomData {
name: string
code: string
gameName: string
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
members: RoomMember[]
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
}
export interface CreateRoomParams {
name: string
name: string | null
gameName: string
creatorName?: string
gameConfig?: Record<string, unknown>
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
password?: string
}
export interface JoinRoomResult {
@@ -68,6 +71,7 @@ async function fetchCurrentRoom(): Promise<RoomData | null> {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
}
@@ -85,6 +89,8 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
gameName: params.gameName,
creatorName: params.creatorName || 'Player',
gameConfig: params.gameConfig || { difficulty: 6 },
accessMode: params.accessMode,
password: params.password,
}),
})
@@ -99,6 +105,7 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
}
@@ -110,11 +117,15 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
async function joinRoomApi(params: {
roomId: string
displayName?: string
password?: string
}): Promise<JoinRoomResult> {
const response = await fetch(`/api/arcade/rooms/${params.roomId}/join`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ displayName: params.displayName || 'Player' }),
body: JSON.stringify({
displayName: params.displayName || 'Player',
password: params.password,
}),
})
if (!response.ok) {
@@ -130,6 +141,7 @@ async function joinRoomApi(params: {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
},
@@ -171,13 +183,14 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
name: data.room.name,
code: data.room.code,
gameName: data.room.gameName,
accessMode: data.room.accessMode || 'open',
members: data.members || [],
memberPlayers: data.memberPlayers || {},
}
}
export interface ModerationEvent {
type: 'kicked' | 'banned' | 'report' | 'invitation'
type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request'
data: {
roomId?: string
kickedBy?: string
@@ -193,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
}
}
@@ -407,6 +424,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)
@@ -415,6 +453,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)
@@ -425,6 +464,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])

View File

@@ -33,7 +33,8 @@ vi.mock('@/db', () => ({
code: 'code',
name: 'name',
gameName: 'gameName',
isLocked: 'isLocked',
accessMode: 'accessMode',
password: 'password',
status: 'status',
lastActivity: 'lastActivity',
},
@@ -59,7 +60,8 @@ describe('Room Manager', () => {
createdAt: new Date(),
lastActivity: new Date(),
ttlMinutes: 60,
isLocked: false,
accessMode: 'open',
password: null,
gameName: 'matching',
gameConfig: { difficulty: 6 },
status: 'lobby',
@@ -245,7 +247,7 @@ describe('Room Manager', () => {
describe('updateRoom', () => {
it('updates room and returns updated data', async () => {
const updates = { name: 'Updated Room', isLocked: true }
const updates = { name: 'Updated Room', status: 'playing' as const }
const mockUpdate = {
set: vi.fn().mockReturnThis(),
@@ -257,7 +259,7 @@ describe('Room Manager', () => {
const room = await updateRoom('room-123', updates)
expect(room?.name).toBe('Updated Room')
expect(room?.isLocked).toBe(true)
expect(room?.status).toBe('playing')
expect(db.update).toHaveBeenCalled()
})
@@ -328,12 +330,12 @@ describe('Room Manager', () => {
expect(db.query.arcadeRooms.findMany).toHaveBeenCalled()
})
it('excludes locked rooms', async () => {
it('only includes open and password-protected rooms', async () => {
vi.mocked(db.query.arcadeRooms.findMany).mockResolvedValue(activeRooms)
await listActiveRooms()
// Verify the where clause excludes locked rooms
// Verify the where clause filters by accessMode
const call = vi.mocked(db.query.arcadeRooms.findMany).mock.calls[0][0]
expect(call).toBeDefined()
})

View File

@@ -0,0 +1,152 @@
/**
* Room Join Requests Manager
* Handles join request logic for approval-only rooms
*/
import { and, eq } from 'drizzle-orm'
import { db, schema } from '@/db'
export interface CreateJoinRequestParams {
roomId: string
userId: string
userName: string
}
/**
* Create a join request
*/
export async function createJoinRequest(
params: CreateJoinRequestParams
): Promise<schema.RoomJoinRequest> {
const now = new Date()
// Check if there's an existing request
const existing = await db
.select()
.from(schema.roomJoinRequests)
.where(
and(
eq(schema.roomJoinRequests.roomId, params.roomId),
eq(schema.roomJoinRequests.userId, params.userId)
)
)
.limit(1)
if (existing.length > 0) {
// Update existing request (reset to pending)
const [updated] = await db
.update(schema.roomJoinRequests)
.set({
userName: params.userName,
status: 'pending',
requestedAt: now,
reviewedAt: null,
reviewedBy: null,
reviewedByName: null,
})
.where(eq(schema.roomJoinRequests.id, existing[0].id))
.returning()
return updated
}
// Create new request
const [request] = await db
.insert(schema.roomJoinRequests)
.values({
roomId: params.roomId,
userId: params.userId,
userName: params.userName,
status: 'pending',
requestedAt: now,
})
.returning()
return request
}
/**
* Get all pending join requests for a room
*/
export async function getPendingJoinRequests(roomId: string): Promise<schema.RoomJoinRequest[]> {
return await db
.select()
.from(schema.roomJoinRequests)
.where(
and(eq(schema.roomJoinRequests.roomId, roomId), eq(schema.roomJoinRequests.status, 'pending'))
)
.orderBy(schema.roomJoinRequests.requestedAt)
}
/**
* Get all join requests for a room (any status)
*/
export async function getAllJoinRequests(roomId: string): Promise<schema.RoomJoinRequest[]> {
return await db
.select()
.from(schema.roomJoinRequests)
.where(eq(schema.roomJoinRequests.roomId, roomId))
.orderBy(schema.roomJoinRequests.requestedAt)
}
/**
* Approve a join request
*/
export async function approveJoinRequest(
requestId: string,
reviewedBy: string,
reviewedByName: string
): Promise<schema.RoomJoinRequest> {
const [request] = await db
.update(schema.roomJoinRequests)
.set({
status: 'approved',
reviewedAt: new Date(),
reviewedBy,
reviewedByName,
})
.where(eq(schema.roomJoinRequests.id, requestId))
.returning()
return request
}
/**
* Deny a join request
*/
export async function denyJoinRequest(
requestId: string,
reviewedBy: string,
reviewedByName: string
): Promise<schema.RoomJoinRequest> {
const [request] = await db
.update(schema.roomJoinRequests)
.set({
status: 'denied',
reviewedAt: new Date(),
reviewedBy,
reviewedByName,
})
.where(eq(schema.roomJoinRequests.id, requestId))
.returning()
return request
}
/**
* Get a specific join request
*/
export async function getJoinRequest(
roomId: string,
userId: string
): Promise<schema.RoomJoinRequest | undefined> {
const results = await db
.select()
.from(schema.roomJoinRequests)
.where(
and(eq(schema.roomJoinRequests.roomId, roomId), eq(schema.roomJoinRequests.userId, userId))
)
.limit(1)
return results[0]
}

View File

@@ -9,17 +9,18 @@ import { generateRoomCode } from './room-code'
import type { GameName } from './validation'
export interface CreateRoomOptions {
name: string
name: string | null
createdBy: string // User/guest ID
creatorName: string
gameName: GameName
gameConfig: unknown
ttlMinutes?: number // Default: 60
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
password?: string
}
export interface UpdateRoomOptions {
name?: string
isLocked?: boolean
status?: 'lobby' | 'playing' | 'finished'
currentSessionId?: string | null
totalGamesPlayed?: number
@@ -56,7 +57,8 @@ export async function createRoom(options: CreateRoomOptions): Promise<schema.Arc
createdAt: now,
lastActivity: now,
ttlMinutes: options.ttlMinutes || 60,
isLocked: false,
accessMode: options.accessMode || 'open', // Default to open access
password: options.password || null,
gameName: options.gameName,
gameConfig: options.gameConfig as any,
status: 'lobby',
@@ -134,6 +136,7 @@ export async function deleteRoom(roomId: string): Promise<void> {
/**
* List active rooms
* Returns rooms ordered by most recently active
* Only returns openly accessible rooms (accessMode: 'open' or 'password')
*/
export async function listActiveRooms(gameName?: GameName): Promise<schema.ArcadeRoom[]> {
const whereConditions = []
@@ -143,9 +146,10 @@ export async function listActiveRooms(gameName?: GameName): Promise<schema.Arcad
whereConditions.push(eq(schema.arcadeRooms.gameName, gameName))
}
// Only return non-locked rooms in lobby or playing status
// Only return accessible rooms in lobby or playing status
// Exclude locked, retired, restricted, and approval-only rooms
whereConditions.push(
eq(schema.arcadeRooms.isLocked, false),
or(eq(schema.arcadeRooms.accessMode, 'open'), eq(schema.arcadeRooms.accessMode, 'password')),
or(eq(schema.arcadeRooms.status, 'lobby'), eq(schema.arcadeRooms.status, 'playing'))
)

View File

@@ -0,0 +1,126 @@
/**
* Utility for displaying room names consistently across the codebase
*/
export interface RoomDisplayData {
/**
* The room's custom name if provided
*/
name: string | null
/**
* The room's unique code (e.g., "ABC123")
*/
code: string
/**
* The game type (optional, for emoji selection)
*/
gameName?: string
}
export interface RoomDisplay {
/**
* Plain text representation - ALWAYS available
* Use this for: document titles, logs, notifications, plaintext contexts
*/
plaintext: string
/**
* Primary display text (without emoji)
*/
primary: string
/**
* Secondary/subtitle text (optional)
*/
secondary?: string
/**
* Emoji/icon for the room (optional)
*/
emoji?: string
/**
* Whether the name was auto-generated (vs. custom)
*/
isGenerated: boolean
}
const GAME_EMOJIS: Record<string, string> = {
matching: '🃏',
'memory-quiz': '🧠',
'complement-race': '⚡',
}
const DEFAULT_EMOJI = '🎮'
/**
* Get structured room display information
*
* @example
* // Custom named room
* const display = getRoomDisplay({ name: "Alice's Room", code: "ABC123" })
* // => { plaintext: "Alice's Room", primary: "Alice's Room", secondary: "ABC123", emoji: undefined, isGenerated: false }
*
* @example
* // Auto-generated (no name)
* const display = getRoomDisplay({ name: null, code: "ABC123", gameName: "matching" })
* // => { plaintext: "Room ABC123", primary: "ABC123", secondary: undefined, emoji: "🃏", isGenerated: true }
*/
export function getRoomDisplay(room: RoomDisplayData): RoomDisplay {
if (room.name) {
// Custom name provided
return {
plaintext: room.name,
primary: room.name,
secondary: room.code,
emoji: undefined,
isGenerated: false,
}
}
// Auto-generate display
const emoji = GAME_EMOJIS[room.gameName || ''] || DEFAULT_EMOJI
return {
plaintext: `Room ${room.code}`, // Always plaintext fallback
primary: room.code,
secondary: undefined,
emoji,
isGenerated: true,
}
}
/**
* Get plaintext room name (shorthand)
* Use this when you just need a string representation
*
* @example
* getRoomDisplayName({ name: "Alice's Room", code: "ABC123" })
* // => "Alice's Room"
*
* @example
* getRoomDisplayName({ name: null, code: "ABC123" })
* // => "Room ABC123"
*/
export function getRoomDisplayName(room: RoomDisplayData): string {
return getRoomDisplay(room).plaintext
}
/**
* Get room display with emoji (for rich contexts)
*
* @example
* getRoomDisplayWithEmoji({ name: "Alice's Room", code: "ABC123" })
* // => "Alice's Room"
*
* @example
* getRoomDisplayWithEmoji({ name: null, code: "ABC123", gameName: "matching" })
* // => "🃏 ABC123"
*/
export function getRoomDisplayWithEmoji(room: RoomDisplayData): string {
const display = getRoomDisplay(room)
if (display.emoji) {
return `${display.emoji} ${display.primary}`
}
return display.primary
}

View File

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

19251
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff