diff --git a/apps/web/.claude/GOOGLE_CLASSROOM_SETUP.md b/apps/web/.claude/GOOGLE_CLASSROOM_SETUP.md new file mode 100644 index 00000000..7e2dac27 --- /dev/null +++ b/apps/web/.claude/GOOGLE_CLASSROOM_SETUP.md @@ -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! 🚀 diff --git a/apps/web/.claude/settings.local.json b/apps/web/.claude/settings.local.json index 0316538b..6c6594da 100644 --- a/apps/web/.claude/settings.local.json +++ b/apps/web/.claude/settings.local.json @@ -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": [] diff --git a/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts b/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts index b57cb2fa..3ccf7cc6 100644 --- a/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts +++ b/apps/web/src/app/arcade/complement-race/hooks/useSoundEffects.ts @@ -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([]) + 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] ) /** diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index 60f2299c..28f5380c 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -45,3 +45,13 @@ body { transform: translate(-50%, -50%) scale(1); } } + +@keyframes float { + 0%, + 100% { + transform: translateY(0px); + } + 50% { + transform: translateY(-10px); + } +} diff --git a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx index d3812f74..8408c04e 100644 --- a/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx +++ b/apps/web/src/arcade-games/card-sorting/components/PlayingPhaseDrag.tsx @@ -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 */} diff --git a/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx b/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx index c697ac99..447264bb 100644 --- a/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx +++ b/apps/web/src/arcade-games/memory-quiz/components/InputPhase.tsx @@ -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 diff --git a/apps/web/src/arcade-games/rithmomachia/components/PlayingGuideModal.tsx b/apps/web/src/arcade-games/rithmomachia/components/PlayingGuideModal.tsx index d94f086d..5c8b9c3e 100644 --- a/apps/web/src/arcade-games/rithmomachia/components/PlayingGuideModal.tsx +++ b/apps/web/src/arcade-games/rithmomachia/components/PlayingGuideModal.tsx @@ -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
('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', }} diff --git a/apps/web/src/components/PageWithNav.tsx b/apps/web/src/components/PageWithNav.tsx index 80c3eb63..b3bafe66 100644 --- a/apps/web/src/components/PageWithNav.tsx +++ b/apps/web/src/components/PageWithNav.tsx @@ -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() diff --git a/apps/web/src/hooks/useArcadeSession.ts b/apps/web/src/hooks/useArcadeSession.ts index 78c950cb..9251b056 100644 --- a/apps/web/src/hooks/useArcadeSession.ts +++ b/apps/web/src/hooks/useArcadeSession.ts @@ -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 extends UseOptimisticGameStateOptions { /** @@ -101,6 +102,40 @@ export function useArcadeSession( ): UseArcadeSessionReturn { 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(optimisticOptions)