Compare commits
8 Commits
45f1e0e7f6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20841ca517 | |||
| 9394d583b7 | |||
| 7ab8cde0ad | |||
| 222e4e45ce | |||
| fe5cbaec2b | |||
| 8232e75fa9 | |||
| 0f7eb19626 | |||
| 972e11718c |
@@ -0,0 +1,16 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- run: npm ci
|
||||||
|
- run: npm run test:server
|
||||||
|
- run: npm run test:client
|
||||||
|
- run: npx vite build
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"production": {
|
||||||
|
"kind": "kitvm3_vm",
|
||||||
|
"name": "GamerComp",
|
||||||
|
"state": 2,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "morsequest",
|
||||||
|
"port": 3004,
|
||||||
|
"restart": "docker rm -f morsequest && docker run -d --name morsequest -p 127.0.0.1:3004:8080 -e PORT=8080 -e NODE_ENV=production --restart unless-stopped morsequest"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"urls": {
|
||||||
|
"game": "https://morsequest.gamercomp.com"
|
||||||
|
},
|
||||||
|
"deploy_notes": "Docker on port 3004. nginx server block at /etc/nginx/sites-enabled/morsequest. Deploy: tar + scp + docker build/run via ssh -i ~/.ssh/claude_gamercomp -p 2135 ubuntu@keylinkit.vpnplus.to."
|
||||||
|
},
|
||||||
|
"development": {
|
||||||
|
"kind": "kitvm3_vm",
|
||||||
|
"name": "GamerComp",
|
||||||
|
"state": 2,
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"name": "morsequest",
|
||||||
|
"port": 3004,
|
||||||
|
"restart": "docker rm -f morsequest && docker run -d --name morsequest -p 127.0.0.1:3004:8080 -e PORT=8080 -e NODE_ENV=production --restart unless-stopped morsequest"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"urls": {
|
||||||
|
"game": "https://morsequest.gamercomp.com"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# MorseQuest — CLAUDE.md
|
||||||
|
|
||||||
|
> AI context file for Claude Code sessions.
|
||||||
|
|
||||||
|
## Project Identity
|
||||||
|
|
||||||
|
**MorseQuest** is an adventure-based Morse code learning game. Players progress through 6 levels with personalized mnemonics, multi-modal audio-visual learning, and achievements. Targets amateur radio enthusiasts, Scout programs, and STEM education.
|
||||||
|
|
||||||
|
- **Production**: https://morsequest.gamercomp.com (port 3004 on GamerComp VM)
|
||||||
|
- **Repository**: https://git.keylinkit.net/allen/morsequest
|
||||||
|
- **Game Platform**: Follows `/home/node/workspace/games/GAME_REFERENCE.md` patterns
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
|
||||||
|
| Component | Technology |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Frontend | React 18.2, TypeScript, Vite 5.1 |
|
||||||
|
| Backend | Node.js HTTP server (CommonJS, no framework) |
|
||||||
|
| Database | better-sqlite3 (WAL mode, closure pattern) |
|
||||||
|
| Auth | Magic link email + guest login (nodemailer) |
|
||||||
|
| PWA | Service worker, manifest.json, install prompt |
|
||||||
|
| Testing | Vitest (client), node:test (server) |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
morsequest/
|
||||||
|
├── client/ # React frontend (Vite root: 'client')
|
||||||
|
│ ├── src/ # Components, hooks, pages, lib
|
||||||
|
│ └── public/ # sw.js, manifest.json, icon.svg
|
||||||
|
├── server/
|
||||||
|
│ ├── server.js # HTTP routing, static serving
|
||||||
|
│ ├── db.js # All DB queries as closures inside initDb()
|
||||||
|
│ ├── mailer.js # Magic link emails
|
||||||
|
│ └── tests/ # db.test.js, auth.test.js, progress.test.js, admin.test.js
|
||||||
|
├── data/ # .db files (gitignored)
|
||||||
|
├── Dockerfile # Multi-stage, node:20-alpine, port 8080
|
||||||
|
└── docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # Starts both client (Vite) and server (concurrently)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Server tests (node:test with real HTTP, :memory: DB)
|
||||||
|
node --test server/tests/db.test.js server/tests/auth.test.js server/tests/progress.test.js server/tests/admin.test.js
|
||||||
|
|
||||||
|
# Client tests (vitest)
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Docker on GamerComp VM (port 3004 → 8080 internal):
|
||||||
|
```bash
|
||||||
|
# Build and deploy
|
||||||
|
npx vite build
|
||||||
|
docker build -t morsequest .
|
||||||
|
docker run -d --name morsequest -p 127.0.0.1:3004:8080 \
|
||||||
|
-e ADMIN_PASSWORD=<secret> \
|
||||||
|
-e SMTP_HOST=mail.keylinkit.net -e SMTP_PORT=2525 \
|
||||||
|
-e SMTP_USER=info@gamercomp.com -e SMTP_PASS='kitPLANE1!!' \
|
||||||
|
-e APP_URL=https://morsequest.gamercomp.com \
|
||||||
|
-v /home/ubuntu/game-arcade/games/morsequest/data:/app/data \
|
||||||
|
--restart unless-stopped morsequest
|
||||||
|
```
|
||||||
|
|
||||||
|
SSH: `ssh -i ~/.ssh/claude_gamercomp -p 2135 ubuntu@keylinkit.vpnplus.to`
|
||||||
|
App dir: `/home/ubuntu/game-arcade/games/morsequest/`
|
||||||
|
Nginx block: `/etc/nginx/sites-enabled/morsequest`
|
||||||
+10
-3
@@ -9,7 +9,7 @@ import './index.css'
|
|||||||
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { profile, token, loading, logout } = useAuth()
|
const { profile, token, loading, logout, setAuth } = useAuth()
|
||||||
const [route, setRoute] = useState<AppRoute>('login')
|
const [route, setRoute] = useState<AppRoute>('login')
|
||||||
const [checkingOnboarding, setCheckingOnboarding] = useState(false)
|
const [checkingOnboarding, setCheckingOnboarding] = useState(false)
|
||||||
|
|
||||||
@@ -49,7 +49,10 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="app-shell">
|
<div className="app-shell">
|
||||||
{route === 'login' && (
|
{route === 'login' && (
|
||||||
<LoginPage onLoginSent={() => setRoute('sent')} />
|
<LoginPage
|
||||||
|
onLoginSent={() => setRoute('sent')}
|
||||||
|
onGuestLogin={(token, profile) => setAuth(token, profile)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{route === 'sent' && (
|
{route === 'sent' && (
|
||||||
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
|
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
|
||||||
@@ -58,7 +61,11 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{route === 'onboarding' && token && (
|
{route === 'onboarding' && token && (
|
||||||
<OnboardingPage token={token} onComplete={() => setRoute('game')} />
|
<OnboardingPage
|
||||||
|
token={token}
|
||||||
|
operatorName={profile?.display_name ?? null}
|
||||||
|
onComplete={() => setRoute('game')}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{route === 'game' && token && (
|
{route === 'game' && token && (
|
||||||
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
|
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { NOVICE_LETTERS, MORSE, validatePhrase } from '../lib/morse'
|
import { NOVICE_LETTERS, MORSE, validatePhrase, suggestPhrases } from '../lib/morse'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
token: string
|
token: string
|
||||||
@@ -13,6 +13,7 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
const [phrase, setPhrase] = useState('')
|
const [phrase, setPhrase] = useState('')
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [suggestions, setSuggestions] = useState<string[]>([])
|
||||||
|
|
||||||
const letter = LETTERS[index]
|
const letter = LETTERS[index]
|
||||||
const pattern = MORSE[letter] ?? ''
|
const pattern = MORSE[letter] ?? ''
|
||||||
@@ -20,6 +21,12 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
|
const display = pattern.split('').map(c => c === '.' ? '·' : '—').join(' ')
|
||||||
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
|
const validation = phrase.trim() ? validatePhrase(phrase.trim(), letter) : null
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSuggestions(suggestPhrases(letter, 5))
|
||||||
|
setPhrase('')
|
||||||
|
setError(null)
|
||||||
|
}, [letter])
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!validation?.valid) return
|
if (!validation?.valid) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
@@ -47,8 +54,6 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
onComplete()
|
onComplete()
|
||||||
} else {
|
} else {
|
||||||
setIndex(i => i + 1)
|
setIndex(i => i + 1)
|
||||||
setPhrase('')
|
|
||||||
setError(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,14 +93,15 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
Create your mnemonic phrase:
|
Create your mnemonic phrase:
|
||||||
</label>
|
</label>
|
||||||
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
|
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem' }}>
|
||||||
{pattern.length} word{pattern.length !== 1 ? 's' : ''} — short words (1-2 letters) for dots,
|
{pattern.length} word{pattern.length !== 1 ? 's' : ''} —
|
||||||
long words (3+ letters) for dashes.
|
{' '}≤3 letters for dots ·, 4+ letters for dashes —.
|
||||||
|
{' '}Start a word with "{letter}" to anchor it!
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={phrase}
|
value={phrase}
|
||||||
onChange={e => { setPhrase(e.target.value); setError(null) }}
|
onChange={e => { setPhrase(e.target.value); setError(null) }}
|
||||||
placeholder={`${pattern.length} word${pattern.length !== 1 ? 's' : ''}, e.g. "${examplePhrase(pattern)}"`}
|
placeholder={suggestions.length > 0 ? `e.g. "${suggestions[0]}"` : ''}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
style={{ marginBottom: '0.5rem' }}
|
style={{ marginBottom: '0.5rem' }}
|
||||||
/>
|
/>
|
||||||
@@ -115,6 +121,61 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
<p style={{ color: 'crimson', fontSize: '0.9rem', marginBottom: '0.5rem' }}>{error}</p>
|
<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 */}
|
{/* Actions */}
|
||||||
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '1rem' }}>
|
||||||
<button
|
<button
|
||||||
@@ -136,8 +197,3 @@ export function MnemonicBuilder({ token, onComplete }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate a simple example phrase for the placeholder
|
|
||||||
function examplePhrase(pattern: string): string {
|
|
||||||
return pattern.split('').map(c => c === '.' ? 'go' : 'WALKING').join(' ')
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
|
||||||
type Profile = { id: number; email: string; display_name: string | null }
|
export type Profile = {
|
||||||
|
id: number
|
||||||
|
email: string | null
|
||||||
|
display_name: string | null
|
||||||
|
is_guest: boolean
|
||||||
|
}
|
||||||
type AuthState = { profile: Profile | null; token: string | null; loading: boolean }
|
type AuthState = { profile: Profile | null; token: string | null; loading: boolean }
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
@@ -50,5 +55,11 @@ export function useAuth() {
|
|||||||
setState({ profile: null, token: null, loading: false })
|
setState({ profile: null, token: null, loading: false })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return { ...state, logout }
|
// Used by guest login to inject a session without a round-trip
|
||||||
|
const setAuth = useCallback((token: string, profile: Profile) => {
|
||||||
|
localStorage.setItem('session', token)
|
||||||
|
setState({ profile, token, loading: false })
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return { ...state, logout, setAuth }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,3 +61,21 @@ input:focus { outline: 2px solid var(--color-gold); outline-offset: 1px; }
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 1rem;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import {
|
|||||||
MORSE,
|
MORSE,
|
||||||
NOVICE_LETTERS,
|
NOVICE_LETTERS,
|
||||||
OPERATOR_WORDS,
|
OPERATOR_WORDS,
|
||||||
|
MNEMONIC_SUGGESTIONS,
|
||||||
wpmToTiming,
|
wpmToTiming,
|
||||||
DEFAULT_WPM,
|
DEFAULT_WPM,
|
||||||
DEFAULT_TIMING,
|
DEFAULT_TIMING,
|
||||||
validatePhrase,
|
validatePhrase,
|
||||||
|
suggestPhrases,
|
||||||
pickChallenge,
|
pickChallenge,
|
||||||
} from './morse'
|
} from './morse'
|
||||||
|
|
||||||
@@ -71,28 +73,99 @@ describe('wpmToTiming', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('MNEMONIC_SUGGESTIONS', () => {
|
||||||
|
it('has suggestions for all 12 novice letters', () => {
|
||||||
|
NOVICE_LETTERS.forEach(l => {
|
||||||
|
expect(MNEMONIC_SUGGESTIONS[l]).toBeDefined()
|
||||||
|
expect(MNEMONIC_SUGGESTIONS[l].length).toBeGreaterThanOrEqual(5)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
it('all suggestions follow the dot/dash word length rules', () => {
|
||||||
|
for (const letter of NOVICE_LETTERS) {
|
||||||
|
const pattern = MORSE[letter]
|
||||||
|
const elements = pattern.split('')
|
||||||
|
for (const phrase of MNEMONIC_SUGGESTIONS[letter]) {
|
||||||
|
const words = phrase.split(' ')
|
||||||
|
expect(words).toHaveLength(elements.length)
|
||||||
|
words.forEach((w, i) => {
|
||||||
|
if (elements[i] === '.') expect(w.length).toBeLessThanOrEqual(3)
|
||||||
|
else expect(w.length).toBeGreaterThanOrEqual(4)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
it('every suggestion has at least one word starting with the target letter', () => {
|
||||||
|
for (const letter of NOVICE_LETTERS) {
|
||||||
|
for (const phrase of MNEMONIC_SUGGESTIONS[letter]) {
|
||||||
|
const words = phrase.split(' ')
|
||||||
|
const hasAnchor = words.some(w => w[0].toUpperCase() === letter)
|
||||||
|
expect(hasAnchor).toBe(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('suggestPhrases', () => {
|
||||||
|
it('returns 5 suggestions for A (.-)', () => {
|
||||||
|
const s = suggestPhrases('A', 5)
|
||||||
|
expect(s).toHaveLength(5)
|
||||||
|
})
|
||||||
|
it('returns all curated suggestions when count exceeds available', () => {
|
||||||
|
const s = suggestPhrases('I', 100)
|
||||||
|
expect(s).toHaveLength(MNEMONIC_SUGGESTIONS['I'].length)
|
||||||
|
})
|
||||||
|
it('returns empty array for unknown letter', () => {
|
||||||
|
expect(suggestPhrases('$', 5)).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('validatePhrase', () => {
|
describe('validatePhrase', () => {
|
||||||
it('accepts valid phrase for E (.)', () => {
|
it('accepts valid phrase for E (.)', () => {
|
||||||
expect(validatePhrase('go', 'E')).toEqual({ valid: true })
|
expect(validatePhrase('eat', 'E')).toEqual({ valid: true })
|
||||||
|
})
|
||||||
|
it('accepts 3-letter dot word', () => {
|
||||||
|
expect(validatePhrase('eat', 'E')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
it('accepts valid phrase for A (.-)', () => {
|
it('accepts valid phrase for A (.-)', () => {
|
||||||
expect(validatePhrase('go WALKING', 'A')).toEqual({ valid: true })
|
// A = .- → dot + dash; "age" starts with A, "WALKING" is 4+
|
||||||
|
expect(validatePhrase('age WALKING', 'A')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
it('accepts valid phrase for S (...)', () => {
|
it('accepts valid phrase for S (...)', () => {
|
||||||
expect(validatePhrase('go go go', 'S')).toEqual({ valid: true })
|
// S = ... → three dot words; "sun" starts with S
|
||||||
|
expect(validatePhrase('sun go go', 'S')).toEqual({ valid: true })
|
||||||
|
})
|
||||||
|
it('rejects 4-letter word in dot position', () => {
|
||||||
|
const r = validatePhrase('walk sun go', 'S')
|
||||||
|
expect(r.valid).toBe(false)
|
||||||
|
})
|
||||||
|
it('accepts 3-letter word in dot position', () => {
|
||||||
|
expect(validatePhrase('eat', 'E')).toEqual({ valid: true })
|
||||||
|
})
|
||||||
|
it('rejects 3-letter word in dash position', () => {
|
||||||
|
const r = validatePhrase('age go', 'A') // A=.- so second word must be 4+ letters
|
||||||
|
expect(r.valid).toBe(false)
|
||||||
|
})
|
||||||
|
it('accepts 4-letter word in dash position', () => {
|
||||||
|
expect(validatePhrase('aim also', 'A')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
it('rejects wrong word count', () => {
|
it('rejects wrong word count', () => {
|
||||||
const r = validatePhrase('go go', 'S')
|
const r = validatePhrase('sun go', 'S')
|
||||||
expect(r.valid).toBe(false)
|
expect(r.valid).toBe(false)
|
||||||
expect(r.message).toContain('3')
|
expect(r.message).toContain('3')
|
||||||
})
|
})
|
||||||
it('rejects long word for dot position', () => {
|
it('rejects long word for dot position', () => {
|
||||||
const r = validatePhrase('WALKING go go', 'S')
|
const r = validatePhrase('SAILING go go', 'S')
|
||||||
expect(r.valid).toBe(false)
|
expect(r.valid).toBe(false)
|
||||||
})
|
})
|
||||||
it('rejects short word for dash position', () => {
|
it('rejects phrase without anchor letter', () => {
|
||||||
const r = validatePhrase('go go', 'A')
|
// A = .- but no word starts with A
|
||||||
|
const r = validatePhrase('go walk', 'A')
|
||||||
expect(r.valid).toBe(false)
|
expect(r.valid).toBe(false)
|
||||||
|
expect(r.message).toContain('"A"')
|
||||||
|
})
|
||||||
|
it('accepts anchor letter in any position', () => {
|
||||||
|
// A = .- → "go ALSO" — "ALSO" starts with A
|
||||||
|
expect(validatePhrase('go ALSO', 'A')).toEqual({ valid: true })
|
||||||
})
|
})
|
||||||
it('rejects unknown letter', () => {
|
it('rejects unknown letter', () => {
|
||||||
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
|
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
|
||||||
|
|||||||
+46
-3
@@ -46,6 +46,44 @@ export interface ValidationResult {
|
|||||||
message?: string
|
message?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Curated mnemonic phrases per letter.
|
||||||
|
// Every phrase follows the dot/dash length rule and starts words with the target letter.
|
||||||
|
// Pattern key: ≤3 letters = dot, 4+ letters = dash
|
||||||
|
export const MNEMONIC_SUGGESTIONS: Record<string, string[]> = {
|
||||||
|
// A (.-): ≤3 4+
|
||||||
|
A: ['an APPLE','an ARROW','an ACORN','an ATLAS','an AWARD','aim AFAR','an ANGEL','ace ARMY'],
|
||||||
|
// E (.): ≤3
|
||||||
|
E: ['egg','elk','ear','eye','end','era','eel','elm'],
|
||||||
|
// T (-): 4+
|
||||||
|
T: ['TREE','TALL','TANK','TIME','TUNE','TOAD','TIDE','TRAIL'],
|
||||||
|
// I (..): ≤3 ≤3
|
||||||
|
I: ['it is','in ice','in ink','is icy','I irk'],
|
||||||
|
// N (-.): 4+ ≤3
|
||||||
|
N: ['noon nap','nine nil','nice nod','navy net','near now','note now','nest no'],
|
||||||
|
// S (...): ≤3 ≤3 ≤3
|
||||||
|
S: ['sun set sky','sky sea sun','she sat shy','sir sat sad','see six spy','sip sip sip','say so sir'],
|
||||||
|
// H (....): ≤3 ≤3 ≤3 ≤3
|
||||||
|
H: ['he has his hat','he hid his hog','he hit his hip','ho hum ho hum','he had his ham','he hug his hen'],
|
||||||
|
// R (.-.): ≤3 4+ ≤3
|
||||||
|
R: ['red ROSE row','rob RICH rat','run RACE run','rip ROPE raw','ram ROAD run','rub RUST raw'],
|
||||||
|
// D (-..): 4+ ≤3 ≤3
|
||||||
|
D: ['dark dim den','deep dry dip','dawn dew dry','dusk dim den','dare dig dry','done did dry'],
|
||||||
|
// L (.-..): ≤3 4+ ≤3 ≤3
|
||||||
|
L: ['lit LAMP lay low','let LUCK lay low','lad LEAP log low','led LION lay low','let LIFE lay low'],
|
||||||
|
// U (..-): ≤3 ≤3 4+
|
||||||
|
U: ['us up UPON','use up URGE','us up UNDER','us up UNTIL','us up UNITY'],
|
||||||
|
// O (---): 4+ 4+ 4+
|
||||||
|
O: ['over only once','open oven oath','obey ours only','opal orbs omen','orca over only'],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function suggestPhrases(letter: string, count: number): string[] {
|
||||||
|
const upper = letter.toUpperCase()
|
||||||
|
const phrases = MNEMONIC_SUGGESTIONS[upper]
|
||||||
|
if (!phrases || phrases.length === 0) return []
|
||||||
|
const shuffled = [...phrases].sort(() => Math.random() - 0.5)
|
||||||
|
return shuffled.slice(0, Math.min(count, shuffled.length))
|
||||||
|
}
|
||||||
|
|
||||||
export function validatePhrase(phrase: string, letter: string): ValidationResult {
|
export function validatePhrase(phrase: string, letter: string): ValidationResult {
|
||||||
const upper = letter.toUpperCase()
|
const upper = letter.toUpperCase()
|
||||||
const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined
|
const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined
|
||||||
@@ -63,15 +101,20 @@ export function validatePhrase(phrase: string, letter: string): ValidationResult
|
|||||||
|
|
||||||
for (let i = 0; i < words.length; i++) {
|
for (let i = 0; i < words.length; i++) {
|
||||||
const isDot = elements[i] === '.'
|
const isDot = elements[i] === '.'
|
||||||
const isShort = words[i].length <= 2
|
const isShort = words[i].length <= 3
|
||||||
if (isDot && !isShort) {
|
if (isDot && !isShort) {
|
||||||
return { valid: false, message: `Word "${words[i]}" should be short (1-2 chars) for a dot` }
|
return { valid: false, message: `"${words[i]}" is too long for a dot — use 3 letters or less` }
|
||||||
}
|
}
|
||||||
if (!isDot && isShort) {
|
if (!isDot && isShort) {
|
||||||
return { valid: false, message: `Word "${words[i]}" should be longer (3+ chars) for a dash` }
|
return { valid: false, message: `"${words[i]}" is too short for a dash — use 4+ letters` }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasAnchor = words.some(w => w[0].toUpperCase() === upper)
|
||||||
|
if (!hasAnchor) {
|
||||||
|
return { valid: false, message: `At least one word should start with "${upper}"` }
|
||||||
|
}
|
||||||
|
|
||||||
return { valid: true }
|
return { valid: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export type Gender = 'male' | 'female' | 'mystery'
|
||||||
|
|
||||||
|
export const OPERATOR_NAMES: Record<Gender, string[]> = {
|
||||||
|
male: [
|
||||||
|
'Dusty Clicksworth',
|
||||||
|
'Buck Dittley',
|
||||||
|
'Flash Henderson',
|
||||||
|
'Smokey McBeep',
|
||||||
|
'Knuckles McGee',
|
||||||
|
'Leadfoot Larry Tapsworth',
|
||||||
|
'Jittery Jack Clicker',
|
||||||
|
"Two-Finger Tex",
|
||||||
|
'Wild Bill Keyer',
|
||||||
|
'Rusty Tapper',
|
||||||
|
'Cactus Pete Signalton',
|
||||||
|
'Ol\' Sparky Johnson',
|
||||||
|
'Slick Fingersby',
|
||||||
|
'Gabby Dan Dashmore',
|
||||||
|
'Young Buck Beepsworth',
|
||||||
|
],
|
||||||
|
female: [
|
||||||
|
'Ada Dottsworth',
|
||||||
|
'Clementine Dashford',
|
||||||
|
'Pearl Signalton',
|
||||||
|
'Nellie Tapsworth',
|
||||||
|
'Hattie Clickmore',
|
||||||
|
'Violet Waverley',
|
||||||
|
'Mabel Keymoor',
|
||||||
|
'Belle Dashmore',
|
||||||
|
'Ruby Signalbright',
|
||||||
|
'Clara Quick-Keys',
|
||||||
|
'Ethel Clicksworth',
|
||||||
|
'Dot O\'Dashes',
|
||||||
|
'Hazel Transmitter',
|
||||||
|
'Iris Tapford',
|
||||||
|
'Goldie Wavemore',
|
||||||
|
],
|
||||||
|
mystery: [
|
||||||
|
'The Phantom Sender',
|
||||||
|
'Agent Dot-Dash',
|
||||||
|
'X. Marconi',
|
||||||
|
'The Ghost Operator',
|
||||||
|
'Shadow Keyer',
|
||||||
|
'The Unknown Transmitter',
|
||||||
|
'Agent Zero-Zero',
|
||||||
|
'The Masked Tapper',
|
||||||
|
'Signal Unknown',
|
||||||
|
'The Wandering Wave',
|
||||||
|
'Cipher McGee',
|
||||||
|
'The Midnight Sender',
|
||||||
|
'Static McFrequency',
|
||||||
|
'Professor Dash',
|
||||||
|
'The Lone Transmitter',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomName(gender: Gender): string {
|
||||||
|
const list = OPERATOR_NAMES[gender]
|
||||||
|
return list[Math.floor(Math.random() * list.length)]
|
||||||
|
}
|
||||||
+175
-25
@@ -1,16 +1,60 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import type { Profile } from '../hooks/useAuth'
|
||||||
|
import { type Gender, randomName } from '../lib/names'
|
||||||
|
|
||||||
type LoginPageProps = { onLoginSent: () => void }
|
type Props = {
|
||||||
|
onLoginSent: () => void
|
||||||
|
onGuestLogin: (token: string, profile: Profile) => void
|
||||||
|
}
|
||||||
|
|
||||||
export function LoginPage({ onLoginSent }: LoginPageProps) {
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
|
{ value: 'male', label: 'Male', icon: '♂' },
|
||||||
|
{ value: 'female', label: 'Female', icon: '♀' },
|
||||||
|
{ value: 'mystery', label: 'Mystery', icon: '✦' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function LoginPage({ onLoginSent, onGuestLogin }: Props) {
|
||||||
|
const [gender, setGender] = useState<Gender | null>(null)
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [joining, setJoining] = useState(false)
|
||||||
|
const [showEmail, setShowEmail] = useState(false)
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
async function handleSubmit(e: React.FormEvent) {
|
function pickGender(g: Gender) {
|
||||||
|
setGender(g)
|
||||||
|
setName(randomName(g))
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
function reroll() {
|
||||||
|
if (gender) setName(randomName(gender))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startPlaying() {
|
||||||
|
if (!name) return
|
||||||
|
setJoining(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/auth/guest', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ display_name: name }),
|
||||||
|
})
|
||||||
|
if (!r.ok) throw new Error('Failed to create session')
|
||||||
|
const { token, profile } = await r.json()
|
||||||
|
onGuestLogin(token, profile)
|
||||||
|
} catch {
|
||||||
|
setError('Something went wrong. Try again.')
|
||||||
|
setJoining(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMagicLink(e: React.FormEvent) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!email.includes('@') || !email.includes('.')) {
|
if (!email.includes('@') || !email.includes('.')) {
|
||||||
setError('Please enter a valid email address.')
|
setError('Enter a valid email address.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
@@ -21,44 +65,150 @@ export function LoginPage({ onLoginSent }: LoginPageProps) {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ email }),
|
body: JSON.stringify({ email }),
|
||||||
})
|
})
|
||||||
if (!r.ok) {
|
if (!r.ok) throw new Error('Failed to send link')
|
||||||
const body = await r.json().catch(() => ({}))
|
|
||||||
throw new Error(body.error || 'Something went wrong')
|
|
||||||
}
|
|
||||||
onLoginSent()
|
onLoginSent()
|
||||||
} catch (err: unknown) {
|
} catch {
|
||||||
setError(err instanceof Error ? err.message : 'Something went wrong')
|
setError('Failed to send magic link. Try again.')
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false)
|
setSubmitting(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ maxWidth: 420, margin: '4rem auto 0' }}>
|
<div style={{ maxWidth: 440, margin: '3rem auto 0', padding: '0 1rem' }}>
|
||||||
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>MorseQuest</h1>
|
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||||
<p style={{ marginBottom: '2rem', color: 'var(--color-brown)' }}>
|
<h1 style={{ fontFamily: 'var(--font-header)', fontSize: '2rem', color: 'var(--color-forest)', marginBottom: '0.25rem' }}>
|
||||||
Learn Morse code, adventurer. Enter your email to begin your quest.
|
Join the Telegraph Corps
|
||||||
|
</h1>
|
||||||
|
<p style={{ color: 'var(--color-brown)', fontSize: '0.95rem' }}>
|
||||||
|
No sign-up needed — pick your operator and start transmitting!
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 1: Gender picker */}
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<p style={{ fontWeight: 600, marginBottom: '0.6rem', textAlign: 'center' }}>
|
||||||
|
Choose your operator type:
|
||||||
|
</p>
|
||||||
|
<div style={{ display: 'flex', gap: '0.75rem' }}>
|
||||||
|
{GENDER_OPTIONS.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => pickGender(opt.value)}
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '0.7rem 0.5rem',
|
||||||
|
fontFamily: 'var(--font-header)',
|
||||||
|
fontSize: '1rem',
|
||||||
|
background: gender === opt.value ? 'var(--color-gold)' : 'var(--color-parchment)',
|
||||||
|
border: `2px solid ${gender === opt.value ? 'var(--color-gold)' : '#c8a96e'}`,
|
||||||
|
borderRadius: '0.4rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-forest)',
|
||||||
|
fontWeight: gender === opt.value ? 700 : 400,
|
||||||
|
transition: 'all 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontSize: '1.4rem' }}>{opt.icon}</div>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Step 2: Name + reroll + start */}
|
||||||
|
{gender && (
|
||||||
|
<div style={{ marginBottom: '1.5rem' }}>
|
||||||
|
<p style={{ fontWeight: 600, marginBottom: '0.5rem', textAlign: 'center' }}>
|
||||||
|
Your operator name:
|
||||||
|
</p>
|
||||||
|
<div style={{
|
||||||
|
background: 'var(--color-parchment)',
|
||||||
|
border: '2px solid var(--color-gold)',
|
||||||
|
borderRadius: '0.4rem',
|
||||||
|
padding: '0.9rem 1rem',
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: '0.75rem',
|
||||||
|
}}>
|
||||||
|
<span style={{
|
||||||
|
fontFamily: 'var(--font-header)',
|
||||||
|
fontSize: '1.3rem',
|
||||||
|
color: 'var(--color-forest)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}}>
|
||||||
|
{name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.6rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={reroll}
|
||||||
|
disabled={joining}
|
||||||
|
className="btn btn-ghost"
|
||||||
|
style={{ whiteSpace: 'nowrap' }}
|
||||||
|
>
|
||||||
|
↻ Different name
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startPlaying}
|
||||||
|
disabled={joining}
|
||||||
|
className="btn"
|
||||||
|
style={{ flex: 1, fontSize: '1.05rem' }}
|
||||||
|
>
|
||||||
|
{joining ? 'Joining...' : 'Begin Transmitting!'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p style={{ color: 'crimson', fontSize: '0.9rem', textAlign: 'center', marginBottom: '1rem' }}>
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Optional email section */}
|
||||||
|
<div style={{ borderTop: '1px solid #c8a96e', paddingTop: '1rem', marginTop: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowEmail(v => !v); setError(null) }}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--color-brown)',
|
||||||
|
fontSize: '0.9rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '0.3rem',
|
||||||
|
margin: '0 auto',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{showEmail ? '▾' : '▸'}</span>
|
||||||
|
Already have an account or want cross-device access?
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showEmail && (
|
||||||
|
<form onSubmit={sendMagicLink} style={{ marginTop: '0.75rem' }}>
|
||||||
|
<p style={{ fontSize: '0.85rem', color: 'var(--color-brown)', marginBottom: '0.5rem', textAlign: 'center' }}>
|
||||||
|
Enter your email to receive a magic link.
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleSubmit}>
|
|
||||||
<label style={{ display: 'block', marginBottom: '0.5rem', fontWeight: 600 }}>
|
|
||||||
Email address
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="email"
|
type="email"
|
||||||
value={email}
|
value={email}
|
||||||
onChange={e => setEmail(e.target.value)}
|
onChange={e => { setEmail(e.target.value); setError(null) }}
|
||||||
placeholder="you@example.com"
|
placeholder="telegraph@example.com"
|
||||||
disabled={submitting}
|
disabled={submitting}
|
||||||
required
|
style={{ marginBottom: '0.5rem' }}
|
||||||
style={{ marginBottom: '1rem' }}
|
|
||||||
/>
|
/>
|
||||||
{error && (
|
|
||||||
<p style={{ color: 'crimson', marginBottom: '1rem', fontSize: '0.9rem' }}>{error}</p>
|
|
||||||
)}
|
|
||||||
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
|
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
|
||||||
{submitting ? 'Sending...' : 'Send Magic Link'}
|
{submitting ? 'Sending...' : 'Send Magic Link'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,41 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
import { MnemonicBuilder } from '../components/MnemonicBuilder'
|
import { MnemonicBuilder } from '../components/MnemonicBuilder'
|
||||||
|
import { MnemonicCodebook } from '../components/MnemonicCodebook'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
token: string
|
token: string
|
||||||
|
operatorName: string | null
|
||||||
onComplete: () => void
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>
|
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '0.5rem' }}>
|
||||||
Build Your Mnemonics
|
Build Your Codebook
|
||||||
</h1>
|
</h1>
|
||||||
<p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}>
|
<p style={{ color: 'var(--color-brown)', marginBottom: '2rem' }}>
|
||||||
Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes.
|
Create a memorable phrase for each Morse letter. Short words for dots, long words for dashes.
|
||||||
</p>
|
</p>
|
||||||
<MnemonicBuilder token={token} onComplete={onComplete} />
|
<MnemonicBuilder token={token} onComplete={handleBuilderComplete} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-1
@@ -8,11 +8,33 @@ function initDb(dbPath) {
|
|||||||
instance.pragma('journal_mode = WAL')
|
instance.pragma('journal_mode = WAL')
|
||||||
instance.pragma('foreign_keys = ON')
|
instance.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
|
// Migration: add is_guest + make email nullable on existing installs
|
||||||
|
const profileCols = instance.pragma('table_info(profiles)').map(c => c.name)
|
||||||
|
if (profileCols.length > 0 && !profileCols.includes('is_guest')) {
|
||||||
|
instance.pragma('foreign_keys = OFF')
|
||||||
|
instance.exec(`
|
||||||
|
CREATE TABLE profiles_v2 (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
email TEXT UNIQUE,
|
||||||
|
display_name TEXT,
|
||||||
|
is_guest INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_seen INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
INSERT INTO profiles_v2 (id, email, display_name, is_guest, created_at, last_seen)
|
||||||
|
SELECT id, email, display_name, 0, created_at, last_seen FROM profiles;
|
||||||
|
DROP TABLE profiles;
|
||||||
|
ALTER TABLE profiles_v2 RENAME TO profiles;
|
||||||
|
`)
|
||||||
|
instance.pragma('foreign_keys = ON')
|
||||||
|
}
|
||||||
|
|
||||||
instance.exec(`
|
instance.exec(`
|
||||||
CREATE TABLE IF NOT EXISTS profiles (
|
CREATE TABLE IF NOT EXISTS profiles (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
email TEXT UNIQUE NOT NULL,
|
email TEXT UNIQUE,
|
||||||
display_name TEXT,
|
display_name TEXT,
|
||||||
|
is_guest INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
last_seen INTEGER NOT NULL
|
last_seen INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
@@ -56,6 +78,13 @@ function initDb(dbPath) {
|
|||||||
);
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
function createGuestProfile(displayName) {
|
||||||
|
const now = Date.now()
|
||||||
|
return instance.prepare(
|
||||||
|
'INSERT INTO profiles (email, display_name, is_guest, created_at, last_seen) VALUES (NULL,?,1,?,?) RETURNING *'
|
||||||
|
).get(displayName, now, now)
|
||||||
|
}
|
||||||
|
|
||||||
function createProfile(email, displayName) {
|
function createProfile(email, displayName) {
|
||||||
const now = Date.now()
|
const now = Date.now()
|
||||||
const stmt = instance.prepare(
|
const stmt = instance.prepare(
|
||||||
@@ -230,6 +259,7 @@ function initDb(dbPath) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
_db: instance,
|
_db: instance,
|
||||||
|
createGuestProfile,
|
||||||
createProfile,
|
createProfile,
|
||||||
findProfileByEmail,
|
findProfileByEmail,
|
||||||
findProfileById,
|
findProfileById,
|
||||||
|
|||||||
@@ -62,6 +62,22 @@ function createServer(db) {
|
|||||||
|
|
||||||
// ── Auth routes ──────────────────────────────────────────────────────────
|
// ── Auth routes ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (pathname === '/api/auth/guest' && method === 'POST') {
|
||||||
|
const body = await readBody(req)
|
||||||
|
const name = body.display_name
|
||||||
|
if (!name || typeof name !== 'string' || name.trim().length < 2 || name.trim().length > 80) {
|
||||||
|
return send(res, 400, { error: 'display_name required (2-80 chars)' })
|
||||||
|
}
|
||||||
|
const profile = db.createGuestProfile(name.trim())
|
||||||
|
db.getOrCreateProgress(profile.id)
|
||||||
|
const sessionToken = crypto.randomBytes(32).toString('hex')
|
||||||
|
db.createSession(sessionToken, profile.id)
|
||||||
|
return send(res, 200, {
|
||||||
|
token: sessionToken,
|
||||||
|
profile: { id: profile.id, email: null, display_name: profile.display_name, is_guest: true },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (pathname === '/api/auth/request' && method === 'POST') {
|
if (pathname === '/api/auth/request' && method === 'POST') {
|
||||||
const body = await readBody(req)
|
const body = await readBody(req)
|
||||||
const email = body.email
|
const email = body.email
|
||||||
@@ -102,6 +118,7 @@ function createServer(db) {
|
|||||||
id: profile.id,
|
id: profile.id,
|
||||||
email: profile.email,
|
email: profile.email,
|
||||||
display_name: profile.display_name,
|
display_name: profile.display_name,
|
||||||
|
is_guest: profile.is_guest === 1,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,37 @@ after(() => {
|
|||||||
return new Promise(resolve => server.close(resolve))
|
return new Promise(resolve => server.close(resolve))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('POST /api/auth/guest', () => {
|
||||||
|
test('creates guest profile and returns token + profile', async () => {
|
||||||
|
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'Dusty Clicksworth' })
|
||||||
|
assert.equal(r.status, 200)
|
||||||
|
assert.ok(r.body.token)
|
||||||
|
assert.equal(r.body.profile.display_name, 'Dusty Clicksworth')
|
||||||
|
assert.equal(r.body.profile.is_guest, true)
|
||||||
|
assert.equal(r.body.profile.email, null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('session token from guest login works with /api/auth/me', async () => {
|
||||||
|
const g = await request(server, 'POST', '/api/auth/guest', { display_name: 'Flash Henderson' })
|
||||||
|
const me = await request(server, 'GET', '/api/auth/me', null, {
|
||||||
|
Authorization: `Bearer ${g.body.token}`,
|
||||||
|
})
|
||||||
|
assert.equal(me.status, 200)
|
||||||
|
assert.equal(me.body.display_name, 'Flash Henderson')
|
||||||
|
assert.equal(me.body.is_guest, true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns 400 if display_name missing', async () => {
|
||||||
|
const r = await request(server, 'POST', '/api/auth/guest', {})
|
||||||
|
assert.equal(r.status, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('returns 400 if display_name too short', async () => {
|
||||||
|
const r = await request(server, 'POST', '/api/auth/guest', { display_name: 'X' })
|
||||||
|
assert.equal(r.status, 400)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('POST /api/auth/request', () => {
|
describe('POST /api/auth/request', () => {
|
||||||
test('returns 200 ok for valid email', async () => {
|
test('returns 200 ok for valid email', async () => {
|
||||||
const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })
|
const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })
|
||||||
|
|||||||
Reference in New Issue
Block a user