Public Access
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
819 lines
24 KiB
Markdown
819 lines
24 KiB
Markdown
# React Router URL Routing Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Replace the custom screen-state machine in App.tsx with React Router v7 so the URL reflects the active exam and question number (e.g. `/exam/my-exam/q3`).
|
|
|
|
**Architecture:** Install `react-router-dom`, wrap the app in `BrowserRouter`, replace the `screen` useState with `<Routes>` / `<Route>`, and replace all `setScreen()` calls with `useNavigate()`. A new `ExamRoute` component handles deep-link restoration from localStorage and keeps `currentIdx` in sync with the `:n` URL param (enabling browser back/forward).
|
|
|
|
**Tech Stack:** React 19, react-router-dom v7, Vite, TypeScript, Vitest
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
| Action | File |
|
|
| ------ | ---- |
|
|
| Create | `frontend/src/utils/slug.ts` |
|
|
| Create | `frontend/src/utils/slug.test.ts` |
|
|
| Create | `frontend/src/components/ExamRoute.tsx` |
|
|
| Modify | `frontend/package.json` |
|
|
| Modify | `frontend/vite.config.ts` |
|
|
| Modify | `frontend/src/main.tsx` |
|
|
| Modify | `frontend/src/components/ExamRunner.tsx` |
|
|
| Modify | `frontend/src/App.tsx` |
|
|
| Modify | `frontend/src/types.ts` |
|
|
|
|
---
|
|
|
|
## Task 1: Install react-router-dom
|
|
|
|
**Files:**
|
|
- Modify: `frontend/package.json`
|
|
|
|
- [ ] **Step 1: Install the package**
|
|
|
|
Run from the `frontend/` directory:
|
|
|
|
```bash
|
|
npm install react-router-dom
|
|
```
|
|
|
|
Expected: `react-router-dom` appears under `"dependencies"` in `package.json`. No type errors.
|
|
|
|
- [ ] **Step 2: Verify TypeScript types are available**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
npm run build 2>&1 | head -5
|
|
```
|
|
|
|
Expected: build succeeds (or fails only on pre-existing errors — not on missing `react-router-dom` types).
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/package.json frontend/package-lock.json
|
|
git commit -m "feat: install react-router-dom"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Add slugify utility (TDD)
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/utils/slug.test.ts`
|
|
- Create: `frontend/src/utils/slug.ts`
|
|
|
|
- [ ] **Step 1: Write the failing tests**
|
|
|
|
Create `frontend/src/utils/slug.test.ts`:
|
|
|
|
```typescript
|
|
import { describe, it, expect } from 'vitest'
|
|
import { slugify } from './slug'
|
|
|
|
describe('slugify', () => {
|
|
it('lowercases', () => {
|
|
expect(slugify('My Exam')).toBe('my-exam')
|
|
})
|
|
it('replaces spaces with hyphens', () => {
|
|
expect(slugify('hello world')).toBe('hello-world')
|
|
})
|
|
it('replaces special chars with hyphens', () => {
|
|
expect(slugify('Exam: Advanced!')).toBe('exam-advanced')
|
|
})
|
|
it('collapses consecutive hyphens', () => {
|
|
expect(slugify('Hello World')).toBe('hello-world')
|
|
})
|
|
it('trims leading and trailing hyphens', () => {
|
|
expect(slugify('!hello!')).toBe('hello')
|
|
})
|
|
it('preserves numbers', () => {
|
|
expect(slugify('Exam 2024')).toBe('exam-2024')
|
|
})
|
|
it('handles empty string', () => {
|
|
expect(slugify('')).toBe('')
|
|
})
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests to confirm they fail**
|
|
|
|
```bash
|
|
cd frontend && npm run test:run -- src/utils/slug.test.ts
|
|
```
|
|
|
|
Expected: FAIL — `Cannot find module './slug'`
|
|
|
|
- [ ] **Step 3: Implement slugify**
|
|
|
|
Create `frontend/src/utils/slug.ts`:
|
|
|
|
```typescript
|
|
export function slugify(title: string): string {
|
|
return title
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, '-')
|
|
.replace(/-+/g, '-')
|
|
.replace(/^-|-$/g, '')
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Run tests to confirm they pass**
|
|
|
|
```bash
|
|
cd frontend && npm run test:run -- src/utils/slug.test.ts
|
|
```
|
|
|
|
Expected: 7 passing tests.
|
|
|
|
- [ ] **Step 5: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/utils/slug.ts frontend/src/utils/slug.test.ts
|
|
git commit -m "feat: add slugify utility"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Update Vite config and wrap app in BrowserRouter
|
|
|
|
**Files:**
|
|
- Modify: `frontend/vite.config.ts`
|
|
- Modify: `frontend/src/main.tsx`
|
|
|
|
- [ ] **Step 1: Add historyApiFallback to Vite config**
|
|
|
|
In `frontend/vite.config.ts`, add `historyApiFallback: true` to the `server` block:
|
|
|
|
```typescript
|
|
import { defineConfig } from 'vitest/config'
|
|
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
|
|
import babel from '@rolldown/plugin-babel'
|
|
|
|
export default defineConfig({
|
|
plugins: [
|
|
react(),
|
|
babel({ presets: [reactCompilerPreset()] })
|
|
],
|
|
server: {
|
|
historyApiFallback: true,
|
|
proxy: {
|
|
'/api': {
|
|
target: 'http://localhost:3001',
|
|
changeOrigin: true,
|
|
},
|
|
},
|
|
},
|
|
test: {
|
|
environment: 'node',
|
|
globals: true,
|
|
},
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Wrap the app in BrowserRouter**
|
|
|
|
Replace the contents of `frontend/src/main.tsx`:
|
|
|
|
```tsx
|
|
import { StrictMode } from 'react'
|
|
import { createRoot } from 'react-dom/client'
|
|
import { BrowserRouter } from 'react-router-dom'
|
|
import 'katex/dist/katex.min.css'
|
|
import './index.css'
|
|
import App from './App.tsx'
|
|
|
|
createRoot(document.getElementById('root')!).render(
|
|
<StrictMode>
|
|
<BrowserRouter>
|
|
<App />
|
|
</BrowserRouter>
|
|
</StrictMode>,
|
|
)
|
|
```
|
|
|
|
- [ ] **Step 3: Verify build still passes**
|
|
|
|
```bash
|
|
cd frontend && npm run build 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: build succeeds — `App` does not yet use any router hooks so wrapping it is safe.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/vite.config.ts frontend/src/main.tsx
|
|
git commit -m "feat: wrap app in BrowserRouter and enable history fallback"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Update ExamRunner with onNavigate prop
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/components/ExamRunner.tsx`
|
|
|
|
The only change is adding an optional `onNavigate` prop and calling it from `goTo`. Everything else stays identical.
|
|
|
|
- [ ] **Step 1: Add the prop and update goTo**
|
|
|
|
In `frontend/src/components/ExamRunner.tsx`, update the `Props` interface and `goTo` callback:
|
|
|
|
```typescript
|
|
// Change the Props interface (lines 9-15) to:
|
|
interface Props {
|
|
exam: Exam
|
|
state: ExamState
|
|
setState: React.Dispatch<React.SetStateAction<ExamState>>
|
|
onSubmit: () => void
|
|
onExit: () => void
|
|
onNavigate?: (i: number) => void
|
|
}
|
|
|
|
// Change the function signature (line 17) to:
|
|
export default function ExamRunner({ exam, state, setState, onSubmit, onExit, onNavigate }: Readonly<Props>) {
|
|
|
|
// Replace the goTo callback (lines 29-30) with:
|
|
const goTo = useCallback((i: number) => {
|
|
const next = Math.max(0, Math.min(total - 1, i))
|
|
setState((s) => ({ ...s, currentIdx: next }))
|
|
onNavigate?.(next)
|
|
}, [total, onNavigate])
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build passes**
|
|
|
|
```bash
|
|
cd frontend && npm run build 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: build succeeds — `onNavigate` is optional so existing callers are unaffected.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/ExamRunner.tsx
|
|
git commit -m "feat: add optional onNavigate prop to ExamRunner"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 5: Create ExamRoute component
|
|
|
|
**Files:**
|
|
- Create: `frontend/src/components/ExamRoute.tsx`
|
|
|
|
This component owns three responsibilities:
|
|
1. On mount, if no exam is active, attempt to restore from localStorage using the `:slug` param; redirect to `/` if no match.
|
|
2. On every render, sync the `:n` URL param to `examState.currentIdx` (enables browser back/forward).
|
|
3. Render `ExamRunner` with an `onNavigate` handler that updates the URL.
|
|
|
|
- [ ] **Step 1: Create the file**
|
|
|
|
Create `frontend/src/components/ExamRoute.tsx`:
|
|
|
|
```tsx
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import { useEffect } from 'react'
|
|
import type { Dispatch, SetStateAction } from 'react'
|
|
import ExamRunner from './ExamRunner'
|
|
import { loadState } from '../utils/storage'
|
|
import { slugify } from '../utils/slug'
|
|
import type { Exam, ExamState } from '../types'
|
|
|
|
interface Props {
|
|
exam: Exam | null
|
|
examState: ExamState | null
|
|
onActivate: (exam: Exam, state: ExamState) => void
|
|
setExamState: Dispatch<SetStateAction<ExamState>>
|
|
onSubmit: () => void
|
|
onExit: () => void
|
|
}
|
|
|
|
export default function ExamRoute({ exam, examState, onActivate, setExamState, onSubmit, onExit }: Readonly<Props>) {
|
|
const { slug, n } = useParams<{ slug: string; n: string }>()
|
|
const navigate = useNavigate()
|
|
|
|
const questionIdx = Math.max(0, (Number(n) || 1) - 1)
|
|
|
|
// Deep link: restore from localStorage if no exam is active on mount
|
|
useEffect(() => {
|
|
if (!exam) {
|
|
const saved = loadState()
|
|
if (saved?.exam && slugify(saved.exam.title) === slug) {
|
|
onActivate(saved.exam, { ...saved.state, currentIdx: questionIdx })
|
|
} else {
|
|
navigate('/', { replace: true })
|
|
}
|
|
}
|
|
// Intentionally runs only on mount — dep changes after mount are handled by the sync effect below
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [])
|
|
|
|
// Sync :n URL param to currentIdx (handles browser back/forward navigation)
|
|
useEffect(() => {
|
|
setExamState((s) => s ? { ...s, currentIdx: questionIdx } : s)
|
|
}, [questionIdx, setExamState])
|
|
|
|
if (!exam || !examState) return null
|
|
|
|
return (
|
|
<ExamRunner
|
|
exam={exam}
|
|
state={examState}
|
|
setState={setExamState}
|
|
onNavigate={(i) => navigate(`/exam/${slug}/q${i + 1}`)}
|
|
onSubmit={onSubmit}
|
|
onExit={onExit}
|
|
/>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build passes**
|
|
|
|
```bash
|
|
cd frontend && npm run build 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: build succeeds. `ExamRoute` is not yet imported by anything so no usages to break.
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/components/ExamRoute.tsx
|
|
git commit -m "feat: add ExamRoute component with deep-link restore and back/forward sync"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 6: Migrate App.tsx to React Router
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/App.tsx`
|
|
|
|
This is the main migration. Replace the `screen` state machine with `<Routes>`, replace all `setScreen()` calls with `navigate()`, add `activateExam` for deep-link activation, and use `useLocation` for the verify/reset-password special layouts.
|
|
|
|
- [ ] **Step 1: Replace App.tsx with the migrated version**
|
|
|
|
Replace the entire contents of `frontend/src/App.tsx`:
|
|
|
|
```tsx
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
|
|
import type { 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 { slugify } from './utils/slug'
|
|
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
|
import Landing from './components/Landing'
|
|
import ExamRoute from './components/ExamRoute'
|
|
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'
|
|
import AdminDashboard from './components/AdminDashboard'
|
|
|
|
const DEFAULT_TWEAKS: Tweaks = { theme: 'light', fontSize: 'medium', feedbackMode: 'auto' }
|
|
|
|
const withNotes = (state: ExamState): ExamState => ({
|
|
...state,
|
|
notes: state.notes ?? '',
|
|
})
|
|
|
|
function AppInner() {
|
|
const { user, loading: authLoading, logout } = useAuth()
|
|
const navigate = useNavigate()
|
|
const { pathname } = useLocation()
|
|
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)
|
|
|
|
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: {}, notes: '', startedAt: 0, feedbackMode: 'end' as const } as ExamState,
|
|
_remoteId: r.id,
|
|
}))
|
|
setHistory(apiHistory)
|
|
})
|
|
.catch(() => {
|
|
setHistory(loadHistory())
|
|
})
|
|
}
|
|
}, [user, authLoading])
|
|
|
|
useEffect(() => {
|
|
document.documentElement.dataset['theme'] = tweaks.theme
|
|
const sizeMap: Record<string, string> = { small: '14px', medium: '15px', large: '17px' }
|
|
document.body.style.fontSize = sizeMap[tweaks.fontSize] ?? '15px'
|
|
}, [tweaks])
|
|
|
|
useEffect(() => {
|
|
if (pathname.startsWith('/exam/') && exam && examState) saveState({ exam, state: examState })
|
|
}, [exam, examState, pathname])
|
|
|
|
const updateTweak = <K extends keyof Tweaks>(key: K, value: Tweaks[K]) =>
|
|
setTweaks((t) => ({ ...t, [key]: value }))
|
|
|
|
const setExamStateStrict: React.Dispatch<React.SetStateAction<ExamState>> = (nextState) => {
|
|
setExamState((prev) => {
|
|
if (!prev) return prev
|
|
return typeof nextState === 'function'
|
|
? (nextState as (prevState: ExamState) => ExamState)(prev)
|
|
: nextState
|
|
})
|
|
}
|
|
|
|
const activateExam = (data: Exam, state: ExamState) => {
|
|
setExam(data)
|
|
setExamState(withNotes(state))
|
|
}
|
|
|
|
const loadExam = (data: Exam) => {
|
|
const fm = tweaks.feedbackMode === 'auto' ? (data.feedbackMode ?? 'end') : tweaks.feedbackMode
|
|
setExam(data)
|
|
setExamState({ currentIdx: 0, answers: {}, flags: {}, notes: '', revealed: {}, startedAt: Date.now(), feedbackMode: fm })
|
|
navigate(`/exam/${slugify(data.title)}/q1`)
|
|
}
|
|
|
|
const resume = () => {
|
|
const saved = loadState()
|
|
if (saved?.exam && saved?.state) {
|
|
setExam(saved.exam)
|
|
setExamState(withNotes(saved.state))
|
|
navigate(`/exam/${slugify(saved.exam.title)}/q${saved.state.currentIdx + 1}`)
|
|
}
|
|
}
|
|
|
|
const submit = useCallback(() => {
|
|
if (!exam || !examState) return
|
|
const completedAt = Date.now()
|
|
const elapsedMs = completedAt - examState.startedAt
|
|
const graded = exam.questions.map((q) => gradeQuestion(q, examState.answers[q.id]))
|
|
const entry: HistoryEntry = {
|
|
id: `${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
|
title: exam.title,
|
|
completedAt,
|
|
elapsedMs,
|
|
gradedCount: graded.filter((r) => r.status !== 'ungraded').length,
|
|
correctCount: graded.filter((r) => r.correct).length,
|
|
totalPoints: exam.questions.reduce((a, q) => a + (q.points ?? 1), 0),
|
|
scorePoints: graded.reduce((a, r) => a + r.points, 0),
|
|
passingScore: exam.passingScore ?? 60,
|
|
exam,
|
|
state: { ...examState, completedAt },
|
|
}
|
|
|
|
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,
|
|
notes: entry.state.notes,
|
|
state: entry.state,
|
|
exam: entry.exam,
|
|
}),
|
|
}).catch(() => {
|
|
appendHistory(entry)
|
|
})
|
|
} else {
|
|
appendHistory(entry)
|
|
}
|
|
|
|
setHistory((prev) => [entry, ...prev])
|
|
clearState()
|
|
setExamState((s) => s ? { ...s, completedAt } : s)
|
|
navigate('/results')
|
|
}, [exam, examState, user, navigate])
|
|
|
|
const exit = () => {
|
|
setExam(null)
|
|
setExamState(null)
|
|
navigate('/')
|
|
}
|
|
|
|
const restart = () => { if (exam) loadExam(exam) }
|
|
|
|
const reviewAttempt = (entry: HistoryEntry) => {
|
|
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(withNotes(data.state))
|
|
navigate(`/review/${slugify(entry.title)}`)
|
|
})
|
|
.catch(() => {
|
|
setExam(entry.exam)
|
|
setExamState(withNotes(entry.state))
|
|
navigate(`/review/${slugify(entry.title)}`)
|
|
})
|
|
} else {
|
|
setExam(entry.exam)
|
|
setExamState(withNotes(entry.state))
|
|
navigate(`/review/${slugify(entry.title)}`)
|
|
}
|
|
}
|
|
|
|
const clearAllHistory = () => {
|
|
if (confirm('Clear all past attempts?')) {
|
|
clearHistory()
|
|
setHistory([])
|
|
}
|
|
}
|
|
|
|
const handleAuthFlowDone = () => {
|
|
navigate('/')
|
|
setAuthModalOpen(true)
|
|
}
|
|
|
|
if (pathname === '/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={handleAuthFlowDone} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (pathname === '/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={handleAuthFlowDone} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="shell">
|
|
<header className="topbar">
|
|
<div className="brand">
|
|
<span className="brand-mark" />
|
|
<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">
|
|
{pathname.startsWith('/exam/') && exam && (
|
|
<>
|
|
<span className="mono" style={{ fontSize: 12 }}>{exam.title}</span>
|
|
<button className="btn btn-ghost btn-sm" onClick={exit}>Exit</button>
|
|
</>
|
|
)}
|
|
<button className="btn btn-ghost btn-sm" onClick={() => setTweaksOpen((o) => !o)} style={{ marginLeft: 8 }} aria-label="Tweaks">⚙</button>
|
|
|
|
{!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" />
|
|
{user.role === 'admin' && (
|
|
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); navigate('/admin') }}>
|
|
Admin Dashboard
|
|
</button>
|
|
)}
|
|
<button className="user-menu-item" onClick={() => { setUserMenuOpen(false); logout() }}>
|
|
Sign Out
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
<Routes>
|
|
<Route path="/" element={
|
|
<Landing onLoadExam={loadExam} history={history} onResume={resume} onClearHistory={clearAllHistory} onReviewAttempt={reviewAttempt} />
|
|
} />
|
|
<Route path="/exam/:slug/q:n" element={
|
|
<ExamRoute
|
|
exam={exam}
|
|
examState={examState}
|
|
onActivate={activateExam}
|
|
setExamState={setExamStateStrict}
|
|
onSubmit={submit}
|
|
onExit={exit}
|
|
/>
|
|
} />
|
|
<Route path="/results" element={
|
|
exam && examState
|
|
? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={false} />
|
|
: <Navigate to="/" replace />
|
|
} />
|
|
<Route path="/review/:slug" element={
|
|
exam && examState
|
|
? <Results exam={exam} state={examState} onRestart={restart} onExit={exit} reviewOnly={true} />
|
|
: <Navigate to="/" replace />
|
|
} />
|
|
<Route path="/admin" element={
|
|
user?.role === 'admin' ? <AdminDashboard onExit={exit} /> : <Navigate to="/" replace />
|
|
} />
|
|
<Route path="*" element={<Navigate to="/" replace />} />
|
|
</Routes>
|
|
|
|
{tweaksOpen && <TweaksPanel tweaks={tweaks} update={updateTweak} onClose={() => setTweaksOpen(false)} />}
|
|
{authModalOpen && (
|
|
<AuthModal
|
|
onClose={() => setAuthModalOpen(false)}
|
|
onForgotPassword={() => navigate('/reset-password')}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<AuthProvider>
|
|
<AppInner />
|
|
</AuthProvider>
|
|
)
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Verify the build passes**
|
|
|
|
```bash
|
|
cd frontend && npm run build 2>&1 | tail -10
|
|
```
|
|
|
|
Expected: build succeeds with no TypeScript errors.
|
|
|
|
- [ ] **Step 3: Run all tests**
|
|
|
|
```bash
|
|
cd frontend && npm run test:run
|
|
```
|
|
|
|
Expected: all tests pass (slugify + pre-existing utils).
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/App.tsx
|
|
git commit -m "feat: migrate App.tsx to React Router — replace screen state with declarative routes"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 7: Remove AppScreen type from types.ts
|
|
|
|
**Files:**
|
|
- Modify: `frontend/src/types.ts`
|
|
|
|
`AppScreen` is no longer referenced anywhere after the App.tsx migration.
|
|
|
|
- [ ] **Step 1: Remove the type**
|
|
|
|
In `frontend/src/types.ts`, delete line 116:
|
|
|
|
```typescript
|
|
// Delete this line:
|
|
export type AppScreen = 'landing' | 'exam' | 'results' | 'review' | 'verify' | 'reset-password' | 'admin'
|
|
```
|
|
|
|
- [ ] **Step 2: Verify build still passes**
|
|
|
|
```bash
|
|
cd frontend && npm run build 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: build succeeds. If there are errors referencing `AppScreen`, search for usages:
|
|
|
|
```bash
|
|
grep -r "AppScreen" frontend/src
|
|
```
|
|
|
|
Remove any remaining import of `AppScreen` from those files.
|
|
|
|
- [ ] **Step 3: Run all tests**
|
|
|
|
```bash
|
|
cd frontend && npm run test:run
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add frontend/src/types.ts
|
|
git commit -m "chore: remove unused AppScreen type"
|
|
```
|
|
|
|
---
|
|
|
|
## Task 8: Manual smoke test
|
|
|
|
No automated tests cover the React Router integration (the test environment is `node`, not jsdom). Verify the feature manually.
|
|
|
|
- [ ] **Step 1: Start the dev server**
|
|
|
|
```bash
|
|
cd frontend && npm run dev
|
|
```
|
|
|
|
- [ ] **Step 2: Verify landing page**
|
|
|
|
Open `http://localhost:5173`. URL should be `/`. Landing page should render.
|
|
|
|
- [ ] **Step 3: Start an exam and verify URL updates**
|
|
|
|
Load any exam. The URL should change to `/exam/<slug>/q1`.
|
|
Navigate to question 3 (sidebar or Next button). URL should change to `/exam/<slug>/q3`.
|
|
|
|
- [ ] **Step 4: Verify browser back/forward**
|
|
|
|
Press the browser Back button. URL should go to `/exam/<slug>/q2` and question 2 should display.
|
|
|
|
- [ ] **Step 5: Verify deep link**
|
|
|
|
While on question 5, copy the URL. Open a new tab and paste it. The exam should resume at question 5 (restored from localStorage).
|
|
|
|
- [ ] **Step 6: Verify submit → results URL**
|
|
|
|
Submit the exam. URL should change to `/results`.
|
|
|
|
- [ ] **Step 7: Verify review URL**
|
|
|
|
From the landing history, click a past attempt to review. URL should change to `/review/<slug>`.
|
|
|
|
- [ ] **Step 8: Verify auth flow routes**
|
|
|
|
Navigate to `http://localhost:5173/verify`. The email verify screen should render (simplified header, no nav).
|
|
Navigate to `http://localhost:5173/reset-password`. The reset password screen should render.
|
|
|
|
- [ ] **Step 9: Verify unknown route redirect**
|
|
|
|
Navigate to `http://localhost:5173/does-not-exist`. Should redirect to `/`.
|