added server, db, auth system
This commit is contained in:
parent
4d3395fa1c
commit
199dab69f9
28 changed files with 989 additions and 192 deletions
|
|
@ -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
180
src/components/AuthGate.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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`}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
1
src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
declare module '*.css'
|
||||
|
|
@ -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
120
src/styles/auth.css
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue