Compare commits

...

12 Commits

Author SHA1 Message Date
semantic-release-bot
d8b5201af9 chore(release): 3.14.0 [skip ci]
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)

### Features

* **arcade:** add Change Game functionality for room hosts ([ee39241](ee39241e3c))
* **arcade:** add game selection screen with navigation to room page ([4124f1c](4124f1cc08))

### Bug Fixes

* **player-config:** correct label positioning in player settings dialog ([554cc40](554cc4063b))

### Code Refactoring

* implement in-room game selection UI ([f07b96d](f07b96d26e))
* make game_name nullable to support in-room game selection ([a9a6cef](a9a6cefafc))
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](6bb7016eea))
2025-10-14 17:31:58 +00:00
Thomas Hallock
554cc4063b fix(player-config): correct label positioning in player settings dialog
Reorganizes layout so labels appear under their corresponding elements:
- Character count under name input
- "Random name" under dice button

Previously labels were misaligned and confusing.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:45 -05:00
Thomas Hallock
6bb7016eea refactor(nav): rename emphasizeGameContext to emphasizePlayerSelection
Improves clarity by renaming the prop to better describe its purpose:
highlighting the player selection/roster UI in the navigation bar.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:38 -05:00
Thomas Hallock
4124f1cc08 feat(arcade): add game selection screen with navigation to room page
- Wraps game selection in PageWithNav for consistent navigation
- Adds game type mapping (GameType keys to internal game names)
- Enables player selection mode on game selection screen
- Adds navigation to "unsupported game" screen
- Fixes 400 error when selecting games like "Matching Pairs Battle"

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:30 -05:00
Thomas Hallock
ee39241e3c feat(arcade): add Change Game functionality for room hosts
Allows room hosts to return to game selection screen by clearing the
room's game selection. Adds useClearRoomGame hook and "Change Game"
menu item in room dropdown (only visible when a game is selected).

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 12:30:22 -05:00
Thomas Hallock
f07b96d26e refactor: implement in-room game selection UI
Phase 2: UI and workflow updates

- Update room settings API to support setting game via PATCH
- Add useSetRoomGame hook for client-side game selection
- Update /arcade/room page to show game selection when no game set
- Create beautiful game selection UI with gradient cards
- Update AddPlayerButton to create rooms without games
- Navigate to /arcade/room after creating or joining rooms
- Remove dependency on local-only play - all games now room-based

Workflow:
1. User clicks "Create Room" from (+) menu
2. Room is created without a game (gameName = null)
3. User is navigated to /arcade/room
4. Game selection screen is shown
5. User clicks a game
6. Room game is set via API
7. Game loads - URL never changes, it's always /arcade/room

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:33:39 -05:00
Thomas Hallock
a9a6cefafc refactor: make game_name nullable to support in-room game selection
Phase 1: Database and API updates

- Create migration 0010 to make game_name and game_config nullable
- Update arcade_rooms schema to support rooms without games
- Update RoomData interface to make gameName optional
- Update CreateRoomParams to make gameName optional
- Update room creation API to allow null gameName
- Update all room data parsing to handle null gameName

This allows rooms to be created without a game selected, enabling
users to choose a game inside the room itself. The URL remains
/arcade/room regardless of selection, setup, or gameplay state.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:30:27 -05:00
Thomas Hallock
710e93c997 revert(nav): restore original room creation/join behavior
Reverts navigation changes that broke lifted state popover behavior.

Original behavior (now restored):
- Create room: Keep popover open, switch to invite tab to share code
- Join room: Close popover, stay on current page

The navigation changes caused the popover to close immediately,
breaking the lifted state pattern that was intentionally designed
to keep the popover open for sharing room codes after creation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:11:03 -05:00
semantic-release-bot
b419e5e3ad chore(release): 3.13.7 [skip ci]
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)

### Bug Fixes

* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](245ed8a625))
2025-10-14 16:02:24 +00:00
Thomas Hallock
245ed8a625 fix(toast): scope animations to prevent affecting other UI elements
The toast CSS animations were using overly broad selectors like
[data-state='open'] which affected ANY element with data-state
attributes, causing nav items and other components to trigger the
toast slide-in/slide-out animations on hover.

Fixed by:
- Renaming animations: slideIn → toastSlideIn, etc.
- Scoping selectors: [data-radix-toast-viewport] [data-state='open']
- Now only toast elements within the viewport are animated

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 11:01:28 -05:00
semantic-release-bot
2b68ddc732 chore(release): 3.13.6 [skip ci]
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)

### Bug Fixes

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

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

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-14 10:39:50 -05:00
17 changed files with 498 additions and 123 deletions

View File

@@ -1,3 +1,37 @@
## [3.14.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.7...v3.14.0) (2025-10-14)
### Features
* **arcade:** add Change Game functionality for room hosts ([ee39241](https://github.com/antialias/soroban-abacus-flashcards/commit/ee39241e3c9e04202592497d9987eafcb89c00c9))
* **arcade:** add game selection screen with navigation to room page ([4124f1c](https://github.com/antialias/soroban-abacus-flashcards/commit/4124f1cc081f5cb9d6f450f3c2e0cca8a247deba))
### Bug Fixes
* **player-config:** correct label positioning in player settings dialog ([554cc40](https://github.com/antialias/soroban-abacus-flashcards/commit/554cc4063bc756c9c9cd1adf0c1964d3f2f6151b))
### Code Refactoring
* implement in-room game selection UI ([f07b96d](https://github.com/antialias/soroban-abacus-flashcards/commit/f07b96d26eb9f63f3ee55f721139c37ccc34c3df))
* make game_name nullable to support in-room game selection ([a9a6cef](https://github.com/antialias/soroban-abacus-flashcards/commit/a9a6cefafcaf7340902328ef1cb02eb3fdd3aa84))
* **nav:** rename emphasizeGameContext to emphasizePlayerSelection ([6bb7016](https://github.com/antialias/soroban-abacus-flashcards/commit/6bb7016eea1e8ca40204a921db4a8b8fb9a06f73))
## [3.13.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.6...v3.13.7) (2025-10-14)
### Bug Fixes
* **toast:** scope animations to prevent affecting other UI elements ([245ed8a](https://github.com/antialias/soroban-abacus-flashcards/commit/245ed8a625ba848f8cd79d51bfd88600cd77f0b9))
## [3.13.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.5...v3.13.6) (2025-10-14)
### Bug Fixes
* **nav:** navigate to /arcade/room (not /arcade/rooms/{id}) ([1c55f36](https://github.com/antialias/soroban-abacus-flashcards/commit/1c55f3630cb5f07b685555e41baa5a49314f15a3))
## [3.13.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v3.13.4...v3.13.5) (2025-10-14)

View File

@@ -0,0 +1,42 @@
-- Make game_name and game_config nullable to support game selection in room
-- SQLite doesn't support ALTER COLUMN, so we need to recreate the table
PRAGMA foreign_keys=OFF;--> statement-breakpoint
-- Create temporary table with correct schema
CREATE TABLE `arcade_rooms_new` (
`id` text PRIMARY KEY NOT NULL,
`code` text(6) NOT NULL,
`name` text(50),
`created_by` text NOT NULL,
`creator_name` text(50) NOT NULL,
`created_at` integer NOT NULL,
`last_activity` integer NOT NULL,
`ttl_minutes` integer DEFAULT 60 NOT NULL,
`access_mode` text DEFAULT 'open' NOT NULL,
`password` text(255),
`display_password` text(100),
`game_name` text,
`game_config` text,
`status` text DEFAULT 'lobby' NOT NULL,
`current_session_id` text,
`total_games_played` integer DEFAULT 0 NOT NULL
);--> statement-breakpoint
-- Copy all data
INSERT INTO `arcade_rooms_new`
SELECT `id`, `code`, `name`, `created_by`, `creator_name`, `created_at`,
`last_activity`, `ttl_minutes`, `access_mode`, `password`, `display_password`,
`game_name`, `game_config`, `status`, `current_session_id`, `total_games_played`
FROM `arcade_rooms`;--> statement-breakpoint
-- Drop old table
DROP TABLE `arcade_rooms`;--> statement-breakpoint
-- Rename new table
ALTER TABLE `arcade_rooms_new` RENAME TO `arcade_rooms`;--> statement-breakpoint
-- Recreate index
CREATE UNIQUE INDEX `arcade_rooms_code_unique` ON `arcade_rooms` (`code`);--> statement-breakpoint
PRAGMA foreign_keys=ON;

View File

@@ -71,6 +71,13 @@
"when": 1760600000000,
"tag": "0009_add_display_password",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1760700000000,
"tag": "0010_make_game_name_nullable",
"breakpoints": true
}
]
}

View File

@@ -18,6 +18,8 @@ type RouteContext = {
* Body:
* - accessMode?: 'open' | 'locked' | 'retired' | 'password' | 'restricted' | 'approval-only'
* - password?: string (plain text, will be hashed)
* - gameName?: 'matching' | 'memory-quiz' | 'complement-race' | null (select game for room)
* - gameConfig?: object (game-specific settings)
*/
export async function PATCH(req: NextRequest, context: RouteContext) {
try {
@@ -58,6 +60,14 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
)
}
// Validate gameName if provided
if (body.gameName !== undefined && body.gameName !== null) {
const validGames = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
}
// Prepare update data
const updateData: Record<string, any> = {}
@@ -77,6 +87,16 @@ export async function PATCH(req: NextRequest, context: RouteContext) {
}
}
// Update game selection if provided
if (body.gameName !== undefined) {
updateData.gameName = body.gameName
}
// Update game config if provided
if (body.gameConfig !== undefined) {
updateData.gameConfig = body.gameConfig
}
// Update room settings
const [updatedRoom] = await db
.update(schema.arcadeRooms)

View File

@@ -70,15 +70,12 @@ export async function POST(req: NextRequest) {
const viewerId = await getViewerId()
const body = await req.json()
// Validate required fields (name is optional, gameName is required)
if (!body.gameName) {
return NextResponse.json({ error: 'Missing required field: gameName' }, { status: 400 })
}
// Validate game name
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
// Validate game name if provided (gameName is now optional)
if (body.gameName) {
const validGames: GameName[] = ['matching', 'memory-quiz', 'complement-race']
if (!validGames.includes(body.gameName)) {
return NextResponse.json({ error: 'Invalid game name' }, { status: 400 })
}
}
// Validate name length (if provided)
@@ -120,8 +117,8 @@ export async function POST(req: NextRequest) {
name: roomName,
createdBy: viewerId,
creatorName: displayName,
gameName: body.gameName,
gameConfig: body.gameConfig || {},
gameName: body.gameName || null,
gameConfig: body.gameConfig || null,
ttlMinutes: body.ttlMinutes,
accessMode: body.accessMode,
password: body.password,

View File

@@ -33,7 +33,7 @@ export function MemoryPairsGame() {
<PageWithNav
navTitle={navTitle}
navEmoji={navEmoji}
emphasizeGameContext={state.gamePhase === 'setup'}
emphasizePlayerSelection={state.gamePhase === 'setup'}
onExitSession={() => {
exitSession()
router.push('/arcade')

View File

@@ -78,7 +78,7 @@ function ArcadeContent() {
function ArcadePageWithRedirect() {
return (
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizeGameContext={true}>
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
<ArcadeContent />
</PageWithNav>
)

View File

@@ -1,13 +1,29 @@
'use client'
import { useRoomData } from '@/hooks/useRoomData'
import { useRouter } from 'next/navigation'
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
import { MemoryPairsGame } from '../matching/components/MemoryPairsGame'
import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProvider'
import { GAMES_CONFIG } from '@/components/GameSelector'
import type { GameType } from '@/components/GameSelector'
import { PageWithNav } from '@/components/PageWithNav'
import { css } from '../../../../styled-system/css'
// Map GameType keys to internal game names
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
'battle-arena': 'matching',
'memory-lightning': 'memory-quiz',
'complement-race': 'complement-race',
'master-organizer': 'master-organizer',
}
/**
* /arcade/room - Renders the game for the user's current room
* Since users can only be in one room at a time, this is a simple singular route
*
* Shows game selection when no game is set, then shows the game itself once selected.
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
*
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
* Instead, we show a friendly message with a link back to the Champion Arena.
*
@@ -15,7 +31,9 @@ import { RoomMemoryPairsProvider } from '../matching/context/RoomMemoryPairsProv
* so we don't need to render it here.
*/
export default function RoomPage() {
const router = useRouter()
const { roomData, isLoading } = useRoomData()
const { mutate: setRoomGame } = useSetRoomGame()
// Show loading state
if (isLoading) {
@@ -64,6 +82,120 @@ export default function RoomPage() {
)
}
// Show game selection if no game is set
if (!roomData.gameName) {
const handleGameSelect = (gameType: GameType) => {
const gameConfig = GAMES_CONFIG[gameType]
if (gameConfig.available === false) {
return // Don't allow selecting unavailable games
}
// Map GameType to internal game name
const internalGameName = GAME_TYPE_TO_NAME[gameType]
setRoomGame({
roomId: roomData.id,
gameName: internalGameName,
gameConfig: {},
})
}
return (
<PageWithNav
navTitle="Choose Game"
navEmoji="🎮"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
<div
className={css({
minHeight: '100vh',
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '4',
})}
>
<h1
className={css({
fontSize: { base: '2xl', md: '3xl' },
fontWeight: 'bold',
color: 'white',
mb: '8',
textAlign: 'center',
})}
>
Choose a Game
</h1>
<div
className={css({
display: 'grid',
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
gap: '4',
maxWidth: '800px',
width: '100%',
})}
>
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => (
<button
key={gameType}
onClick={() => handleGameSelect(gameType as GameType)}
disabled={config.available === false}
className={css({
background: config.gradient,
border: '2px solid',
borderColor: config.borderColor || 'blue.200',
borderRadius: '2xl',
padding: '6',
cursor: config.available === false ? 'not-allowed' : 'pointer',
opacity: config.available === false ? 0.5 : 1,
transition: 'all 0.3s ease',
_hover:
config.available === false
? {}
: {
transform: 'translateY(-4px) scale(1.02)',
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
},
})}
>
<div
className={css({
fontSize: '4xl',
mb: '2',
})}
>
{config.icon}
</div>
<h3
className={css({
fontSize: 'xl',
fontWeight: 'bold',
color: 'gray.900',
mb: '2',
})}
>
{config.name}
</h3>
<p
className={css({
fontSize: 'sm',
color: 'gray.600',
})}
>
{config.description}
</p>
</button>
))}
</div>
</div>
</PageWithNav>
)
}
// Render the appropriate game based on room's gameName
switch (roomData.gameName) {
case 'matching':
@@ -76,18 +208,25 @@ export default function RoomPage() {
// 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',
}}
<PageWithNav
navTitle="Game Not Available"
navEmoji="⚠️"
emphasizePlayerSelection={true}
onExitSession={() => router.push('/arcade')}
>
Game "{roomData.gameName}" not yet supported
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
fontSize: '18px',
color: '#666',
}}
>
Game "{roomData.gameName}" not yet supported
</div>
</PageWithNav>
)
}
}

View File

@@ -32,7 +32,7 @@ export function MemoryPairsGame() {
navTitle={navTitle}
navEmoji={navEmoji}
gameName="matching"
emphasizeGameContext={state.gamePhase === 'setup'}
emphasizePlayerSelection={state.gamePhase === 'setup'}
currentPlayerId={state.currentPlayer}
playerScores={state.scores}
playerStreaks={state.consecutiveMatches}

View File

@@ -13,7 +13,7 @@ interface PageWithNavProps {
navTitle?: string
navEmoji?: string
gameName?: 'matching' | 'memory-quiz' | 'complement-race' // Internal game name for API
emphasizeGameContext?: boolean
emphasizePlayerSelection?: boolean
onExitSession?: () => void
onSetup?: () => void
onNewGame?: () => void
@@ -28,7 +28,7 @@ export function PageWithNav({
navTitle,
navEmoji,
gameName,
emphasizeGameContext = false,
emphasizePlayerSelection = false,
onExitSession,
onSetup,
onNewGame,
@@ -103,7 +103,7 @@ export function PageWithNav({
? 'tournament'
: 'none'
const shouldEmphasize = emphasizeGameContext && mounted
const shouldEmphasize = emphasizePlayerSelection && mounted
const showFullscreenSelection = shouldEmphasize && activePlayerCount === 0
// Compute arcade session info for display

View File

@@ -179,7 +179,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
<style
dangerouslySetInnerHTML={{
__html: `
@keyframes slideIn {
@keyframes toastSlideIn {
from {
transform: translateX(calc(100% + 25px));
}
@@ -188,7 +188,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}
}
@keyframes slideOut {
@keyframes toastSlideOut {
from {
transform: translateX(0);
}
@@ -197,7 +197,7 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}
}
@keyframes hide {
@keyframes toastHide {
from {
opacity: 1;
}
@@ -206,25 +206,25 @@ export function ToastProvider({ children }: { children: ReactNode }) {
}
}
[data-state='open'] {
animation: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1);
[data-radix-toast-viewport] [data-state='open'] {
animation: toastSlideIn 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-radix-toast-viewport] [data-state='closed'] {
animation: toastHide 100ms ease-in, toastSlideOut 200ms cubic-bezier(0.32, 0, 0.67, 0);
}
[data-swipe='move'] {
[data-radix-toast-viewport] [data-swipe='move'] {
transform: translateX(var(--radix-toast-swipe-move-x));
}
[data-swipe='cancel'] {
[data-radix-toast-viewport] [data-swipe='cancel'] {
transform: translateX(0);
transition: transform 200ms ease-out;
}
[data-swipe='end'] {
animation: slideOut 100ms ease-out;
[data-radix-toast-viewport] [data-swipe='end'] {
animation: toastSlideOut 100ms ease-out;
}
`,
}}

View File

@@ -62,12 +62,12 @@ export function AddPlayerButton({
const { mutate: joinRoom } = useJoinRoom()
const { mutateAsync: getRoomByCode } = useGetRoomByCode()
// Handler for creating a new room
// Handler for creating a new room (without a game - game will be selected in room)
const handleCreateRoom = () => {
createRoom(
{
name: `${gameName} Room`,
gameName: gameName,
name: null, // Auto-generated from code
gameName: null, // No game selected yet - will be chosen in room
creatorName: 'Player',
},
{
@@ -78,10 +78,9 @@ export function AddPlayerButton({
name: data.name,
gameName: data.gameName,
})
// Close popover
// Close popover and navigate to room to choose game
setShowPopover(false)
// Navigate to the room page
router.push(`/arcade/rooms/${data.id}`)
router.push('/arcade/room')
},
onError: (error) => {
console.error('Failed to create room:', error)
@@ -110,8 +109,9 @@ export function AddPlayerButton({
gameName: data.room.gameName,
})
}
// Close popover
// Close popover and navigate to room
setShowPopover(false)
router.push('/arcade/room')
},
}
)

View File

@@ -270,75 +270,85 @@ export function PlayerConfigDialog({ playerId, onClose }: PlayerConfigDialogProp
style={{
display: 'flex',
gap: '8px',
alignItems: 'flex-start',
}}
>
<input
type="text"
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
style={{
flex: 1,
padding: '12px 16px',
fontSize: '16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
outline: 'none',
transition: 'all 0.2s ease',
fontWeight: '500',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = gradientColor
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e7eb'
e.currentTarget.style.boxShadow = 'none'
}}
/>
<button
type="button"
onClick={handleGenerateNewName}
style={{
padding: '12px 16px',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
border: 'none',
borderRadius: '12px',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
transition: 'all 0.2s ease',
flexShrink: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)'
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = 'none'
}}
title="Generate random name"
>
🎲
</button>
</div>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginTop: '4px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span>Click dice to generate a random name</span>
<span>{localName.length}/20 characters</span>
<div style={{ flex: 1 }}>
<input
type="text"
value={localName}
onChange={(e) => handleNameChange(e.target.value)}
placeholder="Player Name"
maxLength={20}
style={{
width: '100%',
padding: '12px 16px',
fontSize: '16px',
border: '2px solid #e5e7eb',
borderRadius: '12px',
outline: 'none',
transition: 'all 0.2s ease',
fontWeight: '500',
}}
onFocus={(e) => {
e.currentTarget.style.borderColor = gradientColor
e.currentTarget.style.boxShadow = `0 0 0 3px ${gradientColor}20`
}}
onBlur={(e) => {
e.currentTarget.style.borderColor = '#e5e7eb'
e.currentTarget.style.boxShadow = 'none'
}}
/>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginTop: '6px',
}}
>
{localName.length}/20 characters
</div>
</div>
<div style={{ flexShrink: 0 }}>
<button
type="button"
onClick={handleGenerateNewName}
style={{
padding: '12px 16px',
background: `linear-gradient(135deg, ${gradientColor}, ${gradientColor}dd)`,
border: 'none',
borderRadius: '12px',
color: 'white',
fontSize: '20px',
cursor: 'pointer',
transition: 'all 0.2s ease',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onMouseEnter={(e) => {
e.currentTarget.style.transform = 'scale(1.05)'
e.currentTarget.style.boxShadow = `0 4px 12px ${gradientColor}40`
}}
onMouseLeave={(e) => {
e.currentTarget.style.transform = 'scale(1)'
e.currentTarget.style.boxShadow = 'none'
}}
title="Generate random name"
>
🎲
</button>
<div
style={{
fontSize: '12px',
color: '#6b7280',
marginTop: '6px',
textAlign: 'center',
}}
>
Random name
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,7 +1,7 @@
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
import { useRouter } from 'next/navigation'
import { useEffect, useState } from 'react'
import { useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
import { useClearRoomGame, useLeaveRoom, useRoomData } from '@/hooks/useRoomData'
import { useViewerId } from '@/hooks/useViewerId'
import { getRoomDisplayWithEmoji } from '@/utils/room-display'
import { CreateRoomModal } from './CreateRoomModal'
@@ -62,6 +62,7 @@ export function RoomInfo({
const { getRoomShareUrl, roomData } = useRoomData()
const { data: currentUserId } = useViewerId()
const { mutateAsync: leaveRoom } = useLeaveRoom()
const { mutate: clearRoomGame } = useClearRoomGame()
// Use room display utility for consistent naming
const displayName = joinCode
@@ -403,6 +404,43 @@ export function RoomInfo({
</DropdownMenu.Item>
)}
{/* Change Game - only show for host and only when a game is selected */}
{isCurrentUserCreator && roomId && roomData?.gameName && (
<DropdownMenu.Item
onSelect={() => {
if (roomId) {
clearRoomGame(roomId)
}
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '10px 14px',
borderRadius: '8px',
border: 'none',
background: 'transparent',
color: 'rgba(209, 213, 219, 1)',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
outline: 'none',
transition: 'all 0.2s ease',
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'rgba(236, 72, 153, 0.2)'
e.currentTarget.style.color = 'rgba(249, 168, 212, 1)'
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'rgba(209, 213, 219, 1)'
}}
>
<span style={{ fontSize: '16px' }}>🔄</span>
<span>Change Game</span>
</DropdownMenu.Item>
)}
{/* Moderation - only show for host */}
{isCurrentUserCreator && roomId && (
<DropdownMenu.Item

View File

@@ -32,11 +32,11 @@ export const arcadeRooms = sqliteTable('arcade_rooms', {
password: text('password', { length: 255 }), // Hashed password for password-protected rooms
displayPassword: text('display_password', { length: 100 }), // Plain text password for display to room owner
// Game configuration
// Game configuration (nullable to support game selection in room)
gameName: text('game_name', {
enum: ['matching', 'memory-quiz', 'complement-race'],
}).notNull(),
gameConfig: text('game_config', { mode: 'json' }).notNull(), // Game-specific settings
}),
gameConfig: text('game_config', { mode: 'json' }), // Game-specific settings (nullable when no game selected)
// Current state
status: text('status', {

View File

@@ -22,7 +22,7 @@ export interface RoomData {
id: string
name: string
code: string
gameName: string
gameName: string | null // Nullable to support game selection in room
accessMode: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
members: RoomMember[]
memberPlayers: Record<string, RoomPlayer[]> // userId -> players
@@ -30,7 +30,7 @@ export interface RoomData {
export interface CreateRoomParams {
name: string | null
gameName: string
gameName?: string | null // Optional - rooms can be created without a game
creatorName?: string
gameConfig?: Record<string, unknown>
accessMode?: 'open' | 'password' | 'approval-only' | 'restricted' | 'locked' | 'retired'
@@ -86,9 +86,9 @@ async function createRoomApi(params: CreateRoomParams): Promise<RoomData> {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: params.name,
gameName: params.gameName,
gameName: params.gameName || null,
creatorName: params.creatorName || 'Player',
gameConfig: params.gameConfig || { difficulty: 6 },
gameConfig: params.gameConfig || null,
accessMode: params.accessMode,
password: params.password,
}),
@@ -558,3 +558,91 @@ export function useGetRoomByCode() {
mutationFn: getRoomByCodeApi,
})
}
/**
* Set game for a room
*/
async function setRoomGameApi(params: {
roomId: string
gameName: string
gameConfig?: Record<string, unknown>
}): Promise<void> {
const response = await fetch(`/api/arcade/rooms/${params.roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: params.gameName,
gameConfig: params.gameConfig || {},
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to set room game')
}
}
/**
* Hook: Set game for a room
*/
export function useSetRoomGame() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: setRoomGameApi,
onSuccess: (_, variables) => {
// Update the cache with the new game
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
return {
...prev,
gameName: variables.gameName,
}
})
// Refetch to get the full updated room data
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
},
})
}
/**
* Clear/reset game for a room (host only)
*/
async function clearRoomGameApi(roomId: string): Promise<void> {
const response = await fetch(`/api/arcade/rooms/${roomId}/settings`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
gameName: null,
gameConfig: null,
}),
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Failed to clear room game')
}
}
/**
* Hook: Clear/reset game for a room (returns to game selection screen)
*/
export function useClearRoomGame() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: clearRoomGameApi,
onSuccess: () => {
// Update the cache to clear the game
queryClient.setQueryData<RoomData | null>(roomKeys.current(), (prev) => {
if (!prev) return null
return {
...prev,
gameName: null,
}
})
// Refetch to get the full updated room data
queryClient.invalidateQueries({ queryKey: roomKeys.current() })
},
})
}

View File

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