Compare commits

...

16 commits

Author SHA1 Message Date
Daniel
e7f91e5e5b fix: scope quiz logging and bot status 2026-06-06 00:01:44 +02:00
Daniel
95de56d81b Improve quiz note and navigation layout
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m33s
2026-05-14 17:30:08 +02:00
Daniel
55713902ed Make MyNote draggable
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
2026-05-12 18:39:50 +02:00
Daniel
9f97218f39 Add persistent MyNote for quizzes
All checks were successful
Mobile Android Release / android-release (push) Successful in 1m32s
2026-05-12 17:51:23 +02:00
Daniel
4b75edef4c Remove highlight toast notifications 2026-05-12 17:02:41 +02:00
Daniel
e2c070b70b Refine quiz image zoom increments 2026-05-12 16:44:11 +02:00
Daniel
dd959371d1 Add dashboard in-progress quizzes 2026-05-12 16:41:44 +02:00
Daniel
d4ef94a117 Remove dashboard document management link 2026-05-12 16:38:33 +02:00
Daniel
17b0f06037 Add quiz image zoom controls 2026-05-12 16:36:53 +02:00
Daniel
4f347a18a3 Ignore local Firecrawl cache 2026-05-12 16:20:10 +02:00
Daniel
ffeb35922a Improve quiz UX and add Telegram bot 2026-05-12 15:57:27 +02:00
Daniel
dcab250223 Use explicit Android build actions 2026-05-12 01:25:40 +02:00
Daniel
a80effe9c6 Allow manual Android release tag 2026-05-12 01:07:24 +02:00
Daniel
9b691eb2fd Use local Forgejo runner for Android build 2026-05-12 01:05:37 +02:00
Daniel
fdebda993c Improve quiz TTS and Android release build
Some checks failed
Mobile Android Release / android-release (push) Failing after 1s
2026-05-12 01:01:01 +02:00
Daniel
d59c8bed6f Remove coach modes and improve quiz audio 2026-05-10 01:24:06 +02:00
62 changed files with 6592 additions and 1725 deletions

View file

