Compare commits

...

10 Commits

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

### Bug Fixes

* remove duplicate ModerationNotifications causing double toasts ([c6886a0](c6886a0e59))
2025-10-14 13:19:07 +00:00
Thomas Hallock
c6886a0e59 fix: remove duplicate ModerationNotifications causing double toasts
**Root Cause:**
- ModerationNotifications was rendered in BOTH /arcade/room/page.tsx AND PageWithNav
- Both had separate useRoomData hooks, creating two socket listeners
- When join-request-submitted event fired, BOTH instances showed a toast
- Clicking "Approve" only closed one toast, leaving the other visible

**Fix:**
- Removed ModerationNotifications from room page entirely
- PageWithNav (inside MemoryPairsGame) already handles all moderation events
- Now only ONE instance listens and renders, so approvals properly dismiss

**Files Changed:**
- Removed ModerationNotifications import from room page
- Removed all 4 instances of <ModerationNotifications /> from room page
- Removed moderationEvent and clearModerationEvent from useRoomData destructuring
- Added comment explaining PageWithNav handles notifications

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 08:18:12 -05:00
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
8 changed files with 208 additions and 82 deletions

View File

@@ -1,3 +1,38 @@
## [3.9.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.1...v3.9.2) (2025-10-14)
### Bug Fixes
* remove duplicate ModerationNotifications causing double toasts ([c6886a0](https://github.com/antialias/soroban-abacus-flashcards/commit/c6886a0e59b3cbf051a828e0157495101cd8c823))
## [3.9.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.9.0...v3.9.1) (2025-10-14)
### Bug Fixes
* reset join request toast state when moderation event cleared ([6beb58a](https://github.com/antialias/soroban-abacus-flashcards/commit/6beb58a7b8f8e1841c71729a3517ab459e924aa9))
## [3.9.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.1...v3.9.0) (2025-10-14)
### Features
* prevent invitations to retired rooms ([a7c3c1f](https://github.com/antialias/soroban-abacus-flashcards/commit/a7c3c1f4cd802985c8f040bc1cdf3ea4482a2fce))
## [3.8.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.8.0...v3.8.1) (2025-10-14)
### Bug Fixes
* improve kicked modal message for retired room ejections ([f865ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/f865ce16ecf7648e41549795c8137f4fc33e34ac))
## [3.8.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.1...v3.8.0) (2025-10-14)
### Features
* implement proper retired room behavior with member expulsion ([a2d5368](https://github.com/antialias/soroban-abacus-flashcards/commit/a2d53680f27db04b2cd09973e62a76c5a7d4ce06))
## [3.7.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.7.0...v3.7.1) (2025-10-14)

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

@@ -38,9 +38,10 @@ export async function POST(req: NextRequest, context: RouteContext) {
return NextResponse.json({ error: 'You are banned from this room' }, { status: 403 })
}
// Check if user is already a member (for locked room access)
// Check if user is already a member (for locked/retired room access)
const members = await getRoomMembers(roomId)
const isExistingMember = members.some((m) => m.userId === viewerId)
const isRoomCreator = room.createdBy === viewerId
// Validate access mode
switch (room.accessMode) {
@@ -55,7 +56,14 @@ export async function POST(req: NextRequest, context: RouteContext) {
break
case 'retired':
return NextResponse.json({ error: 'This room has been retired' }, { status: 410 })
// Only the room creator can access retired rooms
if (!isRoomCreator) {
return NextResponse.json(
{ error: 'This room has been retired and is only accessible to the owner' },
{ status: 410 }
)
}
break
case 'password': {
if (!body.password) {

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

@@ -1,7 +1,6 @@
'use client'
import { useRoomData } from '@/hooks/useRoomData'
import { ModerationNotifications } from '@/components/nav/ModerationNotifications'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
@@ -11,60 +10,57 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
*
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
* Instead, we show a friendly message with a link back to the Champion Arena.
*
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
* so we don't need to render it here.
*/
export default function RoomPage() {
const { roomData, isLoading, moderationEvent, clearModerationEvent } = useRoomData()
const { roomData, isLoading } = useRoomData()
// Show loading state
if (isLoading) {
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Loading room...
</div>
)
}
// Show error if no room (instead of redirecting)
if (!roomData) {
return (
<>
<div
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
gap: '1rem',
color: '#3b82f6',
textDecoration: 'underline',
}}
>
<div>No active room found</div>
<a
href="/arcade"
style={{
color: '#3b82f6',
textDecoration: 'underline',
}}
>
Go to Champion Arena
</a>
</div>
<ModerationNotifications moderationEvent={moderationEvent} onClose={clearModerationEvent} />
</>
Go to Champion Arena
</a>
</div>
)
}
@@ -72,38 +68,26 @@ export default function RoomPage() {
switch (roomData.gameName) {
case 'matching':
return (
<>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
<RoomMemoryPairsProvider>
<MemoryPairsGame />
</RoomMemoryPairsProvider>
)
// TODO: Add other games (complement-race, memory-quiz, etc.)
default:
return (
<>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
<ModerationNotifications
moderationEvent={moderationEvent}
onClose={clearModerationEvent}
/>
</>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
)
}
}

View File

@@ -52,6 +52,10 @@ export function ModerationNotifications({
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])
@@ -129,6 +133,8 @@ export function ModerationNotifications({
// Kicked modal
if (moderationEvent?.type === 'kicked') {
const isRetired = moderationEvent.data.reason?.includes('retired')
return (
<Modal isOpen={true} onClose={() => {}}>
<div
@@ -139,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',
@@ -148,7 +154,7 @@ export function ModerationNotifications({
color: 'rgba(253, 186, 116, 1)',
}}
>
Kicked from Room
{isRetired ? 'Room Retired' : 'Kicked from Room'}
</h2>
<p
style={{
@@ -157,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={{
@@ -169,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

View File

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

View File

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