Compare commits
30 Commits
7ea8f12a31
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20841ca517 | |||
| 9394d583b7 | |||
| 7ab8cde0ad | |||
| 222e4e45ce | |||
| fe5cbaec2b | |||
| 8232e75fa9 | |||
| 0f7eb19626 | |||
| 972e11718c | |||
| 45f1e0e7f6 | |||
| 12522f2478 | |||
| bbebc31624 | |||
| b31d6637a7 | |||
| c9c12a4c2c | |||
| 10afc2bbb0 | |||
| 8415e569dd | |||
| 7c9386f125 | |||
| 2dd6402c86 | |||
| 85ba992432 | |||
| 339fa60455 | |||
| b229e6871f | |||
| d8de79ce7d | |||
| 2d96d37866 | |||
| 9b31119c7a | |||
| cf2081f643 | |||
| f62490820a | |||
| 1bfd5a22fe | |||
| f7ad8aa3cc | |||
| 6e83b16c4b | |||
| f4fab89809 | |||
| cb0ffb7e6b |
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
client/dist
|
||||
data
|
||||
.env
|
||||
.git
|
||||
*.md
|
||||
@@ -0,0 +1,17 @@
|
||||
# MorseQuest Environment Variables
|
||||
# Copy to .env and fill in values
|
||||
|
||||
PORT=3001
|
||||
NODE_ENV=development
|
||||
|
||||
# SMTP (for magic link emails)
|
||||
SMTP_HOST=mail.keylinkit.net
|
||||
SMTP_PORT=587
|
||||
SMTP_USER=noreply@morsequest.keylinkit.net
|
||||
SMTP_PASS=
|
||||
|
||||
# Admin panel password
|
||||
ADMIN_PASSWORD=
|
||||
|
||||
# App URL (used in magic link emails)
|
||||
APP_URL=http://localhost:3001
|
||||
@@ -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
|
||||
+5
-5
@@ -180,16 +180,13 @@ dist
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
data/
|
||||
data/*
|
||||
!data/.gitkeep
|
||||
|
||||
# Build artifacts
|
||||
build/
|
||||
dist/
|
||||
|
||||
# Docker
|
||||
.dockerignore
|
||||
Dockerfile*
|
||||
docker-compose*.yml
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
@@ -216,3 +213,6 @@ public/uploads/
|
||||
forge-config.json
|
||||
.forge/
|
||||
deployment-keys/
|
||||
|
||||
# Superpowers brainstorm sessions
|
||||
.superpowers/
|
||||
|
||||
@@ -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`
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
# --- Build stage ---
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM node:20-alpine
|
||||
ENV NODE_ENV=production PORT=8080
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
COPY --from=builder /app/client/dist ./client/dist
|
||||
COPY server/ ./server/
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:8080/ || exit 1
|
||||
CMD ["node", "server/server.js"]
|
||||
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/assets/logos/logo-icon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>MorseQuest</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Open+Sans:wght@400;600&family=Source+Code+Pro:wght@400;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useAuth } from './hooks/useAuth'
|
||||
import { LoginPage } from './pages/LoginPage'
|
||||
import { OnboardingPage } from './pages/OnboardingPage'
|
||||
import { GamePage } from './pages/GamePage'
|
||||
import { AdminPage } from './pages/AdminPage'
|
||||
import './index.css'
|
||||
|
||||
type AppRoute = 'login' | 'sent' | 'onboarding' | 'game' | 'admin'
|
||||
|
||||
export default function App() {
|
||||
const { profile, token, loading, logout, setAuth } = useAuth()
|
||||
const [route, setRoute] = useState<AppRoute>('login')
|
||||
const [checkingOnboarding, setCheckingOnboarding] = useState(false)
|
||||
|
||||
// Check for /admin path
|
||||
useEffect(() => {
|
||||
if (window.location.pathname === '/admin') {
|
||||
setRoute('admin')
|
||||
}
|
||||
}, [])
|
||||
|
||||
// After auth resolves, check onboarding status
|
||||
useEffect(() => {
|
||||
if (!profile || !token) return
|
||||
if (route !== 'login' && route !== 'sent') return
|
||||
|
||||
setCheckingOnboarding(true)
|
||||
fetch('/api/mnemonics', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then(r => r.json())
|
||||
.then((mnemonics: Record<string, string>) => {
|
||||
const count = Object.keys(mnemonics).length
|
||||
setRoute(count >= 12 ? 'game' : 'onboarding')
|
||||
})
|
||||
.catch(() => setRoute('game'))
|
||||
.finally(() => setCheckingOnboarding(false))
|
||||
}, [profile, token])
|
||||
|
||||
if (loading || checkingOnboarding) {
|
||||
return (
|
||||
<div className="app-shell" style={{ textAlign: 'center', paddingTop: '4rem' }}>
|
||||
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-shell">
|
||||
{route === 'login' && (
|
||||
<LoginPage
|
||||
onLoginSent={() => setRoute('sent')}
|
||||
onGuestLogin={(token, profile) => setAuth(token, profile)}
|
||||
/>
|
||||
)}
|
||||
{route === 'sent' && (
|
||||
<div style={{ maxWidth: 420, margin: '4rem auto 0', textAlign: 'center' }}>
|
||||
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>Check your email</h2>
|
||||
<p>We sent a magic link to your inbox. Click it to begin your quest!</p>
|
||||
</div>
|
||||
)}
|
||||
{route === 'onboarding' && token && (
|
||||
<OnboardingPage
|
||||
token={token}
|
||||
operatorName={profile?.display_name ?? null}
|
||||
onComplete={() => setRoute('game')}
|
||||
/>
|
||||
)}
|
||||
{route === 'game' && token && (
|
||||
<GamePage token={token} onLogout={() => { logout(); setRoute('login') }} />
|
||||
)}
|
||||
{route === 'admin' && <AdminPage />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
type FlashAreaProps = { signal: 'dot' | 'dash' | null }
|
||||
|
||||
export function FlashArea({ signal }: FlashAreaProps) {
|
||||
const active = signal !== null
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: signal === 'dash' ? 120 : 80,
|
||||
background: active ? 'var(--color-gold)' : '#2a1a0a',
|
||||
border: '3px solid var(--color-forest)',
|
||||
transition: 'background 0.05s, height 0.05s',
|
||||
borderRadius: 4,
|
||||
}} />
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { MORSE } from '../lib/morse'
|
||||
import { FlashArea } from './FlashArea'
|
||||
|
||||
type PracticeCardProps = {
|
||||
challenge: string // the letter or word to guess
|
||||
signal: 'dot' | 'dash' | null
|
||||
isPlaying: boolean
|
||||
onPlay: () => void
|
||||
onAnswer: (answer: string) => void // called when user submits
|
||||
result: 'correct' | 'incorrect' | null
|
||||
mnemonic: string | null
|
||||
}
|
||||
|
||||
export function PracticeCard({
|
||||
challenge, signal, isPlaying, onPlay, onAnswer, result, mnemonic
|
||||
}: PracticeCardProps) {
|
||||
const [answer, setAnswer] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Clear answer on new challenge (when result goes from non-null back to null)
|
||||
useEffect(() => {
|
||||
if (result === null) {
|
||||
setAnswer('')
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
}, [result, challenge])
|
||||
|
||||
function submit() {
|
||||
if (!answer.trim()) return
|
||||
onAnswer(answer.trim().toUpperCase())
|
||||
}
|
||||
|
||||
const morseDisplay = MORSE[challenge.toUpperCase()]
|
||||
? challenge.toUpperCase().split('').map(c => MORSE[c] ?? '').join(' ')
|
||||
: ''
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FlashArea signal={signal} />
|
||||
|
||||
<div style={{ textAlign: 'center', margin: '1.5rem 0' }}>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={onPlay}
|
||||
disabled={isPlaying}
|
||||
style={{ minWidth: 120 }}
|
||||
>
|
||||
{isPlaying ? 'Playing...' : 'Play'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Show morse pattern hint */}
|
||||
{morseDisplay && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
fontSize: '1.2rem',
|
||||
color: 'var(--color-gold)',
|
||||
marginBottom: '1rem',
|
||||
letterSpacing: '0.2em',
|
||||
}}>
|
||||
{morseDisplay.split('').map(c => c === '.' ? '·' : c === '-' ? '—' : ' ').join('')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Answer input */}
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={answer}
|
||||
onChange={e => setAnswer(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') submit() }}
|
||||
placeholder="Type your answer..."
|
||||
disabled={result === 'correct'}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<button
|
||||
className="btn"
|
||||
onClick={submit}
|
||||
disabled={!answer.trim() || result === 'correct'}
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Result feedback */}
|
||||
{result === 'correct' && (
|
||||
<div style={{ padding: '1rem', background: '#d4edda', border: '2px solid #28a745', borderRadius: 4 }}>
|
||||
<strong style={{ color: '#155724' }}>Correct! The answer was: {challenge.toUpperCase()}</strong>
|
||||
{mnemonic && (
|
||||
<p style={{ margin: '0.5rem 0 0', color: '#155724', fontStyle: 'italic' }}>
|
||||
Your mnemonic: "{mnemonic}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{result === 'incorrect' && (
|
||||
<div style={{ padding: '1rem', background: '#fff3cd', border: '2px solid #ffc107', borderRadius: 4 }}>
|
||||
<strong style={{ color: '#856404' }}>Try again, adventurer!</strong>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
type ScoreBarProps = {
|
||||
score: number
|
||||
streak: number
|
||||
level: number
|
||||
}
|
||||
|
||||
const LEVEL_NAMES: Record<number, string> = { 1: 'Novice', 2: 'Operator' }
|
||||
|
||||
export function ScoreBar({ score, streak, level }: ScoreBarProps) {
|
||||
return (
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
gap: '1.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
background: 'var(--color-forest)',
|
||||
color: 'var(--color-parchment)',
|
||||
fontFamily: 'var(--font-body)',
|
||||
fontSize: '0.9rem',
|
||||
marginBottom: '1.5rem',
|
||||
borderRadius: 4,
|
||||
}}>
|
||||
<span><strong style={{ color: 'var(--color-gold)' }}>{LEVEL_NAMES[level] ?? `Level ${level}`}</strong></span>
|
||||
<span>Score: <strong>{score}</strong></span>
|
||||
<span>Streak: <strong style={{ color: streak >= 5 ? 'var(--color-gold)' : 'inherit' }}>{streak}</strong></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
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 }
|
||||
|
||||
export function useAuth() {
|
||||
const [state, setState] = useState<AuthState>({ profile: null, token: null, loading: true })
|
||||
|
||||
const validate = useCallback(async (token: string) => {
|
||||
const r = await fetch('/api/auth/me', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
if (!r.ok) throw new Error('invalid session')
|
||||
const profile: Profile = await r.json()
|
||||
return profile
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Check URL for ?session= from magic link redirect
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
const urlToken = params.get('session')
|
||||
if (urlToken) {
|
||||
localStorage.setItem('session', urlToken)
|
||||
window.history.replaceState({}, '', window.location.pathname)
|
||||
}
|
||||
|
||||
const token = urlToken || localStorage.getItem('session')
|
||||
if (!token) {
|
||||
setState({ profile: null, token: null, loading: false })
|
||||
return
|
||||
}
|
||||
|
||||
validate(token)
|
||||
.then(profile => setState({ profile, token, loading: false }))
|
||||
.catch(() => {
|
||||
localStorage.removeItem('session')
|
||||
setState({ profile: null, token: null, loading: false })
|
||||
})
|
||||
}, [validate])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
const token = localStorage.getItem('session')
|
||||
if (token) {
|
||||
await fetch('/api/auth/session', {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).catch(() => {})
|
||||
localStorage.removeItem('session')
|
||||
}
|
||||
setState({ profile: null, token: null, loading: false })
|
||||
}, [])
|
||||
|
||||
// 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 }
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useRef, useState, useCallback } from 'react'
|
||||
import { MorseAudioEngine } from '../lib/audio'
|
||||
import { DEFAULT_WPM } from '../lib/morse'
|
||||
|
||||
type SignalType = 'dot' | 'dash' | null
|
||||
|
||||
export function useMorseAudio() {
|
||||
const engineRef = useRef<MorseAudioEngine | null>(null)
|
||||
const [isPlaying, setIsPlaying] = useState(false)
|
||||
const [currentSignal, setCurrentSignal] = useState<SignalType>(null)
|
||||
|
||||
function getEngine() {
|
||||
if (!engineRef.current) {
|
||||
const engine = new MorseAudioEngine()
|
||||
engine.on('signal-start', ({ type }: { type: 'dot' | 'dash' }) => setCurrentSignal(type))
|
||||
engine.on('signal-end', () => setCurrentSignal(null))
|
||||
engine.on('play-end', () => { setIsPlaying(false); setCurrentSignal(null) })
|
||||
engineRef.current = engine
|
||||
}
|
||||
return engineRef.current
|
||||
}
|
||||
|
||||
const play = useCallback((text: string) => {
|
||||
const engine = getEngine()
|
||||
engine.stop()
|
||||
setIsPlaying(true)
|
||||
setCurrentSignal(null)
|
||||
engine.play(text, DEFAULT_WPM)
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
engineRef.current?.stop()
|
||||
setIsPlaying(false)
|
||||
setCurrentSignal(null)
|
||||
}, [])
|
||||
|
||||
return { play, stop, isPlaying, currentSignal }
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
|
||||
type Progress = {
|
||||
level: number
|
||||
score: number
|
||||
streak: number
|
||||
best_streak: number
|
||||
total_correct: number
|
||||
total_attempts: number
|
||||
}
|
||||
|
||||
type LetterStats = Record<string, { correct: number; attempts: number }>
|
||||
|
||||
export function useProgress(token: string | null) {
|
||||
const [progress, setProgress] = useState<Progress | null>(null)
|
||||
const [letterStats, setLetterStats] = useState<LetterStats>({})
|
||||
const [mnemonics, setMnemonics] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) return
|
||||
const headers = { Authorization: `Bearer ${token}` }
|
||||
Promise.all([
|
||||
fetch('/api/progress', { headers }).then(r => r.json()),
|
||||
fetch('/api/mnemonics', { headers }).then(r => r.json()),
|
||||
])
|
||||
.then(([prog, mnems]) => {
|
||||
setProgress(prog)
|
||||
setMnemonics(mnems)
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [token])
|
||||
|
||||
const recordAnswer = useCallback(async (letter: string, correct: boolean): Promise<Progress> => {
|
||||
const headers = token ? { Authorization: `Bearer ${token}` } : {}
|
||||
const r = await fetch('/api/progress/answer', {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ letter, correct }),
|
||||
})
|
||||
const updated: Progress = await r.json()
|
||||
setProgress(updated)
|
||||
// Update local letter stats
|
||||
setLetterStats(prev => {
|
||||
const stat = prev[letter.toUpperCase()] ?? { correct: 0, attempts: 0 }
|
||||
return {
|
||||
...prev,
|
||||
[letter.toUpperCase()]: {
|
||||
correct: stat.correct + (correct ? 1 : 0),
|
||||
attempts: stat.attempts + 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
return updated
|
||||
}, [token])
|
||||
|
||||
return { progress, letterStats, mnemonics, loading, recordAnswer }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/* CSS custom properties */
|
||||
:root {
|
||||
--color-gold: #D4AF37;
|
||||
--color-blue: #4169E1;
|
||||
--color-parchment: #F5E6D3;
|
||||
--color-forest: #3C2415;
|
||||
--color-brown: #654321;
|
||||
--font-header: 'Cinzel', serif;
|
||||
--font-body: 'Open Sans', sans-serif;
|
||||
--font-mono: 'Source Code Pro', monospace;
|
||||
}
|
||||
|
||||
/* Reset + base */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: var(--font-body);
|
||||
background: var(--color-parchment);
|
||||
color: var(--color-forest);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Typography */
|
||||
h1, h2, h3 { font-family: var(--font-header); color: var(--color-forest); }
|
||||
code, .mono { font-family: var(--font-mono); }
|
||||
|
||||
/* Shared button style */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 0.7rem 1.6rem;
|
||||
background: var(--color-gold);
|
||||
color: var(--color-forest);
|
||||
border: 2px solid var(--color-forest);
|
||||
font-family: var(--font-header);
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: var(--color-forest); color: var(--color-parchment); }
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--color-gold);
|
||||
color: var(--color-forest);
|
||||
}
|
||||
|
||||
/* Input */
|
||||
input[type="text"], input[type="email"] {
|
||||
width: 100%;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 2px solid var(--color-brown);
|
||||
background: white;
|
||||
font-family: var(--font-body);
|
||||
font-size: 1rem;
|
||||
color: var(--color-forest);
|
||||
}
|
||||
input:focus { outline: 2px solid var(--color-gold); outline-offset: 1px; }
|
||||
|
||||
/* Layout shell */
|
||||
.app-shell {
|
||||
max-width: 680px;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { MorseAudioEngine } from './audio'
|
||||
|
||||
describe('MorseAudioEngine', () => {
|
||||
let engine: MorseAudioEngine
|
||||
|
||||
beforeEach(() => {
|
||||
engine = new MorseAudioEngine()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('emits signal-start and signal-end events when playing E (.)', async () => {
|
||||
const starts: string[] = []
|
||||
const ends: string[] = []
|
||||
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
|
||||
engine.on('signal-end', ({ type }: { type: string }) => ends.push(type))
|
||||
|
||||
const playPromise = engine.play('E', 10)
|
||||
await vi.runAllTimersAsync()
|
||||
await playPromise
|
||||
|
||||
expect(starts).toEqual(['dot'])
|
||||
expect(ends).toEqual(['dot'])
|
||||
})
|
||||
|
||||
it('emits play-end after playback completes', async () => {
|
||||
let ended = false
|
||||
engine.on('play-end', () => { ended = true })
|
||||
|
||||
const playPromise = engine.play('E', 10)
|
||||
await vi.runAllTimersAsync()
|
||||
await playPromise
|
||||
|
||||
expect(ended).toBe(true)
|
||||
})
|
||||
|
||||
it('stop() before play() prevents signal emission', async () => {
|
||||
const starts: string[] = []
|
||||
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
|
||||
|
||||
engine.stop()
|
||||
const playPromise = engine.play('SOS', 10)
|
||||
await vi.runAllTimersAsync()
|
||||
await playPromise
|
||||
|
||||
expect(starts).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('play() works normally after a stop-before-play suppression', async () => {
|
||||
const starts: string[] = []
|
||||
|
||||
// First play is suppressed
|
||||
engine.stop()
|
||||
await engine.play('SOS', 10)
|
||||
|
||||
// Second play should proceed normally
|
||||
engine.on('signal-start', ({ type }: { type: string }) => starts.push(type))
|
||||
const playPromise = engine.play('E', 10)
|
||||
await vi.runAllTimersAsync()
|
||||
await playPromise
|
||||
|
||||
expect(starts).toEqual(['dot'])
|
||||
})
|
||||
|
||||
it('off() removes listener so it is not called', async () => {
|
||||
const cb = vi.fn()
|
||||
engine.on('play-end', cb)
|
||||
engine.off('play-end', cb)
|
||||
|
||||
const playPromise = engine.play('E', 10)
|
||||
await vi.runAllTimersAsync()
|
||||
await playPromise
|
||||
|
||||
expect(cb).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,121 @@
|
||||
import { MORSE, wpmToTiming } from './morse'
|
||||
|
||||
type SignalType = 'dot' | 'dash'
|
||||
type AudioEventName = 'signal-start' | 'signal-end' | 'play-end'
|
||||
type AudioEventPayload = { type: SignalType } | undefined
|
||||
|
||||
export class MorseAudioEngine {
|
||||
private ctx: AudioContext | null = null
|
||||
private listeners = new Map<AudioEventName, Array<(data?: AudioEventPayload) => void>>()
|
||||
// stop() increments stopGeneration. play() captures it to detect mid-play cancellation.
|
||||
private stopGeneration = 0
|
||||
// If stop() is called while no play is active, suppress exactly one upcoming play() call.
|
||||
private pendingCancel = false
|
||||
private isPlaying = false
|
||||
|
||||
private getCtx(): AudioContext {
|
||||
if (!this.ctx || this.ctx.state === 'closed') {
|
||||
this.ctx = new AudioContext()
|
||||
}
|
||||
return this.ctx
|
||||
}
|
||||
|
||||
on(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
|
||||
this.listeners.set(event, [...(this.listeners.get(event) ?? []), cb])
|
||||
}
|
||||
|
||||
off(event: AudioEventName, cb: (data?: AudioEventPayload) => void): void {
|
||||
this.listeners.set(event, (this.listeners.get(event) ?? []).filter(l => l !== cb))
|
||||
}
|
||||
|
||||
private emit(event: AudioEventName, data?: AudioEventPayload): void {
|
||||
;(this.listeners.get(event) ?? []).forEach(cb => cb(data))
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
// Only suppress the next play() if nothing is currently playing.
|
||||
// If a play is in progress, stopGeneration increment is enough to cancel it.
|
||||
if (!this.isPlaying) {
|
||||
this.pendingCancel = true
|
||||
}
|
||||
this.stopGeneration++
|
||||
this.ctx?.suspend()
|
||||
}
|
||||
|
||||
async play(text: string, wpm = 10): Promise<void> {
|
||||
// Consume a pending pre-play cancellation (stop() was called before this play()).
|
||||
// After consuming, the flag is clear so subsequent play() calls proceed normally.
|
||||
if (this.pendingCancel) {
|
||||
this.pendingCancel = false
|
||||
return
|
||||
}
|
||||
|
||||
this.isPlaying = true
|
||||
const myGen = this.stopGeneration
|
||||
const isStopped = () => this.stopGeneration !== myGen
|
||||
|
||||
try {
|
||||
const ctx = this.getCtx()
|
||||
if (ctx.state === 'suspended') await ctx.resume()
|
||||
|
||||
const t = wpmToTiming(wpm)
|
||||
let scheduleTime = ctx.currentTime + 0.05
|
||||
const events: Array<{ delayMs: number; event: AudioEventName; data?: AudioEventPayload }> = []
|
||||
|
||||
for (const char of text.toUpperCase()) {
|
||||
if (isStopped()) break
|
||||
if (char === ' ') {
|
||||
scheduleTime += t.wordGap / 1000
|
||||
continue
|
||||
}
|
||||
const pattern = MORSE[char]
|
||||
if (!pattern) continue
|
||||
|
||||
let firstElement = true
|
||||
for (const el of pattern) {
|
||||
if (isStopped()) break
|
||||
if (!firstElement) scheduleTime += t.elementGap / 1000
|
||||
firstElement = false
|
||||
|
||||
const isDot = el === '.'
|
||||
const type: SignalType = isDot ? 'dot' : 'dash'
|
||||
const dur = (isDot ? t.dot : t.dash) / 1000
|
||||
|
||||
// Schedule audio
|
||||
const osc = ctx.createOscillator()
|
||||
const gain = ctx.createGain()
|
||||
osc.connect(gain)
|
||||
gain.connect(ctx.destination)
|
||||
osc.frequency.value = 700
|
||||
osc.type = 'sine'
|
||||
gain.gain.setValueAtTime(0, scheduleTime)
|
||||
gain.gain.linearRampToValueAtTime(0.5, scheduleTime + 0.005)
|
||||
gain.gain.linearRampToValueAtTime(0, scheduleTime + dur - 0.005)
|
||||
osc.start(scheduleTime)
|
||||
osc.stop(scheduleTime + dur)
|
||||
|
||||
const nowMs = (scheduleTime - ctx.currentTime) * 1000
|
||||
events.push({ delayMs: nowMs, event: 'signal-start', data: { type } })
|
||||
events.push({ delayMs: nowMs + dur * 1000, event: 'signal-end', data: { type } })
|
||||
|
||||
scheduleTime += dur
|
||||
}
|
||||
scheduleTime += (t.charGap - t.elementGap) / 1000
|
||||
}
|
||||
|
||||
const totalMs = Math.max(0, (scheduleTime - ctx.currentTime) * 1000)
|
||||
|
||||
// Fire events via setTimeout aligned to AudioContext schedule
|
||||
for (const { delayMs, event, data } of events) {
|
||||
setTimeout(() => {
|
||||
if (!isStopped()) this.emit(event, data)
|
||||
}, delayMs)
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => setTimeout(resolve, totalMs))
|
||||
if (!isStopped()) this.emit('play-end')
|
||||
} finally {
|
||||
this.isPlaying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
MORSE,
|
||||
NOVICE_LETTERS,
|
||||
OPERATOR_WORDS,
|
||||
MNEMONIC_SUGGESTIONS,
|
||||
wpmToTiming,
|
||||
DEFAULT_WPM,
|
||||
DEFAULT_TIMING,
|
||||
validatePhrase,
|
||||
suggestPhrases,
|
||||
pickChallenge,
|
||||
} from './morse'
|
||||
|
||||
describe('MORSE map', () => {
|
||||
it('has all 26 letters', () => {
|
||||
const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')
|
||||
letters.forEach(l => expect(MORSE[l]).toBeDefined())
|
||||
})
|
||||
it('A is .-', () => expect(MORSE['A']).toBe('.-'))
|
||||
it('S is ...', () => expect(MORSE['S']).toBe('...'))
|
||||
it('O is ---', () => expect(MORSE['O']).toBe('---'))
|
||||
})
|
||||
|
||||
describe('NOVICE_LETTERS', () => {
|
||||
it('has 12 letters', () => expect(NOVICE_LETTERS).toHaveLength(12))
|
||||
it('includes A E T I N S H R D L U O', () => {
|
||||
;['A','E','T','I','N','S','H','R','D','L','U','O'].forEach(l =>
|
||||
expect(NOVICE_LETTERS).toContain(l)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('OPERATOR_WORDS', () => {
|
||||
it('has at least 20 words', () => expect(OPERATOR_WORDS.length).toBeGreaterThanOrEqual(20))
|
||||
it('all words use only Novice letters', () => {
|
||||
const novice = new Set(['A','E','T','I','N','S','H','R','D','L','U','O'])
|
||||
OPERATOR_WORDS.forEach(word => {
|
||||
word.split('').forEach(l => expect(novice.has(l)).toBe(true))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('DEFAULT_WPM and DEFAULT_TIMING', () => {
|
||||
it('DEFAULT_WPM is 10', () => expect(DEFAULT_WPM).toBe(10))
|
||||
it('DEFAULT_TIMING equals wpmToTiming(10)', () => {
|
||||
expect(DEFAULT_TIMING).toEqual(wpmToTiming(10))
|
||||
})
|
||||
})
|
||||
|
||||
describe('wpmToTiming', () => {
|
||||
it('10 WPM gives dot=120ms', () => {
|
||||
expect(wpmToTiming(10).dot).toBe(120)
|
||||
})
|
||||
it('dash is 3x dot', () => {
|
||||
const t = wpmToTiming(10)
|
||||
expect(t.dash).toBe(t.dot * 3)
|
||||
})
|
||||
it('20 WPM gives dot=60ms', () => {
|
||||
expect(wpmToTiming(20).dot).toBe(60)
|
||||
})
|
||||
it('elementGap equals dot', () => {
|
||||
const t = wpmToTiming(10)
|
||||
expect(t.elementGap).toBe(t.dot)
|
||||
})
|
||||
it('charGap is 3x dot', () => {
|
||||
const t = wpmToTiming(10)
|
||||
expect(t.charGap).toBe(t.dot * 3)
|
||||
})
|
||||
it('wordGap is 7x dot', () => {
|
||||
const t = wpmToTiming(10)
|
||||
expect(t.wordGap).toBe(t.dot * 7)
|
||||
})
|
||||
})
|
||||
|
||||
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', () => {
|
||||
it('accepts valid phrase for E (.)', () => {
|
||||
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 (.-)', () => {
|
||||
// A = .- → dot + dash; "age" starts with A, "WALKING" is 4+
|
||||
expect(validatePhrase('age WALKING', 'A')).toEqual({ valid: true })
|
||||
})
|
||||
it('accepts valid phrase for S (...)', () => {
|
||||
// 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', () => {
|
||||
const r = validatePhrase('sun go', 'S')
|
||||
expect(r.valid).toBe(false)
|
||||
expect(r.message).toContain('3')
|
||||
})
|
||||
it('rejects long word for dot position', () => {
|
||||
const r = validatePhrase('SAILING go go', 'S')
|
||||
expect(r.valid).toBe(false)
|
||||
})
|
||||
it('rejects phrase without anchor letter', () => {
|
||||
// A = .- but no word starts with A
|
||||
const r = validatePhrase('go walk', 'A')
|
||||
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', () => {
|
||||
expect(validatePhrase('test', '1')).toEqual({ valid: false, message: 'Unknown letter' })
|
||||
})
|
||||
it('handles empty phrase', () => {
|
||||
expect(validatePhrase('', 'E')).toEqual({ valid: false, message: expect.stringContaining('1') })
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickChallenge', () => {
|
||||
it('always returns item from pool', () => {
|
||||
const pool = ['A', 'E', 'T', 'I']
|
||||
const result = pickChallenge(pool, {}, [])
|
||||
expect(pool).toContain(result)
|
||||
})
|
||||
it('avoids last 3 items when pool is large enough', () => {
|
||||
const pool = ['A', 'E', 'T', 'I', 'N', 'S']
|
||||
const history = ['A', 'E', 'T']
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = pickChallenge(pool, {}, history)
|
||||
expect(['A','E','T']).not.toContain(result)
|
||||
}
|
||||
})
|
||||
it('prefers items with lower accuracy', () => {
|
||||
const pool = ['A', 'B']
|
||||
const stats = {
|
||||
A: { correct: 9, attempts: 10 },
|
||||
B: { correct: 1, attempts: 10 },
|
||||
}
|
||||
let bCount = 0
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (pickChallenge(pool, stats, []) === 'B') bCount++
|
||||
}
|
||||
expect(bCount).toBeGreaterThan(60)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
export const MORSE: Record<string, string> = {
|
||||
A: '.-', B: '-...', C: '-.-.', D: '-..', E: '.',
|
||||
F: '..-.', G: '--.', H: '....', I: '..', J: '.---',
|
||||
K: '-.-', L: '.-..', M: '--', N: '-.', O: '---',
|
||||
P: '.--.', Q: '--.-', R: '.-.', S: '...', T: '-',
|
||||
U: '..-', V: '...-', W: '.--', X: '-..-', Y: '-.--',
|
||||
Z: '--..',
|
||||
'0': '-----', '1': '.----', '2': '..---', '3': '...--',
|
||||
'4': '....-', '5': '.....', '6': '-....', '7': '--...',
|
||||
'8': '---..', '9': '----.',
|
||||
}
|
||||
|
||||
export const NOVICE_LETTERS = ['A','E','T','I','N','S','H','R','D','L','U','O']
|
||||
|
||||
export const OPERATOR_WORDS = [
|
||||
'SEA','SUN','TAN','RAN','HIT','RUN','ANT','DEN',
|
||||
'HEN','OAR','OUR','USE','TIN','SIN','HIS','AIR',
|
||||
'EAR','AND','THE','HOT','NET','SET','TEN','LIT',
|
||||
'SIT','LET','NIT','ROT','NUT','DUE','RID','IRE',
|
||||
]
|
||||
|
||||
export interface Timing {
|
||||
dot: number
|
||||
dash: number
|
||||
elementGap: number
|
||||
charGap: number
|
||||
wordGap: number
|
||||
}
|
||||
|
||||
export function wpmToTiming(wpm: number): Timing {
|
||||
const dot = Math.round(1200 / wpm)
|
||||
return {
|
||||
dot,
|
||||
dash: dot * 3,
|
||||
elementGap: dot,
|
||||
charGap: dot * 3,
|
||||
wordGap: dot * 7,
|
||||
}
|
||||
}
|
||||
|
||||
export const DEFAULT_WPM = 10
|
||||
export const DEFAULT_TIMING = wpmToTiming(DEFAULT_WPM)
|
||||
|
||||
export interface ValidationResult {
|
||||
valid: boolean
|
||||
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 {
|
||||
const upper = letter.toUpperCase()
|
||||
const pattern = /^[A-Z]$/.test(upper) ? MORSE[upper] : undefined
|
||||
if (!pattern) return { valid: false, message: 'Unknown letter' }
|
||||
|
||||
const words = phrase.trim().split(/\s+/).filter(Boolean)
|
||||
const elements = pattern.split('')
|
||||
|
||||
if (words.length !== elements.length) {
|
||||
return {
|
||||
valid: false,
|
||||
message: `Need ${elements.length} word${elements.length !== 1 ? 's' : ''}, got ${words.length}`,
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const isDot = elements[i] === '.'
|
||||
const isShort = words[i].length <= 3
|
||||
if (isDot && !isShort) {
|
||||
return { valid: false, message: `"${words[i]}" is too long for a dot — use 3 letters or less` }
|
||||
}
|
||||
if (!isDot && isShort) {
|
||||
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 }
|
||||
}
|
||||
|
||||
export function pickChallenge(
|
||||
pool: string[],
|
||||
stats: Record<string, { correct: number; attempts: number }>,
|
||||
recentHistory: string[]
|
||||
): string {
|
||||
const available =
|
||||
pool.length > 3 ? pool.filter(item => !recentHistory.slice(-3).includes(item)) : pool
|
||||
|
||||
const weights = available.map(item => {
|
||||
const s = stats[item]
|
||||
if (!s || s.attempts === 0) return 2 // unseen: prioritise over even 0%-accuracy items
|
||||
const accuracy = s.correct / s.attempts
|
||||
return Math.max(0.1, 1 - accuracy) + 0.1
|
||||
})
|
||||
|
||||
const total = weights.reduce((a, b) => a + b, 0)
|
||||
let r = Math.random() * total
|
||||
for (let i = 0; i < available.length; i++) {
|
||||
r -= weights[i]
|
||||
if (r <= 0) return available[i]
|
||||
}
|
||||
return available[available.length - 1]
|
||||
}
|
||||
@@ -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)]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
type AdminUser = {
|
||||
id: number
|
||||
email: string
|
||||
display_name: string | null
|
||||
level: number | null
|
||||
score: number | null
|
||||
total_correct: number | null
|
||||
total_attempts: number | null
|
||||
created_at: number
|
||||
last_seen: number
|
||||
}
|
||||
|
||||
type AdminStats = {
|
||||
totalUsers: number
|
||||
avgScore: number
|
||||
mostMissed: Array<{ letter: string; misses: number }>
|
||||
}
|
||||
|
||||
export function AdminPage() {
|
||||
const [password, setPassword] = useState(() => sessionStorage.getItem('admin_pwd') ?? '')
|
||||
const [authed, setAuthed] = useState(false)
|
||||
const [users, setUsers] = useState<AdminUser[] | null>(null)
|
||||
const [stats, setStats] = useState<AdminStats | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function login(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!password) return
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch('/api/admin/users', {
|
||||
headers: { Authorization: `Bearer ${password}` },
|
||||
})
|
||||
if (!r.ok) throw new Error('Invalid password')
|
||||
sessionStorage.setItem('admin_pwd', password)
|
||||
setAuthed(true)
|
||||
} catch {
|
||||
setError('Invalid admin password')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!authed || !password) return
|
||||
const headers = { Authorization: `Bearer ${password}` }
|
||||
Promise.all([
|
||||
fetch('/api/admin/users', { headers }).then(r => r.json()),
|
||||
fetch('/api/admin/stats', { headers }).then(r => r.json()),
|
||||
]).then(([u, s]) => {
|
||||
setUsers(u)
|
||||
setStats(s)
|
||||
}).catch(() => setError('Failed to load data'))
|
||||
}, [authed, password])
|
||||
|
||||
// Try auto-login if stored password exists (password state already seeded by useState initializer)
|
||||
useEffect(() => {
|
||||
const stored = sessionStorage.getItem('admin_pwd')
|
||||
if (stored) {
|
||||
fetch('/api/admin/users', { headers: { Authorization: `Bearer ${stored}` } })
|
||||
.then(r => { if (r.ok) setAuthed(true) })
|
||||
.catch(() => {})
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (!authed) {
|
||||
return (
|
||||
<div style={{ maxWidth: 380, margin: '4rem auto 0' }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '1.5rem' }}>Admin</h1>
|
||||
<form onSubmit={login}>
|
||||
<label style={{ display: 'block', marginBottom: '0.4rem', fontWeight: 600 }}>
|
||||
Admin password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
placeholder="Enter admin password"
|
||||
style={{ marginBottom: '1rem' }}
|
||||
/>
|
||||
{error && <p style={{ color: 'crimson', marginBottom: '0.75rem' }}>{error}</p>}
|
||||
<button type="submit" className="btn" disabled={loading || !password} style={{ width: '100%' }}>
|
||||
{loading ? 'Checking...' : 'Enter'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 style={{ fontFamily: 'var(--font-header)', marginBottom: '1.5rem' }}>Admin Panel</h1>
|
||||
|
||||
{/* Stats summary */}
|
||||
{stats && (
|
||||
<div style={{
|
||||
display: 'flex', gap: '2rem', flexWrap: 'wrap',
|
||||
padding: '1rem', background: 'var(--color-forest)',
|
||||
color: 'var(--color-parchment)', borderRadius: 4, marginBottom: '1.5rem',
|
||||
}}>
|
||||
<div><div style={{ fontSize: '1.6rem', fontWeight: 700, color: 'var(--color-gold)' }}>{stats.totalUsers}</div><div>Total users</div></div>
|
||||
<div><div style={{ fontSize: '1.6rem', fontWeight: 700, color: 'var(--color-gold)' }}>{stats.avgScore}</div><div>Avg score</div></div>
|
||||
<div>
|
||||
<div style={{ fontSize: '0.85rem', color: 'var(--color-gold)', marginBottom: '0.25rem' }}>Most missed</div>
|
||||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: '1rem' }}>
|
||||
{stats.mostMissed.slice(0, 5).map(m => `${m.letter}(${m.misses})`).join(' ')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User table */}
|
||||
{users ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.9rem' }}>
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--color-forest)', color: 'var(--color-parchment)' }}>
|
||||
{['Email', 'Name', 'Level', 'Score', 'Correct/Attempts', 'Joined', 'Last seen'].map(h => (
|
||||
<th key={h} style={{ padding: '0.5rem 0.75rem', textAlign: 'left', whiteSpace: 'nowrap' }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u, i) => (
|
||||
<tr key={u.id} style={{ background: i % 2 === 0 ? 'white' : 'var(--color-parchment)' }}>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>{u.email}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>{u.display_name ?? '—'}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>{u.level ?? '—'}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>{u.score ?? '—'}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>
|
||||
{u.total_correct ?? 0} / {u.total_attempts ?? 0}
|
||||
</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>{new Date(u.created_at).toLocaleDateString()}</td>
|
||||
<td style={{ padding: '0.5rem 0.75rem' }}>{new Date(u.last_seen).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{users.length === 0 && <p style={{ padding: '1rem', textAlign: 'center' }}>No users yet.</p>}
|
||||
</div>
|
||||
) : (
|
||||
<p>Loading users...</p>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: '2rem' }}>
|
||||
<button className="btn btn-ghost" onClick={() => {
|
||||
sessionStorage.removeItem('admin_pwd')
|
||||
setAuthed(false)
|
||||
setUsers(null)
|
||||
setStats(null)
|
||||
}}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { pickChallenge, NOVICE_LETTERS, OPERATOR_WORDS } from '../lib/morse'
|
||||
import { useMorseAudio } from '../hooks/useMorseAudio'
|
||||
import { useProgress } from '../hooks/useProgress'
|
||||
import { ScoreBar } from '../components/ScoreBar'
|
||||
import { PracticeCard } from '../components/PracticeCard'
|
||||
|
||||
type GamePageProps = {
|
||||
token: string
|
||||
onLogout: () => void
|
||||
}
|
||||
|
||||
export function GamePage({ token, onLogout }: GamePageProps) {
|
||||
const audio = useMorseAudio()
|
||||
const { progress, letterStats, mnemonics, loading, recordAnswer } = useProgress(token)
|
||||
const [challenge, setChallenge] = useState<string | null>(null)
|
||||
const [result, setResult] = useState<'correct' | 'incorrect' | null>(null)
|
||||
const [showLevelUp, setShowLevelUp] = useState(false)
|
||||
const recentHistoryRef = useRef<string[]>([])
|
||||
const incorrectRecordedRef = useRef(false)
|
||||
|
||||
// Pick a new challenge based on current level
|
||||
const nextChallenge = useCallback(() => {
|
||||
const level = progress?.level ?? 1
|
||||
const pool = level >= 2 ? OPERATOR_WORDS : NOVICE_LETTERS
|
||||
const c = pickChallenge(pool, letterStats, recentHistoryRef.current)
|
||||
recentHistoryRef.current = [...recentHistoryRef.current.slice(-4), c]
|
||||
setChallenge(c)
|
||||
setResult(null)
|
||||
incorrectRecordedRef.current = false
|
||||
audio.stop()
|
||||
}, [progress?.level, letterStats])
|
||||
|
||||
// Load first challenge after progress loads
|
||||
useEffect(() => {
|
||||
if (!loading && progress && !challenge) {
|
||||
nextChallenge()
|
||||
}
|
||||
}, [loading, progress])
|
||||
|
||||
async function handleAnswer(answer: string) {
|
||||
if (!challenge || result === 'correct') return
|
||||
const isCorrect = answer === challenge.toUpperCase()
|
||||
setResult(isCorrect ? 'correct' : 'incorrect')
|
||||
|
||||
if (isCorrect) {
|
||||
const updated = await recordAnswer(challenge, true)
|
||||
// Level-up trigger: 10 consecutive correct
|
||||
if (updated.streak > 0 && updated.streak % 10 === 0) {
|
||||
setShowLevelUp(true)
|
||||
}
|
||||
} else if (!incorrectRecordedRef.current) {
|
||||
// Only record first incorrect attempt per challenge (allow re-tries without inflating stats)
|
||||
incorrectRecordedRef.current = true
|
||||
await recordAnswer(challenge, false)
|
||||
}
|
||||
}
|
||||
|
||||
function playChallenge() {
|
||||
if (!challenge) return
|
||||
audio.play(challenge)
|
||||
}
|
||||
|
||||
if (loading || !progress || !challenge) {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', paddingTop: '3rem' }}>
|
||||
<p style={{ fontFamily: 'var(--font-header)' }}>Loading quest...</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ScoreBar score={progress.score} streak={progress.streak} level={progress.level} />
|
||||
|
||||
<PracticeCard
|
||||
challenge={challenge}
|
||||
signal={audio.currentSignal}
|
||||
isPlaying={audio.isPlaying}
|
||||
onPlay={playChallenge}
|
||||
onAnswer={handleAnswer}
|
||||
result={result}
|
||||
mnemonic={mnemonics[challenge.toUpperCase()] ?? null}
|
||||
/>
|
||||
|
||||
{result === 'correct' && (
|
||||
<div style={{ textAlign: 'center', marginTop: '1.5rem' }}>
|
||||
<button className="btn" onClick={nextChallenge}>
|
||||
Next Challenge
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Level-up modal */}
|
||||
{showLevelUp && (
|
||||
<div style={{
|
||||
position: 'fixed', inset: 0,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
zIndex: 100,
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'var(--color-parchment)',
|
||||
border: '3px solid var(--color-gold)',
|
||||
padding: '2rem',
|
||||
maxWidth: 380,
|
||||
textAlign: 'center',
|
||||
}}>
|
||||
<h2 style={{ fontFamily: 'var(--font-header)', marginBottom: '1rem' }}>
|
||||
Quest Complete!
|
||||
</h2>
|
||||
<p style={{ marginBottom: '1.5rem' }}>
|
||||
10 in a row! {progress.level < 2 ? "You've advanced to Operator level!" : 'Outstanding skill!'}
|
||||
</p>
|
||||
<button className="btn" onClick={() => { setShowLevelUp(false); nextChallenge() }}>
|
||||
Continue
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nav */}
|
||||
<div style={{ marginTop: '2rem', borderTop: '1px solid var(--color-brown)', paddingTop: '1rem' }}>
|
||||
<button className="btn btn-ghost" onClick={onLogout} style={{ fontSize: '0.85rem' }}>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import { useState } from 'react'
|
||||
import type { Profile } from '../hooks/useAuth'
|
||||
import { type Gender, randomName } from '../lib/names'
|
||||
|
||||
type Props = {
|
||||
onLoginSent: () => void
|
||||
onGuestLogin: (token: string, profile: Profile) => void
|
||||
}
|
||||
|
||||
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 [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
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()
|
||||
if (!email.includes('@') || !email.includes('.')) {
|
||||
setError('Enter a valid email address.')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
setError(null)
|
||||
try {
|
||||
const r = await fetch('/api/auth/request', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email }),
|
||||
})
|
||||
if (!r.ok) throw new Error('Failed to send link')
|
||||
onLoginSent()
|
||||
} catch {
|
||||
setError('Failed to send magic link. Try again.')
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 440, margin: '3rem auto 0', padding: '0 1rem' }}>
|
||||
<div style={{ textAlign: 'center', marginBottom: '2rem' }}>
|
||||
<h1 style={{ fontFamily: 'var(--font-header)', fontSize: '2rem', color: 'var(--color-forest)', marginBottom: '0.25rem' }}>
|
||||
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>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={e => { setEmail(e.target.value); setError(null) }}
|
||||
placeholder="telegraph@example.com"
|
||||
disabled={submitting}
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
/>
|
||||
<button type="submit" className="btn" disabled={submitting} style={{ width: '100%' }}>
|
||||
{submitting ? 'Sending...' : 'Send Magic Link'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +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, 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 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={handleBuilderComplete} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
// Mock AudioContext for tests
|
||||
class MockAudioContext {
|
||||
currentTime = 0
|
||||
destination = {}
|
||||
state: AudioContextState = 'running'
|
||||
createOscillator() {
|
||||
return {
|
||||
connect: () => {},
|
||||
start: () => {},
|
||||
stop: () => {},
|
||||
frequency: { value: 0 },
|
||||
type: 'sine' as OscillatorType,
|
||||
}
|
||||
}
|
||||
createGain() {
|
||||
return {
|
||||
connect: () => {},
|
||||
gain: {
|
||||
value: 1,
|
||||
setValueAtTime: () => {},
|
||||
linearRampToValueAtTime: () => {},
|
||||
},
|
||||
}
|
||||
}
|
||||
async resume() {}
|
||||
async suspend() {}
|
||||
}
|
||||
;(global as any).AudioContext = MockAudioContext
|
||||
@@ -0,0 +1,22 @@
|
||||
services:
|
||||
morsequest:
|
||||
image: morsequest:latest
|
||||
container_name: morsequest
|
||||
restart: unless-stopped
|
||||
network_mode: bridge
|
||||
ports:
|
||||
- "127.0.0.1:3004:8080"
|
||||
volumes:
|
||||
- morsequest-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODE_ENV: production
|
||||
SMTP_HOST: 172.17.0.1
|
||||
SMTP_PORT: "2587"
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASS: ${SMTP_PASS}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
APP_URL: https://morsequest.keylinkit.net
|
||||
|
||||
volumes:
|
||||
morsequest-data:
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,390 @@
|
||||
# MorseQuest MVP Design Spec
|
||||
**Date:** 2026-04-29
|
||||
**Deployment:** morsequest.keylinkit.net (Docker on VPS)
|
||||
**Purpose:** Pack 404 pilot — feedback-first, rough edges acceptable
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope
|
||||
|
||||
### In scope (MVP)
|
||||
- Magic link email authentication (allegiance pattern)
|
||||
- Mnemonic creation system — the core differentiator
|
||||
- Novice level: 12 letters (A E T I N S H R D L U O)
|
||||
- Operator level: simple 3-letter words from Novice letters
|
||||
- Full Web Audio API + synchronized visual flash playback
|
||||
- Points, streaks, daily bonus scoring
|
||||
- Level-up flow (Novice → Operator)
|
||||
- Basic admin panel (user list, stats, per-user progress)
|
||||
- Docker deployment matching allegiance pattern
|
||||
|
||||
### Out of scope (Phase 2)
|
||||
- Levels 3–6 (General, Extra, Expert, Legend)
|
||||
- Ham radio license modules
|
||||
- Payment / Authorize.net integration
|
||||
- Leaderboards / social features
|
||||
- PWA offline mode
|
||||
- White-label / multi-tenant
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture
|
||||
|
||||
**Frontend:** React 18 + TypeScript, Vite bundler
|
||||
**Backend:** Node.js HTTP server (no framework), better-sqlite3
|
||||
**Auth:** Magic link email via nodemailer (mail.keylinkit.net:587)
|
||||
**Deployment:** Single Docker container, node:20-alpine, named volume
|
||||
|
||||
### Project structure
|
||||
```
|
||||
morsequest/
|
||||
├── client/
|
||||
│ ├── index.html
|
||||
│ └── src/
|
||||
│ ├── main.tsx
|
||||
│ ├── App.tsx # routing: login / onboarding / game / admin
|
||||
│ ├── lib/
|
||||
│ │ ├── morse.ts # MORSE map, timing constants, word pools
|
||||
│ │ └── audio.ts # MorseAudioEngine class
|
||||
│ ├── hooks/
|
||||
│ │ ├── useAuth.ts # session token, /api/auth/me
|
||||
│ │ ├── useMorseAudio.ts # wraps MorseAudioEngine, exposes currentSignal
|
||||
│ │ └── useProgress.ts # score, streak, level from /api/progress
|
||||
│ ├── pages/
|
||||
│ │ ├── LoginPage.tsx
|
||||
│ │ ├── OnboardingPage.tsx # mnemonic builder
|
||||
│ │ ├── GamePage.tsx # practice loop
|
||||
│ │ └── AdminPage.tsx
|
||||
│ └── components/
|
||||
│ ├── FlashArea.tsx # visual signal display
|
||||
│ ├── MnemonicBuilder.tsx # step-through letter mnemonic entry
|
||||
│ ├── PracticeCard.tsx # challenge + answer input
|
||||
│ └── ScoreBar.tsx
|
||||
├── server/
|
||||
│ ├── server.js # Node HTTP, serves /client/dist, handles /api routes
|
||||
│ ├── db.js # better-sqlite3, schema init, all query functions
|
||||
│ └── mailer.js # nodemailer magic link (allegiance pattern)
|
||||
├── data/ # SQLite db lives here (Docker volume)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
└── package.json # root scripts: dev, build, start
|
||||
```
|
||||
|
||||
### Dev vs production
|
||||
- **Dev:** `vite dev` on :5173 with proxy → Node on :3001
|
||||
- **Production:** `npm run build` compiles React to `/client/dist`; Node serves `/client/dist` as static + all `/api` routes on port 8080
|
||||
|
||||
---
|
||||
|
||||
## 3. Database Schema
|
||||
|
||||
`better-sqlite3`, schema created on startup in `db.js`.
|
||||
|
||||
```sql
|
||||
-- Auth (allegiance pattern)
|
||||
CREATE TABLE profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
display_name TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE magic_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
profile_id INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
profile_id INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Progress
|
||||
CREATE TABLE user_progress (
|
||||
profile_id INTEGER PRIMARY KEY,
|
||||
level INTEGER NOT NULL DEFAULT 1, -- 1=Novice, 2=Operator
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
streak INTEGER NOT NULL DEFAULT 0,
|
||||
best_streak INTEGER NOT NULL DEFAULT 0,
|
||||
total_correct INTEGER NOT NULL DEFAULT 0,
|
||||
total_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_session_at INTEGER,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
-- Mnemonics — one row per letter per user
|
||||
CREATE TABLE user_mnemonics (
|
||||
profile_id INTEGER NOT NULL,
|
||||
letter TEXT NOT NULL,
|
||||
phrase TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (profile_id, letter)
|
||||
);
|
||||
|
||||
-- Per-letter accuracy
|
||||
CREATE TABLE letter_stats (
|
||||
profile_id INTEGER NOT NULL,
|
||||
letter TEXT NOT NULL,
|
||||
correct INTEGER NOT NULL DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (profile_id, letter)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API Routes
|
||||
|
||||
All routes return JSON. Auth routes match allegiance pattern. Protected routes require `Authorization: Bearer <session_token>` header.
|
||||
|
||||
```
|
||||
POST /api/auth/request { email } → sends magic link
|
||||
GET /api/auth/verify?token= → 302 to /?session=<token>
|
||||
GET /api/auth/me → { id, email, display_name } [auth]
|
||||
DELETE /api/auth/session → log out [auth]
|
||||
|
||||
GET /api/progress → { level, score, streak, ... } [auth]
|
||||
POST /api/progress/answer { letter, correct } [auth]
|
||||
GET /api/mnemonics → { A: "phrase", ... } [auth]
|
||||
POST /api/mnemonics { letter, phrase } [auth]
|
||||
|
||||
GET /admin → admin HTML (ADMIN_PASSWORD gate)
|
||||
GET /api/admin/users → user list with stats [admin-token]
|
||||
GET /api/admin/stats → global aggregate stats [admin-token]
|
||||
|
||||
# [admin-token]: request must include Authorization: Bearer <ADMIN_PASSWORD>
|
||||
# AdminPage.tsx stores the password in sessionStorage after the user enters it,
|
||||
# and attaches it as a bearer token to all /api/admin/* requests.
|
||||
# Server checks: token === process.env.ADMIN_PASSWORD
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Core User Flows
|
||||
|
||||
### First visit
|
||||
1. Landing page — enter email → POST /api/auth/request
|
||||
2. "Check your email" screen
|
||||
3. Click magic link → GET /api/auth/verify → session token in URL → stored in localStorage
|
||||
4. Redirect to `/onboarding` (mnemonic builder)
|
||||
5. Step through all 12 Novice letters:
|
||||
- Show letter + Morse pattern (dot/dash display)
|
||||
- Play audio + flash on request
|
||||
- Player types phrase — real-time validation:
|
||||
- Word count must equal signal element count
|
||||
- Words ≤2 chars = dot, words ≥3 chars = dash
|
||||
- Save → 50 pts, next letter
|
||||
- Skip available — skipped letters marked, caps rank at Operator
|
||||
6. Onboarding complete → `/game` (Novice)
|
||||
|
||||
### Practice loop
|
||||
1. Load challenge: random letter/word from current level, weighted by accuracy (lower accuracy = more frequent)
|
||||
2. Display signal: visual flash (FlashArea) + audio (useMorseAudio), with Play button
|
||||
3. Player types answer, submits
|
||||
4. **Correct:** show their mnemonic phrase as reinforcement, +points, streak++
|
||||
5. **Incorrect:** "Try again, adventurer!" — replay, no penalty
|
||||
6. 10 consecutive correct → level-up modal
|
||||
|
||||
### Session token extraction
|
||||
After the magic link redirect, `App.tsx` reads `?session=<token>` from the URL on mount, writes it to `localStorage`, strips it from the URL with `history.replaceState`, then fetches `/api/auth/me`. All subsequent API calls attach `Authorization: Bearer <token>`.
|
||||
|
||||
### Returning user
|
||||
1. Landing page — if `localStorage` has a session token, `App.tsx` immediately fetches `/api/auth/me`
|
||||
2. Valid session → redirect to `/game` at current level (no email entry needed)
|
||||
3. Expired/missing → show login page
|
||||
|
||||
### Admin
|
||||
- `/admin` path, guarded by ADMIN_PASSWORD (same env var pattern as allegiance)
|
||||
- Table: all users, email, display name, level, score, joined, last seen
|
||||
- Click user → per-letter accuracy breakdown, mnemonic completion count
|
||||
|
||||
---
|
||||
|
||||
## 6. Audio Engine
|
||||
|
||||
`client/src/lib/audio.ts` — pure TypeScript, no dependencies.
|
||||
|
||||
```typescript
|
||||
// Timing: ITU standard, pilot starts at 10 WPM
|
||||
// dot=120ms, dash=360ms, element gap=120ms, char gap=360ms, word gap=840ms
|
||||
|
||||
const MORSE: Record<string, string> = { A:'.-', B:'-...', /* ... */ }
|
||||
|
||||
class MorseAudioEngine {
|
||||
private ctx: AudioContext
|
||||
private stopFlag = false
|
||||
|
||||
// Emits events: 'signal-start' | 'signal-end' with payload {type:'dot'|'dash'}
|
||||
on(event: string, cb: Function): void
|
||||
async play(text: string, wpm?: number): Promise<void>
|
||||
stop(): void
|
||||
}
|
||||
```
|
||||
|
||||
`useMorseAudio()` hook:
|
||||
- Wraps `MorseAudioEngine`
|
||||
- Exposes `{ play, stop, isPlaying, currentSignal }` where `currentSignal: 'dot'|'dash'|null`
|
||||
- `currentSignal` drives `FlashArea` CSS class toggle
|
||||
|
||||
`FlashArea` component:
|
||||
- Large colored div — off-state: dark parchment, active-state: quest gold (#D4AF37)
|
||||
- Dot flash: 120ms, Dash flash: 360ms
|
||||
- Synchronized to audio engine events (not setTimeout-based)
|
||||
|
||||
---
|
||||
|
||||
## 7. Mnemonic Validation Logic
|
||||
|
||||
Real-time validation as player types:
|
||||
|
||||
```typescript
|
||||
function validatePhrase(phrase: string, morsePattern: string): ValidationResult {
|
||||
const words = phrase.trim().split(/\s+/)
|
||||
const elements = morsePattern.split('') // '.', '-'
|
||||
|
||||
if (words.length !== elements.length) {
|
||||
return { valid: false, message: `Need ${elements.length} words, got ${words.length}` }
|
||||
}
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const isDot = elements[i] === '.'
|
||||
const isShort = words[i].length <= 2 // 1-2 chars = dot
|
||||
if (isDot && !isShort) return { valid: false, message: `Word ${i+1} should be short (dot)` }
|
||||
if (!isDot && isShort) return { valid: false, message: `Word ${i+1} should be long (dash)` }
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
```
|
||||
|
||||
Letters with Morse patterns for Novice set:
|
||||
```
|
||||
A .- E . T - I ..
|
||||
N -. S ... H .... R .-.
|
||||
D -.. L .-.. U ..- O ---
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Scoring
|
||||
|
||||
```typescript
|
||||
function calculatePoints(level: number, streak: number, isFirstSessionToday: boolean): number {
|
||||
const base = level * 10 // Novice=10, Operator=20
|
||||
const streakMult = streak >= 10 ? 2 : 1
|
||||
const dailyMult = isFirstSessionToday ? 1.25 : 1
|
||||
return Math.floor(base * streakMult * dailyMult)
|
||||
}
|
||||
|
||||
// Mnemonic creation: flat 50 pts per letter
|
||||
// Level-up trigger: 10 consecutive correct
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Content Data
|
||||
|
||||
Static in `client/src/lib/morse.ts`:
|
||||
|
||||
```typescript
|
||||
// Novice word pool (Operator level) — 3-letter words from A,E,T,I,N,S,H,R,D,L,U,O
|
||||
export const OPERATOR_WORDS = [
|
||||
'SEA', 'SUN', 'TAN', 'RAN', 'HIT', 'RUN', 'ANT', 'DEN',
|
||||
'HEN', 'INN', 'OAR', 'OUR', 'USE', 'TIN', 'SIN', 'HIS',
|
||||
'AIR', 'EAR', 'OIL', 'AND', 'THE', 'HOT', 'NET', 'SET'
|
||||
]
|
||||
```
|
||||
|
||||
Challenge selection weights: letters/words with lower `correct/attempts` ratio selected more frequently. Avoids repeating the same letter 3× in a row.
|
||||
|
||||
---
|
||||
|
||||
## 10. Docker & Deployment
|
||||
|
||||
### Dockerfile
|
||||
Two-stage build: build stage installs all deps (including Vite) and compiles the React app; runtime stage copies only the built output and production deps.
|
||||
|
||||
```dockerfile
|
||||
# --- Build stage ---
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --no-audit --no-fund # all deps, including vite
|
||||
COPY . .
|
||||
RUN npm run build # vite build → client/dist
|
||||
|
||||
# --- Runtime stage ---
|
||||
FROM node:20-alpine
|
||||
ENV NODE_ENV=production PORT=8080
|
||||
WORKDIR /app
|
||||
RUN apk add --no-cache python3 make g++ # for better-sqlite3 native rebuild
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund
|
||||
COPY --from=builder /app/client/dist ./client/dist
|
||||
COPY server/ ./server/
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
EXPOSE 8080
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:8080/ || exit 1
|
||||
CMD ["node", "server/server.js"]
|
||||
```
|
||||
|
||||
### docker-compose.yml
|
||||
```yaml
|
||||
services:
|
||||
morsequest:
|
||||
image: morsequest:latest
|
||||
container_name: morsequest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:3004:8080"
|
||||
volumes:
|
||||
- morsequest-data:/app/data
|
||||
environment:
|
||||
PORT: "8080"
|
||||
NODE_ENV: production
|
||||
SMTP_HOST: mail.keylinkit.net
|
||||
SMTP_PORT: "587"
|
||||
SMTP_USER: ${SMTP_USER}
|
||||
SMTP_PASS: ${SMTP_PASS}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD}
|
||||
APP_URL: https://morsequest.keylinkit.net
|
||||
|
||||
volumes:
|
||||
morsequest-data:
|
||||
```
|
||||
|
||||
Host nginx proxies `morsequest.keylinkit.net` → `127.0.0.1:3004` (same pattern as allegiance).
|
||||
|
||||
---
|
||||
|
||||
## 11. Brand Implementation
|
||||
|
||||
From brand guidelines:
|
||||
- **Colors as CSS custom properties:** `--color-gold: #D4AF37`, `--color-blue: #4169E1`, `--color-parchment: #F5E6D3`, `--color-forest: #3C2415`
|
||||
- **Fonts:** Cinzel (headers, Google Fonts), Open Sans (body), Source Code Pro (Morse display)
|
||||
- **Voice:** "Try again, adventurer!" not "Incorrect". "Quest complete!" not "Level up".
|
||||
- **Existing SVG assets** from `assets/` used directly (logos, icons, badges)
|
||||
|
||||
---
|
||||
|
||||
## 12. Environment Variables
|
||||
|
||||
```
|
||||
SMTP_HOST mail.keylinkit.net
|
||||
SMTP_PORT 587
|
||||
SMTP_USER noreply@morsequest.keylinkit.net (or similar)
|
||||
SMTP_PASS <password>
|
||||
ADMIN_PASSWORD <admin panel password>
|
||||
APP_URL https://morsequest.keylinkit.net
|
||||
PORT 8080
|
||||
NODE_ENV production
|
||||
```
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "morsequest",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "concurrently \"node --watch server/server.js\" \"vite\"",
|
||||
"build": "vite build",
|
||||
"start": "node server/server.js",
|
||||
"test": "npm run test:server && npm run test:client",
|
||||
"test:server": "node --test server/tests/db.test.js server/tests/auth.test.js server/tests/progress.test.js server/tests/admin.test.js",
|
||||
"test:client": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.0.0",
|
||||
"nodemailer": "^6.9.0",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.0",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@vitejs/plugin-react": "^4.2.0",
|
||||
"concurrently": "^8.2.0",
|
||||
"jsdom": "^24.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.1.0",
|
||||
"vitest": "^1.3.0"
|
||||
}
|
||||
}
|
||||
+283
@@ -0,0 +1,283 @@
|
||||
'use strict'
|
||||
const Database = require('better-sqlite3')
|
||||
const path = require('path')
|
||||
|
||||
function initDb(dbPath) {
|
||||
const resolvedPath = dbPath || path.join(__dirname, '../data/morsequest.db')
|
||||
const instance = new Database(resolvedPath)
|
||||
instance.pragma('journal_mode = WAL')
|
||||
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(`
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
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
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS magic_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL,
|
||||
used INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_progress (
|
||||
profile_id INTEGER PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
level INTEGER NOT NULL DEFAULT 1,
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
streak INTEGER NOT NULL DEFAULT 0,
|
||||
best_streak INTEGER NOT NULL DEFAULT 0,
|
||||
total_correct INTEGER NOT NULL DEFAULT 0,
|
||||
total_attempts INTEGER NOT NULL DEFAULT 0,
|
||||
last_session_at INTEGER,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS user_mnemonics (
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
letter TEXT NOT NULL,
|
||||
phrase TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (profile_id, letter)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS letter_stats (
|
||||
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
letter TEXT NOT NULL,
|
||||
correct INTEGER NOT NULL DEFAULT 0,
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (profile_id, letter)
|
||||
);
|
||||
`)
|
||||
|
||||
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) {
|
||||
const now = Date.now()
|
||||
const stmt = instance.prepare(
|
||||
'INSERT INTO profiles (email, display_name, created_at, last_seen) VALUES (?,?,?,?) RETURNING *'
|
||||
)
|
||||
return stmt.get(email, displayName ?? null, now, now)
|
||||
}
|
||||
|
||||
function findProfileByEmail(email) {
|
||||
return instance.prepare('SELECT * FROM profiles WHERE email = ?').get(email) ?? null
|
||||
}
|
||||
|
||||
function findProfileById(id) {
|
||||
return instance.prepare('SELECT * FROM profiles WHERE id = ?').get(id) ?? null
|
||||
}
|
||||
|
||||
function updateProfileSeen(id) {
|
||||
const result = instance.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id)
|
||||
if (result.changes === 0) console.warn(`[db] updateProfileSeen: no profile id=${id}`)
|
||||
}
|
||||
|
||||
function createMagicToken(token, profileId, expiresAt) {
|
||||
instance.prepare('INSERT INTO magic_tokens (token, profile_id, expires_at) VALUES (?,?,?)').run(
|
||||
token, profileId, expiresAt
|
||||
)
|
||||
}
|
||||
|
||||
const useMagicToken = instance.transaction((token) => {
|
||||
const row = instance
|
||||
.prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0 AND expires_at > ?')
|
||||
.get(token, Date.now())
|
||||
if (!row) return null
|
||||
instance.prepare('UPDATE magic_tokens SET used = 1 WHERE token = ?').run(token)
|
||||
return row.profile_id
|
||||
})
|
||||
|
||||
function createSession(token, profileId) {
|
||||
const now = Date.now()
|
||||
instance.prepare(
|
||||
'INSERT INTO sessions (token, profile_id, created_at, last_seen) VALUES (?,?,?,?)'
|
||||
).run(token, profileId, now, now)
|
||||
}
|
||||
|
||||
function getSession(token) {
|
||||
const row = instance.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
|
||||
if (!row) return null
|
||||
const now = Date.now()
|
||||
instance.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(now, token)
|
||||
return { ...row, last_seen: now }
|
||||
}
|
||||
|
||||
function deleteSession(token) {
|
||||
instance.prepare('DELETE FROM sessions WHERE token = ?').run(token)
|
||||
}
|
||||
|
||||
function getOrCreateProgress(profileId) {
|
||||
const existing = instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
|
||||
if (existing) return existing
|
||||
const now = Date.now()
|
||||
instance.prepare(
|
||||
`INSERT INTO user_progress (profile_id, level, score, streak, best_streak,
|
||||
total_correct, total_attempts, updated_at) VALUES (?,1,0,0,0,0,0,?)`
|
||||
).run(profileId, now)
|
||||
return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
|
||||
}
|
||||
|
||||
function updateLevel(profileId, level) {
|
||||
getOrCreateProgress(profileId)
|
||||
instance.prepare('UPDATE user_progress SET level = ?, updated_at = ? WHERE profile_id = ?').run(
|
||||
level, Date.now(), profileId
|
||||
)
|
||||
}
|
||||
|
||||
function recordAnswer(profileId, letter, correct) {
|
||||
const normalizedLetter = letter.toUpperCase()
|
||||
const progress = getOrCreateProgress(profileId)
|
||||
const now = Date.now()
|
||||
const isFirstToday = !progress.last_session_at ||
|
||||
new Date(progress.last_session_at).toDateString() !== new Date(now).toDateString()
|
||||
|
||||
const base = progress.level * 10
|
||||
const newStreak = correct ? progress.streak + 1 : 0
|
||||
const streakMult = newStreak >= 10 ? 2 : 1
|
||||
const dailyMult = isFirstToday && correct ? 1.25 : 1
|
||||
const points = correct ? Math.floor(base * streakMult * dailyMult) : 0
|
||||
const newBestStreak = Math.max(progress.best_streak, newStreak)
|
||||
|
||||
instance.prepare(
|
||||
`UPDATE user_progress SET
|
||||
score = score + ?,
|
||||
streak = ?,
|
||||
best_streak = ?,
|
||||
total_correct = total_correct + ?,
|
||||
total_attempts = total_attempts + 1,
|
||||
last_session_at = ?,
|
||||
updated_at = ?
|
||||
WHERE profile_id = ?`
|
||||
).run(points, newStreak, newBestStreak, correct ? 1 : 0, now, now, profileId)
|
||||
|
||||
// Update letter stats
|
||||
const existing = instance
|
||||
.prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?')
|
||||
.get(profileId, normalizedLetter)
|
||||
if (existing) {
|
||||
instance.prepare(
|
||||
'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?'
|
||||
).run(correct ? 1 : 0, now, profileId, normalizedLetter)
|
||||
} else {
|
||||
instance.prepare(
|
||||
'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)'
|
||||
).run(profileId, normalizedLetter, correct ? 1 : 0, now)
|
||||
}
|
||||
|
||||
return instance.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
|
||||
}
|
||||
|
||||
function getMnemonics(profileId) {
|
||||
const rows = instance
|
||||
.prepare('SELECT letter, phrase FROM user_mnemonics WHERE profile_id = ?')
|
||||
.all(profileId)
|
||||
return Object.fromEntries(rows.map(r => [r.letter, r.phrase]))
|
||||
}
|
||||
|
||||
function saveMnemonic(profileId, letter, phrase) {
|
||||
const now = Date.now()
|
||||
const upperLetter = letter.toUpperCase()
|
||||
|
||||
// Try insert first — changes=1 means new row (first save), changes=0 means update path
|
||||
const insertResult = instance.prepare(
|
||||
'INSERT OR IGNORE INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?)'
|
||||
).run(profileId, upperLetter, phrase, now)
|
||||
|
||||
if (insertResult.changes === 0) {
|
||||
// Existing mnemonic — update phrase only, no point award
|
||||
instance.prepare('UPDATE user_mnemonics SET phrase = ? WHERE profile_id = ? AND letter = ?')
|
||||
.run(phrase, profileId, upperLetter)
|
||||
} else {
|
||||
// First save — award 50 points
|
||||
getOrCreateProgress(profileId)
|
||||
instance.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?')
|
||||
.run(now, profileId)
|
||||
}
|
||||
}
|
||||
|
||||
function getLetterStats(profileId) {
|
||||
const rows = instance
|
||||
.prepare('SELECT letter, correct, attempts FROM letter_stats WHERE profile_id = ?')
|
||||
.all(profileId)
|
||||
return Object.fromEntries(rows.map(r => [r.letter, { correct: r.correct, attempts: r.attempts }]))
|
||||
}
|
||||
|
||||
function getAdminUsers() {
|
||||
return instance.prepare(`
|
||||
SELECT p.id, p.email, p.display_name, p.created_at, p.last_seen,
|
||||
pr.level, pr.score, pr.streak, pr.best_streak,
|
||||
pr.total_correct, pr.total_attempts
|
||||
FROM profiles p
|
||||
LEFT JOIN user_progress pr ON pr.profile_id = p.id
|
||||
ORDER BY p.created_at DESC
|
||||
`).all()
|
||||
}
|
||||
|
||||
function getAdminStats() {
|
||||
const totalUsers = instance.prepare('SELECT COUNT(*) as c FROM profiles').get().c
|
||||
const avgScore = instance.prepare('SELECT AVG(score) as a FROM user_progress').get().a ?? 0
|
||||
const mostMissed = instance.prepare(`
|
||||
SELECT letter, SUM(attempts - correct) as misses
|
||||
FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5
|
||||
`).all()
|
||||
return { totalUsers, avgScore: Math.round(avgScore), mostMissed }
|
||||
}
|
||||
|
||||
return {
|
||||
_db: instance,
|
||||
createGuestProfile,
|
||||
createProfile,
|
||||
findProfileByEmail,
|
||||
findProfileById,
|
||||
updateProfileSeen,
|
||||
createMagicToken,
|
||||
useMagicToken,
|
||||
createSession,
|
||||
getSession,
|
||||
deleteSession,
|
||||
getOrCreateProgress,
|
||||
updateLevel,
|
||||
recordAnswer,
|
||||
getMnemonics,
|
||||
saveMnemonic,
|
||||
getLetterStats,
|
||||
getAdminUsers,
|
||||
getAdminStats,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initDb }
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict'
|
||||
const nodemailer = require('nodemailer')
|
||||
|
||||
const SMTP_HOST = process.env.SMTP_HOST || ''
|
||||
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '587', 10)
|
||||
const SMTP_USER = process.env.SMTP_USER || ''
|
||||
const SMTP_PASS = process.env.SMTP_PASS || ''
|
||||
const APP_URL = (process.env.APP_URL || 'http://localhost:3001').replace(/\/$/, '')
|
||||
const FROM_ADDR = SMTP_USER
|
||||
? `"MorseQuest" <${SMTP_USER}>`
|
||||
: '"MorseQuest" <noreply@morsequest.keylinkit.net>'
|
||||
|
||||
let transport = null
|
||||
if (SMTP_HOST && SMTP_USER && SMTP_PASS) {
|
||||
transport = nodemailer.createTransport({
|
||||
host: SMTP_HOST,
|
||||
port: SMTP_PORT,
|
||||
secure: SMTP_PORT === 465,
|
||||
auth: { user: SMTP_USER, pass: SMTP_PASS },
|
||||
requireTLS: true, // force STARTTLS before AUTH
|
||||
connectionTimeout: 10_000,
|
||||
greetingTimeout: 10_000,
|
||||
socketTimeout: 15_000,
|
||||
tls: { rejectUnauthorized: false }, // relay cert is for mail.keylinkit.net, not 172.17.0.1
|
||||
})
|
||||
console.log(`[mailer] SMTP configured: ${SMTP_HOST}:${SMTP_PORT}`)
|
||||
} else {
|
||||
console.warn('[mailer] SMTP not configured — magic links will be logged only')
|
||||
}
|
||||
|
||||
async function send(to, subject, html) {
|
||||
if (!transport) {
|
||||
console.log(`[mailer] Would send to ${to}: ${subject}`)
|
||||
return
|
||||
}
|
||||
await transport.sendMail({ from: FROM_ADDR, to, subject, html })
|
||||
}
|
||||
|
||||
async function sendMagicLink(email, token) {
|
||||
const link = `${APP_URL}/api/auth/verify?token=${token}`
|
||||
const subject = 'Your MorseQuest Login Link'
|
||||
const html = `
|
||||
<div style="font-family:'Open Sans',sans-serif;max-width:480px;margin:0 auto;
|
||||
padding:2rem;background:#F5E6D3;border:2px solid #D4AF37">
|
||||
<h1 style="font-family:'Cinzel',serif;color:#3C2415;font-size:1.4rem;margin-bottom:1rem">
|
||||
⚡ MorseQuest Login
|
||||
</h1>
|
||||
<p style="color:#3C2415;line-height:1.6">
|
||||
Click the button below to log in to MorseQuest. This link is valid for 24 hours
|
||||
and can only be used once.
|
||||
</p>
|
||||
<div style="text-align:center;margin:2rem 0">
|
||||
<a href="${link}"
|
||||
style="display:inline-block;padding:0.8rem 2rem;background:#D4AF37;
|
||||
color:#3C2415;text-decoration:none;font-family:'Cinzel',serif;
|
||||
font-size:1rem;font-weight:600;border:2px solid #3C2415">
|
||||
Begin Your Quest ›
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#654321;font-size:0.85rem;line-height:1.5">
|
||||
If you didn't request this link, you can ignore this email safely.
|
||||
</p>
|
||||
</div>`
|
||||
await send(email, subject, html)
|
||||
}
|
||||
|
||||
module.exports = { sendMagicLink }
|
||||
@@ -0,0 +1,251 @@
|
||||
'use strict'
|
||||
const http = require('node:http')
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const crypto = require('node:crypto')
|
||||
const { initDb } = require('./db.js')
|
||||
const { sendMagicLink } = require('./mailer.js')
|
||||
|
||||
const PORT = parseInt(process.env.PORT || '3001', 10)
|
||||
const DB_PATH = process.env.DB_PATH || undefined
|
||||
|
||||
const DIST_DIR = path.join(__dirname, '../client/dist')
|
||||
|
||||
function send(res, status, body) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let data = ''
|
||||
req.on('error', reject)
|
||||
req.on('data', c => {
|
||||
data += c
|
||||
if (data.length > 1024 * 1024) {
|
||||
req.destroy(new Error('body too large'))
|
||||
}
|
||||
})
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(data)) }
|
||||
catch { resolve({}) }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createServer(db) {
|
||||
if (!db) {
|
||||
db = initDb(DB_PATH)
|
||||
}
|
||||
|
||||
function requireAuth(req, res) {
|
||||
const auth = req.headers['authorization'] || ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
|
||||
if (!token) { send(res, 401, { error: 'unauthorized' }); return null }
|
||||
const session = db.getSession(token)
|
||||
if (!session) { send(res, 401, { error: 'unauthorized' }); return null }
|
||||
return session
|
||||
}
|
||||
|
||||
function requireAdmin(req, res) {
|
||||
const pwd = process.env.ADMIN_PASSWORD
|
||||
const auth = req.headers['authorization'] || ''
|
||||
const token = auth.startsWith('Bearer ') ? auth.slice(7) : null
|
||||
if (!pwd || token !== pwd) { send(res, 401, { error: 'unauthorized' }); return false }
|
||||
return true
|
||||
}
|
||||
|
||||
async function handleRequest(req, res) {
|
||||
const url = new URL(req.url, 'http://127.0.0.1')
|
||||
const pathname = url.pathname
|
||||
const method = req.method
|
||||
|
||||
// ── 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') {
|
||||
const body = await readBody(req)
|
||||
const email = body.email
|
||||
if (!email || typeof email !== 'string' || !email.includes('@') || !email.includes('.')) {
|
||||
return send(res, 400, { error: 'email required' })
|
||||
}
|
||||
let profile = db.findProfileByEmail(email)
|
||||
if (!profile) {
|
||||
profile = db.createProfile(email, null)
|
||||
}
|
||||
const token = crypto.randomBytes(32).toString('hex')
|
||||
const expiresAt = Date.now() + 24 * 60 * 60 * 1000
|
||||
db.createMagicToken(token, profile.id, expiresAt)
|
||||
await sendMagicLink(email, token).catch(err => {
|
||||
// Log SMTP errors but don't fail the request — token is in DB, link logged
|
||||
console.error('[server] email send failed:', err.message ?? err)
|
||||
})
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/verify' && method === 'GET') {
|
||||
const token = url.searchParams.get('token')
|
||||
if (!token) { return send(res, 400, { error: 'token required' }) }
|
||||
const profileId = db.useMagicToken(token)
|
||||
if (!profileId) { return send(res, 400, { error: 'invalid or expired token' }) }
|
||||
const sessionToken = crypto.randomBytes(32).toString('hex')
|
||||
db.createSession(sessionToken, profileId)
|
||||
res.writeHead(302, { Location: `/?session=${sessionToken}` })
|
||||
return res.end()
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/me' && method === 'GET') {
|
||||
const session = requireAuth(req, res)
|
||||
if (!session) return
|
||||
const profile = db.findProfileById(session.profile_id)
|
||||
if (!profile) { return send(res, 404, { error: 'not found' }) }
|
||||
return send(res, 200, {
|
||||
id: profile.id,
|
||||
email: profile.email,
|
||||
display_name: profile.display_name,
|
||||
is_guest: profile.is_guest === 1,
|
||||
})
|
||||
}
|
||||
|
||||
if (pathname === '/api/auth/session' && method === 'DELETE') {
|
||||
const session = requireAuth(req, res)
|
||||
if (!session) return
|
||||
db.deleteSession(session.token)
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
// ── Progress routes ───────────────────────────────────────────────────────
|
||||
|
||||
if (pathname === '/api/progress' && method === 'GET') {
|
||||
const session = requireAuth(req, res)
|
||||
if (!session) return
|
||||
const progress = db.getOrCreateProgress(session.profile_id)
|
||||
return send(res, 200, progress)
|
||||
}
|
||||
|
||||
if (pathname === '/api/progress/answer' && method === 'POST') {
|
||||
const session = requireAuth(req, res)
|
||||
if (!session) return
|
||||
const body = await readBody(req)
|
||||
const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : ''
|
||||
if (!/^[A-Z]$/.test(letter)) {
|
||||
return send(res, 400, { error: 'letter must be a single letter A-Z' })
|
||||
}
|
||||
if (typeof body.correct !== 'boolean') {
|
||||
return send(res, 400, { error: 'correct required' })
|
||||
}
|
||||
const progress = db.recordAnswer(session.profile_id, letter, body.correct)
|
||||
return send(res, 200, progress)
|
||||
}
|
||||
|
||||
if (pathname === '/api/mnemonics' && method === 'GET') {
|
||||
const session = requireAuth(req, res)
|
||||
if (!session) return
|
||||
const mnemonics = db.getMnemonics(session.profile_id)
|
||||
return send(res, 200, mnemonics)
|
||||
}
|
||||
|
||||
if (pathname === '/api/mnemonics' && method === 'POST') {
|
||||
const session = requireAuth(req, res)
|
||||
if (!session) return
|
||||
const body = await readBody(req)
|
||||
const letter = typeof body.letter === 'string' ? body.letter.toUpperCase() : ''
|
||||
if (!/^[A-Z]$/.test(letter)) {
|
||||
return send(res, 400, { error: 'letter must be a single letter A-Z' })
|
||||
}
|
||||
if (!body.phrase || typeof body.phrase !== 'string' || body.phrase.length > 500) {
|
||||
return send(res, 400, { error: 'phrase required (max 500 chars)' })
|
||||
}
|
||||
db.saveMnemonic(session.profile_id, letter, body.phrase)
|
||||
return send(res, 200, { ok: true })
|
||||
}
|
||||
|
||||
// ── Admin routes ──────────────────────────────────────────────────────────
|
||||
|
||||
if (pathname === '/api/admin/users' && method === 'GET') {
|
||||
if (!requireAdmin(req, res)) return
|
||||
const users = db.getAdminUsers()
|
||||
return send(res, 200, users)
|
||||
}
|
||||
|
||||
if (pathname === '/api/admin/stats' && method === 'GET') {
|
||||
if (!requireAdmin(req, res)) return
|
||||
const stats = db.getAdminStats()
|
||||
return send(res, 200, stats)
|
||||
}
|
||||
|
||||
// ── Unknown /api/* ────────────────────────────────────────────────────────
|
||||
|
||||
if (pathname.startsWith('/api/')) {
|
||||
return send(res, 404, { error: 'not found' })
|
||||
}
|
||||
|
||||
// ── Static files (SPA) ───────────────────────────────────────────────────
|
||||
|
||||
// Prevent path traversal — ensure resolved path stays within DIST_DIR
|
||||
const relative = pathname.replace(/^\/+/, '')
|
||||
const filePath = path.join(DIST_DIR, relative)
|
||||
if (!filePath.startsWith(DIST_DIR + path.sep) && filePath !== DIST_DIR) {
|
||||
return send(res, 400, { error: 'bad request' })
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
|
||||
const ext = path.extname(filePath)
|
||||
const mimeTypes = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'application/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
}
|
||||
const contentType = mimeTypes[ext] || 'application/octet-stream'
|
||||
res.writeHead(200, { 'Content-Type': contentType })
|
||||
return fs.createReadStream(filePath).pipe(res)
|
||||
}
|
||||
|
||||
// SPA fallback — serve index.html
|
||||
const indexPath = path.join(DIST_DIR, 'index.html')
|
||||
if (fs.existsSync(indexPath)) {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
return fs.createReadStream(indexPath).pipe(res)
|
||||
}
|
||||
|
||||
// No dist yet
|
||||
return send(res, 404, { error: 'not found' })
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
handleRequest(req, res).catch(err => {
|
||||
console.error('[server] unhandled:', err)
|
||||
if (!res.headersSent) { res.writeHead(500); res.end() }
|
||||
})
|
||||
})
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const db = initDb(DB_PATH)
|
||||
const server = createServer(db)
|
||||
server.listen(PORT, () => console.log(`[server] listening on :${PORT}`))
|
||||
}
|
||||
|
||||
module.exports = { createServer }
|
||||
@@ -0,0 +1,91 @@
|
||||
'use strict'
|
||||
const { test, describe, before, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const http = require('node:http')
|
||||
const { initDb } = require('../db.js')
|
||||
const { createServer } = require('../server.js')
|
||||
|
||||
function request(server, method, path, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address()
|
||||
const opts = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
}
|
||||
const req = http.request(opts, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => {
|
||||
let json = null
|
||||
try { json = JSON.parse(data) } catch {}
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
if (body) req.write(JSON.stringify(body))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
let db, server
|
||||
const ADMIN_PWD = 'testadminpwd'
|
||||
|
||||
before(() => {
|
||||
process.env.ADMIN_PASSWORD = ADMIN_PWD
|
||||
db = initDb(':memory:')
|
||||
server = createServer(db)
|
||||
const p1 = db.createProfile('adm1@example.com', 'Admin1')
|
||||
const p2 = db.createProfile('adm2@example.com', 'Admin2')
|
||||
db.recordAnswer(p1.id, 'A', true)
|
||||
db.recordAnswer(p2.id, 'B', false)
|
||||
return new Promise(resolve => server.listen(0, resolve))
|
||||
})
|
||||
|
||||
after(() => {
|
||||
delete process.env.ADMIN_PASSWORD
|
||||
db._db.close()
|
||||
return new Promise(resolve => server.close(resolve))
|
||||
})
|
||||
|
||||
describe('GET /api/admin/users', () => {
|
||||
test('returns user list with valid admin password', async () => {
|
||||
const r = await request(server, 'GET', '/api/admin/users', null, {
|
||||
Authorization: `Bearer ${ADMIN_PWD}`,
|
||||
})
|
||||
assert.equal(r.status, 200)
|
||||
assert.ok(Array.isArray(r.body))
|
||||
const emails = r.body.map(u => u.email)
|
||||
assert.ok(emails.includes('adm1@example.com'))
|
||||
})
|
||||
|
||||
test('returns 401 without admin password', async () => {
|
||||
const r = await request(server, 'GET', '/api/admin/users', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
|
||||
test('returns 401 with wrong admin password', async () => {
|
||||
const r = await request(server, 'GET', '/api/admin/users', null, {
|
||||
Authorization: 'Bearer wrongpwd',
|
||||
})
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/admin/stats', () => {
|
||||
test('returns stats with valid admin password', async () => {
|
||||
const r = await request(server, 'GET', '/api/admin/stats', null, {
|
||||
Authorization: `Bearer ${ADMIN_PWD}`,
|
||||
})
|
||||
assert.equal(r.status, 200)
|
||||
assert.ok(typeof r.body.totalUsers === 'number')
|
||||
assert.ok(Array.isArray(r.body.mostMissed))
|
||||
})
|
||||
|
||||
test('returns 401 without admin password', async () => {
|
||||
const r = await request(server, 'GET', '/api/admin/stats', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,175 @@
|
||||
'use strict'
|
||||
const { test, describe, before, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const http = require('node:http')
|
||||
const { initDb } = require('../db.js')
|
||||
const { createServer } = require('../server.js')
|
||||
|
||||
function request(server, method, path, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address()
|
||||
const opts = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
}
|
||||
const req = http.request(opts, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => {
|
||||
let json = null
|
||||
try { json = JSON.parse(data) } catch {}
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
if (body) req.write(JSON.stringify(body))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
let db, server
|
||||
|
||||
before(() => {
|
||||
db = initDb(':memory:')
|
||||
server = createServer(db)
|
||||
return new Promise(resolve => server.listen(0, resolve))
|
||||
})
|
||||
|
||||
after(() => {
|
||||
db._db.close()
|
||||
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', () => {
|
||||
test('returns 200 ok for valid email', async () => {
|
||||
const r = await request(server, 'POST', '/api/auth/request', { email: 'test@example.com' })
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.ok, true)
|
||||
})
|
||||
|
||||
test('creates profile if new email', async () => {
|
||||
await request(server, 'POST', '/api/auth/request', { email: 'new@example.com' })
|
||||
const p = db.findProfileByEmail('new@example.com')
|
||||
assert.ok(p)
|
||||
assert.equal(p.email, 'new@example.com')
|
||||
})
|
||||
|
||||
test('returns 400 if email missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/auth/request', {})
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/auth/verify', () => {
|
||||
let profileId
|
||||
|
||||
before(() => {
|
||||
const p = db.createProfile('verify@example.com', 'Verify')
|
||||
profileId = p.id
|
||||
db.createMagicToken('testtoken123', profileId, Date.now() + 86400000)
|
||||
})
|
||||
|
||||
test('redirects to /?session=... on valid token', async () => {
|
||||
const r = await request(server, 'GET', '/api/auth/verify?token=testtoken123', null, {})
|
||||
assert.equal(r.status, 302)
|
||||
assert.ok(r.headers.location?.startsWith('/?session='))
|
||||
})
|
||||
|
||||
test('returns 400 for invalid token', async () => {
|
||||
const r = await request(server, 'GET', '/api/auth/verify?token=badtoken', null)
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 400 for already-used token', async () => {
|
||||
// testtoken123 was already used in the first verify test
|
||||
const r = await request(server, 'GET', '/api/auth/verify?token=testtoken123', null)
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/auth/me', () => {
|
||||
let sessionToken
|
||||
|
||||
before(() => {
|
||||
const p = db.createProfile('me@example.com', 'Me')
|
||||
sessionToken = 'sessmetoken'
|
||||
db.createSession(sessionToken, p.id)
|
||||
})
|
||||
|
||||
test('returns profile for valid session', async () => {
|
||||
const r = await request(server, 'GET', '/api/auth/me', null, {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
})
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.email, 'me@example.com')
|
||||
})
|
||||
|
||||
test('returns 401 without auth header', async () => {
|
||||
const r = await request(server, 'GET', '/api/auth/me', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /api/auth/session', () => {
|
||||
let sessionToken
|
||||
|
||||
before(() => {
|
||||
const p = db.createProfile('logout@example.com', 'Logout')
|
||||
sessionToken = 'sesslogouttoken'
|
||||
db.createSession(sessionToken, p.id)
|
||||
})
|
||||
|
||||
test('returns 200 and session is gone', async () => {
|
||||
const r = await request(server, 'DELETE', '/api/auth/session', null, {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
})
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(db.getSession(sessionToken), null)
|
||||
})
|
||||
|
||||
test('returns 401 without auth header', async () => {
|
||||
const r = await request(server, 'DELETE', '/api/auth/session', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
|
||||
test('returns 401 when using deleted session token', async () => {
|
||||
const r = await request(server, 'GET', '/api/auth/me', null, {
|
||||
Authorization: `Bearer ${sessionToken}`,
|
||||
})
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,216 @@
|
||||
'use strict'
|
||||
const { test, describe, before, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { initDb } = require('../db.js')
|
||||
|
||||
let db
|
||||
|
||||
before(() => {
|
||||
db = initDb(':memory:')
|
||||
})
|
||||
|
||||
after(() => {
|
||||
db._db.close()
|
||||
})
|
||||
|
||||
describe('profiles', () => {
|
||||
test('createProfile creates a profile', () => {
|
||||
const p = db.createProfile('test@example.com', 'Tester')
|
||||
assert.equal(p.email, 'test@example.com')
|
||||
assert.equal(p.display_name, 'Tester')
|
||||
assert.ok(p.id)
|
||||
})
|
||||
|
||||
test('findProfileByEmail returns the profile', () => {
|
||||
const p = db.findProfileByEmail('test@example.com')
|
||||
assert.equal(p.email, 'test@example.com')
|
||||
})
|
||||
|
||||
test('findProfileByEmail returns null for unknown email', () => {
|
||||
assert.equal(db.findProfileByEmail('nobody@example.com'), null)
|
||||
})
|
||||
|
||||
test('findProfileById returns profile by id', () => {
|
||||
const created = db.createProfile('byid@example.com', 'ById')
|
||||
const found = db.findProfileById(created.id)
|
||||
assert.equal(found.id, created.id)
|
||||
assert.equal(found.email, 'byid@example.com')
|
||||
})
|
||||
|
||||
test('findProfileById returns null for unknown id', () => {
|
||||
assert.equal(db.findProfileById(999999), null)
|
||||
})
|
||||
|
||||
test('updateProfileSeen updates last_seen timestamp', () => {
|
||||
const created = db.createProfile('seen@example.com', 'Seen')
|
||||
const callTime = Date.now()
|
||||
db.updateProfileSeen(created.id)
|
||||
const updated = db.findProfileById(created.id)
|
||||
assert.ok(updated.last_seen >= callTime)
|
||||
})
|
||||
})
|
||||
|
||||
describe('magic tokens', () => {
|
||||
let profileId
|
||||
|
||||
before(() => {
|
||||
profileId = db.createProfile('magic@example.com', 'Magic').id
|
||||
})
|
||||
|
||||
test('createMagicToken and useMagicToken returns profileId', () => {
|
||||
const expires = Date.now() + 86400000
|
||||
db.createMagicToken('tok123', profileId, expires)
|
||||
const result = db.useMagicToken('tok123')
|
||||
assert.equal(result, profileId)
|
||||
})
|
||||
|
||||
test('useMagicToken returns null for used token', () => {
|
||||
assert.equal(db.useMagicToken('tok123'), null)
|
||||
})
|
||||
|
||||
test('useMagicToken returns null for expired token', () => {
|
||||
db.createMagicToken('expiredtok', profileId, Date.now() - 1000)
|
||||
assert.equal(db.useMagicToken('expiredtok'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessions', () => {
|
||||
let profileId
|
||||
|
||||
before(() => {
|
||||
profileId = db.createProfile('session@example.com', 'Session').id
|
||||
})
|
||||
|
||||
test('createSession and getSession returns profile_id', () => {
|
||||
db.createSession('sess1', profileId)
|
||||
const s = db.getSession('sess1')
|
||||
assert.equal(s.profile_id, profileId)
|
||||
})
|
||||
|
||||
test('getSession returns null for unknown token', () => {
|
||||
assert.equal(db.getSession('unknown'), null)
|
||||
})
|
||||
|
||||
test('deleteSession removes session', () => {
|
||||
db.deleteSession('sess1')
|
||||
assert.equal(db.getSession('sess1'), null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('progress', () => {
|
||||
let profileId
|
||||
|
||||
before(() => {
|
||||
profileId = db.createProfile('progress@example.com', 'Progress').id
|
||||
})
|
||||
|
||||
test('getOrCreateProgress returns default progress', () => {
|
||||
const p = db.getOrCreateProgress(profileId)
|
||||
assert.equal(p.level, 1)
|
||||
assert.equal(p.score, 0)
|
||||
assert.equal(p.streak, 0)
|
||||
})
|
||||
|
||||
test('recordAnswer increments score and streak on correct', () => {
|
||||
db.recordAnswer(profileId, 'A', true)
|
||||
const p = db.getOrCreateProgress(profileId)
|
||||
assert.ok(p.score > 0)
|
||||
assert.equal(p.streak, 1)
|
||||
assert.equal(p.total_correct, 1)
|
||||
assert.equal(p.total_attempts, 1)
|
||||
})
|
||||
|
||||
test('recordAnswer resets streak on incorrect', () => {
|
||||
db.recordAnswer(profileId, 'A', false)
|
||||
const p = db.getOrCreateProgress(profileId)
|
||||
assert.equal(p.streak, 0)
|
||||
assert.equal(p.total_attempts, 2)
|
||||
})
|
||||
|
||||
test('updateLevel changes the level', () => {
|
||||
db.updateLevel(profileId, 5)
|
||||
const p = db.getOrCreateProgress(profileId)
|
||||
assert.equal(p.level, 5)
|
||||
})
|
||||
|
||||
test('updateLevel works even when no progress row exists yet', () => {
|
||||
const newProfile = db.createProfile('updatelevel@example.com', 'UpdateLevel')
|
||||
db.updateLevel(newProfile.id, 3)
|
||||
const p = db.getOrCreateProgress(newProfile.id)
|
||||
assert.equal(p.level, 3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('mnemonics', () => {
|
||||
let profileId
|
||||
|
||||
before(() => {
|
||||
profileId = db.createProfile('mnemonic@example.com', 'Mnemonic').id
|
||||
})
|
||||
|
||||
test('saveMnemonic and getMnemonics returns map', () => {
|
||||
db.saveMnemonic(profileId, 'A', 'go WALKING')
|
||||
db.saveMnemonic(profileId, 'E', 'go')
|
||||
const m = db.getMnemonics(profileId)
|
||||
assert.equal(m.A, 'go WALKING')
|
||||
assert.equal(m.E, 'go')
|
||||
})
|
||||
|
||||
test('saveMnemonic awards 50 pts on first save, not on update', () => {
|
||||
const p1 = db.getOrCreateProgress(profileId)
|
||||
db.saveMnemonic(profileId, 'T', 'dash') // first save → +50 pts
|
||||
const p2 = db.getOrCreateProgress(profileId)
|
||||
assert.equal(p2.score, p1.score + 50)
|
||||
|
||||
db.saveMnemonic(profileId, 'T', 'updated dash') // update → no extra pts
|
||||
const p3 = db.getOrCreateProgress(profileId)
|
||||
assert.equal(p3.score, p2.score)
|
||||
})
|
||||
})
|
||||
|
||||
describe('letter stats', () => {
|
||||
let profileId
|
||||
|
||||
before(() => {
|
||||
profileId = db.createProfile('stats@example.com', 'Stats').id
|
||||
})
|
||||
|
||||
test('recordAnswer tracks letter stats', () => {
|
||||
db.recordAnswer(profileId, 'S', true)
|
||||
db.recordAnswer(profileId, 'S', false)
|
||||
const stats = db.getLetterStats(profileId)
|
||||
assert.equal(stats.S.correct, 1)
|
||||
assert.equal(stats.S.attempts, 2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin', () => {
|
||||
before(() => {
|
||||
const p1 = db.createProfile('admin1@example.com', 'Admin1')
|
||||
const p2 = db.createProfile('admin2@example.com', 'Admin2')
|
||||
db.recordAnswer(p1.id, 'A', true)
|
||||
db.recordAnswer(p2.id, 'B', false)
|
||||
db.recordAnswer(p2.id, 'B', false)
|
||||
})
|
||||
|
||||
test('getAdminUsers returns users list', () => {
|
||||
const users = db.getAdminUsers()
|
||||
assert.ok(Array.isArray(users))
|
||||
assert.ok(users.length >= 2)
|
||||
// Each user should have email and id
|
||||
const emails = users.map(u => u.email)
|
||||
assert.ok(emails.includes('admin1@example.com'))
|
||||
assert.ok(emails.includes('admin2@example.com'))
|
||||
})
|
||||
|
||||
test('getAdminStats returns totalUsers, avgScore, mostMissed', () => {
|
||||
const stats = db.getAdminStats()
|
||||
assert.ok(typeof stats.totalUsers === 'number')
|
||||
assert.ok(stats.totalUsers >= 2)
|
||||
assert.ok(typeof stats.avgScore === 'number')
|
||||
assert.ok(Array.isArray(stats.mostMissed))
|
||||
// B was missed twice — should appear in mostMissed
|
||||
const missedLetters = stats.mostMissed.map(m => m.letter)
|
||||
assert.ok(missedLetters.includes('B'))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
'use strict'
|
||||
const { test, describe, before, after } = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const http = require('node:http')
|
||||
const { initDb } = require('../db.js')
|
||||
const { createServer } = require('../server.js')
|
||||
|
||||
function request(server, method, path, body, headers = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const addr = server.address()
|
||||
const opts = {
|
||||
hostname: '127.0.0.1',
|
||||
port: addr.port,
|
||||
path,
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
}
|
||||
const req = http.request(opts, res => {
|
||||
let data = ''
|
||||
res.on('data', c => { data += c })
|
||||
res.on('end', () => {
|
||||
let json = null
|
||||
try { json = JSON.parse(data) } catch {}
|
||||
resolve({ status: res.statusCode, headers: res.headers, body: json, raw: data })
|
||||
})
|
||||
})
|
||||
req.on('error', reject)
|
||||
if (body) req.write(JSON.stringify(body))
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
let db, server, sessionToken, profileId
|
||||
|
||||
before(() => {
|
||||
db = initDb(':memory:')
|
||||
server = createServer(db)
|
||||
const p = db.createProfile('prog@example.com', 'Progress')
|
||||
profileId = p.id
|
||||
sessionToken = 'progtoken'
|
||||
db.createSession(sessionToken, profileId)
|
||||
return new Promise(resolve => server.listen(0, resolve))
|
||||
})
|
||||
|
||||
after(() => {
|
||||
db._db.close()
|
||||
return new Promise(resolve => server.close(resolve))
|
||||
})
|
||||
|
||||
const authHeader = () => ({ Authorization: `Bearer ${sessionToken}` })
|
||||
|
||||
describe('GET /api/progress', () => {
|
||||
test('returns progress for authed user', async () => {
|
||||
const r = await request(server, 'GET', '/api/progress', null, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.level, 1)
|
||||
assert.equal(r.body.score, 0)
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'GET', '/api/progress', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/progress/answer', () => {
|
||||
test('records correct answer and returns updated progress', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: true }, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.ok(r.body.score > 0)
|
||||
assert.equal(r.body.streak, 1)
|
||||
})
|
||||
|
||||
test('records incorrect answer (resets streak)', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: false }, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.streak, 0)
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: true })
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
|
||||
test('returns 400 if letter missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ correct: true }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 400 if correct is missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A' }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 400 if correct is non-boolean', async () => {
|
||||
const r = await request(server, 'POST', '/api/progress/answer',
|
||||
{ letter: 'A', correct: 'true' }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /api/mnemonics', () => {
|
||||
before(() => {
|
||||
db.saveMnemonic(profileId, 'A', 'a phrase')
|
||||
})
|
||||
|
||||
test('returns mnemonic map', async () => {
|
||||
const r = await request(server, 'GET', '/api/mnemonics', null, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.A, 'a phrase')
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'GET', '/api/mnemonics', null)
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /api/mnemonics', () => {
|
||||
test('saves mnemonic and returns ok', async () => {
|
||||
const r = await request(server, 'POST', '/api/mnemonics',
|
||||
{ letter: 'E', phrase: 'yes' }, authHeader())
|
||||
assert.equal(r.status, 200)
|
||||
assert.equal(r.body.ok, true)
|
||||
const m = db.getMnemonics(profileId)
|
||||
assert.equal(m.E, 'yes')
|
||||
})
|
||||
|
||||
test('returns 400 if letter or phrase missing', async () => {
|
||||
const r = await request(server, 'POST', '/api/mnemonics',
|
||||
{ letter: 'T' }, authHeader())
|
||||
assert.equal(r.status, 400)
|
||||
})
|
||||
|
||||
test('returns 401 without auth', async () => {
|
||||
const r = await request(server, 'POST', '/api/mnemonics',
|
||||
{ letter: 'E', phrase: 'yes' })
|
||||
assert.equal(r.status, 401)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false
|
||||
},
|
||||
"include": ["client/src"]
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
root: 'client',
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: '../client/dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['src/test-setup.ts'],
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user