@ -0,0 +1,126 @@
name: Mobile Android Release
on:
push:
tags:
- 'v*'
workflow_dispatch:
inputs:
release_name:
description: 'Forgejo release name'
required: false
default: 'Manual Android Build'
release_tag:
description: 'Forgejo release tag'
required: false
default: ''
jobs:
android-release:
runs-on: forgejo-local
steps:
- name: Checkout
uses: https://github.com/actions/checkout@v4
- name: Set up Node
uses: https://github.com/actions/setup-node@v4
with:
node-version: '20'
- name: Set up Java
uses: https://github.com/actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- name: Set up Android SDK
uses: https://github.com/android-actions/setup-android@v3
- name: Validate Android signing secrets
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
run: |
test -n "$ANDROID_KEYSTORE_BASE64"
test -n "$ANDROID_KEYSTORE_PASSWORD"
test -n "$ANDROID_KEY_ALIAS"
test -n "$ANDROID_KEY_PASSWORD"
- name: Build frontend
working-directory: frontend
run: |
npm ci
npm run build
- name: Install mobile dependencies
working-directory: mobile
run: npm ci
- name: Decode Android keystore
env:
ANDROID_KEYSTORE_BASE64: ${{ secrets.ANDROID_KEYSTORE_BASE64 }}
run: |
mkdir -p mobile/android/keystore
printf '%s' "$ANDROID_KEYSTORE_BASE64" | base64 -d > mobile/android/keystore/release.jks
- name: Sync Capacitor Android
working-directory: mobile
run: npx cap sync android
- name: Build signed release APK
working-directory: mobile/android
env:
ANDROID_KEYSTORE_FILE: ${{ github.workspace }}/mobile/android/keystore/release.jks
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
chmod +x gradlew
./gradlew assembleRelease
tag="${RELEASE_TAG:-${GITHUB_REF_NAME:-manual}}"
mkdir -p ../../dist
cp app/build/outputs/apk/release/app-release.apk ../../dist/pedshub-${tag}.apk
- name: Create Forgejo release and upload APK
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_NAME: ${{ inputs.release_name }}
RELEASE_TAG: ${{ inputs.release_tag }}
run: |
set -eu
tag="${RELEASE_TAG:-${GITHUB_REF_NAME:-manual-${GITHUB_RUN_NUMBER}}}"
apk="dist/pedshub-${tag}.apk"
name="${RELEASE_NAME:-PedsHub Android ${tag}}"
body="Automated signed Android APK build for ${tag}."
api="${GITHUB_API_URL}/repos/${GITHUB_REPOSITORY}"
release_json=$(curl -fsS "${api}/releases/tags/${tag}" \
-H "Authorization: token ${GITHUB_TOKEN}" || true)
if [ -z "$release_json" ]; then
payload=$(python3 - <<PY
import json
print(json.dumps({
"tag_name": "${tag}",
"target_commitish": "${GITHUB_SHA}",
"name": "${name}",
"body": "${body}",
"draft": False,
"prerelease": False,
}))
PY
)
release_json=$(curl -fsS -X POST "${api}/releases" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/json" \
-d "$payload")
fi
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
curl -fsS -X POST "${api}/releases/${release_id}/assets?name=$(basename "$apk")" \
-H "Authorization: token ${GITHUB_TOKEN}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary "@${apk}"

1
.gitignore vendored
View file

@ -25,3 +25,4 @@ backend/.env.save
# Database backups # Database backups
backups/ backups/
.firecrawl/

View file

@ -10,7 +10,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
- **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. - **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 - **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 - **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 - **Text-to-Speech**: LiteLLM-routed local TTS, OpenAI TTS, ElevenLabs, Google Cloud — voice selection per quiz
- **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords - **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords
- **Question Bank**: All questions searchable, filterable by category and tags, with inline study mode - **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 - **Image Validation**: AI `has_figure` gating — only links extracted images to questions the AI flagged as having a figure, preventing mismatched images
@ -36,7 +36,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) | | AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) |
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) | | Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) |
| Document vectors | ChromaDB | | Document vectors | ChromaDB |
| TTS | OpenAI (direct), AWS Polly, ElevenLabs, Google Cloud TTS | | TTS | LiteLLM-routed local TTS, OpenAI (direct), ElevenLabs, Google Cloud TTS |
| Queue | Celery + Redis (4 fork workers) | | Queue | Celery + Redis (4 fork workers) |
| Email | SMTP (smtp2go or any SMTP server) | | Email | SMTP (smtp2go or any SMTP server) |
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) | | Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
@ -46,7 +46,7 @@ For detailed architecture documentation, see [docs/architecture.md](docs/archite
## Quick Start ## Quick Start
```bash ```bash
git clone https://github.com/ifedan-ed/pdf-quiz-generator.git git clone ssh://git.danvics.com:2222/danvics/pdf-quiz-generator.git
cd pdf-quiz-generator cd pdf-quiz-generator
# Configure environment # Configure environment
@ -112,6 +112,10 @@ APP_URL=https://your-domain.com
UPLOAD_DIR=/app/uploads UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=524288000 MAX_UPLOAD_SIZE=524288000
CHROMA_PERSIST_DIR=/app/chroma_data CHROMA_PERSIST_DIR=/app/chroma_data
# Optional bootstrap admin. Leave blank to let the first registered user become admin.
DEFAULT_ADMIN_EMAIL=
DEFAULT_ADMIN_PASSWORD=
``` ```
### Frontend (`frontend/.env`) ### Frontend (`frontend/.env`)
@ -307,7 +311,6 @@ Accessible at `/admin` for admin users. Three tabs:
### More Settings ### More Settings
- **Public Registration** — enable/disable new user sign-ups - **Public Registration** — enable/disable new user sign-ups
- **AWS Polly** — global enable/disable toggle for all Polly voices
- **Classify Questions** — trigger AI tag classification for all untagged questions (runs as background task) - **Classify Questions** — trigger AI tag classification for all untagged questions (runs as background task)
- **Embedding Model** — set the model used for semantic search vectors: - **Embedding Model** — set the model used for semantic search vectors:
- Type a model name and click **Save**, or click **Search LiteLLM** to browse proxy models - Type a model name and click **Save**, or click **Search LiteLLM** to browse proxy models
@ -457,9 +460,8 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
## TTS Providers ## TTS Providers
| Provider | Model ID format | Key needed | | Provider | Model/voice ID format | Key needed |
|---|---|---| |---|---|---|
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1-hd:*` | `OPENAI_API_KEY` | | Kokoro via LiteLLM | `local-kokoro-tts:am_adam`, `local-kokoro-tts:af_bella` | `LITELLM_API_KEY` |
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy` | `AWS_ACCESS_KEY_ID` + IAM `polly:SynthesizeSpeech` |
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` | The part before `:` is the LiteLLM speech model route. The part after `:` is the Kokoro speaker voice.
| Google Cloud | `google/<voice-name>` | `GOOGLE_TTS_API_KEY` |

View file

@ -4,6 +4,10 @@ SECRET_KEY=change-me-to-a-random-secret-key-in-production
ALGORITHM=HS256 ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=1440 ACCESS_TOKEN_EXPIRE_MINUTES=1440
# Optional bootstrap admin. Leave blank to use first-user-becomes-admin registration.
DEFAULT_ADMIN_EMAIL=
DEFAULT_ADMIN_PASSWORD=
# Redis (use service name in Docker) # Redis (use service name in Docker)
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
@ -11,6 +15,9 @@ REDIS_URL=redis://redis:6379/0
LITELLM_MODEL=gpt-4o-mini LITELLM_MODEL=gpt-4o-mini
LITELLM_API_KEY=your-api-key-here LITELLM_API_KEY=your-api-key-here
# Local Sherpa speech gateway for self-hosted TTS/STT
LOCAL_SPEECH_GATEWAY_URL=http://local-speech-gateway:8110
# Vector store # Vector store
CHROMA_PERSIST_DIR=/app/chroma_data CHROMA_PERSIST_DIR=/app/chroma_data

View file

@ -21,7 +21,7 @@ if _db_url:
if config.config_file_name is not None: if config.config_file_name is not None:
fileConfig(config.config_file_name) fileConfig(config.config_file_name)
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule # noqa from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule, UserNote # noqa
from app.database import Base from app.database import Base
target_metadata = Base.metadata target_metadata = Base.metadata

View file

@ -0,0 +1,38 @@
"""add user notes
Revision ID: e4c7b2a9d6f1
Revises: 9bac7bf02e38
Create Date: 2026-05-12 17:10:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'e4c7b2a9d6f1'
down_revision: Union[str, None] = '9bac7bf02e38'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
conn.execute(sa.text("""
CREATE TABLE IF NOT EXISTS user_notes (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
)
"""))
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_user_notes_id ON user_notes (id)"))
conn.execute(sa.text("CREATE INDEX IF NOT EXISTS ix_user_notes_user_id ON user_notes (user_id)"))
def downgrade() -> None:
op.drop_index(op.f('ix_user_notes_user_id'), table_name='user_notes')
op.drop_index(op.f('ix_user_notes_id'), table_name='user_notes')
op.drop_table('user_notes')

View file

@ -18,6 +18,7 @@ class Settings(BaseSettings):
OPENAI_API_KEY: str = "" OPENAI_API_KEY: str = ""
ELEVENLABS_API_KEY: str = "" ELEVENLABS_API_KEY: str = ""
GOOGLE_TTS_API_KEY: str = "" GOOGLE_TTS_API_KEY: str = ""
LOCAL_SPEECH_GATEWAY_URL: str = "http://127.0.0.1:8110"
AWS_ACCESS_KEY_ID: str = "" AWS_ACCESS_KEY_ID: str = ""
AWS_SECRET_ACCESS_KEY: str = "" AWS_SECRET_ACCESS_KEY: str = ""
AWS_REGION: str = "us-east-1" AWS_REGION: str = "us-east-1"
@ -41,6 +42,9 @@ class Settings(BaseSettings):
TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha TURNSTILE_SECRET_KEY: str = "" # Cloudflare Turnstile — leave blank to disable captcha
ADMIN_EMAIL: str = "" # Where contact form submissions are emailed ADMIN_EMAIL: str = "" # Where contact form submissions are emailed
DEFAULT_ADMIN_EMAIL: str = "" # Optional explicit bootstrap admin email
DEFAULT_ADMIN_PASSWORD: str = "" # Optional explicit bootstrap admin password
BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton) BBB_SERVER_URL: str = "" # BigBlueButton server URL (e.g. https://bbb.example.com/bigbluebutton)
BBB_SECRET: str = "" # BigBlueButton shared secret BBB_SECRET: str = "" # BigBlueButton shared secret
@ -53,5 +57,4 @@ class Settings(BaseSettings):
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
settings = Settings() settings = Settings()

View file

@ -11,30 +11,39 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else # Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL) setup_logging(settings.LOG_LEVEL)
from app.database import engine, Base, SessionLocal from app.database import engine, Base, SessionLocal
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
from app.utils.auth import get_password_hash from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler from app.utils.scheduler import start_scheduler, stop_scheduler
def seed_admin(): def seed_admin():
"""Create default admin user if none exists.""" """Optionally create a configured bootstrap admin user if none exists."""
import logging
from datetime import datetime
from app.models.user import User from app.models.user import User
from app.models.email_verification import EmailVerification from app.models.email_verification import EmailVerification
from app.models.password_reset import PasswordReset
log = logging.getLogger(__name__)
db = SessionLocal() db = SessionLocal()
try: try:
admin_exists = db.query(User).filter(User.role == "admin").first() admin_exists = db.query(User).filter(User.role == "admin").first()
if not admin_exists: if not admin_exists:
if not settings.DEFAULT_ADMIN_EMAIL or not settings.DEFAULT_ADMIN_PASSWORD:
log.info("No admin exists; skipping bootstrap admin seed. First registered user will become admin.")
return
if len(settings.DEFAULT_ADMIN_PASSWORD) < 8:
log.warning("DEFAULT_ADMIN_PASSWORD is too short; skipping bootstrap admin seed.")
return
admin_user = User( admin_user = User(
email="admin@quizapp.com", email=settings.DEFAULT_ADMIN_EMAIL.lower().strip(),
hashed_password=get_password_hash("admin123"), hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
name="Admin", name="Admin",
role="admin", role="admin",
) )
db.add(admin_user) db.add(admin_user)
db.flush() db.flush()
# Auto-verify seeded admin # Auto-verify seeded admin
from datetime import datetime
db.add(EmailVerification( db.add(EmailVerification(
user_id=admin_user.id, user_id=admin_user.id,
token="seeded", token="seeded",
@ -44,8 +53,6 @@ def seed_admin():
db.commit() db.commit()
else: else:
# Ensure existing admin has a verified email record # Ensure existing admin has a verified email record
from app.models.email_verification import EmailVerification
from datetime import datetime
existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first() existing_v = db.query(EmailVerification).filter(EmailVerification.user_id == admin_exists.id).first()
if not existing_v: if not existing_v:
db.add(EmailVerification( db.add(EmailVerification(
@ -61,6 +68,8 @@ def seed_admin():
def seed_default_models(): def seed_default_models():
"""Seed default AI model configs if none exist.""" """Seed default AI model configs if none exist."""
from sqlalchemy import text
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
db = SessionLocal() db = SessionLocal()
try: try:
@ -82,49 +91,72 @@ def seed_default_models():
db.delete(titan) db.delete(titan)
db.commit() db.commit()
# Always ensure OpenAI TTS voice models exist (idempotent) # Always ensure LiteLLM-routed local speech models exist (idempotent).
tts_voices = [ tts_voices = [
# OpenAI (work with OPENAI_API_KEY) ("Kokoro Adam", "local-kokoro-tts:am_adam", True),
("OpenAI Alloy", "tts-1:alloy", True), ("Kokoro Michael", "local-kokoro-tts:am_michael", False),
("OpenAI Nova", "tts-1:nova", False), ("Kokoro Bella", "local-kokoro-tts:af_bella", False),
("OpenAI Echo", "tts-1:echo", False), ("Kokoro Nicole", "local-kokoro-tts:af_nicole", False),
("OpenAI Shimmer", "tts-1:shimmer", False), ("Kokoro Emma", "local-kokoro-tts:bf_emma", False),
("OpenAI Onyx", "tts-1:onyx", False), ("Kokoro Lewis", "local-kokoro-tts:bm_lewis", False),
("OpenAI Fable", "tts-1:fable", False),
("OpenAI Alloy HD", "tts-1-hd:alloy", False),
("OpenAI Nova HD", "tts-1-hd:nova", False),
# ElevenLabs (work with ELEVENLABS_API_KEY)
("ElevenLabs Adam", "elevenlabs/adam", False),
# Google Cloud TTS (work with GOOGLE_TTS_API_KEY)
("Google Wavenet F (en-US)", "google/en-US-Wavenet-F", False),
("Google Wavenet D (en-US)", "google/en-US-Wavenet-D", False),
("Google Studio O (en-US)", "google/en-US-Studio-O", False),
("Google Studio Q (en-US)", "google/en-US-Studio-Q", False),
("Google Chirp 3 HD (en-US)", "google/en-US-Chirp3-HD-Aoede", False),
# AWS Polly Neural (work with AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY)
("AWS Polly Joanna (en-US)", "polly/Joanna", False),
("AWS Polly Matthew (en-US)", "polly/Matthew", False),
("AWS Polly Amy (en-GB)", "polly/Amy", False),
("AWS Polly Brian (en-GB)", "polly/Brian", False),
] ]
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded) stt_models = [
for old_id in ("tts-1", "tts-1-hd"): ("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first() ("Groq Whisper Turbo", "groq-whisper-large-v3-turbo", False),
if old: ("Groq Whisper Large v3", "groq-whisper-large-v3", False),
]
# Deactivate external/direct legacy TTS entries; speech should route through LiteLLM.
for old in db.query(AIModelConfig).filter(AIModelConfig.task == "tts").all():
if old.model_id and not old.model_id.startswith("local-"):
old.is_active = False
old.is_default = False
if old.model_id == "local-kokoro-tts":
old.is_active = False
old.is_default = False
if old.model_id == "local-chatterbox-turbo":
old.is_active = False
old.is_default = False
if old.model_id == "local-qwen3-tts":
old.is_active = False old.is_active = False
old.is_default = False old.is_default = False
has_default_tts = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True,
).first() is not None
for name, model_id, _ in tts_voices: for name, model_id, _ in tts_voices:
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first() db.execute(text("""
if not exists: INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
is_def = not has_default_tts VALUES (:name, :model_id, 'tts', true, false, NOW())
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def)) ON CONFLICT (model_id, task) DO NOTHING
if is_def: """), {"name": name, "model_id": model_id})
has_default_tts = True
# LiteLLM Kokoro is the intended default for read-aloud; admins can change it later.
db.query(AIModelConfig).filter(AIModelConfig.task == "tts").update({"is_default": False})
local_default = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.model_id == "local-kokoro-tts:am_adam",
).first()
if local_default:
local_default.is_active = True
local_default.is_default = True
for task, rows in (("stt", stt_models),):
has_default = db.query(AIModelConfig).filter(
AIModelConfig.task == task,
AIModelConfig.is_default == True,
AIModelConfig.is_active == True,
).first() is not None
for name, model_id, preferred_default in rows:
exists = db.query(AIModelConfig).filter(
AIModelConfig.model_id == model_id,
AIModelConfig.task == task,
).first()
if not exists:
is_def = preferred_default and not has_default
db.execute(text("""
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
VALUES (:name, :model_id, :task, true, :is_default, NOW())
ON CONFLICT (model_id, task) DO NOTHING
"""), {"name": name, "model_id": model_id, "task": task, "is_default": is_def})
if is_def:
has_default = True
db.commit() db.commit()
finally: finally:
db.close() db.close()
@ -574,6 +606,8 @@ app.include_router(contact.router, prefix="/api/contact", tags=["contact"])
app.include_router(tags.router, prefix="/api/tags", tags=["tags"]) app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"]) app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
app.include_router(courses.router, prefix="/api/courses", tags=["courses"]) app.include_router(courses.router, prefix="/api/courses", tags=["courses"])
app.include_router(mobile.router, prefix="/api/mobile", tags=["mobile"])
app.include_router(mynote.router, prefix="/api/mynote", tags=["mynote"])
@app.get("/api/health") @app.get("/api/health")

View file

@ -7,6 +7,7 @@ from app.models.attempt import QuizAttempt, AttemptAnswer
from app.models.reminder import ReminderSchedule from app.models.reminder import ReminderSchedule
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
from app.models.favorite import Favorite from app.models.favorite import Favorite
from app.models.user_note import UserNote
__all__ = [ __all__ = [
"User", "User",
@ -19,4 +20,5 @@ __all__ = [
"ReminderSchedule", "ReminderSchedule",
"AIModelConfig", "AIModelConfig",
"Favorite", "Favorite",
"UserNote",
] ]

View file

@ -24,6 +24,7 @@ class User(Base):
attempts = relationship("QuizAttempt", back_populates="user") attempts = relationship("QuizAttempt", back_populates="user")
reminders = relationship("ReminderSchedule", back_populates="user") reminders = relationship("ReminderSchedule", back_populates="user")
favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan") favorites = relationship("Favorite", back_populates="user", cascade="all, delete-orphan")
note = relationship("UserNote", back_populates="user", cascade="all, delete-orphan", uselist=False)
@property @property
def is_admin(self): def is_admin(self):

View file

@ -0,0 +1,18 @@
from datetime import datetime
from sqlalchemy import Column, DateTime, ForeignKey, Integer, Text
from sqlalchemy.orm import relationship
from app.database import Base
class UserNote(Base):
__tablename__ = "user_notes"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), unique=True, nullable=False, index=True)
content = Column(Text, nullable=False, default="")
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship("User", back_populates="note")

View file

@ -139,6 +139,7 @@ def list_available_models(
class LiteLLMSearchRequest(BaseModel): class LiteLLMSearchRequest(BaseModel):
api_key: str | None = None api_key: str | None = None
api_base: str | None = None api_base: str | None = None
mode: str | None = None
@router.post("/litellm/models") @router.post("/litellm/models")
@ -155,8 +156,16 @@ def search_litellm_models(
if base: if base:
try: try:
headers = {"Authorization": f"Bearer {key}"} if key else {} headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \ if data.mode:
httpx.get(f"{base}/v1/models", headers=headers, timeout=10) info_base = base.rstrip("/").removesuffix("/v1")
resp = httpx.get(f"{info_base}/model/info", headers=headers, timeout=10)
resp.raise_for_status()
models = sorted([
m.get("model_name") for m in resp.json().get("data", [])
if m.get("model_name") and (m.get("model_info") or {}).get("mode") == data.mode
])
return {"models": models, "source": info_base, "mode": data.mode}
resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
resp.raise_for_status() resp.raise_for_status()
models = sorted([m["id"] for m in resp.json().get("data", [])]) models = sorted([m["id"] for m in resp.json().get("data", [])])
return {"models": models, "source": base} return {"models": models, "source": base}
@ -187,8 +196,9 @@ def create_model(
db: Session = Depends(get_db), db: Session = Depends(get_db),
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
): ):
if data.task not in ("extraction", "tts", "teach", "keyword", "flashcard"): valid_tasks = ("extraction", "tts", "stt", "teach", "keyword", "flashcard")
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword") if data.task not in valid_tasks:
raise HTTPException(status_code=400, detail=f"Task must be one of: {', '.join(valid_tasks)}")
if data.is_default: if data.is_default:
db.query(AIModelConfig).filter( db.query(AIModelConfig).filter(
@ -276,6 +286,27 @@ def test_model(
if model.task == "tts": if model.task == "tts":
raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.") raise HTTPException(status_code=400, detail="Use the Preview button to test TTS voices — it plays audio directly.")
if model.task == "stt":
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
key = model.api_key or settings.LITELLM_API_KEY
if not base:
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
try:
headers = {"Authorization": f"Bearer {key}"} if key else {}
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
resp.raise_for_status()
found = any(
m.get("model_name") == model.model_id and (m.get("model_info") or {}).get("mode") == "audio_transcription"
for m in resp.json().get("data", [])
)
if not found:
raise HTTPException(status_code=404, detail=f"{model.model_id} was not found as an audio_transcription model in LiteLLM")
return {"message": f"{model.model_id} is available for speech transcription"}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=502, detail=str(e))
try: try:
import litellm import litellm
from app.services.ai_service import _proxy_model from app.services.ai_service import _proxy_model
@ -304,90 +335,93 @@ class TTSVoiceSearchRequest(BaseModel):
region: str | None = None region: str | None = None
KOKORO_VOICE_FALLBACKS = [
("am_adam", "Kokoro Adam"),
("am_michael", "Kokoro Michael"),
("af_bella", "Kokoro Bella"),
("af_nicole", "Kokoro Nicole"),
("bf_emma", "Kokoro Emma"),
("bm_lewis", "Kokoro Lewis"),
]
def _kokoro_voice_options(model_name: str) -> list[dict]:
base = settings.LOCAL_SPEECH_GATEWAY_URL.rstrip("/")
voices = []
friendly_names = {voice_id: name for voice_id, name in KOKORO_VOICE_FALLBACKS}
if base:
try:
resp = httpx.get(f"{base}/v1/audio/voices", timeout=10)
resp.raise_for_status()
voices = [
v for v in resp.json().get("voices", [])
if v.get("profile") == "kokoro" and v.get("voice")
]
except Exception:
voices = []
if not voices:
voices = [
{"voice": voice_id, "name": name, "profile": "kokoro"}
for voice_id, name in KOKORO_VOICE_FALLBACKS
]
return [
{
"model_id": f"{model_name}:{v['voice']}",
"name": friendly_names.get(v["voice"], v.get("name") or f"Kokoro {v['voice']}"),
"labels": {"provider": "litellm", "model": model_name, "voice": v["voice"]},
}
for v in voices
]
@router.post("/tts/voices") @router.post("/tts/voices")
def search_tts_voices( def search_tts_voices(
data: TTSVoiceSearchRequest, data: TTSVoiceSearchRequest,
admin: User = Depends(require_admin), admin: User = Depends(require_admin),
): ):
"""Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list.""" """Discover local TTS voices/models from LiteLLM or the local speech gateway."""
import logging import logging
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
provider = data.provider provider = data.provider
api_key = data.api_key api_key = data.api_key
region = data.region region = data.region
if provider == "elevenlabs": if provider != "litellm":
key = api_key or settings.ELEVENLABS_API_KEY raise HTTPException(status_code=400, detail="TTS discovery is routed through LiteLLM only")
if not key:
raise HTTPException(status_code=400, detail="ElevenLabs API key required (set ELEVENLABS_API_KEY in .env or enter it above)") if provider == "litellm":
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
key = api_key or settings.LITELLM_API_KEY
if not base:
raise HTTPException(status_code=400, detail="LiteLLM API base is not configured")
try: try:
resp = httpx.get( headers = {"Authorization": f"Bearer {key}"} if key else {}
"https://api.elevenlabs.io/v1/voices", resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
headers={"xi-api-key": key},
timeout=15,
)
resp.raise_for_status() resp.raise_for_status()
voices = resp.json().get("voices", []) models = resp.json().get("data", [])
return {"voices": [ voices = []
{ for m in models:
"model_id": f"elevenlabs/{v['voice_id']}", model_name = m.get("model_name")
"name": v["name"], if not model_name or (m.get("model_info") or {}).get("mode") != "audio_speech":
"labels": v.get("labels", {}), continue
} if model_name == "local-kokoro-tts":
for v in sorted(voices, key=lambda x: x["name"]) voices.extend(_kokoro_voice_options(model_name))
]} continue
except HTTPException:
raise
except Exception as e:
log.warning(f"ElevenLabs voice discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}")
elif provider == "polly":
access_key = api_key or settings.AWS_ACCESS_KEY_ID
secret_key = settings.AWS_SECRET_ACCESS_KEY
aws_region = region or settings.AWS_REGION or "us-east-1"
if not access_key or not secret_key:
raise HTTPException(
status_code=400,
detail="AWS credentials required — set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in .env",
)
try:
import boto3
polly = boto3.client(
"polly",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=aws_region,
)
resp = polly.describe_voices(Engine="neural")
voices = resp.get("Voices", [])
return {"voices": [
{
"model_id": f"polly/{v['Id']}",
"name": f"{v['Name']}{v['LanguageName']} ({v.get('Gender', '')})",
"labels": {"gender": v.get("Gender", ""), "language": v.get("LanguageName", "")},
}
for v in sorted(voices, key=lambda x: x["Name"])
]}
except HTTPException:
raise
except Exception as e:
log.warning(f"AWS Polly voice discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"AWS Polly error: {e}")
elif provider == "openai":
voices = []
for model_name in ["tts-1", "tts-1-hd"]:
for voice in ["alloy", "ash", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer"]:
voices.append({ voices.append({
"model_id": f"{model_name}:{voice}", "model_id": model_name,
"name": f"{model_name} · {voice}", "name": model_name,
"labels": {"model": model_name, "voice": voice}, "labels": {"provider": "litellm", "mode": "audio_speech"},
}) })
return {"voices": voices} return {"voices": voices}
except HTTPException:
raise
except Exception as e:
log.warning(f"LiteLLM local TTS discovery failed: {e}")
raise HTTPException(status_code=400, detail=f"LiteLLM TTS discovery error: {e}")
else: raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: elevenlabs, polly, openai")
# --- System Settings --- # --- System Settings ---
@ -400,12 +434,10 @@ def get_settings(admin: User = Depends(require_admin)):
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
registration_enabled = r.get("settings:registration_enabled") registration_enabled = r.get("settings:registration_enabled")
embedding_model = r.get("settings:embedding_model") embedding_model = r.get("settings:embedding_model")
polly_enabled = r.get("settings:polly_enabled")
sso_only = r.get("settings:sso_only") sso_only = r.get("settings:sso_only")
return { return {
"registration_enabled": registration_enabled != "false", "registration_enabled": registration_enabled != "false",
"embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "", "embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": polly_enabled != "false",
"sso_only": sso_only == "true", "sso_only": sso_only == "true",
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID), "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME, "sso_provider_name": settings.OIDC_PROVIDER_NAME,
@ -414,7 +446,6 @@ def get_settings(admin: User = Depends(require_admin)):
return { return {
"registration_enabled": True, "registration_enabled": True,
"embedding_model": settings.LITELLM_EMBEDDING_MODEL or "", "embedding_model": settings.LITELLM_EMBEDDING_MODEL or "",
"polly_enabled": True,
"sso_only": False, "sso_only": False,
"sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID), "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID),
"sso_provider_name": settings.OIDC_PROVIDER_NAME, "sso_provider_name": settings.OIDC_PROVIDER_NAME,
@ -438,10 +469,6 @@ def update_settings(
if "embedding_model" in settings_data: if "embedding_model" in settings_data:
r.set("settings:embedding_model", settings_data["embedding_model"]) r.set("settings:embedding_model", settings_data["embedding_model"])
if "polly_enabled" in settings_data:
value = "true" if settings_data["polly_enabled"] else "false"
r.set("settings:polly_enabled", value)
if "sso_only" in settings_data: if "sso_only" in settings_data:
value = "true" if settings_data["sso_only"] else "false" value = "true" if settings_data["sso_only"] else "false"
r.set("settings:sso_only", value) r.set("settings:sso_only", value)

View file

@ -90,6 +90,8 @@ class ProgressUpdate(BaseModel):
class AIGenerateRequest(BaseModel): class AIGenerateRequest(BaseModel):
model_config = {"protected_namespaces": ()}
prompt: str prompt: str
action: str = "generate" # generate, refine, summarize action: str = "generate" # generate, refine, summarize
existing_content: str | None = None existing_content: str | None = None

View file

@ -18,6 +18,8 @@ router = APIRouter()
# ── Schemas ────────────────────────────────────────────────────────── # ── Schemas ──────────────────────────────────────────────────────────
class FlashcardDeckCreate(BaseModel): class FlashcardDeckCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int section_id: int
title: str title: str
model_id: str | None = None model_id: str | None = None

View file

@ -0,0 +1,244 @@
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.email_verification import EmailVerification
from app.models.quiz import Quiz
from app.models.user import User
from app.schemas.auth import Token
from app.utils.auth import create_access_token, get_current_user, verify_password
from app.utils.quiz_questions import get_quiz_questions
router = APIRouter()
class MobileLoginRequest(BaseModel):
email: EmailStr
password: str
class MobileAnswerSubmit(BaseModel):
question_id: int
user_answer: str
transcript: str | None = None
selected_letter: str | None = None
confidence: float | None = None
class MobileAttemptSubmit(BaseModel):
quiz_id: int
started_at: datetime | None = None
completed_at: datetime | None = None
selected_question_ids: list[int] | None = None
answers: list[MobileAnswerSubmit]
ANDROID_MODEL_MANIFEST = {
"version": 1,
"default_stack": {
"stt": "android-speech-offline",
"mapper": "deterministic-option-matcher",
"tts": "android-system-tts",
},
"models": [
{
"id": "android-speech-offline",
"role": "stt",
"runtime": "android.speech.SpeechRecognizer",
"label": "Android offline speech recognizer",
"download_url": None,
"required": False,
"notes": "Uses the device speech service with offline preference while bundled STT artifacts are added.",
},
{
"id": "deterministic-option-matcher",
"role": "mapper",
"runtime": "in-app",
"label": "Exact option/letter matcher",
"download_url": None,
"required": True,
"notes": "Grades locally against synced answer keys; no cloud model decides correctness.",
},
{
"id": "android-system-tts",
"role": "tts",
"runtime": "android.speech.tts.TextToSpeech",
"label": "Android system TTS",
"download_url": None,
"required": False,
"notes": "Fast local read-aloud fallback before packaged neural TTS is shipped.",
},
],
}
def _mobile_login_rate_limit(client_ip: str):
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
key = f"mobile_login_attempts:{client_ip}"
count = r.incr(key)
if count == 1:
r.expire(key, 15 * 60)
if count > 20:
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
except HTTPException:
raise
except Exception:
# Mobile login should still work if Redis is briefly unavailable.
return
def _visible_quizzes_query(db: Session, current_user: User):
query = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
if not current_user.is_moderator:
query = query.filter(Quiz.is_published == 1, Quiz.course_id.is_(None))
return query
def _question_payload(question):
return {
"id": question.id,
"question_text": question.question_text,
"question_type": question.question_type,
"options": question.options,
"correct_answer": question.correct_answer,
"explanation": question.explanation,
"page_reference": question.page_reference,
"image_path": question.image_path,
}
def _quiz_payload(db: Session, quiz: Quiz, include_questions: bool = True):
payload = {
"id": quiz.id,
"title": quiz.title,
"mode": quiz.mode,
"questions_count": quiz.questions_count,
"time_limit_minutes": quiz.time_limit_minutes,
"questions_per_attempt": quiz.questions_per_attempt,
"category_id": quiz.category_id,
"course_id": quiz.course_id,
"is_published": quiz.is_published,
"is_shared": quiz.is_shared,
"created_at": quiz.created_at.isoformat() if quiz.created_at else None,
}
if include_questions:
payload["questions"] = [_question_payload(question) for question in get_quiz_questions(db, quiz.id)]
return payload
@router.post("/auth/login")
def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)):
"""Password login for native apps. Uses rate limiting instead of browser Turnstile."""
client_ip = request.client.host if request and request.client else "unknown"
_mobile_login_rate_limit(client_ip)
email = data.email.lower().strip()
user = db.query(User).filter(User.email == email).first()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password")
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if verification and verification.verified_at is None:
raise HTTPException(status_code=403, detail="Email not verified")
token = create_access_token(data={"sub": user.email})
return {
**Token(access_token=token).model_dump(),
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
"server_time": datetime.utcnow().isoformat(),
}
@router.get("/sync")
def mobile_sync(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Bulk sync all visible quizzes with answer keys for offline study."""
quizzes = _visible_quizzes_query(db, current_user).order_by(Quiz.created_at.desc()).all()
return {
"server_time": datetime.utcnow().isoformat(),
"user": {"id": current_user.id, "email": current_user.email, "name": current_user.name, "role": current_user.role},
"quizzes": [_quiz_payload(db, quiz) for quiz in quizzes],
"deleted_quiz_ids": [],
}
@router.get("/quizzes/{quiz_id}")
def mobile_quiz_detail(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
return _quiz_payload(db, quiz)
@router.get("/models")
def mobile_models(current_user: User = Depends(get_current_user)):
_ = current_user
return ANDROID_MODEL_MANIFEST
@router.post("/attempts")
def upload_mobile_attempt(
data: MobileAttemptSubmit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == data.quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
question_map = {question.id: question for question in get_quiz_questions(db, quiz.id)}
selected_ids = data.selected_question_ids or [answer.question_id for answer in data.answers]
total_questions = len(selected_ids) if selected_ids else len(question_map)
score = 0
attempt = QuizAttempt(
quiz_id=quiz.id,
user_id=current_user.id,
total_questions=total_questions,
started_at=data.started_at or datetime.utcnow(),
completed_at=data.completed_at or datetime.utcnow(),
selected_question_ids=selected_ids or None,
)
db.add(attempt)
db.flush()
for answer in data.answers:
question = question_map.get(answer.question_id)
if not question:
continue
is_correct = bool(answer.user_answer) and answer.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct:
score += 1
db.add(AttemptAnswer(
attempt_id=attempt.id,
question_id=question.id,
user_answer=answer.user_answer,
is_correct=is_correct,
))
attempt.score = score
db.commit()
percentage = round((score / total_questions * 100) if total_questions else 0, 1)
return {
"id": attempt.id,
"quiz_id": quiz.id,
"score": score,
"total_questions": total_questions,
"percentage": percentage,
"completed_at": attempt.completed_at.isoformat() if attempt.completed_at else None,
}

View file

@ -0,0 +1,50 @@
from datetime import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.user import User
from app.models.user_note import UserNote
from app.utils.auth import get_current_user
router = APIRouter()
class MyNoteResponse(BaseModel):
content: str
updated_at: datetime | None = None
class MyNoteUpdate(BaseModel):
content: str = Field(default="", max_length=50000)
@router.get("", response_model=MyNoteResponse)
def get_my_note(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
note = db.query(UserNote).filter(UserNote.user_id == current_user.id).first()
if not note:
return {"content": "", "updated_at": None}
return {"content": note.content or "", "updated_at": note.updated_at}
@router.put("", response_model=MyNoteResponse)
def save_my_note(
data: MyNoteUpdate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
note = db.query(UserNote).filter(UserNote.user_id == current_user.id).first()
if not note:
note = UserNote(user_id=current_user.id, content=data.content)
db.add(note)
else:
note.content = data.content
note.updated_at = datetime.utcnow()
db.commit()
db.refresh(note)
return {"content": note.content or "", "updated_at": note.updated_at}

View file

@ -18,6 +18,8 @@ class ChatMessage(BaseModel):
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
model_config = {"protected_namespaces": ()}
question_id: int question_id: int
messages: list[ChatMessage] messages: list[ChatMessage]
model_id: int | None = None # AIModelConfig.id — if None, use default model_id: int | None = None # AIModelConfig.id — if None, use default

View file

@ -1,9 +1,8 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import Response from fastapi.responses import Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db from app.database import get_db
from app.models.user import User from app.models.user import User
from app.models.ai_model_config import AIModelConfig from app.models.ai_model_config import AIModelConfig
@ -18,14 +17,30 @@ class TTSRequest(BaseModel):
voice: str | None = None # model_id override voice: str | None = None # model_id override
def _polly_enabled() -> bool: MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]:
val = r.get("settings:polly_enabled") config = db.query(AIModelConfig).filter(
return val != "false" AIModelConfig.task == task,
except Exception: AIModelConfig.is_active == True,
return True AIModelConfig.is_default == True,
).first()
if config:
return config.model_id, config.api_key or None
return fallback, None
def _default_tts_model(db: Session) -> tuple[str, str | None]:
config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts",
AIModelConfig.is_active == True,
AIModelConfig.is_default == True,
AIModelConfig.model_id.like("local-%"),
).first()
if config:
return config.model_id, config.api_key or None
return "local-kokoro-tts:am_adam", None
@router.get("/voices") @router.get("/voices")
@ -33,13 +48,12 @@ def get_voices(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
"""Return available TTS voices from DB, excluding Polly if disabled.""" """Return LiteLLM-routed TTS models only."""
query = db.query(AIModelConfig).filter( query = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.task == "tts",
AIModelConfig.is_active == True, AIModelConfig.is_active == True,
AIModelConfig.model_id.like("local-%"),
) )
if not _polly_enabled():
query = query.filter(~AIModelConfig.model_id.like("polly/%"))
db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all() db_models = query.order_by(AIModelConfig.is_default.desc(), AIModelConfig.name).all()
return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models] return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models]
@ -54,9 +68,9 @@ def text_to_speech(
# Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt) # Rate limit: 60 TTS requests per user per hour (admins/unthrottled users exempt)
check_rate_limit( check_rate_limit(
key=f"tts_speak:{current_user.id}", key=f"tts_speak:{current_user.id}",
max_calls=60, max_calls=240,
window_seconds=3600, window_seconds=3600,
detail="You've reached the audio limit (60 clips/hour). The limit resets automatically — try again shortly. Contact an admin if you need this raised.", detail="You've reached the audio limit. The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
user=current_user, user=current_user,
) )
if not request.text.strip(): if not request.text.strip():
@ -64,23 +78,55 @@ def text_to_speech(
text = request.text[:2000] text = request.text[:2000]
if request.voice: if request.voice and request.voice.startswith("local-"):
if request.voice.startswith("polly/") and not _polly_enabled():
raise HTTPException(status_code=400, detail="AWS Polly is currently disabled")
config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first()
model_id = config.model_id if config else request.voice
api_key = config.api_key if (config and config.api_key) else None
else:
config = db.query(AIModelConfig).filter( config = db.query(AIModelConfig).filter(
AIModelConfig.task == "tts", AIModelConfig.task == "tts",
AIModelConfig.is_active == True, AIModelConfig.is_active == True,
AIModelConfig.is_default == True, AIModelConfig.model_id == request.voice,
).first() ).first()
model_id = config.model_id if config else "tts-1:alloy" if not config:
api_key = config.api_key if (config and config.api_key) else None model_id, api_key = _default_tts_model(db)
else:
model_id = config.model_id
api_key = config.api_key or None
else:
model_id, api_key = _default_tts_model(db)
audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key) audio = ai_service.generate_tts_audio(text, model_id=model_id, api_key=api_key)
if audio is None: if audio is None:
raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.") raise HTTPException(status_code=500, detail="TTS generation failed. Check model configuration.")
return Response(content=audio, media_type="audio/mpeg") return Response(content=audio, media_type="audio/mpeg")
@router.post("/transcribe")
def speech_to_text(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Transcribe microphone audio using the configured STT model."""
check_rate_limit(
key=f"stt_transcribe:{current_user.id}",
max_calls=120,
window_seconds=3600,
detail="You've reached the speech transcription limit. Try again shortly.",
user=current_user,
)
audio = file.file.read()
if not audio:
raise HTTPException(status_code=400, detail="Audio file is empty")
if len(audio) > MAX_AUDIO_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Audio file is too large")
model_id, api_key = _task_model(db, "stt", "local-parakeet-v3")
text = ai_service.transcribe_audio(
audio,
filename=file.filename or "audio.webm",
content_type=file.content_type or "audio/webm",
model_id=model_id,
api_key=api_key,
)
if text is None:
raise HTTPException(status_code=502, detail="Speech transcription failed. Check STT model configuration.")
return {"text": text, "model": model_id}

View file

@ -7,7 +7,7 @@ class AIModelConfigCreate(BaseModel):
model_config = {"protected_namespaces": ()} model_config = {"protected_namespaces": ()}
name: str name: str
model_id: str model_id: str
task: str # extraction, tts, general task: str # extraction, tts, stt, teach, keyword, flashcard
api_key: str | None = None api_key: str | None = None
is_active: bool = True is_active: bool = True
is_default: bool = False is_default: bool = False

View file

@ -2,6 +2,8 @@ from datetime import datetime
from pydantic import BaseModel from pydantic import BaseModel
class FlashcardDeckCreate(BaseModel): class FlashcardDeckCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int section_id: int
title: str title: str
model_id: str | None = None model_id: str | None = None

View file

@ -4,6 +4,8 @@ from pydantic import BaseModel
class QuizCreate(BaseModel): class QuizCreate(BaseModel):
model_config = {"protected_namespaces": ()}
section_id: int | None = None section_id: int | None = None
title: str title: str
mode: str = "timed" # timed, learning mode: str = "timed" # timed, learning

View file

@ -233,6 +233,56 @@ def _call_model(prompt: str, model_id: str | None, api_key: str | None) -> str:
return response.choices[0].message.content return response.choices[0].message.content
def _parse_json_response(text: str) -> dict:
text = text.strip()
if text.startswith("```"):
text = text.split("\n", 1)[1] if "\n" in text else text[3:]
if text.endswith("```"):
text = text[:-3]
text = text.strip()
return json.loads(text)
def transcribe_audio(
audio: bytes,
filename: str = "audio.webm",
content_type: str = "audio/webm",
model_id: str | None = None,
api_key: str | None = None,
) -> str | None:
"""Transcribe uploaded audio through LiteLLM's OpenAI-compatible audio endpoint."""
import httpx
if not audio:
return None
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
logger.error("LiteLLM API base not configured for STT")
return None
key = api_key or settings.LITELLM_API_KEY
headers = {"Authorization": f"Bearer {key}"} if key else {}
try:
resp = httpx.post(
f"{base}/v1/audio/transcriptions",
headers=headers,
data={"model": model_id or "local-parakeet-v3", "response_format": "json"},
files={"file": (filename or "audio.webm", audio, content_type or "application/octet-stream")},
timeout=120,
)
resp.raise_for_status()
try:
data = resp.json()
if isinstance(data, dict):
return str(data.get("text") or data.get("transcript") or data.get("transcription") or "").strip()
except ValueError:
return resp.text.strip()
except Exception as e:
logger.error(f"LiteLLM STT failed: {e}")
return None
def extract_questions_no_answers( def extract_questions_no_answers(
content: str, content: str,
page_info: str = "unknown", page_info: str = "unknown",
@ -330,19 +380,63 @@ def generate_tts_audio(
model_id: str | None = None, model_id: str | None = None,
api_key: str | None = None, api_key: str | None = None,
) -> bytes | None: ) -> bytes | None:
"""Generate TTS audio. Supports OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly. """Generate TTS audio. Supports local LiteLLM, local Sherpa, OpenAI, ElevenLabs, and Google Cloud TTS.
model_id conventions: model_id conventions:
tts-1:alloy OpenAI TTS (voice after colon) tts-1:alloy OpenAI TTS (voice after colon)
tts-1-hd:nova OpenAI TTS HD tts-1-hd:nova OpenAI TTS HD
elevenlabs/<voice> ElevenLabs elevenlabs/<voice> ElevenLabs
google/<voice_name> Google Cloud TTS (e.g. google/en-US-Wavenet-D) google/<voice_name> Google Cloud TTS (e.g. google/en-US-Wavenet-D)
polly/<VoiceId> AWS Polly Neural (e.g. polly/Joanna) sherpa/<profile>:<voice> Local speech gateway (e.g. sherpa/kokoro:am_adam)
local-<model>:<voice> Local LiteLLM TTS model + voice (e.g. local-kokoro-tts:am_adam)
""" """
import httpx, base64 import httpx, base64
use_model = model_id or "tts-1:alloy" use_model = model_id or "tts-1:alloy"
# ── Local LiteLLM TTS models ─────────────────────────────────
if use_model.startswith("local-"):
local_model = use_model
local_voice = "am_adam" if use_model.startswith("local-kokoro-tts") else "alloy"
if ":" in use_model:
local_model, local_voice = use_model.split(":", 1)
key = api_key or settings.LITELLM_API_KEY
base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
if not base:
logger.error("LiteLLM API base not configured for local TTS")
return None
try:
resp = httpx.post(
f"{base}/v1/audio/speech",
headers={"Authorization": f"Bearer {key}", "Content-Type": "application/json"} if key else {"Content-Type": "application/json"},
json={"model": local_model, "input": text, "voice": local_voice, "response_format": "mp3"},
timeout=90,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"Local LiteLLM TTS failed: {e}")
return None
# ── Local Sherpa gateway ───────────────────────────────────
if use_model.startswith("sherpa/"):
payload = use_model[len("sherpa/"):]
profile = payload
voice = "0"
if ":" in payload:
profile, voice = payload.split(":", 1)
try:
resp = httpx.post(
f"{settings.LOCAL_SPEECH_GATEWAY_URL.rstrip('/')}/v1/audio/speech",
json={"model": f"sherpa/{profile}", "input": text, "voice": voice, "response_format": "mp3"},
timeout=90,
)
resp.raise_for_status()
return resp.content
except Exception as e:
logger.error(f"Local Sherpa TTS failed: {e}")
return None
# ── ElevenLabs ───────────────────────────────────────────── # ── ElevenLabs ─────────────────────────────────────────────
if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"): if use_model.startswith("elevenlabs/") or use_model.startswith("eleven_labs/"):
voice = use_model.split("/", 1)[1] voice = use_model.split("/", 1)[1]
@ -389,34 +483,6 @@ def generate_tts_audio(
logger.error(f"Google TTS failed: {e}") logger.error(f"Google TTS failed: {e}")
return None return None
# ── AWS Polly ───────────────────────────────────────────────
if use_model.startswith("polly/"):
voice_id = use_model[len("polly/"):]
access_key = api_key or settings.AWS_ACCESS_KEY_ID
secret_key = settings.AWS_SECRET_ACCESS_KEY
region = settings.AWS_REGION or "us-east-1"
if not access_key or not secret_key:
logger.error("AWS credentials not configured (AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY)")
return None
try:
import boto3
polly = boto3.client(
"polly",
aws_access_key_id=access_key,
aws_secret_access_key=secret_key,
region_name=region,
)
response = polly.synthesize_speech(
Text=text,
OutputFormat="mp3",
VoiceId=voice_id,
Engine="neural",
)
return response["AudioStream"].read()
except Exception as e:
logger.error(f"AWS Polly TTS failed: {e}")
return None
# ── OpenAI (default) ──────────────────────────────────────── # ── OpenAI (default) ────────────────────────────────────────
# model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc. # model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc.
clean_model = use_model.replace("openai/", "") clean_model = use_model.replace("openai/", "")

View file

@ -16,3 +16,4 @@ celery_app.conf.task_serializer = "json"
celery_app.conf.result_serializer = "json" celery_app.conf.result_serializer = "json"
celery_app.conf.accept_content = ["json"] celery_app.conf.accept_content = ["json"]
celery_app.conf.worker_hijack_root_logger = False # Don't override our JSON logging celery_app.conf.worker_hijack_root_logger = False # Don't override our JSON logging
celery_app.conf.broker_connection_retry_on_startup = True

View file

@ -1,5 +1,3 @@
version: "3.8"
services: services:
postgres: postgres:
image: pgvector/pgvector:pg16 image: pgvector/pgvector:pg16
@ -37,6 +35,9 @@ services:
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
- chroma_data:/app/chroma_data - chroma_data:/app/chroma_data
networks:
- default
- danvics_speech
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@ -68,6 +69,18 @@ services:
- redis_data:/data - redis_data:/data
restart: unless-stopped restart: unless-stopped
quiz-telegram-bot:
build: ./telegram-bot
env_file:
- ./telegram-bot/.env
environment:
- DATABASE_URL=postgresql://pedquiz:${POSTGRES_PASSWORD}@postgres:5432/pedquiz
- PUBLIC_APP_URL=${APP_URL:-https://pedshub.com}
depends_on:
postgres:
condition: service_healthy
restart: unless-stopped
# ── Logging: Loki + Promtail + Grafana ────────────────────────────── # ── Logging: Loki + Promtail + Grafana ──────────────────────────────
loki: loki:
image: grafana/loki:3.3.2 image: grafana/loki:3.3.2
@ -132,3 +145,7 @@ volumes:
loki_data: loki_data:
grafana_data: grafana_data:
promtail_positions: promtail_positions:
networks:
danvics_speech:
external: true

View file

@ -705,7 +705,7 @@ Convert text to speech audio.
} }
``` ```
- **Response:** Binary `audio/mpeg` data - **Response:** Binary `audio/mpeg` data
- **Notes:** Supports OpenAI TTS (`tts-1:alloy`), ElevenLabs (`elevenlabs/<voice_id>`), Google Cloud TTS (`google/<voice_name>`), and AWS Polly (`polly/<VoiceId>`). - **Notes:** Supports LiteLLM-routed local TTS voices, e.g. `local-kokoro-tts:am_adam`. The prefix before `:` is the LiteLLM model route; the suffix is the speaker voice sent to that route.
--- ---
@ -993,14 +993,14 @@ Discover available TTS voices from a provider.
Get system settings (registration enabled, embedding model, Polly enabled). Get system settings (registration enabled, embedding model, Polly enabled).
- **Auth:** Admin - **Auth:** Admin
- **Response:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}` - **Response:** `{"registration_enabled": bool, "embedding_model": "string"}`
#### PUT `/api/admin/settings` #### PUT `/api/admin/settings`
Update system settings. Update system settings.
- **Auth:** Admin - **Auth:** Admin
- **Request body:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}` - **Request body:** `{"registration_enabled": bool, "embedding_model": "string"}`
- **Response:** `{"success": true, "message": "Settings updated"}` - **Response:** `{"success": true, "message": "Settings updated"}`
### Embedding Management ### Embedding Management

View file

@ -84,7 +84,6 @@ Multi-provider TTS generation. Provider is determined by `model_id` convention:
| `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon | | `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon |
| `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model | | `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model |
| `google/<voice_name>` | Google Cloud TTS | Parses language code from voice name | | `google/<voice_name>` | Google Cloud TTS | Parses language code from voice name |
| `polly/<VoiceId>` | AWS Polly Neural | e.g. `polly/Joanna` |
**OpenAI key resolution order:** per-model API key > `OPENAI_API_KEY` (direct) > `LITELLM_API_KEY` (proxy). When using `OPENAI_API_KEY`, calls go directly to `api.openai.com`; otherwise uses `LITELLM_API_BASE`. **OpenAI key resolution order:** per-model API key > `OPENAI_API_KEY` (direct) > `LITELLM_API_KEY` (proxy). When using `OPENAI_API_KEY`, calls go directly to `api.openai.com`; otherwise uses `LITELLM_API_BASE`.

View file

@ -1,4 +1,4 @@
FROM node:18-alpine AS build FROM node:20-alpine AS build
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./

File diff suppressed because it is too large Load diff

View file

@ -6,33 +6,39 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"dependencies": { "dependencies": {
"@milkdown/core": "^7.6.2",
"@milkdown/ctx": "^7.6.2",
"@milkdown/plugin-history": "^7.6.2",
"@milkdown/plugin-listener": "^7.6.2",
"@milkdown/plugin-math": "^7.5.9",
"@milkdown/preset-commonmark": "^7.6.2",
"@milkdown/preset-gfm": "^7.6.2",
"@milkdown/react": "^7.6.2",
"@milkdown/theme-nord": "^7.6.2",
"axios": "^1.6.7", "axios": "^1.6.7",
"katex": "^0.16.11",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "^6.22.0", "react-router-dom": "^6.22.0",
"rehype-katex": "^7.0.1",
"rehype-raw": "^7.0.0", "rehype-raw": "^7.0.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"@milkdown/core": "^7.6.2", "remark-math": "^6.0.0"
"@milkdown/ctx": "^7.6.2",
"@milkdown/preset-commonmark": "^7.6.2",
"@milkdown/preset-gfm": "^7.6.2",
"@milkdown/plugin-listener": "^7.6.2",
"@milkdown/plugin-history": "^7.6.2",
"@milkdown/react": "^7.6.2",
"@milkdown/theme-nord": "^7.6.2",
"@milkdown/plugin-math": "^7.5.9",
"katex": "^0.16.11",
"remark-math": "^6.0.0",
"rehype-katex": "^7.0.1"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.2.55", "@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19", "@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^6.0.1",
"vite": "^5.1.0" "jsdom": "^29.1.1",
"vite": "^8.0.11",
"vitest": "^4.1.5"
} }
} }

