pdf-quiz-generator/docs/frontend.md
Daniel 4ca7f6b1f2 feat: Cap replaces hCaptcha, self-hosted beside the app
Proof-of-work rather than a puzzle, and — the reason for it — nothing
about the person signing up is described to a third party in order to let
them in. Turnstile and then hCaptcha were both here; both told Cloudflare
who was at the door.

The `cap` service runs on the compose network with its own Redis
database, kept apart from the app's so a flush of one cannot clear the
other's challenges. The widget talks to /cap/ on this origin, proxied by
the frontend's nginx, so the browser reaches nobody else either. Caddy
passes the whole host through to that container, so it needed no change.

Two things that had to be found rather than read:

Cap's key API is undocumented. The routes are `/auth/login` and
`/server/keys`, and the Bearer value is base64 JSON of `{token, hash}` —
not the session token itself, which is why the obvious call returns
"Malformed session token". The site key and secret were created that way
rather than by hand in a dashboard.

And an nginx proxy_pass whose target is a variable passes the URI through
untouched: the trailing slash that strips a location prefix on a literal
target does nothing. Cap was being asked for /cap/<key>/challenge and
answering NOT_FOUND until the prefix was stripped by an explicit rewrite.

Verified end to end against the running service: a challenge is issued
through the public path, and a token that was never issued is refused
rather than waved through.

Also here: the register modal's Name and Email were bare labels that
neither wrapped their input nor named it, so a screen reader met two
boxes with no names and clicking the word did nothing.

And the knowledge profile paginates ten to a page and expands each row to
its two bars beside the next step. "Correct using hints" is missing from
that bar because /study-tools/recommendations does not carry it per
topic — inferring it from the lifetime figure would be a different set of
answers, so the bar is honestly two-tone until the backend offers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 06:14:14 +02:00

341 lines
20 KiB
Markdown

# 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 `<App />` inside `<React.StrictMode>`.
`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 `<Navbar />`, 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)` posts to `/auth/login`, stores the JWT, then fetches `/auth/me`. There is no captcha on login; it is rate limited by IP instead.
- `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 the shared `Captcha` component. 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 captcha widget is embedded inside the registration tab of the modal. The Navbar receives `onSignIn` and `onRegister` callbacks to open the modal.
**Captcha integration**: `components/Captcha.jsx` is the only place a widget is created. The site key comes from `window.__APP_CONFIG__.CAP_SITE_KEY`, read on each call so the runtime value is never captured at import. It appends `https://js.cap.com/1/api.js?render=explicit` once, retries rendering with a 200ms polling loop until `window.cap` exists, and hands the caller an empty token when a solve expires. With no site key it renders nothing and loads nothing, and the forms drop their captcha requirement.
### 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 <token>` 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 <<EOF
window.__APP_CONFIG__ = {
CAP_SITE_KEY: "${CAP_SITE_KEY:-}"
};
EOF
exec nginx -g 'daemon off;'
```
`config.js` is loaded by `index.html` before the app bundle. Components access it via `window.__APP_CONFIG__?.CAP_SITE_KEY`. This avoids baking environment-specific values into the build.
The frontend `.env` file sets `CAP_SITE_KEY` which is read by Docker Compose and passed to the entrypoint script.