added server, db, auth system

This commit is contained in:
polwex 2026-04-06 22:11:34 +09:00
parent 4d3395fa1c
commit 199dab69f9
28 changed files with 989 additions and 192 deletions

View file

@ -9,7 +9,12 @@ import type { SessionResult } from './types'
type Tab = 'lessons' | 'free' | 'game' | 'stats'
export default function App() {
type AppProps = {
user: { id: number; username: string }
onLogout: () => void
}
export default function App({ user, onLogout }: AppProps) {
const [tab, setTab] = useState<Tab>('lessons')
const { progress, completeLesson, addSession, updateHighScore } = useStats()
@ -58,7 +63,7 @@ export default function App() {
}, [updateHighScore, addSession])
return (
<Layout activeTab={tab} onTabChange={setTab}>
<Layout activeTab={tab} onTabChange={setTab} username={user.username} onLogout={onLogout}>
{tab === 'lessons' && (
<LessonMode progress={progress} onComplete={handleLessonComplete} />
)}

180
src/components/AuthGate.tsx Normal file
View file

@ -0,0 +1,180 @@
import { useState, useEffect, type ReactNode } from 'react'
import { startRegistration, startAuthentication } from '@simplewebauthn/browser'
import '../styles/auth.css'
type User = { id: number; username: string }
type Props = {
children: (user: User, onLogout: () => void) => ReactNode
}
export function AuthGate({ children }: Props) {
const [user, setUser] = useState<User | null>(null)
const [loading, setLoading] = useState(true)
const [tab, setTab] = useState<'register' | 'login'>('login')
const [username, setUsername] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Check existing session
useEffect(() => {
fetch('/api/auth/me')
.then(r => r.json())
.then(data => {
if (data.user) setUser(data.user)
})
.catch(() => {})
.finally(() => setLoading(false))
}, [])
const handleRegister = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setBusy(true)
try {
const optRes = await fetch('/api/auth/register/options', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim() }),
})
if (!optRes.ok) {
const err = await optRes.json()
throw new Error(err.error || 'Failed to start registration')
}
const options = await optRes.json()
const credential = await startRegistration({ optionsJSON: options })
const verRes = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
})
if (!verRes.ok) {
const err = await verRes.json()
throw new Error(err.error || 'Registration failed')
}
const result = await verRes.json()
setUser({ id: 0, username: result.username })
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
setError('Passkey creation was cancelled')
} else {
setError(err.message)
}
}
} finally {
setBusy(false)
}
}
const handleLogin = async () => {
setError('')
setBusy(true)
try {
const optRes = await fetch('/api/auth/login/options', { method: 'POST' })
if (!optRes.ok) throw new Error('Failed to start login')
const options = await optRes.json()
const credential = await startAuthentication({ optionsJSON: options })
const verRes = await fetch('/api/auth/login/verify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credential),
})
if (!verRes.ok) {
const err = await verRes.json()
throw new Error(err.error || 'Login failed')
}
const result = await verRes.json()
setUser({ id: 0, username: result.username })
} catch (err) {
if (err instanceof Error) {
if (err.name === 'NotAllowedError') {
setError('Passkey authentication was cancelled')
} else {
setError(err.message)
}
}
} finally {
setBusy(false)
}
}
const handleLogout = async () => {
await fetch('/api/auth/logout', { method: 'POST' })
setUser(null)
}
if (loading) {
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-passkey"></div>
<div className="auth-title">Loading...</div>
</div>
</div>
)
}
if (user) {
return <>{children(user, handleLogout)}</>
}
return (
<div className="auth-container">
<div className="auth-card">
<div className="auth-passkey"></div>
<div className="auth-title">Leo's Typing Tutor</div>
<div className="auth-subtitle">Sign in with a passkey to track your progress</div>
<div className="auth-tabs">
<button
className={`auth-tab ${tab === 'login' ? 'auth-tabActive' : ''}`}
onClick={() => { setTab('login'); setError('') }}
>
Login
</button>
<button
className={`auth-tab ${tab === 'register' ? 'auth-tabActive' : ''}`}
onClick={() => { setTab('register'); setError('') }}
>
Register
</button>
</div>
{tab === 'register' ? (
<form className="auth-form" onSubmit={handleRegister}>
<input
className="auth-input"
type="text"
placeholder="Pick a username"
value={username}
onChange={e => setUsername(e.target.value)}
maxLength={32}
autoFocus
required
/>
<button className="auth-btn" type="submit" disabled={busy || !username.trim()}>
{busy ? 'Creating passkey...' : 'Create Account'}
</button>
<div className="auth-hint">
You'll be asked to create a passkey using your device's fingerprint, face, or PIN.
</div>
</form>
) : (
<div className="auth-form">
<button className="auth-btn" onClick={handleLogin} disabled={busy}>
{busy ? 'Authenticating...' : 'Sign in with Passkey'}
</button>
<div className="auth-hint">
Your device will show your saved passkeys. Pick yours to sign in.
</div>
</div>
)}
{error && <div className="auth-error">{error}</div>}
</div>
</div>
)
}

