Compare commits

...

31 Commits

Author SHA1 Message Date
semantic-release-bot
cb2fec1da5 chore(release): 3.9.1 [skip ci]
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)

### Bug Fixes

* reset join request toast state when moderation event cleared ([6beb58a](6beb58a7b8))
2025-10-14 13:14:31 +00:00
Thomas Hallock
6beb58a7b8 fix: reset join request toast state when moderation event cleared
**Issue:**
Toast notification was persisting after successful approval because
showJoinRequestToast state was never reset to false.

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:13:40 -05:00
semantic-release-bot
544b06e290 chore(release): 3.9.0 [skip ci]
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)

### Features

* prevent invitations to retired rooms ([a7c3c1f](a7c3c1f4cd))
2025-10-14 13:13:04 +00:00
Thomas Hallock
a7c3c1f4cd feat: prevent invitations to retired rooms
- Add room access mode check in invite POST endpoint
- Block invitation creation if room is retired (403 status)
- Clear error message: "Cannot send invitations to retired rooms"
- Check happens before host validation to catch retired rooms early

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

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

### Bug Fixes

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

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

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

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

### Features

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

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

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

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

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

### Bug Fixes

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:02:56 -05:00
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
19 changed files with 2290 additions and 182 deletions

View File

@@ -1,3 +1,113 @@
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
### Bug Fixes
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
### Features
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
### Bug Fixes
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)
### Features
* implement proper retired room behavior with member expulsion ([a2d5368](https://github.com/antialias/soroban-abacus-flashcards/commit/a2d53680f27db04b2cd09973e62a76c5a7d4ce06))
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)
### Bug Fixes
* improve join request approval error handling with actionable messages ([57bf846](https://github.com/antialias/soroban-abacus-flashcards/commit/57bf8460c8ecff374355bfb93f4b06dfbb148273))
## [3.7.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.3...v3.7.0) (2025-10-14)
### Features
* add prominent join request approval notifications for room moderators ([036da6d](https://github.com/antialias/soroban-abacus-flashcards/commit/036da6de66ca7d3f459c55df657b04a9e88d9cd3))
## [3.6.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.6.2...v3.6.3) (2025-10-14)
### 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)

View File

@@ -46,7 +46,18 @@
"Bash(npx @biomejs/biome check:*)",
"Bash(printf '\\n')",
"Bash(npm install bcryptjs)",
"Bash(npm install:*)"
"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

@@ -1,13 +1,14 @@
import { type NextRequest, NextResponse } from 'next/server'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import {
createInvitation,
declineInvitation,
getInvitation,
getRoomInvitations,
} from '@/lib/arcade/room-invitations'
import { getViewerId } from '@/lib/viewer'
import { getRoomById } from '@/lib/arcade/room-manager'
import { getRoomMembers } from '@/lib/arcade/room-membership'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -35,6 +36,20 @@ export async function POST(req: NextRequest, context: RouteContext) {
)
}
// Get room to check access mode
const room = await getRoomById(roomId)
if (!room) {
return NextResponse.json({ error: 'Room not found' }, { status: 404 })
}
// Cannot invite to retired rooms
if (room.accessMode === 'retired') {
return NextResponse.json(
{ error: 'Cannot send invitations to retired rooms' },
{ status: 403 }
)
}
// Check if user is the host
const members = await getRoomMembers(roomId)
const currentMember = members.find((m) => m.userId === viewerId)

View File

@@ -1,6 +1,8 @@
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 { getPendingJoinRequests } from '@/lib/arcade/room-join-requests'
import { getSocketIO } from '@/lib/socket-io'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
@@ -37,3 +39,81 @@ export async function GET(req: NextRequest, context: RouteContext) {
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,13 +1,13 @@
import bcrypt from 'bcryptjs'
import { type NextRequest, NextResponse } from 'next/server'
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 { getInvitation } from '@/lib/arcade/room-invitations'
import { getJoinRequest } from '@/lib/arcade/room-join-requests'
import { getViewerId } from '@/lib/viewer'
import { getRoomById, touchRoom } from '@/lib/arcade/room-manager'
import { addRoomMember, getRoomMembers } from '@/lib/arcade/room-membership'
import { isUserBanned } from '@/lib/arcade/room-moderation'
import { getSocketIO } from '@/lib/socket-io'
import bcrypt from 'bcryptjs'
import { getViewerId } from '@/lib/viewer'
type RouteContext = {
params: Promise<{ roomId: string }>
@@ -38,13 +38,32 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Check if user is already a member (for locked/retired room access)
const members = await getRoomMembers(roomId)
const isExistingMember = members.some((m) => m.userId === viewerId)
const isRoomCreator = room.createdBy === viewerId
// Validate access mode
switch (room.accessMode) {
case 'locked':
return NextResponse.json({ error: 'This room is locked' }, { status: 403 })
// 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 })
// Only the room creator can access retired rooms
if (!isRoomCreator) {
return NextResponse.json(
{ error: 'This room has been retired and is only accessible to the owner' },
{ status: 410 }
)
}
break
case 'password': {
if (!body.password) {
@@ -86,8 +105,6 @@ export async function POST(req: NextRequest, context: RouteContext) {
}
break
}
case 'open':
default:
// No additional checks needed
break

View File

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

View File

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

@@ -1,7 +1,8 @@
'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'
@@ -211,6 +212,8 @@ export default function JoinRoomPage({ params }: { params: { code: 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)
@@ -274,21 +277,17 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
return
}
if (room.accessMode === 'restricted') {
setError('This room is invitation-only')
return
}
if (room.accessMode === 'approval-only') {
setError('This room requires host approval. Please join via the room browser.')
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 (for open rooms)
// Otherwise, auto-join (for open rooms and restricted rooms with invitation)
handleJoin(room.id)
}
})
@@ -323,7 +322,88 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
}
}
if (error) {
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={{
@@ -455,7 +535,10 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => {
setPassword(e.target.value)
setError(null) // Clear error when user starts typing
}}
onKeyDown={(e) => {
if (e.key === 'Enter' && password) {
handlePasswordSubmit()
@@ -542,5 +625,260 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
)
}
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,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,8 +47,94 @@ export function ModerationNotifications({
}
}, [moderationEvent, onClose])
// Handle join request toast (for hosts)
useEffect(() => {
if (moderationEvent?.type === 'join-request') {
setShowJoinRequestToast(true)
setRequestError(null) // Clear any previous errors
} else {
// Reset toast state when event is cleared or changes type
setShowJoinRequestToast(false)
setRequestError(null)
}
}, [moderationEvent])
// Handle approve join request
const handleApprove = async () => {
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/approve`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to approve join request')
}
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
// Invalidate join requests query to refresh the list
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to approve join request:', error)
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to approve request')
} finally {
setIsProcessingRequest(false)
}
}
// Handle deny join request
const handleDeny = async () => {
if (!moderationEvent?.data.requestId || !moderationEvent?.data.roomId) return
setIsProcessingRequest(true)
setRequestError(null) // Clear any previous errors
try {
const response = await fetch(
`/api/arcade/rooms/${moderationEvent.data.roomId}/join-requests/${moderationEvent.data.requestId}/deny`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
const errorData = await response.json().catch(() => ({}))
throw new Error(errorData.error || 'Failed to deny join request')
}
// Close toast and event on success
setShowJoinRequestToast(false)
onClose()
// Invalidate join requests query to refresh the list
queryClient.invalidateQueries({ queryKey: ['join-requests'] })
} catch (error) {
console.error('Failed to deny join request:', error)
// Keep toast visible and show error message
setRequestError(error instanceof Error ? error.message : 'Failed to deny request')
} finally {
setIsProcessingRequest(false)
}
}
// Kicked modal
if (moderationEvent?.type === 'kicked') {
const isRetired = moderationEvent.data.reason?.includes('retired')
return (
<Modal isOpen={true} onClose={() => {}}>
<div
@@ -54,7 +145,7 @@ export function ModerationNotifications({
minWidth: '400px',
}}
>
<div style={{ fontSize: '48px', marginBottom: '16px' }}></div>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>{isRetired ? '🏁' : '⚠️'}</div>
<h2
style={{
fontSize: '24px',
@@ -63,7 +154,7 @@ export function ModerationNotifications({
color: 'rgba(253, 186, 116, 1)',
}}
>
Kicked from Room
{isRetired ? 'Room Retired' : 'Kicked from Room'}
</h2>
<p
style={{
@@ -72,10 +163,16 @@ export function ModerationNotifications({
marginBottom: '8px',
}}
>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
{isRetired ? (
<>The room owner has retired this room and access has been closed</>
) : (
<>
You were kicked from the room by{' '}
<strong style={{ color: 'rgba(253, 186, 116, 1)' }}>
{moderationEvent.data.kickedBy}
</strong>
</>
)}
</p>
<p
style={{
@@ -84,7 +181,9 @@ export function ModerationNotifications({
marginBottom: '24px',
}}
>
You can rejoin if the host sends you a new invite
{isRetired
? 'Only the room owner can access retired rooms'
: 'You can rejoin if the host sends you a new invite'}
</p>
<button
@@ -391,6 +490,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

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'
@@ -190,7 +190,7 @@ async function getRoomByCodeApi(code: string): Promise<RoomData> {
}
export interface ModerationEvent {
type: 'kicked' | 'banned' | 'report' | 'invitation'
type: 'kicked' | 'banned' | 'report' | 'invitation' | 'join-request'
data: {
roomId?: string
kickedBy?: string
@@ -206,6 +206,10 @@ export interface ModerationEvent {
invitedByName?: string
invitationType?: 'manual' | 'auto-unban' | 'auto-create'
message?: string
// Join request fields
requestId?: string
requesterId?: string
requesterName?: string
}
}
@@ -343,13 +347,14 @@ export function useRoomData() {
}
// Moderation event handlers
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string }) => {
const handleKickedFromRoom = (data: { roomId: string; kickedBy: string; reason?: string }) => {
console.log('[useRoomData] User was kicked from room:', data)
setModerationEvent({
type: 'kicked',
data: {
roomId: data.roomId,
kickedBy: data.kickedBy,
reason: data.reason,
},
})
// Clear room data since user was kicked
@@ -420,6 +425,27 @@ export function useRoomData() {
})
}
const handleJoinRequestSubmitted = (data: {
roomId: string
request: {
id: string
userId: string
userName: string
createdAt: Date
}
}) => {
console.log('[useRoomData] New join request submitted:', data)
setModerationEvent({
type: 'join-request',
data: {
roomId: data.roomId,
requestId: data.request.id,
requesterId: data.request.userId,
requesterName: data.request.userName,
},
})
}
socket.on('room-joined', handleRoomJoined)
socket.on('member-joined', handleMemberJoined)
socket.on('member-left', handleMemberLeft)
@@ -428,6 +454,7 @@ export function useRoomData() {
socket.on('banned-from-room', handleBannedFromRoom)
socket.on('report-submitted', handleReportSubmitted)
socket.on('room-invitation-received', handleInvitationReceived)
socket.on('join-request-submitted', handleJoinRequestSubmitted)
return () => {
socket.off('room-joined', handleRoomJoined)
@@ -438,6 +465,7 @@ export function useRoomData() {
socket.off('banned-from-room', handleBannedFromRoom)
socket.off('report-submitted', handleReportSubmitted)
socket.off('room-invitation-received', handleInvitationReceived)
socket.off('join-request-submitted', handleJoinRequestSubmitted)
}
}, [socket, roomData?.id, queryClient])

View File

@@ -9,12 +9,14 @@ 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 {
@@ -55,7 +57,8 @@ export async function createRoom(options: CreateRoomOptions): Promise<schema.Arc
createdAt: now,
lastActivity: now,
ttlMinutes: options.ttlMinutes || 60,
accessMode: 'open', // Default to open access
accessMode: options.accessMode || 'open', // Default to open access
password: options.password || null,
gameName: options.gameName,
gameConfig: options.gameConfig as any,
status: 'lobby',

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