Public Access
docs: add React Router URL routing implementation plan
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
1c13f4849a
commit
e97d90e316
1 file changed
+818
@@ -0,0 +1,818 @@
|
||||
# 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 `/`.
|
||||
Reference in new issue
Block a user