- Fix double /v1 in TTS audio/speech URL when LITELLM_API_BASE includes /v1 - Fix double /v1 in embedding service and vector service URLs - Clean up docs: remove second-person language in deployment, frontend, migrations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
19 KiB
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 callsGET /auth/meto 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
localStorageunder keypedquiz_theme. - Applies the theme by setting
data-themeattribute ondocument.body.
Page Descriptions
LandingPage (/home)
The public-facing marketing page. Sections:
- 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.
- Features - 6 feature cards (Quiz from Any PDF, AI Tutor, Audio Mode, Question Bank, Track Your Progress, Fast by Design) rendered from a static
FEATURESarray. Cards have hover lift animations. - AI Scribe cross-sell - Promotes the sibling PedsHub AI Scribe product (links to
https://peds.danvics.com). - Contact form -
ContactFormcomponent with name, email, type selector (Question or Apply as Moderator), and message. Validates required fields. Includes aTurnstileWidgetfor Cloudflare captcha. Posts toPOST /contact. - Auth modal -
AuthModalcomponent 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 receivesonSignInandonRegistercallbacks 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 -
LineChartSVG 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 (
confirmAttemptstate 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
HighlightTexthighlighting. - 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
TeachChatwith theelevatedprop 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).
QuizCardcomponent 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_IDis generated per tab (Math.random() + Date.now()). Sent asx-quiz-sessionheader 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, callsresumeQuiz()which restores mode, answers, current index, timer state, and voice selection. For study mode, re-fetches the quiz with?study=trueto get correct answers. - Timer - Uses the
timerStartedpattern:const timerStarted = timeLeft !== nulldeclared outside the effect, used as a dependency. The timer is calculated fromstarted_at + total_time, so it survives page reloads. Auto-submits whentimeLeftreaches 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_timeviaPOST /attempts/progress. Uses the session ID header. - Answer tracking -
answersstate is a{ [questionId]: selectedOption }object.setAnswermerges into the object. - Navigation - Previous/Next buttons, question number dots (desktop sidebar + mobile collapsible grid).
QuestionDotshows active (primary), answered (green), and unanswered (gray) states. - TTS -
TTSButtoncomponent callsPOST /tts/speakto get audio blob, createsAudioobject for playback. Voice selector disabled while audio is playing. - Favorites - Star toggle on each question, calls
POST /favoritesorDELETE /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 -
TeachChatlazy-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 bysearchModestate, sent assearch_modeparam. - Multi-category filter -
filterCatIdsarray state. Toggling a category adds/removes its ID. UsescatIdsKey = filterCatIds.join(',')as a useEffect dependency (avoids reference equality issues with arrays). - Tag browser (
TagBrowsercomponent) - Three sections: Subjects, Diseases, Keywords. Each section has its own search input. Tags shown as clickable buttons with counts. Selected tags turn colored.selectedTagIdsarray tracked similarly withtagIdsKey = 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 viaPOST /questions/bulk-category. - Create quiz from selection -
CreateQuizModalcreates 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', pollsGET /documents/:id/statusandGET /documents/:id/processing-stepsevery 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 (
ExtractionProgresscomponent) - Modal overlay that pollsGET /quizzes/job/:jobIdevery 2 seconds. Shows step icons (text, AI, images, save, error, done) and messages. Job is tracked inlocalStorageunder keypedquiz_jobsso 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:
- Local File - Drag-and-drop area or file picker. Accepts
.pdfonly, up to 500MB. Shows file name and size when selected. - Nextcloud -
NextcloudBrowsercomponent. Reads credentials fromlocalStorage(configured in Settings). Browses folders viaPOST /nextcloud/files, downloads PDFs viaPOST /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/modelswith 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
onSignInandonRegisterprops are provided (from LandingPage), they trigger callbacks instead of navigating. Otherwise, links to/loginand/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/jobsfor 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
elevatedprop 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), andrehype-raw. - Follow-up suggestion chips on the last assistant message. Clicking fills the input.
- Posts to
POST /teach/chatwith 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-tokenresponse 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: trueheader, 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:
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:
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:
cat > /usr/share/nginx/html/config.js <<EOF
window.__APP_CONFIG__ = {
TURNSTILE_SITE_KEY: "${TURNSTILE_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__?.TURNSTILE_SITE_KEY. This avoids baking environment-specific values into the build.
The frontend .env file sets TURNSTILE_SITE_KEY which is read by Docker Compose and passed to the entrypoint script.