View file

@ -1,6 +1,6 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import styles from '../styles/game.module.css'
import selectorStyles from '../styles/missile.module.css'
import '../styles/game.css'
import '../styles/missile.css'
import { playKeyClick, playWordComplete, playCombo, playMiss, playGameOver } from '../sounds'
import { MissileGame } from './MissileGame'
@ -22,7 +22,7 @@ type FallingWord = {
x: number
y: number
speed: number
matched: number // how many chars matched
matched: number
}
type Explosion = {
@ -100,7 +100,7 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
const word: FallingWord = {
id: nextIdRef.current++,
text,
x: Math.random() * 70 + 5, // 5%-75% from left
x: Math.random() * 70 + 5,
y: -5,
speed: 0.2 + difficulty * 0.05,
matched: 0,
@ -124,7 +124,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
containerRef.current?.focus()
}
// Game loop
useEffect(() => {
if (!started || gameOver) return
@ -132,17 +131,15 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
const tick = (now: number) => {
if (gameOverRef.current) return
const dt = (now - lastTime) / 16.67 // normalize to ~60fps
const dt = (now - lastTime) / 16.67
lastTime = now
// Spawn
const spawnInterval = Math.max(2200 - difficultyRef.current * 80, 900)
if (now - lastSpawnRef.current > spawnInterval) {
spawnWord()
lastSpawnRef.current = now
}
// Move words
setWords(prev => {
const next: FallingWord[] = []
let lostLife = false
@ -172,7 +169,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
return next
})
// Increase difficulty
difficultyRef.current = 1 + score / 100
frameRef.current = requestAnimationFrame(tick)
@ -182,7 +178,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
return () => cancelAnimationFrame(frameRef.current)
}, [started, gameOver, spawnWord, score])
// Fire onGameOver
useEffect(() => {
if (gameOver && started) {
playGameOver()
@ -224,7 +219,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
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) {
@ -233,7 +227,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
updateInput('')
playWordComplete()
// Spawn explosion at word position
const now = Date.now()
setExplosions(ex => [...ex, {
id: now,
@ -242,11 +235,8 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
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) {
@ -258,7 +248,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
clearTimeout(comboTimerRef.current)
comboTimerRef.current = setTimeout(() => setCombo(0), 2000)
// Auto-clean effects after animation
setTimeout(() => {
setExplosions(ex => ex.filter(e => e.id !== now))
}, 500)
@ -268,7 +257,6 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
return prev.filter(w => w.id !== completed.id)
}
// No completion — update partial matches
updateInput(newInput)
return prev.map(w => ({
...w,
@ -291,20 +279,20 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
<div
ref={containerRef}
className={`${styles.gameContainer} ${!focused ? styles.blurred : ''}`}
className={`gameContainer ${!focused ? '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 className="gameHud">
<div className="hudItem">
<span className="hudLabel">Score</span>
<span className="hudValue">{score}</span>
</div>
<div className={styles.hudItem}>
<span className={styles.hudLabel}>Lives</span>
<span className={`${styles.hudValue} ${styles.lives}`}>
<div className="hudItem">
<span className="hudLabel">Lives</span>
<span className="hudValue lives">
{'❤️'.repeat(Math.max(0, lives))}{'🖤'.repeat(Math.max(0, 5 - lives))}
</span>
</div>
@ -313,33 +301,31 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
{words.map(w => (
<div
key={w.id}
className={`${styles.word} ${w.matched > 0 ? styles.wordMatched : ''}`}
className={`word ${w.matched > 0 ? 'wordMatched' : ''}`}
style={{ left: `${w.x}%`, top: `${w.y}%` }}
>
{w.text.split('').map((ch, i) => (
<span key={i} className={i < w.matched ? styles.wordMatchedChar : ''}>
<span key={i} className={i < w.matched ? 'wordMatchedChar' : ''}>
{ch}
</span>
))}
</div>
))}
{/* Exploding words */}
{explosions.map(ex => (
<div
key={ex.id}
className={`${styles.word} ${styles.wordExploding}`}
className="word wordExploding"
style={{ left: `${ex.x}%`, top: `${ex.y}%` }}
>
{ex.text}
</div>
))}
{/* Particles */}
{particles.map(p => (
<div
key={p.id}
className={styles.particle}
className="particle"
style={{
left: `${p.x}%`,
top: `${p.y}%`,
@ -352,42 +338,39 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
/>
))}
{/* Score popups */}
{explosions.map(ex => (
<div
key={`score-${ex.id}`}
className={styles.scorePopup}
className="scorePopup"
style={{ left: `${ex.x + 3}%`, top: `${ex.y - 2}%` }}
>
+{ex.points}
</div>
))}
{/* Screen flash */}
{flash > 0 && <div key={flash} className={styles.screenFlash} />}
{flash > 0 && <div key={flash} className="screenFlash" />}
{/* Combo text */}
{showCombo > 0 && combo >= 3 && (
<div key={showCombo} className={styles.comboText}>
<div key={showCombo} className="comboText">
{combo}x COMBO!
</div>
)}
<div className={styles.dangerZone} />
<div className="dangerZone" />
{started && !gameOver && (
<div className={styles.inputDisplay}>
<div className="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}>
<div className="gameOver">
<div className="gameOverTitle">Falling Words</div>
<div className="gameOverScore">
Type the words before they fall!
</div>
<button className={styles.gameOverButtons} onClick={startGame}
<button className="gameOverButtons" onClick={startGame}
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 18, padding: '14px 36px' }}>
Start Game
</button>
@ -395,13 +378,13 @@ function FallingWordsGame({ highScore, onGameOver }: Props) {
)}
{gameOver && (
<div className={styles.gameOver}>
<div className={styles.gameOverTitle}>Game Over!</div>
<div className={styles.gameOverScore}>Score: {score}</div>
<div className="gameOver">
<div className="gameOverTitle">Game Over!</div>
<div className="gameOverScore">Score: {score}</div>
{score > highScore && score > 0 && (
<div className={styles.highScore}>New High Score!</div>
<div className="highScore">New High Score!</div>
)}
<div className={styles.gameOverButtons}>
<div className="gameOverButtons">
<button
onClick={startGame}
style={{ background: 'var(--accent)', color: '#fff', fontWeight: 600, fontSize: 16, padding: '12px 28px' }}
@ -453,18 +436,18 @@ export function GameMode({ highScore, onGameOver }: Props) {
<p style={{ color: 'var(--text-dim)', fontSize: 14, marginBottom: 24 }}>
High Score: {highScore}
</p>
<div className={selectorStyles.selector}>
<div className={selectorStyles.selectorCard} onClick={() => setChoice('falling')}>
<div className={selectorStyles.selectorIcon}>🌧</div>
<div className={selectorStyles.selectorTitle}>Falling Words</div>
<div className={selectorStyles.selectorDesc}>
<div className="selector">
<div className="selectorCard" onClick={() => setChoice('falling')}>
<div className="selectorIcon">🌧</div>
<div className="selectorTitle">Falling Words</div>
<div className="selectorDesc">
Type falling words before they hit the ground. Build combos for bonus points!
</div>
</div>
<div className={selectorStyles.selectorCard} onClick={() => setChoice('missile')}>
<div className={selectorStyles.selectorIcon}>🚀</div>
<div className={selectorStyles.selectorTitle}>Missile Strike</div>
<div className={selectorStyles.selectorDesc}>
<div className="selectorCard" onClick={() => setChoice('missile')}>
<div className="selectorIcon">🚀</div>
<div className="selectorTitle">Missile Strike</div>
<div className="selectorDesc">
Launch missiles at the enemy city. Type words to dodge interceptors!
</div>
</div>

View file

@ -1,7 +1,7 @@
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'
import '../styles/keyboard.css'
type Props = {
activeKey?: string
@ -39,9 +39,9 @@ export function Keyboard({ activeKey, keyStats }: Props) {
}
return (
<div className={styles.keyboard}>
<div className="keyboard">
{KEYBOARD_LAYOUT.map((row, ri) => (
<div className={styles.row} key={ri}>
<div className="row" key={ri}>
{row.map(ki => {
const isActive = activeKey !== undefined &&
ki.key === activeKey.toLowerCase()
@ -51,14 +51,14 @@ export function Keyboard({ activeKey, keyStats }: Props) {
return (
<div
key={ki.key}
className={`${styles.key} ${ki.key === ' ' ? styles.space : ''} ${isActive ? styles.active : ''} ${isPressed ? styles.pressed : ''}`}
className={`key ${ki.key === ' ' ? 'space' : ''} ${isActive ? 'active' : ''} ${isPressed ? 'pressed' : ''}`}
style={{
background: getKeyBg(ki),
color: '#fff',
}}
>
{label}
<span className={styles.fingerLabel}>{FINGER_LABELS[ki.finger]}</span>
<span className="fingerLabel">{FINGER_LABELS[ki.finger]}</span>
</div>
)
})}

View file

@ -5,6 +5,8 @@ type Tab = 'lessons' | 'free' | 'game' | 'stats'
type Props = {
activeTab: Tab
onTabChange: (tab: Tab) => void
username: string
onLogout: () => void
children: ReactNode
}
@ -15,7 +17,7 @@ const TABS: { id: Tab; label: string; icon: string }[] = [
{ id: 'stats', label: 'Stats', icon: '📊' },
]
export function Layout({ activeTab, onTabChange, children }: Props) {
export function Layout({ activeTab, onTabChange, username, onLogout, children }: Props) {
return (
<div style={{ maxWidth: 960, margin: '0 auto', padding: '20px 24px' }}>
<header style={{
@ -33,24 +35,41 @@ export function Layout({ activeTab, onTabChange, children }: Props) {
}}>
Leo's Typing Tutor
</h1>
<nav style={{ display: 'flex', gap: 4 }}>
{TABS.map(tab => (
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<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>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginLeft: 8 }}>
<span style={{ color: 'var(--text-dim)', fontSize: 13 }}>{username}</span>
<button
key={tab.id}
onClick={() => onTabChange(tab.id)}
onClick={onLogout}
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',
background: 'var(--bg-card)',
color: 'var(--text-dim)',
fontSize: 12,
padding: '6px 12px',
borderRadius: 'var(--radius)',
fontSize: 14,
}}
>
{tab.icon} {tab.label}
Logout
</button>
))}
</nav>
</div>
</div>
</header>
<main>{children}</main>
</div>

View file

@ -1,6 +1,6 @@
import { LESSONS } from '../data/lessons'
import type { UserProgress } from '../types'
import styles from '../styles/typing.module.css'
import '../styles/typing.css'
type Props = {
progress: UserProgress
@ -16,13 +16,13 @@ export function LessonSelect({ progress, onSelect }: Props) {
return (
<div>
<div className={styles.modeHeader}>
<h2 className={styles.modeTitle}>Lessons</h2>
<p className={styles.modeSubtitle}>
<div className="modeHeader">
<h2 className="modeTitle">Lessons</h2>
<p className="modeSubtitle">
Master each lesson to unlock the next ({progress.completedLessons.length}/{LESSONS.length} completed)
</p>
</div>
<div className={styles.lessonSelect}>
<div className="lessonSelect">
{LESSONS.map(lesson => {
const unlocked = isUnlocked(lesson.id)
const completed = progress.completedLessons.includes(lesson.id)
@ -30,17 +30,17 @@ export function LessonSelect({ progress, onSelect }: Props) {
return (
<div
key={lesson.id}
className={`${styles.lessonCard} ${!unlocked ? styles.locked : ''} ${completed ? styles.completed : ''}`}
className={`lessonCard ${!unlocked ? 'locked' : ''} ${completed ? 'completed' : ''}`}
onClick={() => unlocked && onSelect(lesson.id)}
>
<div className={styles.lessonId}>Lesson {lesson.id}</div>
<div className={styles.lessonName}>{lesson.name}</div>
<div className="lessonId">Lesson {lesson.id}</div>
<div className="lessonName">{lesson.name}</div>
{lesson.newKeys.length > 0 && (
<div className={styles.lessonKeys}>
<div className="lessonKeys">
New: {lesson.newKeys.join(' ')}
</div>
)}
<div className={styles.lessonStatus}>
<div className="lessonStatus">
{completed ? 'Completed' : unlocked ? 'Ready' : 'Locked'}
</div>
</div>

View file

@ -1,5 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react'
import styles from '../styles/missile.module.css'
import '../styles/missile.css'
import { playKeyClick, playLaunch, playDodge, playMissileHit, playExplosion, playGameOver } from '../sounds'
import { LESSONS } from '../data/lessons'
@ -827,7 +827,7 @@ export function MissileGame({ highScore, onGameOver }: Props) {
<div
ref={wrapperRef}
className={`${styles.wrapper} ${!focused ? styles.blurred : ''} ${shaking ? styles.shake : ''}`}
className={`wrapper ${!focused ? 'blurred' : ''} ${shaking ? 'shake' : ''}`}
tabIndex={0}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
@ -835,33 +835,33 @@ export function MissileGame({ highScore, onGameOver }: Props) {
>
<canvas
ref={canvasRef}
className={styles.canvas}
className="canvas"
width={CANVAS_W}
height={CANVAS_H}
/>
{/* HUD overlay */}
{started && !gameOver && (
<div className={styles.hud}>
<div className={styles.hudLeft}>
<span className={styles.hudLabel}>Score</span>
<span className={styles.hudValue}>{hudScore}</span>
<div className="hud">
<div className="hudLeft">
<span className="hudLabel">Score</span>
<span className="hudValue">{hudScore}</span>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 4 }}>
<span className={styles.hudLabel}>Missiles</span>
<div className={styles.missiles}>
<span className="hudLabel">Missiles</span>
<div className="missiles">
{[0, 1, 2].map(i => (
<span key={i} className={`${styles.missilePip} ${i < hudMissilesLeft ? styles.active : ''}`}>
<span key={i} className={`missilePip ${i < hudMissilesLeft ? 'active' : ''}`}>
🚀
</span>
))}
</div>
</div>
<div className={styles.hudRight}>
<span className={styles.hudLabel}>HP</span>
<div className={styles.hpPips}>
<div className="hudRight">
<span className="hudLabel">HP</span>
<div className="hpPips">
{[0, 1, 2].map(i => (
<div key={i} className={`${styles.hpPip} ${i < hudHp ? styles.filled : ''}`} />
<div key={i} className={`hpPip ${i < hudHp ? 'filled' : ''}`} />
))}
</div>
</div>
@ -870,11 +870,11 @@ export function MissileGame({ highScore, onGameOver }: Props) {
{/* Word prompt */}
{started && !gameOver && hudWord && hudPhase === 'dodging' && (
<div className={styles.wordPrompt}>
<div className="wordPrompt">
{hudWord.split('').map((ch, i) => (
<span
key={i}
className={i < hudTypedIndex ? styles.typedChar : i === hudTypedIndex ? styles.cursor : ''}
className={i < hudTypedIndex ? 'typedChar' : i === hudTypedIndex ? 'cursor' : ''}
>
{ch}
</span>
@ -884,22 +884,22 @@ export function MissileGame({ highScore, onGameOver }: Props) {
{/* Progress bar */}
{started && !gameOver && (
<div className={styles.progressWrap}>
<div className={styles.progressFill} style={{ width: `${hudProgress * 100}%` }} />
<div className="progressWrap">
<div className="progressFill" style={{ width: `${hudProgress * 100}%` }} />
</div>
)}
{/* Ready screen */}
{!started && (
<div className={styles.overlay}>
<div className={styles.overlayTitle}>Missile Strike</div>
<div className={styles.overlaySubtitle}>
<div className="overlay">
<div className="overlayTitle">Missile Strike</div>
<div className="overlaySubtitle">
Type words to dodge interceptors and strike the city!
</div>
<div style={{ color: 'var(--text-dim)', fontSize: 14, marginTop: 8, maxWidth: 400, textAlign: 'center', lineHeight: 1.6 }}>
3 missiles, 3 HP each. Type 4 words per missile to reach the target.
</div>
<button className={styles.overlayBtn} onClick={startGame}>
<button className="overlayBtn" onClick={startGame}>
Launch!
</button>
</div>
@ -907,13 +907,13 @@ export function MissileGame({ highScore, onGameOver }: Props) {
{/* Game over */}
{gameOver && (
<div className={styles.overlay}>
<div className={styles.overlayTitle}>Mission Complete</div>
<div className={styles.overlaySubtitle}>Score: {finalScore}</div>
<div className="overlay">
<div className="overlayTitle">Mission Complete</div>
<div className="overlaySubtitle">Score: {finalScore}</div>
{finalScore > highScore && finalScore > 0 && (
<div className={styles.newHighScore}>New High Score!</div>
<div className="newHighScore">New High Score!</div>
)}
<button className={styles.overlayBtn} onClick={startGame}>
<button className="overlayBtn" onClick={startGame}>
Play Again
</button>
</div>

View file

@ -1,4 +1,4 @@
import styles from '../styles/typing.module.css'
import '../styles/typing.css'
type Props = {
wpm: number
@ -10,23 +10,23 @@ type Props = {
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}>
<div className="modal" onClick={onBack}>
<div className="modalContent" onClick={e => e.stopPropagation()}>
<div className="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 className="modalStats">
<div className="stat">
<span className="statValue">{wpm}</span>
<span className="statLabel">WPM</span>
</div>
<div className={styles.stat}>
<span className={styles.statValue}>{accuracy}%</span>
<span className={styles.statLabel}>Accuracy</span>
<div className="stat">
<span className="statValue">{accuracy}%</span>
<span className="statLabel">Accuracy</span>
</div>
</div>
{unlocked && (
<div className={styles.unlockText}>
<div className="unlockText">
Unlocked: {unlocked}
</div>
)}
@ -35,9 +35,9 @@ export function ResultsModal({ wpm, accuracy, unlocked, onRetry, onBack }: Props
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 className="modalButtons">
<button className="secondaryBtn" onClick={onBack}>Back</button>
<button className="primaryBtn" onClick={onRetry}>Try Again</button>
</div>
</div>
</div>

View file

@ -2,8 +2,8 @@ import { LineChart, Line, XAxis, YAxis, Tooltip, ResponsiveContainer, CartesianG
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'
import '../styles/game.css'
import '../styles/keyboard.css'
type Props = {
progress: UserProgress
@ -12,7 +12,6 @@ type Props = {
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,
@ -20,7 +19,6 @@ export function StatsView({ progress }: Props) {
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)) {
@ -43,10 +41,9 @@ export function StatsView({ progress }: Props) {
<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 className="statsGrid">
<div className="statsCard">
<div className="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>
@ -67,20 +64,18 @@ export function StatsView({ progress }: Props) {
</div>
</div>
{/* Lesson Progress */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Lesson Progress</div>
<div className="statsCard">
<div className="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 className="progressBar">
<div className="progressFill" style={{ width: `${lessonProgress}%` }} />
</div>
</div>
{/* WPM Chart */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>WPM Over Time</div>
<div className="statsCard">
<div className="statsCardTitle">WPM Over Time</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
@ -100,9 +95,8 @@ export function StatsView({ progress }: Props) {
)}
</div>
{/* Accuracy Chart */}
<div className={styles.statsCard}>
<div className={styles.statsCardTitle}>Accuracy Over Time</div>
<div className="statsCard">
<div className="statsCardTitle">Accuracy Over Time</div>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData}>
@ -122,12 +116,11 @@ export function StatsView({ progress }: Props) {
)}
</div>
{/* Key Accuracy Heatmap */}
<div className={styles.statsCard} style={{ gridColumn: '1 / -1' }}>
<div className={styles.statsCardTitle}>Key Accuracy Heatmap</div>
<div className={kbStyles.keyboard}>
<div className="statsCard" style={{ gridColumn: '1 / -1' }}>
<div className="statsCardTitle">Key Accuracy Heatmap</div>
<div className="keyboard">
{KEYBOARD_LAYOUT.map((row, ri) => (
<div className={kbStyles.row} key={ri}>
<div className="row" key={ri}>
{row.map(ki => {
const stat = aggKeyStats[ki.key]
let bg = FINGER_COLORS[ki.finger]
@ -146,7 +139,7 @@ export function StatsView({ progress }: Props) {
return (
<div
key={ki.key}
className={`${kbStyles.key} ${ki.key === ' ' ? kbStyles.space : ''}`}
className={`key ${ki.key === ' ' ? '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`}
>

View file

@ -1,5 +1,5 @@
import { useRef, useState, useEffect } from 'react'
import styles from '../styles/typing.module.css'
import '../styles/typing.css'
type Props = {
text: string
@ -20,33 +20,33 @@ export function TypingArea({ text, currentIndex, errors, wpm, accuracy, onKeyDow
return (
<div>
<div className={styles.stats}>
<div className={styles.stat}>
<span className={styles.statValue}>{wpm}</span>
<span className={styles.statLabel}>WPM</span>
<div className="stats">
<div className="stat">
<span className="statValue">{wpm}</span>
<span className="statLabel">WPM</span>
</div>
<div className={styles.stat}>
<span className={styles.statValue}>{accuracy}%</span>
<span className={styles.statLabel}>Accuracy</span>
<div className="stat">
<span className="statValue">{accuracy}%</span>
<span className="statLabel">Accuracy</span>
</div>
</div>
<div
ref={ref}
className={`${styles.typingArea} ${!focused ? styles.blurred : ''}`}
className={`typingArea ${!focused ? 'blurred' : ''}`}
tabIndex={0}
onKeyDown={onKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
>
{text.split('').map((char, i) => {
let cls = styles.pending
let cls = 'pending'
if (i < currentIndex) {
cls = errors.has(i) ? styles.incorrect : styles.correct
cls = errors.has(i) ? 'incorrect' : 'correct'
} else if (i === currentIndex) {
cls = styles.current
cls = 'current'
}
return (
<span key={i} className={`${styles.char} ${cls}`}>
<span key={i} className={`char ${cls}`}>
{char}
</span>
)

1
src/env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
declare module '*.css'

View file

@ -2,9 +2,12 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './styles/global.css'
import App from './App.tsx'
import { AuthGate } from './components/AuthGate.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<AuthGate>
{(user, onLogout) => <App user={user} onLogout={onLogout} />}
</AuthGate>
</StrictMode>,
)

120
src/styles/auth.css Normal file
View file

@ -0,0 +1,120 @@
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg);
padding: 20px;
}
.auth-card {
background: var(--bg-card);
border-radius: var(--radius);
padding: 40px;
width: 100%;
max-width: 400px;
text-align: center;
}
.auth-title {
font-size: 28px;
font-weight: 800;
margin-bottom: 8px;
}
.auth-subtitle {
color: var(--text-dim);
font-size: 14px;
margin-bottom: 32px;
}
.auth-tabs {
display: flex;
gap: 0;
margin-bottom: 24px;
border-radius: var(--radius);
overflow: hidden;
border: 1px solid var(--bg-hover);
}
.auth-tab {
flex: 1;
padding: 10px;
background: transparent;
border: none;
color: var(--text-dim);
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: background 0.2s, color 0.2s;
}
.auth-tab:hover {
background: var(--bg-hover);
}
.auth-tabActive {
background: var(--accent);
color: #fff;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 12px;
}
.auth-input {
padding: 12px 16px;
border-radius: var(--radius);
border: 1px solid var(--bg-hover);
background: var(--bg);
color: var(--text);
font-size: 16px;
font-family: inherit;
outline: none;
transition: border-color 0.2s;
}
.auth-input:focus {
border-color: var(--accent);
}
.auth-btn {
padding: 14px;
border-radius: var(--radius);
border: none;
background: var(--accent);
color: #fff;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.2s;
}
.auth-btn:hover {
opacity: 0.9;
}
.auth-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.auth-error {
color: var(--error);
font-size: 14px;
margin-top: 4px;
}
.auth-passkey {
font-size: 48px;
margin-bottom: 16px;
}
.auth-hint {
color: var(--text-dim);
font-size: 13px;
margin-top: 8px;
line-height: 1.5;
}