Compare commits
34 Commits
abacus-rea
...
abacus-rea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0c46f3a7ba | ||
|
|
1fe12c4837 | ||
|
|
b7e7c4beff | ||
|
|
423ba55350 | ||
|
|
885fc725dc | ||
|
|
0311b0fe03 | ||
|
|
cea5fadbe4 | ||
|
|
9191b12493 | ||
|
|
3dcff2ff88 | ||
|
|
301e65dfa6 | ||
|
|
00bfcbcdee | ||
|
|
0a4bf1765c | ||
|
|
618f5d2cb0 | ||
|
|
8e1648737d | ||
|
|
d4fbdd1463 | ||
|
|
b82e9bb9d6 | ||
|
|
3fa11c4fbc | ||
|
|
342bff739a | ||
|
|
5e6c901f73 | ||
|
|
127cebab69 | ||
|
|
11fd6f9b3d | ||
|
|
de4c03e6b2 | ||
|
|
834b062b2d | ||
|
|
5799cc599d | ||
|
|
7a4ecd2b59 | ||
|
|
cc1f27f0f8 | ||
|
|
8df76c08fd | ||
|
|
3f86163c14 | ||
|
|
728a92076a | ||
|
|
9ef72d7e88 | ||
|
|
5d0dacbee5 | ||
|
|
904074ca82 | ||
|
|
19b14e9440 | ||
|
|
4bb8f6daf1 |
72
.github/workflows/publish-abacus-react.yml
vendored
72
.github/workflows/publish-abacus-react.yml
vendored
@@ -12,6 +12,7 @@ permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
@@ -76,9 +77,74 @@ jobs:
|
||||
working-directory: packages/abacus-react
|
||||
run: |
|
||||
# Only publish if semantic-release created a new version
|
||||
git fetch --tags
|
||||
if git tag --list | grep -q "abacus-react-v"; then
|
||||
echo "Publishing to GitHub Packages..."
|
||||
npm publish
|
||||
echo "Found abacus-react version tag. Publishing to GitHub Packages..."
|
||||
# Update package.json version to match the tag
|
||||
LATEST_TAG=$(git tag --list "abacus-react-v*" | sort -V | tail -1)
|
||||
VERSION=${LATEST_TAG#abacus-react-v}
|
||||
echo "Publishing version: $VERSION"
|
||||
|
||||
# Create a clean package.json for publishing by updating version and cleaning workspace references
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8'));
|
||||
|
||||
// Set the correct version
|
||||
pkg.version = '$VERSION';
|
||||
|
||||
// Clean workspace dependencies function
|
||||
const cleanWorkspaceDeps = (deps) => {
|
||||
if (!deps) return deps;
|
||||
const result = {};
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
if (typeof version === 'string' && version.startsWith('workspace:')) {
|
||||
// Replace workspace: syntax with actual version or latest
|
||||
result[name] = version.replace('workspace:', '') || '*';
|
||||
} else {
|
||||
result[name] = version;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
// Clean all dependency types
|
||||
if (pkg.dependencies) pkg.dependencies = cleanWorkspaceDeps(pkg.dependencies);
|
||||
if (pkg.devDependencies) pkg.devDependencies = cleanWorkspaceDeps(pkg.devDependencies);
|
||||
if (pkg.peerDependencies) pkg.peerDependencies = cleanWorkspaceDeps(pkg.peerDependencies);
|
||||
if (pkg.optionalDependencies) pkg.optionalDependencies = cleanWorkspaceDeps(pkg.optionalDependencies);
|
||||
|
||||
// Set publishConfig for GitHub Packages
|
||||
pkg.publishConfig = {
|
||||
access: 'public',
|
||||
registry: 'https://npm.pkg.github.com'
|
||||
};
|
||||
|
||||
// Write the clean package.json
|
||||
fs.writeFileSync('package.json', JSON.stringify(pkg, null, 2));
|
||||
console.log('Created clean package.json for version:', pkg.version);
|
||||
"
|
||||
|
||||
# Verify the package.json is clean
|
||||
echo "Package.json version: $(node -e "console.log(JSON.parse(require('fs').readFileSync('package.json', 'utf8')).version)")"
|
||||
|
||||
# Check for any remaining workspace dependencies
|
||||
if grep -q "workspace:" package.json; then
|
||||
echo "ERROR: Still found workspace dependencies in package.json:"
|
||||
grep "workspace:" package.json
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Debug and publish to GitHub Packages
|
||||
echo "Contents of .npmrc file:"
|
||||
cat .npmrc
|
||||
echo "Environment variables for npm:"
|
||||
echo "NPM_CONFIG_USERCONFIG: $NPM_CONFIG_USERCONFIG"
|
||||
echo "NODE_AUTH_TOKEN is set: $([ -n "$NODE_AUTH_TOKEN" ] && echo "yes" || echo "no")"
|
||||
|
||||
# Set authentication and registry for GitHub Packages
|
||||
echo "Publishing with explicit authentication..."
|
||||
NPM_CONFIG_USERCONFIG=.npmrc NODE_AUTH_TOKEN="${{ secrets.GITHUB_TOKEN }}" npm publish --registry=https://npm.pkg.github.com
|
||||
else
|
||||
echo "No new version to publish"
|
||||
echo "No new abacus-react version tag found, skipping publish"
|
||||
fi
|
||||
3
apps/web/src/app/@nav/default.tsx
Normal file
3
apps/web/src/app/@nav/default.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function DefaultNav() {
|
||||
return null // No navigation content for routes without specific @nav slots
|
||||
}
|
||||
14
apps/web/src/app/@nav/games/matching/page.tsx
Normal file
14
apps/web/src/app/@nav/games/matching/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function MatchingNav() {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
14
apps/web/src/app/@nav/games/memory-quiz/page.tsx
Normal file
14
apps/web/src/app/@nav/games/memory-quiz/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
export default function MemoryQuizNav() {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧠 Memory Lightning
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useFullscreen } from '../../../../contexts/FullscreenContext'
|
||||
import { SetupPhase } from './SetupPhase'
|
||||
import { GamePhase } from './GamePhase'
|
||||
import { ResultsPhase } from './ResultsPhase'
|
||||
import { StandardGameLayout } from '../../../../components/StandardGameLayout'
|
||||
import { css } from '../../../../../styled-system/css'
|
||||
|
||||
export function MemoryPairsGame() {
|
||||
@@ -22,52 +23,59 @@ export function MemoryPairsGame() {
|
||||
}, [setFullscreenElement])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={gameRef}
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
|
||||
padding: { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative'
|
||||
})}>
|
||||
{/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
|
||||
|
||||
<header className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: { base: '8px', sm: '12px', md: '16px' },
|
||||
px: { base: '4', md: '0' },
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: { base: '16px', sm: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
|
||||
marginBottom: 0
|
||||
<StandardGameLayout
|
||||
theme={{
|
||||
gameName: "Memory Pairs",
|
||||
backgroundColor: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={gameRef}
|
||||
className={css({
|
||||
flex: 1,
|
||||
padding: { base: '12px', sm: '16px', md: '20px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
overflow: 'auto'
|
||||
})}>
|
||||
Memory Pairs
|
||||
</h1>
|
||||
</header>
|
||||
{/* Note: Fullscreen restore prompt removed - client-side navigation preserves fullscreen */}
|
||||
|
||||
<main className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <GamePhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</main>
|
||||
</div>
|
||||
<header className={css({
|
||||
textAlign: 'center',
|
||||
marginBottom: { base: '8px', sm: '12px', md: '16px' },
|
||||
px: { base: '4', md: '0' },
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: { base: '16px', sm: '20px', md: '24px' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
textShadow: '1px 1px 2px rgba(0,0,0,0.3)',
|
||||
marginBottom: 0
|
||||
})}>
|
||||
Memory Pairs
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<main className={css({
|
||||
width: '100%',
|
||||
maxWidth: '1200px',
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
borderRadius: { base: '12px', md: '20px' },
|
||||
padding: { base: '12px', sm: '16px', md: '24px', lg: '32px' },
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.2)',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden'
|
||||
})}>
|
||||
{state.gamePhase === 'setup' && <SetupPhase />}
|
||||
{state.gamePhase === 'playing' && <GamePhase />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase />}
|
||||
</main>
|
||||
</div>
|
||||
</StandardGameLayout>
|
||||
)
|
||||
}
|
||||
@@ -130,16 +130,17 @@ export function SetupPhase() {
|
||||
return (
|
||||
<div className={css({
|
||||
textAlign: 'center',
|
||||
padding: { base: '20px 16px', sm: '24px 20px', md: '40px 20px' },
|
||||
padding: { base: '12px 16px', sm: '16px 20px', md: '20px' },
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
flexDirection: 'column',
|
||||
minHeight: 0, // Allow shrinking
|
||||
overflow: 'auto' // Enable scrolling if needed
|
||||
})}>
|
||||
<h2 className={css({
|
||||
fontSize: { base: '20px', sm: '24px', md: '32px', lg: '36px' },
|
||||
marginBottom: { base: '12px', md: '16px' },
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
marginBottom: { base: '8px', md: '12px' },
|
||||
color: 'gray.800',
|
||||
fontWeight: 'bold'
|
||||
})}>
|
||||
@@ -147,20 +148,21 @@ export function SetupPhase() {
|
||||
</h2>
|
||||
|
||||
<p className={css({
|
||||
fontSize: { base: '14px', sm: '16px', md: '18px' },
|
||||
fontSize: { base: '13px', sm: '14px', md: '16px' },
|
||||
color: 'gray.600',
|
||||
marginBottom: { base: '16px', sm: '20px', md: '32px' },
|
||||
marginBottom: { base: '12px', sm: '16px', md: '20px' },
|
||||
lineHeight: '1.4',
|
||||
display: { base: 'none', md: 'block' }
|
||||
display: { base: 'none', sm: 'block' }
|
||||
})}>
|
||||
Configure your memory challenge. Choose your preferred mode, game type, and difficulty level.
|
||||
</p>
|
||||
|
||||
<div className={css({
|
||||
display: 'grid',
|
||||
gap: { base: '12px', sm: '16px', md: '24px' },
|
||||
gap: { base: '8px', sm: '12px', md: '16px' },
|
||||
margin: '0 auto',
|
||||
flex: 1
|
||||
flex: 1,
|
||||
minHeight: 0 // Allow shrinking
|
||||
})}>
|
||||
|
||||
{/* Current Player Setup */}
|
||||
@@ -389,24 +391,34 @@ export function SetupPhase() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Start Game Button */}
|
||||
<div className={css({ marginTop: { base: '16px', md: '20px' } })}>
|
||||
{/* Start Game Button - Sticky at bottom */}
|
||||
<div className={css({
|
||||
marginTop: 'auto', // Push to bottom
|
||||
paddingTop: { base: '12px', md: '16px' },
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
background: 'rgba(255,255,255,0.95)',
|
||||
backdropFilter: 'blur(10px)',
|
||||
borderTop: '1px solid rgba(0,0,0,0.1)',
|
||||
margin: '0 -16px -12px -16px', // Extend to edges
|
||||
padding: { base: '12px 16px', md: '16px' }
|
||||
})}>
|
||||
<button
|
||||
className={css({
|
||||
background: 'linear-gradient(135deg, #ff6b6b 0%, #ee5a24 50%, #ff9ff3 100%)',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: { base: '20px', sm: '30px', md: '60px' },
|
||||
padding: { base: '16px 32px', sm: '18px 40px', md: '20px 60px' },
|
||||
fontSize: { base: '18px', sm: '22px', md: '28px' },
|
||||
borderRadius: { base: '16px', sm: '20px', md: '24px' },
|
||||
padding: { base: '14px 28px', sm: '16px 32px', md: '18px 36px' },
|
||||
fontSize: { base: '16px', sm: '18px', md: '20px' },
|
||||
fontWeight: 'black',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
boxShadow: '0 10px 30px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
boxShadow: '0 8px 20px rgba(255, 107, 107, 0.4), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
textShadow: '0 2px 4px rgba(0,0,0,0.3)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
width: { base: '100%', sm: 'auto' },
|
||||
width: '100%',
|
||||
_before: {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
@@ -418,8 +430,8 @@ export function SetupPhase() {
|
||||
transition: 'left 0.6s ease',
|
||||
},
|
||||
_hover: {
|
||||
transform: { base: 'translateY(-2px)', md: 'translateY(-5px) scale(1.05)' },
|
||||
boxShadow: '0 15px 40px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
transform: { base: 'translateY(-2px)', md: 'translateY(-3px) scale(1.02)' },
|
||||
boxShadow: '0 12px 30px rgba(255, 107, 107, 0.6), inset 0 2px 0 rgba(255,255,255,0.3)',
|
||||
background: 'linear-gradient(135deg, #ff5252 0%, #dd2c00 50%, #e91e63 100%)',
|
||||
_before: {
|
||||
left: '100%'
|
||||
@@ -434,16 +446,16 @@ export function SetupPhase() {
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: { base: '8px', md: '12px' },
|
||||
gap: { base: '6px', md: '8px' },
|
||||
justifyContent: 'center'
|
||||
})}>
|
||||
<span className={css({
|
||||
fontSize: { base: '20px', sm: '24px', md: '32px' },
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
animation: 'bounce 2s infinite'
|
||||
})}>🚀</span>
|
||||
<span>START GAME</span>
|
||||
<span className={css({
|
||||
fontSize: { base: '20px', sm: '24px', md: '32px' },
|
||||
fontSize: { base: '18px', sm: '20px', md: '24px' },
|
||||
animation: 'bounce 2s infinite',
|
||||
animationDelay: '0.5s'
|
||||
})}>🎮</span>
|
||||
@@ -451,13 +463,13 @@ export function SetupPhase() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Game Preview - Hidden on mobile */}
|
||||
{/* Game Preview - Hidden on mobile and small screens */}
|
||||
<div className={css({
|
||||
background: 'gray.50',
|
||||
borderRadius: '12px',
|
||||
padding: '16px',
|
||||
marginTop: '16px',
|
||||
display: { base: 'none', md: 'block' }
|
||||
display: { base: 'none', lg: 'block' } // Only show on large screens
|
||||
})}>
|
||||
<h3 className={css({
|
||||
fontSize: '16px',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { css } from '../../../../styled-system/css'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useAbacusConfig } from '../../../contexts/AbacusDisplayContext'
|
||||
import { isPrefix } from '../../../lib/memory-quiz-utils'
|
||||
import { FullscreenGameLayout } from '../../../components/FullscreenGameLayout'
|
||||
import { StandardGameLayout } from '../../../components/StandardGameLayout'
|
||||
|
||||
|
||||
interface QuizCard {
|
||||
@@ -42,6 +42,11 @@ interface SorobanQuizState {
|
||||
id: string
|
||||
timestamp: number
|
||||
}>
|
||||
|
||||
// Keyboard state (moved from InputPhase to persist across re-renders)
|
||||
hasPhysicalKeyboard: boolean | null
|
||||
testingMode: boolean
|
||||
showOnScreenKeyboard: boolean
|
||||
}
|
||||
|
||||
type QuizAction =
|
||||
@@ -60,6 +65,9 @@ type QuizAction =
|
||||
| { type: 'SET_PREFIX_TIMEOUT'; timeout: NodeJS.Timeout | null }
|
||||
| { type: 'SHOW_RESULTS' }
|
||||
| { type: 'RESET_QUIZ' }
|
||||
| { type: 'SET_PHYSICAL_KEYBOARD'; hasKeyboard: boolean | null }
|
||||
| { type: 'SET_TESTING_MODE'; enabled: boolean }
|
||||
| { type: 'TOGGLE_ONSCREEN_KEYBOARD' }
|
||||
|
||||
const initialState: SorobanQuizState = {
|
||||
cards: [],
|
||||
@@ -76,7 +84,11 @@ const initialState: SorobanQuizState = {
|
||||
gamePhase: 'setup',
|
||||
prefixAcceptanceTimeout: null,
|
||||
finishButtonsBound: false,
|
||||
wrongGuessAnimations: []
|
||||
wrongGuessAnimations: [],
|
||||
// Keyboard state (persistent across re-renders)
|
||||
hasPhysicalKeyboard: null,
|
||||
testingMode: false,
|
||||
showOnScreenKeyboard: false
|
||||
}
|
||||
|
||||
function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizState {
|
||||
@@ -145,8 +157,18 @@ function quizReducer(state: SorobanQuizState, action: QuizAction): SorobanQuizSt
|
||||
cards: state.cards, // Preserve generated cards
|
||||
displayTime: state.displayTime,
|
||||
selectedCount: state.selectedCount,
|
||||
selectedDifficulty: state.selectedDifficulty
|
||||
selectedDifficulty: state.selectedDifficulty,
|
||||
// Preserve keyboard state across resets
|
||||
hasPhysicalKeyboard: state.hasPhysicalKeyboard,
|
||||
testingMode: state.testingMode,
|
||||
showOnScreenKeyboard: state.showOnScreenKeyboard
|
||||
}
|
||||
case 'SET_PHYSICAL_KEYBOARD':
|
||||
return { ...state, hasPhysicalKeyboard: action.hasKeyboard }
|
||||
case 'SET_TESTING_MODE':
|
||||
return { ...state, testingMode: action.enabled }
|
||||
case 'TOGGLE_ONSCREEN_KEYBOARD':
|
||||
return { ...state, showOnScreenKeyboard: !state.showOnScreenKeyboard }
|
||||
default:
|
||||
return state
|
||||
}
|
||||
@@ -949,13 +971,35 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [displayFeedback, setDisplayFeedback] = useState<'neutral' | 'correct' | 'incorrect'>('neutral')
|
||||
|
||||
// Keyboard detection state
|
||||
const [hasPhysicalKeyboard, setHasPhysicalKeyboard] = useState<boolean | null>(null)
|
||||
const [keyboardDetectionAttempted, setKeyboardDetectionAttempted] = useState(false)
|
||||
const [showOnScreenKeyboard, setShowOnScreenKeyboard] = useState(false)
|
||||
// Use keyboard state from parent state instead of local state
|
||||
const { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard } = state
|
||||
|
||||
// Detect physical keyboard availability
|
||||
// Debug: Log state changes and detect what's causing re-renders
|
||||
useEffect(() => {
|
||||
console.log('🔍 Keyboard state changed:', { hasPhysicalKeyboard, testingMode, showOnScreenKeyboard })
|
||||
console.trace('🔍 State change trace:')
|
||||
}, [hasPhysicalKeyboard, testingMode, showOnScreenKeyboard])
|
||||
|
||||
// Debug: Monitor for unexpected state resets
|
||||
useEffect(() => {
|
||||
if (showOnScreenKeyboard) {
|
||||
const timer = setTimeout(() => {
|
||||
if (!showOnScreenKeyboard) {
|
||||
console.error('🚨 Keyboard was unexpectedly hidden!')
|
||||
}
|
||||
}, 1000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [showOnScreenKeyboard])
|
||||
|
||||
// Detect physical keyboard availability (disabled when testing mode is active)
|
||||
useEffect(() => {
|
||||
// Skip keyboard detection entirely when testing mode is enabled
|
||||
if (testingMode) {
|
||||
console.log('🧪 Testing mode enabled - skipping keyboard detection')
|
||||
return
|
||||
}
|
||||
|
||||
let detectionTimer: NodeJS.Timeout | null = null
|
||||
|
||||
const detectKeyboard = () => {
|
||||
@@ -975,16 +1019,17 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
// - It's a touch device AND has mobile viewport AND lacks precise pointer
|
||||
const likelyNoKeyboard = isTouchDevice && isMobileViewport && !hasKeyboardSupport
|
||||
|
||||
setHasPhysicalKeyboard(!likelyNoKeyboard)
|
||||
setKeyboardDetectionAttempted(true)
|
||||
console.log('⌨️ Keyboard detection result:', !likelyNoKeyboard)
|
||||
dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: !likelyNoKeyboard })
|
||||
}
|
||||
|
||||
// Test for actual keyboard input within 3 seconds
|
||||
let keyboardDetected = false
|
||||
const handleFirstKeyPress = (e: KeyboardEvent) => {
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
console.log('⌨️ Physical keyboard detected via keypress')
|
||||
keyboardDetected = true
|
||||
setHasPhysicalKeyboard(true)
|
||||
dispatch({ type: 'SET_PHYSICAL_KEYBOARD', hasKeyboard: true })
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
if (detectionTimer) clearTimeout(detectionTimer)
|
||||
}
|
||||
@@ -996,6 +1041,7 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
// Fallback to heuristic detection after 3 seconds
|
||||
detectionTimer = setTimeout(() => {
|
||||
if (!keyboardDetected) {
|
||||
console.log('⌨️ Using fallback keyboard detection')
|
||||
detectKeyboard()
|
||||
}
|
||||
document.removeEventListener('keypress', handleFirstKeyPress)
|
||||
@@ -1009,7 +1055,7 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
if (detectionTimer) clearTimeout(detectionTimer)
|
||||
clearTimeout(initialDetection)
|
||||
}
|
||||
}, [])
|
||||
}, [testingMode])
|
||||
|
||||
const acceptCorrectNumber = useCallback((number: number) => {
|
||||
dispatch({ type: 'ACCEPT_NUMBER', number })
|
||||
@@ -1143,14 +1189,13 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
padding: '12px',
|
||||
paddingBottom: '12px', // Removed extra space since keyboard is now toggleable
|
||||
paddingBottom: (hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 ? '100px' : '12px', // Add space for keyboard
|
||||
maxWidth: '800px',
|
||||
margin: '0 auto',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'flex-start',
|
||||
minHeight: '100vh' // Ensure container can scroll if needed
|
||||
justifyContent: 'flex-start'
|
||||
}}>
|
||||
<h3 style={{ marginBottom: '16px', color: '#1f2937', fontSize: '18px', fontWeight: '600' }}>Enter the Numbers You Remember</h3>
|
||||
<div style={{
|
||||
@@ -1232,6 +1277,25 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
: '⌨️ Type the numbers you remember'
|
||||
}
|
||||
</div>
|
||||
|
||||
{/* Testing control - remove in production */}
|
||||
<div style={{
|
||||
fontSize: '10px',
|
||||
color: '#9ca3af',
|
||||
marginBottom: '4px'
|
||||
}}>
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', justifyContent: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={testingMode}
|
||||
onChange={(e) => dispatch({ type: 'SET_TESTING_MODE', enabled: e.target.checked })}
|
||||
/>
|
||||
Test on-screen keyboard (for demo)
|
||||
</label>
|
||||
<div style={{ fontSize: '9px', opacity: 0.7 }}>
|
||||
Keyboard detected: {hasPhysicalKeyboard === null ? 'detecting...' : hasPhysicalKeyboard ? 'yes' : 'no'}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
minHeight: '50px',
|
||||
@@ -1342,153 +1406,43 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Toggle button for on-screen keyboard (only shown when no physical keyboard detected) */}
|
||||
{hasPhysicalKeyboard === false && state.guessesRemaining > 0 && (
|
||||
{/* Simple fixed keyboard bar - appears when needed, no hiding of game elements */}
|
||||
{(hasPhysicalKeyboard === false || testingMode) && state.guessesRemaining > 0 && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '16px',
|
||||
right: '16px',
|
||||
zIndex: 1000
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
background: 'linear-gradient(135deg, #f8fafc 0%, #e2e8f0 100%)',
|
||||
borderTop: '2px solid #3b82f6',
|
||||
padding: '12px',
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
boxShadow: '0 -4px 12px rgba(0, 0, 0, 0.1)'
|
||||
}}>
|
||||
<button
|
||||
style={{
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
border: '2px solid #3b82f6',
|
||||
background: showOnScreenKeyboard ? '#3b82f6' : 'white',
|
||||
color: showOnScreenKeyboard ? 'white' : '#3b82f6',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)',
|
||||
transition: 'all 0.2s ease',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onClick={() => setShowOnScreenKeyboard(!showOnScreenKeyboard)}
|
||||
onMouseDown={(e) => e.currentTarget.style.transform = 'scale(0.95)'}
|
||||
onMouseUp={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
onMouseLeave={(e) => e.currentTarget.style.transform = 'scale(1)'}
|
||||
>
|
||||
⌨️
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible on-screen number pad */}
|
||||
{hasPhysicalKeyboard === false && state.guessesRemaining > 0 && showOnScreenKeyboard && (
|
||||
<div style={{
|
||||
position: 'fixed',
|
||||
bottom: '80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
padding: '16px',
|
||||
background: 'white',
|
||||
borderRadius: '16px',
|
||||
border: '2px solid #3b82f6',
|
||||
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.2)',
|
||||
zIndex: 999,
|
||||
maxWidth: '300px',
|
||||
width: 'calc(100vw - 32px)',
|
||||
animation: 'slideUp 0.2s ease-out forwards'
|
||||
}}>
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
marginBottom: '12px',
|
||||
fontSize: '14px',
|
||||
color: '#3b82f6',
|
||||
fontWeight: '600',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between'
|
||||
}}>
|
||||
<span>📱 Tap to enter numbers</span>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 0].map(digit => (
|
||||
<button
|
||||
key={digit}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
fontSize: '18px',
|
||||
color: '#6b7280',
|
||||
cursor: 'pointer',
|
||||
padding: '4px'
|
||||
}}
|
||||
onClick={() => setShowOnScreenKeyboard(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: '8px',
|
||||
marginBottom: '8px'
|
||||
}}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(digit => (
|
||||
<button
|
||||
key={digit}
|
||||
style={{
|
||||
padding: '16px 12px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
background: 'white',
|
||||
fontSize: '20px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
e.currentTarget.style.borderColor = '#3b82f6'
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
e.currentTarget.style.borderColor = '#3b82f6'
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onClick={() => handleKeyboardInput(digit.toString())}
|
||||
>
|
||||
{digit}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 2fr',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<button
|
||||
style={{
|
||||
padding: '16px 12px',
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
borderRadius: '8px',
|
||||
background: 'white',
|
||||
fontSize: '20px',
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
color: '#1f2937',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.15s ease',
|
||||
minWidth: '50px',
|
||||
minHeight: '50px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)'
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
@@ -1505,64 +1459,34 @@ function InputPhase({ state, dispatch }: { state: SorobanQuizState; dispatch: Re
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
e.currentTarget.style.background = '#f3f4f6'
|
||||
e.currentTarget.style.borderColor = '#3b82f6'
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = 'white'
|
||||
e.currentTarget.style.borderColor = '#e5e7eb'
|
||||
}}
|
||||
onClick={() => handleKeyboardInput('0')}
|
||||
onClick={() => handleKeyboardInput(digit.toString())}
|
||||
>
|
||||
0
|
||||
{digit}
|
||||
</button>
|
||||
<button
|
||||
style={{
|
||||
padding: '16px 12px',
|
||||
border: '2px solid #dc2626',
|
||||
borderRadius: '12px',
|
||||
background: state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'bold',
|
||||
color: state.currentInput.length > 0 ? '#dc2626' : '#9ca3af',
|
||||
cursor: state.currentInput.length > 0 ? 'pointer' : 'not-allowed',
|
||||
transition: 'all 0.15s ease',
|
||||
userSelect: 'none',
|
||||
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.05)'
|
||||
}}
|
||||
disabled={state.currentInput.length === 0}
|
||||
onMouseDown={(e) => {
|
||||
if (state.currentInput.length > 0) {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
e.currentTarget.style.background = '#fee2e2'
|
||||
}
|
||||
}}
|
||||
onMouseUp={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb'
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb'
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
if (state.currentInput.length > 0) {
|
||||
e.currentTarget.style.transform = 'scale(0.95)'
|
||||
e.currentTarget.style.background = '#fee2e2'
|
||||
}
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)'
|
||||
e.currentTarget.style.background = state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb'
|
||||
}}
|
||||
onClick={handleKeyboardBackspace}
|
||||
>
|
||||
⌫ Delete
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
style={{
|
||||
padding: '12px 16px',
|
||||
border: '2px solid #dc2626',
|
||||
borderRadius: '8px',
|
||||
background: state.currentInput.length > 0 ? '#fef2f2' : '#f9fafb',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: state.currentInput.length > 0 ? '#dc2626' : '#9ca3af',
|
||||
cursor: state.currentInput.length > 0 ? 'pointer' : 'not-allowed',
|
||||
minWidth: '70px',
|
||||
minHeight: '50px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
userSelect: 'none',
|
||||
transition: 'all 0.15s ease'
|
||||
}}
|
||||
disabled={state.currentInput.length === 0}
|
||||
onClick={handleKeyboardBackspace}
|
||||
>
|
||||
⌫
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1807,15 +1731,19 @@ export default function MemoryQuizPage() {
|
||||
}, [state.prefixAcceptanceTimeout])
|
||||
|
||||
return (
|
||||
<FullscreenGameLayout title="Memory Lightning">
|
||||
<StandardGameLayout
|
||||
theme={{
|
||||
gameName: "Memory Lightning",
|
||||
backgroundColor: "linear-gradient(to bottom right, #f0fdf4, #eff6ff)"
|
||||
}}
|
||||
>
|
||||
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
|
||||
|
||||
<div
|
||||
style={{
|
||||
minHeight: '100vh',
|
||||
background: 'linear-gradient(to bottom right, #f0fdf4, #eff6ff)',
|
||||
padding: '8px',
|
||||
height: '100vh',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'auto'
|
||||
}}
|
||||
>
|
||||
@@ -1824,7 +1752,7 @@ export default function MemoryQuizPage() {
|
||||
maxWidth: '100%',
|
||||
margin: '0 auto',
|
||||
padding: '0 8px',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
}}
|
||||
@@ -1869,12 +1797,12 @@ export default function MemoryQuizPage() {
|
||||
})}>
|
||||
{state.gamePhase === 'setup' && <SetupPhase state={state} dispatch={dispatch} />}
|
||||
{state.gamePhase === 'display' && <DisplayPhase state={state} dispatch={dispatch} />}
|
||||
{state.gamePhase === 'input' && <InputPhase state={state} dispatch={dispatch} />}
|
||||
{state.gamePhase === 'input' && <InputPhase key="input-phase" state={state} dispatch={dispatch} />}
|
||||
{state.gamePhase === 'results' && <ResultsPhase state={state} dispatch={dispatch} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FullscreenGameLayout>
|
||||
</StandardGameLayout>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +1,34 @@
|
||||
import type { Metadata } from 'next'
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import './globals.css'
|
||||
import { AbacusDisplayProvider } from '@/contexts/AbacusDisplayContext'
|
||||
import { UserProfileProvider } from '@/contexts/UserProfileContext'
|
||||
import { GameModeProvider } from '@/contexts/GameModeContext'
|
||||
import { FullscreenProvider } from '@/contexts/FullscreenContext'
|
||||
import { AppNavBar } from '@/components/AppNavBar'
|
||||
import { ClientProviders } from '@/components/ClientProviders'
|
||||
import { AppNav } from '@/components/AppNav'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Soroban Flashcard Generator',
|
||||
description: 'Create beautiful, educational soroban flashcards with authentic Japanese abacus representations',
|
||||
viewport: {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
},
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
nav,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
nav: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<AbacusDisplayProvider>
|
||||
<UserProfileProvider>
|
||||
<GameModeProvider>
|
||||
<FullscreenProvider>
|
||||
<AppNavBar />
|
||||
{children}
|
||||
</FullscreenProvider>
|
||||
</GameModeProvider>
|
||||
</UserProfileProvider>
|
||||
</AbacusDisplayProvider>
|
||||
<ClientProviders>
|
||||
<AppNav>{nav}</AppNav>
|
||||
{children}
|
||||
</ClientProviders>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
@@ -169,6 +169,62 @@ export function AbacusDisplayDropdown({ isFullscreen = false }: AbacusDisplayDro
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Sound Effects" isFullscreen={isFullscreen}>
|
||||
<SwitchField
|
||||
checked={config.soundEnabled}
|
||||
onCheckedChange={(checked) => updateConfig({ soundEnabled: checked })}
|
||||
isFullscreen={isFullscreen}
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{config.soundEnabled && (
|
||||
<FormField label={`Volume: ${Math.round(config.soundVolume * 100)}%`} isFullscreen={isFullscreen}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={config.soundVolume}
|
||||
onChange={(e) => updateConfig({ soundVolume: parseFloat(e.target.value) })}
|
||||
className={css({
|
||||
w: 'full',
|
||||
h: '2',
|
||||
bg: isFullscreen ? 'rgba(255, 255, 255, 0.2)' : 'gray.200',
|
||||
rounded: 'full',
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
_focusVisible: {
|
||||
outline: 'none',
|
||||
ring: '2px',
|
||||
ringColor: isFullscreen ? 'blue.400' : 'brand.500'
|
||||
},
|
||||
'&::-webkit-slider-thumb': {
|
||||
appearance: 'none',
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
cursor: 'pointer',
|
||||
transition: 'all',
|
||||
_hover: {
|
||||
bg: isFullscreen ? 'blue.500' : 'brand.700',
|
||||
transform: 'scale(1.1)'
|
||||
}
|
||||
},
|
||||
'&::-moz-range-thumb': {
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: isFullscreen ? 'blue.400' : 'brand.600',
|
||||
rounded: 'full',
|
||||
border: 'none',
|
||||
cursor: 'pointer'
|
||||
}
|
||||
})}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent dropdown close
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DropdownMenu.Content>
|
||||
|
||||
52
apps/web/src/components/AppNav.tsx
Normal file
52
apps/web/src/components/AppNav.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
import { AppNavBar } from './AppNavBar'
|
||||
|
||||
interface AppNavProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function getNavContentForPath(pathname: string): React.ReactNode {
|
||||
// Route-based nav content - no lazy loading needed
|
||||
if (pathname === '/games/matching' || pathname.startsWith('/arcade') && pathname.includes('matching')) {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧩 Memory Pairs
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
if (pathname === '/games/memory-quiz' || pathname.startsWith('/arcade') && pathname.includes('memory-quiz')) {
|
||||
return (
|
||||
<h1 style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
margin: 0
|
||||
}}>
|
||||
🧠 Memory Lightning
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function AppNav({ children }: AppNavProps) {
|
||||
const headersList = headers()
|
||||
const pathname = headersList.get('x-pathname') || ''
|
||||
|
||||
// Use @nav slot content if available, otherwise fall back to route-based detection
|
||||
const navContent = children || getNavContentForPath(pathname)
|
||||
|
||||
return <AppNavBar navSlot={navContent} />
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { css } from '../../styled-system/css'
|
||||
@@ -9,15 +10,18 @@ import { useFullscreen } from '../contexts/FullscreenContext'
|
||||
|
||||
interface AppNavBarProps {
|
||||
variant?: 'full' | 'minimal'
|
||||
navSlot?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const isGamePage = pathname?.startsWith('/games')
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
|
||||
|
||||
// Auto-detect variant based on context
|
||||
const actualVariant = variant === 'full' && (isGamePage || isArcadePage) ? 'minimal' : variant
|
||||
|
||||
@@ -34,58 +38,53 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
transition: 'all 0.3s ease'
|
||||
})}>
|
||||
<div className={hstack({ gap: '2' })}>
|
||||
{/* Arcade branding (fullscreen only) */}
|
||||
{isFullscreen && (isArcadePage || isGamePage) && (
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '3',
|
||||
px: '4',
|
||||
py: '2',
|
||||
bg: 'rgba(0, 0, 0, 0.85)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: 'blur(15px)'
|
||||
})}>
|
||||
<h1 className={css({
|
||||
fontSize: 'lg',
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent'
|
||||
})}>
|
||||
🕹️ {isArcadePage ? 'Arcade' : 'Game'}
|
||||
</h1>
|
||||
<div className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
background: 'rgba(34, 197, 94, 0.2)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
color: 'green.300',
|
||||
fontWeight: 'semibold'
|
||||
})}>
|
||||
✨ FULLSCREEN
|
||||
</div>
|
||||
{/* Game branding from slot */}
|
||||
{navSlot && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
{navSlot}
|
||||
{isFullscreen && (
|
||||
<div className={css({
|
||||
px: '2',
|
||||
py: '1',
|
||||
background: 'rgba(34, 197, 94, 0.2)',
|
||||
border: '1px solid rgba(34, 197, 94, 0.3)',
|
||||
rounded: 'full',
|
||||
fontSize: 'xs',
|
||||
color: 'green.300',
|
||||
fontWeight: 'semibold'
|
||||
})}>
|
||||
✨ FULLSCREEN
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isFullscreen ? 'rgba(255, 255, 255, 0.1)' : 'gray.200',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
})}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
@@ -122,19 +121,19 @@ export function AppNavBar({ variant = 'full' }: AppNavBarProps) {
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Controls */}
|
||||
<div className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
px: '3',
|
||||
py: '2',
|
||||
bg: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: '1px solid',
|
||||
borderColor: isFullscreen ? 'rgba(255, 255, 255, 0.1)' : 'gray.200',
|
||||
rounded: 'lg',
|
||||
shadow: 'lg',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
})}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
background: isFullscreen ? 'rgba(0, 0, 0, 0.85)' : 'white',
|
||||
border: isFullscreen ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid #e5e7eb',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
backdropFilter: isFullscreen ? 'blur(15px)' : 'none'
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={toggleFullscreen}
|
||||
title={isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
|
||||
|
||||
63
apps/web/src/components/StandardGameLayout.tsx
Normal file
63
apps/web/src/components/StandardGameLayout.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode, useEffect } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useGameTheme } from '../contexts/GameThemeContext'
|
||||
|
||||
interface StandardGameLayoutProps {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
theme?: {
|
||||
gameName: string
|
||||
backgroundColor: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard game layout that ensures:
|
||||
* 1. Exact 100vh height with no scrolling (vertical or horizontal)
|
||||
* 2. Navigation never covers game elements (safe area padding)
|
||||
* 3. Perfect viewport fit on all devices
|
||||
* 4. Consistent experience across all games
|
||||
*/
|
||||
export function StandardGameLayout({ children, className, theme }: StandardGameLayoutProps) {
|
||||
const { setTheme } = useGameTheme()
|
||||
|
||||
// Set the theme when component mounts and clean up on unmount
|
||||
useEffect(() => {
|
||||
if (theme) {
|
||||
setTheme(theme)
|
||||
}
|
||||
return () => {
|
||||
setTheme(null)
|
||||
}
|
||||
}, [theme, setTheme])
|
||||
|
||||
return (
|
||||
<div className={css({
|
||||
// Exact viewport sizing - no scrolling ever
|
||||
height: '100vh',
|
||||
width: '100vw',
|
||||
overflow: 'hidden',
|
||||
|
||||
// Safe area for navigation (fixed at top: 4px, right: 4px)
|
||||
// Navigation is ~60px tall, so we need padding-top of ~80px to be safe
|
||||
paddingTop: '80px',
|
||||
paddingRight: '4px', // Ensure nav doesn't overlap content on right side
|
||||
paddingBottom: '4px',
|
||||
paddingLeft: '4px',
|
||||
|
||||
// Box sizing to include padding in dimensions
|
||||
boxSizing: 'border-box',
|
||||
|
||||
// Flex container for game content
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
||||
// Apply the theme background if provided
|
||||
background: theme?.backgroundColor || 'transparent'
|
||||
}, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,8 @@ export interface AbacusDisplayConfig {
|
||||
hideInactiveBeads: boolean
|
||||
coloredNumerals: boolean
|
||||
scaleFactor: number
|
||||
soundEnabled: boolean
|
||||
soundVolume: number
|
||||
}
|
||||
|
||||
export interface AbacusDisplayContextType {
|
||||
@@ -26,7 +28,9 @@ const DEFAULT_CONFIG: AbacusDisplayConfig = {
|
||||
beadShape: 'diamond',
|
||||
hideInactiveBeads: false,
|
||||
coloredNumerals: false,
|
||||
scaleFactor: 1.0 // Normalized for display, can be scaled per component
|
||||
scaleFactor: 1.0, // Normalized for display, can be scaled per component
|
||||
soundEnabled: true,
|
||||
soundVolume: 0.8
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'soroban-abacus-display-config'
|
||||
@@ -50,7 +54,11 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
|
||||
coloredNumerals: typeof parsed.coloredNumerals === 'boolean'
|
||||
? parsed.coloredNumerals : DEFAULT_CONFIG.coloredNumerals,
|
||||
scaleFactor: typeof parsed.scaleFactor === 'number' && parsed.scaleFactor > 0
|
||||
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor
|
||||
? parsed.scaleFactor : DEFAULT_CONFIG.scaleFactor,
|
||||
soundEnabled: typeof parsed.soundEnabled === 'boolean'
|
||||
? parsed.soundEnabled : DEFAULT_CONFIG.soundEnabled,
|
||||
soundVolume: typeof parsed.soundVolume === 'number' && parsed.soundVolume >= 0 && parsed.soundVolume <= 1
|
||||
? parsed.soundVolume : DEFAULT_CONFIG.soundVolume
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
39
apps/web/src/contexts/GameThemeContext.tsx
Normal file
39
apps/web/src/contexts/GameThemeContext.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
|
||||
export interface GameTheme {
|
||||
gameName: string
|
||||
backgroundColor: string
|
||||
}
|
||||
|
||||
interface GameThemeContextType {
|
||||
theme: GameTheme | null
|
||||
setTheme: (theme: GameTheme | null) => void
|
||||
isHydrated: boolean
|
||||
}
|
||||
|
||||
const GameThemeContext = createContext<GameThemeContextType | undefined>(undefined)
|
||||
|
||||
export function GameThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<GameTheme | null>(null)
|
||||
const [isHydrated, setIsHydrated] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setIsHydrated(true)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<GameThemeContext.Provider value={{ theme, setTheme, isHydrated }}>
|
||||
{children}
|
||||
</GameThemeContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useGameTheme() {
|
||||
const context = useContext(GameThemeContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useGameTheme must be used within a GameThemeProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
21
apps/web/src/middleware.ts
Normal file
21
apps/web/src/middleware.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
// Add pathname to headers so Server Components can access it
|
||||
const response = NextResponse.next()
|
||||
response.headers.set('x-pathname', request.nextUrl.pathname)
|
||||
return response
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: [
|
||||
/*
|
||||
* Match all request paths except for the ones starting with:
|
||||
* - api (API routes)
|
||||
* - _next/static (static files)
|
||||
* - _next/image (image optimization files)
|
||||
* - favicon.ico (favicon file)
|
||||
*/
|
||||
'/((?!api|_next/static|_next/image|favicon.ico).*)',
|
||||
],
|
||||
}
|
||||
@@ -1,3 +1,86 @@
|
||||
# [1.4.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.3.0...abacus-react-v1.4.0) (2025-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* export missing hooks and types from @soroban/abacus-react package ([423ba55](https://github.com/antialias/soroban-abacus-flashcards/commit/423ba5535023928f1e0198b2bd01c3c6cf7ee848))
|
||||
* migrate viewport from metadata to separate viewport export ([1fe12c4](https://github.com/antialias/soroban-abacus-flashcards/commit/1fe12c4837b1229d0f0ab93c55d0ffb504eb8721))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add middleware for pathname header support in [@nav](https://github.com/nav) fallback ([b7e7c4b](https://github.com/antialias/soroban-abacus-flashcards/commit/b7e7c4beff1e37e90e9e20a890c5af7a134a7fca))
|
||||
* implement [@nav](https://github.com/nav) parallel routes for game name display in mini navigation ([885fc72](https://github.com/antialias/soroban-abacus-flashcards/commit/885fc725dc0bb41bbb5e500c2c907c6182192854))
|
||||
|
||||
# [1.3.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.2.0...abacus-react-v1.3.0) (2025-09-29)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensure game names persist in navigation on page reload ([9191b12](https://github.com/antialias/soroban-abacus-flashcards/commit/9191b124934b9a5577a91f67e8fb6f83b173cc4f))
|
||||
* implement route-based theme detection for page reload persistence ([3dcff2f](https://github.com/antialias/soroban-abacus-flashcards/commit/3dcff2ff888558d7b746a732cfd53a1897c2b1df))
|
||||
* improve navigation chrome background color extraction from gradients ([00bfcbc](https://github.com/antialias/soroban-abacus-flashcards/commit/00bfcbcdee28d63094c09a4ae0359789ebcf4a22))
|
||||
* resolve SSR/client hydration mismatch for themed navigation ([301e65d](https://github.com/antialias/soroban-abacus-flashcards/commit/301e65dfa66d0de6b6efbbfbd09b717308ab57f1))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* complete themed navigation system with game-specific chrome ([0a4bf17](https://github.com/antialias/soroban-abacus-flashcards/commit/0a4bf1765cbd86bf6f67fb3b99c577cfe3cce075))
|
||||
* implement cozy sound effects for abacus with variable intensity ([cea5fad](https://github.com/antialias/soroban-abacus-flashcards/commit/cea5fadbe4b4d5ae9e0ee988e9b1c4db09f21ba6))
|
||||
|
||||
# [1.2.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.3...abacus-react-v1.2.0) (2025-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** add debugging and explicit authentication for npm publish ([b82e9bb](https://github.com/antialias/soroban-abacus-flashcards/commit/b82e9bb9d6adf3793065067f96c6fbbfd1a78bca))
|
||||
* **abacus-react:** add packages: write permission for GitHub Packages publishing ([8e16487](https://github.com/antialias/soroban-abacus-flashcards/commit/8e1648737de9305f82872cb9b86b98b5045f77a7))
|
||||
* add missing GameThemeContext file for themed navigation ([d4fbdd1](https://github.com/antialias/soroban-abacus-flashcards/commit/d4fbdd14630e2f2fcdbc0de23ccc4ccd9eb74b48))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* implement game theming system with context-based navigation chrome ([3fa11c4](https://github.com/antialias/soroban-abacus-flashcards/commit/3fa11c4fbcbeabeb3bdd0db38374fb9a13cbb754))
|
||||
|
||||
## [1.1.3](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.2...abacus-react-v1.1.3) (2025-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** force npm to use GitHub Packages registry ([5e6c901](https://github.com/antialias/soroban-abacus-flashcards/commit/5e6c901f73a68b60ec05f19c4a991ca8affc1589))
|
||||
|
||||
## [1.1.2](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.1...abacus-react-v1.1.2) (2025-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** improve workspace dependency cleanup and add validation ([11fd6f9](https://github.com/antialias/soroban-abacus-flashcards/commit/11fd6f9b3deb1122d3788a7e0698de891eeb0f3a))
|
||||
|
||||
## [1.1.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.1.0...abacus-react-v1.1.1) (2025-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** resolve workspace dependencies before npm publish ([834b062](https://github.com/antialias/soroban-abacus-flashcards/commit/834b062b2d22356b9d96bb9c3c444eccaa51d793))
|
||||
|
||||
# [1.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.0.0...abacus-react-v1.1.0) (2025-09-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **abacus-react:** improve publishing workflow with better version sync ([7a4ecd2](https://github.com/antialias/soroban-abacus-flashcards/commit/7a4ecd2b5970ed8b6bfde8938b36917f8e7a7176))
|
||||
* add testing mode for on-screen keyboard and fix toggle functionality ([904074c](https://github.com/antialias/soroban-abacus-flashcards/commit/904074ca821b62cd6b1e129354eb36c5dd4b5e7f))
|
||||
* redesign matching game setup page for StandardGameLayout ([cc1f27f](https://github.com/antialias/soroban-abacus-flashcards/commit/cc1f27f0f82256f9344531814e8b965fa547d555))
|
||||
* update memory pairs game to use StandardGameLayout ([8df76c0](https://github.com/antialias/soroban-abacus-flashcards/commit/8df76c08fdf4108b88ce95de252cb8bd559fc5e4))
|
||||
* update memory quiz to use StandardGameLayout ([3f86163](https://github.com/antialias/soroban-abacus-flashcards/commit/3f86163c142e577a64adfb3bf262656d2e100ced))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* create StandardGameLayout for perfect viewport sizing ([728a920](https://github.com/antialias/soroban-abacus-flashcards/commit/728a92076a6ac9ef71f0c75d2e9503575881130a))
|
||||
* implement innovative dynamic two-panel layout for on-screen keyboard ([4bb8f6d](https://github.com/antialias/soroban-abacus-flashcards/commit/4bb8f6daf1f3eecb5cbaf31bf4057f43e43aeb07))
|
||||
* implement simple fixed bottom keyboard bar ([9ef72d7](https://github.com/antialias/soroban-abacus-flashcards/commit/9ef72d7e88a6a9b30cfd7a7d3944197cc1e0037a))
|
||||
|
||||
# 1.0.0 (2025-09-28)
|
||||
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface AbacusDisplayConfig {
|
||||
animated: boolean
|
||||
interactive: boolean
|
||||
gestures: boolean
|
||||
soundEnabled: boolean
|
||||
soundVolume: number
|
||||
}
|
||||
|
||||
export interface AbacusDisplayContextType {
|
||||
@@ -37,7 +39,9 @@ const DEFAULT_CONFIG: AbacusDisplayConfig = {
|
||||
showNumbers: true,
|
||||
animated: true,
|
||||
interactive: false,
|
||||
gestures: false
|
||||
gestures: false,
|
||||
soundEnabled: true,
|
||||
soundVolume: 0.8
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'soroban-abacus-display-config'
|
||||
@@ -71,7 +75,11 @@ function loadConfigFromStorage(): AbacusDisplayConfig {
|
||||
interactive: typeof parsed.interactive === 'boolean'
|
||||
? parsed.interactive : DEFAULT_CONFIG.interactive,
|
||||
gestures: typeof parsed.gestures === 'boolean'
|
||||
? parsed.gestures : DEFAULT_CONFIG.gestures
|
||||
? parsed.gestures : DEFAULT_CONFIG.gestures,
|
||||
soundEnabled: typeof parsed.soundEnabled === 'boolean'
|
||||
? parsed.soundEnabled : DEFAULT_CONFIG.soundEnabled,
|
||||
soundVolume: typeof parsed.soundVolume === 'number' && parsed.soundVolume >= 0 && parsed.soundVolume <= 1
|
||||
? parsed.soundVolume : DEFAULT_CONFIG.soundVolume
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSpring, animated, config, to } from '@react-spring/web';
|
||||
import { useDrag } from '@use-gesture/react';
|
||||
import NumberFlow from '@number-flow/react';
|
||||
import { useAbacusConfig, getDefaultAbacusConfig } from './AbacusContext';
|
||||
import { playBeadSound } from './soundManager';
|
||||
|
||||
// Types
|
||||
export interface BeadConfig {
|
||||
@@ -1335,7 +1336,9 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
animated: animated ?? contextConfig.animated,
|
||||
interactive: interactive ?? contextConfig.interactive,
|
||||
gestures: gestures ?? contextConfig.gestures,
|
||||
showNumbers: showNumbers ?? contextConfig.showNumbers
|
||||
showNumbers: showNumbers ?? contextConfig.showNumbers,
|
||||
soundEnabled: contextConfig.soundEnabled,
|
||||
soundVolume: contextConfig.soundVolume
|
||||
};
|
||||
// Calculate effective columns first, without depending on columnStates
|
||||
const effectiveColumns = useMemo(() => {
|
||||
@@ -1435,6 +1438,21 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate how many beads will change to determine sound intensity
|
||||
const currentState = getPlaceState(bead.placeValue);
|
||||
let beadMovementCount = 1; // Default for single bead movements
|
||||
|
||||
if (bead.type === 'earth') {
|
||||
if (bead.active) {
|
||||
// Deactivating: count beads from this position to end of active beads
|
||||
beadMovementCount = currentState.earthActive - bead.position;
|
||||
} else {
|
||||
// Activating: count beads from current active count to this position + 1
|
||||
beadMovementCount = (bead.position + 1) - currentState.earthActive;
|
||||
}
|
||||
}
|
||||
// Heaven bead always moves just 1 bead
|
||||
|
||||
// Create enhanced event object
|
||||
const beadClickEvent: BeadClickEvent = {
|
||||
bead,
|
||||
@@ -1452,13 +1470,33 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
// Legacy callback for backward compatibility
|
||||
onClick?.(bead);
|
||||
|
||||
// Play sound if enabled with intensity based on bead movement count
|
||||
if (finalConfig.soundEnabled) {
|
||||
playBeadSound(finalConfig.soundVolume, beadMovementCount);
|
||||
}
|
||||
|
||||
// Toggle the bead - NO MORE EFFECTIVECOLUMNS THREADING!
|
||||
toggleBead(bead);
|
||||
}, [onClick, callbacks, toggleBead, disabledColumns, disabledBeads]);
|
||||
}, [onClick, callbacks, toggleBead, disabledColumns, disabledBeads, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState]);
|
||||
|
||||
const handleGestureToggle = useCallback((bead: BeadConfig, direction: 'activate' | 'deactivate') => {
|
||||
const currentState = getPlaceState(bead.placeValue);
|
||||
|
||||
// Calculate bead movement count for sound intensity
|
||||
let beadMovementCount = 1;
|
||||
if (bead.type === 'earth') {
|
||||
if (direction === 'activate') {
|
||||
beadMovementCount = Math.max(0, (bead.position + 1) - currentState.earthActive);
|
||||
} else {
|
||||
beadMovementCount = Math.max(0, currentState.earthActive - bead.position);
|
||||
}
|
||||
}
|
||||
|
||||
// Play sound if enabled with intensity
|
||||
if (finalConfig.soundEnabled) {
|
||||
playBeadSound(finalConfig.soundVolume, beadMovementCount);
|
||||
}
|
||||
|
||||
if (bead.type === 'heaven') {
|
||||
// Heaven bead: directly set the state based on direction
|
||||
const newHeavenActive = direction === 'activate';
|
||||
@@ -1484,7 +1522,7 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
earthActive: newEarthActive
|
||||
});
|
||||
}
|
||||
}, [getPlaceState, setPlaceState]);
|
||||
}, [getPlaceState, setPlaceState, finalConfig.soundEnabled, finalConfig.soundVolume]);
|
||||
|
||||
// Place value editing - FRESH IMPLEMENTATION
|
||||
const [activeColumn, setActiveColumn] = React.useState<number | null>(null);
|
||||
@@ -1502,12 +1540,28 @@ export const AbacusReact: React.FC<AbacusConfig> = ({
|
||||
|
||||
// Convert column index to place value
|
||||
const placeValue = (effectiveColumns - 1 - columnIndex) as ValidPlaceValues;
|
||||
const currentState = getPlaceState(placeValue);
|
||||
|
||||
// Calculate how many beads change for sound intensity
|
||||
const currentValue = (currentState.heavenActive ? 5 : 0) + currentState.earthActive;
|
||||
const newHeavenActive = digit >= 5;
|
||||
const newEarthActive = digit % 5;
|
||||
|
||||
// Count bead movements: heaven bead + earth bead changes
|
||||
let beadMovementCount = 0;
|
||||
if (currentState.heavenActive !== newHeavenActive) beadMovementCount += 1;
|
||||
beadMovementCount += Math.abs(currentState.earthActive - newEarthActive);
|
||||
|
||||
// Play sound if enabled with intensity based on bead changes
|
||||
if (finalConfig.soundEnabled && beadMovementCount > 0) {
|
||||
playBeadSound(finalConfig.soundVolume, beadMovementCount);
|
||||
}
|
||||
|
||||
setPlaceState(placeValue, {
|
||||
heavenActive: digit >= 5,
|
||||
earthActive: digit % 5
|
||||
heavenActive: newHeavenActive,
|
||||
earthActive: newEarthActive
|
||||
});
|
||||
}, [setPlaceState, effectiveColumns]);
|
||||
}, [setPlaceState, effectiveColumns, finalConfig.soundEnabled, finalConfig.soundVolume, getPlaceState]);
|
||||
|
||||
// Keyboard handler - only active when interactive
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -3,4 +3,18 @@ export type {
|
||||
AbacusConfig,
|
||||
BeadConfig,
|
||||
AbacusDimensions
|
||||
} from './AbacusReact';
|
||||
} from './AbacusReact';
|
||||
|
||||
export {
|
||||
useAbacusConfig,
|
||||
useAbacusDisplay,
|
||||
getDefaultAbacusConfig,
|
||||
AbacusDisplayProvider
|
||||
} from './AbacusContext';
|
||||
export type {
|
||||
ColorScheme,
|
||||
BeadShape,
|
||||
ColorPalette,
|
||||
AbacusDisplayConfig,
|
||||
AbacusDisplayContextType
|
||||
} from './AbacusContext';
|
||||
124
packages/abacus-react/src/soundManager.ts
Normal file
124
packages/abacus-react/src/soundManager.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
|
||||
// AudioContext manager for generating abacus bead click sounds
|
||||
let audioCtx: AudioContext | null = null
|
||||
|
||||
/**
|
||||
* Gets or creates the global AudioContext instance
|
||||
* SSR-safe - returns null in server environment
|
||||
*/
|
||||
export function getAudioContext(): AudioContext | null {
|
||||
// SSR guard - only initialize on client
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
if (!audioCtx) {
|
||||
// Support older Safari versions with webkit prefix
|
||||
const AudioCtxClass = window.AudioContext || (window as any).webkitAudioContext
|
||||
try {
|
||||
audioCtx = new AudioCtxClass()
|
||||
} catch (e) {
|
||||
console.warn('AudioContext could not be initialized:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
return audioCtx
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a realistic "cozy" bead click sound using Web Audio API
|
||||
* Generates sound on-the-fly with no external assets
|
||||
* @param volume - Volume level from 0.0 to 1.0
|
||||
* @param intensity - Number of beads moved (1-5) to adjust sound heft
|
||||
*/
|
||||
export function playBeadSound(volume: number, intensity: number = 1): void {
|
||||
const ctx = getAudioContext()
|
||||
if (!ctx) return // No audio context available (SSR or initialization failed)
|
||||
|
||||
// Clamp volume to valid range
|
||||
const clampedVolume = Math.max(0, Math.min(1, volume))
|
||||
if (clampedVolume === 0) return // Skip if volume is zero
|
||||
|
||||
// Clamp intensity to reasonable range (1-5 beads)
|
||||
const clampedIntensity = Math.max(1, Math.min(5, intensity))
|
||||
|
||||
const now = ctx.currentTime
|
||||
|
||||
// Calculate sound characteristics based on intensity
|
||||
const intensityFactor = Math.sqrt(clampedIntensity) // Square root for natural scaling
|
||||
const volumeMultiplier = 0.8 + (intensityFactor - 1) * 0.3 // 0.8 to 1.4 range
|
||||
const durationMultiplier = 0.8 + (intensityFactor - 1) * 0.4 // Longer decay for more beads
|
||||
const lowFreqBoost = 1 + (intensityFactor - 1) * 0.3 // Lower frequency for more heft
|
||||
|
||||
// Create gain node for volume envelope
|
||||
const gainNode = ctx.createGain()
|
||||
gainNode.connect(ctx.destination)
|
||||
|
||||
// Create primary oscillator for the warm "thock" sound
|
||||
const lowOsc = ctx.createOscillator()
|
||||
lowOsc.type = 'triangle' // Warmer than sine, less harsh than square
|
||||
lowOsc.frequency.setValueAtTime(220 / lowFreqBoost, now) // Lower frequency for more heft
|
||||
|
||||
// Create secondary oscillator for the sharp "click" component
|
||||
const highOsc = ctx.createOscillator()
|
||||
highOsc.type = 'sine'
|
||||
highOsc.frequency.setValueAtTime(1400, now) // Higher frequency for the tap clarity
|
||||
|
||||
// Optional third oscillator for extra richness on multi-bead movements
|
||||
let richOsc: OscillatorNode | null = null
|
||||
let richGain: GainNode | null = null
|
||||
if (clampedIntensity > 2) {
|
||||
richOsc = ctx.createOscillator()
|
||||
richOsc.type = 'triangle'
|
||||
richOsc.frequency.setValueAtTime(110, now) // Sub-harmonic for richness
|
||||
richGain = ctx.createGain()
|
||||
richGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.2 * (intensityFactor - 1), now)
|
||||
richOsc.connect(richGain)
|
||||
richGain.connect(gainNode)
|
||||
}
|
||||
|
||||
// Create separate gain nodes for mixing the two main components
|
||||
const lowGain = ctx.createGain()
|
||||
const highGain = ctx.createGain()
|
||||
|
||||
lowGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.7, now) // Primary component
|
||||
highGain.gain.setValueAtTime(clampedVolume * volumeMultiplier * 0.3, now) // Secondary accent
|
||||
|
||||
// Connect oscillators through their gain nodes to the main envelope
|
||||
lowOsc.connect(lowGain)
|
||||
highOsc.connect(highGain)
|
||||
lowGain.connect(gainNode)
|
||||
highGain.connect(gainNode)
|
||||
|
||||
// Calculate duration based on intensity
|
||||
const baseDuration = 0.08 // 80ms base duration
|
||||
const actualDuration = baseDuration * durationMultiplier
|
||||
|
||||
// Create exponential decay envelope for natural sound
|
||||
gainNode.gain.setValueAtTime(1.0, now)
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, now + actualDuration)
|
||||
|
||||
// Start oscillators
|
||||
lowOsc.start(now)
|
||||
highOsc.start(now)
|
||||
if (richOsc) richOsc.start(now)
|
||||
|
||||
// Stop oscillators at end of envelope
|
||||
const stopTime = now + actualDuration
|
||||
lowOsc.stop(stopTime)
|
||||
highOsc.stop(stopTime)
|
||||
if (richOsc) richOsc.stop(stopTime)
|
||||
|
||||
// Cleanup: disconnect nodes when sound finishes to prevent memory leaks
|
||||
lowOsc.onended = () => {
|
||||
lowOsc.disconnect()
|
||||
highOsc.disconnect()
|
||||
lowGain.disconnect()
|
||||
highGain.disconnect()
|
||||
gainNode.disconnect()
|
||||
if (richOsc && richGain) {
|
||||
richOsc.disconnect()
|
||||
richGain.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user