feat: initialize database and authentication routes in backend

This commit is contained in:
LaurensBartelds committed 2026-04-21 22:51:22 +02:00
1 parent be69db04a5
commit eb013d4978
22 files changed
+1808 -8

No files matched your search

+200 -7
View File
@@ -2,20 +2,71 @@ import { useState, useEffect, useCallback } from 'react'
import type { AppScreen, Exam, ExamState, HistoryEntry, Tweaks } from './types'
import { gradeQuestion } from './utils/grading'
import { loadState, saveState, clearState, loadHistory, appendHistory, clearHistory } from './utils/storage'
import { apiFetch, isAuthenticated } from './utils/api'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import Landing from './components/Landing'
import ExamRunner from './components/ExamRunner'
import Results from './components/Results'
import TweaksPanel from './components/TweaksPanel'
import AuthModal from './components/AuthModal'
import EmailVerify from './components/EmailVerify'
import ResetPassword from './components/ResetPassword'
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
export default function App() {
function AppInner() {
const { user, loading: authLoading, logout } = useAuth()
const [screen, setScreen] = useState<AppScreen>('landing')
const [exam, setExam] = useState<Exam | null>(null)
const [examState, setExamState] = useState<ExamState | null>(null)
const [history, setHistory] = useState<HistoryEntry[]>(() => loadHistory())
const [tweaks, setTweaks] = useState<Tweaks>(DEFAULT_TWEAKS)
const [tweaksOpen, setTweaksOpen] = useState(false)
const [authModalOpen, setAuthModalOpen] = useState(false)
const [userMenuOpen, setUserMenuOpen] = useState(false)
// Check URL for verify/reset-password routes on mount
useEffect(() => {
const path = window.location.pathname
const search = window.location.search
if (path === '/verify' || search.includes('token=') && path === '/verify') {
setScreen('verify')
} else if (path === '/reset-password') {
setScreen('reset-password')
}
}, [])
// Load history from API when authenticated
useEffect(() => {
if (!authLoading && user && isAuthenticated()) {
apiFetch<{ results: Array<{
id: number; title: string; score_points: number; total_points: number;
correct_count: number; graded_count: number; passing_score: number;
elapsed_ms: number; completed_at: number; exam_id: number | null;
}> }>('/api/results')
.then((data) => {
const apiHistory: HistoryEntry[] = data.results.map((r) => ({
id: String(r.id),
title: r.title,
completedAt: r.completed_at * 1000,
elapsedMs: r.elapsed_ms,
gradedCount: r.graded_count,
correctCount: r.correct_count,
totalPoints: r.total_points,
scorePoints: r.score_points,
passingScore: r.passing_score,
exam: { title: r.title, questions: [] } as unknown as Exam,
state: { currentIdx: 0, answers: {}, flags: {}, startedAt: 0, feedbackMode: 'end' as const } as ExamState,
_remoteId: r.id,
}))
setHistory(apiHistory)
})
.catch(() => {
// Fall back to localStorage
setHistory(loadHistory())
})
}
}, [user, authLoading])
useEffect(() => {
document.documentElement.dataset['theme'] = tweaks.theme
@@ -60,24 +111,122 @@ export default function App() {
exam,
state: { ...examState, completedAt },
}
appendHistory(entry)
setHistory(loadHistory())
// Save to API if authenticated
if (user && isAuthenticated()) {
void apiFetch('/api/results', {
method: 'POST',
body: JSON.stringify({
title: entry.title,
scorePoints: entry.scorePoints,
totalPoints: entry.totalPoints,
correctCount: entry.correctCount,
gradedCount: entry.gradedCount,
passingScore: entry.passingScore,
elapsedMs: entry.elapsedMs,
state: entry.state,
exam: entry.exam,
}),
}).catch(() => {
// Fallback: save locally
appendHistory(entry)
})
} else {
appendHistory(entry)
}
// Always update local state for immediate display
setHistory((prev) => [entry, ...prev])
clearState()
setExamState((s) => s ? { ...s, completedAt } : s)
setScreen('results')
}, [exam, examState])
}, [exam, examState, user])
const exit = () => { setScreen('landing'); setExam(null); setExamState(null) }
const restart = () => { if (exam) loadExam(exam) }
const reviewAttempt = (entry: HistoryEntry) => { setExam(entry.exam); setExamState(entry.state); setScreen('review') }
const clearAllHistory = () => { if (confirm('Clear all past attempts?')) { clearHistory(); setHistory([]) } }
const reviewAttempt = (entry: HistoryEntry) => {
// If this is a remote result, fetch full data
const remoteId = (entry as HistoryEntry & { _remoteId?: number })._remoteId
if (remoteId && user && isAuthenticated()) {
void apiFetch<{
exam: Exam; state: ExamState; title: string; scorePoints: number; totalPoints: number;
correctCount: number; gradedCount: number; passingScore: number; elapsedMs: number; completedAt: number;
}>(`/api/results/${remoteId}`)
.then((data) => {
setExam(data.exam)
setExamState(data.state)
setScreen('review')
})
.catch(() => {
// Fallback to local data
setExam(entry.exam); setExamState(entry.state); setScreen('review')
})
} else {
setExam(entry.exam); setExamState(entry.state); setScreen('review')
}
}
const clearAllHistory = () => {
if (confirm('Clear all past attempts?')) {
clearHistory()
setHistory([])
}
}
const handleVerifyDone = () => {
window.history.replaceState({}, '', '/')
setScreen('landing')
setAuthModalOpen(true)
}
const handleResetDone = () => {
window.history.replaceState({}, '', '/')
setScreen('landing')
setAuthModalOpen(true)
}
if (screen === 'verify') {
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<EmailVerify onDone={handleVerifyDone} />
</div>
)
}
if (screen === 'reset-password') {
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span className="brand-name">BrainWave</span>
</div>
</header>
<ResetPassword onDone={handleResetDone} />
</div>
)
}
return (
<div className="shell">
<header className="topbar">
<div className="brand">
<span className="brand-mark" />
<span>BrainWave</span>
<button
className="brand-name"
onClick={exit}
aria-label="Home"
style={{ background: 'none', border: 'none', padding: 0, margin: 0, cursor: 'pointer', font: 'inherit', color: 'inherit' }}
>
BrainWave
</button>
<span className="faint mono" style={{ fontSize: 11, marginLeft: 4 }}>/ practice</span>
</div>
<div className="topbar-meta">
@@ -88,6 +237,36 @@ export default function App() {
</>
)}
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>
{/* Auth buttons */}
{!authLoading && !user && (
<button className="btn btn-primary btn-sm" onClick={() => setAuthModalOpen(true)}>
Sign In
</button>
)}
{!authLoading && user && (
<div className="user-menu-wrap">
<button
className="user-avatar"
onClick={() => setUserMenuOpen((o) => !o)}
aria-label="User menu"
>
{user.displayName.charAt(0).toUpperCase()}
</button>
{userMenuOpen && (
<div className="user-menu">
<div className="user-menu-header">
<div className="user-menu-name">{user.displayName}</div>
<div className="user-menu-email">{user.email}</div>
</div>
<div className="user-menu-divider" />
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
Sign Out
</button>
</div>
)}
</div>
)}
</div>
</header>
@@ -101,6 +280,20 @@ export default function App() {
<Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={screen === 'review'} />
)}
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
{authModalOpen && (
<AuthModal
onClose={() => setAuthModalOpen(false)}
onForgotPassword={() => setScreen('reset-password')}
/>
)}
</div>
)
}
export default function App() {
return (
<AuthProvider>
<AppInner />
</AuthProvider>
)
}