Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da4fdc90e0 | ||
|
|
ee6c4f2f4f | ||
|
|
9b9f0cdbcb | ||
|
|
e14ffe44d6 | ||
|
|
d5bc0bb27c | ||
|
|
0790074ffc | ||
|
|
1a44daf2ce | ||
|
|
9679d68154 | ||
|
|
80ba94203d | ||
|
|
87631af678 |
35
CHANGELOG.md
35
CHANGELOG.md
@@ -1,3 +1,38 @@
|
||||
## [4.11.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.6...v4.11.0) (2025-10-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **home:** redesign home page to showcase complete platform ([ee6c4f2](https://github.com/antialias/soroban-abacus-flashcards/commit/ee6c4f2f4f39e3b30f59c54866c3857c218fb80f))
|
||||
|
||||
## [4.10.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.5...v4.10.6) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** position slots flow horizontally with wrap ([e14ffe4](https://github.com/antialias/soroban-abacus-flashcards/commit/e14ffe44d66d0c97bc0cc4e0c255698e88ce723a))
|
||||
|
||||
## [4.10.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.4...v4.10.5) (2025-10-18)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **arcade:** merge /arcade/room into /arcade route ([0790074](https://github.com/antialias/soroban-abacus-flashcards/commit/0790074ffc5008bce9a162fe0ddbd1d5c214c4f7))
|
||||
|
||||
## [4.10.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.3...v4.10.4) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **card-sorting:** match Python card layout with flex wrap ([9679d68](https://github.com/antialias/soroban-abacus-flashcards/commit/9679d68154ac8b6a2f905ec7d17a34b39bc00237))
|
||||
|
||||
## [4.10.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.2...v4.10.3) (2025-10-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **arcade:** remove broken query param from game URLs ([87631af](https://github.com/antialias/soroban-abacus-flashcards/commit/87631af6788bd7b42e671374e55ec0ad8435900c))
|
||||
|
||||
## [4.10.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.10.1...v4.10.2) (2025-10-18)
|
||||
|
||||
|
||||
|
||||
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
440
apps/web/.claude/ARCADE_ROUTING_ARCHITECTURE.md
Normal file
@@ -0,0 +1,440 @@
|
||||
# Arcade Routing Architecture - Complete Overview
|
||||
|
||||
## 1. Current /arcade Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/page.tsx` (lines 1-129)
|
||||
|
||||
**Purpose:** The main arcade landing page - displays the "Champion Arena"
|
||||
|
||||
**Key Components:**
|
||||
- `ArcadeContent()` - Renders the main arcade interface
|
||||
- Uses `EnhancedChampionArena` component which contains `GameSelector`
|
||||
- The `GameSelector` displays all available games as cards
|
||||
- `GameSelector` includes both legacy games and registry games
|
||||
|
||||
**Current Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. Page renders `FullscreenProvider` wrapper
|
||||
3. Displays `PageWithNav` with title "🏟️ Champion Arena"
|
||||
4. Content area shows `EnhancedChampionArena` → `GameSelector`
|
||||
5. `GameSelector` renders `GameCard` components for each game
|
||||
6. When user clicks a game card, `GameCard` calls `router.push(config.url)`
|
||||
7. For registry games, `config.url` is `/arcade/room?game={gameName}`
|
||||
8. For legacy games, URL would be direct to their page
|
||||
|
||||
**State Management:**
|
||||
- `GameModeContext` provides player selection (emoji, name, color)
|
||||
- `PageWithNav` wraps content and provides mini-nav with:
|
||||
- Active player list
|
||||
- Add player button
|
||||
- Game mode indicator (single/battle/tournament)
|
||||
- Exit session handler
|
||||
|
||||
## 2. Current /arcade/room Page
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/app/arcade/room/page.tsx` (lines 1-359)
|
||||
|
||||
**Purpose:** "Magical place" that shows either a game OR the game chooser, driven by room state
|
||||
|
||||
**Three States:**
|
||||
|
||||
### State 1: Loading
|
||||
- Shows "Loading room..." message
|
||||
- Waits for `useRoomData()` hook to resolve
|
||||
|
||||
### State 2: Game Selection UI (when `!roomData.gameName`)
|
||||
- Shows large game selection buttons
|
||||
- User clicks to select a game
|
||||
- Calls `setRoomGame()` mutation to save selection to room
|
||||
- Invokes `handleGameSelect()` which:
|
||||
1. Checks if game exists in registry via `hasGame(gameType)`
|
||||
2. If registry game: calls `setRoomGame({roomId, gameName: gameType})`
|
||||
3. If legacy game: maps to internal name via `GAME_TYPE_TO_NAME`, then calls `setRoomGame()`
|
||||
4. Game selection is persisted to the room database
|
||||
|
||||
### State 3: Game Display (when `roomData.gameName` is set)
|
||||
- Checks game registry first via `hasGame(roomData.gameName)`
|
||||
- If registry game:
|
||||
- Gets game definition via `getGame(roomData.gameName)`
|
||||
- Renders: `<Provider><GameComponent /></Provider>`
|
||||
- Provider and GameComponent come from game registry definition
|
||||
- If legacy game:
|
||||
- Switch statement with TODO for individual games
|
||||
- Currently only shows "Game not yet supported"
|
||||
|
||||
**Key Hook:**
|
||||
- `useRoomData()` - Fetches current room from API and subscribes to socket updates
|
||||
- Returns `roomData` with fields: `id`, `name`, `code`, `gameName`, `gameConfig`, `members`, `memberPlayers`
|
||||
- Also returns `isLoading` boolean
|
||||
|
||||
**Navigation Flow:**
|
||||
1. User navigates to `/arcade`
|
||||
2. `GameCard` onClick calls `router.push('/arcade/room?game={gameName}')`
|
||||
3. User arrives at `/arcade/room`
|
||||
4. If NOT in a room yet: Shows error with link back to `/arcade`
|
||||
5. If in room but no game selected: Shows game selection UI
|
||||
6. If game selected: Loads and displays game
|
||||
|
||||
## 3. The "Mini App Nav" - GameContextNav Component
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/nav/GameContextNav.tsx` (lines 1-372)
|
||||
|
||||
**What It Is:**
|
||||
The "mini app nav" is actually a sophisticated component within the `PageWithNav` wrapper that intelligently shows different UI based on context:
|
||||
|
||||
**Components & Props:**
|
||||
- `navTitle` - Current page title (e.g., "Champion Arena", "Choose Game", "Speed Complement Race")
|
||||
- `navEmoji` - Icon emoji for current page
|
||||
- `gameMode` - Computed from active player count: 'none' | 'single' | 'battle' | 'tournament'
|
||||
- `activePlayers` - Array of selected players
|
||||
- `inactivePlayers` - Array of available but unselected players
|
||||
- `shouldEmphasize` - Boolean to emphasize player selection
|
||||
- `showFullscreenSelection` - Boolean to show fullscreen mode for player selection
|
||||
- `roomInfo` - Optional arcade room data (roomId, roomName, gameName, playerCount, joinCode)
|
||||
- `networkPlayers` - Remote players from room members
|
||||
|
||||
**Three Display Modes:**
|
||||
|
||||
### Mode 1: Fullscreen Player Selection
|
||||
- When `showFullscreenSelection === true`
|
||||
- Displays:
|
||||
- Large title with emoji
|
||||
- Game mode indicator
|
||||
- Fullscreen player selection UI
|
||||
- Shows all inactive players for selection
|
||||
|
||||
### Mode 2: Solo Mode (NOT in room)
|
||||
- When `roomInfo` is undefined
|
||||
- Shows:
|
||||
- **Game Title Section** (left side):
|
||||
- `GameTitleMenu` with game title and emoji
|
||||
- Menu options: Setup, New Game, Quit
|
||||
- `GameModeIndicator`
|
||||
- **Player Section** (right side):
|
||||
- `ActivePlayersList` - shows selected players
|
||||
- `AddPlayerButton` - add more players
|
||||
|
||||
### Mode 3: Room Mode (IN a room)
|
||||
- When `roomInfo` is defined
|
||||
- Shows:
|
||||
- **Hidden:** Game title section (display: none)
|
||||
- **Room Info Pane** (left side):
|
||||
- `RoomInfo` component with room details
|
||||
- Game mode indicator with color/emoji
|
||||
- Room name, player count, join code
|
||||
- `NetworkPlayerIndicator` components for remote players
|
||||
- **Player Section** (may be hidden):
|
||||
- Shows local active players
|
||||
- Add player button (for local players only)
|
||||
|
||||
**Key Sub-Components:**
|
||||
- `GameTitleMenu` - Menu for game options (setup, new game, quit)
|
||||
- `GameModeIndicator` - Shows 🎯 Solo, ⚔️ Battle, 🏆 Tournament, 👥 Select
|
||||
- `RoomInfo` - Displays room metadata
|
||||
- `NetworkPlayerIndicator` - Shows remote players with scores/streaks
|
||||
- `ActivePlayersList` - List of selected players
|
||||
- `AddPlayerButton` - Button to add more players with popover
|
||||
- `FullscreenPlayerSelection` - Large player picker for fullscreen mode
|
||||
- `PendingInvitations` - Banner for room invitations
|
||||
|
||||
**State Management:**
|
||||
- Lifted from `PageWithNav` to preserve state across remounts:
|
||||
- `showPopover` / `setShowPopover` - AddPlayerButton popover state
|
||||
- `activeTab` / `setActiveTab` - 'add' or 'invite' tab selection
|
||||
|
||||
## 4. Navigation Flow
|
||||
|
||||
### Flow 1: Solo Player → Game Selection → Room Creation → Game Start
|
||||
|
||||
```
|
||||
/arcade (Champion Arena)
|
||||
↓ [Select players - updates GameModeContext]
|
||||
↓ [Click game card - GameCard.onClick → router.push]
|
||||
/arcade/room (if not in room, shows game selector)
|
||||
↓ [Select game - calls setRoomGame mutation]
|
||||
↓ [Room created, gameName saved to roomData]
|
||||
↓ [useRoomData refetch updates roomData.gameName]
|
||||
/arcade/room (now displays the game)
|
||||
↓ [Game Provider and Component render]
|
||||
```
|
||||
|
||||
### Flow 2: Multiplayer - Room Invitation
|
||||
|
||||
```
|
||||
User A: Creates room via Champion Arena
|
||||
User B: Receives invitation
|
||||
User B: Joins room via /arcade/room
|
||||
User B: Sees same game selection (if set) or game selector (if not set)
|
||||
```
|
||||
|
||||
### Flow 3: Exit Game
|
||||
|
||||
```
|
||||
/arcade/room (in-game)
|
||||
↓ [Click "Quit" or "Exit Session" in GameContextNav]
|
||||
↓ [onExitSession callback → router.push('/arcade')]
|
||||
/arcade (back to champion arena)
|
||||
↓ Player selection reset by GameModeContext
|
||||
```
|
||||
|
||||
## 5. Game Chooser / Game Selection System
|
||||
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**How It Works:**
|
||||
1. `GameSelector` component gets all games from both sources:
|
||||
- Legacy `GAMES_CONFIG` (currently empty)
|
||||
- Registry games via `getAllGames()`
|
||||
|
||||
2. For each game, creates `GameCard` component with configuration including `url` field
|
||||
|
||||
3. Game Cards rendered in 2-column grid (responsive)
|
||||
|
||||
4. When card clicked:
|
||||
- `GameCard` checks `activePlayerCount` against game's `maxPlayers`
|
||||
- If valid: calls `router.push(config.url)` - client-side navigation via Next.js
|
||||
- If invalid: blocks navigation with warning
|
||||
|
||||
**Two Game Systems:**
|
||||
|
||||
### Registry Games (NEW - Modular)
|
||||
- Location: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/arcade-games/`
|
||||
- File: `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/lib/arcade/game-registry.ts`
|
||||
- Examples: `complement-race`, `memory-quiz`, `matching`
|
||||
- Each game has: `manifest` (metadata), `Provider` (context), `GameComponent` (UI)
|
||||
- Games registered globally via `registerGame()` function
|
||||
|
||||
### Legacy Games (OLD)
|
||||
- Location: Directly in `/app/arcade/` directory
|
||||
- Examples: `/app/arcade/complement-race/page.tsx`
|
||||
- Currently, only complement-race is partially migrated
|
||||
- Direct URL structure: `/arcade/{gameName}/page.tsx`
|
||||
|
||||
**Game Config Structure (for display):**
|
||||
```javascript
|
||||
{
|
||||
name: string, // Display name
|
||||
fullName?: string, // Longer name for detailed view
|
||||
description: string, // Short description
|
||||
longDescription?: string, // Detailed description
|
||||
icon: emoji, // Game icon emoji
|
||||
gradient: css gradient, // Background gradient
|
||||
borderColor: css color, // Border color for availability
|
||||
maxPlayers: number, // Player limit for validation
|
||||
chips?: string[], // Feature labels
|
||||
color?: 'green'|'purple'|'blue', // Color theme
|
||||
difficulty?: string, // Difficulty level
|
||||
available: boolean, // Is game available
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Key Components Summary
|
||||
|
||||
### PageWithNav - Main Layout Wrapper
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/PageWithNav.tsx` (lines 1-192)
|
||||
|
||||
**Responsibilities:**
|
||||
- Wraps all game/arcade pages
|
||||
- Manages GameContextNav state (mini-nav)
|
||||
- Handles player configuration dialog
|
||||
- Shows moderation notifications
|
||||
- Renders top navigation bar via `AppNavBar`
|
||||
|
||||
**Key Props:**
|
||||
- `navTitle` - Passed to GameContextNav
|
||||
- `navEmoji` - Passed to GameContextNav
|
||||
- `gameName` - Internal game name for API
|
||||
- `emphasizePlayerSelection` - Highlight player controls
|
||||
- `onExitSession` - Callback when user exits
|
||||
- `onSetup`, `onNewGame` - Game-specific callbacks
|
||||
- `children` - Page content
|
||||
|
||||
### AppNavBar - Top Navigation Bar
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/AppNavBar.tsx` (lines 1-625)
|
||||
|
||||
**Variants:**
|
||||
- `full` - Standard navigation (default for non-game pages)
|
||||
- `minimal` - Game navigation (auto-selected for `/arcade` and `/games`)
|
||||
|
||||
**Minimal Nav Features:**
|
||||
- Hamburger menu (left) with:
|
||||
- Site navigation (Home, Create, Guide, Games)
|
||||
- Controls (Fullscreen, Exit Arcade)
|
||||
- Abacus style dropdown
|
||||
- Centered game context (navSlot)
|
||||
- Fullscreen indicator badge
|
||||
|
||||
### EnhancedChampionArena - Main Arcade Display
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/EnhancedChampionArena.tsx` (lines 1-40)
|
||||
|
||||
**Responsibilities:**
|
||||
- Container for game selector
|
||||
- Full-height flex layout
|
||||
- Passes configuration to `GameSelector`
|
||||
|
||||
### GameSelector - Game Grid
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameSelector.tsx` (lines 1-112)
|
||||
|
||||
**Responsibilities:**
|
||||
- Fetches all games from registry
|
||||
- Arranges in responsive grid
|
||||
- Shows header "🎮 Available Games"
|
||||
- Renders GameCard for each game
|
||||
|
||||
### GameCard - Individual Game Button
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/components/GameCard.tsx` (lines 1-241)
|
||||
|
||||
**Responsibilities:**
|
||||
- Displays game with icon, name, description
|
||||
- Shows feature chips and player count indicator
|
||||
- Validates player count against game requirements
|
||||
- Handles click to navigate to game
|
||||
- Two variants: compact and detailed
|
||||
|
||||
## 7. State Management
|
||||
|
||||
### GameModeContext
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/contexts/GameModeContext.tsx` (lines 1-325)
|
||||
|
||||
**Manages:**
|
||||
- Local players (Map<string, Player>)
|
||||
- Active players (Set<string>)
|
||||
- Game mode (computed from active player count)
|
||||
- Player CRUD operations (add, update, remove)
|
||||
|
||||
**Key Features:**
|
||||
- Fetches players from user's local DB via `useUserPlayers()`
|
||||
- Creates 4 default players if none exist
|
||||
- When in room: merges room members' players (marked as isLocal: false)
|
||||
- Syncs to room members via `notifyRoomOfPlayerUpdate()`
|
||||
|
||||
**Computed Values:**
|
||||
- `activePlayerCount` - Size of activePlayers set
|
||||
- `gameMode`:
|
||||
- 1 player → 'single'
|
||||
- 2 players → 'battle'
|
||||
- 3+ players → 'tournament'
|
||||
|
||||
### useRoomData Hook
|
||||
**File:** `/Users/antialias/projects/soroban-abacus-flashcards/apps/web/src/hooks/useRoomData.ts` (lines 1-450+)
|
||||
|
||||
**Manages:**
|
||||
- Current room fetching via TanStack Query
|
||||
- Socket.io real-time updates
|
||||
- Room state (members, players, game name)
|
||||
- Moderation events (kicked, banned, invitations)
|
||||
|
||||
**Key Operations:**
|
||||
- `fetchCurrentRoom()` - GET `/api/arcade/rooms/current`
|
||||
- `createRoomApi()` - POST `/api/arcade/rooms`
|
||||
- `joinRoomApi()` - POST `/api/arcade/rooms/{id}/join`
|
||||
- `leaveRoomApi()` - POST `/api/arcade/rooms/{id}/leave`
|
||||
- `setRoomGame()` - Updates room's gameName and gameConfig
|
||||
|
||||
**Socket Events:**
|
||||
- `join-user-channel` - Personal notifications
|
||||
- `join-room` - Subscribe to room updates
|
||||
- `room-joined` - Refresh when entering room
|
||||
- `member-joined` - When player joins
|
||||
- `member-left` - When player leaves
|
||||
- `room-players-updated` - When players change
|
||||
- Moderation events (kicked, banned, etc.)
|
||||
|
||||
## 8. Routing Summary
|
||||
|
||||
**Current URL Structure:**
|
||||
|
||||
```
|
||||
/ → Home page (Soroban Generator)
|
||||
/create → Create flashcards
|
||||
/guide → Tutorial guide
|
||||
/games → Games library (external game pages)
|
||||
/arcade → Champion Arena (main landing with game selector)
|
||||
/arcade/room → Active game display or game selection UI
|
||||
/arcade/room?game={name} → Query param for game selection (optional)
|
||||
/arcade/complement-race → OLD: Direct complement-race page (legacy)
|
||||
/arcade/complement-race/practice → Complement-race practice mode
|
||||
/arcade/complement-race/sprint → Complement-race sprint mode
|
||||
/arcade/complement-race/survival → Complement-race survival mode
|
||||
/arcade/memory-quiz → Memory quiz game page (legacy structure)
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `/arcade/room?game={gameName}` - Optional game selection (parsed by GameCard)
|
||||
|
||||
## 9. Key Differences: /arcade vs /arcade/room
|
||||
|
||||
| Aspect | /arcade | /arcade/room |
|
||||
|--------|---------|--------------|
|
||||
| **Purpose** | Game selection hub | Active game display or selection within room |
|
||||
| **Displays** | GameSelector with all games | Selected game OR game selector if no game in room |
|
||||
| **Room Context** | Optional (can start solo) | Usually in a room (fetches via useRoomData) |
|
||||
| **Navigation** | Click game → /arcade/room | Click game → Saves to room → Displays game |
|
||||
| **GameContextNav** | Shows player selector | Shows room info when joined |
|
||||
| **Player State** | Local only | Local + remote (room members) |
|
||||
| **Exit Button** | Usually hidden | Shows "Exit Session" to return to /arcade |
|
||||
| **Socket Connection** | Optional | Always connected (in room) |
|
||||
| **Page Transition** | User controls | Driven by room state updates |
|
||||
|
||||
## 10. Planning the Merge (/arcade/room → /arcade)
|
||||
|
||||
**Challenges to Consider:**
|
||||
|
||||
1. **URL Consolidation:**
|
||||
- `/arcade/room` would become a sub-path or handled by `/arcade` with state
|
||||
- Query param `?game={name}` could drive game selection
|
||||
- Current: `/arcade/room?game=complement-race`
|
||||
- Could become: `/arcade?game=complement-race&mode=play`
|
||||
|
||||
2. **Route Disambiguation:**
|
||||
- `/arcade` needs to handle: game selection display, game display, game loading
|
||||
- Same page different modes based on state
|
||||
- Or: Sub-routes like `/arcade/select`, `/arcade/play`
|
||||
|
||||
3. **State Layering:**
|
||||
- Local game mode (solo player, GameModeContext)
|
||||
- Room state (multiplayer, useRoomData)
|
||||
- Both need to coexist
|
||||
|
||||
4. **Navigation Preservation:**
|
||||
- Currently: `GameCard` → `router.push('/arcade/room?game=X')`
|
||||
- After merge: Would need new logic
|
||||
- Fullscreen state must persist (uses Next.js router, not reload)
|
||||
|
||||
5. **PageWithNav Behavior:**
|
||||
- Mini-nav shows game selection UI vs room info
|
||||
- Currently determined by `roomInfo` presence
|
||||
- After merge: Need same logic but one route
|
||||
|
||||
6. **Game Display:**
|
||||
- Currently: `/arcade/room` fetches game from registry
|
||||
- New: `/arcade` would need same game registry lookup
|
||||
- Game Provider/Component rendering must work identically
|
||||
|
||||
**Merge Strategy Options:**
|
||||
|
||||
### Option A: Single Route with Modes
|
||||
```
|
||||
/arcade
|
||||
├── Mode: browse (default, show GameSelector)
|
||||
├── Mode: select (game selected, show GameSelector for confirmation)
|
||||
└── Mode: play (in-game, show game display)
|
||||
```
|
||||
|
||||
### Option B: Sub-routes
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (selector)
|
||||
├── /arcade/play (game display)
|
||||
└── /arcade/configure (player config)
|
||||
```
|
||||
|
||||
### Option C: Query-Parameter Driven
|
||||
```
|
||||
/arcade
|
||||
├── /arcade (default - selector)
|
||||
├── /arcade?game=X (game loading)
|
||||
└── /arcade?game=X&playing=true (in-game)
|
||||
```
|
||||
|
||||
**Recommendation:** Option C (Query-driven) is closest to current architecture and requires minimal changes to existing logic.
|
||||
@@ -97,7 +97,8 @@
|
||||
"Bash(pnpm exec turbo build --filter=@soroban/web)",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name,databaseId --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"Bash(do gh run list --limit 1 --json conclusion,status,name --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\") - \\(.name)\"\"')",
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')"
|
||||
"Bash(do gh run list --limit 1 --workflow=\"Build and Deploy\" --json conclusion,status --jq '.[0] | \"\"\\(.status) - \\(.conclusion // \"\"running\"\")\"\"')",
|
||||
"WebFetch(domain:abaci.one)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
@@ -158,7 +158,7 @@ export default function RoomDetailPage() {
|
||||
const startGame = () => {
|
||||
if (!room) return
|
||||
// Navigate to the room game page
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
}
|
||||
|
||||
const joinRoom = async () => {
|
||||
|
||||
@@ -1,128 +1,315 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { EnhancedChampionArena } from '../../components/EnhancedChampionArena'
|
||||
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
function ArcadeContent() {
|
||||
const { setFullscreenElement } = useFullscreen()
|
||||
const arcadeRef = useRef<HTMLDivElement>(null)
|
||||
/**
|
||||
* /arcade - 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 regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We show a friendly message with a link if no room exists to avoid navigation loops.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
useEffect(() => {
|
||||
// Register this component's main div as the fullscreen element
|
||||
if (arcadeRef.current) {
|
||||
setFullscreenElement(arcadeRef.current)
|
||||
}
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={arcadeRef}
|
||||
className={css({
|
||||
minHeight: 'calc(100vh - 80px)', // Account for mini nav height
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
py: { base: '4', md: '6' },
|
||||
})}
|
||||
>
|
||||
{/* Animated background elements */}
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundImage: `
|
||||
radial-gradient(circle at 20% 80%, rgba(120, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 119, 198, 0.3) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, rgba(120, 219, 255, 0.2) 0%, transparent 50%)
|
||||
`,
|
||||
animation: 'arcadeFloat 20s ease-in-out infinite',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Main Champion Arena - takes remaining space */}
|
||||
<div
|
||||
className={css({
|
||||
flex: 1,
|
||||
style={{
|
||||
display: 'flex',
|
||||
px: { base: '2', md: '4' },
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
minHeight: 0, // Important for flex children
|
||||
})}
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
<EnhancedChampionArena
|
||||
onConfigurePlayer={() => {}}
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// All games are now in the registry
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Choose Game"
|
||||
navEmoji="🎮"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 20px 40px rgba(0, 0, 0, 0.3)',
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #0f0f23 0%, #1a1a3a 50%, #2d1b69 100%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4',
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '8',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Choose a Game
|
||||
</h1>
|
||||
|
||||
function ArcadePageWithRedirect() {
|
||||
return (
|
||||
<PageWithNav navTitle="Champion Arena" navEmoji="🏟️" emphasizePlayerSelection={true}>
|
||||
<ArcadeContent />
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1fr', md: 'repeat(2, 1fr)' },
|
||||
gap: '4',
|
||||
maxWidth: '800px',
|
||||
width: '100%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]: [string, any]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
||||
export default function ArcadePage() {
|
||||
return (
|
||||
<FullscreenProvider>
|
||||
<ArcadePageWithRedirect />
|
||||
</FullscreenProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// Arcade-specific animations
|
||||
const arcadeAnimations = `
|
||||
@keyframes arcadeFloat {
|
||||
0%, 100% {
|
||||
transform: translateY(0px) rotate(0deg);
|
||||
opacity: 0.7;
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
33% {
|
||||
transform: translateY(-20px) rotate(1deg);
|
||||
opacity: 1;
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
66% {
|
||||
transform: translateY(-10px) rotate(-1deg);
|
||||
opacity: 0.8;
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes arcadePulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px rgba(96, 165, 250, 0.3);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 40px rgba(96, 165, 250, 0.6);
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
// Inject arcade animations
|
||||
if (typeof document !== 'undefined' && !document.getElementById('arcade-animations')) {
|
||||
const style = document.createElement('style')
|
||||
style.id = 'arcade-animations'
|
||||
style.textContent = arcadeAnimations
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
@@ -1,358 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useRoomData, useSetRoomGame } from '@/hooks/useRoomData'
|
||||
import { GAMES_CONFIG } from '@/components/GameSelector'
|
||||
import type { GameType } from '@/components/GameSelector'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../../styled-system/css'
|
||||
import { getAllGames, getGame, hasGame } from '@/lib/arcade/game-registry'
|
||||
|
||||
// Map GameType keys to internal game names
|
||||
// Note: "battle-arena" removed - now handled by game registry as "matching"
|
||||
const GAME_TYPE_TO_NAME: Record<GameType, string> = {
|
||||
'complement-race': 'complement-race',
|
||||
'master-organizer': 'master-organizer',
|
||||
}
|
||||
|
||||
/**
|
||||
* /arcade/room - Renders the game for the user's current room
|
||||
* Since users can only be in one room at a time, this is a simple singular route
|
||||
*
|
||||
* Shows game selection when no game is set, then shows the game itself once selected.
|
||||
* URL never changes - it's always /arcade/room regardless of selection, setup, or gameplay.
|
||||
*
|
||||
* Note: We don't redirect to /arcade if no room exists to avoid navigation loops.
|
||||
* Instead, we show a friendly message with a link back to the Champion Arena.
|
||||
*
|
||||
* Note: ModerationNotifications is handled by PageWithNav inside each game component,
|
||||
* so we don't need to render it here.
|
||||
*/
|
||||
export default function RoomPage() {
|
||||
const router = useRouter()
|
||||
const { roomData, isLoading } = useRoomData()
|
||||
const { mutate: setRoomGame } = useSetRoomGame()
|
||||
|
||||
// Show loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Loading room...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show error if no room (instead of redirecting)
|
||||
if (!roomData) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
gap: '1rem',
|
||||
}}
|
||||
>
|
||||
<div>No active room found</div>
|
||||
<a
|
||||
href="/arcade"
|
||||
style={{
|
||||
color: '#3b82f6',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Go to Champion Arena
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Show game selection if no game is set
|
||||
if (!roomData.gameName) {
|
||||
const handleGameSelect = (gameType: GameType) => {
|
||||
console.log('[RoomPage] handleGameSelect called with gameType:', gameType)
|
||||
|
||||
// Check if it's a registry game first
|
||||
if (hasGame(gameType)) {
|
||||
const gameDef = getGame(gameType)
|
||||
if (!gameDef?.manifest.available) {
|
||||
console.log('[RoomPage] Registry game not available, blocking selection')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Selecting registry game:', gameType)
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: gameType, // Use the game name directly for registry games
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Legacy game handling
|
||||
const gameConfig = GAMES_CONFIG[gameType as keyof typeof GAMES_CONFIG]
|
||||
if (!gameConfig) {
|
||||
console.log('[RoomPage] Unknown game type:', gameType)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[RoomPage] Game config:', {
|
||||
name: gameConfig.name,
|
||||
available: 'available' in gameConfig ? gameConfig.available : true,
|
||||
})
|
||||
|
||||
if ('available' in gameConfig && gameConfig.available === false) {
|
||||
console.log('[RoomPage] Game not available, blocking selection')
|
||||
return // Don't allow selecting unavailable games
|
||||
}
|
||||
|
||||
// Map GameType to internal game name
|
||||
const internalGameName = GAME_TYPE_TO_NAME[gameType]
|
||||
console.log('[RoomPage] Mapping:', {
|
||||
gameType,
|
||||
internalGameName,
|
||||
mappingExists: !!internalGameName,
|
||||
})
|
||||
|
||||
console.log('[RoomPage] Calling setRoomGame with:', {
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
preservingGameConfig: true,
|
||||
})
|
||||
|
||||
// Don't pass gameConfig - we want to preserve existing settings for all games
|
||||
setRoomGame({
|
||||
roomId: roomData.id,
|
||||
gameName: internalGameName,
|
||||
})
|
||||
}
|
||||
|
||||
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%',
|
||||
})}
|
||||
>
|
||||
{/* Legacy games */}
|
||||
{Object.entries(GAMES_CONFIG).map(([gameType, config]) => {
|
||||
const isAvailable = !('available' in config) || config.available !== false
|
||||
return (
|
||||
<button
|
||||
key={gameType}
|
||||
onClick={() => handleGameSelect(gameType as GameType)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: config.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: config.borderColor || 'blue.200',
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
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>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Registry games */}
|
||||
{getAllGames().map((gameDef) => {
|
||||
const isAvailable = gameDef.manifest.available
|
||||
return (
|
||||
<button
|
||||
key={gameDef.manifest.name}
|
||||
onClick={() => handleGameSelect(gameDef.manifest.name)}
|
||||
disabled={!isAvailable}
|
||||
className={css({
|
||||
background: gameDef.manifest.gradient,
|
||||
border: '2px solid',
|
||||
borderColor: gameDef.manifest.borderColor,
|
||||
borderRadius: '2xl',
|
||||
padding: '6',
|
||||
cursor: !isAvailable ? 'not-allowed' : 'pointer',
|
||||
opacity: !isAvailable ? 0.5 : 1,
|
||||
transition: 'all 0.3s ease',
|
||||
_hover: !isAvailable
|
||||
? {}
|
||||
: {
|
||||
transform: 'translateY(-4px) scale(1.02)',
|
||||
boxShadow: '0 20px 40px rgba(59, 130, 246, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.displayName}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.600',
|
||||
})}
|
||||
>
|
||||
{gameDef.manifest.description}
|
||||
</p>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Check if this is a registry game first
|
||||
if (hasGame(roomData.gameName)) {
|
||||
const gameDef = getGame(roomData.gameName)
|
||||
if (!gameDef) {
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Found"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not found in registry
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
// Render registry game dynamically
|
||||
const { Provider, GameComponent } = gameDef
|
||||
return (
|
||||
<Provider>
|
||||
<GameComponent />
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// Render legacy games based on room's gameName
|
||||
switch (roomData.gameName) {
|
||||
// TODO: Add other legacy games (complement-race, etc.) once migrated
|
||||
default:
|
||||
return (
|
||||
<PageWithNav
|
||||
navTitle="Game Not Available"
|
||||
navEmoji="⚠️"
|
||||
emphasizePlayerSelection={true}
|
||||
onExitSession={() => router.push('/arcade')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100vh',
|
||||
fontSize: '18px',
|
||||
color: '#666',
|
||||
}}
|
||||
>
|
||||
Game "{roomData.gameName}" not yet supported
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -231,7 +231,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
password: roomPassword,
|
||||
})
|
||||
// Navigate to the game
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to join room')
|
||||
setIsJoining(false)
|
||||
@@ -261,7 +261,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
|
||||
// If user is already in this exact room, just navigate to game
|
||||
if (roomData && roomData.id === room.id) {
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -313,7 +313,7 @@ export default function JoinRoomPage({ params }: { params: { code: string } }) {
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
router.push('/arcade/room') // Stay in current room
|
||||
router.push('/arcade') // Stay in current room
|
||||
}
|
||||
|
||||
const handlePasswordSubmit = () => {
|
||||
|
||||
@@ -3,133 +3,400 @@
|
||||
import Link from 'next/link'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack, stack } from '../../styled-system/patterns'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<PageWithNav navTitle="Soroban Flashcards" navEmoji="🧮">
|
||||
<div
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
bg: 'gradient-to-br from-brand.50 to-brand.100',
|
||||
})}
|
||||
>
|
||||
{/* Hero Section */}
|
||||
<main className={container({ maxW: '6xl', px: '4' })}>
|
||||
<div
|
||||
className={stack({
|
||||
gap: '12',
|
||||
py: '16',
|
||||
align: 'center',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{/* Hero Content */}
|
||||
<div className={stack({ gap: '6', maxW: '4xl' })}>
|
||||
<PageWithNav navTitle="Soroban Mastery Platform" navEmoji="🧮">
|
||||
<div className={css({ bg: 'gray.50', minHeight: '100vh' })}>
|
||||
{/* Compact Hero */}
|
||||
<div
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
color: 'white',
|
||||
py: { base: '8', md: '12' },
|
||||
})}
|
||||
>
|
||||
<div className={container({ maxW: '6xl', px: '4' })}>
|
||||
<div className={css({ textAlign: 'center', maxW: '4xl', mx: 'auto' })}>
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl' },
|
||||
fontSize: { base: '3xl', md: '5xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4',
|
||||
lineHeight: 'tight',
|
||||
})}
|
||||
>
|
||||
Beautiful Soroban <span className={css({ color: 'brand.600' })}>Flashcards</span>
|
||||
Master Soroban Through{' '}
|
||||
<span className={css({ color: 'yellow.300' })}>Play & Practice</span>
|
||||
</h1>
|
||||
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'lg', md: 'xl' },
|
||||
color: 'gray.600',
|
||||
maxW: '2xl',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
Create stunning, educational flashcards with authentic Japanese abacus
|
||||
representations. Perfect for teachers, students, and mental math enthusiasts.
|
||||
<p className={css({ fontSize: { base: 'md', md: 'lg' }, opacity: 0.95, mb: '6' })}>
|
||||
Interactive tutorials, multiplayer games, and beautiful flashcards—your complete
|
||||
soroban learning ecosystem
|
||||
</p>
|
||||
|
||||
<div className={hstack({ gap: '4', justify: 'center', mt: '8' })}>
|
||||
<div className={hstack({ gap: '3', justify: 'center', flexWrap: 'wrap' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
href="/arcade"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
bg: 'brand.600',
|
||||
color: 'white',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'yellow.400',
|
||||
color: 'gray.900',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
_hover: { bg: 'yellow.300', transform: 'translateY(-2px)' },
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: 'brand.700',
|
||||
transform: 'translateY(-2px)',
|
||||
shadow: 'modal',
|
||||
},
|
||||
})}
|
||||
>
|
||||
✨ Start Creating →
|
||||
🎮 Play Games
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/guide"
|
||||
className={css({
|
||||
px: '8',
|
||||
py: '4',
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'white',
|
||||
color: 'brand.700',
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'xl',
|
||||
shadow: 'card',
|
||||
border: '2px solid',
|
||||
borderColor: 'brand.200',
|
||||
color: 'purple.700',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
_hover: { bg: 'gray.100', transform: 'translateY(-2px)' },
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
borderColor: 'brand.400',
|
||||
transform: 'translateY(-2px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
📚 Learn Soroban
|
||||
📚 Learn
|
||||
</Link>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
px: '6',
|
||||
py: '3',
|
||||
bg: 'purple.600',
|
||||
color: 'white',
|
||||
fontWeight: 'bold',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
_hover: { bg: 'purple.700', transform: 'translateY(-2px)' },
|
||||
transition: 'all',
|
||||
})}
|
||||
>
|
||||
🎨 Create
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features Grid */}
|
||||
<div
|
||||
{/* Main Content Grid */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>
|
||||
<div className={stack({ gap: '8' })}>
|
||||
{/* Arcade Games Section */}
|
||||
<section>
|
||||
<div className={hstack({ justify: 'space-between', mb: '4' })}>
|
||||
<h2 className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'gray.900' })}>
|
||||
🕹️ Multiplayer Arcade
|
||||
</h2>
|
||||
<Link
|
||||
href="/arcade"
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'purple.600',
|
||||
fontWeight: 'semibold',
|
||||
_hover: { color: 'purple.700' },
|
||||
})}
|
||||
>
|
||||
View All →
|
||||
</Link>
|
||||
</div>
|
||||
<div className={grid({ columns: { base: 1, sm: 2, lg: 4 }, gap: '4' })}>
|
||||
<GameCard
|
||||
icon="🧠"
|
||||
title="Memory Lightning"
|
||||
description="Memorize soroban numbers"
|
||||
players="1-8 players"
|
||||
tags={['Co-op', 'Competitive']}
|
||||
/>
|
||||
<GameCard
|
||||
icon="⚔️"
|
||||
title="Matching Pairs"
|
||||
description="Turn-based card battles"
|
||||
players="1-4 players"
|
||||
tags={['Pattern Recognition']}
|
||||
/>
|
||||
<GameCard
|
||||
icon="🏁"
|
||||
title="Speed Race"
|
||||
description="Race AI with complements"
|
||||
players="1-4 players + AI"
|
||||
tags={['Practice', 'Sprint', 'Survival']}
|
||||
/>
|
||||
<GameCard
|
||||
icon="🔢"
|
||||
title="Card Sorting"
|
||||
description="Arrange cards visually"
|
||||
players="Solo challenge"
|
||||
tags={['Visual Literacy']}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Two Column Layout */}
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '6' })}>
|
||||
{/* Interactive Learning */}
|
||||
<section
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
📚 Interactive Learning
|
||||
</h2>
|
||||
<div className={stack({ gap: '3' })}>
|
||||
<FeatureItem
|
||||
icon="🔍"
|
||||
title="Reading Numbers"
|
||||
description="Visual tutorials on interpreting bead positions"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="🧮"
|
||||
title="Arithmetic Operations"
|
||||
description="Step-by-step interactive practice: +, −, ×, ÷"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="🎯"
|
||||
title="Guided Tutorials"
|
||||
description="Hands-on exercises with instant feedback"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/guide"
|
||||
className={css({
|
||||
display: 'block',
|
||||
mt: '4',
|
||||
py: '2',
|
||||
textAlign: 'center',
|
||||
bg: 'purple.50',
|
||||
color: 'purple.700',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
_hover: { bg: 'purple.100' },
|
||||
})}
|
||||
>
|
||||
Start Learning →
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Flashcard Creator */}
|
||||
<section
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
🎨 Flashcard Creator
|
||||
</h2>
|
||||
<div className={stack({ gap: '3' })}>
|
||||
<FeatureItem
|
||||
icon="📄"
|
||||
title="Multiple Formats"
|
||||
description="PDF, PNG, SVG, interactive HTML"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="🎨"
|
||||
title="Custom Styling"
|
||||
description="Bead shapes, color schemes, fonts, layouts"
|
||||
/>
|
||||
<FeatureItem
|
||||
icon="📐"
|
||||
title="Paper Options"
|
||||
description="A3, A4, A5, US Letter • Portrait/Landscape"
|
||||
/>
|
||||
</div>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'block',
|
||||
mt: '4',
|
||||
py: '2',
|
||||
textAlign: 'center',
|
||||
bg: 'blue.50',
|
||||
color: 'blue.700',
|
||||
fontWeight: 'semibold',
|
||||
rounded: 'lg',
|
||||
_hover: { bg: 'blue.100' },
|
||||
})}
|
||||
>
|
||||
Create Flashcards →
|
||||
</Link>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Multiplayer Features */}
|
||||
<section>
|
||||
<h2
|
||||
className={css({ fontSize: '2xl', fontWeight: 'bold', color: 'gray.900', mb: '4' })}
|
||||
>
|
||||
🌐 Multiplayer Features
|
||||
</h2>
|
||||
<div className={grid({ columns: { base: 1, sm: 2, md: 4 }, gap: '4' })}>
|
||||
<FeatureCard
|
||||
icon="🎭"
|
||||
title="Player Characters"
|
||||
description="Custom names, emojis, and colors for each player"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🏠"
|
||||
title="Private Rooms"
|
||||
description="Create rooms with codes, passwords, or approval-only access"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="⚡"
|
||||
title="Real-time Play"
|
||||
description="Socket.io powered instant multiplayer sync"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="📊"
|
||||
title="Stats & Progress"
|
||||
description="Track wins, accuracy, and performance across games"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<section
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: { base: '1', md: '3' },
|
||||
gap: '8',
|
||||
mt: '16',
|
||||
w: 'full',
|
||||
bg: 'gradient-to-r',
|
||||
gradientFrom: 'purple.600',
|
||||
gradientTo: 'indigo.600',
|
||||
rounded: 'xl',
|
||||
p: '6',
|
||||
color: 'white',
|
||||
})}
|
||||
>
|
||||
<FeatureCard
|
||||
icon="🎨"
|
||||
title="Beautiful Design"
|
||||
description="Vector graphics, color schemes, authentic bead positioning"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="⚡"
|
||||
title="Instant Generation"
|
||||
description="Create PDFs, interactive HTML, PNGs, and SVGs in seconds"
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="🎯"
|
||||
title="Educational Focus"
|
||||
description="Perfect for teachers, students, and soroban enthusiasts"
|
||||
/>
|
||||
</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
mb: '4',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
Complete Soroban Learning Platform
|
||||
</h2>
|
||||
<div className={grid({ columns: { base: 2, md: 4 }, gap: '6', textAlign: 'center' })}>
|
||||
<StatItem number="4" label="Arcade Games" />
|
||||
<StatItem number="8" label="Max Players" />
|
||||
<StatItem number="3" label="Learning Modes" />
|
||||
<StatItem number="4+" label="Export Formats" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</PageWithNav>
|
||||
)
|
||||
}
|
||||
|
||||
function GameCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
players,
|
||||
tags,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
players: string
|
||||
tags: string[]
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
transition: 'all',
|
||||
_hover: { shadow: 'md', transform: 'translateY(-2px)' },
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{icon}</div>
|
||||
<h3 className={css({ fontSize: 'md', fontWeight: 'bold', color: 'gray.900', mb: '1' })}>
|
||||
{title}
|
||||
</h3>
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.600', mb: '2' })}>{description}</p>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.500', mb: '2' })}>{players}</p>
|
||||
<div className={hstack({ gap: '1', flexWrap: 'wrap' })}>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
px: '2',
|
||||
py: '0.5',
|
||||
bg: 'purple.100',
|
||||
color: 'purple.700',
|
||||
rounded: 'full',
|
||||
})}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureItem({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
}: {
|
||||
icon: string
|
||||
title: string
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className={hstack({ gap: '3', alignItems: 'flex-start' })}>
|
||||
<div className={css({ fontSize: 'xl', flexShrink: 0 })}>{icon}</div>
|
||||
<div>
|
||||
<h4 className={css({ fontSize: 'sm', fontWeight: 'semibold', color: 'gray.900' })}>
|
||||
{title}
|
||||
</h4>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.600' })}>{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
@@ -142,44 +409,31 @@ function FeatureCard({
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
p: '8',
|
||||
bg: 'white',
|
||||
rounded: '2xl',
|
||||
shadow: 'card',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
shadow: 'sm',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
textAlign: 'center',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
transform: 'translateY(-4px)',
|
||||
shadow: 'modal',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.900',
|
||||
mb: '3',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{icon}</div>
|
||||
<h3 className={css({ fontSize: 'sm', fontWeight: 'bold', color: 'gray.900', mb: '1' })}>
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
className={css({
|
||||
color: 'gray.600',
|
||||
lineHeight: 'relaxed',
|
||||
})}
|
||||
>
|
||||
<p className={css({ fontSize: 'xs', color: 'gray.600', lineHeight: 'relaxed' })}>
|
||||
{description}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatItem({ number, label }: { number: string; label: string }) {
|
||||
return (
|
||||
<div>
|
||||
<div className={css({ fontSize: '3xl', fontWeight: 'bold', mb: '1' })}>{number}</div>
|
||||
<div className={css({ fontSize: 'sm', opacity: 0.9 })}>{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
createContext,
|
||||
useContext,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { type ReactNode, useCallback, useMemo, createContext, useContext, useState } from 'react'
|
||||
import { useArcadeSession } from '@/hooks/useArcadeSession'
|
||||
import { useRoomData, useUpdateGameConfig } from '@/hooks/useRoomData'
|
||||
import { useViewerId } from '@/hooks/useViewerId'
|
||||
|
||||
@@ -264,13 +264,15 @@ export function PlayingPhase() {
|
||||
</h3>
|
||||
<div
|
||||
className={css({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
base: '1',
|
||||
sm: '2',
|
||||
md: '3',
|
||||
},
|
||||
gap: '0.75rem',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '10px',
|
||||
justifyContent: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.5)',
|
||||
borderRadius: '8px',
|
||||
minHeight: '120px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{state.availableCards.map((card) => (
|
||||
@@ -278,27 +280,47 @@ export function PlayingPhase() {
|
||||
key={card.id}
|
||||
onClick={() => handleCardClick(card.id)}
|
||||
className={css({
|
||||
padding: '0.5rem',
|
||||
width: '90px',
|
||||
height: '90px',
|
||||
padding: '8px',
|
||||
border: '2px solid',
|
||||
borderColor: selectedCardId === card.id ? 'blue.500' : 'gray.300',
|
||||
borderRadius: '0.5rem',
|
||||
background: selectedCardId === card.id ? 'blue.50' : 'white',
|
||||
borderColor: selectedCardId === card.id ? '#1976d2' : 'transparent',
|
||||
borderRadius: '8px',
|
||||
background: selectedCardId === card.id ? '#e3f2fd' : 'white',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s',
|
||||
transform: selectedCardId === card.id ? 'scale(1.05)' : 'scale(1)',
|
||||
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: '0 2px 6px rgba(0,0,0,0.1)',
|
||||
_hover: {
|
||||
transform: 'scale(1.05)',
|
||||
borderColor: 'blue.500',
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||
transform: 'translateY(-5px)',
|
||||
boxShadow: '0 8px 16px rgba(0,0,0,0.2)',
|
||||
borderColor: '#2c5f76',
|
||||
},
|
||||
})}
|
||||
style={
|
||||
selectedCardId === card.id
|
||||
? {
|
||||
transform: 'scale(1.1)',
|
||||
boxShadow: '0 6px 20px rgba(25, 118, 210, 0.3)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: card.svgContent }}
|
||||
className={css({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
'& svg': {
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
height: 'auto',
|
||||
},
|
||||
})}
|
||||
@@ -306,11 +328,15 @@ export function PlayingPhase() {
|
||||
{state.numbersRevealed && (
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
marginTop: '0.5rem',
|
||||
fontSize: 'lg',
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
right: '5px',
|
||||
background: '#ffc107',
|
||||
color: '#333',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 8px',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.700',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
@@ -336,8 +362,14 @@ export function PlayingPhase() {
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0.25rem',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: '15px',
|
||||
background: 'rgba(255,255,255,0.7)',
|
||||
borderRadius: '8px',
|
||||
border: '2px dashed #2c5f76',
|
||||
})}
|
||||
>
|
||||
{/* Insert button before first position */}
|
||||
@@ -376,9 +408,10 @@ export function PlayingPhase() {
|
||||
const isEmpty = card === null
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<>
|
||||
{/* Position slot */}
|
||||
<div
|
||||
key={`slot-${index}`}
|
||||
onClick={() => handleSlotClick(index)}
|
||||
className={css({
|
||||
width: '90px',
|
||||
@@ -457,6 +490,7 @@ export function PlayingPhase() {
|
||||
|
||||
{/* Insert button after this position */}
|
||||
<button
|
||||
key={`insert-${index + 1}`}
|
||||
type="button"
|
||||
onClick={() => handleInsertClick(index + 1)}
|
||||
disabled={!selectedCardId}
|
||||
@@ -472,7 +506,6 @@ export function PlayingPhase() {
|
||||
cursor: selectedCardId ? 'pointer' : 'default',
|
||||
opacity: selectedCardId ? 1 : 0.3,
|
||||
transition: 'all 0.2s',
|
||||
marginTop: '0.25rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -485,7 +518,7 @@ export function PlayingPhase() {
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ function getAllGameConfigs() {
|
||||
maxPlayers: gameDef.manifest.maxPlayers,
|
||||
description: gameDef.manifest.description,
|
||||
longDescription: gameDef.manifest.longDescription,
|
||||
url: `/arcade/room?game=${gameDef.manifest.name}`, // Registry games load in room
|
||||
url: '/arcade', // Arcade page handles game selection through UI
|
||||
icon: gameDef.manifest.icon,
|
||||
chips: gameDef.manifest.chips,
|
||||
color: gameDef.manifest.color,
|
||||
|
||||
@@ -80,7 +80,7 @@ export function AddPlayerButton({
|
||||
})
|
||||
// Close popover and navigate to room to choose game
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Failed to create room:', error)
|
||||
@@ -111,7 +111,7 @@ export function AddPlayerButton({
|
||||
}
|
||||
// Close popover and navigate to room
|
||||
setShowPopover(false)
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -833,7 +833,7 @@ export function ModerationNotifications({
|
||||
// Close the modal
|
||||
onClose()
|
||||
// Navigate to the room
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
} catch (error) {
|
||||
console.error('Failed to join room:', error)
|
||||
showError(
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PendingInvitations({ onInvitationChange, currentRoomId }: Pendin
|
||||
// Join the room
|
||||
await joinRoom({ roomId: invitation.roomId })
|
||||
// Navigate to the room
|
||||
router.push('/arcade/room')
|
||||
router.push('/arcade')
|
||||
// Refresh invitations
|
||||
await fetchInvitations()
|
||||
onInvitationChange?.()
|
||||
|
||||
@@ -81,7 +81,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
session = await createArcadeSession({
|
||||
userId,
|
||||
gameName: room.gameName as GameName,
|
||||
gameUrl: '/arcade/room',
|
||||
gameUrl: '/arcade',
|
||||
initialState,
|
||||
activePlayers: roomPlayerIds, // Include all room members' active players
|
||||
roomId: room.id,
|
||||
@@ -173,7 +173,7 @@ export function initializeSocketServer(httpServer: HTTPServer) {
|
||||
await createArcadeSession({
|
||||
userId: data.userId,
|
||||
gameName: 'matching',
|
||||
gameUrl: '/arcade/room', // Room-based sessions use /arcade/room
|
||||
gameUrl: '/arcade', // Room-based sessions use /arcade
|
||||
initialState,
|
||||
activePlayers,
|
||||
roomId: room.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.10.2",
|
||||
"version": "4.11.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user