fix: various game improvements and UI enhancements
Collection of improvements across multiple games and components: - Complement Race: Improve sound effects timing and reliability - Card Sorting: Enhance drag-and-drop physics and visual feedback - Memory Quiz: Fix input phase keyboard navigation - Rithmomachia: Update playing guide modal content and layout - PageWithNav: Add viewport context integration - ArcadeSession: Improve session state management and cleanup - Global CSS: Add utility classes for 3D transforms Documentation: - Add Google Classroom setup guide - Update Claude Code settings 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
25880cc7e4
commit
b67cf610c5
|
|
@ -0,0 +1,468 @@
|
|||
# Google Classroom Integration Setup Guide
|
||||
|
||||
**Goal:** Set up Google Classroom API integration using mostly CLI commands, minimizing web console interaction.
|
||||
|
||||
**Time Required:** 15-20 minutes
|
||||
**Cost:** $0 (free for educational use)
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
✅ **gcloud CLI installed** (already installed at `/opt/homebrew/bin/gcloud`)
|
||||
✅ **Valid Google account**
|
||||
- **Billing account** (required by Google, but FREE for Classroom API)
|
||||
|
||||
---
|
||||
|
||||
## Quick Start (TL;DR)
|
||||
|
||||
```bash
|
||||
# Run the automated setup script
|
||||
./scripts/setup-google-classroom.sh
|
||||
```
|
||||
|
||||
The script will:
|
||||
1. Authenticate with your Google account
|
||||
2. Create a GCP project
|
||||
3. Enable Classroom & People APIs
|
||||
4. Guide you through OAuth setup (2 web console steps)
|
||||
5. Configure your `.env.local` file
|
||||
|
||||
**Note:** Steps 6 & 7 still require web console (Google doesn't provide CLI for OAuth consent screen), but the script opens the pages for you and provides exact instructions.
|
||||
|
||||
---
|
||||
|
||||
## What the Script Does (Step by Step)
|
||||
|
||||
### 1. Authentication ✅ Fully Automated
|
||||
```bash
|
||||
gcloud auth login
|
||||
```
|
||||
Opens browser, you log in with Google, done.
|
||||
|
||||
### 2. Create GCP Project ✅ Fully Automated
|
||||
```bash
|
||||
PROJECT_ID="soroban-abacus-$(date +%s)" # Unique ID with timestamp
|
||||
gcloud projects create "$PROJECT_ID" --name="Soroban Abacus Flashcards"
|
||||
gcloud config set project "$PROJECT_ID"
|
||||
```
|
||||
|
||||
### 3. Link Billing Account ✅ Mostly Automated
|
||||
```bash
|
||||
# List your billing accounts
|
||||
gcloud billing accounts list
|
||||
|
||||
# Link to project
|
||||
gcloud billing projects link "$PROJECT_ID" --billing-account="BILLING_ACCOUNT_ID"
|
||||
```
|
||||
|
||||
**Why billing is required:**
|
||||
- Google requires billing for API access (even free APIs!)
|
||||
- Classroom API is **FREE** with no usage charges
|
||||
- You won't be charged unless you enable paid services
|
||||
|
||||
**If you don't have a billing account:**
|
||||
- Script will prompt you to create one at: https://console.cloud.google.com/billing
|
||||
- It's quick: just add payment method (won't be charged)
|
||||
- Press Enter in terminal after creation
|
||||
|
||||
### 4. Enable APIs ✅ Fully Automated
|
||||
```bash
|
||||
gcloud services enable classroom.googleapis.com
|
||||
gcloud services enable people.googleapis.com
|
||||
```
|
||||
|
||||
Takes 1-2 minutes to propagate.
|
||||
|
||||
### 5. Create OAuth Credentials ⚠️ Requires Web Console
|
||||
|
||||
**Why CLI doesn't work:**
|
||||
Google doesn't provide `gcloud` commands for creating OAuth clients. You need the web console.
|
||||
|
||||
**What the script does:**
|
||||
- Opens: https://console.cloud.google.com/apis/credentials?project=YOUR_PROJECT
|
||||
- Provides exact instructions (copy-paste ready)
|
||||
|
||||
**Manual steps (takes 2 minutes):**
|
||||
1. Click "**Create Credentials**" → "**OAuth client ID**"
|
||||
2. Application type: **"Web application"**
|
||||
3. Name: **"Soroban Abacus Web"**
|
||||
4. **Authorized JavaScript origins:**
|
||||
```
|
||||
http://localhost:3000
|
||||
https://abaci.one
|
||||
```
|
||||
5. **Authorized redirect URIs:**
|
||||
```
|
||||
http://localhost:3000/api/auth/callback/google
|
||||
https://abaci.one/api/auth/callback/google
|
||||
```
|
||||
6. Click **"Create"**
|
||||
7. **Copy** the Client ID and Client Secret (you'll paste into terminal)
|
||||
|
||||
### 6. Configure OAuth Consent Screen ⚠️ Requires Web Console
|
||||
|
||||
**Why CLI doesn't work:**
|
||||
OAuth consent screen configuration is web-only.
|
||||
|
||||
**What the script does:**
|
||||
- Opens: https://console.cloud.google.com/apis/credentials/consent?project=YOUR_PROJECT
|
||||
- Provides step-by-step instructions
|
||||
|
||||
**Manual steps (takes 3 minutes):**
|
||||
|
||||
**Screen 1: OAuth consent screen**
|
||||
- User Type: **"External"** (unless you have Google Workspace)
|
||||
- Click "**Create**"
|
||||
|
||||
**Screen 2: App information**
|
||||
- App name: **"Soroban Abacus Flashcards"**
|
||||
- User support email: **Your email**
|
||||
- App logo: (optional)
|
||||
- App domain: (optional, can add later)
|
||||
- Developer contact: **Your email**
|
||||
- Click "**Save and Continue**"
|
||||
|
||||
**Screen 3: Scopes**
|
||||
- Click "**Add or Remove Scopes**"
|
||||
- Filter/search for these scopes and check them:
|
||||
- ✅ `.../auth/userinfo.email` (See your primary Google Account email)
|
||||
- ✅ `.../auth/userinfo.profile` (See your personal info)
|
||||
- ✅ `.../auth/classroom.courses.readonly` (View courses)
|
||||
- ✅ `.../auth/classroom.rosters.readonly` (View class rosters)
|
||||
- Click "**Update**"
|
||||
- Click "**Save and Continue**"
|
||||
|
||||
**Screen 4: Test users**
|
||||
- Click "**Add Users**"
|
||||
- Add your email address (for testing)
|
||||
- Click "**Save and Continue**"
|
||||
|
||||
**Screen 5: Summary**
|
||||
- Review and click "**Back to Dashboard**"
|
||||
|
||||
Done! ✅
|
||||
|
||||
### 7. Save Credentials to .env.local ✅ Fully Automated
|
||||
|
||||
Script prompts you for:
|
||||
- Client ID (paste from step 5)
|
||||
- Client Secret (paste from step 5)
|
||||
|
||||
Then automatically adds to `.env.local`:
|
||||
```bash
|
||||
# Google OAuth (Generated by setup-google-classroom.sh)
|
||||
GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
|
||||
GOOGLE_CLIENT_SECRET="GOCSPX-your-secret"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## After Running the Script
|
||||
|
||||
### Verify Setup
|
||||
|
||||
```bash
|
||||
# Check project configuration
|
||||
gcloud config get-value project
|
||||
|
||||
# List enabled APIs
|
||||
gcloud services list --enabled
|
||||
|
||||
# Check Classroom API is enabled
|
||||
gcloud services list --enabled | grep classroom
|
||||
```
|
||||
|
||||
Expected output:
|
||||
```
|
||||
classroom.googleapis.com Google Classroom API
|
||||
```
|
||||
|
||||
### Test API Access
|
||||
|
||||
```bash
|
||||
# Get an access token
|
||||
gcloud auth application-default login
|
||||
gcloud auth application-default print-access-token
|
||||
|
||||
# Test Classroom API (replace TOKEN)
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" \
|
||||
https://classroom.googleapis.com/v1/courses
|
||||
```
|
||||
|
||||
Expected response (if you have no courses yet):
|
||||
```json
|
||||
{}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## NextAuth Configuration
|
||||
|
||||
Now that you have credentials, add Google provider to NextAuth:
|
||||
|
||||
### 1. Check Current NextAuth Config
|
||||
|
||||
```bash
|
||||
cat src/app/api/auth/[...nextauth]/route.ts
|
||||
```
|
||||
|
||||
### 2. Add Google Provider
|
||||
|
||||
Add to your NextAuth providers array:
|
||||
|
||||
```typescript
|
||||
import GoogleProvider from "next-auth/providers/google"
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: process.env.GOOGLE_CLIENT_ID!,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||
authorization: {
|
||||
params: {
|
||||
scope: [
|
||||
'openid',
|
||||
'email',
|
||||
'profile',
|
||||
'https://www.googleapis.com/auth/classroom.courses.readonly',
|
||||
'https://www.googleapis.com/auth/classroom.rosters.readonly',
|
||||
].join(' '),
|
||||
prompt: 'consent',
|
||||
access_type: 'offline',
|
||||
response_type: 'code'
|
||||
}
|
||||
}
|
||||
}),
|
||||
// ... your existing providers
|
||||
],
|
||||
// ... rest of config
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Test Login
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
npm run dev
|
||||
|
||||
# Open browser
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
Click "Sign in with Google" and verify:
|
||||
- ✅ OAuth consent screen appears
|
||||
- ✅ Shows requested permissions
|
||||
- ✅ Successfully logs in
|
||||
- ✅ User profile is created
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Billing account required"
|
||||
|
||||
**Problem:** Can't enable APIs without billing
|
||||
**Solution:** Create billing account at https://console.cloud.google.com/billing
|
||||
- Won't be charged for Classroom API (it's free)
|
||||
- Just need payment method on file
|
||||
|
||||
### "Error 401: deleted_client"
|
||||
|
||||
**Problem:** OAuth client was deleted or not created properly
|
||||
**Solution:** Re-run OAuth client creation (step 5)
|
||||
```bash
|
||||
open "https://console.cloud.google.com/apis/credentials?project=$(gcloud config get-value project)"
|
||||
```
|
||||
|
||||
### "Error 403: Access Not Configured"
|
||||
|
||||
**Problem:** APIs not enabled yet (takes 1-2 min to propagate)
|
||||
**Solution:** Wait 2 minutes, then verify:
|
||||
```bash
|
||||
gcloud services list --enabled | grep classroom
|
||||
```
|
||||
|
||||
### "Invalid redirect URI"
|
||||
|
||||
**Problem:** Redirect URI doesn't match OAuth client config
|
||||
**Solution:** Check that these URIs are in your OAuth client:
|
||||
- http://localhost:3000/api/auth/callback/google
|
||||
- https://abaci.one/api/auth/callback/google
|
||||
|
||||
### "App is not verified"
|
||||
|
||||
**Problem:** OAuth consent screen in "Testing" mode
|
||||
**Solution:** This is **normal** for development!
|
||||
- Click "Advanced" → "Go to [app name] (unsafe)"
|
||||
- Only affects external test users
|
||||
- For production, submit for verification (takes 1-2 weeks)
|
||||
|
||||
---
|
||||
|
||||
## CLI Reference
|
||||
|
||||
### Project Management
|
||||
|
||||
```bash
|
||||
# List all your projects
|
||||
gcloud projects list
|
||||
|
||||
# Switch project
|
||||
gcloud config set project PROJECT_ID
|
||||
|
||||
# Delete project (if needed)
|
||||
gcloud projects delete PROJECT_ID
|
||||
```
|
||||
|
||||
### API Management
|
||||
|
||||
```bash
|
||||
# List enabled APIs
|
||||
gcloud services list --enabled
|
||||
|
||||
# Enable an API
|
||||
gcloud services enable APINAME.googleapis.com
|
||||
|
||||
# Disable an API
|
||||
gcloud services disable APINAME.googleapis.com
|
||||
|
||||
# Check quota
|
||||
gcloud services quota describe classroom.googleapis.com
|
||||
```
|
||||
|
||||
### OAuth Management
|
||||
|
||||
```bash
|
||||
# List OAuth clients (requires REST API)
|
||||
PROJECT_ID=$(gcloud config get-value project)
|
||||
ACCESS_TOKEN=$(gcloud auth application-default print-access-token)
|
||||
|
||||
curl -H "Authorization: Bearer $ACCESS_TOKEN" \
|
||||
"https://oauth2.googleapis.com/v1/projects/$PROJECT_ID/oauthClients"
|
||||
```
|
||||
|
||||
### Billing
|
||||
|
||||
```bash
|
||||
# List billing accounts
|
||||
gcloud billing accounts list
|
||||
|
||||
# Link billing to project
|
||||
gcloud billing projects link PROJECT_ID --billing-account=ACCOUNT_ID
|
||||
|
||||
# Check project billing status
|
||||
gcloud billing projects describe PROJECT_ID
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## What You Can Do From CLI (Summary)
|
||||
|
||||
✅ **Fully Automated:**
|
||||
- Authenticate with Google
|
||||
- Create GCP project
|
||||
- Enable APIs
|
||||
- Link billing account
|
||||
- Configure environment variables
|
||||
|
||||
⚠️ **Requires Web Console (2-5 minutes):**
|
||||
- Create OAuth client (2 min)
|
||||
- Configure OAuth consent screen (3 min)
|
||||
|
||||
**Why web console required:**
|
||||
Google doesn't provide CLI for these security-sensitive operations. But the script:
|
||||
- Opens the exact pages for you
|
||||
- Provides step-by-step instructions
|
||||
- Makes it as painless as possible
|
||||
|
||||
---
|
||||
|
||||
## Cost Breakdown
|
||||
|
||||
| Item | Cost |
|
||||
|------|------|
|
||||
| GCP project | $0 |
|
||||
| Google Classroom API | $0 (free forever) |
|
||||
| Google People API | $0 (free forever) |
|
||||
| Billing account requirement | $0 (no charges) |
|
||||
| **Total** | **$0** |
|
||||
|
||||
**Note:** You need to add a payment method for billing account, but Google Classroom API is completely free with no usage limits.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps After Setup
|
||||
|
||||
1. ✅ Run the setup script: `./scripts/setup-google-classroom.sh`
|
||||
2. ✅ Add Google provider to NextAuth
|
||||
3. ✅ Test "Sign in with Google"
|
||||
4. 📝 Implement class import feature (Phase 2 of roadmap)
|
||||
5. 📝 Build teacher dashboard
|
||||
6. 📝 Add assignment integration
|
||||
|
||||
Refer to `.claude/PLATFORM_INTEGRATION_ROADMAP.md` for full implementation timeline.
|
||||
|
||||
---
|
||||
|
||||
## Security Best Practices
|
||||
|
||||
### Protect Your Secrets
|
||||
|
||||
```bash
|
||||
# Check .env.local is in .gitignore
|
||||
cat .gitignore | grep .env.local
|
||||
```
|
||||
|
||||
Should see:
|
||||
```
|
||||
.env*.local
|
||||
```
|
||||
|
||||
### Rotate Credentials Periodically
|
||||
|
||||
```bash
|
||||
# Open credentials page
|
||||
PROJECT_ID=$(gcloud config get-value project)
|
||||
open "https://console.cloud.google.com/apis/credentials?project=$PROJECT_ID"
|
||||
|
||||
# Delete old client, create new one
|
||||
# Update .env.local with new credentials
|
||||
```
|
||||
|
||||
### Use Different Credentials for Dev/Prod
|
||||
|
||||
**Development:**
|
||||
- OAuth client: `http://localhost:3000/api/auth/callback/google`
|
||||
- Test users only
|
||||
|
||||
**Production:**
|
||||
- OAuth client: `https://abaci.one/api/auth/callback/google`
|
||||
- Verified app (submit for review)
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
**Official Documentation:**
|
||||
- GCP CLI: https://cloud.google.com/sdk/gcloud
|
||||
- Classroom API: https://developers.google.com/classroom
|
||||
- OAuth 2.0: https://developers.google.com/identity/protocols/oauth2
|
||||
|
||||
**Script Location:**
|
||||
- `scripts/setup-google-classroom.sh`
|
||||
|
||||
**Configuration Files:**
|
||||
- `.env.local` (credentials)
|
||||
- `src/app/api/auth/[...nextauth]/route.ts` (NextAuth config)
|
||||
|
||||
---
|
||||
|
||||
**Ready to run?**
|
||||
|
||||
```bash
|
||||
./scripts/setup-google-classroom.sh
|
||||
```
|
||||
|
||||
Good luck! 🚀
|
||||
|
|
@ -145,7 +145,17 @@
|
|||
"Bash(gcloud config list:*)",
|
||||
"WebFetch(domain:www.boardspace.net)",
|
||||
"WebFetch(domain:www.gamecabinet.com)",
|
||||
"WebFetch(domain:en.wikipedia.org)"
|
||||
"WebFetch(domain:en.wikipedia.org)",
|
||||
"Bash(pnpm search:*)",
|
||||
"Bash(mkdir:*)",
|
||||
"Bash(timeout 10 npx drizzle-kit generate:sqlite:*)",
|
||||
"Bash(brew install:*)",
|
||||
"Bash(sudo ln:*)",
|
||||
"Bash(cd:*)",
|
||||
"Bash(git clone:*)",
|
||||
"Bash(git ls-remote:*)",
|
||||
"Bash(openscad:*)",
|
||||
"Bash(npx eslint:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { useCallback, useRef } from 'react'
|
||||
import { useCallback, useContext, useRef } from 'react'
|
||||
import { PreviewModeContext } from '@/components/GamePreview'
|
||||
|
||||
/**
|
||||
* Web Audio API sound effects system
|
||||
|
|
@ -15,6 +16,7 @@ interface Note {
|
|||
|
||||
export function useSoundEffects() {
|
||||
const audioContextsRef = useRef<AudioContext[]>([])
|
||||
const previewMode = useContext(PreviewModeContext)
|
||||
|
||||
/**
|
||||
* Helper function to play multi-note 90s arcade sounds
|
||||
|
|
@ -107,6 +109,11 @@ export function useSoundEffects() {
|
|||
| 'steam_hiss',
|
||||
volume: number = 0.15
|
||||
) => {
|
||||
// Disable all audio in preview mode
|
||||
if (previewMode?.isPreview) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const audioContext = new (window.AudioContext || (window as any).webkitAudioContext)()
|
||||
|
||||
|
|
@ -438,7 +445,7 @@ export function useSoundEffects() {
|
|||
console.log('🎵 Web Audio not supported - missing out on rad 90s sounds!')
|
||||
}
|
||||
},
|
||||
[play90sSound]
|
||||
[play90sSound, previewMode]
|
||||
)
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -45,3 +45,13 @@ body {
|
|||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { css } from '../../../../styled-system/css'
|
|||
import { useCardSorting } from '../Provider'
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useSpring, animated, to } from '@react-spring/web'
|
||||
import { useViewport } from '@/contexts/ViewportContext'
|
||||
import type { SortingCard } from '../types'
|
||||
|
||||
// Add celebration animations
|
||||
|
|
@ -929,10 +930,12 @@ export function PlayingPhaseDrag() {
|
|||
const [nextZIndex, setNextZIndex] = useState(1)
|
||||
|
||||
// Track viewport dimensions for responsive positioning
|
||||
// Get viewport dimensions (uses mock dimensions in preview mode)
|
||||
const viewport = useViewport()
|
||||
|
||||
// For spectators, reduce dimensions to account for panels
|
||||
const getEffectiveViewportWidth = () => {
|
||||
if (typeof window === 'undefined') return 1000
|
||||
const baseWidth = window.innerWidth
|
||||
const baseWidth = viewport.width
|
||||
// Sidebar is hidden on mobile (< 768px), narrower on desktop
|
||||
if (isSpectating && !spectatorStatsCollapsed && baseWidth >= 768) {
|
||||
return baseWidth - 240 // Subtract stats sidebar width on desktop
|
||||
|
|
@ -941,9 +944,8 @@ export function PlayingPhaseDrag() {
|
|||
}
|
||||
|
||||
const getEffectiveViewportHeight = () => {
|
||||
if (typeof window === 'undefined') return 800
|
||||
const baseHeight = window.innerHeight
|
||||
const baseWidth = window.innerWidth
|
||||
const baseHeight = viewport.height
|
||||
const baseWidth = viewport.width
|
||||
if (isSpectating) {
|
||||
// Banner is 170px on mobile (130px mini nav + 40px spectator banner), 56px on desktop
|
||||
return baseHeight - (baseWidth < 768 ? 170 : 56)
|
||||
|
|
@ -1284,8 +1286,8 @@ export function PlayingPhaseDrag() {
|
|||
const newYPx = e.clientY - offsetY
|
||||
|
||||
// Convert to percentages
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const viewportWidth = viewport.width
|
||||
const viewportHeight = viewport.height
|
||||
const newX = (newXPx / viewportWidth) * 100
|
||||
const newY = (newYPx / viewportHeight) * 100
|
||||
|
||||
|
|
@ -1901,22 +1903,15 @@ export function PlayingPhaseDrag() {
|
|||
})}
|
||||
style={{
|
||||
width:
|
||||
isSpectating &&
|
||||
!spectatorStatsCollapsed &&
|
||||
typeof window !== 'undefined' &&
|
||||
window.innerWidth >= 768
|
||||
isSpectating && !spectatorStatsCollapsed && viewport.width >= 768
|
||||
? 'calc(100vw - 240px)'
|
||||
: '100vw',
|
||||
height: isSpectating
|
||||
? typeof window !== 'undefined' && window.innerWidth < 768
|
||||
? viewport.width < 768
|
||||
? 'calc(100vh - 170px)'
|
||||
: 'calc(100vh - 56px)'
|
||||
: '100vh',
|
||||
top: isSpectating
|
||||
? typeof window !== 'undefined' && window.innerWidth < 768
|
||||
? '170px'
|
||||
: '56px'
|
||||
: '0',
|
||||
top: isSpectating ? (viewport.width < 768 ? '170px' : '56px') : '0',
|
||||
}}
|
||||
>
|
||||
{/* Render continuous curved path through the entire sequence */}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { isPrefix } from '@/lib/memory-quiz-utils'
|
||||
import { useMemoryQuiz } from '../Provider'
|
||||
import { useViewport } from '@/contexts/ViewportContext'
|
||||
import { CardGrid } from './CardGrid'
|
||||
|
||||
export function InputPhase() {
|
||||
const { state, dispatch, acceptNumber, rejectNumber, setInput, showResults } = useMemoryQuiz()
|
||||
const viewport = useViewport()
|
||||
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>(
|
||||
'neutral'
|
||||
)
|
||||
|
|
@ -56,7 +58,7 @@ export function InputPhase() {
|
|||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
|
||||
// Method 3: Check viewport characteristics for mobile devices
|
||||
const isMobileViewport = window.innerWidth <= 768 && window.innerHeight <= 1024
|
||||
const isMobileViewport = viewport.width <= 768 && viewport.height <= 1024
|
||||
|
||||
// Combined heuristic: assume no physical keyboard if:
|
||||
// - It's a touch device AND has mobile viewport AND lacks precise pointer
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Textfit } from 'react-textfit'
|
|||
import { css } from '../../../../styled-system/css'
|
||||
import { Z_INDEX } from '@/constants/zIndex'
|
||||
import { useAbacusSettings } from '@/hooks/useAbacusSettings'
|
||||
import { useViewport } from '@/contexts/ViewportContext'
|
||||
import { OverviewSection } from './guide-sections/OverviewSection'
|
||||
import { PiecesSection } from './guide-sections/PiecesSection'
|
||||
import { CaptureSection } from './guide-sections/CaptureSection'
|
||||
|
|
@ -37,6 +38,7 @@ export function PlayingGuideModal({
|
|||
const t = useTranslations('rithmomachia.guide')
|
||||
const { data: abacusSettings } = useAbacusSettings()
|
||||
const useNativeAbacusNumbers = abacusSettings?.nativeAbacusNumbers ?? false
|
||||
const viewport = useViewport()
|
||||
|
||||
const [activeSection, setActiveSection] = useState<Section>('overview')
|
||||
|
||||
|
|
@ -69,7 +71,7 @@ export function PlayingGuideModal({
|
|||
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [windowWidth, setWindowWidth] = useState(
|
||||
typeof window !== 'undefined' ? window.innerWidth : 800
|
||||
typeof window !== 'undefined' ? viewport.width : 800
|
||||
)
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 })
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
|
|
@ -98,18 +100,16 @@ export function PlayingGuideModal({
|
|||
|
||||
// Track window width for responsive behavior
|
||||
useEffect(() => {
|
||||
const handleResize = () => setWindowWidth(window.innerWidth)
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [])
|
||||
setWindowWidth(viewport.width)
|
||||
}, [viewport.width])
|
||||
|
||||
// Center modal on mount (not in standalone mode)
|
||||
useEffect(() => {
|
||||
if (isOpen && modalRef.current && !standalone) {
|
||||
const rect = modalRef.current.getBoundingClientRect()
|
||||
setPosition({
|
||||
x: (window.innerWidth - rect.width) / 2,
|
||||
y: Math.max(50, (window.innerHeight - rect.height) / 2),
|
||||
x: (viewport.width - rect.width) / 2,
|
||||
y: Math.max(50, (viewport.height - rect.height) / 2),
|
||||
})
|
||||
}
|
||||
}, [isOpen, standalone])
|
||||
|
|
@ -118,13 +118,13 @@ export function PlayingGuideModal({
|
|||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
console.log(
|
||||
'[GUIDE_DRAG] === MOUSE DOWN === windowWidth: ' +
|
||||
window.innerWidth +
|
||||
viewport.width +
|
||||
', standalone: ' +
|
||||
standalone +
|
||||
', docked: ' +
|
||||
docked
|
||||
)
|
||||
if (window.innerWidth < 768 || standalone) {
|
||||
if (viewport.width < 768 || standalone) {
|
||||
console.log('[GUIDE_DRAG] Skipping drag - mobile or standalone')
|
||||
return // No dragging on mobile or standalone
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ export function PlayingGuideModal({
|
|||
|
||||
// Handle resize start
|
||||
const handleResizeStart = (e: React.MouseEvent, direction: string) => {
|
||||
if (window.innerWidth < 768 || standalone) return
|
||||
if (viewport.width < 768 || standalone) return
|
||||
e.stopPropagation()
|
||||
setIsResizing(true)
|
||||
setResizeDirection(direction)
|
||||
|
|
@ -326,7 +326,7 @@ export function PlayingGuideModal({
|
|||
if (e.clientX < DOCK_THRESHOLD) {
|
||||
setDockPreview('left')
|
||||
onDockPreview('left')
|
||||
} else if (e.clientX > window.innerWidth - DOCK_THRESHOLD) {
|
||||
} else if (e.clientX > viewport.width - DOCK_THRESHOLD) {
|
||||
setDockPreview('right')
|
||||
onDockPreview('right')
|
||||
} else {
|
||||
|
|
@ -351,26 +351,23 @@ export function PlayingGuideModal({
|
|||
const minHeight = 300
|
||||
|
||||
if (resizeDirection.includes('e')) {
|
||||
newWidth = Math.max(
|
||||
minWidth,
|
||||
Math.min(window.innerWidth * 0.9, resizeStart.width + deltaX)
|
||||
)
|
||||
newWidth = Math.max(minWidth, Math.min(viewport.width * 0.9, resizeStart.width + deltaX))
|
||||
}
|
||||
if (resizeDirection.includes('w')) {
|
||||
const desiredWidth = resizeStart.width - deltaX
|
||||
newWidth = Math.max(minWidth, Math.min(window.innerWidth * 0.9, desiredWidth))
|
||||
newWidth = Math.max(minWidth, Math.min(viewport.width * 0.9, desiredWidth))
|
||||
// Move left edge by the amount we actually changed width
|
||||
newX = resizeStart.x + (resizeStart.width - newWidth)
|
||||
}
|
||||
if (resizeDirection.includes('s')) {
|
||||
newHeight = Math.max(
|
||||
minHeight,
|
||||
Math.min(window.innerHeight * 0.9, resizeStart.height + deltaY)
|
||||
Math.min(viewport.height * 0.9, resizeStart.height + deltaY)
|
||||
)
|
||||
}
|
||||
if (resizeDirection.includes('n')) {
|
||||
const desiredHeight = resizeStart.height - deltaY
|
||||
newHeight = Math.max(minHeight, Math.min(window.innerHeight * 0.9, desiredHeight))
|
||||
newHeight = Math.max(minHeight, Math.min(viewport.height * 0.9, desiredHeight))
|
||||
// Move top edge by the amount we actually changed height
|
||||
newY = resizeStart.y + (resizeStart.height - newHeight)
|
||||
}
|
||||
|
|
@ -403,7 +400,7 @@ export function PlayingGuideModal({
|
|||
', threshold: ' +
|
||||
DOCK_THRESHOLD +
|
||||
', windowWidth: ' +
|
||||
window.innerWidth
|
||||
viewport.width
|
||||
)
|
||||
|
||||
if (e.clientX < DOCK_THRESHOLD) {
|
||||
|
|
@ -420,7 +417,7 @@ export function PlayingGuideModal({
|
|||
}
|
||||
console.log('[GUIDE_DRAG] Cleared state after re-dock to left')
|
||||
return
|
||||
} else if (e.clientX > window.innerWidth - DOCK_THRESHOLD) {
|
||||
} else if (e.clientX > viewport.width - DOCK_THRESHOLD) {
|
||||
console.log('[GUIDE_DRAG] Mouse up - near right edge, calling onDock(right)')
|
||||
onDock('right')
|
||||
// Don't call onUndock if we're re-docking
|
||||
|
|
@ -496,7 +493,7 @@ export function PlayingGuideModal({
|
|||
const isMedium = effectiveWidth < 600
|
||||
|
||||
const renderResizeHandles = () => {
|
||||
if (!isHovered || window.innerWidth < 768 || standalone) return null
|
||||
if (!isHovered || viewport.width < 768 || standalone) return null
|
||||
|
||||
const handleStyle = {
|
||||
position: 'absolute' as const,
|
||||
|
|
@ -662,7 +659,7 @@ export function PlayingGuideModal({
|
|||
opacity:
|
||||
dockPreview !== null
|
||||
? 0.8
|
||||
: !standalone && !docked && window.innerWidth >= 768 && !isHovered
|
||||
: !standalone && !docked && viewport.width >= 768 && !isHovered
|
||||
? 0.8
|
||||
: 1,
|
||||
transition: 'opacity 0.2s ease',
|
||||
|
|
@ -705,7 +702,7 @@ export function PlayingGuideModal({
|
|||
padding: isVeryNarrow ? '8px' : isNarrow ? '12px' : '24px',
|
||||
cursor: isDragging
|
||||
? 'grabbing'
|
||||
: !standalone && window.innerWidth >= 768
|
||||
: !standalone && viewport.width >= 768
|
||||
? 'grab'
|
||||
: 'default',
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import React, { useContext } from 'react'
|
||||
import { useGameMode } from '../contexts/GameModeContext'
|
||||
import { useRoomData } from '../hooks/useRoomData'
|
||||
import { useViewerId } from '../hooks/useViewerId'
|
||||
|
|
@ -9,6 +9,7 @@ import { GameContextNav, type RosterWarning } from './nav/GameContextNav'
|
|||
import type { PlayerBadge } from './nav/types'
|
||||
import { PlayerConfigDialog } from './nav/PlayerConfigDialog'
|
||||
import { ModerationNotifications } from './nav/ModerationNotifications'
|
||||
import { PreviewModeContext } from './GamePreview'
|
||||
|
||||
interface PageWithNavProps {
|
||||
navTitle?: string
|
||||
|
|
@ -57,6 +58,12 @@ export function PageWithNav({
|
|||
onAssignBlackPlayer,
|
||||
gamePhase,
|
||||
}: PageWithNavProps) {
|
||||
// In preview mode, render just the children without navigation
|
||||
const previewMode = useContext(PreviewModeContext)
|
||||
if (previewMode?.isPreview) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const { players, activePlayers, setActive, activePlayerCount } = useGameMode()
|
||||
const { roomData, isInRoom, moderationEvent, clearModerationEvent } = useRoomData()
|
||||
const { data: viewerId } = useViewerId()
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import type { GameMove } from '@/lib/arcade/validation'
|
||||
import { useArcadeSocket } from './useArcadeSocket'
|
||||
import {
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
useOptimisticGameState,
|
||||
} from './useOptimisticGameState'
|
||||
import type { RetryState } from '@/lib/arcade/error-handling'
|
||||
import { PreviewModeContext } from '@/components/GamePreview'
|
||||
|
||||
export interface UseArcadeSessionOptions<TState> extends UseOptimisticGameStateOptions<TState> {
|
||||
/**
|
||||
|
|
@ -101,6 +102,40 @@ export function useArcadeSession<TState>(
|
|||
): UseArcadeSessionReturn<TState> {
|
||||
const { userId, roomId, autoJoin = true, ...optimisticOptions } = options
|
||||
|
||||
// Check if we're in preview mode
|
||||
const previewMode = useContext(PreviewModeContext)
|
||||
|
||||
// If in preview mode, return mock session immediately
|
||||
if (previewMode?.isPreview && previewMode?.mockState) {
|
||||
const mockRetryState: RetryState = {
|
||||
isRetrying: false,
|
||||
retryCount: 0,
|
||||
move: null,
|
||||
timestamp: null,
|
||||
}
|
||||
|
||||
return {
|
||||
state: previewMode.mockState as TState,
|
||||
version: 1,
|
||||
connected: true,
|
||||
hasPendingMoves: false,
|
||||
lastError: null,
|
||||
retryState: mockRetryState,
|
||||
sendMove: () => {
|
||||
// Mock: do nothing in preview
|
||||
},
|
||||
exitSession: () => {
|
||||
// Mock: do nothing in preview
|
||||
},
|
||||
clearError: () => {
|
||||
// Mock: do nothing in preview
|
||||
},
|
||||
refresh: () => {
|
||||
// Mock: do nothing in preview
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Optimistic state management
|
||||
const optimistic = useOptimisticGameState<TState>(optimisticOptions)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue