feat(onboarding): mnemonic codebook with print/PDF option
After saving all 12 mnemonics, players see their full codebook — each letter with its Morse pattern (· —) and mnemonic phrase in a grid layout. Two actions: Print/Save PDF (window.print) and Start Playing. Operator name appears on the printed page. Print CSS hides UI chrome and renders a clean 3-column grid. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+5
-1
@@ -61,7 +61,11 @@ export default function App() {
|
||||
</div>
|
||||
)}
|
||||
{route === 'onboarding' && token && (
|
||||
<OnboardingPage token={token} onComplete={() => setRoute('game')} />
|
||||
<OnboardingPage
|
||||
token={token}
|
||||
operatorName={profile?.display_name ?? null}
|
||||
onComplete={() => setRoute('game')}
|
||||
/>
|
||||
)}
|
||||
{route === 'game' && token && (
|
||||
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { NOVICE_LETTERS, MORSE } from '../lib/morse'
|
||||
|
||||
type Props = {
|
||||
mnemonics: Record<string, string>
|
||||
operatorName: string | null
|
||||
onStartPlaying: () => void
|
||||
}
|
||||
|
||||
function morseDisplay(pattern: string): string {
|
||||
return pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
|
||||
}
|
||||
|
||||
export function MnemonicCodebook({ mnemonics, operatorName, onStartPlaying }: Props) {
|
||||
return (
|
||||
<div style={{ maxWidth: 680, margin: '0 auto' }}>
|
||||
|
||||
{/* Header — hidden when printing, replaced by print-only title */}
|
||||
<div className="no-print" style={{ textAlign: 'center', marginBottom: '1.5rem' }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-header)', fontSize: '1.8rem', marginBottom: '0.25rem' }}>
|
||||
Your Codebook is Ready!
|
||||
</h1>
|
||||
<p style={{ color: 'var(--color-brown)' }}>
|
||||
Print it out or save as PDF to keep with you during practice.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Print-only title */}
|
||||
<div style={{ display: 'none' }} className="print-title">
|
||||
<h1 style={{ fontFamily: 'var(--font-header)', textAlign: 'center', marginBottom: '0.25rem' }}>
|
||||
Morse Code Codebook
|
||||
</h1>
|
||||
{operatorName && (
|
||||
<p style={{ textAlign: 'center', marginBottom: '1rem', fontStyle: 'italic' }}>
|
||||
Operator: {operatorName}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Letter grid */}
|
||||
<div
|
||||
className="codebook-grid"
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(180px, 1fr))',
|
||||
gap: '0.75rem',
|
||||
marginBottom: '1.5rem',
|
||||
}}
|
||||
>
|
||||
{NOVICE_LETTERS.map(letter => {
|
||||
const pattern = MORSE[letter] ?? ''
|
||||
const phrase = mnemonics[letter]
|
||||
return (
|
||||
<div
|
||||
key={letter}
|
||||
className="codebook-card"
|
||||
style={{
|
||||
background: 'white',
|
||||
border: '2px solid var(--color-gold)',
|
||||
borderRadius: '0.4rem',
|
||||
padding: '0.75rem',
|
||||
}}
|
||||
>
|
||||
{/* Letter + pattern */}
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: '0.6rem', marginBottom: '0.4rem' }}>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-header)',
|
||||
fontSize: '2rem',
|
||||
fontWeight: 700,
|
||||
color: 'var(--color-forest)',
|
||||
lineHeight: 1,
|
||||
}}>
|
||||
{letter}
|
||||
</span>
|
||||
<span style={{
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '1.1rem',
|
||||
color: 'var(--color-gold)',
|
||||
letterSpacing: '0.2em',
|
||||
}}>
|
||||
{morseDisplay(pattern)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Mnemonic phrase */}
|
||||
<p style={{
|
||||
fontSize: '0.9rem',
|
||||
color: phrase ? 'var(--color-forest)' : '#aaa',
|
||||
fontStyle: phrase ? 'normal' : 'italic',
|
||||
lineHeight: 1.3,
|
||||
wordBreak: 'break-word',
|
||||
}}>
|
||||
{phrase ?? 'skipped'}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="no-print" style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => window.print()}
|
||||
>
|
||||
🖨 Print / Save PDF
|
||||
</button>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={onStartPlaying}
|
||||
style={{ fontSize: '1.05rem' }}
|
||||
>
|
||||
Start Playing →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@media print {
|
||||
.print-title { display: block !important; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -61,3 +61,21 @@ input:focus { outline: 2px solid var(--color-gold); outline-offset: 1px; }
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* Print styles */
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
body { background: white; }
|
||||
.app-shell { max-width: 100%; padding: 0; }
|
||||
.codebook-grid {
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(3, 1fr) !important;
|
||||
gap: 0.5rem !important;
|
||||
}
|
||||
.codebook-card {
|
||||
border: 1px solid #999 !important;
|
||||
background: white !important;
|
||||
break-inside: avoid;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,41 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { MnemonicBuilder } from '../components/MnemonicBuilder'
|
||||
import { MnemonicCodebook } from '../components/MnemonicCodebook'
|
||||
|
||||
type Props = {
|
||||
token: string
|
||||
operatorName: string | null
|
||||
onComplete: () => void
|
||||
}
|
||||
|
||||
export function OnboardingPage({ token, onComplete }: Props) {
|
||||
export function OnboardingPage({ token, operatorName, onComplete }: Props) {
|
||||
const [mnemonics, setMnemonics] = useState<Record<string, string> | null>(null)
|
||||
|
||||
const handleBuilderComplete = useCallback(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/mnemonics', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
const data = await r.json()
|
||||
setMnemonics(data)
|
||||
} catch {
|
||||
onComplete()
|
||||
}
|
||||
}, [token, onComplete])
|
||||
|
||||
if (mnemonics) {
|
||||
return <MnemonicCodebook mnemonics={mnemonics} operatorName={operatorName} onStartPlaying={onComplete} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>
|
||||
Build Your Mnemonics
|
||||
Build Your Codebook
|
||||
</h1>
|
||||
<p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}>
|
||||
Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes.
|
||||
</p>
|
||||
<MnemonicBuilder token={token} onComplete={onComplete} />
|
||||
<MnemonicBuilder token={token} onComplete={handleBuilderComplete} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user