Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b72e0c561 | ||
|
|
e0b6a2e88b | ||
|
|
230f1dcd86 | ||
|
|
d8ec64280e | ||
|
|
1d4419364a | ||
|
|
cce8980e17 | ||
|
|
4bcce2a8db | ||
|
|
2818fd15ca | ||
|
|
29d20a6c07 | ||
|
|
be2c3f63b0 | ||
|
|
aa0bdcf686 | ||
|
|
baea602000 | ||
|
|
05dd0b30d3 | ||
|
|
4febf5905b | ||
|
|
6739d59f2b | ||
|
|
cb20019c16 | ||
|
|
d90b5d5532 | ||
|
|
7028db0263 | ||
|
|
fa3b73c691 | ||
|
|
fd4d25c2d1 | ||
|
|
6501b073b1 | ||
|
|
9b4d9c21df | ||
|
|
53d23f19bc | ||
|
|
6c14012b97 | ||
|
|
c650ffa193 | ||
|
|
28834e8a3e | ||
|
|
8681b17340 | ||
|
|
d52cc608eb | ||
|
|
6f89d9e274 | ||
|
|
a8cc2bc0f0 | ||
|
|
9dff3e7b7b | ||
|
|
92fedb698d | ||
|
|
1e90d6c620 | ||
|
|
5fcb7925eb | ||
|
|
41eaed24fc | ||
|
|
de5f36481b | ||
|
|
e5ffe3927e | ||
|
|
7c47fcdc54 | ||
|
|
4f4c73577a | ||
|
|
1e6459f9c1 | ||
|
|
477a0b367e | ||
|
|
8751649233 | ||
|
|
07c783a794 | ||
|
|
ca8cef1c36 | ||
|
|
0d47664f9f | ||
|
|
1e5467fad4 | ||
|
|
44f8b27fa1 | ||
|
|
0146ce1e67 | ||
|
|
acfb0dac0a | ||
|
|
0fbde53039 | ||
|
|
90bbe6fbb7 | ||
|
|
a03e73c849 | ||
|
|
f3dce84532 | ||
|
|
3b6284ae18 | ||
|
|
563136fb79 | ||
|
|
ead9ee9589 |
195
CHANGELOG.md
195
CHANGELOG.md
@@ -1,3 +1,198 @@
|
||||
## [4.46.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.45.0...v4.46.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add full-page hero abacus with scroll-based nav transition ([d8ec642](https://github.com/antialias/soroban-abacus-flashcards/commit/d8ec64280ec0c2f44f2fd9c72a93a882481f650b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** improve hero abacus sizing and layout ([230f1dc](https://github.com/antialias/soroban-abacus-flashcards/commit/230f1dcd866e5b3625e19f7400f5eae478fe7d0c))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **hero-abacus:** apply dark theme to match homepage styling ([e0b6a2e](https://github.com/antialias/soroban-abacus-flashcards/commit/e0b6a2e88b3ebbaae41ed54f23f9e514604d2262))
|
||||
|
||||
## [4.45.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.3...v4.45.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **branding:** rebrand navigation from 'Soroban Generator' to 'Abaci One' ([cce8980](https://github.com/antialias/soroban-abacus-flashcards/commit/cce8980e177da1b3c344e46561d928ed98b86f6c))
|
||||
|
||||
## [4.44.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.2...v4.44.3) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** reduce operator box sizes and remove divider line ([29d20a6](https://github.com/antialias/soroban-abacus-flashcards/commit/29d20a6c0741e7427f2bb64bc9c3e950b1a3238a))
|
||||
* **levels:** use uniform padding on operator box grid ([2818fd1](https://github.com/antialias/soroban-abacus-flashcards/commit/2818fd15cacac78de6d86ba769b9b2a02800ed1e))
|
||||
|
||||
## [4.44.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.1...v4.44.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** match top/bottom margins to left padding on kyu detail boxes ([aa0bdcf](https://github.com/antialias/soroban-abacus-flashcards/commit/aa0bdcf686adcbfd1a145cf67121181d1f1194d9))
|
||||
|
||||
## [4.44.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.44.0...v4.44.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add fixed dimensions and margins to kyu detail boxes ([05dd0b3](https://github.com/antialias/soroban-abacus-flashcards/commit/05dd0b30d3c397b82b7b7cc93a5ea575f3aada6d))
|
||||
|
||||
## [4.44.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.2...v4.44.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** redesign kyu details with larger operators and prominent digits ([6739d59](https://github.com/antialias/soroban-abacus-flashcards/commit/6739d59f2b6189a98570e23e04c20d86d774ccce))
|
||||
|
||||
## [4.43.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.1...v4.43.2) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** remove Time and Pass sections from kyu details ([d90b5d5](https://github.com/antialias/soroban-abacus-flashcards/commit/d90b5d55322e75dd28b95376614663a506c829d4))
|
||||
|
||||
## [4.43.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.43.0...v4.43.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** use two-column grid for kyu details to prevent clipping ([fa3b73c](https://github.com/antialias/soroban-abacus-flashcards/commit/fa3b73c69169b4694201ffa19ae3f8b5a68dfe32))
|
||||
|
||||
## [4.43.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.42.1...v4.43.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add structured kyu exam details with card UI ([6501b07](https://github.com/antialias/soroban-abacus-flashcards/commit/6501b073b100a00982cff1ca3140921e74f31a9c))
|
||||
|
||||
## [4.42.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.42.0...v4.42.1) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** store kyu data verbatim, add formatting layer ([53d23f1](https://github.com/antialias/soroban-abacus-flashcards/commit/53d23f19bc06459462afb76ed94d9b99d583a32d))
|
||||
|
||||
## [4.42.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.41.0...v4.42.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add kyu level details display with English translations ([c650ffa](https://github.com/antialias/soroban-abacus-flashcards/commit/c650ffa1935fe370d37190b2843c0deecdcce8e7))
|
||||
|
||||
## [4.41.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.40.1...v4.41.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** right-align abacus display ([8681b17](https://github.com/antialias/soroban-abacus-flashcards/commit/8681b17340e757cf04d17f884a780a251645bb33))
|
||||
|
||||
## [4.40.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.40.0...v4.40.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** increase animation speed to 10ms for 10th Dan ([6f89d9e](https://github.com/antialias/soroban-abacus-flashcards/commit/6f89d9e274082908fc090a9c0ba310f2cb06f014))
|
||||
|
||||
## [4.40.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.39.1...v4.40.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** progressive animation speed for Dan levels ([9dff3e7](https://github.com/antialias/soroban-abacus-flashcards/commit/9dff3e7b7b1ca46ea7f19a48135124b80c5182c0))
|
||||
|
||||
## [4.39.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.39.0...v4.39.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** improve slider tick spacing to use full width ([1e90d6c](https://github.com/antialias/soroban-abacus-flashcards/commit/1e90d6c6207f29084a8dc96ccfbb1013a1a62271))
|
||||
|
||||
## [4.39.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.38.1...v4.39.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add auto-advance slider with hover pause ([41eaed2](https://github.com/antialias/soroban-abacus-flashcards/commit/41eaed24fce510bab7fd03fa2e39e829b33a7346))
|
||||
|
||||
## [4.38.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.38.0...v4.38.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** adjust slider text positioning to prevent emoji overlap ([e5ffe39](https://github.com/antialias/soroban-abacus-flashcards/commit/e5ffe3927edfb1baea7ddd216507e081f50e5d2c))
|
||||
|
||||
## [4.38.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.37.0...v4.38.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add animated calculation effect to abacus display ([4f4c735](https://github.com/antialias/soroban-abacus-flashcards/commit/4f4c73577a944518c093b3208a85482909fe3064))
|
||||
|
||||
## [4.37.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.36.0...v4.37.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** add hover tracking to slider for real-time level preview ([477a0b3](https://github.com/antialias/soroban-abacus-flashcards/commit/477a0b367e32749b865b5a5405846e86d5bcef6a))
|
||||
|
||||
## [4.36.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.35.1...v4.36.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** make emoji tick marks clickable and remove redundant UI ([07c783a](https://github.com/antialias/soroban-abacus-flashcards/commit/07c783a79454f50e7302b19684be6d2e5930154d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add smooth CSS transitions for slider thumb movement ([ca8cef1](https://github.com/antialias/soroban-abacus-flashcards/commit/ca8cef1c36efeb1c8c214c74f8bd383f9295be3b))
|
||||
|
||||
## [4.35.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.35.0...v4.35.1) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** export StandaloneBead component wired to AbacusDisplayContext ([0146ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/0146ce1e67da27a24cbaa8338ba6a1a6befd6bd3))
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **levels:** speed up slider animations for more responsive feel ([1e5467f](https://github.com/antialias/soroban-abacus-flashcards/commit/1e5467fad4e27b832300c49b4f73547dc47598b0))
|
||||
|
||||
## [4.35.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.34.0...v4.35.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](https://github.com/antialias/soroban-abacus-flashcards/commit/0fbde53039d3ea000c6a3be492b733479e7bf47c))
|
||||
|
||||
## [4.34.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.8...v4.34.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **levels:** redesign slider with abacus-themed beads ([f3dce84](https://github.com/antialias/soroban-abacus-flashcards/commit/f3dce84532fa706e4ec9551facde2055a060ee13))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** convert to Radix UI Slider with abacus theme ([a03e73c](https://github.com/antialias/soroban-abacus-flashcards/commit/a03e73c849c5da4337f26a74b8f12b617c66068e))
|
||||
|
||||
## [4.33.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.7...v4.33.8) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](https://github.com/antialias/soroban-abacus-flashcards/commit/563136fb79fa10b2af3a119bf0f861e3b0812b2e))
|
||||
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](https://github.com/antialias/soroban-abacus-flashcards/commit/ead9ee9589aa4d7376e9385da5da53a6b444858a))
|
||||
|
||||
## [4.33.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.33.6...v4.33.7) (2025-10-20)
|
||||
|
||||
|
||||
|
||||
@@ -1,24 +1,86 @@
|
||||
'use client'
|
||||
|
||||
import { useRef, useState } from 'react'
|
||||
import { useSpring, animated } from '@react-spring/web'
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useSpring, useTransition, animated } from '@react-spring/web'
|
||||
import * as Slider from '@radix-ui/react-slider'
|
||||
import { AbacusReact, StandaloneBead } from '@soroban/abacus-react'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { css } from '../../../styled-system/css'
|
||||
import { container, stack } from '../../../styled-system/patterns'
|
||||
import { kyuLevelDetails } from '@/data/kyuLevelDetails'
|
||||
|
||||
// Combine all levels into one array for the slider
|
||||
const allLevels = [
|
||||
{ level: '10th Kyu', emoji: '🧒', color: 'green', digits: 2, type: 'kyu' as const },
|
||||
{ level: '9th Kyu', emoji: '🧒', color: 'green', digits: 2, type: 'kyu' as const },
|
||||
{ level: '8th Kyu', emoji: '🧒', color: 'green', digits: 3, type: 'kyu' as const },
|
||||
{ level: '7th Kyu', emoji: '🧒', color: 'green', digits: 4, type: 'kyu' as const },
|
||||
{ level: '6th Kyu', emoji: '🧑', color: 'blue', digits: 5, type: 'kyu' as const },
|
||||
{ level: '5th Kyu', emoji: '🧑', color: 'blue', digits: 6, type: 'kyu' as const },
|
||||
{ level: '4th Kyu', emoji: '🧑', color: 'blue', digits: 7, type: 'kyu' as const },
|
||||
{ level: '3rd Kyu', emoji: '🧔', color: 'violet', digits: 8, type: 'kyu' as const },
|
||||
{ level: '2nd Kyu', emoji: '🧔', color: 'violet', digits: 9, type: 'kyu' as const },
|
||||
{ level: '1st Kyu', emoji: '🧔', color: 'violet', digits: 10, type: 'kyu' as const },
|
||||
{
|
||||
level: '10th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 2,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '9th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 2,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '8th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 3,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '7th Kyu',
|
||||
emoji: '🧒',
|
||||
color: 'green',
|
||||
digits: 4,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '6th Kyu',
|
||||
emoji: '🧑',
|
||||
color: 'blue',
|
||||
digits: 5,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '5th Kyu',
|
||||
emoji: '🧑',
|
||||
color: 'blue',
|
||||
digits: 6,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '4th Kyu',
|
||||
emoji: '🧑',
|
||||
color: 'blue',
|
||||
digits: 7,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Kyu',
|
||||
emoji: '🧔',
|
||||
color: 'violet',
|
||||
digits: 8,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Kyu',
|
||||
emoji: '🧔',
|
||||
color: 'violet',
|
||||
digits: 9,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: '1st Kyu',
|
||||
emoji: '🧔',
|
||||
color: 'violet',
|
||||
digits: 10,
|
||||
type: 'kyu' as const,
|
||||
},
|
||||
{
|
||||
level: 'Pre-1st Dan',
|
||||
name: 'Jun-Shodan',
|
||||
@@ -120,50 +182,192 @@ const allLevels = [
|
||||
},
|
||||
] as const
|
||||
|
||||
// Helper function to map level names to kyuLevelDetails keys
|
||||
function getLevelDetailsKey(levelName: string): string | null {
|
||||
// Convert "10th Kyu" → "10-kyu", "3rd Kyu" → "3-kyu", etc.
|
||||
const match = levelName.match(/^(\d+)(?:st|nd|rd|th)\s+Kyu$/)
|
||||
if (match) {
|
||||
return `${match[1]}-kyu`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Parse and format kyu level details into structured sections with icons
|
||||
function parseKyuDetails(rawText: string) {
|
||||
const lines = rawText.split('\n').filter((line) => line.trim() && !line.includes('shuzan.jp'))
|
||||
|
||||
// Always return sections in consistent order: Add/Sub, Multiply, Divide
|
||||
const sections: Array<{
|
||||
type: 'addSub' | 'multiply' | 'divide'
|
||||
icon: string
|
||||
label: string
|
||||
digits: string | null
|
||||
rows: string | null
|
||||
chars: string | null
|
||||
problems: string | null
|
||||
}> = [
|
||||
{
|
||||
type: 'addSub',
|
||||
icon: '➕➖',
|
||||
label: 'Add/Sub',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
{
|
||||
type: 'multiply',
|
||||
icon: '✖️',
|
||||
label: 'Multiply',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
{
|
||||
type: 'divide',
|
||||
icon: '➗',
|
||||
label: 'Divide',
|
||||
digits: null,
|
||||
rows: null,
|
||||
chars: null,
|
||||
problems: null,
|
||||
},
|
||||
]
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.includes('Add/Sub:')) {
|
||||
const match = line.match(/(\d+)-digit.*?(\d+)口.*?(\d+)字/)
|
||||
if (match) {
|
||||
sections[0].digits = match[1]
|
||||
sections[0].rows = match[2]
|
||||
sections[0].chars = match[3]
|
||||
}
|
||||
} else if (line.includes('×:')) {
|
||||
const match = line.match(/(\d+) digits.*?\((\d+)/)
|
||||
if (match) {
|
||||
sections[1].digits = match[1]
|
||||
sections[1].problems = match[2]
|
||||
}
|
||||
} else if (line.includes('÷:')) {
|
||||
const match = line.match(/(\d+) digits.*?\((\d+)/)
|
||||
if (match) {
|
||||
sections[2].digits = match[1]
|
||||
sections[2].problems = match[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export default function LevelsPage() {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const sliderRef = useRef<HTMLInputElement>(null)
|
||||
const [isHovering, setIsHovering] = useState(false)
|
||||
const [isPaneHovered, setIsPaneHovered] = useState(false)
|
||||
const currentLevel = allLevels[currentIndex]
|
||||
|
||||
// State for animated abacus digits
|
||||
const [animatedDigits, setAnimatedDigits] = useState<string>('')
|
||||
|
||||
// Initialize animated digits when level changes
|
||||
useEffect(() => {
|
||||
const generateRandomDigits = (numDigits: number) => {
|
||||
return Array.from({ length: numDigits }, () => Math.floor(Math.random() * 10)).join('')
|
||||
}
|
||||
setAnimatedDigits(generateRandomDigits(currentLevel.digits))
|
||||
}, [currentLevel.digits])
|
||||
|
||||
// Animate abacus calculations - speed increases with Dan level
|
||||
useEffect(() => {
|
||||
// Calculate animation speed based on level
|
||||
// Kyu levels: 500ms
|
||||
// Pre-1st Dan: 500ms
|
||||
// 1st-10th Dan: interpolate from 500ms to 10ms
|
||||
const getAnimationInterval = () => {
|
||||
if (currentIndex < 11) {
|
||||
// Kyu levels and Pre-1st Dan: constant 500ms
|
||||
return 500
|
||||
}
|
||||
// 1st Dan through 10th Dan: speed up from 500ms to 10ms
|
||||
// Index 11 (1st Dan) → 500ms
|
||||
// Index 20 (10th Dan) → 10ms
|
||||
const danProgress = (currentIndex - 11) / 9 // 0.0 to 1.0
|
||||
return 500 - danProgress * 490 // 500ms down to 10ms
|
||||
}
|
||||
|
||||
const intervalMs = getAnimationInterval()
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setAnimatedDigits((prev) => {
|
||||
const digits = prev.split('').map(Number)
|
||||
const numColumns = digits.length
|
||||
|
||||
// Pick 1-3 adjacent columns to change (grouping effect)
|
||||
const groupSize = Math.floor(Math.random() * 3) + 1
|
||||
const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1))
|
||||
|
||||
// Change the selected columns
|
||||
for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) {
|
||||
digits[i] = Math.floor(Math.random() * 10)
|
||||
}
|
||||
|
||||
return digits.join('')
|
||||
})
|
||||
}, intervalMs)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [currentIndex])
|
||||
|
||||
// Auto-advance slider position every 3 seconds (unless pane is hovered)
|
||||
useEffect(() => {
|
||||
if (isPaneHovered) return // Don't auto-advance when mouse is over the pane
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
// Cycle back to 0 when reaching the end
|
||||
return prev >= allLevels.length - 1 ? 0 : prev + 1
|
||||
})
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isPaneHovered])
|
||||
|
||||
// Handle hover on slider track
|
||||
const handleSliderHover = (e: React.MouseEvent<HTMLSpanElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const percentage = x / rect.width
|
||||
const index = Math.round(percentage * (allLevels.length - 1))
|
||||
setCurrentIndex(Math.max(0, Math.min(allLevels.length - 1, index)))
|
||||
}
|
||||
|
||||
// Calculate scale factor based on number of columns to fit the page
|
||||
// Use constrained range to prevent huge size differences between levels
|
||||
// Min 1.5 (for 30-column Dan levels) to Max 3.0 (for 2-column Kyu levels)
|
||||
const scaleFactor = Math.max(1.5, Math.min(3.0, 20 / currentLevel.digits))
|
||||
// Min 1.2 (for 30-column Dan levels) to Max 2.0 (for 2-column Kyu levels)
|
||||
const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits))
|
||||
|
||||
// Animate scale factor with React Spring for smooth transitions
|
||||
const animatedProps = useSpring({
|
||||
scaleFactor,
|
||||
config: { tension: 280, friction: 60 },
|
||||
config: { tension: 350, friction: 45 },
|
||||
})
|
||||
|
||||
// Handle mouse/touch move over slider for hover interaction
|
||||
const handleSliderHover = (
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>
|
||||
) => {
|
||||
if (!sliderRef.current) return
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect()
|
||||
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
|
||||
const x = clientX - rect.left
|
||||
const percentage = Math.max(0, Math.min(1, x / rect.width))
|
||||
const newIndex = Math.round(percentage * (allLevels.length - 1))
|
||||
|
||||
if (newIndex !== currentIndex) {
|
||||
setCurrentIndex(newIndex)
|
||||
}
|
||||
}
|
||||
|
||||
// Generate an interesting non-zero number to display on the abacus
|
||||
// Use a suffix pattern so rightmost digits stay constant as columns increase
|
||||
// This prevents beads from shifting: ones always 9, tens always 8, etc.
|
||||
const digitPattern = '123456789'
|
||||
// Use BigInt for numbers > 15 digits (Dan levels with 30 columns)
|
||||
const repeatedPattern = digitPattern.repeat(Math.ceil(currentLevel.digits / digitPattern.length))
|
||||
const digitString = repeatedPattern.slice(-currentLevel.digits)
|
||||
// Animate emoji with proper cross-fade (old fades out, new fades in)
|
||||
const emojiTransitions = useTransition(currentLevel.emoji, {
|
||||
keys: currentIndex,
|
||||
from: { opacity: 0 },
|
||||
enter: { opacity: 1 },
|
||||
leave: { opacity: 0 },
|
||||
config: { duration: 120 },
|
||||
})
|
||||
|
||||
// Convert animated digits to a number/BigInt for the abacus display
|
||||
// Use BigInt for large numbers to get full 30-digit precision
|
||||
const displayValue =
|
||||
currentLevel.digits > 15 ? BigInt(digitString) : Number.parseInt(digitString, 10)
|
||||
animatedDigits.length > 15
|
||||
? BigInt(animatedDigits || '0')
|
||||
: Number.parseInt(animatedDigits || '0', 10)
|
||||
|
||||
// Dark theme styles matching the homepage
|
||||
const darkStyles = {
|
||||
@@ -204,7 +408,13 @@ export default function LevelsPage() {
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className={container({ maxW: '6xl', px: '4', position: 'relative' })}>
|
||||
<div
|
||||
className={container({
|
||||
maxW: '6xl',
|
||||
px: '4',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<div className={css({ textAlign: 'center', maxW: '5xl', mx: 'auto' })}>
|
||||
<h1
|
||||
className={css({
|
||||
@@ -241,8 +451,10 @@ export default function LevelsPage() {
|
||||
<section className={stack({ gap: '8' })}>
|
||||
{/* Current Level Display */}
|
||||
<div
|
||||
onMouseEnter={() => setIsPaneHovered(true)}
|
||||
onMouseLeave={() => setIsPaneHovered(false)}
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
bg: 'transparent',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
currentLevel.color === 'green'
|
||||
@@ -254,81 +466,178 @@ export default function LevelsPage() {
|
||||
: 'amber.500',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
height: { base: 'auto', md: '900px' },
|
||||
height: { base: 'auto', md: '700px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Level Info */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
mb: '4',
|
||||
height: '160px',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '5xl', mb: '3' })}>{currentLevel.emoji}</div>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.400'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.400'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.400'
|
||||
: 'amber.400',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
{currentLevel.level}
|
||||
</h2>
|
||||
{'name' in currentLevel && (
|
||||
<div className={css({ fontSize: 'md', color: 'gray.400', mb: '1' })}>
|
||||
{currentLevel.name}
|
||||
</div>
|
||||
)}
|
||||
{'minScore' in currentLevel && (
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.500' })}>
|
||||
Minimum Score: {currentLevel.minScore} points
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Range Slider with hover support */}
|
||||
{/* Abacus-themed Radix Slider */}
|
||||
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
|
||||
<div className={css({ mb: '3', textAlign: 'center' })}>
|
||||
<p className={css({ fontSize: 'sm', color: 'gray.400' })}>
|
||||
Hover, drag, or touch the slider to explore all levels
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
onMouseMove={handleSliderHover}
|
||||
onTouchMove={handleSliderHover}
|
||||
className={css({ position: 'relative' })}
|
||||
>
|
||||
<input
|
||||
ref={sliderRef}
|
||||
type="range"
|
||||
min="0"
|
||||
max={allLevels.length - 1}
|
||||
value={currentIndex}
|
||||
onChange={(e) => setCurrentIndex(Number(e.target.value))}
|
||||
<div className={css({ position: 'relative', py: '12' })}>
|
||||
{/* Emoji tick marks */}
|
||||
<div
|
||||
className={css({
|
||||
w: '100%',
|
||||
h: '2',
|
||||
bg: 'gray.700',
|
||||
rounded: 'full',
|
||||
outline: 'none',
|
||||
position: 'absolute',
|
||||
top: '0',
|
||||
left: '0',
|
||||
right: '0',
|
||||
h: 'full',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
pointerEvents: 'none',
|
||||
px: '0', // Use full width for tick spacing
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
w: 'full',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
{allLevels.map((level, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => setCurrentIndex(index)}
|
||||
className={css({
|
||||
fontSize: '4xl',
|
||||
opacity: index === currentIndex ? '1' : '0.3',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
_hover: { opacity: index === currentIndex ? '1' : '0.6' },
|
||||
})}
|
||||
>
|
||||
{level.emoji}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Slider.Root
|
||||
value={[currentIndex]}
|
||||
onValueChange={([value]) => setCurrentIndex(value)}
|
||||
min={0}
|
||||
max={allLevels.length - 1}
|
||||
step={1}
|
||||
onMouseMove={handleSliderHover}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
userSelect: 'none',
|
||||
touchAction: 'none',
|
||||
w: 'full',
|
||||
h: '32',
|
||||
cursor: 'pointer',
|
||||
})}
|
||||
/>
|
||||
>
|
||||
<Slider.Track
|
||||
className={css({
|
||||
bg: 'rgba(255, 255, 255, 0.2)',
|
||||
position: 'relative',
|
||||
flexGrow: 1,
|
||||
rounded: 'full',
|
||||
h: '3px',
|
||||
})}
|
||||
>
|
||||
<Slider.Range className={css({ display: 'none' })} />
|
||||
</Slider.Track>
|
||||
|
||||
<Slider.Thumb
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
w: '180px',
|
||||
h: '128px',
|
||||
bg: 'transparent',
|
||||
cursor: 'grab',
|
||||
transition: 'transform 0.15s ease-out, left 0.3s ease-out',
|
||||
zIndex: 10,
|
||||
_hover: { transform: 'scale(1.15)' },
|
||||
_focus: {
|
||||
outline: 'none',
|
||||
transform: 'scale(1.15)',
|
||||
},
|
||||
_active: { cursor: 'grabbing' },
|
||||
})}
|
||||
>
|
||||
<div className={css({ opacity: 0.75 })}>
|
||||
<StandaloneBead
|
||||
size={128}
|
||||
color={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
{emojiTransitions((style, emoji) => (
|
||||
<animated.div
|
||||
style={style}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
fontSize: '9xl',
|
||||
pointerEvents: 'none',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</animated.div>
|
||||
))}
|
||||
|
||||
{/* Level text as part of the bead display */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '-80px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.400'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.400'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.400'
|
||||
: 'amber.400',
|
||||
mb: '0.5',
|
||||
})}
|
||||
>
|
||||
{currentLevel.level}
|
||||
</h2>
|
||||
{'name' in currentLevel && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'md',
|
||||
color: 'gray.300',
|
||||
mb: '0.5',
|
||||
})}
|
||||
>
|
||||
{currentLevel.name}
|
||||
</div>
|
||||
)}
|
||||
{'minScore' in currentLevel && (
|
||||
<div className={css({ fontSize: 'sm', color: 'gray.400' })}>
|
||||
Min: {currentLevel.minScore}pts
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Slider.Thumb>
|
||||
</Slider.Root>
|
||||
</div>
|
||||
|
||||
{/* Level Markers */}
|
||||
@@ -336,7 +645,7 @@ export default function LevelsPage() {
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: '3',
|
||||
mt: '1',
|
||||
fontSize: 'xs',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
@@ -347,12 +656,11 @@ export default function LevelsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abacus Display */}
|
||||
{/* Abacus Display with Level Details */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '4',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
@@ -362,23 +670,155 @@ export default function LevelsPage() {
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
|
||||
}}
|
||||
{/* Level Details (only for Kyu levels) */}
|
||||
{currentLevel.type === 'kyu' &&
|
||||
(() => {
|
||||
const detailsKey = getLevelDetailsKey(currentLevel.level)
|
||||
const rawText = detailsKey
|
||||
? kyuLevelDetails[detailsKey as keyof typeof kyuLevelDetails]
|
||||
: null
|
||||
const sections = rawText ? parseKyuDetails(rawText) : []
|
||||
|
||||
// Use consistent sizing across all levels
|
||||
const sizing = { fontSize: 'md', gap: '3', iconSize: '4xl' }
|
||||
|
||||
return sections.length > 0 ? (
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '2',
|
||||
p: '2',
|
||||
maxW: '400px',
|
||||
alignContent: 'center',
|
||||
})}
|
||||
>
|
||||
{sections.map((section, idx) => {
|
||||
const hasData = section.digits !== null
|
||||
const levelColor =
|
||||
currentLevel.color === 'green'
|
||||
? 'green.300'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.300'
|
||||
: 'violet.300'
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={css({
|
||||
bg: hasData ? 'rgba(0, 0, 0, 0.4)' : 'rgba(0, 0, 0, 0.2)',
|
||||
border: '1px solid',
|
||||
borderColor: hasData ? 'gray.700' : 'gray.800',
|
||||
rounded: 'md',
|
||||
p: '3',
|
||||
transition: 'all 0.2s',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '1.5',
|
||||
opacity: hasData ? 1 : 0.3,
|
||||
width: '170px',
|
||||
height: '150px',
|
||||
_hover: hasData
|
||||
? {
|
||||
borderColor: 'gray.500',
|
||||
transform: 'scale(1.05)',
|
||||
}
|
||||
: {},
|
||||
})}
|
||||
>
|
||||
<span className={css({ fontSize: sizing.iconSize, lineHeight: '1' })}>
|
||||
{section.icon}
|
||||
</span>
|
||||
{hasData && section.digits && (
|
||||
<>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: levelColor,
|
||||
})}
|
||||
>
|
||||
{section.digits} digits
|
||||
</div>
|
||||
{(section.rows || section.chars) && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{section.rows && `${section.rows} rows`}
|
||||
{section.rows && section.chars && ' • '}
|
||||
{section.chars && `${section.chars} chars`}
|
||||
</div>
|
||||
)}
|
||||
{section.problems && (
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
textAlign: 'center',
|
||||
})}
|
||||
>
|
||||
{section.problems} problems
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
color: hasData ? 'gray.500' : 'gray.700',
|
||||
textAlign: 'center',
|
||||
fontWeight: hasData ? 'normal' : 'bold',
|
||||
})}
|
||||
>
|
||||
{section.label}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: currentLevel.type === 'kyu' ? 'flex-end' : 'center',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</animated.div>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s) => `scale(${s / scaleFactor})`),
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</animated.div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Digit Count */}
|
||||
<div className={css({ textAlign: 'center', color: 'gray.400', fontSize: 'sm' })}>
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
color: 'gray.400',
|
||||
fontSize: 'sm',
|
||||
})}
|
||||
>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,26 +837,78 @@ export default function LevelsPage() {
|
||||
borderColor: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'green.500', rounded: 'sm' })} />
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'green.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Beginner (10-7 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'blue.500', rounded: 'sm' })} />
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'blue.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Intermediate (6-4 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'violet.500', rounded: 'sm' })} />
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'violet.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Advanced (3-1 Kyu)
|
||||
</span>
|
||||
</div>
|
||||
<div className={css({ display: 'flex', alignItems: 'center', gap: '2' })}>
|
||||
<div className={css({ w: '4', h: '4', bg: 'amber.500', rounded: 'sm' })} />
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
w: '4',
|
||||
h: '4',
|
||||
bg: 'amber.500',
|
||||
rounded: 'sm',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Master (Dan ranks)
|
||||
</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
|
||||
import * as Tooltip from '@radix-ui/react-tooltip'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import React, { useState } from 'react'
|
||||
import React, { useContext, useMemo, useState } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, hstack } from '../../styled-system/patterns'
|
||||
import { Z_INDEX } from '../constants/zIndex'
|
||||
import { useFullscreen } from '../contexts/FullscreenContext'
|
||||
import { getRandomSubtitle } from '../data/abaciOneSubtitles'
|
||||
import { AbacusDisplayDropdown } from './AbacusDisplayDropdown'
|
||||
|
||||
// Import HomeHeroContext for optional usage
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
|
||||
// HomeHeroContext - imported dynamically to avoid circular deps
|
||||
let HomeHeroContextModule: any = null
|
||||
try {
|
||||
HomeHeroContextModule = require('../contexts/HomeHeroContext')
|
||||
} catch {
|
||||
// Context not available
|
||||
}
|
||||
|
||||
const HomeHeroContext = HomeHeroContextModule?.HomeHeroContext || React.createContext(null)
|
||||
|
||||
// Use HomeHeroContext without requiring it
|
||||
function useOptionalHomeHero(): { subtitle: Subtitle; isHeroVisible: boolean } | null {
|
||||
return useContext(HomeHeroContext)
|
||||
}
|
||||
|
||||
interface AppNavBarProps {
|
||||
variant?: 'full' | 'minimal'
|
||||
navSlot?: React.ReactNode
|
||||
@@ -514,6 +534,17 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
const isArcadePage = pathname?.startsWith('/arcade')
|
||||
const { isFullscreen, toggleFullscreen, exitFullscreen } = useFullscreen()
|
||||
|
||||
// Try to get home hero context (if on homepage)
|
||||
const homeHero = useOptionalHomeHero()
|
||||
|
||||
// Select a random subtitle once on mount (performance: won't change on re-renders)
|
||||
// Use homeHero subtitle if available, otherwise generate one
|
||||
const fallbackSubtitle = useMemo(() => getRandomSubtitle(), [])
|
||||
const subtitle = homeHero?.subtitle || fallbackSubtitle
|
||||
|
||||
// Show branding unless we're on homepage with visible hero
|
||||
const showBranding = !homeHero || !homeHero.isHeroVisible
|
||||
|
||||
// Auto-detect variant based on context
|
||||
const actualVariant = variant === 'full' && (isGamePage || isArcadePage) ? 'minimal' : variant
|
||||
|
||||
@@ -533,53 +564,128 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<header
|
||||
className={css({
|
||||
bg: 'white',
|
||||
shadow: 'sm',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 30,
|
||||
})}
|
||||
>
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '3' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
{/* Logo */}
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.800',
|
||||
textDecoration: 'none',
|
||||
_hover: { color: 'brand.900' },
|
||||
})}
|
||||
>
|
||||
🧮 Soroban Generator
|
||||
</Link>
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<header
|
||||
className={css({
|
||||
bg: 'white',
|
||||
shadow: 'sm',
|
||||
borderBottom: '1px solid',
|
||||
borderColor: 'gray.200',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 30,
|
||||
})}
|
||||
>
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '3' })}>
|
||||
<div className={hstack({ justify: 'space-between', alignItems: 'center' })}>
|
||||
{/* Logo - conditionally shown based on hero visibility */}
|
||||
{showBranding ? (
|
||||
<Link
|
||||
href="/"
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '0',
|
||||
textDecoration: 'none',
|
||||
_hover: { '& > .brand-name': { color: 'brand.900' } },
|
||||
opacity: 0,
|
||||
animation: 'fadeIn 0.3s ease-out forwards',
|
||||
})}
|
||||
>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.800',
|
||||
})}
|
||||
>
|
||||
🧮 Abaci One
|
||||
</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
<span
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: 'brand.600',
|
||||
fontStyle: 'italic',
|
||||
cursor: 'help',
|
||||
_hover: { color: 'brand.700' },
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
</span>
|
||||
</Tooltip.Trigger>
|
||||
<Tooltip.Portal>
|
||||
<Tooltip.Content
|
||||
side="bottom"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className={css({
|
||||
bg: 'gray.900',
|
||||
color: 'white',
|
||||
px: '3',
|
||||
py: '2',
|
||||
rounded: 'md',
|
||||
fontSize: 'sm',
|
||||
maxW: '250px',
|
||||
shadow: 'lg',
|
||||
zIndex: 50,
|
||||
})}
|
||||
>
|
||||
{subtitle.description}
|
||||
<Tooltip.Arrow
|
||||
className={css({
|
||||
fill: 'gray.900',
|
||||
})}
|
||||
/>
|
||||
</Tooltip.Content>
|
||||
</Tooltip.Portal>
|
||||
</Tooltip.Root>
|
||||
</Link>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
|
||||
<div className={hstack({ gap: '6', alignItems: 'center' })}>
|
||||
{/* Navigation Links */}
|
||||
<nav className={hstack({ gap: '4' })}>
|
||||
<NavLink href="/create" currentPath={pathname}>
|
||||
Create
|
||||
</NavLink>
|
||||
<NavLink href="/guide" currentPath={pathname}>
|
||||
Guide
|
||||
</NavLink>
|
||||
<NavLink href="/games" currentPath={pathname}>
|
||||
Games
|
||||
</NavLink>
|
||||
</nav>
|
||||
<div className={hstack({ gap: '6', alignItems: 'center' })}>
|
||||
{/* Navigation Links */}
|
||||
<nav className={hstack({ gap: '4' })}>
|
||||
<NavLink href="/create" currentPath={pathname}>
|
||||
Create
|
||||
</NavLink>
|
||||
<NavLink href="/guide" currentPath={pathname}>
|
||||
Guide
|
||||
</NavLink>
|
||||
<NavLink href="/games" currentPath={pathname}>
|
||||
Games
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
{/* Abacus Style Dropdown */}
|
||||
<AbacusDisplayDropdown isFullscreen={false} />
|
||||
{/* Abacus Style Dropdown */}
|
||||
<AbacusDisplayDropdown isFullscreen={false} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
|
||||
{/* Keyframes for fade-in animation */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
180
apps/web/src/components/HeroAbacus.tsx
Normal file
180
apps/web/src/components/HeroAbacus.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { useHomeHero } from '../contexts/HomeHeroContext'
|
||||
|
||||
export function HeroAbacus() {
|
||||
const { subtitle, abacusValue, setAbacusValue, setIsHeroVisible } = useHomeHero()
|
||||
const appConfig = useAbacusConfig()
|
||||
const heroRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Dark theme styles for the abacus (matching the mini abacus on homepage)
|
||||
const darkStyles = {
|
||||
columnPosts: {
|
||||
fill: 'rgba(255, 255, 255, 0.3)',
|
||||
stroke: 'rgba(255, 255, 255, 0.2)',
|
||||
strokeWidth: 2,
|
||||
},
|
||||
reckoningBar: {
|
||||
fill: 'rgba(255, 255, 255, 0.4)',
|
||||
stroke: 'rgba(255, 255, 255, 0.25)',
|
||||
strokeWidth: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Detect when hero scrolls out of view
|
||||
useEffect(() => {
|
||||
if (!heroRef.current) return
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
// Hero is visible if more than 20% is in viewport
|
||||
setIsHeroVisible(entry.intersectionRatio > 0.2)
|
||||
},
|
||||
{
|
||||
threshold: [0, 0.2, 0.5, 1],
|
||||
}
|
||||
)
|
||||
|
||||
observer.observe(heroRef.current)
|
||||
|
||||
return () => observer.disconnect()
|
||||
}, [setIsHeroVisible])
|
||||
|
||||
// Auto-cycle through random numbers (optional - user can also interact)
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const randomNum = Math.floor(Math.random() * 10000) // 0-9999
|
||||
setAbacusValue(randomNum)
|
||||
}, 4000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [setAbacusValue])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={heroRef}
|
||||
className={css({
|
||||
minHeight: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background:
|
||||
'linear-gradient(135deg, rgba(17, 24, 39, 1) 0%, rgba(88, 28, 135, 0.3) 50%, rgba(17, 24, 39, 1) 100%)',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
px: '4',
|
||||
})}
|
||||
>
|
||||
{/* Background pattern */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
opacity: 0.1,
|
||||
backgroundImage:
|
||||
'radial-gradient(circle at 2px 2px, rgba(255, 255, 255, 0.15) 1px, transparent 0)',
|
||||
backgroundSize: '40px 40px',
|
||||
})}
|
||||
/>
|
||||
|
||||
{/* Content */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '8',
|
||||
})}
|
||||
>
|
||||
{/* Title */}
|
||||
<h1
|
||||
className={css({
|
||||
fontSize: { base: '4xl', md: '6xl', lg: '7xl' },
|
||||
fontWeight: 'bold',
|
||||
background: 'linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #fbbf24 100%)',
|
||||
backgroundClip: 'text',
|
||||
color: 'transparent',
|
||||
mb: '4',
|
||||
})}
|
||||
>
|
||||
Abaci One
|
||||
</h1>
|
||||
|
||||
{/* Subtitle */}
|
||||
<p
|
||||
className={css({
|
||||
fontSize: { base: 'xl', md: '2xl' },
|
||||
fontWeight: 'medium',
|
||||
color: 'purple.300',
|
||||
fontStyle: 'italic',
|
||||
mb: '16',
|
||||
zIndex: 10,
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
</p>
|
||||
|
||||
{/* Large Interactive Abacus */}
|
||||
<div
|
||||
className={css({
|
||||
transform: { base: 'scale(2)', md: 'scale(3)', lg: 'scale(4)' },
|
||||
transformOrigin: 'center center',
|
||||
mb: { base: '12', md: '16', lg: '20' },
|
||||
transition: 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
position: 'relative',
|
||||
zIndex: 1,
|
||||
})}
|
||||
>
|
||||
<AbacusReact
|
||||
value={abacusValue}
|
||||
columns={4}
|
||||
beadShape={appConfig.beadShape}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Subtle hint to scroll */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.400',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
animation: 'bounce 2s ease-in-out infinite',
|
||||
})}
|
||||
>
|
||||
<span>Scroll to explore</span>
|
||||
<span>↓</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyframes for bounce animation */}
|
||||
<style
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
@keyframes bounce {
|
||||
0%, 100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
apps/web/src/contexts/HomeHeroContext.tsx
Normal file
55
apps/web/src/contexts/HomeHeroContext.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { createContext, useContext, useEffect, useMemo, useState } from 'react'
|
||||
import type { Subtitle } from '../data/abaciOneSubtitles'
|
||||
import { getRandomSubtitle, subtitles } from '../data/abaciOneSubtitles'
|
||||
|
||||
interface HomeHeroContextValue {
|
||||
subtitle: Subtitle
|
||||
abacusValue: number
|
||||
setAbacusValue: (value: number) => void
|
||||
isHeroVisible: boolean
|
||||
setIsHeroVisible: (visible: boolean) => void
|
||||
}
|
||||
|
||||
const HomeHeroContext = createContext<HomeHeroContextValue | null>(null)
|
||||
|
||||
export { HomeHeroContext }
|
||||
|
||||
export function HomeHeroProvider({ children }: { children: React.ReactNode }) {
|
||||
// Use first subtitle for SSR, then select random one on client mount
|
||||
const [subtitle, setSubtitle] = useState<Subtitle>(subtitles[0])
|
||||
|
||||
// Select random subtitle only on client side to avoid SSR mismatch
|
||||
useEffect(() => {
|
||||
setSubtitle(getRandomSubtitle())
|
||||
}, [])
|
||||
|
||||
// Shared abacus value (so it stays consistent during morph)
|
||||
const [abacusValue, setAbacusValue] = useState(1234)
|
||||
|
||||
// Track hero visibility for nav branding
|
||||
const [isHeroVisible, setIsHeroVisible] = useState(true)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
subtitle,
|
||||
abacusValue,
|
||||
setAbacusValue,
|
||||
isHeroVisible,
|
||||
setIsHeroVisible,
|
||||
}),
|
||||
[subtitle, abacusValue, isHeroVisible]
|
||||
)
|
||||
|
||||
return <HomeHeroContext.Provider value={value}>{children}</HomeHeroContext.Provider>
|
||||
}
|
||||
|
||||
export function useHomeHero() {
|
||||
const context = useContext(HomeHeroContext)
|
||||
if (!context) {
|
||||
throw new Error('useHomeHero must be used within HomeHeroProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
97
apps/web/src/data/abaciOneSubtitles.ts
Normal file
97
apps/web/src/data/abaciOneSubtitles.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Abaci One subtitle options with descriptions
|
||||
* Three-word rhyming subtitles for the main app navigation
|
||||
*/
|
||||
|
||||
export interface Subtitle {
|
||||
text: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export const subtitles: Subtitle[] = [
|
||||
{ text: 'Speed Bead Lead', description: 'blaze through bead races' },
|
||||
{ text: 'Rod Mod Nod', description: 'tweak rod technique, approval earned' },
|
||||
{ text: 'Grid Kid Lid', description: 'lock in neat grid habits' },
|
||||
{ text: 'Count Mount Amount', description: 'stack up that number sense' },
|
||||
{ text: 'Stack Track Tack', description: 'line up beads, lock in sums' },
|
||||
{ text: 'Quick Flick Trick', description: 'rapid-fire bead tactics' },
|
||||
{ text: 'Flash Dash Math', description: 'fly through numeric challenges' },
|
||||
{ text: 'Slide Glide Pride', description: 'smooth soroban strokes' },
|
||||
{ text: 'Shift Sift Gift', description: 'sort beads, reveal talent' },
|
||||
{ text: 'Beat Seat Meet', description: 'compete head-to-head' },
|
||||
{ text: 'Brain Train Gain', description: 'mental math muscle building' },
|
||||
{ text: 'Flow Show Pro', description: 'demonstrate soroban mastery' },
|
||||
{ text: 'Fast Blast Past', description: 'surpass speed limits' },
|
||||
{ text: 'Snap Tap Map', description: 'chart your calculation path' },
|
||||
{ text: 'Row Grow Know', description: 'advance through structured drills' },
|
||||
{ text: 'Drill Skill Thrill', description: 'practice that excites' },
|
||||
{ text: 'Think Link Sync', description: 'connect mind and beads' },
|
||||
{ text: 'Boost Joust Roost', description: 'power up, compete, settle in' },
|
||||
{ text: 'Add Grad Rad', description: 'level up addition awesomely' },
|
||||
{ text: 'Sum Fun Run', description: 'enjoy the arithmetic sprint' },
|
||||
{ text: 'Track Stack Pack', description: 'organize solutions systematically' },
|
||||
{ text: 'Beat Neat Feat', description: 'clean victories, impressive wins' },
|
||||
{ text: 'Math Path Wrath', description: 'dominate numeric challenges' },
|
||||
{ text: 'Spark Mark Arc', description: 'ignite progress, track growth' },
|
||||
{ text: 'Race Pace Ace', description: 'speed up, master it' },
|
||||
{ text: 'Flex Hex Reflex', description: 'adapt calculations instantly' },
|
||||
{ text: 'Glide Pride Stride', description: 'smooth confident progress' },
|
||||
{ text: 'Flash Dash Smash', description: 'speed through, crush totals' },
|
||||
{ text: 'Stack Attack Jack', description: 'aggressive bead strategies' },
|
||||
{ text: 'Quick Pick Click', description: 'rapid bead selection' },
|
||||
{ text: 'Snap Map Tap', description: 'visualize and execute' },
|
||||
{ text: 'Mind Find Grind', description: 'discover mental endurance' },
|
||||
{ text: 'Flip Skip Rip', description: 'fast transitions, tear through' },
|
||||
{ text: 'Blend Trend Send', description: 'mix methods, share progress' },
|
||||
{ text: 'Power Tower Hour', description: 'build skills intensively' },
|
||||
{ text: 'Launch Staunch Haunch', description: 'start strong, stay firm' },
|
||||
{ text: 'Rush Crush Hush', description: 'speed quietly dominates' },
|
||||
{ text: 'Swipe Stripe Hype', description: 'sleek moves, excitement' },
|
||||
{ text: 'Train Gain Sustain', description: 'build lasting ability' },
|
||||
{ text: 'Frame Claim Flame', description: 'structure your fire' },
|
||||
{ text: 'Streak Peak Tweak', description: 'hot runs, optimize performance' },
|
||||
{ text: 'Edge Pledge Wedge', description: 'commit to precision' },
|
||||
{ text: 'Pace Grace Space', description: 'rhythm, elegance, room to grow' },
|
||||
{ text: 'Link Think Brink', description: 'connect at breakthrough edge' },
|
||||
{ text: 'Quest Test Best', description: 'challenge yourself to excel' },
|
||||
{ text: 'Drive Thrive Arrive', description: 'push hard, succeed, reach goals' },
|
||||
{ text: 'Smart Start Chart', description: 'begin wisely, track progress' },
|
||||
{ text: 'Boost Coast Toast', description: 'accelerate, cruise, celebrate' },
|
||||
{ text: 'Spark Dark Embark', description: 'ignite before dawn journeys' },
|
||||
{ text: 'Blaze Graze Amaze', description: 'burn through, touch lightly, wow' },
|
||||
{ text: 'Shift Drift Gift', description: 'adapt smoothly, reveal talent' },
|
||||
{ text: 'Zone Hone Own', description: 'focus, refine, claim mastery' },
|
||||
{ text: 'Vault Halt Exalt', description: 'leap high, pause, celebrate' },
|
||||
{ text: 'Peak Seek Streak', description: 'find heights, maintain momentum' },
|
||||
{ text: 'Glow Show Grow', description: 'shine, display, expand' },
|
||||
{ text: 'Scope Hope Rope', description: 'survey possibilities, climb up' },
|
||||
{ text: 'Core Score More', description: 'fundamentals yield better results' },
|
||||
{ text: 'Rank Bank Thank', description: 'earn status, save wins, appreciate' },
|
||||
{ text: 'Merge Surge Verge', description: 'combine forces, power up, edge closer' },
|
||||
{ text: 'Bold Gold Hold', description: 'brave attempts, prize rewards, maintain' },
|
||||
{ text: 'Rise Prize Wise', description: 'ascend, win, learn' },
|
||||
{ text: 'Move Groove Prove', description: 'act, find rhythm, demonstrate' },
|
||||
{ text: 'Trust Thrust Adjust', description: 'believe, push, refine' },
|
||||
{ text: 'Beam Dream Team', description: 'radiate, aspire, collaborate' },
|
||||
{ text: 'Spin Win Grin', description: 'rotate beads, succeed, smile' },
|
||||
{ text: 'String Ring Bring', description: 'connect, cycle, deliver' },
|
||||
{ text: 'Clear Gear Steer', description: 'focus, equip, direct' },
|
||||
{ text: 'Path Math Aftermath', description: 'route, calculate, results' },
|
||||
{ text: 'Play Slay Day', description: 'engage, dominate, own it' },
|
||||
{ text: 'Code Mode Road', description: 'pattern, style, journey' },
|
||||
{ text: 'Craft Draft Shaft', description: 'build, sketch, core structure' },
|
||||
{ text: 'Light Might Fight', description: 'illuminate, empower, compete' },
|
||||
{ text: 'Stream Dream Extreme', description: 'flow, envision, push limits' },
|
||||
{ text: 'Claim Frame Aim', description: 'assert, structure, target' },
|
||||
{ text: 'Chart Smart Start', description: 'map, intelligent, begin' },
|
||||
{ text: 'Bright Flight Height', description: 'brilliant, soar, elevation' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Get a random subtitle from the list
|
||||
* Uses current timestamp as seed for variety across sessions
|
||||
*/
|
||||
export function getRandomSubtitle(): Subtitle {
|
||||
const index = Math.floor(Math.random() * subtitles.length)
|
||||
return subtitles[index]
|
||||
}
|
||||
123
apps/web/src/data/kyuLevelDetails.ts
Normal file
123
apps/web/src/data/kyuLevelDetails.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* Detailed requirements for each Kyu level in the Soroban certification system
|
||||
* Source: shuzan.jp
|
||||
*
|
||||
* Note: Stored verbatim from source. Display formatting/translation happens in the UI layer.
|
||||
*/
|
||||
|
||||
export const kyuLevelDetails = {
|
||||
'10-kyu': `Add/Sub: 2-digit, 5口, 10字
|
||||
|
||||
×: 実+法 = 3 digits (20 problems)
|
||||
|
||||
Time: 20 min; Pass ≥ 60/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'9-kyu': `Add/Sub: 2-digit, 5口, 10字
|
||||
|
||||
×: 実+法 = 3 digits (20)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200. (If only one part clears, it's treated as 10-kyu per federation notes.)
|
||||
shuzan.jp`,
|
||||
|
||||
'8-kyu': `Add/Sub: 2-digit, 8口, 16字
|
||||
|
||||
×: 実+法 = 4 digits (10)
|
||||
|
||||
÷: 法+商 = 3 digits (10)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'7-kyu': `Add/Sub: 2-digit, 10口, 20字
|
||||
|
||||
×: 実+法 = 4 digits (10)
|
||||
|
||||
÷: 法+商 = 4 digits (10)
|
||||
|
||||
Time: 20 min; Pass ≥ 120/200.
|
||||
shuzan.jp`,
|
||||
|
||||
'6-kyu': `Add/Sub: 10口, 30字
|
||||
|
||||
×: 実+法 = 5 digits (20)
|
||||
|
||||
÷: 法+商 = 4 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'5-kyu': `Add/Sub: 10口, 40字
|
||||
|
||||
×: 実+法 = 6 digits (20)
|
||||
|
||||
÷: 法+商 = 5 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'4-kyu': `Add/Sub: 10口, 50字
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 210/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-3-kyu': `Add/Sub: 10口, 50字 ×5題 and 10口, 60字 ×5題 (total 10)
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'3-kyu': `Add/Sub: 10口, 60字
|
||||
|
||||
×: 実+法 = 7 digits (20)
|
||||
|
||||
÷: 法+商 = 6 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-2-kyu': `Add/Sub: 10口, 70字
|
||||
|
||||
×: 実+法 = 8 digits (20)
|
||||
|
||||
÷: 法+商 = 7 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'2-kyu': `Add/Sub: 10口, 80字
|
||||
|
||||
×: 実+法 = 9 digits (20)
|
||||
|
||||
÷: 法+商 = 8 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'Pre-1-kyu': `Add/Sub: 10口, 90字
|
||||
|
||||
×: 実+法 = 10 digits (20)
|
||||
|
||||
÷: 法+商 = 9 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
|
||||
'1-kyu': `Add/Sub: 10口, 100字
|
||||
|
||||
×: 実+法 = 11 digits (20)
|
||||
|
||||
÷: 法+商 = 10 digits (20)
|
||||
|
||||
Time: 30 min; Pass ≥ 240/300.
|
||||
shuzan.jp`,
|
||||
} as const
|
||||
|
||||
export type KyuLevel = keyof typeof kyuLevelDetails
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.33.7",
|
||||
"version": "4.46.0",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
@@ -1,3 +1,25 @@
|
||||
# [2.1.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v2.0.0...abacus-react-v2.1.0) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **levels:** add fixed height to entire level display pane ([200b26c](https://github.com/antialias/soroban-abacus-flashcards/commit/200b26c2cd35d1d637ede9dcfc3dbbc7f3f19320))
|
||||
* **levels:** increase container height to prevent abacus clipping ([cd5c15a](https://github.com/antialias/soroban-abacus-flashcards/commit/cd5c15aeb260c568fe7ad9b6a4f51c4d6498b2b8))
|
||||
* **levels:** only animate abacus, not container with background/border ([c80477d](https://github.com/antialias/soroban-abacus-flashcards/commit/c80477d24877ddada5f3f4405abbf05e1d753b5d))
|
||||
* **levels:** reduce Dan scale and container height to prevent clipping ([563136f](https://github.com/antialias/soroban-abacus-flashcards/commit/563136fb79fa10b2af3a119bf0f861e3b0812b2e))
|
||||
* **levels:** reduce max scale factor to allow more compact container ([ead9ee9](https://github.com/antialias/soroban-abacus-flashcards/commit/ead9ee9589aa4d7376e9385da5da53a6b444858a))
|
||||
* **levels:** reduce scale factor variation to minimize margin differences ([abb647c](https://github.com/antialias/soroban-abacus-flashcards/commit/abb647ce40b8f9d0c8268ab18c139324ae3195c5))
|
||||
* **levels:** revert delayed column change, keep overflow hidden ([22f00f5](https://github.com/antialias/soroban-abacus-flashcards/commit/22f00f59f5facc36a846408dcd196ec54ea676b1))
|
||||
* **levels:** stabilize slider position and prevent abacus clipping ([09004dc](https://github.com/antialias/soroban-abacus-flashcards/commit/09004dc2c055031ee2f71c964ceee6f7b1d42ecd))
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **abacus-react:** export StandaloneBead component wired to AbacusDisplayContext ([0146ce1](https://github.com/antialias/soroban-abacus-flashcards/commit/0146ce1e67da27a24cbaa8338ba6a1a6befd6bd3))
|
||||
* **levels:** add hover interaction and smooth React Spring transitions ([fd2b633](https://github.com/antialias/soroban-abacus-flashcards/commit/fd2b6338a84c3bbc683eff216a8da3b155749f0f))
|
||||
* **levels:** redesign slider with abacus-themed beads ([f3dce84](https://github.com/antialias/soroban-abacus-flashcards/commit/f3dce84532fa706e4ec9551facde2055a060ee13))
|
||||
* **levels:** replace slider thumb with diamond-shaped abacus beads ([0fbde53](https://github.com/antialias/soroban-abacus-flashcards/commit/0fbde53039d3ea000c6a3be492b733479e7bf47c))
|
||||
|
||||
# [2.0.0](https://github.com/antialias/soroban-abacus-flashcards/compare/abacus-react-v1.8.0...abacus-react-v2.0.0) (2025-10-20)
|
||||
|
||||
|
||||
|
||||
147
packages/abacus-react/src/StandaloneBead.tsx
Normal file
147
packages/abacus-react/src/StandaloneBead.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { useSpring, animated } from "@react-spring/web";
|
||||
import {
|
||||
useAbacusConfig,
|
||||
getDefaultAbacusConfig,
|
||||
type BeadShape,
|
||||
} from "./AbacusContext";
|
||||
|
||||
export interface StandaloneBeadProps {
|
||||
/** Size of the bead in pixels */
|
||||
size?: number;
|
||||
/** Override the shape from context (diamond, circle, square) */
|
||||
shape?: BeadShape;
|
||||
/** Override the color from context */
|
||||
color?: string;
|
||||
/** Enable animation */
|
||||
animated?: boolean;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
/** Custom style */
|
||||
style?: React.CSSProperties;
|
||||
/** Active state for the bead */
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone Bead component that respects the AbacusDisplayContext.
|
||||
* This component renders a single abacus bead with styling from the context.
|
||||
*
|
||||
* Usage:
|
||||
* ```tsx
|
||||
* import { StandaloneBead, AbacusDisplayProvider } from '@soroban/abacus-react';
|
||||
*
|
||||
* <AbacusDisplayProvider>
|
||||
* <StandaloneBead size={28} color="#8b5cf6" />
|
||||
* </AbacusDisplayProvider>
|
||||
* ```
|
||||
*/
|
||||
export const StandaloneBead: React.FC<StandaloneBeadProps> = ({
|
||||
size = 28,
|
||||
shape: shapeProp,
|
||||
color: colorProp,
|
||||
animated: animatedProp,
|
||||
className,
|
||||
style,
|
||||
active = true,
|
||||
}) => {
|
||||
// Try to use context config, fallback to defaults if no context
|
||||
let contextConfig;
|
||||
try {
|
||||
contextConfig = useAbacusConfig();
|
||||
} catch {
|
||||
// No context provider, use defaults
|
||||
contextConfig = getDefaultAbacusConfig();
|
||||
}
|
||||
|
||||
// Use props if provided, otherwise fall back to context config
|
||||
const shape = shapeProp ?? contextConfig.beadShape;
|
||||
const enableAnimation = animatedProp ?? contextConfig.animated;
|
||||
const color = colorProp ?? "#000000";
|
||||
|
||||
const [springs, api] = useSpring(() => ({
|
||||
scale: 1,
|
||||
config: { tension: 300, friction: 20 },
|
||||
}));
|
||||
|
||||
React.useEffect(() => {
|
||||
if (enableAnimation) {
|
||||
api.start({ scale: 1 });
|
||||
}
|
||||
}, [enableAnimation, api]);
|
||||
|
||||
const renderShape = () => {
|
||||
const halfSize = size / 2;
|
||||
const actualColor = active ? color : "rgb(211, 211, 211)";
|
||||
|
||||
switch (shape) {
|
||||
case "diamond":
|
||||
return (
|
||||
<polygon
|
||||
points={`${size * 0.7},0 ${size * 1.4},${halfSize} ${size * 0.7},${size} 0,${halfSize}`}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
case "square":
|
||||
return (
|
||||
<rect
|
||||
width={size}
|
||||
height={size}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
rx="1"
|
||||
/>
|
||||
);
|
||||
case "circle":
|
||||
default:
|
||||
return (
|
||||
<circle
|
||||
cx={halfSize}
|
||||
cy={halfSize}
|
||||
r={halfSize}
|
||||
fill={actualColor}
|
||||
stroke="#000"
|
||||
strokeWidth="0.5"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getXOffset = () => {
|
||||
return shape === "diamond" ? size * 0.7 : size / 2;
|
||||
};
|
||||
|
||||
const getYOffset = () => {
|
||||
return size / 2;
|
||||
};
|
||||
|
||||
const AnimatedG = animated.g;
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size * (shape === "diamond" ? 1.4 : 1)}
|
||||
height={size}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
<AnimatedG
|
||||
transform={`translate(0, 0)`}
|
||||
style={
|
||||
enableAnimation
|
||||
? {
|
||||
transform: springs.scale.to((s) => `scale(${s})`),
|
||||
transformOrigin: "center",
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderShape()}
|
||||
</AnimatedG>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -14,3 +14,6 @@ export type {
|
||||
AbacusDisplayConfig,
|
||||
AbacusDisplayContextType,
|
||||
} from "./AbacusContext";
|
||||
|
||||
export { StandaloneBead } from "./StandaloneBead";
|
||||
export type { StandaloneBeadProps } from "./StandaloneBead";
|
||||
|
||||
Reference in New Issue
Block a user