Compare commits

...

7 Commits

Author SHA1 Message Date
semantic-release-bot
6c0bf7b0f7 chore(abacus-react): release v1.5.1 [skip ci]
## [1.5.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.5.0...abacus-react-v1.5.1) (2025-09-29)

### Bug Fixes

* resolve JSX parsing error with emoji in guide page ([bf046c9](bf046c999b))
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](a935e5aed8))
* restore navigation to all pages using PageWithNav ([183706d](183706dade))
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](ae4b71b986))
2025-09-29 18:23:50 +00:00
Thomas Hallock
ae4b71b986 fix: update workflow to support Personal Access Token for GitHub Packages publishing auth 2025-09-29 13:23:18 -05:00
Thomas Hallock
bf046c999b fix: resolve JSX parsing error with emoji in guide page
Changed guide page emoji from 📚 to 📖 to resolve syntax error that was
preventing the dev server from compiling. The original emoji was causing
JSX parser issues with 'Unexpected token PageWithNav' error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
183706dade fix: restore navigation to all pages using PageWithNav
After removing @nav parallel routes, all pages were missing navigation.
Added PageWithNav wrapper to main application pages:

- Homepage: 🧮 Soroban Flashcards
- Games listing: 🕹️ Soroban Arcade
- Interactive guide: 📚 Interactive Guide
- Create flashcards:  Create Flashcards
- Game pages already had navigation from previous commit

Each page now has appropriate nav title and emoji displayed in the
mini navigation bar. All navigation is working correctly again.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
54ff20c755 refactor: completely remove @nav parallel routes and simplify navigation
- Remove entire src/app/@nav directory and all parallel route files
- Delete complex AppNav component that handled route-based nav detection
- Update layout.tsx to remove nav slot parameter entirely
- Create simple PageWithNav component that takes title/emoji as props
- Update matching and memory-quiz games to use PageWithNav directly
- Each page now controls its own navigation - dead simple and direct

This eliminates the over-engineered parallel routes approach in favor of
straightforward React prop passing. Much easier to understand and maintain.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
7a3e34b4fa refactor: streamline UI and remove duplicate information displays
- Remove redundant "matched pairs" progress from MemoryGrid since it's shown in PlayerStatusBar
- Drastically reduce oversized padding and styling in PlayerStatusBar components
- Simplify single player mode to clean, compact layout without excessive animations
- Remove duplicate game progress info from bottom of multiplayer view
- Scale down emoji sizes and reduce dramatic visual effects
- Keep only essential information in each component to recover screen real estate

The UI now shows game progress information in one place only and has much
more reasonable sizing throughout.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
Thomas Hallock
a935e5aed8 fix: resolve TypeScript errors in PlayerStatusBar component
- Fix duplicate color properties in single player mode styling
- Fix className prop passing to css() function in both single and multiplayer modes
- Replace undefined 'super-bounce' animation with 'gentle-bounce'

The make-plural pluralization integration is working correctly with proper
display of "1 pair" vs "2 pairs", "1 move" vs "3 moves", etc.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-29 13:21:45 -05:00
26 changed files with 179 additions and 244 deletions

View File

@@ -164,7 +164,11 @@
"Bash(open http://localhost:3003/arcade/matching)",
"Bash(open http://localhost:3000)",
"Bash(open http://localhost:3003/games/memory-quiz)",
"Bash(open http://localhost:3001)"
"Bash(open http://localhost:3001)",
"Bash(open http://localhost:3001/arcade)",
"Bash(open http://localhost:6006)",
"Bash(open http://localhost:3002/games/matching)",
"Bash(open http://localhost:3002/create)"
],
"deny": [],
"ask": []

View File

@@ -72,7 +72,7 @@ jobs:
- name: Configure npm for GitHub Packages
working-directory: packages/abacus-react
run: |
echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" > .npmrc
echo "//npm.pkg.github.com/:_authToken=${{ secrets.NPM_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" > .npmrc
echo "@soroban:registry=https://npm.pkg.github.com" >> .npmrc
echo "registry=https://npm.pkg.github.com" >> .npmrc
@@ -147,7 +147,7 @@ jobs:
# 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
NPM_CONFIG_USERCONFIG=.npmrc NODE_AUTH_TOKEN="${{ secrets.NPM_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}" npm publish --registry=https://npm.pkg.github.com
else
echo "No new abacus-react version tag found, skipping publish"
fi