View file

@ -1,33 +1,39 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom' import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom'
import { AuthProvider, useAuth } from './context/AuthContext' import { AuthProvider, useAuth } from './context/AuthContext'
import { ThemeProvider } from './context/ThemeContext' import { ThemeProvider } from './context/ThemeContext'
import Navbar from './components/Navbar' import Navbar from './components/Navbar'
import LoginPage from './pages/LoginPage'
import RegisterPage from './pages/RegisterPage' const LoginPage = lazy(() => import('./pages/LoginPage'))
import DashboardPage from './pages/DashboardPage' const RegisterPage = lazy(() => import('./pages/RegisterPage'))
import UploadPage from './pages/UploadPage' const DashboardPage = lazy(() => import('./pages/DashboardPage'))
import DocumentDetailPage from './pages/DocumentDetailPage' const UploadPage = lazy(() => import('./pages/UploadPage'))
import QuizPage from './pages/QuizPage' const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
import QuizzesPage from './pages/QuizzesPage' const QuizPage = lazy(() => import('./pages/QuizPage'))
import ResultsPage from './pages/ResultsPage' const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
import AdminPage from './pages/AdminPage' const ResultsPage = lazy(() => import('./pages/ResultsPage'))
import AccountPage from './pages/AccountPage' const AdminPage = lazy(() => import('./pages/AdminPage'))
import SettingsPage from './pages/SettingsPage' const AccountPage = lazy(() => import('./pages/AccountPage'))
import QuestionBankPage from './pages/QuestionBankPage' const SettingsPage = lazy(() => import('./pages/SettingsPage'))
import JobsPage from './pages/JobsPage' const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
import TrashPage from './pages/TrashPage' const JobsPage = lazy(() => import('./pages/JobsPage'))
import QuizEditPage from './pages/QuizEditPage' const TrashPage = lazy(() => import('./pages/TrashPage'))
import VerifyEmailPage from './pages/VerifyEmailPage' const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
import ForgotPasswordPage from './pages/ForgotPasswordPage' const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
import ResetPasswordPage from './pages/ResetPasswordPage' const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
import NotFoundPage from './pages/NotFoundPage' const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
import LandingPage from './pages/LandingPage' const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
import FlashcardsPage from './pages/FlashcardsPage' const LandingPage = lazy(() => import('./pages/LandingPage'))
import FlashcardStudyPage from './pages/FlashcardStudyPage' const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
import CoursesPage from './pages/CoursesPage' const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
import CourseDetailPage from './pages/CourseDetailPage' const CoursesPage = lazy(() => import('./pages/CoursesPage'))
import CourseEditorPage from './pages/CourseEditorPage' const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
import SsoCallbackPage from './pages/SsoCallbackPage' const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage'))
const SsoCallbackPage = lazy(() => import('./pages/SsoCallbackPage'))
function LoadingFallback() {
return <div className="loading"><div className="spinner" /></div>
}
// Layout wrapper for authenticated app pages (Navbar + container + footer) // Layout wrapper for authenticated app pages (Navbar + container + footer)
function AppLayout() { function AppLayout() {
@ -47,7 +53,7 @@ function AppLayout() {
// Guard: redirect to /home if not logged in, or to / if not moderator // Guard: redirect to /home if not logged in, or to / if not moderator
function RequireAuth({ moderator = false }) { function RequireAuth({ moderator = false }) {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner" /></div> if (loading) return <LoadingFallback />
if (!user) return <Navigate to="/home" replace /> if (!user) return <Navigate to="/home" replace />
if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace /> if (moderator && user.role !== 'admin' && user.role !== 'moderator') return <Navigate to="/" replace />
return <Outlet /> return <Outlet />
@ -55,52 +61,54 @@ function RequireAuth({ moderator = false }) {
function AppRoutes() { function AppRoutes() {
const { user, loading } = useAuth() const { user, loading } = useAuth()
if (loading) return <div className="loading"><div className="spinner" /></div> if (loading) return <LoadingFallback />
return ( return (
<Routes> <Suspense fallback={<LoadingFallback />}>
{/* Always public */} <Routes>
<Route path="/home" element={<LandingPage />} /> {/* Always public */}
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} /> <Route path="/home" element={<LandingPage />} />
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} /> <Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
<Route path="/verify-email" element={<VerifyEmailPage />} /> <Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
<Route path="/forgot-password" element={<ForgotPasswordPage />} /> <Route path="/verify-email" element={<VerifyEmailPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} /> <Route path="/forgot-password" element={<ForgotPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} /> <Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/sso-callback" element={<SsoCallbackPage />} />
{/* Authenticated app — wrapped in AppLayout */} {/* Authenticated app — wrapped in AppLayout */}
<Route element={<RequireAuth />}> <Route element={<RequireAuth />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/" element={<DashboardPage />} /> <Route path="/" element={<DashboardPage />} />
<Route path="/quizzes" element={<QuizzesPage />} /> <Route path="/quizzes" element={<QuizzesPage />} />
<Route path="/quizzes/:id" element={<QuizPage />} /> <Route path="/quizzes/:id" element={<QuizPage />} />
<Route path="/results/:id" element={<ResultsPage />} /> <Route path="/results/:id" element={<ResultsPage />} />
<Route path="/documents/:id" element={<DocumentDetailPage />} /> <Route path="/documents/:id" element={<DocumentDetailPage />} />
<Route path="/account" element={<AccountPage />} /> <Route path="/account" element={<AccountPage />} />
<Route path="/settings" element={<SettingsPage />} /> <Route path="/settings" element={<SettingsPage />} />
<Route path="/question-bank" element={<QuestionBankPage />} /> <Route path="/question-bank" element={<QuestionBankPage />} />
<Route path="/flashcards" element={<FlashcardsPage />} /> <Route path="/flashcards" element={<FlashcardsPage />} />
<Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} /> <Route path="/flashcards/:deckId/study" element={<FlashcardStudyPage />} />
<Route path="/courses" element={<CoursesPage />} /> <Route path="/courses" element={<CoursesPage />} />
<Route path="/courses/:courseId" element={<CourseDetailPage />} /> <Route path="/courses/:courseId" element={<CourseDetailPage />} />
<Route path="/courses/:courseId/edit" element={<CourseEditorPage />} /> <Route path="/courses/:courseId/edit" element={<CourseEditorPage />} />
<Route path="/admin" element={<AdminPage />} /> <Route path="/admin" element={<AdminPage />} />
</Route>
</Route> </Route>
</Route>
{/* Moderator-only */} {/* Moderator-only */}
<Route element={<RequireAuth moderator />}> <Route element={<RequireAuth moderator />}>
<Route element={<AppLayout />}> <Route element={<AppLayout />}>
<Route path="/upload" element={<UploadPage />} /> <Route path="/upload" element={<UploadPage />} />
<Route path="/quizzes/:id/edit" element={<QuizEditPage />} /> <Route path="/quizzes/:id/edit" element={<QuizEditPage />} />
<Route path="/jobs" element={<JobsPage />} /> <Route path="/jobs" element={<JobsPage />} />
<Route path="/trash" element={<TrashPage />} /> <Route path="/trash" element={<TrashPage />} />
</Route>
</Route> </Route>
</Route>
{/* Catch-all */} {/* Catch-all */}
<Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} /> <Route path="*" element={user ? <NotFoundPage /> : <Navigate to="/home" replace />} />
</Routes> </Routes>
</Suspense>
) )
} }

View file

@ -0,0 +1,50 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '../api/client'
import ConfirmButton from './ConfirmButton'
export default function InProgressQuizzes() {
const [inProgress, setInProgress] = useState([])
const navigate = useNavigate()
useEffect(() => {
api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {})
}, [])
const deleteAttempt = async (attemptId) => {
await api.delete(`/attempts/${attemptId}`)
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
}
if (inProgress.length === 0) return null
return (
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
In Progress ({inProgress.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{inProgress.map(a => (
<div key={a.attempt_id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
<ConfirmButton
label="Delete" confirmLabel="Yes, delete"
onConfirm={() => deleteAttempt(a.attempt_id)}
/>
</div>
</div>
))}
</div>
</div>
)
}

View file

@ -0,0 +1,245 @@
import { useEffect, useRef, useState } from 'react'
import api from '../api/client'
const TAB_POSITION_KEY = 'pedshub_mynote_tab_position'
function formatSavedAt(value) {
if (!value) return 'Not saved yet'
return `Saved ${new Date(value).toLocaleString()}`
}
function clampPosition(x, y, width, height) {
const margin = 8
return {
x: Math.min(Math.max(margin, x), Math.max(margin, window.innerWidth - width - margin)),
y: Math.min(Math.max(margin, y), Math.max(margin, window.innerHeight - height - margin)),
}
}
export default function MyNote({ variant = 'tab' }) {
const [open, setOpen] = useState(false)
const [content, setContent] = useState('')
const [updatedAt, setUpdatedAt] = useState(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const [position, setPosition] = useState(null)
const [dragging, setDragging] = useState(false)
const tabRef = useRef(null)
const saveRef = useRef(null)
const loadedRef = useRef(false)
const dragRef = useRef(null)
const draggedRef = useRef(false)
const textareaRef = useRef(null)
const loadNote = async () => {
if (loadedRef.current) return
setLoading(true)
setError('')
try {
const res = await api.get('/mynote')
setContent(res.data.content || '')
setUpdatedAt(res.data.updated_at || null)
loadedRef.current = true
} catch {
setError('Could not load MyNote')
} finally {
setLoading(false)
}
}
useEffect(() => {
if (variant === 'card') loadNote()
}, [variant])
useEffect(() => {
if (variant !== 'tab') return
const rect = tabRef.current?.getBoundingClientRect()
let next = null
try {
const saved = JSON.parse(localStorage.getItem(TAB_POSITION_KEY) || 'null')
if (saved && Number.isFinite(saved.x) && Number.isFinite(saved.y)) {
next = clampPosition(saved.x, saved.y, rect?.width || 120, rect?.height || 48)
}
} catch {
next = null
}
if (!next && rect) next = { x: rect.left, y: rect.top }
if (next) setPosition(next)
const handleResize = () => {
setPosition(current => {
if (!current) return current
const resizedRect = tabRef.current?.getBoundingClientRect()
const clamped = clampPosition(current.x, current.y, resizedRect?.width || 120, resizedRect?.height || 48)
localStorage.setItem(TAB_POSITION_KEY, JSON.stringify(clamped))
return clamped
})
}
window.addEventListener('resize', handleResize)
return () => window.removeEventListener('resize', handleResize)
}, [variant])
useEffect(() => {
if (variant !== 'tab') return
setPosition(current => {
if (!current) return current
const rect = tabRef.current?.getBoundingClientRect()
if (!rect) return current
return clampPosition(current.x, current.y, rect.width, rect.height)
})
}, [open, variant])
useEffect(() => {
if (!loadedRef.current) return
clearTimeout(saveRef.current)
saveRef.current = setTimeout(async () => {
setSaving(true)
setError('')
try {
const res = await api.put('/mynote', { content })
setUpdatedAt(res.data.updated_at || null)
} catch {
setError('Could not save MyNote')
} finally {
setSaving(false)
}
}, 700)
return () => clearTimeout(saveRef.current)
}, [content])
useEffect(() => {
if (!open || loading) return
requestAnimationFrame(() => {
const textarea = textareaRef.current
if (!textarea) return
const end = textarea.value.length
textarea.focus()
textarea.setSelectionRange(end, end)
textarea.scrollTop = textarea.scrollHeight
})
}, [open, loading])
const openNote = () => {
setOpen(true)
loadNote()
}
const startDrag = event => {
if (variant !== 'tab' || event.button !== 0) return
if (event.target.closest('button, textarea') && event.currentTarget !== event.target) return
const rect = tabRef.current?.getBoundingClientRect()
if (!rect) return
dragRef.current = {
pointerId: event.pointerId,
offsetX: event.clientX - rect.left,
offsetY: event.clientY - rect.top,
startX: event.clientX,
startY: event.clientY,
moved: false,
position: { x: rect.left, y: rect.top },
}
event.currentTarget.setPointerCapture?.(event.pointerId)
}
const moveDrag = event => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
const rect = tabRef.current?.getBoundingClientRect()
if (!rect) return
const next = clampPosition(event.clientX - drag.offsetX, event.clientY - drag.offsetY, rect.width, rect.height)
drag.position = next
if (Math.abs(event.clientX - drag.startX) > 4 || Math.abs(event.clientY - drag.startY) > 4) {
drag.moved = true
setDragging(true)
}
setPosition(next)
event.preventDefault()
}
const stopDrag = event => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
if (drag.moved) {
localStorage.setItem(TAB_POSITION_KEY, JSON.stringify(drag.position))
}
draggedRef.current = drag.moved
dragRef.current = null
setDragging(false)
setTimeout(() => {
draggedRef.current = false
}, 0)
}
const openFromTabButton = event => {
if (draggedRef.current) {
event.preventDefault()
return
}
openNote()
}
const preview = content.trim()
? content.trim().slice(0, 150) + (content.trim().length > 150 ? '...' : '')
: 'Your single study note will appear here once you start writing during quizzes.'
const editor = (
<div className="mynote-editor">
<div
className={`mynote-editor-header ${variant === 'tab' ? 'mynote-drag-handle' : ''}`}
onPointerDown={variant === 'tab' ? startDrag : undefined}
>
<div>
<div className="mynote-title">MyNote</div>
<div className="mynote-status">{variant === 'tab' ? 'Drag this header to move. ' : ''}{saving ? 'Saving...' : formatSavedAt(updatedAt)}</div>
</div>
{variant === 'tab' && <button className="mynote-close" type="button" onClick={() => setOpen(false)}>Collapse</button>}
</div>
{error && <div className="mynote-error">{error}</div>}
<textarea
ref={textareaRef}
value={content}
onChange={e => setContent(e.target.value)}
placeholder={loading ? 'Loading MyNote...' : 'Capture one running note across all quizzes...'}
disabled={loading}
/>
</div>
)
if (variant === 'card') {
return (
<div className="card mynote-card">
<div className="mynote-card-copy">
<div className="mynote-eyebrow">Saved across all quiz sessions</div>
<h2>MyNote</h2>
<p>{preview}</p>
<span>{formatSavedAt(updatedAt)}</span>
</div>
<button className="btn btn-primary" type="button" onClick={openNote}>Open MyNote</button>
{open && (
<div className="mynote-modal" role="dialog" aria-modal="true" aria-label="MyNote" onClick={() => setOpen(false)}>
<div className="mynote-modal-panel" onClick={e => e.stopPropagation()}>
<button className="mynote-modal-close" type="button" onClick={() => setOpen(false)} aria-label="Close MyNote">×</button>
{editor}
</div>
</div>
)}
</div>
)
}
return (
<div
ref={tabRef}
className={`mynote-tab ${open ? 'open' : ''} ${dragging ? 'dragging' : ''}`}
style={position ? { left: position.x, top: position.y, right: 'auto', bottom: 'auto' } : undefined}
onPointerMove={moveDrag}
onPointerUp={stopDrag}
onPointerCancel={stopDrag}
>
{!open ? (
<button className="mynote-tab-button" type="button" onPointerDown={startDrag} onClick={openFromTabButton}>MyNote</button>
) : editor}
</div>
)
}

View file

@ -61,7 +61,7 @@
--navbar-fg: #f0e8d8; --navbar-fg: #f0e8d8;
--badge-radius: 4px; --badge-radius: 4px;
--font-body: 'Source Serif 4', Georgia, serif; --font-body: 'Source Serif 4', Georgia, serif;
--font-heading: 'Playfair Display', 'Source Serif 4', serif; --font-heading: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
--font-ui: 'Inter', system-ui, sans-serif; --font-ui: 'Inter', system-ui, sans-serif;
} }
@ -71,9 +71,9 @@ body[data-theme="markdown"] {
font-size: 15.5px; font-size: 15.5px;
line-height: 1.7; line-height: 1.7;
} }
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: 0; } [data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: -0.02em; }
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 700; letter-spacing: -0.02em; } [data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 760; letter-spacing: -0.03em; line-height: 1.12; }
[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 600; } [data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 680; letter-spacing: -0.02em; line-height: 1.2; }
[data-theme="markdown"] h3 { font-family: var(--font-ui); font-weight: 600; } [data-theme="markdown"] h3 { font-family: var(--font-ui); font-weight: 600; }
[data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 18px; } [data-theme="markdown"] .card h2 { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 18px; }
[data-theme="markdown"] .question-card { border-left: 3px solid var(--primary); } [data-theme="markdown"] .question-card { border-left: 3px solid var(--primary); }
@ -135,6 +135,34 @@ body {
} }
.card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; } .card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; }
.quiz-header-card .quiz-header-title,
[data-theme="markdown"] .quiz-header-card .quiz-header-title {
margin: 0 0 2px;
padding: 0;
border: 0;
font-family: var(--font-ui, 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif);
font-size: clamp(0.98rem, 1.8vw, 1.14rem);
font-weight: 720;
line-height: 1.18;
letter-spacing: -0.025em;
}
.quiz-nav-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-top: 14px;
}
.quiz-nav-controls-top {
margin: -4px 0 16px;
padding: 10px 12px;
background: color-mix(in srgb, var(--card-bg) 92%, transparent);
border: var(--card-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
}
/* ── Buttons ────────────────────────────────────────────────── */ /* ── Buttons ────────────────────────────────────────────────── */
.btn { .btn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
@ -182,6 +210,264 @@ body {
box-shadow: var(--card-shadow); box-shadow: var(--card-shadow);
} }
.question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; } .question-card h3 { font-size: 1.05rem; line-height: 1.6; margin-bottom: 20px; color: var(--text); font-weight: 600; }
.question-image-preview {
display: block;
margin: 10px 0;
padding: 0;
border: 0;
background: transparent;
cursor: zoom-in;
text-align: left;
}
.question-image-preview img {
display: block;
max-width: 100%;
max-height: 280px;
border-radius: 8px;
border: 1px solid var(--border);
}
.image-lightbox {
position: fixed;
inset: 0;
z-index: 1000;
padding: 24px;
background: rgba(15, 23, 42, 0.82);
cursor: zoom-out;
}
.image-lightbox-viewport {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
overflow: auto;
cursor: default;
}
.image-lightbox img {
display: block;
border-radius: 10px;
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35);
cursor: default;
}
.image-lightbox-controls {
position: fixed;
top: 16px;
left: 50%;
z-index: 1001;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 9px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
background: rgba(15, 23, 42, 0.82);
color: white;
transform: translateX(-50%);
}
.image-lightbox-controls button {
min-width: 34px;
height: 30px;
padding: 0 10px;
border: 1px solid rgba(255, 255, 255, 0.28);
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
color: white;
cursor: pointer;
}
.image-lightbox-controls button:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.image-lightbox-controls span {
min-width: 48px;
text-align: center;
font-size: 0.86rem;
font-weight: 700;
}
.image-lightbox-close {
position: fixed;
top: 16px;
right: 16px;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 999px;
background: rgba(15, 23, 42, 0.72);
color: white;
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
}
.mynote-tab {
position: fixed;
right: 18px;
bottom: 18px;
z-index: 45;
}
.mynote-tab.dragging {
user-select: none;
}
.mynote-tab-button {
border: 1px solid rgba(37, 99, 235, 0.22);
border-radius: 999px;
padding: 11px 18px;
background: linear-gradient(135deg, #2563eb, #7c3aed);
color: white;
box-shadow: 0 16px 40px rgba(37, 99, 235, 0.28);
cursor: grab;
font-weight: 800;
letter-spacing: -0.02em;
touch-action: none;
}
.mynote-editor {
width: min(420px, calc(100vw - 28px));
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 18px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--card-bg) 96%, transparent), var(--card-bg)),
radial-gradient(circle at top left, rgba(37, 99, 235, 0.12), transparent 42%);
box-shadow: 0 24px 70px rgba(15, 23, 42, 0.24);
overflow: hidden;
}
.mynote-editor-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
}
.mynote-drag-handle {
cursor: grab;
touch-action: none;
user-select: none;
}
.mynote-tab.dragging .mynote-tab-button,
.mynote-tab.dragging .mynote-drag-handle {
cursor: grabbing;
}
.mynote-title {
font-weight: 850;
font-size: 1rem;
letter-spacing: -0.03em;
color: var(--text);
}
.mynote-status {
margin-top: 2px;
color: var(--text-muted);
font-size: 0.75rem;
}
.mynote-close,
.mynote-modal-close {
border: 0;
background: var(--border);
color: var(--text);
border-radius: 999px;
cursor: pointer;
}
.mynote-close { padding: 6px 10px; font-size: 0.76rem; }
.mynote-error {
margin: 10px 16px 0;
color: var(--wrong-fg);
font-size: 0.8rem;
}
.mynote-editor textarea {
width: 100%;
min-height: 260px;
resize: vertical;
border: 0;
outline: 0;
padding: 16px;
background: transparent;
color: var(--text);
font: inherit;
line-height: 1.65;
}
.mynote-card {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
border-left: 4px solid #7c3aed;
}
.mynote-card-copy { min-width: 0; }
.mynote-eyebrow {
margin-bottom: 4px;
color: #7c3aed;
font-size: 0.72rem;
font-weight: 850;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.mynote-card h2 { margin: 0 0 6px; }
.mynote-card p {
margin: 0 0 6px;
color: var(--text-muted);
font-size: 0.9rem;
line-height: 1.55;
overflow-wrap: anywhere;
}
.mynote-card span {
color: var(--text-subtle);
font-size: 0.76rem;
}
.mynote-modal {
position: fixed;
inset: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 18px;
background: rgba(15, 23, 42, 0.55);
}
.mynote-modal-panel {
position: relative;
width: min(720px, 100%);
}
.mynote-modal-panel .mynote-editor { width: 100%; }
.mynote-modal-panel .mynote-editor textarea { min-height: min(58vh, 520px); }
.mynote-modal-close {
position: absolute;
top: -12px;
right: -12px;
z-index: 1;
width: 34px;
height: 34px;
background: var(--navbar-bg);
color: white;
font-size: 1.2rem;
}
.manual-highlight-toolbar {
display: inline-flex;
gap: 6px;
flex-wrap: wrap;
align-items: center;
padding: 4px;
border-radius: 10px;
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
}
.manual-highlight-segment { user-select: text; -webkit-user-select: text; touch-action: auto; }
.manual-highlight-active {
background: linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%);
border-radius: 2px;
cursor: context-menu;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.speech-highlight-active {
background: rgba(96, 165, 250, 0.18);
box-shadow: inset 0 -2px 0 rgba(59, 130, 246, 0.72);
border-radius: 3px;
padding: 0 2px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
}
.manual-highlight-active.speech-highlight-active {
background:
linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%),
rgba(96, 165, 250, 0.18);
}
/* Options */ /* Options */
.question-card .options { display: flex; flex-direction: column; gap: 10px; } .question-card .options { display: flex; flex-direction: column; gap: 10px; }
@ -189,7 +475,7 @@ body {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px; padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px;
cursor: pointer; transition: border-color 0.12s, background 0.12s; cursor: pointer; transition: border-color 0.12s, background 0.12s;
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none; background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: text;
-webkit-tap-highlight-color: transparent; touch-action: manipulation; -webkit-tap-highlight-color: transparent; touch-action: manipulation;
} }
.question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); } .question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); }
@ -349,6 +635,43 @@ body {
/* Mobile quiz: hide sidebar, show toggle */ /* Mobile quiz: hide sidebar, show toggle */
.quiz-sidebar { display: none; } .quiz-sidebar { display: none; }
.quiz-nav-toggle { display: inline-flex; } .quiz-nav-toggle { display: inline-flex; }
.quiz-nav-controls { gap: 6px; }
.quiz-nav-controls .btn { flex: 1; justify-content: center; padding-left: 10px; padding-right: 10px; }
.quiz-nav-controls .quiz-nav-toggle { flex: 0 0 auto; }
.manual-highlight-toolbar {
display: grid;
grid-template-columns: auto;
gap: 8px;
width: 100%;
margin-top: 4px;
position: sticky;
top: 54px;
z-index: 5;
border: 1px solid var(--border);
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.08);
}
.manual-highlight-toolbar .btn {
justify-content: center;
min-height: 36px;
white-space: normal;
}
.mynote-tab {
right: 12px;
bottom: 12px;
}
.mynote-tab.open {
left: 10px;
}
.mynote-editor textarea {
min-height: 220px;
}
.mynote-card {
align-items: stretch;
flex-direction: column;
}
.mynote-card .btn {
justify-content: center;
}
} }
/* ── Lesson content (markdown/HTML) ───────────────────────────── */ /* ── Lesson content (markdown/HTML) ───────────────────────────── */

View file

@ -5,7 +5,7 @@ import api from '../api/client'
import Dialog from '../components/Dialog' import Dialog from '../components/Dialog'
import { useDialog } from '../hooks/useDialog' import { useDialog } from '../hooks/useDialog'
const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard'] const TASKS = ['extraction', 'tts', 'stt', 'teach', 'keyword', 'flashcard']
export default function AdminPage() { export default function AdminPage() {
const { user } = useAuth() const { user } = useAuth()
@ -14,17 +14,15 @@ export default function AdminPage() {
const [tab, setTab] = useState('models') const [tab, setTab] = useState('models')
const [users, setUsers] = useState([]) const [users, setUsers] = useState([])
const [models, setModels] = useState([]) const [models, setModels] = useState([])
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true }) const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' })
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState('') const [error, setError] = useState('')
const [success, setSuccess] = useState('') const [success, setSuccess] = useState('')
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false }) const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false })
const [newUser, setNewUser] = useState({ name: '', email: '', password: '' }) const [newUser, setNewUser] = useState({ name: '', email: '', password: '' })
// LiteLLM search state // LiteLLM search state
const [searchApiKey, setSearchApiKey] = useState('')
const [searchApiBase, setSearchApiBase] = useState('')
const [searchResults, setSearchResults] = useState([]) const [searchResults, setSearchResults] = useState([])
const [searchLoading, setSearchLoading] = useState(false) const [searchLoading, setSearchLoading] = useState(false)
const [searchError, setSearchError] = useState('') const [searchError, setSearchError] = useState('')
@ -32,8 +30,7 @@ export default function AdminPage() {
const [searchTaskHint, setSearchTaskHint] = useState('extraction') const [searchTaskHint, setSearchTaskHint] = useState('extraction')
// TTS voice discovery state // TTS voice discovery state
const [ttsProvider, setTtsProvider] = useState('elevenlabs') const [ttsProvider, setTtsProvider] = useState('litellm')
const [ttsSearchKey, setTtsSearchKey] = useState('')
const [ttsVoices, setTtsVoices] = useState([]) const [ttsVoices, setTtsVoices] = useState([])
const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false) const [ttsVoicesLoading, setTtsVoicesLoading] = useState(false)
const [ttsVoicesError, setTtsVoicesError] = useState('') const [ttsVoicesError, setTtsVoicesError] = useState('')
@ -115,7 +112,7 @@ export default function AdminPage() {
try { try {
await api.post('/admin/models', newModel) await api.post('/admin/models', newModel)
setSuccess('Model added') setSuccess('Model added')
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', is_active: true, is_default: false }) setNewModel({ name: '', model_id: '', task: 'extraction', is_active: true, is_default: false })
loadData(false) loadData(false)
} catch (err) { } catch (err) {
setError(err.response?.data?.detail || 'Failed to add model') setError(err.response?.data?.detail || 'Failed to add model')
@ -173,10 +170,7 @@ export default function AdminPage() {
setSearchResults([]) setSearchResults([])
setSearchLoading(true) setSearchLoading(true)
try { try {
const res = await api.post('/admin/litellm/models', { const res = await api.post('/admin/litellm/models', {})
api_key: searchApiKey || null,
api_base: searchApiBase || null,
})
setSearchResults(res.data.models) setSearchResults(res.data.models)
} catch (err) { } catch (err) {
setSearchError(err.response?.data?.detail || 'Failed to query models') setSearchError(err.response?.data?.detail || 'Failed to query models')
@ -188,12 +182,11 @@ export default function AdminPage() {
const searchEmbeddingModels = async () => { const searchEmbeddingModels = async () => {
setEmbedSearchError('') setEmbedSearchError('')
setEmbedSearchResults([]) setEmbedSearchResults([])
setEmbedSearchFilter('')
setEmbedSearchLoading(true) setEmbedSearchLoading(true)
try { try {
const res = await api.post('/admin/litellm/models', {}) const res = await api.post('/admin/litellm/models', { mode: 'embedding' })
const all = res.data.models || [] setEmbedSearchResults(res.data.models || [])
// Filter to likely embedding models
setEmbedSearchResults(all)
} catch (err) { } catch (err) {
setEmbedSearchError(err.response?.data?.detail || 'Failed to query models') setEmbedSearchError(err.response?.data?.detail || 'Failed to query models')
} finally { } finally {
@ -255,7 +248,6 @@ export default function AdminPage() {
try { try {
const res = await api.post('/admin/tts/voices', { const res = await api.post('/admin/tts/voices', {
provider: ttsProvider, provider: ttsProvider,
api_key: ttsSearchKey || null,
}) })
setTtsVoices(res.data.voices) setTtsVoices(res.data.voices)
} catch (err) { } catch (err) {
@ -325,27 +317,9 @@ export default function AdminPage() {
<div className="card"> <div className="card">
<h2>Search LLM Models</h2> <h2>Search LLM Models</h2>
<p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}> <p style={{ color: '#64748b', fontSize: '0.85rem', marginBottom: 16 }}>
Query your LiteLLM proxy or any OpenAI-compatible API. Works for <strong>extraction</strong>, <strong>teach</strong>, and <strong>general</strong> tasks. Query the configured LiteLLM proxy from the server environment. Works for chat, extraction, STT, and other configured tasks.
</p> </p>
<div className="grid-2"> <div className="grid-2">
<div className="form-group">
<label>API Base URL (optional uses .env if blank)</label>
<input
type="text"
value={searchApiBase}
onChange={e => setSearchApiBase(e.target.value)}
placeholder="e.g. https://litellm.myserver.com"
/>
</div>
<div className="form-group">
<label>API Key (optional uses .env if blank)</label>
<input
type="password"
value={searchApiKey}
onChange={e => setSearchApiKey(e.target.value)}
placeholder="sk-..."
/>
</div>
<div className="form-group"> <div className="form-group">
<label>Adding model for task</label> <label>Adding model for task</label>
<select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}> <select value={searchTaskHint} onChange={e => setSearchTaskHint(e.target.value)}>
@ -403,9 +377,11 @@ export default function AdminPage() {
{task} Models {task} Models
<span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}> <span style={{ fontSize: '0.75rem', color: '#64748b', marginLeft: 8, fontWeight: 400 }}>
{task === 'extraction' ? '— AI that extracts questions from PDFs' : {task === 'extraction' ? '— AI that extracts questions from PDFs' :
task === 'tts' ? '— Text-to-speech voices' : task === 'tts' ? '— Text-to-speech voices' :
task === 'teach' ? '— AI tutor shown in study mode chat' : task === 'stt' ? '— Speech-to-text models' :
'— General purpose AI'} task === 'teach' ? '— AI tutor shown in study mode chat' :
task === 'keyword' ? '— Keyword and topic classification' :
'— Flashcard generation'}
</span> </span>
</h2> </h2>
@ -416,13 +392,8 @@ export default function AdminPage() {
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 8 }}>
<select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }} <select value={ttsProvider} onChange={e => { setTtsProvider(e.target.value); setTtsVoices([]) }}
style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}> style={{ padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }}>
<option value="elevenlabs">ElevenLabs</option> <option value="litellm">LiteLLM Local</option>
<option value="polly">AWS Polly (Neural)</option>
<option value="openai">OpenAI TTS</option>
</select> </select>
<input type="password" value={ttsSearchKey} onChange={e => setTtsSearchKey(e.target.value)}
placeholder="API key (uses .env if blank)"
style={{ flex: 1, minWidth: 140, padding: '5px 10px', borderRadius: 6, border: '1px solid var(--border)', fontSize: '0.85rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
<button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}> <button className="btn btn-primary btn-sm" onClick={searchTtsVoices} disabled={ttsVoicesLoading}>
{ttsVoicesLoading ? 'Searching...' : 'Search'} {ttsVoicesLoading ? 'Searching...' : 'Search'}
</button> </button>
@ -546,10 +517,6 @@ export default function AdminPage() {
<label>Model ID (LiteLLM format)</label> <label>Model ID (LiteLLM format)</label>
<input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required /> <input type="text" value={newModel.model_id} onChange={e => setNewModel(m => ({ ...m, model_id: e.target.value }))} placeholder="e.g. gpt-4o-mini" required />
</div> </div>
<div className="form-group">
<label>API Key (optional override)</label>
<input type="password" value={newModel.api_key} onChange={e => setNewModel(m => ({ ...m, api_key: e.target.value }))} placeholder="Leave blank to use .env key" />
</div>
<div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}> <div className="form-group" style={{ display: 'flex', gap: 16, alignItems: 'center', marginTop: 24 }}>
<label style={{ display: 'flex', gap: 6, alignItems: 'center' }}> <label style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
<input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} /> <input type="checkbox" checked={newModel.is_default} onChange={e => setNewModel(m => ({ ...m, is_default: e.target.checked }))} />
@ -679,37 +646,6 @@ export default function AdminPage() {
</label> </label>
</div> </div>
{/* AWS Polly */}
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>
<div>
<strong style={{ display: 'block', marginBottom: 4 }}>AWS Polly Voices</strong>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)' }}>
Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab.
</span>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input
type="checkbox"
checked={settings.polly_enabled}
onChange={async (e) => {
const enabled = e.target.checked
setSettings(s => ({ ...s, polly_enabled: enabled }))
try {
await api.put('/admin/settings', { polly_enabled: enabled })
setSuccess(`AWS Polly ${enabled ? 'enabled' : 'disabled'}`)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to update setting')
setSettings(s => ({ ...s, polly_enabled: !enabled }))
}
}}
style={{ width: 'auto', accentColor: 'var(--primary)' }}
/>
<span style={{ fontSize: '0.9rem', fontWeight: 600 }}>
{settings.polly_enabled ? 'Enabled' : 'Disabled'}
</span>
</label>
</div>
{/* SSO Only */} {/* SSO Only */}
{settings.sso_configured && ( {settings.sso_configured && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>

View file

@ -2,6 +2,8 @@ import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
import LineChart from '../components/LineChart' import LineChart from '../components/LineChart'
import InProgressQuizzes from '../components/InProgressQuizzes'
import MyNote from '../components/MyNote'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
function greeting(name) { function greeting(name) {
@ -13,7 +15,6 @@ function greeting(name) {
export default function DashboardPage() { export default function DashboardPage() {
const { user } = useAuth() const { user } = useAuth()
const isModerator = user?.role === 'admin' || user?.role === 'moderator'
const [stats, setStats] = useState(null) const [stats, setStats] = useState(null)
const [history, setHistory] = useState([]) const [history, setHistory] = useState([])
const [selectedQuizId, setSelectedQuizId] = useState(null) const [selectedQuizId, setSelectedQuizId] = useState(null)
@ -47,7 +48,7 @@ export default function DashboardPage() {
if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id) if (histRes.data.length > 0) setSelectedQuizId(histRes.data[0].quiz_id)
}).catch(console.error) }).catch(console.error)
.finally(() => setLoading(false)) .finally(() => setLoading(false))
}, [isModerator]) }, [])
if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div> if (loading) return <div className="loading"><div className="spinner"></div> Loading...</div>
@ -75,6 +76,10 @@ export default function DashboardPage() {
</div> </div>
)} )}
<InProgressQuizzes />
<MyNote variant="card" />
{/* Performance graph with dropdown */} {/* Performance graph with dropdown */}
{history.length > 0 && ( {history.length > 0 && (
<div className="card"> <div className="card">
@ -153,12 +158,6 @@ export default function DashboardPage() {
)} )}
</div> </div>
)} )}
{isModerator && (
<div style={{ textAlign: 'center', marginTop: 8 }}>
<Link to="/upload" className="btn btn-secondary">Manage Documents</Link>
</div>
)}
</div> </div>
) )
} }

