From 2cbbfe00c3cb903bf4556f558b03bcf83757239c Mon Sep 17 00:00:00 2001 From: Daniel Date: Sat, 4 Apr 2026 22:48:26 +0200 Subject: [PATCH] Tag filtering, multi-category, bug fixes, image validation, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix tag filtering (sa_text import shadowing caused UnboundLocalError) - Add TagBrowser component with per-section search - Multi-category selection (OR within categories, AND with tags) - AI image validation: has_figure field in extraction prompt - Skip known branding images by MD5 hash + dimension filters - Fix quiz timer auto-submit (wrong useEffect dependency) - Fix QuizResponse schema: section_id nullable - Fix Question.quiz_id → source_quiz_id attribute name - Fix SQL injection in quizzes.py vector search - Add PDF processing progress steps via Redis - Add delete user from admin panel - Admin page: no spinner flash on data refresh - Upload progress: axios 1.x e.progress, remove manual Content-Type - Duplicate model error: 409 with clear message - Backend startup: retry DDL migration on lock timeout - Replace all silent except:pass with warning logs - Comprehensive multi-page documentation (docs/) Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 379 ++++++-- backend/app/database.py | 2 +- backend/app/main.py | 52 +- backend/app/models/ai_model_config.py | 7 +- backend/app/routers/admin.py | 44 +- backend/app/routers/attempts.py | 26 +- backend/app/routers/auth.py | 52 +- backend/app/routers/contact.py | 8 +- backend/app/routers/documents.py | 16 + backend/app/routers/questions.py | 62 +- backend/app/routers/quizzes.py | 10 +- backend/app/routers/tags.py | 67 ++ backend/app/schemas/auth.py | 2 + backend/app/schemas/quiz.py | 4 +- backend/app/services/ai_service.py | 12 +- backend/app/services/extraction_modes.py | 89 ++ backend/app/services/pdf_service.py | 23 +- backend/app/services/vector_service.py | 15 +- backend/app/tasks/pdf_tasks.py | 29 + backend/app/tasks/quiz_tasks.py | 187 +++- docker-compose.yml | 2 + docs/README.md | 19 + docs/api-reference.md | 1051 +++++++++++++++++++++ docs/architecture.md | 533 +++++++++++ docs/deployment.md | 446 +++++++++ docs/frontend.md | 341 +++++++ docs/services.md | 394 ++++++++ frontend/Dockerfile | 8 +- frontend/docker-entrypoint.sh | 10 + frontend/index.html | 9 + frontend/nginx.conf | 10 + frontend/package-lock.json | 464 ++++++++- frontend/package.json | 4 +- frontend/public/config.js | 2 + frontend/public/icon.svg | 4 + frontend/public/manifest.json | 19 + frontend/public/sw.js | 12 + frontend/src/components/Navbar.jsx | 16 +- frontend/src/components/TeachChat.jsx | 21 +- frontend/src/context/AuthContext.jsx | 4 +- frontend/src/pages/AdminPage.jsx | 43 +- frontend/src/pages/DashboardPage.jsx | 57 +- frontend/src/pages/DocumentDetailPage.jsx | 32 +- frontend/src/pages/LandingPage.jsx | 254 ++++- frontend/src/pages/LoginPage.jsx | 34 +- frontend/src/pages/QuestionBankPage.jsx | 293 ++++-- frontend/src/pages/QuizPage.jsx | 43 +- frontend/src/pages/QuizzesPage.jsx | 130 ++- frontend/src/pages/RegisterPage.jsx | 34 +- frontend/src/pages/ResultsPage.jsx | 37 +- frontend/src/pages/UploadPage.jsx | 61 +- 51 files changed, 5088 insertions(+), 385 deletions(-) create mode 100644 backend/app/routers/tags.py create mode 100644 docs/README.md create mode 100644 docs/api-reference.md create mode 100644 docs/architecture.md create mode 100644 docs/deployment.md create mode 100644 docs/frontend.md create mode 100644 docs/services.md create mode 100644 frontend/docker-entrypoint.sh create mode 100644 frontend/public/config.js create mode 100644 frontend/public/icon.svg create mode 100644 frontend/public/manifest.json create mode 100644 frontend/public/sw.js diff --git a/README.md b/README.md index 3c27db3..ee3e121 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,47 @@ -# PedQuiz +# PedsHub -AI-powered pediatric knowledge quiz platform. Upload PDF study materials, automatically extract MCQ questions with AI, and take quizzes with text-to-speech support and semantic search. +AI-powered pediatric learning platform. Upload PDF study materials, automatically extract or generate MCQ questions with AI, study with an AI tutor, and track your progress. Installable as a PWA. ## Features - **PDF → Quiz**: Upload PREP PDFs, AI extracts questions, answers, and explanations -- **Quiz Modes**: Study (instant feedback) and Exam (timed, scored) +- **Generative Mode**: AI reads plain text/textbook chapters and creates MCQ questions from scratch +- **Quiz Modes**: Study (instant feedback with AI tutor) and Exam (timed, scored) +- **AI Tutor (TeachChat)**: Ask follow-up questions mid-study — AI knows the current question, correct answer, and related content. Renders markdown tables, code, and follow-up suggestion chips. +- **Tag Classification**: AI classifies questions with subjects, diseases, and keywords — filter your question bank by any combination of tags +- **Multi-Category Filtering**: Filter questions by question category, tags, or quiz source — combine multiple filters for precise study sets - **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs, Google Cloud — voice selection per quiz - **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords -- **Embedding Model**: Configurable via Admin UI or env — supports any LiteLLM proxy model or AWS Bedrock -- **Job Cancellation**: Cancel running extractions from the web UI +- **Question Bank**: All questions searchable, filterable by category and tags, with inline study mode +- **Image Validation**: AI `has_figure` gating — only links extracted images to questions the AI flagged as having a figure, preventing mismatched images +- **Performance Tracking**: Per-quiz attempt history with line charts, score trends, delete individual attempts +- **PDF Processing Progress**: Step-by-step progress reporting for PDF uploads and quiz extraction — see each stage in real time +- **Concurrent Quiz Protection**: Redis session locks prevent the same quiz from being resumed on multiple devices simultaneously +- **Landing Page**: Integrated with the app — Sign In / Register open as modal overlays, shared Navbar +- **PWA**: Installable on mobile/desktop (no caching — avoids stale JS issues) +- **Bot Protection**: Cloudflare Turnstile on registration and contact forms - **Email Verification**: Required before first login; password reset via email -- **Role system**: Admin / Moderator / User +- **Role System**: Admin / Moderator / User with optional rate-limit exemption (unthrottle) +- **Admin User Management**: Delete users, change roles, toggle unthrottle — all from the admin dashboard - **Nextcloud Integration**: Browse and import PDFs from your Nextcloud -- **Themes**: Default and Warm Brown +- **Themes**: Default (blue) and Warm/Literary (brown, serif fonts) +- **Multi-worker**: 4 uvicorn workers with Redis singleton lock for scheduler ## Stack | Layer | Tech | |---|---| -| Frontend | React + React Router, plain CSS, Nginx | +| Frontend | React 18 + React Router 6, plain CSS, Nginx, PWA (network-only service worker) | | Backend | FastAPI, SQLAlchemy, PostgreSQL 16 + pgvector | -| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, and more) | -| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim, set via Admin UI or env) | +| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) | +| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) | | Document vectors | ChromaDB | | TTS | OpenAI (direct), AWS Polly, ElevenLabs, Google Cloud TTS | -| Queue | Celery + Redis | +| Queue | Celery + Redis (4 fork workers) | | Email | SMTP (smtp2go or any SMTP server) | +| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) | + +For detailed architecture documentation, see [docs/architecture.md](docs/architecture.md). ## Quick Start @@ -44,7 +59,7 @@ Frontend available at `http://localhost:8081`. The first registered user becomes ## Environment Variables -Create `backend/.env`: +### Backend (`backend/.env`) ```env # Database @@ -61,7 +76,7 @@ LITELLM_API_BASE=https://your-litellm-proxy.com # leave empty for direct OpenA # Embedding model — use the model name exactly as your proxy lists it (no prefix needed) # Can also be changed live via Admin → More settings without redeploying -LITELLM_EMBEDDING_MODEL=ge-gemini-embedding-001 +LITELLM_EMBEDDING_MODEL=gemini-embedding-001 # OpenAI (for TTS — calls api.openai.com directly, not the proxy) OPENAI_API_KEY= @@ -86,6 +101,12 @@ MAIL_PASSWORD= MAIL_FROM=noreply@yourdomain.com MAIL_STARTTLS=true +# Bot protection — Cloudflare Turnstile (backend secret) +TURNSTILE_SECRET_KEY= + +# Contact form admin notifications +ADMIN_EMAIL=admin@yourdomain.com + # App APP_URL=https://your-domain.com UPLOAD_DIR=/app/uploads @@ -93,9 +114,163 @@ MAX_UPLOAD_SIZE=524288000 CHROMA_PERSIST_DIR=/app/chroma_data ``` +### Frontend (`frontend/.env`) + +```env +# Bot protection — Cloudflare Turnstile (public site key) +TURNSTILE_SITE_KEY= +``` + +The frontend env file is **not** baked into the Docker image at build time. Instead, `docker-entrypoint.sh` generates a `/config.js` file from the env vars when the container **starts**. This means: + +- Change keys by editing `frontend/.env` and restarting the container (no rebuild) +- Switch captcha providers by updating the entrypoint script and the widget component +- Remove bot protection by clearing the key (empty = disabled) + +## Cloudflare Turnstile (Bot Protection) + +### How it works + +Turnstile protects the **registration** and **contact** forms from bot submissions. It does NOT require Cloudflare DNS/proxy — it works standalone on any domain. + +**Flow:** + +``` +1. Page loads → Turnstile JS loads from challenges.cloudflare.com +2. Widget renders (invisible or interactive depending on risk score) +3. User completes challenge → widget calls onVerify(token) +4. Frontend stores token in state → Sign Up button becomes enabled +5. User submits form → token sent as `turnstile_token` in POST body +6. Backend receives token → POSTs to Cloudflare's siteverify API: + POST https://challenges.cloudflare.com/turnstile/v0/siteverify + Body: { secret: TURNSTILE_SECRET_KEY, response: turnstile_token } +7. Cloudflare returns { success: true/false } +8. If false → 400 "Bot verification failed" +9. If true → registration proceeds normally +``` + +**Where Turnstile is active:** +- Register form (modal overlay on landing page) +- Register form (standalone `/register` page) +- Contact form (landing page) + +**Where it is NOT active (by design):** +- Login — protected by IP-based rate limiting (10 attempts / 15 min) instead + +### Setup + +1. Go to https://dash.cloudflare.com → Turnstile → Add widget +2. Add your domain(s), choose "Managed" widget type +3. Copy the Site Key and Secret Key + +```bash +# frontend/.env +TURNSTILE_SITE_KEY=0x4AAAAAAA... + +# backend/.env +TURNSTILE_SECRET_KEY=0x4AAAAAAA... + +# Restart (no rebuild needed) +docker compose restart frontend backend +``` + +### Testing + +Cloudflare provides test keys for development: + +| Purpose | Site Key | Secret Key | +|---|---|---| +| Always passes | `1x00000000000000000000AA` | `1x0000000000000000000000000000000AA` | +| Always blocks | `2x00000000000000000000AB` | `2x0000000000000000000000000000000AB` | + +### Disabling + +Set both keys to empty strings (or remove them), restart. The widget won't render and the backend skips verification. + +### Runtime Config Architecture + +``` +docker-compose.yml + └─ frontend service: env_file: ./frontend/.env + +Container startup (docker-entrypoint.sh): + └─ Reads $TURNSTILE_SITE_KEY from environment + └─ Writes /usr/share/nginx/html/config.js: + window.__APP_CONFIG__ = { TURNSTILE_SITE_KEY: "0x4AAA..." }; + +Browser loads index.html: + └─ + diff --git a/frontend/nginx.conf b/frontend/nginx.conf index f3a83be..79d8f91 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -35,6 +35,16 @@ server { proxy_read_timeout 600s; } + # Uploaded images proxy to backend + location /uploads/ { + resolver 127.0.0.11 valid=10s; + set $backend http://backend:8000; + proxy_pass $backend; + proxy_set_header Host $host; + expires 7d; + add_header Cache-Control "public, immutable"; + } + # SPA fallback location / { try_files $uri $uri/ /index.html; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6bd82c4..1a338ea 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,7 +12,9 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-router-dom": "^6.22.0" + "react-router-dom": "^6.22.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/react": "^18.2.55", @@ -1537,6 +1539,18 @@ "dev": true, "license": "ISC" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1631,6 +1645,18 @@ "node": ">=6" } }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/estree-util-is-identifier-name": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", @@ -1805,6 +1831,64 @@ "node": ">= 0.4" } }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -1832,6 +1916,25 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.1.tgz", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -1845,6 +1948,23 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -1855,6 +1975,16 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -1981,6 +2111,16 @@ "yallist": "^3.0.2" } }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1990,6 +2130,22 @@ "node": ">= 0.4" } }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-from-markdown": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", @@ -2014,6 +2170,107 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdast-util-mdx-expression": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", @@ -2212,6 +2469,127 @@ "micromark-util-types": "^2.0.0" } }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/micromark-factory-destination": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", @@ -2663,6 +3041,18 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2812,6 +3202,39 @@ "react-dom": ">=16.8" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark-parse": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", @@ -2845,6 +3268,21 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -3113,6 +3551,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vfile-message": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", @@ -3187,6 +3639,16 @@ } } }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 543a58c..8e4be52 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -13,7 +13,9 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-markdown": "^10.1.0", - "react-router-dom": "^6.22.0" + "react-router-dom": "^6.22.0", + "rehype-raw": "^7.0.0", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@types/react": "^18.2.55", diff --git a/frontend/public/config.js b/frontend/public/config.js new file mode 100644 index 0000000..d744707 --- /dev/null +++ b/frontend/public/config.js @@ -0,0 +1,2 @@ +// Fallback for local dev. In production, nginx generates this from env vars. +window.__APP_CONFIG__ = window.__APP_CONFIG__ || {}; diff --git a/frontend/public/icon.svg b/frontend/public/icon.svg new file mode 100644 index 0000000..07ef377 --- /dev/null +++ b/frontend/public/icon.svg @@ -0,0 +1,4 @@ + + + 🏥 + diff --git a/frontend/public/manifest.json b/frontend/public/manifest.json new file mode 100644 index 0000000..b9e35f1 --- /dev/null +++ b/frontend/public/manifest.json @@ -0,0 +1,19 @@ +{ + "name": "PedsHub", + "short_name": "PedsHub", + "description": "Pediatric learning platform — quizzes, AI tutor, and question bank", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait-primary", + "theme_color": "#0f172a", + "background_color": "#0f172a", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..d961c81 --- /dev/null +++ b/frontend/public/sw.js @@ -0,0 +1,12 @@ +// Minimal service worker — network-only, no caching. +// This exists solely to make the app installable as a PWA. +// Do NOT add caching here: the app loads JS modules dynamically +// and stale cache causes blank-screen / module-not-found failures. + +self.addEventListener('install', () => self.skipWaiting()) +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())) + +// Pass every fetch straight through to the network. +self.addEventListener('fetch', event => { + event.respondWith(fetch(event.request)) +}) diff --git a/frontend/src/components/Navbar.jsx b/frontend/src/components/Navbar.jsx index e475494..f4fead5 100644 --- a/frontend/src/components/Navbar.jsx +++ b/frontend/src/components/Navbar.jsx @@ -57,7 +57,7 @@ function JobsBadge({ jobs }) { ) } -export default function Navbar() { +export default function Navbar({ onSignIn, onRegister }) { const { user, logout } = useAuth() const [menuOpen, setMenuOpen] = useState(false) const [jobs, setJobs] = useState([]) @@ -101,7 +101,7 @@ export default function Navbar() {
setMenuOpen(false)}>🏥 PedsHub - {user && ( + {user ? ( <> {/* Desktop nav */}
+ ) : ( + /* Logged-out: Sign In + Register */ +
+ {onSignIn + ? + : Sign In + } + {onRegister + ? + : Register + } +
)} diff --git a/frontend/src/components/TeachChat.jsx b/frontend/src/components/TeachChat.jsx index 524e07a..c082502 100644 --- a/frontend/src/components/TeachChat.jsx +++ b/frontend/src/components/TeachChat.jsx @@ -1,5 +1,7 @@ import { useState, useEffect, useRef } from 'react' import ReactMarkdown from 'react-markdown' +import remarkGfm from 'remark-gfm' +import rehypeRaw from 'rehype-raw' import api from '../api/client' /** @@ -12,7 +14,7 @@ import api from '../api/client' * Mobile — bottom sheet, 70vh * Desktop — right-side panel inside quiz-layout, 320px wide */ -export default function TeachChat({ question }) { +export default function TeachChat({ question, elevated = false }) { const [open, setOpen] = useState(false) const [messages, setMessages] = useState([]) // {role, content, suggestions?} const [input, setInput] = useState('') @@ -84,7 +86,7 @@ export default function TeachChat({ question }) { position: 'fixed', bottom: 80, right: 18, - zIndex: 400, + zIndex: elevated ? 1070 : 400, width: 48, height: 48, borderRadius: '50%', @@ -111,7 +113,7 @@ export default function TeachChat({ question }) { background: 'var(--card-bg)', borderTop: '2px solid var(--primary)', borderRadius: '16px 16px 0 0', - zIndex: 390, + zIndex: elevated ? 1060 : 390, display: 'flex', flexDirection: 'column', boxShadow: '0 -8px 32px rgba(0,0,0,0.18)', @@ -136,7 +138,7 @@ export default function TeachChat({ question }) {
setOpen(false)} - style={{ position: 'fixed', inset: 0, zIndex: 380, background: 'rgba(0,0,0,0.3)' }} + style={{ position: 'fixed', inset: 0, zIndex: elevated ? 1050 : 380, background: 'rgba(0,0,0,0.3)' }} /> )} @@ -198,13 +200,22 @@ export default function TeachChat({ question }) { border: m.role === 'assistant' ? '1px solid var(--border)' : 'none', }}> {m.role === 'assistant' - ?

{children}

, ul: ({children}) =>
    {children}
, ol: ({children}) =>
    {children}
, li: ({children}) =>
  • {children}
  • , strong: ({children}) => {children}, code: ({children}) => {children}, + table: ({children}) => ( +
    + {children}
    +
    + ), + thead: ({children}) => {children}, + th: ({children}) => {children}, + td: ({children}) => {children}, + tr: ({children}) => {children}, }}>{m.content}
    : m.content } diff --git a/frontend/src/context/AuthContext.jsx b/frontend/src/context/AuthContext.jsx index 986cea5..b9676d7 100644 --- a/frontend/src/context/AuthContext.jsx +++ b/frontend/src/context/AuthContext.jsx @@ -19,8 +19,8 @@ export function AuthProvider({ children }) { } }, []) - const login = async (email, password) => { - const res = await api.post('/auth/login', { email, password }) + const login = async (email, password, turnstileToken) => { + const res = await api.post('/auth/login', { email, password, turnstile_token: turnstileToken || null }) localStorage.setItem('token', res.data.access_token) const me = await api.get('/auth/me') setUser(me.data) diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index 9dd44ae..f70ab74 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -5,7 +5,7 @@ import api from '../api/client' import Dialog from '../components/Dialog' import { useDialog } from '../hooks/useDialog' -const TASKS = ['extraction', 'tts', 'general', 'teach'] +const TASKS = ['extraction', 'tts', 'teach', 'keyword'] export default function AdminPage() { const { user } = useAuth() @@ -58,8 +58,8 @@ export default function AdminPage() { loadData() }, [user]) - const loadData = async () => { - setLoading(true) + const loadData = async (showSpinner = true) => { + if (showSpinner) setLoading(true) try { const [usersRes, modelsRes, settingsRes] = await Promise.all([ api.get('/admin/users'), @@ -81,17 +81,29 @@ export default function AdminPage() { try { await api.put(`/admin/users/${userId}/role`, { role }) setSuccess(`Role updated to ${role}`) - loadData() + loadData(false) } catch (err) { setError(err.response?.data?.detail || 'Failed to update role') } } + const deleteUser = async (userId, userName) => { + const ok = await openConfirm(`Delete user "${userName}"? This removes all their data (attempts, favorites, settings).`, { title: 'Delete User', confirmLabel: 'Delete', danger: true }) + if (!ok) return + try { + await api.delete(`/admin/users/${userId}`) + setSuccess(`User "${userName}" deleted`) + loadData(false) + } catch (err) { + setError(err.response?.data?.detail || 'Failed to delete user') + } + } + const toggleUnthrottle = async (userId, currentVal) => { try { await api.put(`/admin/users/${userId}/unthrottle`, { unthrottled: !currentVal }) setSuccess(currentVal ? 'Rate limits restored for user' : 'Rate limits removed for user') - loadData() + loadData(false) } catch (err) { setError(err.response?.data?.detail || 'Failed to update throttle setting') } @@ -104,7 +116,7 @@ export default function AdminPage() { await api.post('/admin/models', newModel) setSuccess('Model added') setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false }) - loadData() + loadData(false) } catch (err) { setError(err.response?.data?.detail || 'Failed to add model') } @@ -114,7 +126,7 @@ export default function AdminPage() { try { await api.put(`/admin/models/${modelId}`, { is_default: true }) setSuccess('Default model updated') - loadData() + loadData(false) } catch (err) { setError(err.response?.data?.detail || 'Failed to update') } @@ -123,7 +135,7 @@ export default function AdminPage() { const toggleActive = async (model) => { try { await api.put(`/admin/models/${model.id}`, { is_active: !model.is_active }) - loadData() + loadData(false) } catch (err) { setError(err.response?.data?.detail || 'Failed to update') } @@ -134,7 +146,7 @@ export default function AdminPage() { if (!ok) return try { await api.delete(`/admin/models/${modelId}`) - loadData() + loadData(false) } catch (err) { setError(err.response?.data?.detail || 'Failed to delete') } @@ -337,7 +349,7 @@ export default function AdminPage() {
    @@ -561,7 +573,7 @@ export default function AdminPage() { await api.post('/admin/users', newUser) setSuccess(`User ${newUser.email} created`) setNewUser({ name: '', email: '', password: '' }) - loadData() + loadData(false) } catch (err) { setError(err.response?.data?.detail || 'Failed to create user') } }}>
    @@ -615,6 +627,15 @@ export default function AdminPage() { > {u.is_unthrottled ? '🔒 Throttle' : '🚀 Unlimited'} + {u.id !== user?.id && ( + + )}
    ))} diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 0b3a49e..68204dc 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -19,6 +19,23 @@ export default function DashboardPage() { const [history, setHistory] = useState([]) const [selectedQuizId, setSelectedQuizId] = useState(null) const [loading, setLoading] = useState(true) + const [deletingAttempt, setDeletingAttempt] = useState(null) + + const [confirmAttempt, setConfirmAttempt] = useState(null) + + const deleteAttempt = async (attemptId) => { + if (confirmAttempt !== attemptId) { setConfirmAttempt(attemptId); return } + setDeletingAttempt(attemptId) + setConfirmAttempt(null) + try { + await api.delete(`/attempts/${attemptId}`) + setHistory(prev => prev.map(q => ({ + ...q, + attempts: q.attempts.filter(a => a.attempt_id !== attemptId), + })).filter(q => q.attempts.length > 0)) + } catch { } + finally { setDeletingAttempt(null) } + } useEffect(() => { const promises = [ @@ -33,7 +50,7 @@ export default function DashboardPage() { if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id) }).catch(console.error) .finally(() => setLoading(false)) - }, []) + }, [isModerator]) if (loading) return
    Loading...
    @@ -98,6 +115,44 @@ export default function DashboardPage() { ⚠️ Below 75% — a reminder will be sent to review this quiz

    )} + {/* Attempt rows with delete */} + {selectedQuiz.attempts.length > 0 && ( +
    + {[...selectedQuiz.attempts].reverse().map(a => ( +
    + {new Date(a.date).toLocaleDateString()} + + {a.percentage}% ({a.score}/{a.total}) + + {confirmAttempt === a.attempt_id ? ( + <> + + + + ) : ( + + )} +
    + ))} +
    + )} )} diff --git a/frontend/src/pages/DocumentDetailPage.jsx b/frontend/src/pages/DocumentDetailPage.jsx index 99608df..d896990 100644 --- a/frontend/src/pages/DocumentDetailPage.jsx +++ b/frontend/src/pages/DocumentDetailPage.jsx @@ -141,22 +141,27 @@ export default function DocumentDetailPage() { api.get('/question-categories/').catch(() => ({ data: [] })), ]).then(([modelsRes, catsRes]) => { setAvailableModels(modelsRes.data) - const def = modelsRes.data.find(m => m.is_default) + const def = modelsRes.data.find(m => m.is_default) || modelsRes.data[0] if (def) setSelectedModelId(def.model_id) setQuestionCategories(catsRes.data) }) }, [id]) + const [processingSteps, setProcessingSteps] = useState([]) useEffect(() => { if (!doc || doc.status !== 'processing') return const interval = setInterval(() => { - api.get(`/documents/${id}/status`).then(res => { - if (res.data.status !== 'processing') { + Promise.all([ + api.get(`/documents/${id}/status`), + api.get(`/documents/${id}/processing-steps`).catch(() => ({ data: { steps: [] } })), + ]).then(([statusRes, stepsRes]) => { + setProcessingSteps(stepsRes.data.steps || []) + if (statusRes.data.status !== 'processing') { fetchDoc() clearInterval(interval) } }) - }, 3000) + }, 2000) return () => clearInterval(interval) }, [doc?.status]) @@ -275,9 +280,20 @@ export default function DocumentDetailPage() { {doc.status === 'processing' && ( -
    -
    -

    Processing PDF and extracting text... This may take a while for large files.

    +
    +
    +
    + Processing PDF… +
    + {processingSteps.length > 0 && ( +
    + {processingSteps.map((s, i) => ( +
    + {s.step === 'error' ? '✗' : s.step === 'done' ? '✓' : '›'} {s.message} +
    + ))} +
    + )}
    )} {doc.status === 'error' && ( @@ -368,6 +384,7 @@ export default function DocumentDetailPage() { +

    {extractionMode === 'standard' && 'Best for PREP 2012, 2014 and most PDFs with answers inline.'} @@ -375,6 +392,7 @@ export default function DocumentDetailPage() { {extractionMode === 'two_step' && 'For PDFs where all questions come first, then all answers at the back (PREP 2013 style).'} {extractionMode === 'regex' && 'AI detects the answer pattern, then uses regex for fast reliable extraction.'} {extractionMode === 'ai_decide' && 'AI samples the document and automatically picks the right strategy.'} + {extractionMode === 'generate' && 'For textbook chapters, lecture notes, or any material without a Q&A format. AI creates MCQ questions with correct answers from the text.'}

    {availableModels.length > 1 && ( diff --git a/frontend/src/pages/LandingPage.jsx b/frontend/src/pages/LandingPage.jsx index c30b80d..42af2e7 100644 --- a/frontend/src/pages/LandingPage.jsx +++ b/frontend/src/pages/LandingPage.jsx @@ -1,20 +1,27 @@ import { useState, useEffect, useRef } from 'react' -import { Link } from 'react-router-dom' +import { Link, useNavigate } from 'react-router-dom' import api from '../api/client' import { useAuth } from '../context/AuthContext' +import Navbar from '../components/Navbar' // ── Turnstile widget ────────────────────────────────────────────────────────── -const TURNSTILE_SITE_KEY = import.meta.env.VITE_TURNSTILE_SITE_KEY || '' +const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || '' function TurnstileWidget({ onVerify }) { const ref = useRef(null) + const widgetId = useRef(null) useEffect(() => { - if (!TURNSTILE_SITE_KEY || !window.turnstile) return - const id = window.turnstile.render(ref.current, { - sitekey: TURNSTILE_SITE_KEY, - callback: onVerify, - }) - return () => { try { window.turnstile.remove(id) } catch {} } + if (!TURNSTILE_SITE_KEY) return + let timer + const tryRender = () => { + if (!window.turnstile || !ref.current) { timer = setTimeout(tryRender, 200); return } + widgetId.current = window.turnstile.render(ref.current, { + sitekey: TURNSTILE_SITE_KEY, + callback: onVerify, + }) + } + tryRender() + return () => { clearTimeout(timer); if (widgetId.current != null) try { window.turnstile.remove(widgetId.current) } catch {} } }, []) if (!TURNSTILE_SITE_KEY) return null return
    @@ -157,11 +164,176 @@ function ContactForm() { ) } +// ── Auth modal (Sign In / Register as overlay) ────────────────────────────── +function AuthModal({ mode, onClose, onSwitch }) { + const { login, loginWithToken } = useAuth() + const navigate = useNavigate() + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState('') + const [loading, setLoading] = useState(false) + const [unverified, setUnverified] = useState(false) + const [turnstileToken, setTurnstileToken] = useState('') + const [resendSent, setResendSent] = useState(false) + const [resending, setResending] = useState(false) + const [registered, setRegistered] = useState(false) + + const reset = () => { setError(''); setUnverified(false); setResendSent(false); setRegistered(false) } + const switchMode = (m) => { reset(); setName(''); setEmail(''); setPassword(''); onSwitch(m) } + + const handleLogin = async (e) => { + e.preventDefault() + setError(''); setUnverified(false) + setLoading(true) + try { + await login(email, password, turnstileToken) + onClose() + navigate('/') + } catch (err) { + if (err.response?.status === 403) setUnverified(true) + else setError(err.response?.data?.detail || 'Login failed') + } finally { setLoading(false) } + } + + const handleRegister = async (e) => { + e.preventDefault() + setError('') + setLoading(true) + try { + const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null }) + if (res.data.requires_verification) { + setRegistered(true) + } else { + await loginWithToken(res.data.access_token) + onClose() + navigate('/') + } + } catch (err) { + setError(err.response?.data?.detail || 'Registration failed') + } finally { setLoading(false) } + } + + const resendVerification = async () => { + setResending(true) + try { await api.post('/auth/resend-verification', { email }) } catch {} + setResendSent(true) + setResending(false) + } + + const isLogin = mode === 'login' + + return ( +
    +
    e.stopPropagation()} style={{ + background: 'var(--card-bg)', borderRadius: 'var(--card-radius, 14px)', + padding: '28px 24px', maxWidth: 400, width: '100%', + boxShadow: '0 20px 60px rgba(0,0,0,0.3)', position: 'relative', + }}> + + + {/* Tabs */} +
    + {[['login', 'Sign In'], ['register', 'Register']].map(([m, label]) => ( + + ))} +
    + + {/* Registration success */} + {registered && ( +
    +
    📧
    +

    Check your email

    +

    + We sent a verification link to {email}. Click it to activate your account. +

    + +
    + )} + + {/* Login form */} + {isLogin && !registered && ( + <> + {unverified && !resendSent && ( +
    +

    + Email not verified. Check your inbox. +

    + +
    + )} + {resendSent &&
    Verification email sent.
    } + {error &&
    {error}
    } +
    +
    + + setEmail(e.target.value)} required autoFocus /> +
    +
    + + setPassword(e.target.value)} required /> +
    + + + +
    + Forgot password? +
    + + )} + + {/* Register form */} + {!isLogin && !registered && ( + <> + {error &&
    {error}
    } +
    +
    + + setName(e.target.value)} required autoFocus /> +
    +
    + + setEmail(e.target.value)} required /> +
    +
    + + setPassword(e.target.value)} required minLength={8} /> +
    + + + + + )} +
    +
    + ) +} + // ── Main landing page ───────────────────────────────────────────────────────── -const navLink = { fontSize: '0.85rem', color: '#94a3b8', textDecoration: 'none', padding: '6px 10px', borderRadius: 6 } export default function LandingPage() { const { user } = useAuth() + const [authModal, setAuthModal] = useState(null) // null | 'login' | 'register' // Load Turnstile script once useEffect(() => { @@ -176,37 +348,23 @@ export default function LandingPage() { return (
    - {/* ── Navbar ─────────────────────────────────────────────────────────── */} - + {/* Auth modal overlay */} + {authModal && ( + setAuthModal(null)} + onSwitch={setAuthModal} + /> + )} + + {/* ── Shared Navbar (handles auth state) ─────────────────────────────── */} + + setAuthModal('login')} onRegister={() => setAuthModal('register')} /> {/* ── Hero ───────────────────────────────────────────────────────────── */}
    -

    +

    Upload any PDF — PREP materials, lecture notes, textbook chapters. AI extracts questions, reads them aloud, and explains every answer.

    {user ? <> Dashboard - My Quizzes - Question Bank + My Quizzes + Question Bank : <> - Sign In - Request Access + + }
    @@ -351,19 +509,19 @@ export default function LandingPage() { {/* ── Footer ─────────────────────────────────────────────────────────── */}
    - - PedsHub + + 🏥 PedsHub © {new Date().getFullYear()}
    - Sign In - Contact - AI Scribe + + Contact + AI Scribe
    diff --git a/frontend/src/pages/LoginPage.jsx b/frontend/src/pages/LoginPage.jsx index 7942bcb..e0094d3 100644 --- a/frontend/src/pages/LoginPage.jsx +++ b/frontend/src/pages/LoginPage.jsx @@ -1,8 +1,34 @@ -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { useNavigate, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' +const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || '' + +function TurnstileWidget({ onVerify }) { + const ref = useRef(null) + const widgetId = useRef(null) + useEffect(() => { + if (!TURNSTILE_SITE_KEY) return + if (!document.getElementById('cf-turnstile-script')) { + const s = document.createElement('script') + s.id = 'cf-turnstile-script' + s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js' + s.async = true + document.head.appendChild(s) + } + let timer + const tryRender = () => { + if (!window.turnstile || !ref.current) { timer = setTimeout(tryRender, 200); return } + widgetId.current = window.turnstile.render(ref.current, { sitekey: TURNSTILE_SITE_KEY, callback: onVerify }) + } + tryRender() + return () => { clearTimeout(timer); if (widgetId.current != null) try { window.turnstile.remove(widgetId.current) } catch {} } + }, []) + if (!TURNSTILE_SITE_KEY) return null + return
    +} + export default function LoginPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') @@ -11,6 +37,7 @@ export default function LoginPage() { const [unverified, setUnverified] = useState(false) const [resendSent, setResendSent] = useState(false) const [resending, setResending] = useState(false) + const [turnstileToken, setTurnstileToken] = useState('') const { login } = useAuth() const navigate = useNavigate() @@ -20,7 +47,7 @@ export default function LoginPage() { setUnverified(false) setLoading(true) try { - await login(email, password) + await login(email, password, turnstileToken) navigate('/') } catch (err) { if (err.response?.status === 403) { @@ -78,7 +105,8 @@ export default function LoginPage() { setPassword(e.target.value)} required />
    - diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index ae956f0..c3bdc1a 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -1,52 +1,67 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, lazy, Suspense } from 'react' import { useNavigate } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' import Dialog from '../components/Dialog' import { useDialog } from '../hooks/useDialog' +const TeachChat = lazy(() => import('../components/TeachChat')) + function QuestionStudyModal({ question, onClose }) { const [answered, setAnswered] = useState(null) return ( -
    e.target === e.currentTarget && onClose()}> -
    -
    - - {question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''} - - -
    -

    {question.question_text}

    - {question.options && ( -
    - {question.options.map((opt, i) => { - const isSelected = answered === opt - const hasAnswered = !!answered - const isCorrectOpt = opt === question.correct_answer - return ( -
    !hasAnswered && setAnswered(opt)} - style={{ cursor: hasAnswered ? 'default' : 'pointer' }}> - {String.fromCharCode(65 + i)} - {opt} - {hasAnswered && isCorrectOpt && ✓ {isSelected ? 'Your answer' : 'Correct'}} - {hasAnswered && isSelected && !isCorrectOpt && ✗ Wrong} -
    - ) - })} + <> +
    e.target === e.currentTarget && onClose()}> +
    +
    + + {question.quiz_title}{question.question_category_name ? ` · ${question.question_category_name}` : ''} + +
    - )} - {answered && question.explanation && ( -
    Explanation:
    {question.explanation}
    - )} - {answered &&
    - - -
    } +

    {question.question_text}

    + {question.image_path && ( +
    + Question illustration e.target.style.display = 'none'} /> +
    + )} + {question.options && ( +
    + {question.options.map((opt, i) => { + const isSelected = answered === opt + const hasAnswered = !!answered + const isCorrectOpt = opt === question.correct_answer + return ( +
    !hasAnswered && setAnswered(opt)} + style={{ cursor: hasAnswered ? 'default' : 'pointer' }}> + {String.fromCharCode(65 + i)} + {opt} + {hasAnswered && isCorrectOpt && ✓ {isSelected ? 'Your answer' : 'Correct'}} + {hasAnswered && isSelected && !isCorrectOpt && ✗ Wrong} +
    + ) + })} +
    + )} + {answered && question.explanation && ( +
    Explanation:
    {question.explanation}
    + )} + {answered &&
    + + +
    } +
    -
    + {/* AI Tutor — z-index above the modal */} + + + + ) } @@ -120,6 +135,53 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) { ) } +function TagBrowser({ tags, selectedTagIds, toggleTag }) { + const [search, setSearch] = useState({ subjects: '', diseases: '', keywords: '' }) + const sections = [ + { key: 'subjects', label: 'Subjects', color: '#8b5cf6', items: tags.subjects || [] }, + { key: 'diseases', label: 'Diseases', color: '#ef4444', items: tags.diseases || [] }, + { key: 'keywords', label: 'Keywords', color: '#0ea5e9', items: tags.keywords || [] }, + ] + return ( +
    + {sections.map(sec => { + if (sec.items.length === 0) return null + const q = search[sec.key].toLowerCase() + const filtered = q ? sec.items.filter(t => t.name.toLowerCase().includes(q)) : sec.items + return ( +
    +
    + {sec.label} ({sec.items.length}) +
    + setSearch(s => ({ ...s, [sec.key]: e.target.value }))} + style={{ width: '100%', padding: '5px 8px', border: '1px solid var(--border)', borderRadius: 6, fontSize: '0.78rem', background: 'var(--input-bg)', color: 'var(--text)', marginBottom: 6, boxSizing: 'border-box' }} /> +
    + {filtered.map(tag => { + const selected = selectedTagIds.includes(tag.id) + return ( + + ) + })} + {filtered.length === 0 &&
    No matches
    } +
    +
    + ) + })} +
    + ) +} + const LETTERS = ['A','B','C','D','E','F'] function QuestionEditModal({ question, categories, onSaved, onClose }) { @@ -228,7 +290,7 @@ export default function QuestionBankPage() { const [categories, setCategories] = useState([]) const [searchQuery, setSearchQuery] = useState('') const [searchMode, setSearchMode] = useState('hybrid') - const [filterCatId, setFilterCatId] = useState('') + const [filterCatIds, setFilterCatIds] = useState([]) const [showUncategorized, setShowUncategorized] = useState(false) const [showFavorites, setShowFavorites] = useState(false) const [favorites, setFavorites] = useState([]) @@ -242,24 +304,37 @@ export default function QuestionBankPage() { const [assignCatId, setAssignCatId] = useState('') const [bulkError, setBulkError] = useState('') const [pageSize, setPageSize] = useState(50) + const [tags, setTags] = useState({ subjects: [], diseases: [], keywords: [] }) + const [selectedTagIds, setSelectedTagIds] = useState([]) + const [showTags, setShowTags] = useState(false) + const [classifyJobId, setClassifyJobId] = useState(null) + const [classifyStatus, setClassifyStatus] = useState(null) + const [classifySteps, setClassifySteps] = useState([]) const debounceRef = useRef(null) + const classifyPollRef = useRef(null) const { user } = useAuth() const isModerator = user?.role === 'admin' || user?.role === 'moderator' const LIMIT = pageSize + const loadTags = () => { + api.get('/tags').then(res => setTags(res.data)).catch(() => {}) + } + useEffect(() => { api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {}) api.get('/favorites').then(res => setFavorites(res.data)).catch(() => {}) + loadTags() }, []) - const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, favOnly = showFavorites, mode = searchMode, size = pageSize) => { + const loadQuestions = async (query = searchQuery, off = 0, catIds = filterCatIds, uncatOnly = showUncategorized, favOnly = showFavorites, mode = searchMode, size = pageSize, tagIds = selectedTagIds) => { setLoading(true) try { const params = { limit: size === 'all' ? 5000 : size, offset: off, search_mode: mode } if (query.trim()) params.q = query.trim() - if (catId) params.category_id = parseInt(catId) + if (catIds.length > 0) params.category_ids = catIds.join(',') if (uncatOnly) params.uncategorized = true if (favOnly) params.favorites_only = true + if (tagIds.length > 0) params.tag_ids = tagIds.join(',') const res = await api.get('/questions/bank', { params }) setQuestions(off === 0 ? res.data.questions : prev => [...prev, ...res.data.questions]) setTotal(res.data.total) @@ -267,11 +342,49 @@ export default function QuestionBankPage() { } catch { } finally { setLoading(false) } } + const tagIdsKey = selectedTagIds.join(',') + const catIdsKey = filterCatIds.join(',') useEffect(() => { clearTimeout(debounceRef.current) - debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, showFavorites, searchMode, pageSize), 300) + debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatIds, showUncategorized, showFavorites, searchMode, pageSize, selectedTagIds), 300) return () => clearTimeout(debounceRef.current) - }, [searchQuery, filterCatId, showUncategorized, showFavorites, searchMode, pageSize]) + }, [searchQuery, catIdsKey, showUncategorized, showFavorites, searchMode, pageSize, tagIdsKey]) + + const toggleTag = (tagId) => { + setSelectedTagIds(prev => { + const n = prev.includes(tagId) ? prev.filter(id => id !== tagId) : [...prev, tagId] + return n + }) + } + + const startClassification = async () => { + try { + const res = await api.post('/tags/classify') + setClassifyJobId(res.data.job_id) + setClassifyStatus('running') + setClassifySteps([]) + // Start polling + if (classifyPollRef.current) clearInterval(classifyPollRef.current) + classifyPollRef.current = setInterval(async () => { + try { + const s = await api.get(`/tags/status/${res.data.job_id}`) + setClassifyStatus(s.data.status) + setClassifySteps(s.data.steps || []) + if (s.data.status === 'completed' || s.data.status === 'failed') { + clearInterval(classifyPollRef.current) + classifyPollRef.current = null + if (s.data.status === 'completed') loadTags() + } + } catch { } + }, 2000) + } catch (err) { + await openAlert(err.response?.data?.detail || 'Failed to start classification', { title: 'Error' }) + } + } + + useEffect(() => { + return () => { if (classifyPollRef.current) clearInterval(classifyPollRef.current) } + }, []) const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n }) @@ -280,9 +393,10 @@ export default function QuestionBankPage() { try { const params = {} if (searchQuery.trim()) params.q = searchQuery.trim() - if (filterCatId) params.category_id = parseInt(filterCatId) + if (filterCatIds.length > 0) params.category_ids = filterCatIds.join(',') if (showUncategorized) params.uncategorized = true if (showFavorites) params.favorites_only = true + if (selectedTagIds.length > 0) params.tag_ids = selectedTagIds.join(',') const res = await api.get('/questions/bank/ids', { params }) setSelectedIds(new Set(res.data)) } catch { @@ -333,8 +447,8 @@ export default function QuestionBankPage() { try { await api.delete(`/question-categories/${catId}`, { params: moveTo ? { move_to: moveTo } : {} }) setCategories(prev => prev.filter(c => c.id !== catId)) - if (parseInt(filterCatId) === catId) setFilterCatId('') - loadQuestions(searchQuery, 0, '', showUncategorized, showFavorites) + setFilterCatIds(prev => prev.filter(c => c !== catId)) + loadQuestions(searchQuery, 0, filterCatIds.filter(c => c !== catId), showUncategorized, showFavorites) } catch { } } @@ -424,25 +538,82 @@ export default function QuestionBankPage() { {/* Category filter chips */}
    Filter: - + + onClick={() => { setShowFavorites(v => !v); setFilterCatIds([]); setShowUncategorized(false) }}>⭐ Favorites ({favorites.length}) - {categories.map(cat => ( -
    - + {categories.map(cat => { + const isActive = filterCatIds.includes(cat.id) + return ( +
    + + {isModerator && } +
    + ) + })} + {/* Active tag chips — shown inline with categories */} + {selectedTagIds.map(tid => { + const allTags = [...(tags.subjects || []), ...(tags.diseases || []), ...(tags.keywords || [])] + const tag = allTags.find(t => t.id === tid) + if (!tag) return null + const color = (tags.subjects || []).find(t => t.id === tid) ? '#8b5cf6' : (tags.diseases || []).find(t => t.id === tid) ? '#ef4444' : '#0ea5e9' + return ( + - {isModerator && } -
    - ))} + ) + })} + {selectedTagIds.length > 0 && ( + + )}
    + {/* Tag browser — collapsible */} + {(tags.subjects?.length > 0 || tags.diseases?.length > 0 || tags.keywords?.length > 0) && ( +
    + + {showTags && } +
    + )} + + {/* Classify + status */} + {isModerator && ( +
    +
    + + {classifyStatus && ( + + {classifyStatus === 'completed' ? 'Done' : classifyStatus === 'failed' ? 'Failed' : classifyStatus === 'running' ? 'Running...' : ''} + + )} +
    + {classifySteps.length > 0 && classifyStatus === 'running' && ( +
    + {classifySteps.slice(-5).map((s, i) => ( +
    {s.message}
    + ))} +
    + )} +
    + )} + {/* Search row */}
    @@ -564,7 +735,7 @@ export default function QuestionBankPage() { {questions.length < total && (
    -
    diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 27bdda5..e7211b2 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -128,6 +128,9 @@ function ModeSelectScreen({ quiz, voices, onStart }) { ) } +// Unique session ID per tab — used to prevent concurrent resume on multiple devices +const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36) + export default function QuizPage() { const { id } = useParams() const navigate = useNavigate() @@ -172,13 +175,21 @@ export default function QuizPage() { try { const quizRes = await api.get(`/quizzes/${id}?study=true`) setQuiz(quizRes.data) - } catch { } + } catch (err) { + console.error('Failed to load quiz:', err) + navigate('/') + return + } } else { // For exam mode, ensure quiz data is loaded try { const quizRes = await api.get(`/quizzes/${id}`) setQuiz(quizRes.data) - } catch { } + } catch (err) { + console.error('Failed to load quiz:', err) + navigate('/') + return + } } hasStarted.current = true @@ -224,11 +235,18 @@ export default function QuizPage() { // Check for saved progress and auto-resume try { - const progressRes = await api.get('/attempts/progress', { params: { quiz_id: id } }) + const progressRes = await api.get('/attempts/progress', { + params: { quiz_id: id }, + headers: { 'x-quiz-session': SESSION_ID }, + }) if (progressRes.data) { await resumeQuiz(progressRes.data) } - } catch {} + } catch (err) { + if (err.response?.status === 409) { + showToast(err.response.data.detail || 'Quiz active on another device') + } + } } catch { navigate('/') } finally { setLoading(false) } } @@ -268,14 +286,19 @@ export default function QuizPage() { finally { setStarting(false) } } -useEffect(() => { - if (timeLeft === null) return - if (timeLeft <= 0) { handleSubmit(true); return } +const timerStarted = timeLeft !== null + useEffect(() => { + if (!timerStarted) return timerRef.current = setInterval(() => { setTimeLeft(t => { if (t <= 1) { clearInterval(timerRef.current); return 0 } return t - 1 }) }, 1000) return () => clearInterval(timerRef.current) - }, [timeLeft === null]) + }, [timerStarted]) + + // Auto-submit when timer reaches zero + useEffect(() => { + if (timeLeft === 0) handleSubmit(true) + }, [timeLeft]) // Save progress to Redis (survives logout/browser change) const saveProgressRef = useRef(null) @@ -293,7 +316,7 @@ useEffect(() => { time_left: timeLeft, started_at: startedAt, total_time: totalTime, - }).catch(() => {}) + }, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {}) }, 1500) // debounce 1.5s return () => clearTimeout(saveProgressRef.current) }, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime]) @@ -322,7 +345,7 @@ useEffect(() => { } catch (err) { if (!autoSubmit) showToast(err.response?.data?.detail || 'Submission failed') } finally { setSubmitting(false) } - }, [attemptId, answers, submitting]) + }, [attemptId, answers, submitting, navigate, showToast]) if (loading) return
    Loading quiz...
    if (!quiz) return null diff --git a/frontend/src/pages/QuizzesPage.jsx b/frontend/src/pages/QuizzesPage.jsx index 3d0e546..13634ef 100644 --- a/frontend/src/pages/QuizzesPage.jsx +++ b/frontend/src/pages/QuizzesPage.jsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useRef } from 'react' +import { useState, useEffect, useRef, lazy, Suspense } from 'react' import { useNavigate, Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' @@ -6,6 +6,8 @@ import ConfirmButton from '../components/ConfirmButton' import Dialog from '../components/Dialog' import { useDialog } from '../hooks/useDialog' +const TeachChat = lazy(() => import('../components/TeachChat')) + function InProgressSection() { const [inProgress, setInProgress] = useState([]) const navigate = useNavigate() @@ -56,6 +58,7 @@ function PastAttemptsSection() { const [open, setOpen] = useState(false) const [history, setHistory] = useState(null) const [loading, setLoading] = useState(false) + const [deletingId, setDeletingId] = useState(null) const load = async () => { if (history !== null) { setOpen(v => !v); return } @@ -67,6 +70,19 @@ function PastAttemptsSection() { finally { setLoading(false); setOpen(true) } } + const deleteAttempt = async (attemptId) => { + setDeletingId(attemptId) + try { + await api.delete(`/attempts/${attemptId}`) + setHistory(prev => + prev + .map(q => ({ ...q, attempts: q.attempts.filter(a => a.attempt_id !== attemptId) })) + .filter(q => q.attempts.length > 0) + ) + } catch { } + finally { setDeletingId(null) } + } + const totalCompleted = history?.reduce((s, q) => s + q.attempts.length, 0) ?? 0 return ( @@ -92,13 +108,13 @@ function PastAttemptsSection() {
    {quiz.title}
    - {quiz.attempts.map((a, i) => ( + {quiz.attempts.map((a) => (
    - + {new Date(a.date).toLocaleDateString()} — {a.score}/{a.total} Review → +
    ))}
    @@ -135,47 +166,60 @@ function HighlightText({ text, query }) { function QuestionStudyModal({ question, query, onClose }) { const [answered, setAnswered] = useState(null) return ( -
    e.target === e.currentTarget && onClose()}> -
    -
    - Study Question - + <> +
    e.target === e.currentTarget && onClose()}> +
    +
    + Study Question + +
    +

    + +

    + {question.image_path && ( +
    + Question illustration e.target.style.display = 'none'} /> +
    + )} + {question.options && ( +
    + {question.options.map((opt, i) => { + const isSelected = answered === opt + const hasAnswered = !!answered + const isCorrectOpt = opt === question.correct_answer + return ( +
    !hasAnswered && setAnswered(opt)} + style={{ cursor: hasAnswered ? 'default' : 'pointer' }}> + {String.fromCharCode(65 + i)} + + {hasAnswered && isCorrectOpt && ✓ {isSelected ? 'Your answer' : 'Correct'}} + {hasAnswered && isSelected && !isCorrectOpt && ✗ Wrong} +
    + ) + })} +
    + )} + {answered && question.explanation && ( +
    Explanation:
    {question.explanation}
    + )} + {answered && ( +
    + + +
    + )}
    -

    - -

    - {question.options && ( -
    - {question.options.map((opt, i) => { - const isSelected = answered === opt - const hasAnswered = !!answered - const isCorrectOpt = opt === question.correct_answer - return ( -
    !hasAnswered && setAnswered(opt)} - style={{ cursor: hasAnswered ? 'default' : 'pointer' }}> - {String.fromCharCode(65 + i)} - - {hasAnswered && isCorrectOpt && ✓ {isSelected ? 'Your answer' : 'Correct'}} - {hasAnswered && isSelected && !isCorrectOpt && ✗ Wrong} -
    - ) - })} -
    - )} - {answered && question.explanation && ( -
    Explanation:
    {question.explanation}
    - )} - {answered && ( -
    - - -
    - )}
    -
    + {/* AI Tutor — z-index above the modal */} + + + + ) } diff --git a/frontend/src/pages/RegisterPage.jsx b/frontend/src/pages/RegisterPage.jsx index 7f44d19..b2f8af3 100644 --- a/frontend/src/pages/RegisterPage.jsx +++ b/frontend/src/pages/RegisterPage.jsx @@ -1,8 +1,34 @@ -import { useState } from 'react' +import { useState, useEffect, useRef } from 'react' import { Link } from 'react-router-dom' import { useAuth } from '../context/AuthContext' import api from '../api/client' +const TURNSTILE_SITE_KEY = window.__APP_CONFIG__?.TURNSTILE_SITE_KEY || '' + +function TurnstileWidget({ onVerify }) { + const ref = useRef(null) + useEffect(() => { + if (!TURNSTILE_SITE_KEY) return + // Load script if needed + if (!document.getElementById('cf-turnstile-script')) { + const s = document.createElement('script') + s.id = 'cf-turnstile-script' + s.src = 'https://challenges.cloudflare.com/turnstile/v0/api.js' + s.async = true + document.head.appendChild(s) + } + const tryRender = () => { + if (!window.turnstile || !ref.current) return setTimeout(tryRender, 200) + const id = window.turnstile.render(ref.current, { sitekey: TURNSTILE_SITE_KEY, callback: onVerify }) + return () => { try { window.turnstile.remove(id) } catch {} } + } + const cleanup = tryRender() + return () => { if (typeof cleanup === 'function') cleanup() } + }, []) + if (!TURNSTILE_SITE_KEY) return null + return
    +} + export default function RegisterPage() { const [name, setName] = useState('') const [email, setEmail] = useState('') @@ -10,6 +36,7 @@ export default function RegisterPage() { const [error, setError] = useState('') const [loading, setLoading] = useState(false) const [done, setDone] = useState(false) + const [turnstileToken, setTurnstileToken] = useState('') const { loginWithToken } = useAuth() const handleSubmit = async (e) => { @@ -17,7 +44,7 @@ export default function RegisterPage() { setError('') setLoading(true) try { - const res = await api.post('/auth/register', { email, password, name }) + const res = await api.post('/auth/register', { email, password, name, turnstile_token: turnstileToken || null }) if (res.data.requires_verification) { setDone(true) } else { @@ -71,7 +98,8 @@ export default function RegisterPage() { setPassword(e.target.value)} required minLength={8} />
    - diff --git a/frontend/src/pages/ResultsPage.jsx b/frontend/src/pages/ResultsPage.jsx index 30528a9..a6bde58 100644 --- a/frontend/src/pages/ResultsPage.jsx +++ b/frontend/src/pages/ResultsPage.jsx @@ -8,12 +8,26 @@ export default function ResultsPage() { const navigate = useNavigate() const [result, setResult] = useState(location.state?.result || null) const [loading, setLoading] = useState(!result) + const [deleting, setDeleting] = useState(false) + const [confirmDelete, setConfirmDelete] = useState(false) + + const deleteAttempt = async () => { + if (!confirmDelete) { setConfirmDelete(true); return } + setDeleting(true) + try { + await api.delete(`/attempts/${result.id}`) + navigate('/quizzes') + } catch { + setDeleting(false) + setConfirmDelete(false) + } + } useEffect(() => { if (!result) { api.get(`/attempts/${id}`) .then(res => setResult(res.data)) - .catch(() => navigate('/')) + .catch(() => navigate('/quizzes')) .finally(() => setLoading(false)) } }, [id]) @@ -60,6 +74,27 @@ export default function ResultsPage() { Retake Quiz All Quizzes Dashboard + {confirmDelete ? ( + <> + + + + ) : ( + + )}
    diff --git a/frontend/src/pages/UploadPage.jsx b/frontend/src/pages/UploadPage.jsx index 7873070..b26d4d2 100644 --- a/frontend/src/pages/UploadPage.jsx +++ b/frontend/src/pages/UploadPage.jsx @@ -127,24 +127,19 @@ export default function UploadPage() { const hasNextcloud = !!localStorage.getItem('nc_username') - const handleFile = (f, fromNextcloud = false) => { - if (f && f.type === 'application/pdf') { - setFile(f); setError('') - if (!fromNextcloud) setTab('local') - } else { - setError('Please select a PDF file') - } - } - - const handleUpload = async () => { - if (!file) return - setUploading(true); setError('') + const [uploadName, setUploadName] = useState('') + const doUpload = async (f) => { + if (!f) return + setUploadName(f.name) + setUploading(true); setError(''); setProgress(0) const formData = new FormData() - formData.append('file', file) + formData.append('file', f) try { const res = await api.post('/documents/upload', formData, { - headers: { 'Content-Type': 'multipart/form-data' }, - onUploadProgress: (e) => { if (e.total) setProgress(Math.round((e.loaded / e.total) * 100)) }, + onUploadProgress: (e) => { + if (e.progress != null) setProgress(Math.round(e.progress * 100)) + else if (e.total) setProgress(Math.round((e.loaded / e.total) * 100)) + }, }) navigate(`/documents/${res.data.id}`) } catch (err) { @@ -152,6 +147,18 @@ export default function UploadPage() { } finally { setUploading(false) } } + const handleFile = (f, fromNextcloud = false) => { + if (f && f.type === 'application/pdf') { + setFile(f); setError('') + if (!fromNextcloud) setTab('local') + if (fromNextcloud) doUpload(f) + } else { + setError('Please select a PDF file') + } + } + + const handleUpload = () => doUpload(file) + return (
    @@ -213,17 +220,25 @@ export default function UploadPage() { )} {uploading && ( -
    -
    +
    +
    + Uploading {uploadName || file?.name || 'file'}... + {progress}% +
    +
    +
    +
    )} -
    - - -
    + {!uploading && ( +
    + + +
    + )}
    )