View File

@@ -48,6 +48,7 @@
"@types/jsdom": "^21.1.7",
"emojibase-data": "^16.0.3",
"lucide-react": "^0.294.0",
"make-plural": "^7.4.0",
"next": "^14.2.32",
"python-bridge": "^1.1.0",
"react": "^18.2.0",

View File

@@ -1,3 +0,0 @@
export default function CreateNav() {
return null
}

View File

@@ -1,3 +0,0 @@
export default function DefaultNav() {
return null // No navigation content for routes without specific @nav slots
}

View File

@@ -1,14 +0,0 @@
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>
)
}

View File

@@ -1,14 +0,0 @@
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>
)
}

View File

@@ -1,3 +0,0 @@
export default function GamesNav() {
return null
}

View File

@@ -1,3 +0,0 @@
export default function GuideNav() {
return null
}

View File

@@ -1,3 +0,0 @@
export default function HomeNav() {
return null
}

View File

@@ -4,6 +4,7 @@ import { useState } from 'react'
import { useForm } from '@tanstack/react-form'
import { css } from '../../../styled-system/css'
import { container, stack, hstack, grid } from '../../../styled-system/patterns'
import { PageWithNav } from '@/components/PageWithNav'
import Link from 'next/link'
import { ConfigurationForm } from '@/components/ConfigurationForm'
import { ConfigurationFormWithoutGenerate } from '@/components/ConfigurationFormWithoutGenerate'
@@ -185,7 +186,8 @@ export default function CreatePage() {
}
return (
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
<PageWithNav navTitle="Create Flashcards" navEmoji="✨">
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
{/* Main Content */}
<div className={container({ maxW: '7xl', px: '4', py: '8' })}>
@@ -378,6 +380,7 @@ export default function CreatePage() {
</div>
)}
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -5,6 +5,7 @@ import { useGameMode } from '../../../../contexts/GameModeContext'
import { MemoryGrid } from './MemoryGrid'
import { PlayerStatusBar } from './PlayerStatusBar'
import { css } from '../../../../../styled-system/css'
import { pluralizeWord } from '../../../../utils/pluralization'
export function GamePhase() {
const { state, resetGame, activePlayers } = useMemoryPairs()
@@ -54,7 +55,7 @@ export function GamePhase() {
{state.gameMode === 'multiplayer' && (
<>
<span className={css({ color: 'gray.400' })}></span>
<span> {activePlayers.length}P</span>
<span> {activePlayers.length}{pluralizeWord(activePlayers.length, 'P')}</span>
</>
)}
</div>

View File

@@ -5,6 +5,7 @@ import { useMemoryPairs } from '../context/MemoryPairsContext'
import { GameCard } from './GameCard'
import { getGridConfiguration } from '../utils/cardGeneration'
import { css } from '../../../../../styled-system/css'
import { gamePlurals } from '../../../../utils/pluralization'
// Helper function to calculate optimal grid dimensions
function calculateOptimalGrid(cards: number, aspectRatio: number, config: any) {
@@ -102,37 +103,6 @@ export function MemoryGrid() {
gap: { base: '12px', sm: '16px', md: '20px' }
})}>
{/* Compact Game Progress */}
<div className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: { base: '12px', sm: '16px', md: '24px' },
padding: { base: '8px 12px', sm: '10px 16px', md: '12px 20px' },
background: 'linear-gradient(135deg, rgba(255,255,255,0.95), rgba(248,250,252,0.95))',
borderRadius: '12px',
boxShadow: '0 2px 8px rgba(0,0,0,0.08)',
border: '1px solid rgba(255,255,255,0.9)',
fontSize: { base: '14px', sm: '15px', md: '16px' },
fontWeight: 'bold',
color: 'gray.700'
})}>
<span className={css({ color: 'blue.600' })}>
{state.matchedPairs} matched
</span>
<span className={css({ color: 'gray.400' })}></span>
<span className={css({ color: 'purple.600' })}>
{state.moves} moves
</span>
{state.gameMode === 'single' && (
<>
<span className={css({ color: 'gray.400' })}></span>
<span className={css({ color: 'green.600' })}>
{Math.round((state.matchedPairs / state.totalPairs) * 100)}% complete
</span>
</>
)}
</div>
{/* Cards Grid - Consistent r×c Layout */}
<div