View file

@ -39,6 +39,11 @@ const FEATURES = [
title: 'AI Tutor Built In', title: 'AI Tutor Built In',
desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.', desc: 'Ask anything mid-study. The AI tutor knows the current question, the correct answer, and pulls in related questions from your bank for context.',
}, },
{
icon: '🩺',
title: 'Clinical Assistant',
desc: 'Access pediatric workflows, bedside calculators, dosing support, and clinical references alongside your study tools.',
},
{ {
icon: '🔊', icon: '🔊',
title: 'Audio Mode', title: 'Audio Mode',
@ -430,7 +435,7 @@ export default function LandingPage() {
</div> </div>
</section> </section>
{/* ── AI Scribe section ──────────────────────────────────────────────── */} {/* ── Clinical tools section ─────────────────────────────────────────── */}
<section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}> <section style={{ background: 'var(--navbar-bg)', color: 'var(--navbar-fg)', padding: '80px 24px' }}>
<div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 40, alignItems: 'center' }}> <div style={{ maxWidth: 1000, margin: '0 auto', display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 320px), 1fr))', gap: 40, alignItems: 'center' }}>
<div> <div>
@ -438,22 +443,23 @@ export default function LandingPage() {
Also from PedsHub Also from PedsHub
</div> </div>
<h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}> <h2 style={{ fontSize: 'clamp(1.6rem, 3vw, 2rem)', fontWeight: 800, letterSpacing: '-0.02em', margin: '0 0 16px', color: '#f1f5f9' }}>
Pediatric AI Scribe Pediatric Clinical Tools
</h2> </h2>
<p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}> <p style={{ color: '#94a3b8', lineHeight: 1.7, marginBottom: 28, fontSize: '0.95rem' }}>
A clinical assistant built for pediatric providers. Voice-to-note documentation, A pediatric clinical assistant for daily practice: well visit workflows,
age-specific well visit workflows, developmental milestones, vaccine schedules, developmental milestones, vaccine schedules, catch-up planning,
catch-up planners, automatic ICD-10 billing codes, and a full pediatric bedside calculators, dosing support, emergency pathways, and concise
calculators &amp; bedside emergency reference all in one tool. clinical references in one place, powered by Peds-AI clinical assistant tools.
</p> </p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32, overflow: 'hidden' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 10, marginBottom: 32, overflow: 'hidden' }}>
{[ {[
{ text: '🩺 Well visit planner from newborn through adolescence' }, { text: '🩺 Well visit planner from newborn through adolescence' },
{ text: '📋 Developmental milestone tracking across 4 domains' }, { text: '📋 Developmental milestone tracking across 4 domains' },
{ text: '💉 Full AAP/ACIP vaccine schedule with catch-up planner' }, { text: '💉 Full AAP/ACIP vaccine schedule with catch-up planner' },
{ text: '🎙 AI scribe — speak, get a structured note' }, { text: '💊 Weight-based dosing and pediatric calculators' },
{ text: '💊 Automatic ICD-10 and CPT billing codes' }, { text: '🤖 Peds-AI clinical assistant for quick pediatric guidance' },
{ text: '🚨 Bedside calculators — weight-based dosing, emergency pathways (sepsis, status epilepticus, RSI, burns, anaphylaxis)', badge: 'NEW' }, { text: '🚨 Emergency pathways for sepsis, status epilepticus, RSI, burns, and anaphylaxis', badge: 'NEW' },
{ text: '🎙 Optional voice-to-note support for structured documentation' },
].map((item, i) => ( ].map((item, i) => (
<div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start', overflowWrap: 'break-word', wordBreak: 'break-word', minWidth: 0 }}> <div key={i} style={{ fontSize: '0.88rem', color: '#cbd5e1', display: 'flex', gap: 8, alignItems: 'flex-start', overflowWrap: 'break-word', wordBreak: 'break-word', minWidth: 0 }}>
<span>{item.text}</span> <span>{item.text}</span>
@ -472,7 +478,7 @@ export default function LandingPage() {
className="btn btn-primary" className="btn btn-primary"
style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }} style={{ display: 'inline-block', textDecoration: 'none', padding: '11px 24px', borderRadius: 10 }}
> >
Open AI Scribe Open Clinical Tools
</a> </a>
</div> </div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 130px), 1fr))', gap: 10 }}> <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 130px), 1fr))', gap: 10 }}>
@ -480,9 +486,8 @@ export default function LandingPage() {
{ icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' }, { icon: '📅', label: 'Well Visits', sub: '2wk → 18yr' },
{ icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' }, { icon: '🧠', label: 'Milestones', sub: '2mo → 5yr' },
{ icon: '💉', label: 'Vaccines', sub: 'Full schedule' }, { icon: '💉', label: 'Vaccines', sub: 'Full schedule' },
{ icon: '🎙', label: 'AI Scribe', sub: 'Voice-to-note' }, { icon: '💊', label: 'Dosing', sub: 'Weight-based' },
{ icon: '💊', label: 'ICD-10', sub: 'Auto-suggested' }, { icon: '📋', label: 'Clinical Assistant', sub: 'References + plans' },
{ icon: '📋', label: 'SOAP Notes', sub: 'Structured' },
{ icon: '🚨', label: 'Bedside', sub: 'Dosing + pathways' }, { icon: '🚨', label: 'Bedside', sub: 'Dosing + pathways' },
].map((item, i) => ( ].map((item, i) => (
<div key={i} style={{ <div key={i} style={{
@ -532,7 +537,7 @@ export default function LandingPage() {
<div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}> <div style={{ display: 'flex', gap: 24, fontSize: '0.85rem' }}>
<button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button> <button onClick={() => setAuthModal('login')} style={{ color: 'rgba(226,232,240,0.45)', background: 'none', border: 'none', cursor: 'pointer', fontSize: 'inherit' }}>Sign In</button>
<a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a> <a href="#contact" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Contact</a>
<a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>AI Scribe</a> <a href="https://app.pedshub.com" target="_blank" rel="noopener noreferrer" style={{ color: 'rgba(226,232,240,0.45)', textDecoration: 'none' }}>Clinical Tools</a>
</div> </div>
</div> </div>
</footer> </footer>

View file

@ -532,6 +532,8 @@ export default function QuestionBankPage() {
const [importFile, setImportFile] = useState(null) const [importFile, setImportFile] = useState(null)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [importResult, setImportResult] = useState(null) const [importResult, setImportResult] = useState(null)
const [qtiImporting, setQtiImporting] = useState(false)
const [qtiExporting, setQtiExporting] = useState(false)
const debounceRef = useRef(null) const debounceRef = useRef(null)
const classifyPollRef = useRef(null) const classifyPollRef = useRef(null)
const { user } = useAuth() const { user } = useAuth()
@ -700,6 +702,49 @@ export default function QuestionBankPage() {
} }
} }
const handleQtiImport = () => {
if (qtiImporting) return
const input = document.createElement('input')
input.type = 'file'
input.accept = '.xml'
input.onchange = async (e) => {
const file = e.target.files?.[0]
if (!file) return
setQtiImporting(true)
try {
const fd = new FormData()
fd.append('file', file)
const res = await api.post('/questions/import/qti', fd)
const errors = res.data.errors?.length ? ` ${res.data.errors.length} error(s).` : ''
openAlert(`Imported ${res.data.imported} of ${res.data.total_items} questions.${errors}`, { title: 'QTI Import Complete' })
loadQuestions()
} catch (err) {
openAlert(err.response?.data?.detail || 'QTI import failed', { title: 'Import Failed' })
} finally {
setQtiImporting(false)
}
}
input.click()
}
const handleQtiExport = async () => {
setQtiExporting(true)
try {
const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url
a.download = 'questions_qti.xml'
a.click()
URL.revokeObjectURL(url)
} catch (err) {
openAlert(err.response?.data?.detail || 'QTI export failed', { title: 'Export Failed' })
} finally {
setQtiExporting(false)
}
}
const toggleFavorite = async (questionId) => { const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId) const isFavorited = favorites.includes(questionId)
try { try {
@ -808,27 +853,12 @@ export default function QuestionBankPage() {
)} )}
<button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button> <button className="btn btn-secondary btn-sm" onClick={() => setShowCreateQuestion(true)}>+ Question</button>
<button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button> <button className="btn btn-secondary btn-sm" onClick={() => { setShowImport(true); setImportFile(null); setImportResult(null) }}>Import CSV/Excel</button>
<button className="btn btn-secondary btn-sm" onClick={async () => { <button className="btn btn-secondary btn-sm" onClick={handleQtiImport} disabled={qtiImporting}>
const input = document.createElement('input'); input.type = 'file'; input.accept = '.xml' {qtiImporting ? 'Importing QTI...' : 'Import QTI'}
input.onchange = async (e) => { </button>
const f = e.target.files[0]; if (!f) return <button className="btn btn-secondary btn-sm" onClick={handleQtiExport} disabled={qtiExporting}>
const fd = new FormData(); fd.append('file', f) {qtiExporting ? 'Exporting QTI...' : `Export QTI${selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}`}
try { </button>
const res = await api.post('/questions/import/qti', fd)
alert(`Imported ${res.data.imported} of ${res.data.total_items} questions${res.data.errors?.length ? `. ${res.data.errors.length} error(s).` : ''}`)
loadQuestions()
} catch (err) { alert(err.response?.data?.detail || 'QTI import failed') }
}; input.click()
}}>Import QTI</button>
<button className="btn btn-secondary btn-sm" onClick={async () => {
try {
const ids = selectedIds.size > 0 ? [...selectedIds].join(',') : ''
const res = await api.get(`/questions/export/qti${ids ? `?question_ids=${ids}` : ''}`, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a'); a.href = url; a.download = 'questions_qti.xml'; a.click()
URL.revokeObjectURL(url)
} catch { }
}}>Export QTI{selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}</button>
{isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>} {isModerator && <button className="btn btn-secondary btn-sm" onClick={() => setShowCatForm(v => !v)}>+ Category</button>}
</div> </div>
</div> </div>

View file

@ -0,0 +1,101 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import QuestionBankPage from './QuestionBankPage'
import api from '../api/client'
vi.mock('../api/client', () => ({
default: {
get: vi.fn(),
post: vi.fn(),
delete: vi.fn(),
},
}))
vi.mock('../context/AuthContext', () => ({
useAuth: () => ({ user: { role: 'admin' } }),
}))
function mockInitialRequests() {
api.get.mockImplementation((url) => {
if (url === '/question-categories/') return Promise.resolve({ data: [] })
if (url === '/favorites') return Promise.resolve({ data: [] })
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
return Promise.resolve({ data: [] })
})
}
function renderPage() {
return render(
<MemoryRouter>
<QuestionBankPage />
</MemoryRouter>
)
}
describe('QuestionBankPage QTI actions', () => {
let originalCreateElement
let fileInput
beforeEach(() => {
vi.clearAllMocks()
mockInitialRequests()
originalCreateElement = document.createElement.bind(document)
fileInput = null
vi.spyOn(document, 'createElement').mockImplementation((tagName, options) => {
const element = originalCreateElement(tagName, options)
if (tagName === 'input') {
fileInput = element
vi.spyOn(element, 'click').mockImplementation(() => {})
}
return element
})
})
afterEach(() => {
document.createElement.mockRestore()
})
it('imports QTI files with an in-app success dialog', async () => {
api.post.mockResolvedValueOnce({ data: { imported: 3, total_items: 4, errors: ['Skipped duplicate'] } })
renderPage()
await userEvent.click(await screen.findByRole('button', { name: 'Import QTI' }))
expect(fileInput).toBeTruthy()
expect(fileInput.accept).toBe('.xml')
const file = new File(['<questestinterop />'], 'questions.xml', { type: 'text/xml' })
Object.defineProperty(fileInput, 'files', { value: [file], configurable: true })
fireEvent.change(fileInput)
await waitFor(() => {
expect(api.post).toHaveBeenCalledWith('/questions/import/qti', expect.any(FormData))
})
expect(await screen.findByText('QTI Import Complete')).toBeInTheDocument()
expect(screen.getByText('Imported 3 of 4 questions. 1 error(s).')).toBeInTheDocument()
})
it('shows QTI export failures instead of swallowing them', async () => {
api.get.mockImplementation((url) => {
if (url.startsWith('/questions/export/qti')) {
return Promise.reject({ response: { data: { detail: 'Export unavailable' } } })
}
if (url === '/question-categories/') return Promise.resolve({ data: [] })
if (url === '/favorites') return Promise.resolve({ data: [] })
if (url === '/tags') return Promise.resolve({ data: { subjects: [], diseases: [], keywords: [] } })
if (url === '/questions/bank') return Promise.resolve({ data: { questions: [], total: 0 } })
return Promise.resolve({ data: [] })
})
renderPage()
await userEvent.click(await screen.findByRole('button', { name: 'Export QTI' }))
expect(await screen.findByText('Export Failed')).toBeInTheDocument()
expect(screen.getByText('Export unavailable')).toBeInTheDocument()
})
})

View file

@ -2,24 +2,197 @@ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom' import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import MyNote from '../components/MyNote'
const TeachChat = lazy(() => import('../components/TeachChat')) const TeachChat = lazy(() => import('../components/TeachChat'))
function TTSButton({ text, voice, onActiveChange }) { const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
const QUESTION_HIGHLIGHT_WORDS = 8
const OPTION_HIGHLIGHT_WORDS = 7
const TTS_PRELOAD_AHEAD = 5
function mergeTextRanges(ranges) {
const sorted = ranges
.filter(r => Number.isFinite(r.start) && Number.isFinite(r.end) && r.end > r.start)
.sort((a, b) => a.start - b.start || a.end - b.end)
const merged = []
sorted.forEach(range => {
const last = merged[merged.length - 1]
if (!last || range.start > last.end) {
merged.push({ start: range.start, end: range.end })
} else {
last.end = Math.max(last.end, range.end)
}
})
return merged
}
function removeTextRange(ranges, removeRange) {
const next = []
ranges.forEach(range => {
if (removeRange.end <= range.start || removeRange.start >= range.end) {
next.push(range)
return
}
if (removeRange.start > range.start) next.push({ start: range.start, end: removeRange.start })
if (removeRange.end < range.end) next.push({ start: removeRange.end, end: range.end })
})
return mergeTextRanges(next)
}
function getSpeechChunkRange(text, maxWords, activeChunk) {
if (activeChunk === null || activeChunk === undefined) return null
const words = []
const re = /\S+/g
let match
while ((match = re.exec(text || ''))) words.push({ start: match.index, end: match.index + match[0].length })
const startWord = activeChunk * maxWords
if (!words[startWord]) return null
const endWord = Math.min(startWord + maxWords - 1, words.length - 1)
return { start: words[startWord].start, end: words[endWord].end }
}
function getManualHighlightSelection(selection = window.getSelection?.()) {
if (!selection || selection.rangeCount === 0 || !selection.toString().trim()) return null
const offsetFromNode = (node, offset) => {
const element = node.nodeType === Node.TEXT_NODE ? node.parentElement : node
const span = element?.closest?.('[data-manual-highlight-id]')
if (!span) return null
let charOffset = offset
if (node.nodeType !== Node.TEXT_NODE) {
charOffset = offset <= 0 ? 0 : Number(span.dataset.end || span.dataset.start || 0) - Number(span.dataset.start || 0)
}
return {
id: span.dataset.manualHighlightId,
offset: Number(span.dataset.start || 0) + charOffset,
}
}
const range = selection.getRangeAt(0)
const start = offsetFromNode(range.startContainer, range.startOffset)
const end = offsetFromNode(range.endContainer, range.endOffset)
if (!start || !end || start.id !== end.id) return null
const ordered = start.offset <= end.offset ? { start: start.offset, end: end.offset } : { start: end.offset, end: start.offset }
if (ordered.end <= ordered.start) return null
return { id: start.id, ...ordered }
}
function ManualHighlightText({ text, textId, highlights = [], speechRange = null, onRemoveHighlight = null }) {
const ranges = mergeTextRanges(highlights)
const boundaries = new Set([0, (text || '').length])
ranges.forEach(range => { boundaries.add(range.start); boundaries.add(range.end) })
if (speechRange) { boundaries.add(speechRange.start); boundaries.add(speechRange.end) }
const points = [...boundaries]
.filter(point => point >= 0 && point <= (text || '').length)
.sort((a, b) => a - b)
return points.slice(0, -1).map((start, i) => {
const end = points[i + 1]
if (end <= start) return null
const manuallyHighlighted = ranges.some(range => start >= range.start && end <= range.end)
const speechHighlighted = speechRange && start >= speechRange.start && end <= speechRange.end
const className = [
'manual-highlight-segment',
manuallyHighlighted ? 'manual-highlight-active' : '',
speechHighlighted ? 'speech-highlight-active' : '',
].filter(Boolean).join(' ')
return (
<span
key={`${start}-${end}`}
className={className}
data-manual-highlight-id={textId}
data-start={start}
data-end={end}
onContextMenu={manuallyHighlighted ? (event) => {
event.preventDefault()
onRemoveHighlight?.(textId, start)
} : undefined}
title={manuallyHighlighted ? 'Right-click to remove this highlight' : undefined}
>
{text.slice(start, end)}
</span>
)
})
}
function splitSpeechChunks(text, maxWords) {
const words = (text || '').trim().split(/\s+/).filter(Boolean)
if (!words.length) return []
const chunks = []
for (let i = 0; i < words.length; i += maxWords) {
chunks.push(words.slice(i, i + maxWords).join(' '))
}
return chunks
}
function questionStem(question) {
return (question?.question_text || '').replace('[IMAGE]', '').trim()
}
function getQuestionSpeechSegments(question, index) {
if (!question) return []
const segments = []
splitSpeechChunks(questionStem(question), QUESTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => {
segments.push({
type: 'question',
chunkIndex,
text: chunkIndex === 0 ? `Question ${index + 1}. ${chunk}` : chunk,
})
})
if (question.options?.length) segments.push({ type: 'meta', text: 'Options.' })
;(question.options || []).forEach((option, i) => {
splitSpeechChunks(option, OPTION_HIGHLIGHT_WORDS).forEach((chunk, chunkIndex) => {
segments.push({
type: 'option',
index: i,
chunkIndex,
text: chunkIndex === 0 ? `${OPTION_LETTERS[i] || i + 1}. ${chunk}` : chunk,
})
})
})
return segments
}
function buildQuestionSpeechText(question, index) {
const segments = getQuestionSpeechSegments(question, index)
return segments.map(s => s.text).join(' ')
}
function getActiveSpeechSegment(audio, segments) {
if (!segments.length) return null
if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0
const weights = segments.map(segment => Math.max(12, segment.text.length))
const total = weights.reduce((sum, weight) => sum + weight, 0)
const target = (audio.currentTime / audio.duration) * total
let cursor = 0
for (let i = 0; i < weights.length; i += 1) {
cursor += weights[i]
if (target <= cursor) return i
}
return segments.length - 1
}
function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange, getAudio, autoPlay, onEnded }) {
const [state, setState] = useState('idle') // idle | loading | playing const [state, setState] = useState('idle') // idle | loading | playing
const audioRef = useRef(null) const audioRef = useRef(null)
// Stop audio when question changes (component unmounts)
useEffect(() => { useEffect(() => {
return () => { return () => {
audioRef.current?.pause() audioRef.current?.pause()
onActiveChange?.(false) onActiveChange?.(false)
onSegmentChange?.(null)
} }
}, []) }, [])
useEffect(() => {
if (autoPlay && state === 'idle') speak()
}, [autoPlay])
const setStateAndNotify = (s) => { const setStateAndNotify = (s) => {
setState(s) setState(s)
onActiveChange?.(s !== 'idle') onActiveChange?.(s !== 'idle')
if (s === 'idle') onSegmentChange?.(null)
} }
const speak = async () => { const speak = async () => {
@ -31,14 +204,29 @@ function TTSButton({ text, voice, onActiveChange }) {
if (state === 'loading') return if (state === 'loading') return
try { try {
setStateAndNotify('loading') setStateAndNotify('loading')
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' }) let url
const url = URL.createObjectURL(res.data) let revokeOnDone = false
if (getAudio) {
url = (await getAudio(text, voice))?.url
} else {
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
url = URL.createObjectURL(res.data)
revokeOnDone = true
}
if (!url) throw new Error('No audio returned')
const audio = new Audio(url) const audio = new Audio(url)
audioRef.current = audio audioRef.current = audio
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) } const updateSegment = () => {
audio.onerror = () => setStateAndNotify('idle') const activeIndex = getActiveSpeechSegment(audio, segments)
audio.play() onSegmentChange?.(activeIndex === null ? null : segments[activeIndex] || null)
}
audio.onloadedmetadata = updateSegment
audio.ontimeupdate = updateSegment
audio.onended = () => { setStateAndNotify('idle'); onEnded?.(); if (revokeOnDone) URL.revokeObjectURL(url) }
audio.onerror = () => { setStateAndNotify('idle'); if (revokeOnDone) URL.revokeObjectURL(url) }
await audio.play()
setStateAndNotify('playing') setStateAndNotify('playing')
updateSegment()
} catch { setStateAndNotify('idle') } } catch { setStateAndNotify('idle') }
} }
@ -99,6 +287,21 @@ function CourseQuizStart({ quiz, onStart }) {
function ModeSelectScreen({ quiz, voices, onStart }) { function ModeSelectScreen({ quiz, voices, onStart }) {
const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '') const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '')
const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '') const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '')
const [startError, setStartError] = useState('')
const [startingMode, setStartingMode] = useState('')
const handleStart = async (mode) => {
const timerMinutes = mode === 'exam' && customTimer ? parseInt(customTimer) : null
setStartError('')
setStartingMode(mode)
try {
await onStart(mode, selectedVoice, timerMinutes)
} catch {
setStartError('Could not start the quiz. Try again.')
} finally {
setStartingMode('')
}
}
return ( return (
<div style={{ maxWidth: 520, margin: '40px auto' }}> <div style={{ maxWidth: 520, margin: '40px auto' }}>
@ -115,17 +318,22 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
{ mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' }, { mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' },
{ mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' }, { mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' },
].map(({ mode, icon, label, desc, color, bg }) => ( ].map(({ mode, icon, label, desc, color, bg }) => (
<div key={mode} onClick={() => onStart(mode, selectedVoice, mode === 'exam' && customTimer ? parseInt(customTimer) : null)} <div key={mode} onClick={() => !startingMode && handleStart(mode)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }} style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: startingMode ? 'wait' : 'pointer', background: bg, transition: 'transform 0.1s', opacity: startingMode && startingMode !== mode ? 0.55 : 1 }}
onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'} onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'}
onMouseLeave={e => e.currentTarget.style.transform = 'none'} onMouseLeave={e => e.currentTarget.style.transform = 'none'}
> >
<div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div> <div style={{ fontSize: '1.8rem', marginBottom: 6 }}>{icon}</div>
<div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div> <div style={{ fontWeight: 700, color, marginBottom: 4 }}>{label}</div>
<div style={{ fontSize: '0.8rem', color }}>{desc}</div> <div style={{ fontSize: '0.8rem', color }}>{startingMode === mode ? 'Starting...' : desc}</div>
</div> </div>
))} ))}
</div> </div>
{startError && (
<div style={{ background: '#fef2f2', color: '#991b1b', border: '1px solid #fecaca', borderRadius: 8, padding: '8px 10px', fontSize: '0.82rem', marginBottom: 14 }}>
{startError}
</div>
)}
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginBottom: 14 }}> <div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginBottom: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}> Timer for Exam Mode <span style={{ fontWeight: 400 }}>(minutes, optional)</span></label> <label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}> Timer for Exam Mode <span style={{ fontWeight: 400 }}>(minutes, optional)</span></label>
@ -151,8 +359,21 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
) )
} }
// Unique session ID per tab used to prevent concurrent resume on multiple devices function getQuizSessionId() {
const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36) const key = 'pedshub_quiz_session_id'
try {
const existing = localStorage.getItem(key)
if (existing) return existing
const created = window.crypto?.randomUUID?.() || `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
localStorage.setItem(key, created)
return created
} catch {
return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
}
}
// Stable per-device session ID lets the same app/webview resume after restart.
const SESSION_ID = getQuizSessionId()
export default function QuizPage() { export default function QuizPage() {
const { id } = useParams() const { id } = useParams()
@ -165,6 +386,7 @@ export default function QuizPage() {
const [voices, setVoices] = useState([]) const [voices, setVoices] = useState([])
const [selectedVoice, setSelectedVoice] = useState('') const [selectedVoice, setSelectedVoice] = useState('')
const [ttsActive, setTtsActive] = useState(false) const [ttsActive, setTtsActive] = useState(false)
const [readThrough, setReadThrough] = useState(false)
const [quizMode, setQuizMode] = useState(null) const [quizMode, setQuizMode] = useState(null)
const [answers, setAnswers] = useState({}) const [answers, setAnswers] = useState({})
const [currentIdx, setCurrentIdx] = useState(0) const [currentIdx, setCurrentIdx] = useState(0)
@ -176,11 +398,19 @@ export default function QuizPage() {
const [totalTime, setTotalTime] = useState(null) const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('') const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false) const [navOpen, setNavOpen] = useState(false)
const [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1)
const [startedAt, setStartedAt] = useState(null) const [startedAt, setStartedAt] = useState(null)
const [favorites, setFavorites] = useState([]) const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null)
const [manualHighlights, setManualHighlights] = useState({})
const timerRef = useRef(null) const timerRef = useRef(null)
const toastRef = useRef(null) const toastRef = useRef(null)
const hasStarted = useRef(false) const hasStarted = useRef(false)
const ttsCacheRef = useRef(new Map())
const autoAdvanceRef = useRef(null)
const savedHighlightSelectionRef = useRef(null)
const autoHighlightTimerRef = useRef(null)
const showToast = (msg) => { const showToast = (msg) => {
setToast(msg) setToast(msg)
@ -188,15 +418,133 @@ export default function QuizPage() {
toastRef.current = setTimeout(() => setToast(''), 3000) toastRef.current = setTimeout(() => setToast(''), 3000)
} }
const [leaveTarget, setLeaveTarget] = useState(null) const adjustImageZoom = (delta) => {
setImageZoom(z => Math.max(1, Math.min(4, z + delta)))
}
const resumeQuiz = useCallback(async (saved) => { useEffect(() => {
const mode = saved.mode || saved.quizMode try {
const saved = localStorage.getItem(`quiz-highlights:${id}`)
setManualHighlights(saved ? JSON.parse(saved) : {})
} catch {
setManualHighlights({})
}
}, [id])
useEffect(() => {
try {
localStorage.setItem(`quiz-highlights:${id}`, JSON.stringify(manualHighlights))
} catch { }
}, [id, manualHighlights])
const [leaveTarget, setLeaveTarget] = useState(null)
const questions = quiz?.questions || []
const current = questions[currentIdx]
const isStudy = quizMode === 'study'
const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => {
if (!selected || !current) return
const [questionKey, fieldKey] = selected.id.split('::')
if (questionKey !== String(current.id)) return
const existing = manualHighlights[current.id]?.[fieldKey] || []
const removeExisting = existing.some(range => selected.start >= range.start && selected.end <= range.end)
setManualHighlights(prev => {
const questionRanges = prev[current.id] || {}
const currentRanges = questionRanges[fieldKey] || []
const nextRanges = removeExisting
? removeTextRange(currentRanges, selected)
: mergeTextRanges([...currentRanges, { start: selected.start, end: selected.end }])
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
if (!nextRanges.length) delete nextQuestion[fieldKey]
const next = { ...prev, [current.id]: nextQuestion }
if (!Object.keys(nextQuestion).length) delete next[current.id]
return next
})
window.getSelection?.().removeAllRanges()
savedHighlightSelectionRef.current = null
}, [current?.id, manualHighlights])
const captureHighlightSelection = useCallback(() => {
const selected = getManualHighlightSelection()
if (!selected || !current) return
const [questionKey] = selected.id.split('::')
if (questionKey !== String(current.id)) return
savedHighlightSelectionRef.current = selected
clearTimeout(autoHighlightTimerRef.current)
autoHighlightTimerRef.current = setTimeout(() => applyManualHighlightSelection(selected), 450)
}, [current?.id, applyManualHighlightSelection])
useEffect(() => {
document.addEventListener('selectionchange', captureHighlightSelection)
document.addEventListener('mouseup', captureHighlightSelection)
document.addEventListener('touchend', captureHighlightSelection)
return () => {
clearTimeout(autoHighlightTimerRef.current)
document.removeEventListener('selectionchange', captureHighlightSelection)
document.removeEventListener('mouseup', captureHighlightSelection)
document.removeEventListener('touchend', captureHighlightSelection)
}
}, [captureHighlightSelection])
const fetchTtsAudio = useCallback(async (text, voice) => {
const cleanText = (text || '').trim()
if (!cleanText) return null
const key = `${voice || 'default'}::${cleanText}`
const cached = ttsCacheRef.current.get(key)
if (cached?.url) return cached
if (cached?.promise) return cached.promise
const promise = api.post('/tts/speak', { text: cleanText, voice: voice || null }, { responseType: 'blob' })
.then(res => {
const entry = { url: URL.createObjectURL(res.data) }
ttsCacheRef.current.set(key, entry)
return entry
})
.catch(err => {
if (ttsCacheRef.current.get(key)?.promise === promise) ttsCacheRef.current.delete(key)
throw err
})
ttsCacheRef.current.set(key, { promise })
return promise
}, [])
useEffect(() => {
return () => {
clearTimeout(autoAdvanceRef.current)
ttsCacheRef.current.forEach(entry => { if (entry.url) URL.revokeObjectURL(entry.url) })
ttsCacheRef.current.clear()
}
}, [])
useEffect(() => {
setActiveReadSegment(null)
setTtsActive(false)
savedHighlightSelectionRef.current = null
clearTimeout(autoHighlightTimerRef.current)
if (!readThrough) setActiveReadSegment(null)
clearTimeout(autoAdvanceRef.current)
}, [currentIdx, readThrough])
useEffect(() => {
if (!quizMode || !questions.length || !voices.length) return
for (let index = currentIdx; index <= Math.min(currentIdx + TTS_PRELOAD_AHEAD, questions.length - 1); index += 1) {
const q = questions[index]
if (!q) return
fetchTtsAudio(buildQuestionSpeechText(q, index), selectedVoice).catch(() => {})
}
}, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio])
const resumeQuiz = useCallback(async (saved, availableVoices = []) => {
const savedMode = saved.mode || saved.quizMode
const mode = savedMode === 'exam' ? 'exam' : 'study'
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0 const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
const savedAnswers = saved.answers || {} const savedAnswers = saved.answers || {}
const aid = saved.attempt_id || saved.attemptId || '' const aid = saved.attempt_id || saved.attemptId || ''
// For study mode, load quiz with correct answers BEFORE showing // For feedback modes, load quiz with correct answers BEFORE showing.
if (mode === 'study') { if (mode === 'study') {
try { try {
const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`) const quizRes = await api.get(`/quizzes/${id}?study=true${aid ? `&attempt_id=${aid}` : ''}`)
@ -223,7 +571,7 @@ export default function QuizPage() {
setAnswers(savedAnswers) setAnswers(savedAnswers)
setCurrentIdx(savedIdx) setCurrentIdx(savedIdx)
setAttemptId(saved.attempt_id || saved.attemptId) setAttemptId(saved.attempt_id || saved.attemptId)
if (saved.voice) setSelectedVoice(saved.voice) if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
if (saved.started_at) setStartedAt(saved.started_at) if (saved.started_at) setStartedAt(saved.started_at)
// Restore timer calculate remaining from started_at + total_time // Restore timer calculate remaining from started_at + total_time
if (saved.total_time && saved.started_at) { if (saved.total_time && saved.started_at) {
@ -266,7 +614,7 @@ export default function QuizPage() {
headers: { 'x-quiz-session': SESSION_ID }, headers: { 'x-quiz-session': SESSION_ID },
}) })
if (progressRes.data) { if (progressRes.data) {
await resumeQuiz(progressRes.data) await resumeQuiz(progressRes.data, voicesRes.data)
} }
} catch (err) { } catch (err) {
if (err.response?.status === 409) { if (err.response?.status === 409) {
@ -296,7 +644,7 @@ export default function QuizPage() {
// Fetch quiz with attempt_id for question pool filtering // Fetch quiz with attempt_id for question pool filtering
let quizData = quiz let quizData = quiz
const studyParam = (mode === 'study') ? '&study=true' : '' const studyParam = mode === 'study' ? '&study=true' : ''
const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`) const quizRes = await api.get(`/quizzes/${id}?attempt_id=${aid}${studyParam}`)
quizData = quizRes.data quizData = quizRes.data
setQuiz(quizData) setQuiz(quizData)
@ -309,6 +657,17 @@ export default function QuizPage() {
} }
// NOW set mode quiz data is fully loaded, safe to render // NOW set mode quiz data is fully loaded, safe to render
setQuizMode(mode) setQuizMode(mode)
api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: aid,
answers: {},
current_idx: 0,
mode,
voice: voice || null,
time_left: mode === 'exam' && mins ? mins * 60 : null,
started_at: now,
total_time: mode === 'exam' && mins ? mins * 60 : null,
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
} catch { navigate('/') } } catch { navigate('/') }
finally { setStarting(false) } finally { setStarting(false) }
} }
@ -327,30 +686,80 @@ const timerStarted = timeLeft !== null
if (timeLeft === 0) handleSubmit(true) if (timeLeft === 0) handleSubmit(true)
}, [timeLeft]) }, [timeLeft])
const saveProgressNow = useCallback((overrides = {}) => {
if (!attemptId || !quizMode) return Promise.resolve()
return api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
current_idx: currentIdx,
mode: quizMode,
voice: selectedVoice || null,
time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
...overrides,
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
}, [id, answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
// Save progress to Redis (survives logout/browser change) // Save progress to Redis (survives logout/browser change)
const saveProgressRef = useRef(null) const saveProgressRef = useRef(null)
useEffect(() => { useEffect(() => {
if (!attemptId || !quizMode) return if (!attemptId || !quizMode) return
clearTimeout(saveProgressRef.current) clearTimeout(saveProgressRef.current)
saveProgressRef.current = setTimeout(() => { saveProgressRef.current = setTimeout(() => { saveProgressNow() }, 500)
api.post('/attempts/progress', {
quiz_id: parseInt(id),
attempt_id: attemptId,
answers,
current_idx: currentIdx,
mode: quizMode,
voice: selectedVoice || null,
time_left: timeLeft,
started_at: startedAt,
total_time: totalTime,
}, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {})
}, 1500) // debounce 1.5s
return () => clearTimeout(saveProgressRef.current) return () => clearTimeout(saveProgressRef.current)
}, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime]) }, [saveProgressNow, attemptId, quizMode])
useEffect(() => {
if (!attemptId || !quizMode) return
const flush = () => { saveProgressNow() }
const flushWhenHidden = () => { if (document.visibilityState === 'hidden') flush() }
window.addEventListener('pagehide', flush)
document.addEventListener('visibilitychange', flushWhenHidden)
return () => {
window.removeEventListener('pagehide', flush)
document.removeEventListener('visibilitychange', flushWhenHidden)
}
}, [attemptId, quizMode, saveProgressNow])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
const safeNavigate = (targetIdx) => setCurrentIdx(targetIdx) const clearCurrentHighlights = () => {
if (!current || !manualHighlights[current.id]) return
setManualHighlights(prev => {
const next = { ...prev }
delete next[current.id]
return next
})
}
const removeJoinedHighlight = (textId, offset) => {
if (!current) return
const [questionKey, fieldKey] = textId.split('::')
if (questionKey !== String(current.id)) return
const existing = manualHighlights[current.id]?.[fieldKey] || []
const joined = existing.find(range => offset >= range.start && offset < range.end)
if (!joined) return
setManualHighlights(prev => {
const questionRanges = prev[current.id] || {}
const nextRanges = (questionRanges[fieldKey] || []).filter(range => range.start !== joined.start || range.end !== joined.end)
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
if (!nextRanges.length) delete nextQuestion[fieldKey]
const next = { ...prev, [current.id]: nextQuestion }
if (!Object.keys(nextQuestion).length) delete next[current.id]
return next
})
}
const highlightsFor = (fieldKey) => manualHighlights[current?.id]?.[fieldKey] || []
const hasActiveTextSelection = () => Boolean(window.getSelection?.().toString().trim())
const safeNavigate = (targetIdx, { keepReadThrough = false } = {}) => {
if (!keepReadThrough) setReadThrough(false)
setCurrentIdx(targetIdx)
}
const handleSubmit = useCallback(async (autoSubmit = false) => { const handleSubmit = useCallback(async (autoSubmit = false) => {
if (!attemptId || submitting) return if (!attemptId || submitting) return
@ -384,7 +793,7 @@ const timerStarted = timeLeft !== null
if (!quizMode) return ( if (!quizMode) return (
<div> <div>
{isModerator && ( {isModerator && (
<div style={{ textAlign: 'right', marginBottom: 8 }}> <div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
<Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link> <Link to={`/quizzes/${id}/edit`} className="btn btn-secondary btn-sm"> Edit Questions</Link>
</div> </div>
)} )}
@ -401,12 +810,37 @@ const timerStarted = timeLeft !== null
</div> </div>
) )
const isStudy = quizMode === 'study'
const questions = quiz.questions || []
const current = questions[currentIdx]
const answeredCount = Object.keys(answers).length const answeredCount = Object.keys(answers).length
const totalCount = questions.length const totalCount = questions.length
const isLast = currentIdx === totalCount - 1 const isLast = currentIdx === totalCount - 1
const quizNavigation = (position = 'bottom') => (
<div className={`quiz-nav-controls quiz-nav-controls-${position}`}>
<button className="btn btn-secondary"
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}> Prev</button>
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
onClick={() => setNavOpen(v => !v)}>
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
</button>
{isLast ? (
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next </button>
)}
</div>
)
const activeReadForCurrent = current && activeReadSegment?.questionId === current.id
const questionSpeechRange = current
? getSpeechChunkRange(
questionStem(current),
QUESTION_HIGHLIGHT_WORDS,
activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null,
)
: null
const toggleFavorite = async (questionId) => { const toggleFavorite = async (questionId) => {
const isFavorited = favorites.includes(questionId) const isFavorited = favorites.includes(questionId)
@ -441,6 +875,7 @@ const timerStarted = timeLeft !== null
return ( return (
<div className="quiz-bottom"> <div className="quiz-bottom">
<MyNote variant="tab" />
{/* In-app leave confirmation */} {/* In-app leave confirmation */}
{leaveTarget && ( {leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}> <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
@ -499,13 +934,14 @@ const timerStarted = timeLeft !== null
)} )}
{/* Header */} {/* Header */}
<div className="card" style={{ marginBottom: 14 }}> <div className="card quiz-header-card" style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div> <div>
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2> <h2 className="quiz-header-title">{quiz.title}</h2>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ <span style={{
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3', background: isStudy ? '#d1fae5' : '#e0e7ff',
color: isStudy ? '#065f46' : '#3730a3',
padding: '1px 8px', borderRadius: 12, fontWeight: 600, padding: '1px 8px', borderRadius: 12, fontWeight: 600,
}}> }}>
{isStudy ? '📖 Study' : '🎯 Exam'} {isStudy ? '📖 Study' : '🎯 Exam'}
@ -542,10 +978,24 @@ const timerStarted = timeLeft !== null
<div className="quiz-layout"> <div className="quiz-layout">
{/* Main content */} {/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}> <div style={{ flex: 1, minWidth: 0 }}>
{quizNavigation('top')}
{current && ( {current && (
<div className="question-card"> <div className="question-card" style={{
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
borderColor: activeReadForCurrent ? '#60a5fa' : undefined,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 8, gap: 12 }}>
<h3 style={{ marginBottom: 0, flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3> <h3 style={{ marginBottom: 0, flex: 1 }}>
Q{currentIdx + 1}.{' '}
<ManualHighlightText
text={questionStem(current)}
textId={`${current.id}::question`}
highlights={highlightsFor('question')}
speechRange={questionSpeechRange}
onRemoveHighlight={removeJoinedHighlight}
/>
</h3>
<button <button
onClick={() => toggleFavorite(current.id)} onClick={() => toggleFavorite(current.id)}
title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'} title={favorites.includes(current.id) ? 'Remove from favorites' : 'Add to favorites'}
@ -564,24 +1014,75 @@ const timerStarted = timeLeft !== null
{favorites.includes(current.id) ? '⭐' : '☆'} {favorites.includes(current.id) ? '⭐' : '☆'}
</button> </button>
</div> </div>
{voices.length > 0 && ( <div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ marginBottom: 10 }}> {voices.length > 0 && (
<TTSButton <TTSButton
key={`${current.id}_${answers[current.id] || ''}`} key={`${current.id}_${selectedVoice || 'default'}`}
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`} text={buildQuestionSpeechText(current, currentIdx)}
voice={selectedVoice} voice={selectedVoice}
segments={getQuestionSpeechSegments(current, currentIdx)}
getAudio={fetchTtsAudio}
autoPlay={readThrough}
onEnded={() => {
if (readThrough) {
if (currentIdx < totalCount - 1) {
safeNavigate(currentIdx + 1, { keepReadThrough: true })
} else {
setReadThrough(false)
}
return
}
}}
onActiveChange={setTtsActive} onActiveChange={setTtsActive}
onSegmentChange={segment => setActiveReadSegment(segment === null ? null : { questionId: current.id, ...segment })}
/> />
)}
{voices.length > 0 && (
<button
className={`btn btn-sm ${readThrough ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setReadThrough(v => !v)}
title="Read each question aloud and advance automatically"
>
{readThrough ? 'Stop listen-through' : 'Listen through'}
</button>
)}
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
<button className="btn btn-secondary btn-sm" onMouseDown={e => e.preventDefault()} onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
Clear
</button>
</div> </div>
)} </div>
<span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}> <span className="badge" style={{ background: '#e0e7ff', color: '#3730a3', margin: '6px 0 12px', display: 'inline-block' }}>
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'} {current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span> </span>
{current.image_path && ( {current.image_path && (
<div style={{ margin: '10px 0' }}> <button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
<img src={`/uploads/${current.image_path}`} alt="Question illustration" <img src={`/uploads/${current.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }} onError={e => e.currentTarget.closest('button').style.display = 'none'} />
onError={e => e.target.style.display = 'none'} /> </button>
)}
{expandedImagePath && (
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="Expanded question image" onClick={() => setExpandedImagePath('')}>
<div className="image-lightbox-controls" onClick={e => e.stopPropagation()}>
<button type="button" onClick={() => adjustImageZoom(-0.1)} disabled={imageZoom <= 1}>-</button>
<span>{Math.round(imageZoom * 100)}%</span>
<button type="button" onClick={() => adjustImageZoom(0.1)} disabled={imageZoom >= 4}>+</button>
{[1, 2, 3, 4].map(zoom => (
<button key={zoom} type="button" onClick={() => setImageZoom(zoom)} disabled={imageZoom === zoom}>{zoom}x</button>
))}
</div>
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
<div className="image-lightbox-viewport" onClick={e => e.stopPropagation()}>
<img
src={`/uploads/${expandedImagePath}`}
alt="Expanded question illustration"
style={{
maxWidth: imageZoom === 1 ? 'min(100%, 1100px)' : 'none',
maxHeight: imageZoom === 1 ? '92vh' : 'none',
width: imageZoom > 1 ? `${imageZoom * 100}%` : undefined,
}}
/>
</div>
</div> </div>
)} )}
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? ( {(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
@ -593,13 +1094,31 @@ const timerStarted = timeLeft !== null
const showCorrect = hasAnswered && isCorrectOpt const showCorrect = hasAnswered && isCorrectOpt
const showWrong = hasAnswered && isSelected && !isCorrectOpt const showWrong = hasAnswered && isSelected && !isCorrectOpt
const letter = String.fromCharCode(65 + i) const letter = String.fromCharCode(65 + i)
const activeOptionChunk = activeReadForCurrent && activeReadSegment.type === 'option' && activeReadSegment.index === i
? activeReadSegment.chunkIndex
: null
const optionSpeechRange = getSpeechChunkRange(opt, OPTION_HIGHLIGHT_WORDS, activeOptionChunk)
const optionFieldKey = `option-${i}`
return ( return (
<div key={i} <div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`} className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)} onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)}
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}> style={{
cursor: hasAnswered ? 'default' : 'pointer',
borderColor: activeOptionChunk !== null ? '#60a5fa' : undefined,
boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(59, 130, 246, 0.2)' : undefined,
transition: 'background 0.15s ease, box-shadow 0.15s ease',
}}>
<span className="option-letter">{letter}</span> <span className="option-letter">{letter}</span>
<span style={{ flex: 1 }}>{opt}</span> <span style={{ flex: 1 }}>
<ManualHighlightText
text={opt}
textId={`${current.id}::${optionFieldKey}`}
highlights={highlightsFor(optionFieldKey)}
speechRange={optionSpeechRange}
onRemoveHighlight={removeJoinedHighlight}
/>
</span>
{showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> Correct</span>} {showCorrect && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--correct-fg)' }}> Correct</span>}
{showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>} {showWrong && <span style={{ marginLeft: 'auto', fontSize: '0.8rem', fontWeight: 700, color: 'var(--wrong-fg)' }}> Wrong</span>}
</div> </div>
@ -629,26 +1148,7 @@ const timerStarted = timeLeft !== null
</div> </div>
)} )}
{/* Prev / Next + mobile nav toggle */} {quizNavigation('bottom')}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 14, gap: 8 }}>
<button className="btn btn-secondary"
onClick={() => safeNavigate(Math.max(0, currentIdx - 1))}
disabled={currentIdx === 0}> Prev</button>
{/* Mobile-only nav toggle */}
<button className="quiz-nav-toggle btn btn-secondary btn-sm"
onClick={() => setNavOpen(v => !v)}>
{currentIdx + 1} / {totalCount} {navOpen ? '▼' : '▲'}
</button>
{isLast ? (
<button className="btn btn-primary" onClick={() => handleSubmit(false)} disabled={submitting}>
{submitting ? 'Submitting...' : 'Submit Quiz'}
</button>
) : (
<button className="btn btn-primary" onClick={() => safeNavigate(Math.min(totalCount - 1, currentIdx + 1))}>Next </button>
)}
</div>
{/* Mobile: collapsible number grid */} {/* Mobile: collapsible number grid */}
{navOpen && ( {navOpen && (

View file

@ -4,56 +4,11 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client' import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton' import ConfirmButton from '../components/ConfirmButton'
import Dialog from '../components/Dialog' import Dialog from '../components/Dialog'
import InProgressQuizzes from '../components/InProgressQuizzes'
import { useDialog } from '../hooks/useDialog' import { useDialog } from '../hooks/useDialog'
const TeachChat = lazy(() => import('../components/TeachChat')) const TeachChat = lazy(() => import('../components/TeachChat'))
function InProgressSection() {
const [inProgress, setInProgress] = useState([])
const navigate = useNavigate()
useEffect(() => {
api.get('/attempts/in-progress').then(res => setInProgress(res.data)).catch(() => {})
}, [])
const deleteAttempt = async (attemptId) => {
await api.delete(`/attempts/${attemptId}`)
setInProgress(prev => prev.filter(a => a.attempt_id !== attemptId))
}
if (inProgress.length === 0) return null
return (
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #f59e0b' }}>
<h2 style={{ marginBottom: 12, fontSize: '1rem', color: '#92400e' }}>
In Progress ({inProgress.length})
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{inProgress.map(a => (
<div key={a.attempt_id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 14px', background: 'var(--bg)', borderRadius: 8, gap: 12,
}}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.9rem' }}>{a.quiz_title}</div>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)' }}>
Started {new Date(a.started_at).toLocaleDateString()} · {a.total_questions} questions
</div>
</div>
<div style={{ display: 'flex', gap: 8, flexShrink: 0 }}>
<button className="btn btn-primary btn-sm" onClick={() => navigate(`/quizzes/${a.quiz_id}`)}>Resume</button>
<ConfirmButton
label="Delete" confirmLabel="Yes, delete"
onConfirm={() => deleteAttempt(a.attempt_id)}
/>
</div>
</div>
))}
</div>
</div>
)
}
function PastAttemptsSection() { function PastAttemptsSection() {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [history, setHistory] = useState(null) const [history, setHistory] = useState(null)
@ -403,6 +358,24 @@ export default function QuizzesPage() {
<QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} /> <QuestionStudyModal question={studyQuestion} query={searchQuery} onClose={() => setStudyQuestion(null)} />
)} )}
<div className="card" style={{ marginBottom: 16, borderLeft: '4px solid #229ed9', display: 'flex', justifyContent: 'space-between', gap: 14, alignItems: 'center', flexWrap: 'wrap' }}>
<div>
<div style={{ fontWeight: 700, fontSize: '1rem', marginBottom: 4 }}>Use our Telegram bot</div>
<div style={{ color: 'var(--text-muted)', fontSize: '0.9rem' }}>
Start quick random quizzes, browse categories, and study questions from Telegram.
</div>
</div>
<a
href="https://t.me/pedshubbot"
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
style={{ textDecoration: 'none', flexShrink: 0 }}
>
Open @pedshubbot
</a>
</div>
{/* Search bar */} {/* Search bar */}
<div className="card" style={{ marginBottom: 16 }}> <div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}> <div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
@ -488,7 +461,7 @@ export default function QuizzesPage() {
)} )}
{/* In-progress quizzes */} {/* In-progress quizzes */}
{!isSearching && <InProgressSection />} {!isSearching && <InProgressQuizzes />}
{/* Past attempts (loads on demand) */} {/* Past attempts (loads on demand) */}
{!isSearching && <PastAttemptsSection />} {!isSearching && <PastAttemptsSection />}

View file

@ -1,5 +1,5 @@
import { useState, useRef } from 'react' import { useState, useRef } from 'react'
import { useNavigate } from 'react-router-dom' import { Link, useNavigate } from 'react-router-dom'
import api from '../api/client' import api from '../api/client'
function NextcloudBrowser({ onFile }) { function NextcloudBrowser({ onFile }) {
@ -49,7 +49,7 @@ function NextcloudBrowser({ onFile }) {
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}> <div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)', fontSize: '0.875rem' }}>
<div style={{ fontSize: '1.5rem', marginBottom: 8 }}></div> <div style={{ fontSize: '1.5rem', marginBottom: 8 }}></div>
No Nextcloud account configured.{' '} No Nextcloud account configured.{' '}
<a href="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</a> to add one. <Link to="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</Link> to add one.
</div> </div>
) )
} }

View file

@ -0,0 +1,31 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import UploadPage from './UploadPage'
vi.mock('../api/client', () => ({
default: {
post: vi.fn(),
},
}))
describe('UploadPage', () => {
beforeEach(() => {
localStorage.clear()
})
it('uses client-side navigation for the settings link', async () => {
render(
<MemoryRouter>
<UploadPage />
</MemoryRouter>
)
await userEvent.click(screen.getByRole('button', { name: '☁️ Nextcloud (not set up)' }))
const settingsLink = screen.getByRole('link', { name: 'Go to Settings' })
expect(settingsLink).toHaveAttribute('href', '/settings')
})
})

View file

@ -0,0 +1,7 @@
import '@testing-library/jest-dom/vitest'
import { cleanup } from '@testing-library/react'
import { afterEach } from 'vitest'
afterEach(() => {
cleanup()
})

View file

@ -7,5 +7,9 @@ export default defineConfig({
proxy: { proxy: {
'/api': 'http://localhost:8000' '/api': 'http://localhost:8000'
} }
},
test: {
environment: 'jsdom',
setupFiles: './src/test/setup.js'
} }
}) })

View file

@ -3,6 +3,12 @@ apply plugin: 'com.android.application'
android { android {
namespace "com.pedshub.quiz" namespace "com.pedshub.quiz"
compileSdk rootProject.ext.compileSdkVersion compileSdk rootProject.ext.compileSdkVersion
def releaseKeystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
def releaseKeystorePassword = System.getenv("ANDROID_KEYSTORE_PASSWORD")
def releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
def releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
def hasReleaseSigning = releaseKeystoreFile && releaseKeystorePassword && releaseKeyAlias && releaseKeyPassword
defaultConfig { defaultConfig {
applicationId "com.pedshub.quiz" applicationId "com.pedshub.quiz"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
@ -13,12 +19,25 @@ android {
aaptOptions { aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
release {
if (hasReleaseSigning) {
storeFile file(releaseKeystoreFile)
storePassword releaseKeystorePassword
keyAlias releaseKeyAlias
keyPassword releaseKeyPassword
}
} }
} }
buildTypes { buildTypes {
release { release {
minifyEnabled false minifyEnabled false
if (hasReleaseSigning) {
signingConfig signingConfigs.release
}
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }

View file

@ -20,9 +20,9 @@
} }
}, },
"android": { "android": {
"allowMixedContent": true, "allowMixedContent": false,
"backgroundColor": "#0f172a", "backgroundColor": "#0f172a",
"webContentsDebuggingEnabled": true, "webContentsDebuggingEnabled": false,
"androidScheme": "https" "androidScheme": "https"
}, },
"ios": { "ios": {

View file

@ -21,7 +21,7 @@
<div class="form-group"> <div class="form-group">
<label>Server URL</label> <label>Server URL</label>
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false"> <input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
</div> </div>
<button id="btn-connect" class="btn-primary">Connect</button> <button id="btn-connect" class="btn-primary">Connect</button>

View file

@ -1,6 +1,6 @@
(function() { (function() {
var STORAGE_KEY = 'pedshub_server_url'; var STORAGE_KEY = 'pedshub_server_url';
var DEFAULT_URL = 'https://pedshub.com'; var DEFAULT_URL = 'https://quiz.danvics.com';
var setupScreen = document.getElementById('setup-screen'); var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen'); var connectingScreen = document.getElementById('connecting-screen');

View file

@ -18,9 +18,9 @@
} }
}, },
"android": { "android": {
"allowMixedContent": true, "allowMixedContent": false,
"backgroundColor": "#0f172a", "backgroundColor": "#0f172a",
"webContentsDebuggingEnabled": true, "webContentsDebuggingEnabled": false,
"androidScheme": "https" "androidScheme": "https"
}, },
"ios": { "ios": {

View file

@ -20,9 +20,9 @@
} }
}, },
"android": { "android": {
"allowMixedContent": true, "allowMixedContent": false,
"backgroundColor": "#0f172a", "backgroundColor": "#0f172a",
"webContentsDebuggingEnabled": true, "webContentsDebuggingEnabled": false,
"androidScheme": "https" "androidScheme": "https"
}, },
"ios": { "ios": {

View file

@ -21,7 +21,7 @@
<div class="form-group"> <div class="form-group">
<label>Server URL</label> <label>Server URL</label>
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false"> <input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
</div> </div>
<button id="btn-connect" class="btn-primary">Connect</button> <button id="btn-connect" class="btn-primary">Connect</button>

View file

@ -1,6 +1,6 @@
(function() { (function() {
var STORAGE_KEY = 'pedshub_server_url'; var STORAGE_KEY = 'pedshub_server_url';
var DEFAULT_URL = 'https://pedshub.com'; var DEFAULT_URL = 'https://quiz.danvics.com';
var setupScreen = document.getElementById('setup-screen'); var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen'); var connectingScreen = document.getElementById('connecting-screen');

View file

@ -7,7 +7,9 @@
"sync": "npx cap sync", "sync": "npx cap sync",
"android": "npx cap open android", "android": "npx cap open android",
"ios": "npx cap open ios", "ios": "npx cap open ios",
"build:android": "npx cap sync android && cd android && ./gradlew assembleRelease" "build:android": "npx cap sync android && cd android && ./gradlew assembleRelease",
"build:android:release": "npx cap sync android && cd android && ./gradlew assembleRelease",
"build:android:debug": "npx cap sync android && cd android && ./gradlew assembleDebug"
}, },
"dependencies": { "dependencies": {
"@capacitor/android": "^6.0.0", "@capacitor/android": "^6.0.0",

View file

@ -21,7 +21,7 @@
<div class="form-group"> <div class="form-group">
<label>Server URL</label> <label>Server URL</label>
<input type="url" id="server-url" placeholder="https://pedshub.com" autocapitalize="none" autocorrect="off" spellcheck="false"> <input type="url" id="server-url" placeholder="https://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
</div> </div>
<button id="btn-connect" class="btn-primary">Connect</button> <button id="btn-connect" class="btn-primary">Connect</button>

View file

@ -1,6 +1,6 @@
(function() { (function() {
var STORAGE_KEY = 'pedshub_server_url'; var STORAGE_KEY = 'pedshub_server_url';
var DEFAULT_URL = 'https://pedshub.com'; var DEFAULT_URL = 'https://quiz.danvics.com';
var setupScreen = document.getElementById('setup-screen'); var setupScreen = document.getElementById('setup-screen');
var connectingScreen = document.getElementById('connecting-screen'); var connectingScreen = document.getElementById('connecting-screen');

View file

@ -13,10 +13,14 @@ scrape_configs:
docker_sd_configs: docker_sd_configs:
- host: unix:///var/run/docker.sock - host: unix:///var/run/docker.sock
refresh_interval: 5s refresh_interval: 5s
filters:
- name: label
values: ["com.docker.compose.project=quiz"]
relabel_configs: relabel_configs:
# Keep only quiz-* containers # Keep only this compose project; the Docker API filter above prevents
- source_labels: ['__meta_docker_container_name'] # Promtail from opening non-readable log streams from other stacks.
regex: '.*quiz.*' - source_labels: ['__meta_docker_container_label_com_docker_compose_project']
regex: 'quiz'
action: keep action: keep
# Extract container name as label # Extract container name as label
- source_labels: ['__meta_docker_container_name'] - source_labels: ['__meta_docker_container_name']

13
telegram-bot/Dockerfile Normal file
View file

@ -0,0 +1,13 @@
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY bot.py .
CMD ["python", "bot.py"]

615
telegram-bot/bot.py Normal file
View file

@ -0,0 +1,615 @@
import html
import json
import logging
import os
import random
import re
from dataclasses import dataclass, field
from typing import Any
import psycopg
from psycopg.rows import dict_row
from telegram import BotCommand, InlineKeyboardButton, InlineKeyboardMarkup, Update
from telegram.constants import ChatAction, ParseMode
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes, MessageHandler, filters
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger("quiz-telegram-bot")
logging.getLogger("httpx").setLevel(logging.WARNING)
logging.getLogger("httpcore").setLevel(logging.WARNING)
TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"]
DATABASE_URL = os.environ["DATABASE_URL"]
DEFAULT_QUIZ_SIZE = int(os.getenv("DEFAULT_QUIZ_SIZE", "20"))
MAX_QUIZ_SIZE = int(os.getenv("MAX_QUIZ_SIZE", "50"))
PUBLIC_APP_URL = os.getenv("PUBLIC_APP_URL", "https://pedshub.com").rstrip("/")
TELEGRAM_MESSAGE_LIMIT = 4096
LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
NUMBER_RE = re.compile(r"^\s*(\d{1,3})\s*$")
@dataclass
class QuizState:
questions: list[dict[str, Any]]
mode: str = "study"
index: int = 0
score: int = 0
answers: list[tuple[int, str, str, bool]] = field(default_factory=list)
active_quizzes: dict[int, QuizState] = {}
def db_query(sql: str, params: tuple[Any, ...] = ()) -> list[dict[str, Any]]:
with psycopg.connect(DATABASE_URL, row_factory=dict_row) as conn:
with conn.cursor() as cur:
cur.execute(sql, params)
return list(cur.fetchall())
def normalize_options(value: Any) -> list[str]:
if value is None:
return []
if isinstance(value, list):
return [str(item) for item in value]
if isinstance(value, str):
try:
parsed = json.loads(value)
except json.JSONDecodeError:
return []
if isinstance(parsed, list):
return [str(item) for item in parsed]
return []
def answer_index(question: dict[str, Any], options: list[str]) -> int | None:
raw = str(question.get("correct_answer") or "").strip()
if not raw:
return None
upper = raw.upper()
if len(upper) == 1 and upper in LETTERS:
idx = LETTERS.index(upper)
return idx if idx < len(options) else None
if raw.isdigit():
idx = int(raw) - 1
return idx if 0 <= idx < len(options) else None
for idx, option in enumerate(options):
if option.strip().lower() == raw.lower():
return idx
return None
def question_base_sql(where: str = "") -> str:
return f"""
select q.id, q.question_text, q.options, q.correct_answer, q.explanation, qc.name as category
from questions q
left join question_categories qc on qc.id = q.question_category_id
where q.question_type = 'mcq'
and q.options is not null
and q.is_shared = 1
{where}
order by random()
limit %s
"""
def valid_questions(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
clean = []
for row in rows:
options = normalize_options(row.get("options"))
if len(options) < 2:
continue
idx = answer_index(row, options)
if idx is None:
continue
row["options"] = options
row["correct_index"] = idx
clean.append(row)
return clean
def random_questions(limit: int) -> list[dict[str, Any]]:
rows = db_query(question_base_sql(), (limit * 2,))
return valid_questions(rows)[:limit]
def search_questions(term: str, limit: int) -> list[dict[str, Any]]:
pattern = f"%{term}%"
rows = db_query(
question_base_sql("""
and (
q.question_text ilike %s
or q.explanation ilike %s
or qc.name ilike %s
or exists (
select 1
from question_tag_links qtl
join question_tags qt on qt.id = qtl.tag_id
where qtl.question_id = q.id and qt.name ilike %s
)
)
"""),
(pattern, pattern, pattern, pattern, limit * 2),
)
return valid_questions(rows)[:limit]
def category_questions(category_id: int, limit: int) -> list[dict[str, Any]]:
rows = db_query(question_base_sql("and q.question_category_id = %s"), (category_id, limit * 2))
return valid_questions(rows)[:limit]
def tag_questions(tag_id: int, limit: int) -> list[dict[str, Any]]:
rows = db_query(
question_base_sql("and exists (select 1 from question_tag_links qtl where qtl.question_id = q.id and qtl.tag_id = %s)"),
(tag_id, limit * 2),
)
return valid_questions(rows)[:limit]
def list_categories() -> list[dict[str, Any]]:
return db_query(
"""
select qc.id, qc.name, count(q.id)::int as count
from question_categories qc
join questions q on q.question_category_id = qc.id and q.question_type = 'mcq' and q.is_shared = 1
group by qc.id, qc.name
having count(q.id) > 0
order by qc.name
"""
)
def search_tags(term: str | None = None, limit: int = 20) -> list[dict[str, Any]]:
where = ""
params: list[Any] = []
if term:
where = "where t.name ilike %s"
params.append(f"%{term}%")
params.append(limit)
return db_query(
f"""
select t.id, t.name, t.type, count(qtl.question_id)::int as count
from question_tags t
join question_tag_links qtl on qtl.tag_id = t.id
join questions q on q.id = qtl.question_id and q.question_type = 'mcq' and q.is_shared = 1
{where}
group by t.id, t.name, t.type
order by count(qtl.question_id) desc, t.name
limit %s
""",
tuple(params),
)
def clamp_count(value: int | None) -> int:
if value is None:
return DEFAULT_QUIZ_SIZE
return max(1, min(MAX_QUIZ_SIZE, value))
def parse_count(args: list[str]) -> int:
for arg in args:
if arg.isdigit():
return clamp_count(int(arg))
return DEFAULT_QUIZ_SIZE
def parse_mode(args: list[str]) -> str:
lowered = {arg.lower() for arg in args}
return "exam" if "exam" in lowered else "study"
async def send_text(update: Update, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None:
if update.message:
await update.message.reply_text(
text,
parse_mode=ParseMode.HTML,
disable_web_page_preview=True,
reply_markup=reply_markup,
)
async def respond(update: Update, context: ContextTypes.DEFAULT_TYPE | None, text: str, reply_markup: InlineKeyboardMarkup | None = None) -> None:
if update.message:
await send_text(update, text, reply_markup)
elif update.callback_query and update.callback_query.message:
await update.callback_query.edit_message_text(text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True)
elif context and update.effective_chat:
await context.bot.send_message(update.effective_chat.id, text, parse_mode=ParseMode.HTML, reply_markup=reply_markup, disable_web_page_preview=True)
def main_menu() -> InlineKeyboardMarkup:
return InlineKeyboardMarkup([
[InlineKeyboardButton("Random 20 study", callback_data="quiz:random:20:study")],
[InlineKeyboardButton("Random 20 exam", callback_data="quiz:random:20:exam")],
[InlineKeyboardButton("Categories", callback_data="list:categories:0")],
[InlineKeyboardButton("Top keywords", callback_data="list:tags:0")],
])
def count_menu(kind: str, item_id: int) -> InlineKeyboardMarkup:
buttons = []
for count in (5, 10, 20, 30, 50):
buttons.append([
InlineKeyboardButton(f"{count} study", callback_data=f"start:{kind}:{item_id}:{count}:study"),
InlineKeyboardButton(f"{count} exam", callback_data=f"start:{kind}:{item_id}:{count}:exam"),
])
return InlineKeyboardMarkup(buttons)
def format_question_review(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int, ok: bool) -> str:
lines = [
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
html.escape(question["question_text"]),
"",
]
for idx, option in enumerate(question["options"][:8]):
marker = ""
if idx == correct_idx:
marker = " correct"
elif idx == chosen_idx:
marker = " your answer"
lines.append(f"{LETTERS[idx]}. {html.escape(option)}{marker}")
lines.extend([
"",
"Correct." if ok else f"Incorrect. Correct answer: {LETTERS[correct_idx]}",
"",
f"<b>Explanation:</b> {html.escape(question.get('explanation') or 'No explanation available.')}",
])
return "\n".join(lines)
def truncate_text(value: str, limit: int) -> str:
value = (value or "").strip()
if len(value) <= limit:
return value
return value[:limit].rsplit(" ", 1)[0].rstrip() + "..."
def answer_feedback_header(chosen_idx: int, correct_idx: int, ok: bool) -> str:
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
return "Correct." if ok else f"Incorrect. You chose {chosen}; correct answer: {correct}."
def split_plain_text(text: str, limit: int) -> list[str]:
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
split_at = remaining.rfind("\n\n", 0, limit)
if split_at < limit // 2:
split_at = remaining.rfind("\n", 0, limit)
if split_at < limit // 2:
split_at = remaining.rfind(" ", 0, limit)
if split_at < limit // 2:
split_at = limit
chunks.append(remaining[:split_at].rstrip())
remaining = remaining[split_at:].lstrip()
return chunks
async def send_answer_feedback(
context: ContextTypes.DEFAULT_TYPE,
chat_id: int,
question: dict[str, Any],
chosen_idx: int,
correct_idx: int,
ok: bool,
) -> None:
header = answer_feedback_header(chosen_idx, correct_idx, ok)
explanation = (question.get("explanation") or "No explanation available.").strip()
first_prefix = f"{header}\n\n<b>Explanation:</b> "
next_prefix = "<b>Explanation continued:</b> "
first_limit = TELEGRAM_MESSAGE_LIMIT - len(first_prefix) - 200
next_limit = TELEGRAM_MESSAGE_LIMIT - len(next_prefix) - 200
chunks = split_plain_text(explanation, max(1000, first_limit))
for idx, chunk in enumerate(chunks):
prefix = first_prefix if idx == 0 else next_prefix
if idx > 0 and len(chunk) > next_limit:
# First chunk has a smaller budget because it includes answer feedback.
for subchunk in split_plain_text(chunk, next_limit):
await context.bot.send_message(chat_id, f"{next_prefix}{html.escape(subchunk)}", parse_mode=ParseMode.HTML)
continue
await context.bot.send_message(chat_id, f"{prefix}{html.escape(chunk)}", parse_mode=ParseMode.HTML)
def answered_question_text(state: QuizState, question: dict[str, Any], chosen_idx: int, correct_idx: int) -> str:
lines = [
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
html.escape(truncate_text(question["question_text"], 1600)),
"",
]
for idx, option in enumerate(question["options"][:8]):
marker = ""
if idx == correct_idx:
marker = " ✓ correct"
elif idx == chosen_idx:
marker = " ✗ your answer"
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 420))}{marker}")
lines.extend([
"",
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
])
return "\n".join(lines)
async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await send_text(update, "\n".join([
"<b>PedQuiz bot</b>",
"Send <code>20</code> for a random 20-question study quiz.",
"Use /random 20 exam for exam mode.",
"Use /categories to choose from PREP/category buckets.",
"Use /keywords fever to search generated keywords/subjects.",
"Use /search sepsis 20 to quiz by text search.",
"Use /stop to end the current quiz.",
"",
"Study mode shows each answer immediately. Exam mode shows answers at the end.",
]), main_menu())
async def random_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await start_quiz(update, random_questions(parse_count(context.args)), parse_mode(context.args))
async def search_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if not context.args:
await send_text(update, "Usage: /search <topic> [number] [study|exam]")
return
count = parse_count(context.args)
mode = parse_mode(context.args)
term = " ".join(arg for arg in context.args if not arg.isdigit() and arg.lower() not in {"study", "exam"}).strip()
if not term:
await send_text(update, "Usage: /search <topic> [number] [study|exam]")
return
if update.message:
await update.message.chat.send_action(ChatAction.TYPING)
await start_quiz(update, search_questions(term, count), mode, label=f"Search: {term}")
async def categories_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
await show_categories(update, page=0)
async def keywords_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
term = " ".join(context.args).strip() if context.args else None
rows = search_tags(term, 30)
if not rows:
await send_text(update, "No keywords found.")
return
buttons = [
[InlineKeyboardButton(f"{row['name']} ({row['type']}, {row['count']})", callback_data=f"pick:tag:{row['id']}")]
for row in rows[:20]
]
await send_text(update, "Choose a keyword/subject, then choose quiz size:", InlineKeyboardMarkup(buttons))
async def stop_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
if update.effective_chat:
active_quizzes.pop(update.effective_chat.id, None)
await send_text(update, "Quiz stopped.", main_menu())
async def show_categories(update: Update, page: int) -> None:
rows = list_categories()
per_page = 10
start = page * per_page
chunk = rows[start:start + per_page]
if not chunk:
await send_text(update, "No categories found.")
return
buttons = [
[InlineKeyboardButton(f"{row['name']} ({row['count']})", callback_data=f"pick:category:{row['id']}")]
for row in chunk
]
nav = []
if page > 0:
nav.append(InlineKeyboardButton("Prev", callback_data=f"list:categories:{page - 1}"))
if start + per_page < len(rows):
nav.append(InlineKeyboardButton("Next", callback_data=f"list:categories:{page + 1}"))
if nav:
buttons.append(nav)
text = "Choose a category, then choose quiz size and mode."
if update.callback_query:
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons))
else:
await send_text(update, text, InlineKeyboardMarkup(buttons))
async def show_tags(update: Update, page: int) -> None:
rows = search_tags(limit=80)
per_page = 10
start = page * per_page
chunk = rows[start:start + per_page]
buttons = [
[InlineKeyboardButton(f"{row['name']} ({row['type']}, {row['count']})", callback_data=f"pick:tag:{row['id']}")]
for row in chunk
]
nav = []
if page > 0:
nav.append(InlineKeyboardButton("Prev", callback_data=f"list:tags:{page - 1}"))
if start + per_page < len(rows):
nav.append(InlineKeyboardButton("Next", callback_data=f"list:tags:{page + 1}"))
if nav:
buttons.append(nav)
text = "Choose a keyword/subject, then choose quiz size and mode."
if update.callback_query:
await update.callback_query.edit_message_text(text, reply_markup=InlineKeyboardMarkup(buttons))
else:
await send_text(update, text, InlineKeyboardMarkup(buttons))
async def start_quiz(
update: Update,
questions: list[dict[str, Any]],
mode: str,
label: str = "Random",
context: ContextTypes.DEFAULT_TYPE | None = None,
) -> None:
if not update.effective_chat:
return
if not questions:
await respond(update, context, "No usable MCQ questions found for that selection. Try /keywords, /categories, or a broader /search term.", main_menu())
return
random.shuffle(questions)
active_quizzes[update.effective_chat.id] = QuizState(questions=questions, mode=mode)
start_text = f"{html.escape(label)} quiz started: {len(questions)} questions, {mode} mode."
if update.message:
await send_text(update, start_text)
await send_current_question(update.effective_chat.id, update, None)
elif context:
await context.bot.send_message(update.effective_chat.id, start_text, parse_mode=ParseMode.HTML)
await send_current_question(update.effective_chat.id, None, context)
async def send_current_question(chat_id: int, update: Update | None, context: ContextTypes.DEFAULT_TYPE | None) -> None:
state = active_quizzes.get(chat_id)
if not state:
return
question = state.questions[state.index]
options = question["options"]
buttons = [
[InlineKeyboardButton(LETTERS[idx], callback_data=f"answer:{idx}")]
for idx, option in enumerate(options[:8])
]
lines = [
f"<b>Question {state.index + 1}/{len(state.questions)}</b>",
html.escape(truncate_text(question["question_text"], 1600)),
"",
]
for idx, option in enumerate(options[:8]):
lines.append(f"{LETTERS[idx]}. {html.escape(truncate_text(option, 700))}")
lines.extend([
"",
f"Category: {html.escape(str(question.get('category') or 'Uncategorized'))}",
])
text = "\n".join(lines)
markup = InlineKeyboardMarkup(buttons)
if update and update.message:
await update.message.reply_text(text, parse_mode=ParseMode.HTML, reply_markup=markup)
elif context:
await context.bot.send_message(chat_id, text, parse_mode=ParseMode.HTML, reply_markup=markup)
async def finish_quiz(chat_id: int, context: ContextTypes.DEFAULT_TYPE) -> None:
state = active_quizzes.pop(chat_id, None)
if not state:
return
total = len(state.questions)
lines = [f"Quiz complete. Score: {state.score}/{total}"]
if state.mode == "exam":
lines.append("")
lines.append("Answers:")
for question_id, chosen, correct, ok in state.answers:
marker = "OK" if ok else "MISS"
lines.append(f"Q{question_id}: {marker}. You: {chosen}. Correct: {correct}")
lines.append("")
lines.append(f"Full question bank: {PUBLIC_APP_URL}")
await context.bot.send_message(chat_id, "\n".join(lines), reply_markup=main_menu(), disable_web_page_preview=True)
async def callback_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
query = update.callback_query
if not query:
return
await query.answer()
data = query.data or ""
chat_id = query.message.chat_id if query.message else update.effective_chat.id
if data.startswith("list:categories:"):
await show_categories(update, int(data.rsplit(":", 1)[1]))
return
if data.startswith("list:tags:"):
await show_tags(update, int(data.rsplit(":", 1)[1]))
return
if data.startswith("quiz:random:"):
_, _, count, mode = data.split(":", 3)
await start_quiz(update, random_questions(clamp_count(int(count))), mode, context=context)
return
if data.startswith("pick:category:"):
category_id = int(data.rsplit(":", 1)[1])
await query.edit_message_text("How many questions?", reply_markup=count_menu("category", category_id))
return
if data.startswith("pick:tag:"):
tag_id = int(data.rsplit(":", 1)[1])
await query.edit_message_text("How many questions?", reply_markup=count_menu("tag", tag_id))
return
if data.startswith("start:category:"):
_, _, category_id, count, mode = data.split(":", 4)
await start_quiz(update, category_questions(int(category_id), clamp_count(int(count))), mode, "Category", context)
return
if data.startswith("start:tag:"):
_, _, tag_id, count, mode = data.split(":", 4)
await start_quiz(update, tag_questions(int(tag_id), clamp_count(int(count))), mode, "Keyword", context)
return
if data.startswith("answer:"):
state = active_quizzes.get(chat_id)
if not state:
await query.edit_message_text("This quiz expired. Start a new one with /random or /categories.")
return
chosen_idx = int(data.split(":", 1)[1])
question = state.questions[state.index]
correct_idx = question["correct_index"]
ok = chosen_idx == correct_idx
if ok:
state.score += 1
chosen = LETTERS[chosen_idx] if chosen_idx < len(LETTERS) else "?"
correct = LETTERS[correct_idx] if correct_idx < len(LETTERS) else "?"
state.answers.append((state.index + 1, chosen, correct, ok))
if state.mode == "study":
await query.edit_message_text(
answered_question_text(state, question, chosen_idx, correct_idx),
parse_mode=ParseMode.HTML,
)
await send_answer_feedback(context, chat_id, question, chosen_idx, correct_idx, ok)
else:
await query.edit_message_reply_markup(reply_markup=None)
state.index += 1
if state.index >= len(state.questions):
await finish_quiz(chat_id, context)
else:
await send_current_question(chat_id, None, context)
async def text_handler(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
text = update.message.text if update.message else ""
match = NUMBER_RE.match(text or "")
if match:
await start_quiz(update, random_questions(clamp_count(int(match.group(1)))), "study")
return
await send_text(update, "Send a number like 20, or use /categories, /keywords, /search, /random.", main_menu())
async def post_init(app: Application) -> None:
await app.bot.set_my_commands([
BotCommand("start", "Show help and quick quiz buttons"),
BotCommand("random", "Start a random quiz: /random 20 exam"),
BotCommand("categories", "Browse categories"),
BotCommand("keywords", "Browse/search keywords: /keywords fever"),
BotCommand("search", "Search text/topic: /search sepsis 20"),
BotCommand("stop", "Stop the current quiz"),
])
def main() -> None:
app = Application.builder().token(TELEGRAM_BOT_TOKEN).post_init(post_init).build()
app.add_handler(CommandHandler(["start", "help"], help_cmd))
app.add_handler(CommandHandler("random", random_cmd))
app.add_handler(CommandHandler("search", search_cmd))
app.add_handler(CommandHandler("categories", categories_cmd))
app.add_handler(CommandHandler(["keywords", "tags"], keywords_cmd))
app.add_handler(CommandHandler("stop", stop_cmd))
app.add_handler(CallbackQueryHandler(callback_handler))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, text_handler))
app.run_polling(allowed_updates=Update.ALL_TYPES)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,2 @@
python-telegram-bot>=21.0,<22
psycopg[binary]>=3.1,<4