init
This commit is contained in:
commit
d42e47b15b
31 changed files with 3045 additions and 0 deletions
76
src/App.tsx
Normal file
76
src/App.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { useStats } from './hooks/useStats'
|
||||
import { Layout } from './components/Layout'
|
||||
import { LessonMode } from './components/LessonMode'
|
||||
import { FreeMode } from './components/FreeMode'
|
||||
import { GameMode } from './components/GameMode'
|
||||
import { StatsView } from './components/StatsView'
|
||||
import type { SessionResult } from './types'
|
||||
|
||||
type Tab = 'lessons' | 'free' | 'game' | 'stats'
|
||||
|
||||
export default function App() {
|
||||
const [tab, setTab] = useState<Tab>('lessons')
|
||||
const { progress, completeLesson, addSession, updateHighScore } = useStats()
|
||||
|
||||
const handleLessonComplete = useCallback((
|
||||
lessonId: number,
|
||||
wpm: number,
|
||||
accuracy: number,
|
||||
keyStats: Record<string, { hits: number; misses: number }>,
|
||||
) => {
|
||||
const result: SessionResult = {
|
||||
mode: `lesson-${lessonId}`,
|
||||
wpm,
|
||||
accuracy,
|
||||
timestamp: Date.now(),
|
||||
keyStats,
|
||||
}
|
||||
addSession(result)
|
||||
if (accuracy >= 90) {
|
||||
completeLesson(lessonId)
|
||||
}
|
||||
}, [addSession, completeLesson])
|
||||
|
||||
const handleFreeComplete = useCallback((
|
||||
wpm: number,
|
||||
accuracy: number,
|
||||
keyStats: Record<string, { hits: number; misses: number }>,
|
||||
) => {
|
||||
addSession({
|
||||
mode: 'free',
|
||||
wpm,
|
||||
accuracy,
|
||||
timestamp: Date.now(),
|
||||
keyStats,
|
||||
})
|
||||
}, [addSession])
|
||||
|
||||
const handleGameOver = useCallback((score: number) => {
|
||||
updateHighScore(score)
|
||||
addSession({
|
||||
mode: 'game',
|
||||
wpm: 0,
|
||||
accuracy: 0,
|
||||
timestamp: Date.now(),
|
||||
keyStats: {},
|
||||
})
|
||||
}, [updateHighScore, addSession])
|
||||
|
||||
return (
|
||||
<Layout activeTab={tab} onTabChange={setTab}>
|
||||
{tab === 'lessons' && (
|
||||
<LessonMode progress={progress} onComplete={handleLessonComplete} />
|
||||
)}
|
||||
{tab === 'free' && (
|
||||
<FreeMode onComplete={handleFreeComplete} />
|
||||
)}
|
||||
{tab === 'game' && (
|
||||
<GameMode highScore={progress.gameHighScore} onGameOver={handleGameOver} />
|
||||
)}
|
||||
{tab === 'stats' && (
|
||||
<StatsView progress={progress} />
|
||||
)}
|
||||
</Layout>
|
||||
)
|
||||
}
|
||||
69
src/components/FreeMode.tsx
Normal file
69
src/components/FreeMode.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { QUOTES } from '../data/quotes'
|
||||
import { useTypingEngine } from '../hooks/useTypingEngine'
|
||||
import { TypingArea } from './TypingArea'
|
||||
import { Keyboard } from './Keyboard'
|
||||
import { ResultsModal } from './ResultsModal'
|
||||
|
||||
type Props = {
|
||||
onComplete: (wpm: number, accuracy: number, keyStats: Record<string, { hits: number; misses: number }>) => void
|
||||
}
|
||||
|
||||
function randomQuote() {
|
||||
return QUOTES[Math.floor(Math.random() * QUOTES.length)]
|
||||
}
|
||||
|
||||
export function FreeMode({ onComplete }: Props) {
|
||||
const [quote, setQuote] = useState(randomQuote)
|
||||
const [result, setResult] = useState<{ wpm: number; accuracy: number } | null>(null)
|
||||
|
||||
const handleComplete = useCallback((r: { wpm: number; accuracy: number; keyStats: Record<string, { hits: number; misses: number }> }) => {
|
||||
setResult(r)
|
||||
onComplete(r.wpm, r.accuracy, r.keyStats)
|
||||
}, [onComplete])
|
||||
|
||||
const engine = useTypingEngine(quote, handleComplete)
|
||||
|
||||
const handleRetry = () => {
|
||||
setResult(null)
|
||||
engine.reset()
|
||||
}
|
||||
|
||||
const handleNew = () => {
|
||||
const q = randomQuote()
|
||||
setQuote(q)
|
||||
setResult(null)
|
||||
engine.reset(q)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Free Typing</h2>
|
||||
<button onClick={handleNew}>New Quote</button>
|
||||
</div>
|
||||
|
||||
<TypingArea
|
||||
text={quote}
|
||||
currentIndex={engine.currentIndex}
|
||||
errors={engine.errors}
|
||||
wpm={engine.wpm}
|
||||
accuracy={engine.accuracy}
|
||||
onKeyDown={engine.handleKeyDown}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Keyboard activeKey={quote[engine.currentIndex]} keyStats={engine.keyStats} />
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<ResultsModal
|
||||
wpm={result.wpm}
|
||||
accuracy={result.accuracy}
|
||||
onRetry={handleRetry}
|
||||
onBack={handleNew}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
396
src/components/GameMode.tsx
Normal file
396
src/components/GameMode.tsx
Normal file
|
|
@ -0,0 +1,396 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import styles from '../styles/game.module.css'
|
||||
|
||||
const WORD_POOL = [
|
||||
'the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'her',
|
||||
'was', 'one', 'our', 'out', 'day', 'get', 'has', 'him', 'his', 'how',
|
||||
'its', 'may', 'new', 'now', 'old', 'see', 'way', 'who', 'did', 'got',
|
||||
'let', 'say', 'she', 'too', 'use', 'big', 'cat', 'dog', 'fun', 'hat',
|
||||
'jump', 'kick', 'love', 'make', 'nice', 'open', 'play', 'quiz', 'run',
|
||||
'star', 'time', 'up', 'very', 'walk', 'xray', 'year', 'zero', 'code',
|
||||
'type', 'fast', 'hero', 'epic', 'cool', 'fire', 'bolt', 'zoom', 'dash',
|
||||
'pixel', 'blaze', 'storm', 'power', 'quest', 'magic', 'super', 'turbo',
|
||||
'cyber', 'ultra', 'royal', 'brave', 'swift', 'light', 'dream', 'shine',
|
||||
]
|
||||
|
||||
type FallingWord = {
|
||||
id: number
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
speed: number
|
||||
matched: number // how many chars matched
|
||||
}
|
||||
|
||||
type Explosion = {
|
||||
id: number
|
||||
text: string
|
||||
x: number
|
||||
y: number
|
||||
points: number
|
||||
}
|
||||
|
||||
type Particle = {
|
||||
id: number
|
||||
x: number
|
||||
y: number
|
||||
tx: number
|
||||
ty: number
|
||||
color: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
const PARTICLE_COLORS = ['#2ecc71', '#27ae60', '#7c6ff7', '#f1c40f', '#e84393', '#3498db', '#00d2d3']
|
||||
|
||||
function spawnParticles(x: number, y: number, count: number): Particle[] {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = (Math.PI * 2 * i) / count + (Math.random() - 0.5) * 0.5
|
||||
const dist = 40 + Math.random() * 80
|
||||
return {
|
||||
id: Date.now() + i,
|
||||
x,
|
||||
y,
|
||||
tx: Math.cos(angle) * dist,
|
||||
ty: Math.sin(angle) * dist,
|
||||
color: PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)],
|
||||
duration: 0.4 + Math.random() * 0.3,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type Props = {
|
||||
highScore: number
|
||||
onGameOver: (score: number) => void
|
||||
}
|
||||
|
||||
export function GameMode({ highScore, onGameOver }: Props) {
|
||||
const [words, setWords] = useState<FallingWord[]>([])
|
||||
const [input, setInput] = useState('')
|
||||
const inputRef = useRef('')
|
||||
const [score, setScore] = useState(0)
|
||||
const [lives, setLives] = useState(3)
|
||||
const [gameOver, setGameOver] = useState(false)
|
||||
const [started, setStarted] = useState(false)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const [explosions, setExplosions] = useState<Explosion[]>([])
|
||||
const [particles, setParticles] = useState<Particle[]>([])
|
||||
const [flash, setFlash] = useState(0)
|
||||
const [combo, setCombo] = useState(0)
|
||||
const [showCombo, setShowCombo] = useState(0)
|
||||
const comboTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nextIdRef = useRef(0)
|
||||
const frameRef = useRef<number>(0)
|
||||
const lastSpawnRef = useRef(0)
|
||||
const difficultyRef = useRef(1)
|
||||
const livesRef = useRef(3)
|
||||
const gameOverRef = useRef(false)
|
||||
|
||||
livesRef.current = lives
|
||||
gameOverRef.current = gameOver
|
||||
|
||||
const spawnWord = useCallback(() => {
|
||||
const difficulty = difficultyRef.current
|
||||
const maxLen = Math.min(3 + Math.floor(difficulty / 3), 7)
|
||||
const pool = WORD_POOL.filter(w => w.length <= maxLen)
|
||||
const text = pool[Math.floor(Math.random() * pool.length)]
|
||||
const word: FallingWord = {
|
||||
id: nextIdRef.current++,
|
||||
text,
|
||||
x: Math.random() * 70 + 5, // 5%-75% from left
|
||||
y: -5,
|
||||
speed: 0.3 + difficulty * 0.08,
|
||||
matched: 0,
|
||||
}
|
||||
setWords(prev => [...prev, word])
|
||||
}, [])
|
||||
|
||||
const startGame = () => {
|
||||
setWords([])
|
||||
updateInput('')
|
||||
setScore(0)
|
||||
setLives(3)
|
||||
setGameOver(false)
|
||||
setStarted(true)
|
||||
setExplosions([])
|
||||
setParticles([])
|
||||
setCombo(0)
|
||||
difficultyRef.current = 1
|
||||
lastSpawnRef.current = 0
|
||||
nextIdRef.current = 0
|
||||
containerRef.current?.focus()
|
||||
}
|
||||
|
||||
// Game loop
|
||||
useEffect(() => {
|
||||
if (!started || gameOver) return
|
||||
|
||||
let lastTime = performance.now()
|
||||
|
||||
const tick = (now: number) => {
|
||||
if (gameOverRef.current) return
|
||||
const dt = (now - lastTime) / 16.67 // normalize to ~60fps
|
||||
lastTime = now
|
||||
|
||||
// Spawn
|
||||
const spawnInterval = Math.max(1500 - difficultyRef.current * 80, 600)
|
||||
if (now - lastSpawnRef.current > spawnInterval) {
|
||||
spawnWord()
|
||||
lastSpawnRef.current = now
|
||||
}
|
||||
|
||||
// Move words
|
||||
setWords(prev => {
|
||||
const next: FallingWord[] = []
|
||||
let lostLife = false
|
||||
for (const w of prev) {
|
||||
const ny = w.y + w.speed * dt
|
||||
if (ny > 100) {
|
||||
lostLife = true
|
||||
} else {
|
||||
next.push({ ...w, y: ny })
|
||||
}
|
||||
}
|
||||
if (lostLife) {
|
||||
setLives(l => {
|
||||
const nl = l - 1
|
||||
if (nl <= 0) {
|
||||
setGameOver(true)
|
||||
}
|
||||
return nl
|
||||
})
|
||||
}
|
||||
return next
|
||||
})
|
||||
|
||||
// Increase difficulty
|
||||
difficultyRef.current = 1 + score / 50
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frameRef.current)
|
||||
}, [started, gameOver, spawnWord, score])
|
||||
|
||||
// Fire onGameOver
|
||||
useEffect(() => {
|
||||
if (gameOver && started) {
|
||||
onGameOver(score)
|
||||
}
|
||||
}, [gameOver, started, score, onGameOver])
|
||||
|
||||
const updateInput = (val: string) => {
|
||||
inputRef.current = val
|
||||
setInput(val)
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (gameOver) return
|
||||
if (!started) {
|
||||
startGame()
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
const newInput = inputRef.current.slice(0, -1)
|
||||
updateInput(newInput)
|
||||
setWords(prev => prev.map(w => ({
|
||||
...w,
|
||||
matched: w.text.startsWith(newInput) && newInput.length > 0 ? newInput.length : 0,
|
||||
})))
|
||||
return
|
||||
}
|
||||
|
||||
if (e.key.length !== 1) return
|
||||
e.preventDefault()
|
||||
|
||||
const newInput = inputRef.current + e.key
|
||||
|
||||
// Do matching + removal in one atomic setWords call
|
||||
setWords(prev => {
|
||||
const completed = prev.find(w => w.text === newInput)
|
||||
if (completed) {
|
||||
const points = completed.text.length * 10
|
||||
setScore(s => s + points)
|
||||
updateInput('')
|
||||
|
||||
// Spawn explosion at word position
|
||||
const now = Date.now()
|
||||
setExplosions(ex => [...ex, {
|
||||
id: now,
|
||||
text: completed.text,
|
||||
x: completed.x,
|
||||
y: completed.y,
|
||||
points,
|
||||
}])
|
||||
// Spawn particles
|
||||
setParticles(p => [...p, ...spawnParticles(completed.x, completed.y, 12)])
|
||||
// Screen flash
|
||||
setFlash(now)
|
||||
// Combo tracking
|
||||
setCombo(c => {
|
||||
const next = c + 1
|
||||
if (next >= 3) setShowCombo(now)
|
||||
return next
|
||||
})
|
||||
clearTimeout(comboTimerRef.current)
|
||||
comboTimerRef.current = setTimeout(() => setCombo(0), 2000)
|
||||
|
||||
// Auto-clean effects after animation
|
||||
setTimeout(() => {
|
||||
setExplosions(ex => ex.filter(e => e.id !== now))
|
||||
}, 500)
|
||||
setTimeout(() => {
|
||||
setParticles(p => p.slice(-24))
|
||||
}, 800)
|
||||
|
||||
return prev.filter(w => w.id !== completed.id)
|
||||
}
|
||||
// No completion — update partial matches
|
||||
updateInput(newInput)
|
||||
return prev.map(w => ({
|
||||
...w,
|
||||
matched: w.text.startsWith(newInput) ? newInput.length : 0,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>Falling Words</h2>
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: 14 }}>
|
||||
High Score: {highScore}
|
||||
</span>
|
||||
{started && !gameOver && (
|
||||
<button onClick={startGame} style={{ marginLeft: 'auto' }}>Restart</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`${styles.gameContainer} ${!focused ? styles.blurred : ''}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
>
|
||||
<div className={styles.gameHud}>
|
||||
<div className={styles.hudItem}>
|
||||
<span className={styles.hudLabel}>Score</span>
|
||||
<span className={styles.hudValue}>{score}</span>
|
||||
</div>
|
||||
<div className={styles.hudItem}>
|
||||
<span className={styles.hudLabel}>Lives</span>
|
||||
<span className={`${styles.hudValue} ${styles.lives}`}>
|
||||
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 3 - lives))}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{words.map(w => (
|
||||
<div
|
||||
key={w.id}
|
||||
className={`${styles.word} ${w.matched > 0 ? styles.wordMatched : ''}`}
|
||||
style={{ left: `${w.x}%`, top: `${w.y}%` }}
|
||||
>
|
||||
{w.text.split('').map((ch, i) => (
|
||||
<span key={i} className={i < w.matched ? styles.wordMatchedChar : ''}>
|
||||
{ch}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Exploding words */}
|
||||
{explosions.map(ex => (
|
||||
<div
|
||||
key={ex.id}
|
||||
className={`${styles.word} ${styles.wordExploding}`}
|
||||
style={{ left: `${ex.x}%`, top: `${ex.y}%` }}
|
||||
>
|
||||
{ex.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Particles */}
|
||||
{particles.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
className={styles.particle}
|
||||
style={{
|
||||
left: `${p.x}%`,
|
||||
top: `${p.y}%`,
|
||||
background: p.color,
|
||||
boxShadow: `0 0 6px ${p.color}, 0 0 12px ${p.color}`,
|
||||
'--tx': `${p.tx}px`,
|
||||
'--ty': `${p.ty}px`,
|
||||
'--duration': `${p.duration}s`,
|
||||
} as React.CSSProperties}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Score popups */}
|
||||
{explosions.map(ex => (
|
||||
<div
|
||||
key={`score-${ex.id}`}
|
||||
className={styles.scorePopup}
|
||||
style={{ left: `${ex.x + 3}%`, top: `${ex.y - 2}%` }}
|
||||
>
|
||||
+{ex.points}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Screen flash */}
|
||||
{flash > 0 && <div key={flash} className={styles.screenFlash} />}
|
||||
|
||||
{/* Combo text */}
|
||||
{showCombo > 0 && combo >= 3 && (
|
||||
<div key={showCombo} className={styles.comboText}>
|
||||
{combo}x COMBO!
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.dangerZone} />
|
||||
|
||||
{started && !gameOver && (
|
||||
<div className={styles.inputDisplay}>
|
||||
{input || <span style={{ opacity: 0.3 }}>type a word...</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!started && (
|
||||
<div className={styles.gameOver}>
|
||||
<div className={styles.gameOverTitle}>Falling Words</div>
|
||||
<div className={styles.gameOverScore}>
|
||||
Type the words before they fall!
|
||||
</div>
|
||||
<button className={styles.gameOverButtons} onClick={startGame}
|
||||
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 18, padding: '14px 36px' }}>
|
||||
Start Game
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gameOver && (
|
||||
<div className={styles.gameOver}>
|
||||
<div className={styles.gameOverTitle}>Game Over!</div>
|
||||
<div className={styles.gameOverScore}>Score: {score}</div>
|
||||
{score > highScore && score > 0 && (
|
||||
<div className={styles.highScore}>New High Score!</div>
|
||||
)}
|
||||
<div className={styles.gameOverButtons}>
|
||||
<button
|
||||
onClick={startGame}
|
||||
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 16, padding: '12px 28px' }}
|
||||
>
|
||||
Play Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
69
src/components/Keyboard.tsx
Normal file
69
src/components/Keyboard.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { KEYBOARD_LAYOUT, FINGER_COLORS, FINGER_LABELS } from '../data/keyboard'
|
||||
import type { KeyInfo } from '../types'
|
||||
import styles from '../styles/keyboard.module.css'
|
||||
|
||||
type Props = {
|
||||
activeKey?: string
|
||||
keyStats?: Record<string, { hits: number; misses: number }>
|
||||
}
|
||||
|
||||
export function Keyboard({ activeKey, keyStats }: Props) {
|
||||
const [pressedKey, setPressedKey] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => setPressedKey(e.key.toLowerCase())
|
||||
const up = () => setPressedKey(null)
|
||||
window.addEventListener('keydown', down)
|
||||
window.addEventListener('keyup', up)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', down)
|
||||
window.removeEventListener('keyup', up)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getKeyBg = (ki: KeyInfo) => {
|
||||
const base = FINGER_COLORS[ki.finger]
|
||||
if (keyStats) {
|
||||
const stat = keyStats[ki.key]
|
||||
if (stat) {
|
||||
const total = stat.hits + stat.misses
|
||||
if (total > 0) {
|
||||
const acc = stat.hits / total
|
||||
// Blend toward red for low accuracy
|
||||
if (acc < 0.7) return `color-mix(in srgb, ${base}, var(--error) 40%)`
|
||||
}
|
||||
}
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.keyboard}>
|
||||
{KEYBOARD_LAYOUT.map((row, ri) => (
|
||||
<div className={styles.row} key={ri}>
|
||||
{row.map(ki => {
|
||||
const isActive = activeKey !== undefined &&
|
||||
ki.key === activeKey.toLowerCase()
|
||||
const isPressed = pressedKey === ki.key
|
||||
const label = ki.key === ' ' ? 'Space' : ki.key
|
||||
|
||||
return (
|
||||
<div
|
||||
key={ki.key}
|
||||
className={`${styles.key} ${ki.key === ' ' ? styles.space : ''} ${isActive ? styles.active : ''} ${isPressed ? styles.pressed : ''}`}
|
||||
style={{
|
||||
background: getKeyBg(ki),
|
||||
color: '#fff',
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span className={styles.fingerLabel}>{FINGER_LABELS[ki.finger]}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
58
src/components/Layout.tsx
Normal file
58
src/components/Layout.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import { type ReactNode } from 'react'
|
||||
|
||||
type Tab = 'lessons' | 'free' | 'game' | 'stats'
|
||||
|
||||
type Props = {
|
||||
activeTab: Tab
|
||||
onTabChange: (tab: Tab) => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const TABS: { id: Tab; label: string; icon: string }[] = [
|
||||
{ id: 'lessons', label: 'Lessons', icon: '📚' },
|
||||
{ id: 'free', label: 'Free Type', icon: '⌨️' },
|
||||
{ id: 'game', label: 'Game', icon: '🎮' },
|
||||
{ id: 'stats', label: 'Stats', icon: '📊' },
|
||||
]
|
||||
|
||||
export function Layout({ activeTab, onTabChange, children }: Props) {
|
||||
return (
|
||||
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
|
||||
<header style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 32,
|
||||
}}>
|
||||
<h1 style={{
|
||||
fontSize: 26,
|
||||
fontWeight: 800,
|
||||
background: 'linear-gradient(135deg, var(--accent), #e84393)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}}>
|
||||
Leo's Typing Tutor
|
||||
</h1>
|
||||
<nav style={{ display: 'flex', gap: 4 }}>
|
||||
{TABS.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => onTabChange(tab.id)}
|
||||
style={{
|
||||
background: activeTab === tab.id ? 'var(--accent)' : 'var(--bg-card)',
|
||||
color: activeTab === tab.id ? '#fff' : 'var(--text)',
|
||||
fontWeight: activeTab === tab.id ? 600 : 400,
|
||||
padding: '10px 18px',
|
||||
borderRadius: 'var(--radius)',
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</header>
|
||||
<main>{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
97
src/components/LessonMode.tsx
Normal file
97
src/components/LessonMode.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { LESSONS } from '../data/lessons'
|
||||
import { useTypingEngine } from '../hooks/useTypingEngine'
|
||||
import type { UserProgress } from '../types'
|
||||
import { LessonSelect } from './LessonSelect'
|
||||
import { TypingArea } from './TypingArea'
|
||||
import { Keyboard } from './Keyboard'
|
||||
import { ResultsModal } from './ResultsModal'
|
||||
|
||||
type Props = {
|
||||
progress: UserProgress
|
||||
onComplete: (lessonId: number, wpm: number, accuracy: number, keyStats: Record<string, { hits: number; misses: number }>) => void
|
||||
}
|
||||
|
||||
export function LessonMode({ progress, onComplete }: Props) {
|
||||
const [activeLessonId, setActiveLessonId] = useState<number | null>(null)
|
||||
const [result, setResult] = useState<{ wpm: number; accuracy: number } | null>(null)
|
||||
|
||||
const lesson = activeLessonId ? LESSONS.find(l => l.id === activeLessonId) : null
|
||||
const text = lesson ? lesson.words.join(' ') : ''
|
||||
|
||||
const handleComplete = useCallback((r: { wpm: number; accuracy: number; keyStats: Record<string, { hits: number; misses: number }> }) => {
|
||||
setResult(r)
|
||||
if (activeLessonId) {
|
||||
onComplete(activeLessonId, r.wpm, r.accuracy, r.keyStats)
|
||||
}
|
||||
}, [activeLessonId, onComplete])
|
||||
|
||||
const engine = useTypingEngine(text, handleComplete)
|
||||
|
||||
const handleSelect = (id: number) => {
|
||||
const l = LESSONS.find(l => l.id === id)
|
||||
if (!l) return
|
||||
setActiveLessonId(id)
|
||||
setResult(null)
|
||||
engine.reset(l.words.join(' '))
|
||||
}
|
||||
|
||||
const handleRetry = () => {
|
||||
setResult(null)
|
||||
engine.reset()
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveLessonId(null)
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
if (!lesson) {
|
||||
return <LessonSelect progress={progress} onSelect={handleSelect} />
|
||||
}
|
||||
|
||||
// Find next lesson name for unlock message
|
||||
const nextLesson = LESSONS.find(l => l.unlockAfter === lesson.id)
|
||||
const unlockText = result && result.accuracy >= 90 && nextLesson
|
||||
? nextLesson.name
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<button onClick={handleBack}>Back</button>
|
||||
<div>
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700 }}>{lesson.name}</h2>
|
||||
{lesson.newKeys.length > 0 && (
|
||||
<span style={{ color: 'var(--text-dim)', fontSize: 14 }}>
|
||||
New keys: <span style={{ color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{lesson.newKeys.join(' ')}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TypingArea
|
||||
text={text}
|
||||
currentIndex={engine.currentIndex}
|
||||
errors={engine.errors}
|
||||
wpm={engine.wpm}
|
||||
accuracy={engine.accuracy}
|
||||
onKeyDown={engine.handleKeyDown}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<Keyboard activeKey={text[engine.currentIndex]} keyStats={engine.keyStats} />
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<ResultsModal
|
||||
wpm={result.wpm}
|
||||
accuracy={result.accuracy}
|
||||
unlocked={unlockText}
|
||||
onRetry={handleRetry}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
52
src/components/LessonSelect.tsx
Normal file
52
src/components/LessonSelect.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { LESSONS } from '../data/lessons'
|
||||
import type { UserProgress } from '../types'
|
||||
import styles from '../styles/typing.module.css'
|
||||
|
||||
type Props = {
|
||||
progress: UserProgress
|
||||
onSelect: (lessonId: number) => void
|
||||
}
|
||||
|
||||
export function LessonSelect({ progress, onSelect }: Props) {
|
||||
const isUnlocked = (lessonId: number) => {
|
||||
const lesson = LESSONS.find(l => l.id === lessonId)
|
||||
if (!lesson?.unlockAfter) return true
|
||||
return progress.completedLessons.includes(lesson.unlockAfter)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.modeHeader}>
|
||||
<h2 className={styles.modeTitle}>Lessons</h2>
|
||||
<p className={styles.modeSubtitle}>
|
||||
Master each lesson to unlock the next ({progress.completedLessons.length}/{LESSONS.length} completed)
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.lessonSelect}>
|
||||
{LESSONS.map(lesson => {
|
||||
const unlocked = isUnlocked(lesson.id)
|
||||
const completed = progress.completedLessons.includes(lesson.id)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lesson.id}
|
||||
className={`${styles.lessonCard} ${!unlocked ? styles.locked : ''} ${completed ? styles.completed : ''}`}
|
||||
onClick={() => unlocked && onSelect(lesson.id)}
|
||||
>
|
||||
<div className={styles.lessonId}>Lesson {lesson.id}</div>
|
||||
<div className={styles.lessonName}>{lesson.name}</div>
|
||||
{lesson.newKeys.length > 0 && (
|
||||
<div className={styles.lessonKeys}>
|
||||
New: {lesson.newKeys.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.lessonStatus}>
|
||||
{completed ? 'Completed' : unlocked ? 'Ready' : 'Locked'}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
src/components/ResultsModal.tsx
Normal file
45
src/components/ResultsModal.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import styles from '../styles/typing.module.css'
|
||||
|
||||
type Props = {
|
||||
wpm: number
|
||||
accuracy: number
|
||||
unlocked?: string
|
||||
onRetry: () => void
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props) {
|
||||
return (
|
||||
<div className={styles.modal} onClick={onBack}>
|
||||
<div className={styles.modalContent} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalTitle}>
|
||||
{accuracy >= 90 ? 'Great Job!' : 'Keep Practicing!'}
|
||||
</div>
|
||||
<div className={styles.modalStats}>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue}>{wpm}</span>
|
||||
<span className={styles.statLabel}>WPM</span>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue}>{accuracy}%</span>
|
||||
<span className={styles.statLabel}>Accuracy</span>
|
||||
</div>
|
||||
</div>
|
||||
{unlocked && (
|
||||
<div className={styles.unlockText}>
|
||||
Unlocked: {unlocked}
|
||||
</div>
|
||||
)}
|
||||
{accuracy < 90 && (
|
||||
<div style={{ color: 'var(--text-dim)', fontSize: 14 }}>
|
||||
Need 90% accuracy to unlock the next lesson
|
||||
</div>
|
||||
)}
|
||||
<div className={styles.modalButtons}>
|
||||
<button className={styles.secondaryBtn} onClick={onBack}>Back</button>
|
||||
<button className={styles.primaryBtn} onClick={onRetry}>Try Again</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
src/components/StatsView.tsx
Normal file
164
src/components/StatsView.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianGrid } from 'recharts'
|
||||
import { LESSONS } from '../data/lessons'
|
||||
import { KEYBOARD_LAYOUT, FINGER_COLORS } from '../data/keyboard'
|
||||
import type { UserProgress } from '../types'
|
||||
import styles from '../styles/game.module.css'
|
||||
import kbStyles from '../styles/keyboard.module.css'
|
||||
|
||||
type Props = {
|
||||
progress: UserProgress
|
||||
}
|
||||
|
||||
export function StatsView({ progress }: Props) {
|
||||
const { sessions, completedLessons, gameHighScore } = progress
|
||||
|
||||
// Build chart data from sessions (last 50)
|
||||
const recentSessions = sessions.slice(-50)
|
||||
const chartData = recentSessions.map((s, i) => ({
|
||||
session: i + 1,
|
||||
wpm: s.wpm,
|
||||
accuracy: s.accuracy,
|
||||
}))
|
||||
|
||||
// Aggregate key stats across all sessions
|
||||
const aggKeyStats: Record<string, { hits: number; misses: number }> = {}
|
||||
for (const s of sessions) {
|
||||
for (const [key, stat] of Object.entries(s.keyStats)) {
|
||||
if (!aggKeyStats[key]) aggKeyStats[key] = { hits: 0, misses: 0 }
|
||||
aggKeyStats[key].hits += stat.hits
|
||||
aggKeyStats[key].misses += stat.misses
|
||||
}
|
||||
}
|
||||
|
||||
const lessonProgress = Math.round((completedLessons.length / LESSONS.length) * 100)
|
||||
|
||||
const avgWpm = sessions.length > 0
|
||||
? Math.round(sessions.reduce((s, r) => s + r.wpm, 0) / sessions.length)
|
||||
: 0
|
||||
const avgAccuracy = sessions.length > 0
|
||||
? Math.round(sessions.reduce((s, r) => s + r.accuracy, 0) / sessions.length)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="fade-in">
|
||||
<h2 style={{ fontSize: 20, fontWeight: 700, marginBottom: 20 }}>Stats & Progress</h2>
|
||||
|
||||
<div className={styles.statsGrid}>
|
||||
{/* Summary */}
|
||||
<div className={styles.statsCard}>
|
||||
<div className={styles.statsCardTitle}>Overview</div>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{avgWpm}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Avg WPM</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{avgAccuracy}%</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Avg Accuracy</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{sessions.length}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Sessions</div>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', fontFamily: 'var(--font-mono)' }}>{gameHighScore}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text-dim)' }}>Game High Score</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lesson Progress */}
|
||||
<div className={styles.statsCard}>
|
||||
<div className={styles.statsCardTitle}>Lesson Progress</div>
|
||||
<div style={{ fontSize: 28, fontWeight: 700, color: 'var(--accent)', marginBottom: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
{completedLessons.length} / {LESSONS.length}
|
||||
</div>
|
||||
<div className={styles.progressBar}>
|
||||
<div className={styles.progressFill} style={{ width: `${lessonProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WPM Chart */}
|
||||
<div className={styles.statsCard}>
|
||||
<div className={styles.statsCardTitle}>WPM Over Time</div>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="session" stroke="#666" />
|
||||
<YAxis stroke="#666" />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--bg-card)', border: '1px solid var(--accent)', borderRadius: 8 }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="wpm" stroke="var(--accent)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-dim)', padding: 40, textAlign: 'center' }}>
|
||||
Complete some sessions to see your progress!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Accuracy Chart */}
|
||||
<div className={styles.statsCard}>
|
||||
<div className={styles.statsCardTitle}>Accuracy Over Time</div>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={200}>
|
||||
<LineChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="session" stroke="#666" />
|
||||
<YAxis stroke="#666" domain={[0, 100]} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: 'var(--bg-card)', border: '1px solid var(--accent)', borderRadius: 8 }}
|
||||
/>
|
||||
<Line type="monotone" dataKey="accuracy" stroke="var(--success)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div style={{ color: 'var(--text-dim)', padding: 40, textAlign: 'center' }}>
|
||||
Complete some sessions to see your progress!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Key Accuracy Heatmap */}
|
||||
<div className={styles.statsCard} style={{ gridColumn: '1 / -1' }}>
|
||||
<div className={styles.statsCardTitle}>Key Accuracy Heatmap</div>
|
||||
<div className={kbStyles.keyboard}>
|
||||
{KEYBOARD_LAYOUT.map((row, ri) => (
|
||||
<div className={kbStyles.row} key={ri}>
|
||||
{row.map(ki => {
|
||||
const stat = aggKeyStats[ki.key]
|
||||
let bg = FINGER_COLORS[ki.finger]
|
||||
let opacity = 0.3
|
||||
if (stat) {
|
||||
const total = stat.hits + stat.misses
|
||||
if (total > 0) {
|
||||
const acc = stat.hits / total
|
||||
opacity = 0.4 + acc * 0.6
|
||||
if (acc < 0.7) bg = 'var(--error)'
|
||||
else if (acc < 0.9) bg = 'var(--warning)'
|
||||
else bg = 'var(--success)'
|
||||
}
|
||||
}
|
||||
const label = ki.key === ' ' ? 'Space' : ki.key
|
||||
return (
|
||||
<div
|
||||
key={ki.key}
|
||||
className={`${kbStyles.key} ${ki.key === ' ' ? kbStyles.space : ''}`}
|
||||
style={{ background: bg, opacity, color: '#fff' }}
|
||||
title={stat ? `${ki.key}: ${Math.round((stat.hits / (stat.hits + stat.misses)) * 100)}% accuracy` : `${ki.key}: no data`}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
57
src/components/TypingArea.tsx
Normal file
57
src/components/TypingArea.tsx
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import { useRef, useState, useEffect } from 'react'
|
||||
import styles from '../styles/typing.module.css'
|
||||
|
||||
type Props = {
|
||||
text: string
|
||||
currentIndex: number
|
||||
errors: Set<number>
|
||||
wpm: number
|
||||
accuracy: number
|
||||
onKeyDown: (e: React.KeyboardEvent) => void
|
||||
}
|
||||
|
||||
export function TypingArea({ text, currentIndex, errors, wpm, accuracy, onKeyDown }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
ref.current?.focus()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.stats}>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue}>{wpm}</span>
|
||||
<span className={styles.statLabel}>WPM</span>
|
||||
</div>
|
||||
<div className={styles.stat}>
|
||||
<span className={styles.statValue}>{accuracy}%</span>
|
||||
<span className={styles.statLabel}>Accuracy</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={ref}
|
||||
className={`${styles.typingArea} ${!focused ? styles.blurred : ''}`}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
>
|
||||
{text.split('').map((char, i) => {
|
||||
let cls = styles.pending
|
||||
if (i < currentIndex) {
|
||||
cls = errors.has(i) ? styles.incorrect : styles.correct
|
||||
} else if (i === currentIndex) {
|
||||
cls = styles.current
|
||||
}
|
||||
return (
|
||||
<span key={i} className={`${styles.char} ${cls}`}>
|
||||
{char}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
100
src/data/keyboard.ts
Normal file
100
src/data/keyboard.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import type { Finger, KeyInfo } from '../types'
|
||||
|
||||
export const FINGER_COLORS: Record<Finger, string> = {
|
||||
lpinky: '#e74c3c',
|
||||
lring: '#e67e22',
|
||||
lmiddle: '#f1c40f',
|
||||
lindex: '#2ecc71',
|
||||
rindex: '#27ae60',
|
||||
rmiddle: '#3498db',
|
||||
rring: '#9b59b6',
|
||||
rpinky: '#e84393',
|
||||
thumb: '#95a5a6',
|
||||
}
|
||||
|
||||
export const FINGER_LABELS: Record<Finger, string> = {
|
||||
lpinky: 'Left Pinky',
|
||||
lring: 'Left Ring',
|
||||
lmiddle: 'Left Middle',
|
||||
lindex: 'Left Index',
|
||||
rindex: 'Right Index',
|
||||
rmiddle: 'Right Middle',
|
||||
rring: 'Right Ring',
|
||||
rpinky: 'Right Pinky',
|
||||
thumb: 'Thumb',
|
||||
}
|
||||
|
||||
// Row 0 = number row, Row 1 = top, Row 2 = home, Row 3 = bottom, Row 4 = space
|
||||
export const KEYBOARD_LAYOUT: KeyInfo[][] = [
|
||||
// Number row
|
||||
[
|
||||
{ key: '`', finger: 'lpinky', row: 0, col: 0 },
|
||||
{ key: '1', finger: 'lpinky', row: 0, col: 1 },
|
||||
{ key: '2', finger: 'lring', row: 0, col: 2 },
|
||||
{ key: '3', finger: 'lmiddle', row: 0, col: 3 },
|
||||
{ key: '4', finger: 'lindex', row: 0, col: 4 },
|
||||
{ key: '5', finger: 'lindex', row: 0, col: 5 },
|
||||
{ key: '6', finger: 'rindex', row: 0, col: 6 },
|
||||
{ key: '7', finger: 'rindex', row: 0, col: 7 },
|
||||
{ key: '8', finger: 'rmiddle', row: 0, col: 8 },
|
||||
{ key: '9', finger: 'rring', row: 0, col: 9 },
|
||||
{ key: '0', finger: 'rpinky', row: 0, col: 10 },
|
||||
{ key: '-', finger: 'rpinky', row: 0, col: 11 },
|
||||
{ key: '=', finger: 'rpinky', row: 0, col: 12 },
|
||||
],
|
||||
// Top row
|
||||
[
|
||||
{ key: 'q', finger: 'lpinky', row: 1, col: 0 },
|
||||
{ key: 'w', finger: 'lring', row: 1, col: 1 },
|
||||
{ key: 'e', finger: 'lmiddle', row: 1, col: 2 },
|
||||
{ key: 'r', finger: 'lindex', row: 1, col: 3 },
|
||||
{ key: 't', finger: 'lindex', row: 1, col: 4 },
|
||||
{ key: 'y', finger: 'rindex', row: 1, col: 5 },
|
||||
{ key: 'u', finger: 'rindex', row: 1, col: 6 },
|
||||
{ key: 'i', finger: 'rmiddle', row: 1, col: 7 },
|
||||
{ key: 'o', finger: 'rring', row: 1, col: 8 },
|
||||
{ key: 'p', finger: 'rpinky', row: 1, col: 9 },
|
||||
{ key: '[', finger: 'rpinky', row: 1, col: 10 },
|
||||
{ key: ']', finger: 'rpinky', row: 1, col: 11 },
|
||||
{ key: '\\', finger: 'rpinky', row: 1, col: 12 },
|
||||
],
|
||||
// Home row
|
||||
[
|
||||
{ key: 'a', finger: 'lpinky', row: 2, col: 0 },
|
||||
{ key: 's', finger: 'lring', row: 2, col: 1 },
|
||||
{ key: 'd', finger: 'lmiddle', row: 2, col: 2 },
|
||||
{ key: 'f', finger: 'lindex', row: 2, col: 3 },
|
||||
{ key: 'g', finger: 'lindex', row: 2, col: 4 },
|
||||
{ key: 'h', finger: 'rindex', row: 2, col: 5 },
|
||||
{ key: 'j', finger: 'rindex', row: 2, col: 6 },
|
||||
{ key: 'k', finger: 'rmiddle', row: 2, col: 7 },
|
||||
{ key: 'l', finger: 'rring', row: 2, col: 8 },
|
||||
{ key: ';', finger: 'rpinky', row: 2, col: 9 },
|
||||
{ key: "'", finger: 'rpinky', row: 2, col: 10 },
|
||||
],
|
||||
// Bottom row
|
||||
[
|
||||
{ key: 'z', finger: 'lpinky', row: 3, col: 0 },
|
||||
{ key: 'x', finger: 'lring', row: 3, col: 1 },
|
||||
{ key: 'c', finger: 'lmiddle', row: 3, col: 2 },
|
||||
{ key: 'v', finger: 'lindex', row: 3, col: 3 },
|
||||
{ key: 'b', finger: 'lindex', row: 3, col: 4 },
|
||||
{ key: 'n', finger: 'rindex', row: 3, col: 5 },
|
||||
{ key: 'm', finger: 'rmiddle', row: 3, col: 6 },
|
||||
{ key: ',', finger: 'rmiddle', row: 3, col: 7 },
|
||||
{ key: '.', finger: 'rring', row: 3, col: 8 },
|
||||
{ key: '/', finger: 'rpinky', row: 3, col: 9 },
|
||||
],
|
||||
// Space row
|
||||
[
|
||||
{ key: ' ', finger: 'thumb', row: 4, col: 0 },
|
||||
],
|
||||
]
|
||||
|
||||
// Flat lookup: key → KeyInfo
|
||||
export const KEY_MAP: Record<string, KeyInfo> = {}
|
||||
for (const row of KEYBOARD_LAYOUT) {
|
||||
for (const ki of row) {
|
||||
KEY_MAP[ki.key] = ki
|
||||
}
|
||||
}
|
||||
171
src/data/lessons.ts
Normal file
171
src/data/lessons.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import type { Lesson } from '../types'
|
||||
|
||||
export const LESSONS: Lesson[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Home Row: Left Hand',
|
||||
newKeys: ['a', 's', 'd', 'f'],
|
||||
words: [
|
||||
'add', 'sad', 'dad', 'fad', 'ads', 'fas', 'das',
|
||||
'sass', 'fads', 'adds', 'dads', 'asdf', 'fdsa',
|
||||
'aff', 'sadf', 'dafs', 'fads', 'sass', 'adds',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Home Row: Right Hand',
|
||||
newKeys: ['j', 'k', 'l', ';'],
|
||||
unlockAfter: 1,
|
||||
words: [
|
||||
'all', 'fall', 'jail', 'kale', 'lake', 'fake',
|
||||
'flask', 'falls', 'jacks', 'slak', 'flak', 'alf',
|
||||
'lads', 'salad', 'flask', 'shall', 'djall', 'alks',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Home Row: G and H',
|
||||
newKeys: ['g', 'h'],
|
||||
unlockAfter: 2,
|
||||
words: [
|
||||
'had', 'has', 'gal', 'lag', 'hag', 'gash', 'lash',
|
||||
'half', 'glad', 'flash', 'gag', 'hash', 'sash',
|
||||
'shall', 'shag', 'ghalf', 'flags', 'glass', 'halls',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Top Row: Left Hand',
|
||||
newKeys: ['q', 'w', 'e', 'r', 't'],
|
||||
unlockAfter: 3,
|
||||
words: [
|
||||
'the', 'were', 'tree', 'read', 'tear', 'rest',
|
||||
'west', 'test', 'quest', 'wheat', 'street', 'great',
|
||||
'stew', 'tread', 'sweat', 'wreath', 'wet', 'set',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Top Row: Right Hand',
|
||||
newKeys: ['y', 'u', 'i', 'o', 'p'],
|
||||
unlockAfter: 4,
|
||||
words: [
|
||||
'you', 'your', 'type', 'youthful', 'quiet', 'equip',
|
||||
'ripe', 'trip', 'pour', 'our', 'proud', 'youth',
|
||||
'quite', 'quote', 'opaque', 'tulip', 'quip', 'outpour',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Home + Top Row Practice',
|
||||
newKeys: [],
|
||||
unlockAfter: 5,
|
||||
words: [
|
||||
'the quick', 'just right', 'play together', 'light house',
|
||||
'quiet whisper', 'write daily', 'type faster', 'your style',
|
||||
'keep it up', 'forge ahead', 'super power', 'world hero',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Bottom Row: Left Hand',
|
||||
newKeys: ['z', 'x', 'c', 'v', 'b'],
|
||||
unlockAfter: 6,
|
||||
words: [
|
||||
'box', 'vex', 'cab', 'back', 'cave', 'brave',
|
||||
'exact', 'verb', 'crisp', 'black', 'beach', 'voice',
|
||||
'brace', 'civic', 'blaze', 'crux', 'vibe', 'cube',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Bottom Row: Right Hand',
|
||||
newKeys: ['n', 'm', ',', '.', '/'],
|
||||
unlockAfter: 7,
|
||||
words: [
|
||||
'man', 'name', 'mine', 'moon', 'noon', 'main',
|
||||
'manner', 'common', 'command', 'mention', 'moment',
|
||||
'minion', 'melon', 'nominate', 'minimum', 'memo',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Full Keyboard Practice',
|
||||
newKeys: [],
|
||||
unlockAfter: 8,
|
||||
words: [
|
||||
'the lazy fox', 'jumped over', 'brown quick', 'from every angle',
|
||||
'maximize your', 'best typing', 'skills now', 'extra credit',
|
||||
'puzzle solved', 'victory dance', 'zero mistakes', 'nice job',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Capital Letters',
|
||||
newKeys: ['Shift'],
|
||||
unlockAfter: 9,
|
||||
words: [
|
||||
'Hello World', 'Good Morning', 'Dear Friend', 'New York',
|
||||
'San Diego', 'Leo Rules', 'Amazing Job', 'Keep Going',
|
||||
'Great Work', 'High Five', 'The Best', 'Super Star',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Numbers Row',
|
||||
newKeys: ['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
|
||||
unlockAfter: 10,
|
||||
words: [
|
||||
'123', '456', '789', '100 cats', '250 dogs',
|
||||
'42 is the answer', '365 days', '12 months',
|
||||
'7 wonders', '50 states', '3 wishes', '99 stars',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 12,
|
||||
name: 'Common Punctuation',
|
||||
newKeys: ['.', ',', '!', '?', "'"],
|
||||
unlockAfter: 11,
|
||||
words: [
|
||||
"Hello, world!", "What's up?", "I can't wait!",
|
||||
"Great job, Leo!", "Ready? Set. Go!", "Yes, please.",
|
||||
"Wow, that's cool!", "Don't stop now!",
|
||||
"Nice work, champ!", "Is it fun? Yes!",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
name: 'Special Symbols',
|
||||
newKeys: ['-', '=', '[', ']', '/', '\\'],
|
||||
unlockAfter: 12,
|
||||
words: [
|
||||
'a-b-c', 'x=y', '[hello]', 'left/right', 'win-win',
|
||||
'self-made', 'all=equal', '[done]', 'yes/no', 'up-down',
|
||||
'hi-five', 're=set', '[start]', 'and/or', 'check-in',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
name: 'Speed Challenge: Common Words',
|
||||
newKeys: [],
|
||||
unlockAfter: 13,
|
||||
words: [
|
||||
'the and for are but not you all any can had her',
|
||||
'was one our out day get has him his how its may',
|
||||
'new now old see way who did got let say she too use',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: 'Speed Challenge: Sentences',
|
||||
newKeys: [],
|
||||
unlockAfter: 14,
|
||||
words: [
|
||||
'The quick brown fox jumps over the lazy dog.',
|
||||
'Pack my box with five dozen liquor jugs.',
|
||||
'How vexingly quick daft zebras jump!',
|
||||
'The five boxing wizards jump quickly.',
|
||||
'Sphinx of black quartz, judge my vow.',
|
||||
],
|
||||
},
|
||||
]
|
||||
37
src/data/quotes.ts
Normal file
37
src/data/quotes.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export const QUOTES: string[] = [
|
||||
"The only way to do great work is to love what you do.",
|
||||
"In the middle of every difficulty lies opportunity.",
|
||||
"It does not matter how slowly you go as long as you do not stop.",
|
||||
"The future belongs to those who believe in the beauty of their dreams.",
|
||||
"Believe you can and you are halfway there.",
|
||||
"The best time to plant a tree was twenty years ago. The second best time is now.",
|
||||
"You miss one hundred percent of the shots you never take.",
|
||||
"Be yourself; everyone else is already taken.",
|
||||
"Two things are infinite: the universe and human stupidity.",
|
||||
"A room without books is like a body without a soul.",
|
||||
"You only live once, but if you do it right, once is enough.",
|
||||
"Be the change that you wish to see in the world.",
|
||||
"In three words I can sum up everything I learned about life: it goes on.",
|
||||
"If you tell the truth, you do not have to remember anything.",
|
||||
"Life is what happens when you are busy making other plans.",
|
||||
"To be yourself in a world that is constantly trying to make you something else is the greatest accomplishment.",
|
||||
"The secret of getting ahead is getting started.",
|
||||
"It is never too late to be what you might have been.",
|
||||
"Everything you can imagine is real.",
|
||||
"Do what you can, with what you have, where you are.",
|
||||
"The journey of a thousand miles begins with a single step.",
|
||||
"Not all those who wander are lost.",
|
||||
"What we think, we become.",
|
||||
"The only impossible journey is the one you never begin.",
|
||||
"Turn your wounds into wisdom.",
|
||||
"Stars cannot shine without darkness.",
|
||||
"Dream big and dare to fail.",
|
||||
"Happiness is not something ready made. It comes from your own actions.",
|
||||
"The best revenge is massive success.",
|
||||
"Every moment is a fresh beginning.",
|
||||
"If opportunity does not knock, build a door.",
|
||||
"Nothing is impossible. The word itself says I am possible.",
|
||||
"Keep your face always toward the sunshine and shadows will fall behind you.",
|
||||
"What you do today can improve all your tomorrows.",
|
||||
"Quality is not an act, it is a habit.",
|
||||
]
|
||||
68
src/hooks/useStats.ts
Normal file
68
src/hooks/useStats.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import type { UserProgress, SessionResult } from '../types'
|
||||
|
||||
const STORAGE_KEY = 'leo-typing-progress'
|
||||
|
||||
const DEFAULT_PROGRESS: UserProgress = {
|
||||
completedLessons: [],
|
||||
sessions: [],
|
||||
gameHighScore: 0,
|
||||
}
|
||||
|
||||
function loadProgress(): UserProgress {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
if (!raw) return DEFAULT_PROGRESS
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return DEFAULT_PROGRESS
|
||||
}
|
||||
}
|
||||
|
||||
function saveProgress(p: UserProgress) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(p))
|
||||
}
|
||||
|
||||
export function useStats() {
|
||||
const [progress, setProgress] = useState<UserProgress>(loadProgress)
|
||||
|
||||
const completeLesson = useCallback((lessonId: number) => {
|
||||
setProgress(prev => {
|
||||
const next = {
|
||||
...prev,
|
||||
completedLessons: prev.completedLessons.includes(lessonId)
|
||||
? prev.completedLessons
|
||||
: [...prev.completedLessons, lessonId],
|
||||
}
|
||||
saveProgress(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addSession = useCallback((result: SessionResult) => {
|
||||
setProgress(prev => {
|
||||
const next = {
|
||||
...prev,
|
||||
sessions: [...prev.sessions, result],
|
||||
}
|
||||
saveProgress(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const updateHighScore = useCallback((score: number) => {
|
||||
setProgress(prev => {
|
||||
if (score <= prev.gameHighScore) return prev
|
||||
const next = { ...prev, gameHighScore: score }
|
||||
saveProgress(next)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const resetProgress = useCallback(() => {
|
||||
setProgress(DEFAULT_PROGRESS)
|
||||
saveProgress(DEFAULT_PROGRESS)
|
||||
}, [])
|
||||
|
||||
return { progress, completeLesson, addSession, updateHighScore, resetProgress }
|
||||
}
|
||||
122
src/hooks/useTypingEngine.ts
Normal file
122
src/hooks/useTypingEngine.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
|
||||
export type TypingEngineResult = {
|
||||
currentIndex: number
|
||||
errors: Set<number>
|
||||
wpm: number
|
||||
accuracy: number
|
||||
isComplete: boolean
|
||||
keyStats: Record<string, { hits: number; misses: number }>
|
||||
handleKeyDown: (e: React.KeyboardEvent) => void
|
||||
reset: (newText?: string) => void
|
||||
}
|
||||
|
||||
export function useTypingEngine(
|
||||
initialText: string,
|
||||
onComplete?: (result: { wpm: number; accuracy: number; keyStats: Record<string, { hits: number; misses: number }> }) => void,
|
||||
): TypingEngineResult {
|
||||
const [text, setText] = useState(initialText)
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [errors, setErrors] = useState<Set<number>>(new Set())
|
||||
const [totalKeystrokes, setTotalKeystrokes] = useState(0)
|
||||
const [correctKeystrokes, setCorrectKeystrokes] = useState(0)
|
||||
const [isComplete, setIsComplete] = useState(false)
|
||||
const [keyStats, setKeyStats] = useState<Record<string, { hits: number; misses: number }>>({})
|
||||
|
||||
const startTimeRef = useRef<number | null>(null)
|
||||
const [wpm, setWpm] = useState(0)
|
||||
const onCompleteRef = useRef(onComplete)
|
||||
onCompleteRef.current = onComplete
|
||||
|
||||
const accuracy = totalKeystrokes > 0 ? Math.round((correctKeystrokes / totalKeystrokes) * 100) : 100
|
||||
|
||||
// Update WPM periodically
|
||||
useEffect(() => {
|
||||
if (isComplete || currentIndex === 0) return
|
||||
const interval = setInterval(() => {
|
||||
if (startTimeRef.current) {
|
||||
const minutes = (Date.now() - startTimeRef.current) / 60000
|
||||
if (minutes > 0) {
|
||||
setWpm(Math.round((currentIndex / 5) / minutes))
|
||||
}
|
||||
}
|
||||
}, 500)
|
||||
return () => clearInterval(interval)
|
||||
}, [currentIndex, isComplete])
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (isComplete) return
|
||||
// Ignore modifier keys, function keys, etc.
|
||||
if (e.key.length > 1 && e.key !== 'Backspace') return
|
||||
e.preventDefault()
|
||||
|
||||
if (e.key === 'Backspace') {
|
||||
setCurrentIndex(prev => Math.max(0, prev - 1))
|
||||
setErrors(prev => {
|
||||
const next = new Set(prev)
|
||||
next.delete(currentIndex - 1)
|
||||
return next
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!startTimeRef.current) {
|
||||
startTimeRef.current = Date.now()
|
||||
}
|
||||
|
||||
const expected = text[currentIndex]
|
||||
const typed = e.key
|
||||
const charKey = expected?.toLowerCase() ?? typed.toLowerCase()
|
||||
|
||||
setTotalKeystrokes(prev => prev + 1)
|
||||
|
||||
if (typed === expected) {
|
||||
setCorrectKeystrokes(prev => prev + 1)
|
||||
setKeyStats(prev => ({
|
||||
...prev,
|
||||
[charKey]: { hits: (prev[charKey]?.hits ?? 0) + 1, misses: prev[charKey]?.misses ?? 0 },
|
||||
}))
|
||||
const nextIndex = currentIndex + 1
|
||||
setCurrentIndex(nextIndex)
|
||||
|
||||
if (nextIndex >= text.length) {
|
||||
setIsComplete(true)
|
||||
const minutes = (Date.now() - (startTimeRef.current ?? Date.now())) / 60000
|
||||
const finalWpm = minutes > 0 ? Math.round((text.length / 5) / minutes) : 0
|
||||
setWpm(finalWpm)
|
||||
const finalAccuracy = (totalKeystrokes + 1) > 0
|
||||
? Math.round(((correctKeystrokes + 1) / (totalKeystrokes + 1)) * 100)
|
||||
: 100
|
||||
// Build final keyStats including this last keystroke
|
||||
setKeyStats(prev => {
|
||||
const final = {
|
||||
...prev,
|
||||
[charKey]: { hits: (prev[charKey]?.hits ?? 0) + 1, misses: prev[charKey]?.misses ?? 0 },
|
||||
}
|
||||
onCompleteRef.current?.({ wpm: finalWpm, accuracy: finalAccuracy, keyStats: final })
|
||||
return prev // Don't double-count, already set above
|
||||
})
|
||||
}
|
||||
} else {
|
||||
setErrors(prev => new Set(prev).add(currentIndex))
|
||||
setKeyStats(prev => ({
|
||||
...prev,
|
||||
[charKey]: { hits: prev[charKey]?.hits ?? 0, misses: (prev[charKey]?.misses ?? 0) + 1 },
|
||||
}))
|
||||
}
|
||||
}, [currentIndex, text, isComplete, totalKeystrokes, correctKeystrokes])
|
||||
|
||||
const reset = useCallback((newText?: string) => {
|
||||
if (newText !== undefined) setText(newText)
|
||||
setCurrentIndex(0)
|
||||
setErrors(new Set())
|
||||
setTotalKeystrokes(0)
|
||||
setCorrectKeystrokes(0)
|
||||
setIsComplete(false)
|
||||
setKeyStats({})
|
||||
setWpm(0)
|
||||
startTimeRef.current = null
|
||||
}, [])
|
||||
|
||||
return { currentIndex, errors, wpm, accuracy, isComplete, keyStats, handleKeyDown, reset }
|
||||
}
|
||||
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './styles/global.css'
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
310
src/styles/game.module.css
Normal file
310
src/styles/game.module.css
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
.gameContainer {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
height: 500px;
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.gameContainer:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.gameContainer.blurred::after {
|
||||
content: 'Click to play!';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(26, 27, 46, 0.8);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
font-size: 18px;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.gameHud {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
background: linear-gradient(to bottom, rgba(26, 27, 46, 0.9), transparent);
|
||||
z-index: 2;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.hudItem {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.hudLabel {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.hudValue {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.dangerZone {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: var(--error);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.word {
|
||||
position: absolute;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
padding: 6px 14px;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-hover);
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--text);
|
||||
transition: transform 0.05s linear;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.wordMatched {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.wordMatchedChar {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.inputDisplay {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg);
|
||||
padding: 10px 24px;
|
||||
border-radius: var(--radius);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 20px;
|
||||
color: var(--accent);
|
||||
border: 2px solid var(--accent);
|
||||
min-width: 200px;
|
||||
text-align: center;
|
||||
z-index: 3;
|
||||
}
|
||||
|
||||
.lives {
|
||||
font-size: 20px;
|
||||
letter-spacing: 4px;
|
||||
}
|
||||
|
||||
.gameOver {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(26, 27, 46, 0.95);
|
||||
z-index: 10;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.gameOverTitle {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gameOverScore {
|
||||
font-size: 20px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.highScore {
|
||||
color: var(--warning);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.gameOverButtons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.statsGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.statsCard {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.statsCardTitle {
|
||||
font-size: 14px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--text-dim);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.progressBar {
|
||||
height: 12px;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progressFill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--accent), var(--success));
|
||||
border-radius: 6px;
|
||||
transition: width 0.5s ease;
|
||||
}
|
||||
|
||||
/* === Word destroy effects === */
|
||||
|
||||
.wordExploding {
|
||||
animation: wordExplode 0.45s ease-out forwards;
|
||||
pointer-events: none;
|
||||
border-color: var(--success) !important;
|
||||
background: var(--success) !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
@keyframes wordExplode {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
filter: brightness(1);
|
||||
}
|
||||
30% {
|
||||
transform: scale(1.3);
|
||||
opacity: 1;
|
||||
filter: brightness(2);
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.2);
|
||||
opacity: 0;
|
||||
filter: brightness(3) blur(4px);
|
||||
}
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
animation: particleFly var(--duration) ease-out forwards;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
@keyframes particleFly {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(var(--tx), var(--ty)) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.scorePopup {
|
||||
position: absolute;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 24px;
|
||||
font-weight: 800;
|
||||
color: var(--success);
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
text-shadow: 0 0 12px var(--success), 0 0 24px rgba(46, 204, 113, 0.4);
|
||||
animation: scoreFloat 0.8s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes scoreFloat {
|
||||
0% {
|
||||
transform: translateY(0) scale(0.5);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
transform: translateY(-10px) scale(1.2);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translateY(-60px) scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.screenFlash {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 15;
|
||||
animation: flash 0.3s ease-out forwards;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
@keyframes flash {
|
||||
0% {
|
||||
background: rgba(46, 204, 113, 0.2);
|
||||
}
|
||||
100% {
|
||||
background: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.comboText {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 48px;
|
||||
font-weight: 800;
|
||||
pointer-events: none;
|
||||
z-index: 20;
|
||||
animation: comboPop 0.6s ease-out forwards;
|
||||
background: linear-gradient(135deg, var(--accent), #e84393, var(--warning));
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: none;
|
||||
filter: drop-shadow(0 0 20px var(--accent));
|
||||
}
|
||||
|
||||
@keyframes comboPop {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0.3) rotate(-10deg);
|
||||
opacity: 0;
|
||||
}
|
||||
30% {
|
||||
transform: translate(-50%, -50%) scale(1.3) rotate(3deg);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(0.8) rotate(0deg);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
69
src/styles/global.css
Normal file
69
src/styles/global.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
:root {
|
||||
--bg: #1a1b2e;
|
||||
--bg-card: #242640;
|
||||
--bg-hover: #2d2f52;
|
||||
--text: #e8e8f0;
|
||||
--text-dim: #8888a8;
|
||||
--accent: #7c6ff7;
|
||||
--accent-glow: rgba(124, 111, 247, 0.4);
|
||||
--success: #2ecc71;
|
||||
--error: #e74c3c;
|
||||
--warning: #f1c40f;
|
||||
--radius: 12px;
|
||||
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 14px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: var(--bg-hover);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { box-shadow: 0 0 8px var(--accent-glow); }
|
||||
50% { box-shadow: 0 0 20px var(--accent-glow), 0 0 40px var(--accent-glow); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
71
src/styles/keyboard.module.css
Normal file
71
src/styles/keyboard.module.css
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
.keyboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 20px;
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.row:nth-child(2) { margin-left: 20px; }
|
||||
.row:nth-child(3) { margin-left: 35px; }
|
||||
.row:nth-child(4) { margin-left: 55px; }
|
||||
|
||||
.key {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
position: relative;
|
||||
transition: all 0.15s ease;
|
||||
border: 2px solid transparent;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.key:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.space {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.active {
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.3);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.pressed {
|
||||
transform: translateY(2px) !important;
|
||||
filter: brightness(0.8) !important;
|
||||
transition: all 0.05s ease;
|
||||
}
|
||||
|
||||
.fingerLabel {
|
||||
position: absolute;
|
||||
bottom: -18px;
|
||||
font-size: 9px;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.key:hover .fingerLabel {
|
||||
opacity: 1;
|
||||
}
|
||||
221
src/styles/typing.module.css
Normal file
221
src/styles/typing.module.css
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
.typingArea {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 30px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 22px;
|
||||
line-height: 1.8;
|
||||
min-height: 120px;
|
||||
cursor: text;
|
||||
outline: none;
|
||||
position: relative;
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.typingArea:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.typingArea.blurred::after {
|
||||
content: 'Click here to start typing...';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(26, 27, 46, 0.8);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-dim);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.char {
|
||||
position: relative;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.correct {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.incorrect {
|
||||
color: var(--error);
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.current {
|
||||
border-left: 2px solid var(--accent);
|
||||
animation: blink 1s step-end infinite;
|
||||
margin-left: -1px;
|
||||
padding-left: 1px;
|
||||
}
|
||||
|
||||
.pending {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
50% { border-color: transparent; }
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.statValue {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.statLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.lessonSelect {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lessonCard {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.lessonCard:hover:not(.locked) {
|
||||
border-color: var(--accent);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.locked {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.completed {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.lessonId {
|
||||
font-size: 12px;
|
||||
color: var(--text-dim);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.lessonName {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.lessonKeys {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.lessonStatus {
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
animation: fadeIn 0.2s ease;
|
||||
}
|
||||
|
||||
.modalContent {
|
||||
background: var(--bg-card);
|
||||
border-radius: var(--radius);
|
||||
padding: 40px;
|
||||
max-width: 440px;
|
||||
width: 90%;
|
||||
text-align: center;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.modalStats {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 40px;
|
||||
margin: 24px 0;
|
||||
}
|
||||
|
||||
.unlockText {
|
||||
color: var(--success);
|
||||
font-size: 16px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.modalButtons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.primaryBtn {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 12px 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.primaryBtn:hover {
|
||||
filter: brightness(1.1);
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.secondaryBtn {
|
||||
background: var(--bg-hover);
|
||||
padding: 12px 28px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.modeHeader {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.modeTitle {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.modeSubtitle {
|
||||
color: var(--text-dim);
|
||||
margin-top: 4px;
|
||||
}
|
||||
33
src/types.ts
Normal file
33
src/types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export type Finger =
|
||||
| 'lpinky' | 'lring' | 'lmiddle' | 'lindex'
|
||||
| 'rindex' | 'rmiddle' | 'rring' | 'rpinky'
|
||||
| 'thumb'
|
||||
|
||||
export type KeyInfo = {
|
||||
key: string
|
||||
finger: Finger
|
||||
row: number
|
||||
col: number
|
||||
}
|
||||
|
||||
export type Lesson = {
|
||||
id: number
|
||||
name: string
|
||||
newKeys: string[]
|
||||
words: string[]
|
||||
unlockAfter?: number
|
||||
}
|
||||
|
||||
export type SessionResult = {
|
||||
mode: string
|
||||
wpm: number
|
||||
accuracy: number
|
||||
timestamp: number
|
||||
keyStats: Record<string, { hits: number; misses: number }>
|
||||
}
|
||||
|
||||
export type UserProgress = {
|
||||
completedLessons: number[]
|
||||
sessions: SessionResult[]
|
||||
gameHighScore: number
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue