8232e75fa9
Replace random word banks with hand-crafted mnemonic suggestions for each letter. All phrases are meaningful (e.g. "he has his hat" for H, "sun set sky" for S, "over only once" for O) and every word starts with the target letter. Placeholder shows first suggestion. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
200 lines
6.1 KiB
TypeScript
200 lines
6.1 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import { NOVICE_LETTERS, MORSE, validatePhrase, suggestPhrases } from '../lib/morse'
|
|
|
|
type Props = {
|
|
token: string
|
|
onComplete: () => void
|
|
}
|
|
|
|
const LETTERS = NOVICE_LETTERS // ['A','E','T','I','N','S','H','R','D','L','U','O']
|
|
|
|
export function MnemonicBuilder({ token, onComplete }: Props) {
|
|
const [index, setIndex] = useState(0)
|
|
const [phrase, setPhrase] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [suggestions, setSuggestions] = useState<string[]>([])
|
|
|
|
const letter = LETTERS[index]
|
|
const pattern = MORSE[letter] ?? ''
|
|
// Display pattern as dots/dashes: '.' → '·', '-' → '—'
|
|
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
|
|
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
|
|
|
|
useEffect(() => {
|
|
setSuggestions(suggestPhrases(letter, 5))
|
|
setPhrase('')
|
|
setError(null)
|
|
}, [letter])
|
|
|
|
async function save() {
|
|
if (!validation?.valid) return
|
|
setSaving(true)
|
|
setError(null)
|
|
try {
|
|
const r = await fetch('/api/mnemonics', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Authorization: `Bearer ${token}`,
|
|
},
|
|
body: JSON.stringify({ letter, phrase: phrase.trim() }),
|
|
})
|
|
if (!r.ok) throw new Error('Failed to save')
|
|
advance()
|
|
} catch {
|
|
setError('Failed to save. Try again.')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
function advance() {
|
|
if (index + 1 >= LETTERS.length) {
|
|
onComplete()
|
|
} else {
|
|
setIndex(i => i + 1)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={{ maxWidth: 520, margin: '0 auto' }}>
|
|
{/* Progress */}
|
|
<p style={{ color: 'var(--color-brown)', marginBottom: '1.5rem' }}>
|
|
Letter {index + 1} of {LETTERS.length}
|
|
</p>
|
|
|
|
{/* Letter display */}
|
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
|
<div style={{
|
|
fontSize: '4rem',
|
|
fontFamily: 'var(--font-header)',
|
|
color: 'var(--color-forest)',
|
|
lineHeight: 1,
|
|
}}>
|
|
{letter}
|
|
</div>
|
|
<div style={{
|
|
fontSize: '1.8rem',
|
|
fontFamily: 'var(--font-mono)',
|
|
color: 'var(--color-gold)',
|
|
marginTop: '0.5rem',
|
|
letterSpacing: '0.3em',
|
|
}}>
|
|
{display}
|
|
</div>
|
|
<div style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginTop: '0.25rem' }}>
|
|
{pattern.length} {pattern.length === 1 ? 'word' : 'words'} needed
|
|
</div>
|
|
</div>
|
|
|
|
{/* Phrase input */}
|
|
<label style={{ display: 'block', fontWeight: 600, marginBottom: '0.4rem' }}>
|
|
Create your mnemonic phrase:
|
|
</label>
|
|
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
|
|
{pattern.length} word{pattern.length !== 1 ? 's' : ''} —
|
|
{' '}≤3 letters for dots ·, 4+ letters for dashes —.
|
|
{' '}Start a word with "{letter}" to anchor it!
|
|
</p>
|
|
<input
|
|
type="text"
|
|
value={phrase}
|
|
onChange={e => { setPhrase(e.target.value); setError(null) }}
|
|
placeholder={suggestions.length > 0 ? `e.g. "${suggestions[0]}"` : ''}
|
|
disabled={saving}
|
|
style={{ marginBottom: '0.5rem' }}
|
|
/>
|
|
|
|
{/* Validation feedback */}
|
|
{validation && !validation.valid && (
|
|
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
|
|
{validation.message}
|
|
</p>
|
|
)}
|
|
{validation?.valid && (
|
|
<p style={{ color: 'green', fontSize: '0.9rem', marginBottom: '0.5rem' }}>
|
|
Looks good!
|
|
</p>
|
|
)}
|
|
{error && (
|
|
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>{error}</p>
|
|
)}
|
|
|
|
{/* Suggestions */}
|
|
<div style={{ marginTop: '0.75rem', marginBottom: '1rem' }}>
|
|
<div style={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '0.5rem',
|
|
marginBottom: '0.4rem',
|
|
}}>
|
|
<span style={{ fontSize: '0.85rem', color: 'var(--color-brown)' }}>
|
|
Suggestions — click to use:
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSuggestions(suggestPhrases(letter, 5))}
|
|
disabled={saving}
|
|
style={{
|
|
padding: '0.1rem 0.4rem',
|
|
fontSize: '0.8rem',
|
|
background: 'none',
|
|
border: '1px solid var(--color-brown)',
|
|
borderRadius: '0.25rem',
|
|
cursor: 'pointer',
|
|
color: 'var(--color-brown)',
|
|
lineHeight: 1.4,
|
|
}}
|
|
>
|
|
↻ new
|
|
</button>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
|
|
{suggestions.map(s => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => { setPhrase(s); setError(null) }}
|
|
disabled={saving}
|
|
style={{
|
|
padding: '0.35rem 0.7rem',
|
|
fontSize: '0.9rem',
|
|
fontFamily: 'var(--font-mono)',
|
|
background: phrase === s ? 'var(--color-gold)' : 'var(--color-parchment)',
|
|
border: `1px solid ${phrase === s ? 'var(--color-gold)' : '#c8a96e'}`,
|
|
borderRadius: '0.25rem',
|
|
cursor: 'pointer',
|
|
color: 'var(--color-forest)',
|
|
textAlign: 'left',
|
|
transition: 'background 0.1s',
|
|
}}
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
|
<button
|
|
className="btn"
|
|
onClick={save}
|
|
disabled={saving || !validation?.valid}
|
|
style={{ flex: 1 }}
|
|
>
|
|
{saving ? 'Saving...' : 'Save (+50 pts)'}
|
|
</button>
|
|
<button
|
|
className="btn btn-ghost"
|
|
onClick={advance}
|
|
disabled={saving}
|
|
>
|
|
Skip
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|