feat(homepage): add interactive levels slider to replace static progression
Replace the static emoji progression display in "Your Journey" section with an interactive slider component adapted from /levels page. Features: - Auto-advancing slider (3s intervals, pauses on hover) - Interactive bead thumb with animated emoji overlay - Animated abacus display that changes with level - Shows 8 key milestone levels (10th Kyu through 10th Dan) - Emoji tick marks above slider for visual navigation - Dynamic border colors based on level category Components: - Created LevelsSlider component to encapsulate the UI - Reuses existing React Spring animations from /levels page - Uses Radix Slider for accessible interaction - Dark theme abacus styling for consistency 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import Link from 'next/link'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useSpring, useTransition } from '@react-spring/web'
|
||||
import { HeroAbacus } from '@/components/HeroAbacus'
|
||||
import { HomeHeroProvider } from '@/contexts/HomeHeroContext'
|
||||
import { PageWithNav } from '@/components/PageWithNav'
|
||||
@@ -10,9 +11,21 @@ import { TutorialPlayer } from '@/components/tutorial/TutorialPlayer'
|
||||
import { getTutorialForEditor } from '@/utils/tutorialConverter'
|
||||
import { getAvailableGames } from '@/lib/arcade/game-registry'
|
||||
import { InteractiveFlashcards } from '@/components/InteractiveFlashcards'
|
||||
import { LevelsSlider } from '@/components/LevelsSlider'
|
||||
import { css } from '../../styled-system/css'
|
||||
import { container, grid, hstack, stack } from '../../styled-system/patterns'
|
||||
import { token } from '../../styled-system/tokens'
|
||||
|
||||
// Simplified level data for homepage slider (showing key milestones)
|
||||
const allLevels = [
|
||||
{ level: '10th Kyu', emoji: '🧒', color: 'green', digits: 2 },
|
||||
{ level: '7th Kyu', emoji: '🧒', color: 'green', digits: 4 },
|
||||
{ level: '5th Kyu', emoji: '🧑', color: 'blue', digits: 6 },
|
||||
{ level: '3rd Kyu', emoji: '🧔', color: 'violet', digits: 8 },
|
||||
{ level: '1st Kyu', emoji: '🧔', color: 'violet', digits: 10 },
|
||||
{ level: '1st Dan', emoji: '🧙', color: 'amber', digits: 30 },
|
||||
{ level: '5th Dan', emoji: '🧙♀️', color: 'amber', digits: 30 },
|
||||
{ level: '10th Dan', emoji: '👑', color: 'amber', digits: 30 },
|
||||
] as const
|
||||
|
||||
// Mini abacus that cycles through a sequence of values
|
||||
function MiniAbacus({
|
||||
@@ -77,6 +90,87 @@ export default function HomePage() {
|
||||
const [selectedSkillIndex, setSelectedSkillIndex] = useState(1) // Default to "Friends techniques"
|
||||
const fullTutorial = getTutorialForEditor()
|
||||
|
||||
// State for interactive levels slider
|
||||
const [levelIndex, setLevelIndex] = useState(0)
|
||||
const [isPaneHovered, setIsPaneHovered] = useState(false)
|
||||
const [animatedDigits, setAnimatedDigits] = useState<string>('')
|
||||
const currentLevel = allLevels[levelIndex]
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setAnimatedDigits((prev) => {
|
||||
const digits = prev.split('').map(Number)
|
||||
const numColumns = digits.length
|
||||
const groupSize = Math.floor(Math.random() * 3) + 1
|
||||
const startCol = Math.floor(Math.random() * (numColumns - groupSize + 1))
|
||||
|
||||
for (let i = startCol; i < startCol + groupSize && i < numColumns; i++) {
|
||||
digits[i] = Math.floor(Math.random() * 10)
|
||||
}
|
||||
return digits.join('')
|
||||
})
|
||||
}, 500)
|
||||
return () => clearInterval(interval)
|
||||
}, [levelIndex])
|
||||
|
||||
// Auto-advance slider every 3 seconds (unless pane is hovered)
|
||||
useEffect(() => {
|
||||
if (isPaneHovered) return
|
||||
|
||||
const interval = setInterval(() => {
|
||||
setLevelIndex((prev) => {
|
||||
return prev >= allLevels.length - 1 ? 0 : prev + 1
|
||||
})
|
||||
}, 3000)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [isPaneHovered])
|
||||
|
||||
// Calculate scale factor and animate it
|
||||
const scaleFactor = Math.max(1.2, Math.min(2.0, 20 / currentLevel.digits))
|
||||
const animatedProps = useSpring({
|
||||
scaleFactor,
|
||||
config: { tension: 350, friction: 45 },
|
||||
})
|
||||
|
||||
// Animate emoji transitions
|
||||
const emojiTransitions = useTransition(currentLevel.emoji, {
|
||||
keys: levelIndex,
|
||||
from: { opacity: 0 },
|
||||
enter: { opacity: 1 },
|
||||
leave: { opacity: 0 },
|
||||
config: { duration: 120 },
|
||||
})
|
||||
|
||||
// Convert animated digits to number for abacus
|
||||
const displayValue =
|
||||
animatedDigits.length > 15
|
||||
? BigInt(animatedDigits || '0')
|
||||
: Number.parseInt(animatedDigits || '0', 10)
|
||||
|
||||
// Dark theme styles for abacus
|
||||
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,
|
||||
},
|
||||
}
|
||||
|
||||
// Create different tutorials for each skill level
|
||||
const skillTutorials = [
|
||||
// Skill 0: Read and set numbers (0-9999)
|
||||
@@ -407,146 +501,17 @@ 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>
|
||||
<LevelsSlider
|
||||
levels={allLevels}
|
||||
currentIndex={levelIndex}
|
||||
onIndexChange={setLevelIndex}
|
||||
onPaneHoverChange={setIsPaneHovered}
|
||||
emojiTransitions={emojiTransitions}
|
||||
displayValue={displayValue}
|
||||
scaleFactor={scaleFactor}
|
||||
animatedProps={animatedProps}
|
||||
darkStyles={darkStyles}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Flashcard Generator Section */}
|
||||
|
||||
253
apps/web/src/components/LevelsSlider.tsx
Normal file
253
apps/web/src/components/LevelsSlider.tsx
Normal file
@@ -0,0 +1,253 @@
|
||||
'use client'
|
||||
|
||||
import { 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'
|
||||
|
||||
interface Level {
|
||||
level: string
|
||||
emoji: string
|
||||
color: 'green' | 'blue' | 'violet' | 'amber'
|
||||
digits: number
|
||||
}
|
||||
|
||||
interface LevelsSliderProps {
|
||||
levels: readonly Level[]
|
||||
currentIndex: number
|
||||
onIndexChange: (index: number) => void
|
||||
onPaneHoverChange: (isHovered: boolean) => void
|
||||
emojiTransitions: any
|
||||
displayValue: number | bigint
|
||||
scaleFactor: number
|
||||
animatedProps: any
|
||||
darkStyles: any
|
||||
}
|
||||
|
||||
export function LevelsSlider({
|
||||
levels,
|
||||
currentIndex,
|
||||
onIndexChange,
|
||||
onPaneHoverChange,
|
||||
emojiTransitions,
|
||||
displayValue,
|
||||
scaleFactor,
|
||||
animatedProps,
|
||||
darkStyles,
|
||||
}: LevelsSliderProps) {
|
||||
const currentLevel = levels[currentIndex]
|
||||
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => onPaneHoverChange(true)}
|
||||
onMouseLeave={() => onPaneHoverChange(false)}
|
||||
className={css({
|
||||
bg: 'rgba(0, 0, 0, 0.4)',
|
||||
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: '500px' },
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
})}
|
||||
>
|
||||
{/* Slider */}
|
||||
<div className={css({ mb: '6', px: { base: '2', md: '4' } })}>
|
||||
<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',
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={css({
|
||||
position: 'relative',
|
||||
w: 'full',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
})}
|
||||
>
|
||||
{levels.map((level, index) => (
|
||||
<div
|
||||
key={index}
|
||||
onClick={() => onIndexChange(index)}
|
||||
className={css({
|
||||
fontSize: '3xl',
|
||||
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]) => onIndexChange(value)}
|
||||
min={0}
|
||||
max={levels.length - 1}
|
||||
step={1}
|
||||
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: '120px',
|
||||
h: '120px',
|
||||
bg: 'transparent',
|
||||
cursor: 'grab',
|
||||
transition: 'transform 0.15s 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={120}
|
||||
color={currentLevel.color === 'violet' ? '#8b5cf6' : '#22c55e'}
|
||||
animated={false}
|
||||
/>
|
||||
</div>
|
||||
{emojiTransitions((style: any, emoji: string) => (
|
||||
<animated.div
|
||||
style={style}
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
fontSize: '6xl',
|
||||
pointerEvents: 'none',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
})}
|
||||
>
|
||||
{emoji}
|
||||
</animated.div>
|
||||
))}
|
||||
|
||||
<div
|
||||
className={css({
|
||||
position: 'absolute',
|
||||
bottom: '-60px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
})}
|
||||
>
|
||||
<h3
|
||||
className={css({
|
||||
fontSize: 'xl',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
currentLevel.color === 'green'
|
||||
? 'green.400'
|
||||
: currentLevel.color === 'blue'
|
||||
? 'blue.400'
|
||||
: currentLevel.color === 'violet'
|
||||
? 'violet.400'
|
||||
: 'amber.400',
|
||||
})}
|
||||
>
|
||||
{currentLevel.level}
|
||||
</h3>
|
||||
</div>
|
||||
</Slider.Thumb>
|
||||
</Slider.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Abacus Display */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
p: '6',
|
||||
bg: 'rgba(0, 0, 0, 0.3)',
|
||||
rounded: 'lg',
|
||||
border: '1px solid',
|
||||
borderColor: 'gray.700',
|
||||
flex: 1,
|
||||
})}
|
||||
>
|
||||
<animated.div
|
||||
style={{
|
||||
transform: animatedProps.scaleFactor.to((s: number) => `scale(${s / scaleFactor})`),
|
||||
}}
|
||||
>
|
||||
<AbacusReact
|
||||
value={displayValue}
|
||||
columns={currentLevel.digits}
|
||||
scaleFactor={scaleFactor}
|
||||
showNumbers={true}
|
||||
customStyles={darkStyles}
|
||||
/>
|
||||
</animated.div>
|
||||
</div>
|
||||
|
||||
{/* Digit Count */}
|
||||
<div
|
||||
className={css({
|
||||
textAlign: 'center',
|
||||
color: 'gray.400',
|
||||
fontSize: 'sm',
|
||||
mt: '4',
|
||||
})}
|
||||
>
|
||||
Master <strong>{currentLevel.digits}-digit</strong> calculations
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user