# PedsHub Frontend Documentation
## Architecture Overview
The frontend is a React SPA built with Vite and served via Nginx. It communicates with the backend exclusively through `/api` (proxied by Nginx to the backend container).
### Entry Point
`main.jsx` renders `` inside ``.
`App.jsx` provides the top-level provider hierarchy:
```
BrowserRouter
ThemeProvider
AuthProvider
AppRoutes
```
### Routing
`AppRoutes` reads `useAuth()` to decide what to render:
| Route | Component | Access |
|---|---|---|
| `/home` | LandingPage | Public (always) |
| `/login` | LoginPage | Public (redirects to `/` if logged in) |
| `/register` | RegisterPage | Public (redirects to `/` if logged in) |
| `/verify-email` | VerifyEmailPage | Public |
| `/forgot-password` | ForgotPasswordPage | Public |
| `/reset-password` | ResetPasswordPage | Public |
| `/` | DashboardPage | Authenticated |
| `/quizzes` | QuizzesPage | Authenticated |
| `/quizzes/:id` | QuizPage | Authenticated |
| `/results/:id` | ResultsPage | Authenticated |
| `/documents/:id` | DocumentDetailPage | Authenticated |
| `/account` | AccountPage | Authenticated |
| `/settings` | SettingsPage | Authenticated |
| `/question-bank` | QuestionBankPage | Authenticated |
| `/admin` | AdminPage | Authenticated |
| `/upload` | UploadPage | Moderator only |
| `/quizzes/:id/edit` | QuizEditPage | Moderator only |
| `/jobs` | JobsPage | Moderator only |
| `/trash` | TrashPage | Moderator only |
| `*` | NotFoundPage or redirect to `/home` | Catch-all |
Unauthenticated users hitting any protected route are redirected to `/home`. Non-moderators hitting moderator routes are redirected to `/`.
`AppLayout` wraps authenticated routes with ``, a container div, and a footer.
---
## Context Providers
### AuthContext (`context/AuthContext.jsx`)
Provides `{ user, loading, login, loginWithToken, logout }`.
- On mount, checks `localStorage.getItem('token')` and calls `GET /auth/me` to hydrate the user object.
- `login(email, password, turnstileToken)` posts to `/auth/login`, stores the JWT, then fetches `/auth/me`.
- `loginWithToken(token)` stores a token directly (used after registration when verification is not required).
- `logout()` clears the token and sets user to null.
### ThemeContext (`context/ThemeContext.jsx`)
Provides `{ theme, setTheme }`.
- Persists the active theme name in `localStorage` under key `pedquiz_theme`.
- Applies the theme by setting `data-theme` attribute on `document.body`.
---
## Page Descriptions
### LandingPage (`/home`)
The public-facing marketing page. Sections:
1. **Hero** - Gradient background with tagline "Turn your textbooks into tests". Shows Sign In / Register buttons for guests, or Dashboard / My Quizzes / Question Bank links for logged-in users.
2. **Features** - 6 feature cards (Quiz from Any PDF, AI Tutor, Audio Mode, Question Bank, Track Your Progress, Fast by Design) rendered from a static `FEATURES` array. Cards have hover lift animations.
3. **AI Scribe cross-sell** - Promotes the sibling PedsHub AI Scribe product (links to `https://peds.danvics.com`).
4. **Contact form** - `ContactForm` component with name, email, type selector (Question or Apply as Moderator), and message. Validates required fields. Includes a `TurnstileWidget` for Cloudflare captcha. Posts to `POST /contact`.
5. **Auth modal** - `AuthModal` component rendered as an overlay with Sign In / Register tabs. Handles login, registration, email verification resend, and unverified-email warnings. The Turnstile widget is embedded inside the modal. The Navbar receives `onSignIn` and `onRegister` callbacks to open the modal.
**Turnstile integration**: The site key comes from `window.__APP_CONFIG__.TURNSTILE_SITE_KEY`. The Turnstile script is loaded dynamically once. `TurnstileWidget` retries rendering with a 200ms polling loop until `window.turnstile` is available.
### DashboardPage (`/`)
The authenticated home screen. Loads three API calls in parallel on mount:
- `GET /documents/` (moderators only, otherwise skipped)
- `GET /attempts/stats/dashboard` (total quizzes, attempts, avg score, optionally documents)
- `GET /attempts/history` (attempt history grouped by quiz)
Features:
- **Greeting** - Time-of-day greeting using the user's first name.
- **Stats cards** - Grid of stat cards (Documents for moderators, Quizzes, Attempts, Avg Score).
- **Performance chart** - `LineChart` SVG component showing score trend for a selected quiz. Dropdown to switch between quizzes. Shows latest score, best score, and a retake button. Warns if latest score is below 75%.
- **Attempt history** - Listed under the chart. Each attempt row shows date, score link to results, and inline delete with two-click confirmation (`confirmAttempt` state pattern).
- **Documents list** - Moderator-only card listing uploaded PDFs with status badges and an Upload PDF button.
### QuizzesPage (`/quizzes`)
Loads quizzes and categories in parallel on mount.
Features:
- **Search bar** - Debounced search (350ms) across quizzes and questions. Three filter modes: All, Title only, Questions only. Results show quiz title matches and matching question snippets with `HighlightText` highlighting.
- **Expanded search** - Toggle to view all matching questions in a flat list. Each question has a Study button.
- **QuestionStudyModal** - Interactive modal for answering a single question, showing correct/incorrect feedback, and displaying the explanation. Includes a lazy-loaded `TeachChat` with the `elevated` prop for higher z-index (above the modal).
- **In-progress quizzes** (`InProgressSection`) - Shows quizzes the user started but hasn't finished. Resume and Delete buttons.
- **Past attempts** (`PastAttemptsSection`) - Collapsible section, loads on demand. Delete buttons on each attempt.
- **Quiz grid** - Quizzes grouped by category (moderator-created categories). `QuizCard` component shows title, question count, mode badge (Learning/Timed), time limit. Hover lift effect.
- **Moderator controls** - Edit link, publish/unpublish toggle, category assignment dropdown, trash button with `ConfirmButton`.
- **Category management** - Moderators can add categories inline, delete categories, and see quiz counts per category.
### QuizPage (`/quizzes/:id`)
The core quiz-taking experience. Two phases: mode selection, then quiz.
**Mode selection** (`ModeSelectScreen`):
- Study Mode: answers and explanations revealed after each question.
- Exam Mode: answers hidden until submitted.
- Optional timer input (minutes) for exam mode.
- TTS voice selector (if voices are configured).
**Quiz phase**:
- **Concurrent device lock** - A unique `SESSION_ID` is generated per tab (`Math.random() + Date.now()`). Sent as `x-quiz-session` header when fetching/saving progress. The backend returns 409 if the quiz is active on another device.
- **Auto-resume** - On load, fetches `GET /attempts/progress?quiz_id=...`. If saved progress exists, calls `resumeQuiz()` which restores mode, answers, current index, timer state, and voice selection. For study mode, re-fetches the quiz with `?study=true` to get correct answers.
- **Timer** - Uses the `timerStarted` pattern: `const timerStarted = timeLeft !== null` declared outside the effect, used as a dependency. The timer is calculated from `started_at + total_time`, so it survives page reloads. Auto-submits when `timeLeft` reaches 0.
- **Progress save to Redis** - Debounced at 1.5 seconds. Saves `quiz_id, attempt_id, answers, current_idx, mode, voice, time_left, started_at, total_time` via `POST /attempts/progress`. Uses the session ID header.
- **Answer tracking** - `answers` state is a `{ [questionId]: selectedOption }` object. `setAnswer` merges into the object.
- **Navigation** - Previous/Next buttons, question number dots (desktop sidebar + mobile collapsible grid). `QuestionDot` shows active (primary), answered (green), and unanswered (gray) states.
- **TTS** - `TTSButton` component calls `POST /tts/speak` to get audio blob, creates `Audio` object for playback. Voice selector disabled while audio is playing.
- **Favorites** - Star toggle on each question, calls `POST /favorites` or `DELETE /favorites/:id`.
- **Suspend** - "Suspend" button shows a confirmation dialog. Progress is already saved; timer warning shown for timed quizzes. Navigates to dashboard on confirm.
- **beforeunload** - Browser tab close warning when mid-quiz.
- **AI Tutor** - `TeachChat` lazy-loaded in study mode only.
### ResultsPage (`/results/:id`)
Shows quiz results. Accepts `location.state.result` for instant display after submission, or fetches `GET /attempts/:id` if navigated directly.
Features:
- **Score card** - Large percentage display with color coding (green >= 75, yellow >= 50, red < 50). Score bar visualization. Contextual message.
- **Summary row** - Correct, Incorrect, and Skipped counts with colored backgrounds.
- **Question review** - Full review of each question with:
- Correct/Incorrect/Skipped badge
- Option display with letter badges, color-coded (correct = green, user's wrong answer = red)
- Fill-in-the-blank display for non-MCQ
- Explanation section
- **Actions** - Retake Quiz, All Quizzes, Dashboard, Delete Attempt (with two-click confirmation).
### QuestionBankPage (`/question-bank`)
The central question repository with advanced filtering.
Features:
- **Search modes** - Three modes available: `keyword` (text search), `semantic` (vector similarity), `hybrid` (combines both). Controlled by `searchMode` state, sent as `search_mode` param.
- **Multi-category filter** - `filterCatIds` array state. Toggling a category adds/removes its ID. Uses `catIdsKey = filterCatIds.join(',')` as a useEffect dependency (avoids reference equality issues with arrays).
- **Tag browser** (`TagBrowser` component) - Three sections: Subjects, Diseases, Keywords. Each section has its own search input. Tags shown as clickable buttons with counts. Selected tags turn colored. `selectedTagIds` array tracked similarly with `tagIdsKey = selectedTagIds.join(',')`.
- **Debounced loading** - 300ms debounce on search query, category, tag, favorites, uncategorized, and page size changes.
- **Pagination** - Configurable page size (25, 50, 100, All). Load more button appends to existing questions.
- **Favorites filter** - Toggle to show only favorited questions.
- **Uncategorized filter** - Toggle to show only questions without a category.
- **Bulk operations** - Checkbox selection on questions. "Select All" fetches ALL matching IDs from `GET /questions/bank/ids` (not just the loaded page). Bulk assign category via `POST /questions/bulk-category`.
- **Create quiz from selection** - `CreateQuizModal` creates a quiz from the currently selected questions or from all questions in a category. Inputs: title, mode (Timed/Learning), and optional time limit.
- **Question study modal** - Same interactive study modal as QuizzesPage with TeachChat (elevated z-index).
- **Question edit modal** (`QuestionEditModal`) - Moderators can edit question text, options (with radio button for correct answer), category, and explanation. Warning that changes apply to all quizzes sharing the question.
- **Tag classification** - Moderators can trigger AI classification (`POST /tags/classify`). Progress is polled every 2 seconds. Tags reload on completion.
- **Category management** - Add new categories inline. Delete categories with optional move-to target.
### DocumentDetailPage (`/documents/:id`)
Moderator-focused document management page.
Features:
- **Document info** - Filename, page count, upload date, status badge. Delete document button.
- **Processing progress** - When `doc.status === 'processing'`, polls `GET /documents/:id/status` and `GET /documents/:id/processing-steps` every 2 seconds. Shows step-by-step progress (text extraction, image extraction, etc.).
- **Section management** - Form to create sections with name, start page, end page. List of existing sections with Extract & Create Quiz and Delete buttons.
- **Quiz settings** (moderator-only panel):
- Quiz title (optional, auto-generated if blank)
- Mode: Timed or Learning
- Time limit (for timed mode)
- Extraction mode: Standard, Questions Only, Two-Step, AI + Regex, AI Decides, Generate
- Extraction model selector (from `GET /admin/models/available?task=extraction`)
- Question bank category selector with inline "New" button
- **Extraction progress** (`ExtractionProgress` component) - Modal overlay that polls `GET /quizzes/job/:jobId` every 2 seconds. Shows step icons (text, AI, images, save, error, done) and messages. Job is tracked in `localStorage` under key `pedquiz_jobs` so the Navbar can show it. Close button dismisses the panel but the job continues in the background.
### UploadPage (`/upload`)
Moderator-only page for uploading PDFs. Two tabs:
1. **Local File** - Drag-and-drop area or file picker. Accepts `.pdf` only, up to 500MB. Shows file name and size when selected.
2. **Nextcloud** - `NextcloudBrowser` component. Reads credentials from `localStorage` (configured in Settings). Browses folders via `POST /nextcloud/files`, downloads PDFs via `POST /nextcloud/download`. Importing a file auto-triggers upload.
**Upload progress** - Uses axios upload progress. Reads `e.progress` (axios 1.x pattern) with fallback to `e.loaded / e.total`. Shows progress bar and percentage.
**Auto-upload on Nextcloud import** - When `handleFile` is called with `fromNextcloud = true`, `doUpload(f)` is called immediately without waiting for the user to click Upload.
### AdminPage (`/admin`)
Admin-only page with three tabs: Models, Users, Settings.
**Access control**: Redirects to `/` if `user.role !== 'admin'`.
**loadData(false) pattern**: After mutations (role change, model add/delete, etc.), calls `loadData(false)` which skips the loading spinner but refreshes all data.
**Models tab**:
- Lists models grouped by task (extraction, tts, teach, keyword).
- Each model shows name, model_id, active/inactive toggle, default toggle, test button, delete button.
- TTS models have an inline preview player.
- **LiteLLM model search** - Posts to `POST /admin/litellm/models` with optional API key and base URL. Shows filterable results. Click "Add" to pre-fill the add-model form.
- **TTS voice discovery** - Select provider (ElevenLabs, etc.), enter API key, search. Shows voice list with "Add" buttons.
- Add model form: name, model_id, task dropdown, API key, active/default toggles.
**Users tab**:
- User list with name, email, role, verified status.
- Role dropdown (user, moderator, admin).
- Unthrottle toggle (removes rate limits for a user).
- Delete user with Dialog confirmation.
- Create user form.
**Settings tab**:
- Registration enabled toggle.
- Embedding model selector with LiteLLM search, test button, and regeneration trigger if model changed.
- Polly enabled toggle.
---
## Components
### Navbar (`components/Navbar.jsx`)
Auth-aware navigation bar. Behavior changes based on login state:
- **Logged out**: Shows Sign In and Register buttons/links. If `onSignIn` and `onRegister` props are provided (from LandingPage), they trigger callbacks instead of navigating. Otherwise, links to `/login` and `/register`.
- **Logged in**: Desktop nav with links (Home, Dashboard, Quizzes, Question Bank, Upload PDF for moderators, Settings). Mobile hamburger menu with animated bars. Logout button.
- **JobsBadge** - Polls `GET /quizzes/jobs` for active extraction jobs. Poll interval: 3 seconds if jobs are running, 30 seconds otherwise. Shows running count or total job count. Dropdown with job details and links to completed quizzes.
- Auto-closes mobile menu on route change.
### TeachChat (`components/TeachChat.jsx`)
Lazy-loaded AI tutor drawer. Appears as a floating action button (graduation cap icon).
- **Mobile**: Bottom sheet, 70vh height, slides up.
- **Desktop**: Right-side panel, 340px wide, slides in from right.
- **Elevated mode**: When `elevated` prop is true, z-index is raised above modals (1060-1070 vs 380-400). Used when TeachChat appears alongside a study modal in QuizzesPage/QuestionBankPage.
- **Features**:
- Question context chip at top.
- Model selector dropdown (if multiple teach models configured).
- Markdown rendering with `react-markdown`, `remark-gfm` (GFM tables), and `rehype-raw`.
- Follow-up suggestion chips on the last assistant message. Clicking fills the input.
- Posts to `POST /teach/chat` with question_id, messages array, and model_id.
- Shows "no model" error if backend returns 503.
- Chat clears when question changes (keyed on `question.id`).
### Dialog (`components/Dialog.jsx`)
Modal replacement for `window.confirm()` and `window.alert()`. Used via the `useDialog()` hook which returns `{ dialogProps, openConfirm, openAlert }`.
Props: `open`, `title`, `message`, `confirmLabel`, `cancelLabel` (null = alert mode), `onConfirm`, `onCancel`, `danger` (red confirm button).
### ConfirmButton (`components/ConfirmButton.jsx`)
Inline two-click confirmation. First click shows "Confirm" and "Cancel" buttons. Second click fires `onConfirm`. Used throughout for delete operations.
### LineChart (`components/LineChart.jsx`)
Pure SVG line chart for performance trends. Renders a line with fill, dot markers (green >= 75%, red < 75%), grid lines, and a dashed 75% threshold line. Requires at least 2 data points. Responsive via `viewBox`.
---
## API Client (`api/client.js`)
Axios instance with `baseURL: '/api'`.
**Request interceptor**: Attaches `Authorization: Bearer ` from localStorage.
**Response interceptor**:
- **Token refresh**: Checks `x-new-token` response header on both success and error responses. If present, updates localStorage. This implements sliding token expiration.
- **401 handling**: Clears token and redirects to `/login`. Skips redirect for auth endpoints (login, register, verify, reset, forgot).
- **Unverified email (403)**: If response has `x-unverified: true` header, clears token and redirects to `/login` (which shows a resend verification option).
---
## State Patterns
### Set vs Array for useEffect deps
`selectedIds` in QuestionBankPage is a `Set` (for O(1) lookups). Since React compares deps by reference, toggling creates a new `Set` each time, which correctly triggers re-renders.
### tagIdsKey / catIdsKey join pattern
Arrays like `selectedTagIds` and `filterCatIds` would cause useEffect to fire on every render due to reference inequality. The pattern:
```js
const tagIdsKey = selectedTagIds.join(',')
const catIdsKey = filterCatIds.join(',')
useEffect(() => { ... }, [searchQuery, catIdsKey, tagIdsKey, ...])
```
Converts arrays to stable string keys so useEffect only fires when the actual content changes.
### timerStarted pattern
In QuizPage, the timer effect must only start once when `timeLeft` transitions from null to a number:
```js
const timerStarted = timeLeft !== null
useEffect(() => {
if (!timerStarted) return
timerRef.current = setInterval(...)
return () => clearInterval(timerRef.current)
}, [timerStarted])
```
This prevents the interval from being torn down and recreated every second as `timeLeft` decrements.
---
## Runtime Configuration
The frontend Docker image uses `docker-entrypoint.sh` to inject runtime config at container startup:
```sh
cat > /usr/share/nginx/html/config.js <