View File

@@ -1,6 +1,7 @@
import type { Meta, StoryObj } from '@storybook/react'
import React, { useEffect } from 'react'
import { css } from '../../../../../styled-system/css'
import { gamePlurals } from '../../../../utils/pluralization'
// Inject the celebration animations for Storybook
const celebrationAnimations = `
@@ -267,7 +268,7 @@ const MockPlayerCard = ({
color: isCurrentPlayer ? playerColor : 'gray.500',
fontWeight: isCurrentPlayer ? 'black' : 'semibold'
})}>
{score} pairs
{gamePlurals.pair(score)}
{isCurrentPlayer && (
<span className={css({
color: 'red.600',

View File

@@ -4,6 +4,7 @@ import { css } from '../../../../../styled-system/css'
import { useGameMode } from '../../../../contexts/GameModeContext'
import { useUserProfile } from '../../../../contexts/UserProfileContext'
import { useMemoryPairs } from '../context/MemoryPairsContext'
import { gamePlurals } from '../../../../utils/pluralization'
interface PlayerStatusBarProps {
className?: string
@@ -38,82 +39,44 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
}
if (activePlayers.length <= 1) {
// Epic single player mode
// Simple single player indicator
return (
<div className={css({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
background: 'linear-gradient(135deg, #667eea, #764ba2)',
rounded: '2xl',
p: { base: '4', md: '6' },
border: '3px solid',
borderColor: 'purple.300',
mb: { base: '3', md: '4' },
boxShadow: '0 0 0 2px white, 0 0 0 6px rgba(102, 126, 234, 0.4), 0 12px 32px rgba(0,0,0,0.2)',
animation: 'gentle-pulse 3s ease-in-out infinite',
position: 'relative'
}, className)}>
{/* Subtle glow effect */}
<div className={css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'linear-gradient(45deg, transparent 40%, rgba(255,255,255,0.1) 50%, transparent 60%)',
pointerEvents: 'none'
})} />
background: 'white',
rounded: 'lg',
p: { base: '2', md: '3' },
border: '2px solid',
borderColor: 'blue.200',
mb: { base: '2', md: '3' },
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
})}
className={className}>
<div className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '4', md: '6' },
position: 'relative',
zIndex: 2
gap: { base: '2', md: '3' }
})}>
<div className={css({
fontSize: { base: '3xl', md: '5xl' },
animation: 'gentle-sway 2s ease-in-out infinite',
textShadow: '0 0 20px currentColor',
transform: 'scale(1.2)'
fontSize: { base: 'xl', md: '2xl' }
})}>
{activePlayers[0]?.displayEmoji || '🚀'}
</div>
<div>
<div className={css({
fontSize: { base: 'lg', md: 'xl' },
fontWeight: 'black',
color: 'white',
color: 'white',
textShadow: '0 0 15px rgba(255,255,255,0.8)'
})}>
{activePlayers[0]?.displayName || 'Player 1'}
</div>
<div className={css({
fontSize: { base: 'sm', md: 'md' },
color: 'rgba(255,255,255,0.9)',
fontWeight: 'bold',
color: 'rgba(255,255,255,0.9)'
})}>
Solo Challenge {state.moves} moves
</div>
</div>
{/* Epic progress indicator */}
<div className={css({
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
color: 'white',
px: { base: '3', md: '4' },
py: { base: '2', md: '3' },
rounded: 'xl',
fontSize: { base: 'md', md: 'lg' },
fontWeight: 'black',
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
animation: 'gentle-bounce 2s ease-in-out infinite',
textShadow: '0 0 10px rgba(255,255,255,0.8)'
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'bold',
color: 'gray.700'
})}>
{state.matchedPairs}/{state.totalPairs}
{activePlayers[0]?.displayName || 'Player 1'}
</div>
<div className={css({
fontSize: { base: 'xs', md: 'sm' },
color: 'blue.600',
fontWeight: 'medium'
})}>
{gamePlurals.pair(state.matchedPairs)} of {state.totalPairs} {gamePlurals.move(state.moves)}
</div>
</div>
</div>
@@ -129,7 +92,8 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
border: '2px solid',
borderColor: 'gray.200',
mb: { base: '3', md: '4' }
}, className)}>
})}
className={className}>
<div className={css({
display: 'grid',
gridTemplateColumns: activePlayers.length <= 2
@@ -151,8 +115,8 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
className={css({
display: 'flex',
alignItems: 'center',
gap: { base: '3', md: '4' },
p: isCurrentPlayer ? { base: '4', md: '6' } : { base: '2', md: '3' },
gap: { base: '2', md: '3' },
p: isCurrentPlayer ? { base: '3', md: '4' } : { base: '2', md: '2' },
rounded: isCurrentPlayer ? '2xl' : 'lg',
background: isCurrentPlayer
? `linear-gradient(135deg, ${player.color || '#3b82f6'}15, ${player.color || '#3b82f6'}25, ${player.color || '#3b82f6'}15)`
@@ -216,7 +180,7 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
{/* Living, breathing player emoji */}
<div className={css({
fontSize: isCurrentPlayer ? { base: '3xl', md: '5xl' } : { base: 'lg', md: 'xl' },
fontSize: isCurrentPlayer ? { base: '2xl', md: '3xl' } : { base: 'lg', md: 'xl' },
flexShrink: 0,
animation: isCurrentPlayer
? 'float 3s ease-in-out infinite'
@@ -253,7 +217,7 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
fontWeight: isCurrentPlayer ? 'black' : 'semibold',
animation: 'none'
})}>
{player.score} pairs
{gamePlurals.pair(player.score)}
{isCurrentPlayer && (
<span className={css({
color: 'red.600',
@@ -281,44 +245,24 @@ export function PlayerStatusBar({ className }: PlayerStatusBarProps) {
</div>
</div>
{/* Epic score display for current player */}
{/* Simple score display for current player */}
{isCurrentPlayer && (
<div className={css({
background: 'linear-gradient(135deg, #ff6b6b, #ee5a24)',
background: 'blue.500',
color: 'white',
px: { base: '3', md: '4' },
py: { base: '2', md: '3' },
rounded: 'xl',
fontSize: { base: 'lg', md: 'xl' },
fontWeight: 'black',
boxShadow: '0 4px 15px rgba(238, 90, 36, 0.4)',
animation: 'super-bounce 1.5s ease-in-out infinite',
textShadow: '0 0 10px rgba(255,255,255,0.8)'
px: { base: '2', md: '3' },
py: { base: '1', md: '2' },
rounded: 'md',
fontSize: { base: 'sm', md: 'md' },
fontWeight: 'bold'
})}>
{player.score}
{player.score}
</div>
)}
</div>
)
})}
</div>
{/* Game progress */}
<div className={css({
mt: '3',
pt: '2',
borderTop: '1px solid',
borderColor: 'gray.300',
textAlign: 'center'
})}>
<div className={css({
fontSize: { base: 'xs', md: 'sm' },
color: 'gray.600',
fontWeight: 'medium'
})}>
{state.matchedPairs} of {state.totalPairs} pairs found {state.moves} total moves
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,13 @@
import { PageWithNav } from '@/components/PageWithNav'
import { MemoryPairsProvider } from './context/MemoryPairsContext'
import { MemoryPairsGame } from './components/MemoryPairsGame'
export default function MatchingPage() {
return (
<MemoryPairsProvider>
<MemoryPairsGame />
</MemoryPairsProvider>
<PageWithNav navTitle="Memory Pairs" navEmoji="🧩">
<MemoryPairsProvider>
<MemoryPairsGame />
</MemoryPairsProvider>
</PageWithNav>
)
}

View File

@@ -6,7 +6,7 @@ import { css } from '../../../../styled-system/css'
import { AbacusReact } from '@soroban/abacus-react'
import { useAbacusConfig } from '@soroban/abacus-react'
import { isPrefix } from '../../../lib/memory-quiz-utils'
import { StandardGameLayout } from '../../../components/StandardGameLayout'
import { PageWithNav } from '@/components/PageWithNav'
interface QuizCard {
@@ -1733,7 +1733,7 @@ export default function MemoryQuizPage() {
}, [state.prefixAcceptanceTimeout])
return (
<StandardGameLayout>
<PageWithNav navTitle="Memory Lightning" navEmoji="🧠">
<style dangerouslySetInnerHTML={{ __html: globalAnimations }} />
<div
@@ -1741,14 +1741,16 @@ export default function MemoryQuizPage() {
flex: 1,
display: 'flex',
flexDirection: 'column',
overflow: 'auto'
overflow: 'auto',
padding: '20px 8px',
minHeight: '100vh',
background: 'linear-gradient(135deg, #f8fafc, #e2e8f0)'
}}
>
<div
style={{
maxWidth: '100%',
margin: '0 auto',
padding: '0 8px',
flex: 1,
display: 'flex',
flexDirection: 'column'
@@ -1800,6 +1802,6 @@ export default function MemoryQuizPage() {
</div>
</div>
</div>
</StandardGameLayout>
</PageWithNav>
)
}

View File

@@ -5,6 +5,7 @@ import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { css } from '../../../styled-system/css'
import { grid } from '../../../styled-system/patterns'
import { PageWithNav } from '@/components/PageWithNav'
import { useUserProfile } from '../../contexts/UserProfileContext'
import { useGameMode } from '../../contexts/GameModeContext'
import { FullscreenProvider, useFullscreen } from '../../contexts/FullscreenContext'
@@ -1158,7 +1159,11 @@ const globalAnimations = `
`
export default function GamesPage() {
return <GamesPageContent />
return (
<PageWithNav navTitle="Soroban Arcade" navEmoji="🕹️">
<GamesPageContent />
</PageWithNav>
)
}
// Inject refined animations into the page

View File

@@ -4,6 +4,7 @@ import { useState } from 'react'
import Link from 'next/link'
import { css } from '../../../styled-system/css'
import { container, stack, hstack, grid } from '../../../styled-system/patterns'
import { PageWithNav } from '@/components/PageWithNav'
import { TypstSoroban } from '@/components/TypstSoroban'
import { InteractiveAbacus } from '@/components/InteractiveAbacus'
import { AbacusReact } from '@soroban/abacus-react'
@@ -16,7 +17,8 @@ type TabType = 'reading' | 'arithmetic'
export default function GuidePage() {
const [activeTab, setActiveTab] = useState<TabType>('reading')
return (
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
<PageWithNav navTitle="Interactive Guide" navEmoji="📖">
<div className={css({ minHeight: '100vh', bg: 'gray.50' })}>
{/* Hero Section */}
<div className={css({
@@ -1281,6 +1283,7 @@ function ArithmeticOperationsGuide() {
Practice Arithmetic Operations
</Link>
</div>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -1,7 +1,6 @@
import type { Metadata, Viewport } from 'next'
import './globals.css'
import { ClientProviders } from '@/components/ClientProviders'
import { AppNav } from '@/components/AppNav'
export const metadata: Metadata = {
title: 'Soroban Flashcard Generator',
@@ -17,16 +16,13 @@ export const viewport: Viewport = {
export default function RootLayout({
children,
nav,
}: {
children: React.ReactNode
nav: React.ReactNode
}) {
return (
<html lang="en">
<body>
<ClientProviders>
<AppNav>{nav}</AppNav>
{children}
</ClientProviders>
</body>

View File

@@ -2,11 +2,13 @@
import { css } from '../../styled-system/css'
import { container, stack, hstack } from '../../styled-system/patterns'
import { PageWithNav } from '@/components/PageWithNav'
import Link from 'next/link'
export default function HomePage() {
return (
<div className={css({ minHeight: '100vh', bg: 'gradient-to-br from-brand.50 to-brand.100' })}>
<PageWithNav navTitle="Soroban Flashcards" navEmoji="🧮">
<div className={css({ minHeight: '100vh', bg: 'gradient-to-br from-brand.50 to-brand.100' })}>
{/* Hero Section */}
<main className={container({ maxW: '6xl', px: '4' })}>
@@ -109,7 +111,8 @@ export default function HomePage() {
</div>
</div>
</main>
</div>
</div>
</PageWithNav>
)
}

View File

@@ -1,52 +0,0 @@
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} />
}

View File

@@ -0,0 +1,33 @@
'use client'
import React from 'react'
import { AppNavBar } from './AppNavBar'
interface PageWithNavProps {
navTitle?: string
navEmoji?: string
children: React.ReactNode
}
export function PageWithNav({ navTitle, navEmoji, children }: PageWithNavProps) {
// Create nav content if title is provided
const navContent = navTitle ? (
<h1 style={{
fontSize: '18px',
fontWeight: 'bold',
background: 'linear-gradient(135deg, #60a5fa, #a78bfa, #f472b6)',
backgroundClip: 'text',
color: 'transparent',
margin: 0
}}>
{navEmoji && `${navEmoji} `}{navTitle}
</h1>
) : null
return (
<>
<AppNavBar navSlot={navContent} />
{children}
</>
)
}

View File

@@ -0,0 +1,44 @@
import { en } from 'make-plural'
// Use English pluralization rules from make-plural
const plural = en
/**
* Pluralize a word based on count using make-plural library
* @param count - The number to base pluralization on
* @param singular - The singular form of the word
* @param plural - The plural form of the word (optional, will add 's' by default)
* @returns The properly pluralized word
*/
export function pluralizeWord(count: number, singular: string, pluralForm?: string): string {
const category = plural(count)
if (category === 'one') {
return singular
}
return pluralForm || (singular + 's')
}
/**
* Create a formatted string with count and properly pluralized word
* @param count - The number to display
* @param singular - The singular form of the word
* @param plural - The plural form of the word (optional)
* @returns Formatted string like "1 pair" or "3 pairs"
*/
export function pluralizeCount(count: number, singular: string, pluralForm?: string): string {
return `${count} ${pluralizeWord(count, singular, pluralForm)}`
}
/**
* Common game-specific pluralization helpers using make-plural
*/
export const gamePlurals = {
pair: (count: number) => pluralizeCount(count, 'pair'),
pairs: (count: number) => pluralizeCount(count, 'pair'),
move: (count: number) => pluralizeCount(count, 'move'),
moves: (count: number) => pluralizeCount(count, 'move'),
match: (count: number) => pluralizeCount(count, 'match', 'matches'),
matches: (count: number) => pluralizeCount(count, 'match', 'matches'),
player: (count: number) => pluralizeCount(count, 'player'),
players: (count: number) => pluralizeCount(count, 'player'),
} as const

View File

@@ -1,3 +1,13 @@
## [1.5.1](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.5.0...abacus-react-v1.5.1) (2025-09-29)
### Bug Fixes
* resolve JSX parsing error with emoji in guide page ([bf046c9](https://github.com/antialias/soroban-abacus-flashcards/commit/bf046c999b51ba422284a139ebadde2c35187ac7))
* resolve TypeScript errors in PlayerStatusBar component ([a935e5a](https://github.com/antialias/soroban-abacus-flashcards/commit/a935e5aed8c4584d21c8fc4359453b7dec494464))
* restore navigation to all pages using PageWithNav ([183706d](https://github.com/antialias/soroban-abacus-flashcards/commit/183706dade12080a748b0c074d0bd71fb0471d7e))
* update workflow to support Personal Access Token for GitHub Packages publishing auth ([ae4b71b](https://github.com/antialias/soroban-abacus-flashcards/commit/ae4b71b98655364887a729ef9d2b67b6a753d6e9))
# [1.5.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.4.0...abacus-react-v1.5.0) (2025-09-29)

7
pnpm-lock.yaml generated
View File

@@ -139,6 +139,9 @@ importers:
lucide-react:
specifier: ^0.294.0
version: 0.294.0(react@18.2.0)
make-plural:
specifier: ^7.4.0
version: 7.4.0
next:
specifier: ^14.2.32
version: 14.2.32(@babel/core@7.28.4)(@playwright/test@1.55.1)(react-dom@18.2.0)(react@18.2.0)
@@ -12298,6 +12301,10 @@ packages:
semver: 6.3.1
dev: true
/make-plural@7.4.0:
resolution: {integrity: sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg==}
dev: false
/makeerror@1.0.12:
resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==}
dependencies: