Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11aa44d882 | ||
|
|
30e16c8e5a | ||
|
|
86357b3d7a | ||
|
|
ad1ad690f0 | ||
|
|
53475cf40e | ||
|
|
424f41d4bf | ||
|
|
4c6939807e | ||
|
|
b8235be612 | ||
|
|
86dee31c9a | ||
|
|
b401bb5fa4 | ||
|
|
7666b0aea9 | ||
|
|
39afa455de | ||
|
|
a58f7b78b0 | ||
|
|
1c001e07b7 | ||
|
|
8893675b36 | ||
|
|
4254459238 | ||
|
|
89b90723b7 | ||
|
|
6e5aec858f | ||
|
|
5611d148aa | ||
|
|
d5f60ce9d2 | ||
|
|
284fc90a53 | ||
|
|
e54ea20dbe | ||
|
|
415a1fb1fa | ||
|
|
60b3a788b3 | ||
|
|
62ff067bb9 | ||
|
|
3d774c8d82 | ||
|
|
61403f2f50 | ||
|
|
aa297d4ef7 | ||
|
|
712d318e7c | ||
|
|
cd3eb61cb5 | ||
|
|
8871050990 | ||
|
|
a6ac55b7b1 | ||
|
|
64e2464ec1 | ||
|
|
422bf3d968 | ||
|
|
bc1ad3a43a | ||
|
|
3b5d14765d | ||
|
|
9847f8f461 | ||
|
|
57c212f4f5 | ||
|
|
b2f5c19ce3 | ||
|
|
e65e96952f | ||
|
|
556a0eb194 | ||
|
|
5070d8d64f | ||
|
|
54cedbe03a | ||
|
|
fa26acfbae | ||
|
|
9ec0a71546 | ||
|
|
6448249512 | ||
|
|
a537bc18c3 | ||
|
|
33ab7aaaf0 | ||
|
|
f47b172f66 | ||
|
|
9d25e1dd35 | ||
|
|
015e30b085 | ||
|
|
8a2d5ae319 | ||
|
|
d2ff2c6a29 | ||
|
|
3cf4f92643 | ||
|
|
074488349a | ||
|
|
30a5587bca | ||
|
|
4082a246a3 | ||
|
|
9703fed94c | ||
|
|
5dc636a71c | ||
|
|
16d978db9a | ||
|
|
e711c52757 | ||
|
|
009162e22c | ||
|
|
cd30944c5e | ||
|
|
3e58cb5f92 | ||
|
|
aba9f8a94d | ||
|
|
dc19622bbb | ||
|
|
1babfde328 | ||
|
|
76d6f19d51 | ||
|
|
9ad35e65d3 | ||
|
|
d362a770d6 |
235
CHANGELOG.md
235
CHANGELOG.md
@@ -1,3 +1,238 @@
|
||||
## [4.61.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.61.0...v4.61.1) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **flashcards:** fix position snap-back by using api.set before decay ([30e16c8](https://github.com/antialias/soroban-abacus-flashcards/commit/30e16c8e5ac3bb25f2d54cf715dc6fb45adc4fcc))
|
||||
|
||||
## [4.61.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.60.0...v4.61.0) (2025-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **flashcards:** enable unbounded drag and position persistence ([ad1ad69](https://github.com/antialias/soroban-abacus-flashcards/commit/ad1ad690f014257b5a3c3f599e794205a11d286f))
|
||||
|
||||
## [4.60.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.59.1...v4.60.0) (2025-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** significantly increase mobile hero abacus size ([424f41d](https://github.com/antialias/soroban-abacus-flashcards/commit/424f41d4bfc1ddea068f8c110b495ebd5c0bb455))
|
||||
|
||||
## [4.59.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.59.0...v4.59.1) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** adjust hero abacus scale for optimal sizing across devices ([86dee31](https://github.com/antialias/soroban-abacus-flashcards/commit/86dee31c9a51ca0712f1b4181a4899d25374d403))
|
||||
* **homepage:** reduce mobile abacus scale to prevent scroll hint overlap ([b8235be](https://github.com/antialias/soroban-abacus-flashcards/commit/b8235be612c3f1dbd0da2b6cd1a935001b7dac9b))
|
||||
|
||||
## [4.59.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.58.2...v4.59.0) (2025-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** increase hero abacus size for better visibility ([7666b0a](https://github.com/antialias/soroban-abacus-flashcards/commit/7666b0aea949f2432a4d0f4648c1a366af3ea6d2))
|
||||
|
||||
## [4.58.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.58.1...v4.58.2) (2025-10-21)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **navbar:** prevent subtitle wrap and remove abacus emoji ([a58f7b7](https://github.com/antialias/soroban-abacus-flashcards/commit/a58f7b78b0020c85da523c36fdf6d70ad069736a))
|
||||
|
||||
## [4.58.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.58.0...v4.58.1) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **navbar:** apply glassmorphism to transparent mode, not scrolled mode ([8893675](https://github.com/antialias/soroban-abacus-flashcards/commit/8893675b36b1c1534c6fe7e57fa7e0cc55f198d6))
|
||||
|
||||
## [4.58.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.10...v4.58.0) (2025-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **navbar:** add glassmorphism effect to nav links when scrolled ([89b9072](https://github.com/antialias/soroban-abacus-flashcards/commit/89b90723b7a3fc9ed12da3ba8718fccb6ce0760f))
|
||||
|
||||
## [4.57.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.9...v4.57.10) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **navbar:** remove border artifact and add 10px bottom fade ([d5f60ce](https://github.com/antialias/soroban-abacus-flashcards/commit/d5f60ce9d2fbc2a870b3bb96f5365a0e04e0afc4))
|
||||
|
||||
|
||||
### Styles
|
||||
|
||||
* **navbar:** improve theming to match homepage dark aesthetic ([284fc90](https://github.com/antialias/soroban-abacus-flashcards/commit/284fc90a53f5f4868a3e41421760ebc813be12b5))
|
||||
|
||||
## [4.57.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.8...v4.57.9) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** add overflow hidden to Your Journey section ([415a1fb](https://github.com/antialias/soroban-abacus-flashcards/commit/415a1fb1faa263c9d69b4e781ce22da235ca2b66))
|
||||
|
||||
## [4.57.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.7...v4.57.8) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** adjust responsive breakpoints to prevent skill card clipping ([62ff067](https://github.com/antialias/soroban-abacus-flashcards/commit/62ff067bb956b17a9b3569eadc2a32abd24c27b8))
|
||||
|
||||
## [4.57.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.6...v4.57.7) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** restructure layout to center 1400px wide demo section ([61403f2](https://github.com/antialias/soroban-abacus-flashcards/commit/61403f2f506557b57716a298d4dc481d7853552f))
|
||||
* **homepage:** set min-width 1400px on container and remove max-width ([aa297d4](https://github.com/antialias/soroban-abacus-flashcards/commit/aa297d4ef7559473a147934766bfa3868552f58d))
|
||||
|
||||
## [4.57.6](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.5...v4.57.6) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** increase skill cards container width to prevent title wrapping ([cd3eb61](https://github.com/antialias/soroban-abacus-flashcards/commit/cd3eb61cb59e6faef37fbf609f37f7e2dc302e72))
|
||||
|
||||
## [4.57.5](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.4...v4.57.5) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** prevent text overflow in skill cards ([a6ac55b](https://github.com/antialias/soroban-abacus-flashcards/commit/a6ac55b7b161e0dd33a4dd5acc0df647b2a513aa))
|
||||
|
||||
## [4.57.4](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.3...v4.57.4) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** align container width breakpoint with grid columns ([422bf3d](https://github.com/antialias/soroban-abacus-flashcards/commit/422bf3d968b67e4683ac7ea7e487a84513f992f9))
|
||||
* **homepage:** make MiniAbacus fill container properly ([3b5d147](https://github.com/antialias/soroban-abacus-flashcards/commit/3b5d14765dfb2d61a76f66ba3ae09695ce88bb6d))
|
||||
* **homepage:** widen skill cards container to 650px ([bc1ad3a](https://github.com/antialias/soroban-abacus-flashcards/commit/bc1ad3a43a79570e1f9c61d5118d14ac4c201d71))
|
||||
|
||||
## [4.57.3](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.2...v4.57.3) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** increase abacus container width to 120px/150px ([57c212f](https://github.com/antialias/soroban-abacus-flashcards/commit/57c212f4f5be591f712e1c5610e1f323e56e15dd))
|
||||
|
||||
## [4.57.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.1...v4.57.2) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** increase skill card abacus container width ([e65e969](https://github.com/antialias/soroban-abacus-flashcards/commit/e65e96952f4e631722c73fc56d088fa3ff1ba858))
|
||||
|
||||
## [4.57.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.57.0...v4.57.1) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** add overflow hidden to skill cards ([5070d8d](https://github.com/antialias/soroban-abacus-flashcards/commit/5070d8d64f7f58887ff7259bee9ce5166c4f8af8))
|
||||
|
||||
## [4.57.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.56.0...v4.57.0) (2025-10-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** make skills section responsive with emojis ([9ec0a71](https://github.com/antialias/soroban-abacus-flashcards/commit/9ec0a71546ee483233ed7866dae97345bf2384d7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** prevent skill card overflow ([fa26acf](https://github.com/antialias/soroban-abacus-flashcards/commit/fa26acfbaef1a04bb225956b2f684cd5023b56fa))
|
||||
|
||||
## [4.56.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.55.0...v4.56.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** emphasize single-player and observer modes ([a537bc1](https://github.com/antialias/soroban-abacus-flashcards/commit/a537bc18c34d94ca931e483ea01e497d6f5d4e5b))
|
||||
|
||||
## [4.55.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.54.0...v4.55.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** update section title to "The Arcade" ([f47b172](https://github.com/antialias/soroban-abacus-flashcards/commit/f47b172f66bee0017c11d8f129f5b83f2ef3dcd9))
|
||||
|
||||
## [4.54.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.53.0...v4.54.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive levels slider to replace static progression ([8a2d5ae](https://github.com/antialias/soroban-abacus-flashcards/commit/8a2d5ae319af8fd66010dd5538e4b82f7fb35d40))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **levels:** extract levels slider into shared component ([015e30b](https://github.com/antialias/soroban-abacus-flashcards/commit/015e30b085ad2ef798ffd6f7f6716269e3256651))
|
||||
|
||||
## [4.53.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.2...v4.53.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive draggable flashcards with physics ([0744883](https://github.com/antialias/soroban-abacus-flashcards/commit/074488349a3ec480548223c313006aa1e9e64e5c))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** merge flashcard display with create button section ([3cf4f92](https://github.com/antialias/soroban-abacus-flashcards/commit/3cf4f92643306f055188ede508557515ef5efe98))
|
||||
|
||||
## [4.52.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.1...v4.52.2) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** use actual container dimensions for flashcard positioning ([4082a24](https://github.com/antialias/soroban-abacus-flashcards/commit/4082a246a33ea67617b762d5b7490a8c9af0ad49))
|
||||
|
||||
## [4.52.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.52.0...v4.52.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** correct flashcard transform rendering ([5dc636a](https://github.com/antialias/soroban-abacus-flashcards/commit/5dc636a71c15db28c029fd4f60e4a6c95620f953))
|
||||
|
||||
## [4.52.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.51.0...v4.52.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive draggable flashcards with physics ([e711c52](https://github.com/antialias/soroban-abacus-flashcards/commit/e711c527574412de2f9d451c7985c4f8667d269a))
|
||||
|
||||
## [4.51.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.50.1...v4.51.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** create fancy flashcard display with spread-out cards ([cd30944](https://github.com/antialias/soroban-abacus-flashcards/commit/cd30944c5e067f84d00dfdf41c37580acc589548))
|
||||
|
||||
## [4.50.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.50.0...v4.50.1) (2025-10-20)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** set fixed width for learning panel to prevent layout shift ([dc19622](https://github.com/antialias/soroban-abacus-flashcards/commit/dc19622bbba2fead8cd9c0b2bda3a38abba0bd41))
|
||||
* **homepage:** set fixed width for tutorial panel to prevent layout shift ([aba9f8a](https://github.com/antialias/soroban-abacus-flashcards/commit/aba9f8a94d50590cf94b6cd87f85b497e89045e7))
|
||||
|
||||
## [4.50.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.49.1...v4.50.0) (2025-10-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **homepage:** add interactive learning panels with animated mini-tutorials ([76d6f19](https://github.com/antialias/soroban-abacus-flashcards/commit/76d6f19d51fe4b9594998ae4e0a8823aff389854))
|
||||
|
||||
## [4.49.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.49.0...v4.49.1) (2025-10-20)
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* **homepage:** streamline homepage sections ([d362a77](https://github.com/antialias/soroban-abacus-flashcards/commit/d362a770d63405efee5ef8a896d34e783dd11de2))
|
||||
|
||||
## [4.49.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.48.5...v4.49.0) (2025-10-20)
|
||||
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
"@tanstack/react-form": "^0.19.0",
|
||||
"@tanstack/react-query": "^5.90.2",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@use-gesture/react": "^10.3.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.4.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
|
||||
@@ -1,388 +1,11 @@
|
||||
'use client'
|
||||
|
||||
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 { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
|
||||
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: 'Pre-1st Dan',
|
||||
name: 'Jun-Shodan',
|
||||
minScore: 90,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '1st Dan',
|
||||
name: 'Shodan',
|
||||
minScore: 100,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Dan',
|
||||
name: 'Nidan',
|
||||
minScore: 120,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Dan',
|
||||
name: 'Sandan',
|
||||
minScore: 140,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '4th Dan',
|
||||
name: 'Yondan',
|
||||
minScore: 160,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '5th Dan',
|
||||
name: 'Godan',
|
||||
minScore: 180,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '6th Dan',
|
||||
name: 'Rokudan',
|
||||
minScore: 200,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '7th Dan',
|
||||
name: 'Nanadan',
|
||||
minScore: 220,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '8th Dan',
|
||||
name: 'Hachidan',
|
||||
minScore: 250,
|
||||
emoji: '🧝♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '9th Dan',
|
||||
name: 'Kudan',
|
||||
minScore: 270,
|
||||
emoji: '🧝♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '10th Dan',
|
||||
name: 'Judan',
|
||||
minScore: 290,
|
||||
emoji: '👑',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
] 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 [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.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: 350, friction: 45 },
|
||||
})
|
||||
|
||||
// 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 =
|
||||
animatedDigits.length > 15
|
||||
? BigInt(animatedDigits || '0')
|
||||
: Number.parseInt(animatedDigits || '0', 10)
|
||||
|
||||
// Dark theme styles matching the 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,
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWithNav navTitle="Kyu & Dan Levels" navEmoji="📊">
|
||||
<div className={css({ bg: 'gray.900', minHeight: '100vh', pb: '12' })}>
|
||||
@@ -449,471 +72,7 @@ export default function LevelsPage() {
|
||||
{/* Main content */}
|
||||
<div className={container({ maxW: '6xl', px: '4', py: '12' })}>
|
||||
<section className={stack({ gap: '8' })}>
|
||||
{/* Current Level Display */}
|
||||
<div
|
||||
onMouseEnter={() => setIsPaneHovered(true)}
|
||||
onMouseLeave={() => setIsPaneHovered(false)}
|
||||
className={css({
|
||||
bg: 'transparent',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.500'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.500'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.500'
|
||||
: 'amber.500',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
height: { base: 'auto', md: '700px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Abacus-themed Radix Slider */}
|
||||
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
|
||||
<div className={css({ position: 'relative', py: '12' })}>
|
||||
{/* Emoji tick marks */}
|
||||
<div
|
||||
className={css({
|
||||
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 */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: '1',
|
||||
fontSize: 'xs',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
<span>10th Kyu</span>
|
||||
<span>1st Kyu</span>
|
||||
<span>10th Dan</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abacus Display with Level Details */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '4',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
{/* 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,
|
||||
})}
|
||||
>
|
||||
<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',
|
||||
})}
|
||||
>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6',
|
||||
justifyContent: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>
|
||||
Master (Dan ranks)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<LevelSliderDisplay />
|
||||
|
||||
{/* Info Section */}
|
||||
<div
|
||||
|
||||
@@ -9,24 +9,33 @@ import { PageWithNav } from '@/components/PageWithNav'
|
||||
import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
import { getAvailableGames } from '@/lib/arcade/game-registry'
|
||||
import { InteractiveFlashcards } from '@/components/InteractiveFlashcards'
|
||||
import { LevelSliderDisplay } from '@/components/LevelSliderDisplay'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
import { token } from '../../styled-system/tokens'
|
||||
|
||||
// Mini abacus that cycles through random 3-digit numbers
|
||||
function MiniAbacus() {
|
||||
const [currentValue, setCurrentValue] = useState(123)
|
||||
// Mini abacus that cycles through a sequence of values
|
||||
function MiniAbacus({
|
||||
values,
|
||||
columns = 3,
|
||||
interval = 2500,
|
||||
}: {
|
||||
values: number[]
|
||||
columns?: number
|
||||
interval?: number
|
||||
}) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
useEffect(() => {
|
||||
// Cycle through random 3-digit numbers every 2.5 seconds
|
||||
const interval = setInterval(() => {
|
||||
const randomNum = Math.floor(Math.random() * 1000) // 0-999
|
||||
setCurrentValue(randomNum)
|
||||
}, 2500)
|
||||
if (values.length === 0) return
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
const timer = setInterval(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % values.length)
|
||||
}, interval)
|
||||
|
||||
return () => clearInterval(timer)
|
||||
}, [values, interval])
|
||||
|
||||
// Dark theme styles for the abacus
|
||||
const darkStyles = {
|
||||
@@ -45,17 +54,17 @@ function MiniAbacus() {
|
||||
return (
|
||||
<div
|
||||
className={css({
|
||||
width: '75px',
|
||||
height: '80px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
})}
|
||||
>
|
||||
<div className={css({ transform: 'scale(0.6)', transformOrigin: 'center center' })}>
|
||||
<div className={css({ transform: 'scale(0.75)', transformOrigin: 'center center' })}>
|
||||
<AbacusReact
|
||||
value={currentValue}
|
||||
columns={3}
|
||||
value={values[currentIndex] || 0}
|
||||
columns={columns}
|
||||
beadShape={appConfig.beadShape}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
@@ -65,15 +74,46 @@ function MiniAbacus() {
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
// Extract just the "Friends of 5" step (2+3=5) for homepage demo
|
||||
const [selectedSkillIndex, setSelectedSkillIndex] = useState(1) // Default to "Friends techniques"
|
||||
const fullTutorial = getTutorialForEditor()
|
||||
const friendsOf5Tutorial = {
|
||||
...fullTutorial,
|
||||
id: 'friends-of-5-demo',
|
||||
title: 'Friends of 5',
|
||||
description: 'Learn the "Friends of 5" technique: adding 3 to make 5',
|
||||
steps: fullTutorial.steps.filter((step) => step.id === 'complement-2'),
|
||||
}
|
||||
|
||||
// Create different tutorials for each skill level
|
||||
const skillTutorials = [
|
||||
// Skill 0: Read and set numbers (0-9999)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'read-numbers-demo',
|
||||
title: 'Read and Set Numbers',
|
||||
description: 'Master abacus number representation from zero to thousands',
|
||||
steps: fullTutorial.steps.filter((step) => step.id.startsWith('basic-')),
|
||||
},
|
||||
// Skill 1: Friends techniques (5 = 2+3)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'friends-of-5-demo',
|
||||
title: 'Friends of 5',
|
||||
description: 'Add and subtract using complement pairs: 5 = 2+3',
|
||||
steps: fullTutorial.steps.filter((step) => step.id === 'complement-2'),
|
||||
},
|
||||
// Skill 2: Multiply & divide (12×34)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'multiply-demo',
|
||||
title: 'Multiplication',
|
||||
description: 'Fluent multi-digit calculations with advanced techniques',
|
||||
steps: fullTutorial.steps.filter((step) => step.id.includes('complement')).slice(0, 3),
|
||||
},
|
||||
// Skill 3: Mental calculation (Speed math)
|
||||
{
|
||||
...fullTutorial,
|
||||
id: 'mental-calc-demo',
|
||||
title: 'Mental Calculation',
|
||||
description: 'Visualize and compute without the physical tool (Anzan)',
|
||||
steps: fullTutorial.steps.slice(-3),
|
||||
},
|
||||
]
|
||||
|
||||
const selectedTutorial = skillTutorials[selectedSkillIndex]
|
||||
|
||||
return (
|
||||
<HomeHeroProvider>
|
||||
@@ -82,125 +122,149 @@ export default function HomePage() {
|
||||
{/* Hero Section with Large Interactive Abacus */}
|
||||
<HeroAbacus />
|
||||
|
||||
{/* Main content container */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
|
||||
{/* Learn by Doing Section - with inline tutorial demo */}
|
||||
<section className={stack({ gap: '8', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Learn by Doing
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Interactive tutorials teach you step-by-step. Try this example right now:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live demo and learning objectives */}
|
||||
<div
|
||||
{/* Learn by Doing Section - with inline tutorial demo */}
|
||||
<section className={stack({ gap: '8', mb: '16', px: '4', py: '12' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Learn by Doing
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Interactive tutorials teach you step-by-step. Try this example right now:
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Live demo and learning objectives */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
minW: { base: '100%', xl: '1400px' },
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: { base: 'column', xl: 'row' },
|
||||
gap: '8',
|
||||
alignItems: { base: 'center', xl: 'flex-start' },
|
||||
})}
|
||||
>
|
||||
{/* Tutorial on the left */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexDirection: { base: 'column', md: 'row' },
|
||||
gap: '8',
|
||||
alignItems: { base: 'center', md: 'flex-start' },
|
||||
flex: '1',
|
||||
minW: { base: '100%', xl: '500px' },
|
||||
maxW: { base: '100%', xl: '500px' },
|
||||
})}
|
||||
>
|
||||
{/* Tutorial on the left */}
|
||||
<div className={css({ flex: '1' })}>
|
||||
<TutorialPlayer
|
||||
tutorial={friendsOf5Tutorial}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
hideNavigation={true}
|
||||
hideTooltip={true}
|
||||
silentErrors={true}
|
||||
abacusColumns={1}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
<TutorialPlayer
|
||||
key={selectedTutorial.id}
|
||||
tutorial={selectedTutorial}
|
||||
isDebugMode={false}
|
||||
showDebugPanel={false}
|
||||
hideNavigation={true}
|
||||
hideTooltip={true}
|
||||
silentErrors={true}
|
||||
abacusColumns={1}
|
||||
theme="dark"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* What you'll learn on the right */}
|
||||
<div
|
||||
{/* What you'll learn on the right */}
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
w: { base: '100%', lg: '800px' },
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
minW: '340px',
|
||||
maxW: { base: '100%', md: '420px' },
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: '2xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '6',
|
||||
})}
|
||||
>
|
||||
What You'll Learn
|
||||
</h3>
|
||||
<div className={stack({ gap: '5' })}>
|
||||
{[
|
||||
{
|
||||
icon: '🔢',
|
||||
title: 'Read and set numbers',
|
||||
desc: 'Master abacus number representation from zero to thousands',
|
||||
example: '0-9999',
|
||||
badge: 'Foundation',
|
||||
},
|
||||
{
|
||||
icon: '🤝',
|
||||
title: 'Friends techniques',
|
||||
desc: 'Add and subtract using complement pairs and mental shortcuts',
|
||||
example: '5 = 2+3',
|
||||
badge: 'Core',
|
||||
},
|
||||
{
|
||||
icon: '✖️➗',
|
||||
title: 'Multiply & divide',
|
||||
desc: 'Fluent multi-digit calculations with advanced techniques',
|
||||
example: '12×34',
|
||||
badge: 'Advanced',
|
||||
},
|
||||
{
|
||||
icon: '🧠',
|
||||
title: 'Mental calculation',
|
||||
desc: 'Visualize and compute without the physical tool (Anzan)',
|
||||
example: 'Speed math',
|
||||
badge: 'Expert',
|
||||
},
|
||||
].map((skill, i) => (
|
||||
What You'll Learn
|
||||
</h3>
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '5' })}>
|
||||
{[
|
||||
{
|
||||
title: '📖 Read and set numbers',
|
||||
desc: 'Master abacus number representation from zero to thousands',
|
||||
example: '0-9999',
|
||||
badge: 'Foundation',
|
||||
values: [0, 1, 2, 3, 4, 5, 10, 50, 100, 500, 999],
|
||||
columns: 3,
|
||||
},
|
||||
{
|
||||
title: '🤝 Friends techniques',
|
||||
desc: 'Add and subtract using complement pairs and mental shortcuts',
|
||||
example: '5 = 2+3',
|
||||
badge: 'Core',
|
||||
values: [2, 5, 3],
|
||||
columns: 1,
|
||||
},
|
||||
{
|
||||
title: '✖️ Multiply & divide',
|
||||
desc: 'Fluent multi-digit calculations with advanced techniques',
|
||||
example: '12×34',
|
||||
badge: 'Advanced',
|
||||
values: [12, 24, 36, 48],
|
||||
columns: 2,
|
||||
},
|
||||
{
|
||||
title: '🧠 Mental calculation',
|
||||
desc: 'Visualize and compute without the physical tool (Anzan)',
|
||||
example: 'Speed math',
|
||||
badge: 'Expert',
|
||||
values: [7, 14, 21, 28, 35],
|
||||
columns: 2,
|
||||
},
|
||||
].map((skill, i) => {
|
||||
const isSelected = i === selectedSkillIndex
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
onClick={() => setSelectedSkillIndex(i)}
|
||||
className={css({
|
||||
bg: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.15), rgba(250, 204, 21, 0.08))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.03))',
|
||||
borderRadius: 'xl',
|
||||
p: '4',
|
||||
p: { base: '4', lg: '5' },
|
||||
border: '1px solid',
|
||||
borderColor: 'rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.4)'
|
||||
: 'rgba(255, 255, 255, 0.15)',
|
||||
boxShadow: isSelected
|
||||
? '0 6px 16px rgba(250, 204, 21, 0.2)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.3)',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
_hover: {
|
||||
bg: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
|
||||
borderColor: 'rgba(255, 255, 255, 0.25)',
|
||||
bg: isSelected
|
||||
? 'linear-gradient(135deg, rgba(250, 204, 21, 0.2), rgba(250, 204, 21, 0.12))'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05))',
|
||||
borderColor: isSelected
|
||||
? 'rgba(250, 204, 21, 0.5)'
|
||||
: 'rgba(255, 255, 255, 0.25)',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 6px 16px rgba(0, 0, 0, 0.4)',
|
||||
boxShadow: isSelected
|
||||
? '0 8px 20px rgba(250, 204, 21, 0.3)'
|
||||
: '0 6px 16px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
@@ -208,20 +272,28 @@ export default function HomePage() {
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
width: '75px',
|
||||
height: '115px',
|
||||
width: { base: '120px', lg: '150px' },
|
||||
minHeight: { base: '115px', lg: '140px' },
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
textAlign: 'center',
|
||||
bg: 'rgba(255, 255, 255, 0.1)',
|
||||
bg: isSelected
|
||||
? 'rgba(250, 204, 21, 0.15)'
|
||||
: 'rgba(255, 255, 255, 0.1)',
|
||||
borderRadius: 'lg',
|
||||
})}
|
||||
>
|
||||
{i === 0 ? <MiniAbacus /> : skill.icon}
|
||||
<MiniAbacus values={skill.values} columns={skill.columns} />
|
||||
</div>
|
||||
<div className={stack({ gap: '2', flex: '1' })}>
|
||||
<div className={hstack({ gap: '2', alignItems: 'center' })}>
|
||||
<div className={stack({ gap: '2', flex: '1', minWidth: '0' })}>
|
||||
<div
|
||||
className={hstack({
|
||||
gap: '2',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
color: 'white',
|
||||
@@ -273,13 +345,16 @@ export default function HomePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Main content container */}
|
||||
<div className={container({ maxW: '7xl', px: '4', py: '12' })}>
|
||||
{/* Current Offerings Section */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
@@ -291,10 +366,11 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Available Now
|
||||
The Arcade
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md' })}>
|
||||
Foundation tutorials and reinforcement games ready to use
|
||||
Single-player challenges and multiplayer battles in networked rooms. Invite
|
||||
friends to play or watch live.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -320,51 +396,8 @@ export default function HomePage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* For Kids & Families Section */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
fontSize: { base: '2xl', md: '3xl' },
|
||||
fontWeight: 'bold',
|
||||
color: 'white',
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
For Kids & Families
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Simple enough for kids to start on their own, structured enough for parents to
|
||||
trust
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '8' })}>
|
||||
<FeaturePanel
|
||||
icon="🧒"
|
||||
title="Self-Directed for Children"
|
||||
features={[
|
||||
'Big, obvious buttons and clear instructions',
|
||||
'Progress at your own pace',
|
||||
'Works with or without a physical abacus',
|
||||
]}
|
||||
accentColor="purple"
|
||||
/>
|
||||
<FeaturePanel
|
||||
icon="👨👩👧"
|
||||
title="Trusted by Parents"
|
||||
features={[
|
||||
'Structured curriculum following Japanese methods',
|
||||
'Traditional kyu/dan progression levels',
|
||||
'Track progress and celebrate achievements',
|
||||
]}
|
||||
accentColor="blue"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Progression Visualization */}
|
||||
<section className={stack({ gap: '6', mb: '16' })}>
|
||||
<section className={stack({ gap: '6', mb: '16', overflow: 'hidden' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
@@ -381,150 +414,11 @@ export default function HomePage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/levels"
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
rounded: 'xl',
|
||||
p: '8',
|
||||
display: 'block',
|
||||
transition: 'all 0.2s',
|
||||
cursor: 'pointer',
|
||||
position: 'relative',
|
||||
_hover: {
|
||||
bg: 'rgba(0, 0, 0, 0.5)',
|
||||
borderColor: 'violet.500',
|
||||
transform: 'translateY(-2px)',
|
||||
boxShadow: '0 8px 16px rgba(124, 58, 237, 0.2)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Subtle arrow indicator */}
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
top: '4',
|
||||
right: '4',
|
||||
fontSize: 'xl',
|
||||
color: 'gray.500',
|
||||
transition: 'all 0.2s',
|
||||
_groupHover: {
|
||||
color: 'violet.400',
|
||||
transform: 'translateX(4px)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
→
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
gap: '4',
|
||||
flexWrap: 'wrap',
|
||||
})}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{
|
||||
level: '10 Kyu',
|
||||
label: 'Beginner',
|
||||
color: 'colors.green.400',
|
||||
emoji: '🧒',
|
||||
},
|
||||
{
|
||||
level: '5 Kyu',
|
||||
label: 'Intermediate',
|
||||
color: 'colors.blue.400',
|
||||
emoji: '🧑',
|
||||
},
|
||||
{
|
||||
level: '1 Kyu',
|
||||
label: 'Advanced',
|
||||
color: 'colors.violet.400',
|
||||
emoji: '🧔',
|
||||
},
|
||||
{ level: 'Dan', label: 'Master', color: 'colors.amber.400', emoji: '🧙' },
|
||||
] as const
|
||||
).map((stage, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={stack({
|
||||
gap: '0',
|
||||
textAlign: 'center',
|
||||
flex: '1',
|
||||
position: 'relative',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: '5xl',
|
||||
mb: '0',
|
||||
})}
|
||||
>
|
||||
{stage.emoji}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
mt: '-2',
|
||||
})}
|
||||
style={{ color: token(stage.color) }}
|
||||
>
|
||||
{stage.level}
|
||||
</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
color: 'gray.300',
|
||||
})}
|
||||
>
|
||||
{stage.label}
|
||||
</div>
|
||||
{i < 3 && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '100%',
|
||||
marginLeft: '0.5rem',
|
||||
top: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
fontSize: '20px',
|
||||
color: '#9ca3af',
|
||||
}}
|
||||
className={css({
|
||||
display: { base: 'none', md: 'block' },
|
||||
})}
|
||||
>
|
||||
→
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
fontSize: '14px',
|
||||
color: '#d1d5db',
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
className={css({
|
||||
mt: '6',
|
||||
})}
|
||||
>
|
||||
Click to learn about the official Japanese ranking system →
|
||||
</div>
|
||||
</Link>
|
||||
<LevelSliderDisplay />
|
||||
</section>
|
||||
|
||||
{/* Additional Tools Section */}
|
||||
<section className={stack({ gap: '6' })}>
|
||||
{/* Flashcard Generator Section */}
|
||||
<section className={stack({ gap: '8', mb: '16' })}>
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<h2
|
||||
className={css({
|
||||
@@ -534,35 +428,101 @@ export default function HomePage() {
|
||||
mb: '2',
|
||||
})}
|
||||
>
|
||||
Additional Tools
|
||||
Create Custom Flashcards
|
||||
</h2>
|
||||
<p className={css({ color: 'gray.400', fontSize: 'md', maxW: '2xl', mx: 'auto' })}>
|
||||
Design beautiful flashcards for learning and practice
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={grid({ columns: { base: 1, lg: 2 }, gap: '8' })}>
|
||||
<FeaturePanel
|
||||
icon="🎨"
|
||||
title="Flashcard Creator"
|
||||
features={[
|
||||
'Multiple formats: PDF, PNG, SVG, HTML',
|
||||
'Custom bead shapes, colors, and layouts',
|
||||
'All paper sizes: A3, A4, A5, US Letter',
|
||||
]}
|
||||
accentColor="blue"
|
||||
ctaText="Create Flashcards →"
|
||||
ctaHref="/create"
|
||||
/>
|
||||
<FeaturePanel
|
||||
icon="🧮"
|
||||
title="Interactive Abacus"
|
||||
features={[
|
||||
'Practice anytime in your browser',
|
||||
'Multiple color schemes and bead styles',
|
||||
'Sound effects and animations',
|
||||
]}
|
||||
accentColor="purple"
|
||||
ctaText="Try the Abacus →"
|
||||
ctaHref="/guide"
|
||||
/>
|
||||
{/* Combined interactive display and CTA */}
|
||||
<div
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
shadow: 'lg',
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
})}
|
||||
>
|
||||
{/* Interactive Flashcards Display */}
|
||||
<div className={css({ mb: '8' })}>
|
||||
<InteractiveFlashcards />
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className={grid({ columns: { base: 1, md: 3 }, gap: '4', mb: '6' })}>
|
||||
{[
|
||||
{
|
||||
icon: '📄',
|
||||
title: 'Multiple Formats',
|
||||
desc: 'PDF, PNG, SVG, HTML',
|
||||
},
|
||||
{
|
||||
icon: '🎨',
|
||||
title: 'Customizable',
|
||||
desc: 'Bead shapes, colors, layouts',
|
||||
},
|
||||
{
|
||||
icon: '📐',
|
||||
title: 'All Paper Sizes',
|
||||
desc: 'A3, A4, A5, US Letter',
|
||||
},
|
||||
].map((feature, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
p: '4',
|
||||
rounded: 'lg',
|
||||
bg: 'rgba(255, 255, 255, 0.05)',
|
||||
})}
|
||||
>
|
||||
<div className={css({ fontSize: '2xl', mb: '2' })}>{feature.icon}</div>
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'sm',
|
||||
fontWeight: 'semibold',
|
||||
color: 'white',
|
||||
mb: '1',
|
||||
})}
|
||||
>
|
||||
{feature.title}
|
||||
</div>
|
||||
<div className={css({ fontSize: 'xs', color: 'gray.400' })}>
|
||||
{feature.desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* CTA Button */}
|
||||
<div className={css({ textAlign: 'center' })}>
|
||||
<Link
|
||||
href="/create"
|
||||
className={css({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
bg: 'blue.600',
|
||||
color: 'white',
|
||||
px: '6',
|
||||
py: '3',
|
||||
rounded: 'lg',
|
||||
fontWeight: 'semibold',
|
||||
transition: 'all 0.2s',
|
||||
_hover: {
|
||||
bg: 'blue.500',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<span>Create Flashcards</span>
|
||||
<span>→</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -576,10 +576,11 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
<Tooltip.Provider delayDuration={200}>
|
||||
<header
|
||||
className={css({
|
||||
bg: isTransparent ? 'transparent' : 'white',
|
||||
shadow: isTransparent ? 'none' : 'sm',
|
||||
bg: isTransparent ? 'transparent' : 'rgba(0, 0, 0, 0.5)',
|
||||
backdropFilter: isTransparent ? 'none' : 'blur(12px)',
|
||||
shadow: isTransparent ? 'none' : 'lg',
|
||||
borderBottom: isTransparent ? 'none' : '1px solid',
|
||||
borderColor: isTransparent ? 'transparent' : 'gray.200',
|
||||
borderColor: isTransparent ? 'transparent' : 'rgba(139, 92, 246, 0.2)',
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
@@ -599,7 +600,7 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
flexDirection: 'column',
|
||||
gap: '0',
|
||||
textDecoration: 'none',
|
||||
_hover: { '& > .brand-name': { color: 'brand.900' } },
|
||||
_hover: { '& > .brand-name': { color: 'rgba(196, 181, 253, 1)' } },
|
||||
opacity: 0,
|
||||
animation: 'fadeIn 0.3s ease-out forwards',
|
||||
})}
|
||||
@@ -608,10 +609,10 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'brand.800',
|
||||
color: 'rgba(255, 255, 255, 0.95)',
|
||||
})}
|
||||
>
|
||||
🧮 Abaci One
|
||||
Abaci One
|
||||
</span>
|
||||
<Tooltip.Root>
|
||||
<Tooltip.Trigger asChild>
|
||||
@@ -619,10 +620,11 @@ export function AppNavBar({ variant = 'full', navSlot }: AppNavBarProps) {
|
||||
className={css({
|
||||
fontSize: 'xs',
|
||||
fontWeight: 'medium',
|
||||
color: 'brand.600',
|
||||
color: 'rgba(196, 181, 253, 0.8)',
|
||||
fontStyle: 'italic',
|
||||
cursor: 'help',
|
||||
_hover: { color: 'brand.700' },
|
||||
whiteSpace: 'nowrap',
|
||||
_hover: { color: 'rgba(196, 181, 253, 1)' },
|
||||
})}
|
||||
>
|
||||
{subtitle.text}
|
||||
@@ -717,6 +719,9 @@ function NavLink({
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
style={{
|
||||
backdropFilter: isTransparent ? 'blur(8px)' : 'none',
|
||||
}}
|
||||
className={css({
|
||||
px: { base: '4', md: '3' },
|
||||
py: { base: '3', md: '2' },
|
||||
@@ -729,24 +734,33 @@ function NavLink({
|
||||
? 'white'
|
||||
: 'rgba(255, 255, 255, 0.8)'
|
||||
: isActive
|
||||
? 'brand.700'
|
||||
: 'gray.600',
|
||||
? 'rgba(196, 181, 253, 1)'
|
||||
: 'rgba(209, 213, 219, 0.9)',
|
||||
bg: isTransparent
|
||||
? isActive
|
||||
? 'rgba(255, 255, 255, 0.15)'
|
||||
: 'transparent'
|
||||
? 'rgba(255, 255, 255, 0.2)'
|
||||
: 'rgba(255, 255, 255, 0.08)'
|
||||
: isActive
|
||||
? 'brand.50'
|
||||
? 'rgba(139, 92, 246, 0.2)'
|
||||
: 'transparent',
|
||||
border: isTransparent ? '1px solid' : 'none',
|
||||
borderColor: isTransparent
|
||||
? isActive
|
||||
? 'rgba(255, 255, 255, 0.3)'
|
||||
: 'rgba(255, 255, 255, 0.15)'
|
||||
: 'transparent',
|
||||
rounded: 'lg',
|
||||
transition: 'all',
|
||||
textDecoration: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
boxShadow: isTransparent ? '0 2px 8px rgba(0, 0, 0, 0.2)' : 'none',
|
||||
_hover: {
|
||||
color: isTransparent ? 'white' : isActive ? 'brand.800' : 'gray.900',
|
||||
bg: isTransparent ? 'rgba(255, 255, 255, 0.2)' : isActive ? 'brand.100' : 'gray.50',
|
||||
color: isTransparent ? 'white' : 'rgba(196, 181, 253, 1)',
|
||||
bg: isTransparent ? 'rgba(255, 255, 255, 0.25)' : 'rgba(139, 92, 246, 0.25)',
|
||||
borderColor: isTransparent ? 'rgba(255, 255, 255, 0.4)' : 'transparent',
|
||||
boxShadow: isTransparent ? '0 4px 12px rgba(0, 0, 0, 0.3)' : 'none',
|
||||
},
|
||||
})}
|
||||
>
|
||||
|
||||
@@ -131,7 +131,7 @@ export function HeroAbacus() {
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
transform: { base: 'scale(2)', md: 'scale(3)', lg: 'scale(4)' },
|
||||
transform: { base: 'scale(3.5)', md: 'scale(3.5)', lg: 'scale(4.25)' },
|
||||
transformOrigin: 'center center',
|
||||
transition: 'all 0.5s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
})}
|
||||
|
||||
316
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
316
apps/web/src/components/InteractiveFlashcards.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { useDrag } from '@use-gesture/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { animated, config, to, useSpring } from '@react-spring/web'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
interface Flashcard {
|
||||
id: number
|
||||
number: number
|
||||
initialX: number
|
||||
initialY: number
|
||||
initialRotation: number
|
||||
zIndex: number
|
||||
}
|
||||
|
||||
/**
|
||||
* InteractiveFlashcards - A fun, physics-based flashcard display
|
||||
* Users can drag and throw flashcards around with realistic momentum
|
||||
*/
|
||||
export function InteractiveFlashcards() {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
// Generate 8-15 random flashcards (client-side only to avoid hydration errors)
|
||||
const [cards, setCards] = useState<Flashcard[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
// Double rAF pattern - ensures layout is fully complete
|
||||
const frameId1 = requestAnimationFrame(() => {
|
||||
const frameId2 = requestAnimationFrame(() => {
|
||||
if (!containerRef.current) return
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth
|
||||
const containerHeight = containerRef.current.offsetHeight
|
||||
|
||||
// Only generate cards once we have proper dimensions
|
||||
if (containerWidth < 100 || containerHeight < 100) {
|
||||
return
|
||||
}
|
||||
|
||||
const count = Math.floor(Math.random() * 8) + 8 // 8-15 cards
|
||||
const generated: Flashcard[] = []
|
||||
|
||||
// Position cards within the actual container bounds
|
||||
const cardWidth = 120 // approximate card width
|
||||
const cardHeight = 200 // approximate card height
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const card = {
|
||||
id: i,
|
||||
number: Math.floor(Math.random() * 900) + 100, // 100-999
|
||||
initialX: Math.random() * (containerWidth - cardWidth - 40) + 20,
|
||||
initialY: Math.random() * (containerHeight - cardHeight - 40) + 20,
|
||||
initialRotation: Math.random() * 40 - 20, // -20 to 20 degrees
|
||||
zIndex: i,
|
||||
}
|
||||
generated.push(card)
|
||||
}
|
||||
|
||||
setCards(generated)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Note: can't cancel nested rAF properly, but component cleanup will prevent state updates
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={css({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
maxW: '1200px',
|
||||
mx: 'auto',
|
||||
height: { base: '400px', md: '500px' },
|
||||
overflow: 'visible',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'xl',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
})}
|
||||
>
|
||||
{cards.map((card) => (
|
||||
<DraggableCard key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DraggableCardProps {
|
||||
card: Flashcard
|
||||
}
|
||||
|
||||
function DraggableCard({ card }: DraggableCardProps) {
|
||||
// Track the card's current position in state (separate from the animation values)
|
||||
const currentPositionRef = useRef({
|
||||
x: card.initialX,
|
||||
y: card.initialY,
|
||||
rotation: card.initialRotation,
|
||||
})
|
||||
|
||||
const [{ x, y, rotation, scale }, api] = useSpring(() => ({
|
||||
x: card.initialX,
|
||||
y: card.initialY,
|
||||
rotation: card.initialRotation,
|
||||
scale: 1,
|
||||
config: config.wobbly,
|
||||
}))
|
||||
|
||||
const [zIndex, setZIndex] = useState(card.zIndex)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const dragOffsetRef = useRef({ x: 0, y: 0 })
|
||||
const lastVelocityRef = useRef({ vx: 0, vy: 0 })
|
||||
const velocityHistoryRef = useRef<Array<{ vx: number; vy: number }>>([])
|
||||
const [transformOrigin, setTransformOrigin] = useState('center center')
|
||||
|
||||
const bind = useDrag(
|
||||
({ down, movement: [mx, my], velocity: [vx, vy], direction: [dx, dy], first, xy }) => {
|
||||
// Bring card to front when dragging
|
||||
if (down) {
|
||||
setZIndex(1000)
|
||||
|
||||
// Calculate drag offset from card center on first touch
|
||||
if (first && cardRef.current) {
|
||||
const cardRect = cardRef.current.getBoundingClientRect()
|
||||
const cardWidth = cardRect.width
|
||||
const cardHeight = cardRect.height
|
||||
|
||||
// xy is in viewport coordinates, convert to position relative to card
|
||||
const clickRelativeToCard = {
|
||||
x: xy[0] - cardRect.left,
|
||||
y: xy[1] - cardRect.top,
|
||||
}
|
||||
|
||||
// Calculate offset from card center
|
||||
const cardCenterX = cardWidth / 2
|
||||
const cardCenterY = cardHeight / 2
|
||||
const offsetX = clickRelativeToCard.x - cardCenterX
|
||||
const offsetY = clickRelativeToCard.y - cardCenterY
|
||||
|
||||
dragOffsetRef.current = { x: offsetX, y: offsetY }
|
||||
|
||||
// Convert offset to transform-origin (50% + offset as percentage of card size)
|
||||
const originX = 50 + (offsetX / cardWidth) * 100
|
||||
const originY = 50 + (offsetY / cardHeight) * 100
|
||||
const transformOriginValue = `${originX}% ${originY}%`
|
||||
|
||||
console.log(
|
||||
`Drag start: click at (${clickRelativeToCard.x.toFixed(0)}, ${clickRelativeToCard.y.toFixed(0)}) in card, offset from center: (${offsetX.toFixed(0)}, ${offsetY.toFixed(0)}), origin: ${transformOriginValue}`
|
||||
)
|
||||
|
||||
setTransformOrigin(transformOriginValue)
|
||||
velocityHistoryRef.current = []
|
||||
}
|
||||
|
||||
// Smooth velocity by averaging last 3 frames
|
||||
velocityHistoryRef.current.push({ vx, vy })
|
||||
if (velocityHistoryRef.current.length > 3) {
|
||||
velocityHistoryRef.current.shift()
|
||||
}
|
||||
|
||||
const avgVx =
|
||||
velocityHistoryRef.current.reduce((sum, v) => sum + v.vx, 0) /
|
||||
velocityHistoryRef.current.length
|
||||
const avgVy =
|
||||
velocityHistoryRef.current.reduce((sum, v) => sum + v.vy, 0) /
|
||||
velocityHistoryRef.current.length
|
||||
|
||||
// Calculate rotation based on smoothed velocity and drag offset
|
||||
const velocityAngle = Math.atan2(avgVy, avgVx) * (180 / Math.PI)
|
||||
const offsetAngle =
|
||||
Math.atan2(dragOffsetRef.current.y, dragOffsetRef.current.x) * (180 / Math.PI)
|
||||
|
||||
// Card rotates to align with movement direction, offset by where we're grabbing
|
||||
const targetRotation = velocityAngle - offsetAngle + 90
|
||||
|
||||
const speed = Math.sqrt(avgVx * avgVx + avgVy * avgVy)
|
||||
|
||||
// Store smoothed velocity for throw
|
||||
lastVelocityRef.current = { vx: avgVx, vy: avgVy }
|
||||
|
||||
const finalRotation = speed > 0.01 ? targetRotation : currentPositionRef.current.rotation
|
||||
|
||||
api.start({
|
||||
x: currentPositionRef.current.x + mx,
|
||||
y: currentPositionRef.current.y + my,
|
||||
scale: 1.1,
|
||||
rotation: finalRotation,
|
||||
immediate: (key) => key !== 'rotation', // Position immediate, rotation smooth
|
||||
config: { tension: 200, friction: 30 }, // Smoother rotation spring
|
||||
})
|
||||
} else {
|
||||
// On release, reset transform origin to center
|
||||
setTransformOrigin('center center')
|
||||
|
||||
// Update current position to where the card was dropped
|
||||
currentPositionRef.current.x = currentPositionRef.current.x + mx
|
||||
currentPositionRef.current.y = currentPositionRef.current.y + my
|
||||
|
||||
// First, snap the spring to the dropped position immediately
|
||||
api.set({
|
||||
x: currentPositionRef.current.x,
|
||||
y: currentPositionRef.current.y,
|
||||
})
|
||||
|
||||
// On release, apply momentum with decay physics
|
||||
const throwVelocityX = lastVelocityRef.current.vx * 1000
|
||||
const throwVelocityY = lastVelocityRef.current.vy * 1000
|
||||
|
||||
// Calculate final rotation based on throw direction
|
||||
const throwAngle = Math.atan2(throwVelocityY, throwVelocityX) * (180 / Math.PI)
|
||||
|
||||
api.start({
|
||||
x: {
|
||||
velocity: throwVelocityX,
|
||||
config: { decay: true },
|
||||
},
|
||||
y: {
|
||||
velocity: throwVelocityY,
|
||||
config: { decay: true },
|
||||
},
|
||||
scale: 1,
|
||||
rotation: throwAngle + 90, // Card aligns with throw direction
|
||||
config: config.wobbly,
|
||||
onChange: (result) => {
|
||||
// Continue updating position as card settles with momentum
|
||||
if (result.value.x !== undefined) {
|
||||
currentPositionRef.current.x = result.value.x
|
||||
}
|
||||
if (result.value.y !== undefined) {
|
||||
currentPositionRef.current.y = result.value.y
|
||||
}
|
||||
if (result.value.rotation !== undefined) {
|
||||
currentPositionRef.current.rotation = result.value.rotation
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
// Prevent scrolling when dragging
|
||||
preventDefault: true,
|
||||
filterTaps: true,
|
||||
}
|
||||
)
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
ref={cardRef}
|
||||
{...bind()}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
transform: to(
|
||||
[x, y, rotation, scale],
|
||||
(x, y, r, s) => `translate(${x}px, ${y}px) rotate(${r}deg) scale(${s})`
|
||||
),
|
||||
transformOrigin,
|
||||
zIndex,
|
||||
touchAction: 'none',
|
||||
cursor: 'grab',
|
||||
}}
|
||||
className={css({
|
||||
userSelect: 'none',
|
||||
_active: {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
bg: 'white',
|
||||
rounded: 'lg',
|
||||
p: '4',
|
||||
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.3)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: '2',
|
||||
minW: '120px',
|
||||
border: '2px solid rgba(0, 0, 0, 0.1)',
|
||||
transition: 'box-shadow 0.2s',
|
||||
_hover: {
|
||||
boxShadow: '0 12px 32px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
})}
|
||||
>
|
||||
{/* Abacus visualization */}
|
||||
<div
|
||||
className={css({
|
||||
transform: 'scale(0.6)',
|
||||
transformOrigin: 'center',
|
||||
})}
|
||||
>
|
||||
<AbacusReact value={card.number} columns={3} beadShape="circle" />
|
||||
</div>
|
||||
|
||||
{/* Number display */}
|
||||
<div
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color: 'gray.800',
|
||||
fontFamily: 'mono',
|
||||
})}
|
||||
>
|
||||
{card.number}
|
||||
</div>
|
||||
</div>
|
||||
</animated.div>
|
||||
)
|
||||
}
|
||||
862
apps/web/src/components/LevelSliderDisplay.tsx
Normal file
862
apps/web/src/components/LevelSliderDisplay.tsx
Normal file
@@ -0,0 +1,862 @@
|
||||
'use client'
|
||||
|
||||
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 { css } from '../../styled-system/css'
|
||||
import { 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: 'Pre-1st Dan',
|
||||
name: 'Jun-Shodan',
|
||||
minScore: 90,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '1st Dan',
|
||||
name: 'Shodan',
|
||||
minScore: 100,
|
||||
emoji: '🧙',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '2nd Dan',
|
||||
name: 'Nidan',
|
||||
minScore: 120,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '3rd Dan',
|
||||
name: 'Sandan',
|
||||
minScore: 140,
|
||||
emoji: '🧙♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '4th Dan',
|
||||
name: 'Yondan',
|
||||
minScore: 160,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '5th Dan',
|
||||
name: 'Godan',
|
||||
minScore: 180,
|
||||
emoji: '🧙♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '6th Dan',
|
||||
name: 'Rokudan',
|
||||
minScore: 200,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '7th Dan',
|
||||
name: 'Nanadan',
|
||||
minScore: 220,
|
||||
emoji: '🧝',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '8th Dan',
|
||||
name: 'Hachidan',
|
||||
minScore: 250,
|
||||
emoji: '🧝♂️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '9th Dan',
|
||||
name: 'Kudan',
|
||||
minScore: 270,
|
||||
emoji: '🧝♀️',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
{
|
||||
level: '10th Dan',
|
||||
name: 'Judan',
|
||||
minScore: 290,
|
||||
emoji: '👑',
|
||||
color: 'amber',
|
||||
digits: 30,
|
||||
type: 'dan' as const,
|
||||
},
|
||||
] 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
|
||||
}
|
||||
|
||||
// Dark theme styles matching the 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,
|
||||
},
|
||||
}
|
||||
|
||||
interface LevelSliderDisplayProps {
|
||||
initialIndex?: number
|
||||
autoAdvanceEnabled?: boolean
|
||||
autoAdvanceInterval?: number
|
||||
showLegend?: boolean
|
||||
}
|
||||
|
||||
export function LevelSliderDisplay({
|
||||
initialIndex = 0,
|
||||
autoAdvanceEnabled = true,
|
||||
autoAdvanceInterval = 3000,
|
||||
showLegend = true,
|
||||
}: LevelSliderDisplayProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex)
|
||||
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 (!autoAdvanceEnabled || isPaneHovered) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setCurrentIndex((prev) => {
|
||||
// Cycle back to 0 when reaching the end
|
||||
return prev >= allLevels.length - 1 ? 0 : prev + 1
|
||||
})
|
||||
}, autoAdvanceInterval)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [autoAdvanceEnabled, isPaneHovered, autoAdvanceInterval])
|
||||
|
||||
// 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.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: 350, friction: 45 },
|
||||
})
|
||||
|
||||
// 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 =
|
||||
animatedDigits.length > 15
|
||||
? BigInt(animatedDigits || '0')
|
||||
: Number.parseInt(animatedDigits || '0', 10)
|
||||
|
||||
return (
|
||||
<div className={stack({ gap: '8' })}>
|
||||
{/* Current Level Display */}
|
||||
<div
|
||||
onMouseEnter={() => setIsPaneHovered(true)}
|
||||
onMouseLeave={() => setIsPaneHovered(false)}
|
||||
className={css({
|
||||
bg: 'transparent',
|
||||
border: '2px solid',
|
||||
borderColor:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.500'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.500'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.500'
|
||||
: 'amber.500',
|
||||
rounded: 'xl',
|
||||
p: { base: '6', md: '8' },
|
||||
height: { base: 'auto', md: '700px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Abacus-themed Radix Slider */}
|
||||
<div className={css({ mb: '6', px: { base: '2', md: '8' } })}>
|
||||
<div className={css({ position: 'relative', py: '12' })}>
|
||||
{/* Emoji tick marks */}
|
||||
<div
|
||||
className={css({
|
||||
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 */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
mt: '1',
|
||||
fontSize: 'xs',
|
||||
color: 'gray.500',
|
||||
})}
|
||||
>
|
||||
<span>10th Kyu</span>
|
||||
<span>1st Kyu</span>
|
||||
<span>10th Dan</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abacus Display with Level Details */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
gap: '4',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
{/* 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,
|
||||
})}
|
||||
>
|
||||
<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',
|
||||
})}
|
||||
>
|
||||
Requires mastery of <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
{showLegend && (
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '6',
|
||||
justifyContent: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
})}
|
||||
>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<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',
|
||||
})}
|
||||
/>
|
||||
<span className={css({ fontSize: 'sm', color: 'gray.300' })}>Master (Dan ranks)</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.49.0",
|
||||
"version": "4.61.1",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -134,6 +134,9 @@ importers:
|
||||
'@types/jsdom':
|
||||
specifier: ^21.1.7
|
||||
version: 21.1.7
|
||||
'@use-gesture/react':
|
||||
specifier: ^10.3.1
|
||||
version: 10.3.1(react@18.3.1)
|
||||
bcryptjs:
|
||||
specifier: ^2.4.3
|
||||
version: 2.4.3
|
||||
|
||||
Reference in New Issue
Block a user