Compare commits
No commits in common. "master" and "pre-ky-openapi" have entirely different histories.
master
...
pre-ky-ope
62 changed files with 1726 additions and 6593 deletions
|
|
@ -1,126 +0,0 @@
|
||||||
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
1
.gitignore
vendored
|
|
@ -25,4 +25,3 @@ backend/.env.save
|
||||||
|
|
||||||
# Database backups
|
# Database backups
|
||||||
backups/
|
backups/
|
||||||
.firecrawl/
|
|
||||||
|
|
|
||||||
20
README.md
20
README.md
|
|
@ -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**: LiteLLM-routed local TTS, OpenAI TTS, ElevenLabs, Google Cloud — voice selection per quiz
|
- **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs, Google Cloud — voice selection per quiz
|
||||||
- **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords
|
- **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 | LiteLLM-routed local TTS, OpenAI (direct), ElevenLabs, Google Cloud TTS |
|
| TTS | OpenAI (direct), AWS Polly, 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 ssh://git.danvics.com:2222/danvics/pdf-quiz-generator.git
|
git clone https://github.com/ifedan-ed/pdf-quiz-generator.git
|
||||||
cd pdf-quiz-generator
|
cd pdf-quiz-generator
|
||||||
|
|
||||||
# Configure environment
|
# Configure environment
|
||||||
|
|
@ -112,10 +112,6 @@ 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`)
|
||||||
|
|
@ -311,6 +307,7 @@ 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
|
||||||
|
|
@ -460,8 +457,9 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
|
||||||
|
|
||||||
## TTS Providers
|
## TTS Providers
|
||||||
|
|
||||||
| Provider | Model/voice ID format | Key needed |
|
| Provider | Model ID format | Key needed |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Kokoro via LiteLLM | `local-kokoro-tts:am_adam`, `local-kokoro-tts:af_bella` | `LITELLM_API_KEY` |
|
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1-hd:*` | `OPENAI_API_KEY` |
|
||||||
|
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy` | `AWS_ACCESS_KEY_ID` + IAM `polly:SynthesizeSpeech` |
|
||||||
The part before `:` is the LiteLLM speech model route. The part after `:` is the Kokoro speaker voice.
|
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
|
||||||
|
| Google Cloud | `google/<voice-name>` | `GOOGLE_TTS_API_KEY` |
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -15,9 +11,6 @@ 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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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, UserNote # noqa
|
from app.models import User, PDFDocument, Section, Quiz, Question, QuizAttempt, AttemptAnswer, ReminderSchedule # noqa
|
||||||
from app.database import Base
|
from app.database import Base
|
||||||
|
|
||||||
target_metadata = Base.metadata
|
target_metadata = Base.metadata
|
||||||
|
|
|
||||||
|
|
@ -1,38 +0,0 @@
|
||||||
"""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')
|
|
||||||
|
|
@ -18,7 +18,6 @@ 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"
|
||||||
|
|
@ -42,9 +41,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -57,4 +53,5 @@ class Settings(BaseSettings):
|
||||||
|
|
||||||
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
|
LOG_LEVEL: str = "INFO" # DEBUG, INFO, WARNING, ERROR
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
|
||||||
|
|
@ -11,39 +11,30 @@ 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, mobile, mynote
|
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses
|
||||||
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():
|
||||||
"""Optionally create a configured bootstrap admin user if none exists."""
|
"""Create default 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=settings.DEFAULT_ADMIN_EMAIL.lower().strip(),
|
email="admin@quizapp.com",
|
||||||
hashed_password=get_password_hash(settings.DEFAULT_ADMIN_PASSWORD),
|
hashed_password=get_password_hash("admin123"),
|
||||||
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",
|
||||||
|
|
@ -53,6 +44,8 @@ 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(
|
||||||
|
|
@ -68,8 +61,6 @@ 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:
|
||||||
|
|
@ -91,72 +82,49 @@ def seed_default_models():
|
||||||
db.delete(titan)
|
db.delete(titan)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# Always ensure LiteLLM-routed local speech models exist (idempotent).
|
# Always ensure OpenAI TTS voice models exist (idempotent)
|
||||||
tts_voices = [
|
tts_voices = [
|
||||||
("Kokoro Adam", "local-kokoro-tts:am_adam", True),
|
# OpenAI (work with OPENAI_API_KEY)
|
||||||
("Kokoro Michael", "local-kokoro-tts:am_michael", False),
|
("OpenAI Alloy", "tts-1:alloy", True),
|
||||||
("Kokoro Bella", "local-kokoro-tts:af_bella", False),
|
("OpenAI Nova", "tts-1:nova", False),
|
||||||
("Kokoro Nicole", "local-kokoro-tts:af_nicole", False),
|
("OpenAI Echo", "tts-1:echo", False),
|
||||||
("Kokoro Emma", "local-kokoro-tts:bf_emma", False),
|
("OpenAI Shimmer", "tts-1:shimmer", False),
|
||||||
("Kokoro Lewis", "local-kokoro-tts:bm_lewis", False),
|
("OpenAI Onyx", "tts-1:onyx", 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),
|
||||||
]
|
]
|
||||||
stt_models = [
|
# Deactivate old generic tts-1 / tts-1-hd entries (no voice encoded)
|
||||||
("Parakeet STT (LiteLLM)", "local-parakeet-v3", True),
|
for old_id in ("tts-1", "tts-1-hd"):
|
||||||
("Groq Whisper Turbo", "groq-whisper-large-v3-turbo", False),
|
old = db.query(AIModelConfig).filter(AIModelConfig.model_id == old_id).first()
|
||||||
("Groq Whisper Large v3", "groq-whisper-large-v3", False),
|
if old:
|
||||||
]
|
|
||||||
# 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:
|
||||||
db.execute(text("""
|
exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first()
|
||||||
INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at)
|
if not exists:
|
||||||
VALUES (:name, :model_id, 'tts', true, false, NOW())
|
is_def = not has_default_tts
|
||||||
ON CONFLICT (model_id, task) DO NOTHING
|
db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def))
|
||||||
"""), {"name": name, "model_id": model_id})
|
if is_def:
|
||||||
|
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()
|
||||||
|
|
@ -606,8 +574,6 @@ 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")
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ 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",
|
||||||
|
|
@ -20,5 +19,4 @@ __all__ = [
|
||||||
"ReminderSchedule",
|
"ReminderSchedule",
|
||||||
"AIModelConfig",
|
"AIModelConfig",
|
||||||
"Favorite",
|
"Favorite",
|
||||||
"UserNote",
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ 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):
|
||||||
|
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
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")
|
|
||||||
|
|
@ -139,7 +139,6 @@ 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")
|
||||||
|
|
@ -156,16 +155,8 @@ 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 {}
|
||||||
if data.mode:
|
resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \
|
||||||
info_base = base.rstrip("/").removesuffix("/v1")
|
httpx.get(f"{base}/v1/models", headers=headers, timeout=10)
|
||||||
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}
|
||||||
|
|
@ -196,9 +187,8 @@ def create_model(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
admin: User = Depends(require_admin),
|
admin: User = Depends(require_admin),
|
||||||
):
|
):
|
||||||
valid_tasks = ("extraction", "tts", "stt", "teach", "keyword", "flashcard")
|
if data.task not in ("extraction", "tts", "teach", "keyword", "flashcard"):
|
||||||
if data.task not in valid_tasks:
|
raise HTTPException(status_code=400, detail="Task must be extraction, tts, teach, or keyword")
|
||||||
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(
|
||||||
|
|
@ -286,27 +276,6 @@ 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
|
||||||
|
|
@ -335,93 +304,90 @@ 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 local TTS voices/models from LiteLLM or the local speech gateway."""
|
"""Discover available TTS voices from ElevenLabs, AWS Polly, or return OpenAI hardcoded list."""
|
||||||
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 != "litellm":
|
if provider == "elevenlabs":
|
||||||
raise HTTPException(status_code=400, detail="TTS discovery is routed through LiteLLM only")
|
key = api_key or settings.ELEVENLABS_API_KEY
|
||||||
|
if not key:
|
||||||
if provider == "litellm":
|
raise HTTPException(status_code=400, detail="ElevenLabs API key required (set ELEVENLABS_API_KEY in .env or enter it above)")
|
||||||
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:
|
||||||
headers = {"Authorization": f"Bearer {key}"} if key else {}
|
resp = httpx.get(
|
||||||
resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10)
|
"https://api.elevenlabs.io/v1/voices",
|
||||||
|
headers={"xi-api-key": key},
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
models = resp.json().get("data", [])
|
voices = resp.json().get("voices", [])
|
||||||
voices = []
|
return {"voices": [
|
||||||
for m in models:
|
{
|
||||||
model_name = m.get("model_name")
|
"model_id": f"elevenlabs/{v['voice_id']}",
|
||||||
if not model_name or (m.get("model_info") or {}).get("mode") != "audio_speech":
|
"name": v["name"],
|
||||||
continue
|
"labels": v.get("labels", {}),
|
||||||
if model_name == "local-kokoro-tts":
|
}
|
||||||
voices.extend(_kokoro_voice_options(model_name))
|
for v in sorted(voices, key=lambda x: x["name"])
|
||||||
continue
|
]}
|
||||||
voices.append({
|
|
||||||
"model_id": model_name,
|
|
||||||
"name": model_name,
|
|
||||||
"labels": {"provider": "litellm", "mode": "audio_speech"},
|
|
||||||
})
|
|
||||||
return {"voices": voices}
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.warning(f"LiteLLM local TTS discovery failed: {e}")
|
log.warning(f"ElevenLabs voice discovery failed: {e}")
|
||||||
raise HTTPException(status_code=400, detail=f"LiteLLM TTS discovery error: {e}")
|
raise HTTPException(status_code=400, detail=f"ElevenLabs API error: {e}")
|
||||||
|
|
||||||
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: litellm")
|
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({
|
||||||
|
"model_id": f"{model_name}:{voice}",
|
||||||
|
"name": f"{model_name} · {voice}",
|
||||||
|
"labels": {"model": model_name, "voice": voice},
|
||||||
|
})
|
||||||
|
return {"voices": voices}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown provider '{provider}'. Valid: elevenlabs, polly, openai")
|
||||||
|
|
||||||
|
|
||||||
# --- System Settings ---
|
# --- System Settings ---
|
||||||
|
|
@ -434,10 +400,12 @@ 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,
|
||||||
|
|
@ -446,6 +414,7 @@ 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,
|
||||||
|
|
@ -469,6 +438,10 @@ 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)
|
||||||
|
|
|
||||||
|
|
@ -90,8 +90,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,244 +0,0 @@
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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}
|
|
||||||
|
|
@ -18,8 +18,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
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
|
||||||
|
|
@ -17,30 +18,14 @@ class TTSRequest(BaseModel):
|
||||||
voice: str | None = None # model_id override
|
voice: str | None = None # model_id override
|
||||||
|
|
||||||
|
|
||||||
MAX_AUDIO_UPLOAD_BYTES = 25 * 1024 * 1024
|
def _polly_enabled() -> bool:
|
||||||
|
try:
|
||||||
|
import redis as redis_lib
|
||||||
def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None]:
|
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True)
|
||||||
config = db.query(AIModelConfig).filter(
|
val = r.get("settings:polly_enabled")
|
||||||
AIModelConfig.task == task,
|
return val != "false"
|
||||||
AIModelConfig.is_active == True,
|
except Exception:
|
||||||
AIModelConfig.is_default == True,
|
return 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")
|
||||||
|
|
@ -48,12 +33,13 @@ 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 LiteLLM-routed TTS models only."""
|
"""Return available TTS voices from DB, excluding Polly if disabled."""
|
||||||
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]
|
||||||
|
|
||||||
|
|
@ -68,9 +54,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=240,
|
max_calls=60,
|
||||||
window_seconds=3600,
|
window_seconds=3600,
|
||||||
detail="You've reached the audio limit. The limit resets automatically — try again shortly. Contact an admin if you need this raised.",
|
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.",
|
||||||
user=current_user,
|
user=current_user,
|
||||||
)
|
)
|
||||||
if not request.text.strip():
|
if not request.text.strip():
|
||||||
|
|
@ -78,55 +64,23 @@ def text_to_speech(
|
||||||
|
|
||||||
text = request.text[:2000]
|
text = request.text[:2000]
|
||||||
|
|
||||||
if request.voice and request.voice.startswith("local-"):
|
if request.voice:
|
||||||
|
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.model_id == request.voice,
|
AIModelConfig.is_default == True,
|
||||||
).first()
|
).first()
|
||||||
if not config:
|
model_id = config.model_id if config else "tts-1:alloy"
|
||||||
model_id, api_key = _default_tts_model(db)
|
api_key = config.api_key if (config and config.api_key) else None
|
||||||
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}
|
|
||||||
|
|
|
||||||
|
|
@ -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, stt, teach, keyword, flashcard
|
task: str # extraction, tts, general
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,6 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -233,56 +233,6 @@ 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",
|
||||||
|
|
@ -380,63 +330,19 @@ 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 local LiteLLM, local Sherpa, OpenAI, ElevenLabs, and Google Cloud TTS.
|
"""Generate TTS audio. Supports OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly.
|
||||||
|
|
||||||
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)
|
||||||
sherpa/<profile>:<voice> → Local speech gateway (e.g. sherpa/kokoro:am_adam)
|
polly/<VoiceId> → AWS Polly Neural (e.g. polly/Joanna)
|
||||||
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]
|
||||||
|
|
@ -483,6 +389,34 @@ 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/", "")
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,3 @@ 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
|
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
image: pgvector/pgvector:pg16
|
image: pgvector/pgvector:pg16
|
||||||
|
|
@ -35,9 +37,6 @@ 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
|
||||||
|
|
@ -69,18 +68,6 @@ 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
|
||||||
|
|
@ -145,7 +132,3 @@ volumes:
|
||||||
loki_data:
|
loki_data:
|
||||||
grafana_data:
|
grafana_data:
|
||||||
promtail_positions:
|
promtail_positions:
|
||||||
|
|
||||||
networks:
|
|
||||||
danvics_speech:
|
|
||||||
external: true
|
|
||||||
|
|
|
||||||
|
|
@ -705,7 +705,7 @@ Convert text to speech audio.
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
- **Response:** Binary `audio/mpeg` data
|
- **Response:** Binary `audio/mpeg` data
|
||||||
- **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.
|
- **Notes:** Supports OpenAI TTS (`tts-1:alloy`), ElevenLabs (`elevenlabs/<voice_id>`), Google Cloud TTS (`google/<voice_name>`), and AWS Polly (`polly/<VoiceId>`).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -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"}`
|
- **Response:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}`
|
||||||
|
|
||||||
#### 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"}`
|
- **Request body:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}`
|
||||||
- **Response:** `{"success": true, "message": "Settings updated"}`
|
- **Response:** `{"success": true, "message": "Settings updated"}`
|
||||||
|
|
||||||
### Embedding Management
|
### Embedding Management
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ 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`.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
FROM node:20-alpine AS build
|
FROM node:18-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json* ./
|
COPY package.json package-lock.json* ./
|
||||||
|
|
|
||||||
4658
frontend/package-lock.json
generated
4658
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -6,39 +6,33 @@
|
||||||
"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",
|
||||||
"remark-math": "^6.0.0"
|
"@milkdown/core": "^7.6.2",
|
||||||
|
"@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": "^6.0.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
"jsdom": "^29.1.1",
|
"vite": "^5.1.0"
|
||||||
"vite": "^8.0.11",
|
|
||||||
"vitest": "^4.1.5"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,33 @@
|
||||||
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'
|
||||||
const LoginPage = lazy(() => import('./pages/LoginPage'))
|
import RegisterPage from './pages/RegisterPage'
|
||||||
const RegisterPage = lazy(() => import('./pages/RegisterPage'))
|
import DashboardPage from './pages/DashboardPage'
|
||||||
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
|
import UploadPage from './pages/UploadPage'
|
||||||
const UploadPage = lazy(() => import('./pages/UploadPage'))
|
import DocumentDetailPage from './pages/DocumentDetailPage'
|
||||||
const DocumentDetailPage = lazy(() => import('./pages/DocumentDetailPage'))
|
import QuizPage from './pages/QuizPage'
|
||||||
const QuizPage = lazy(() => import('./pages/QuizPage'))
|
import QuizzesPage from './pages/QuizzesPage'
|
||||||
const QuizzesPage = lazy(() => import('./pages/QuizzesPage'))
|
import ResultsPage from './pages/ResultsPage'
|
||||||
const ResultsPage = lazy(() => import('./pages/ResultsPage'))
|
import AdminPage from './pages/AdminPage'
|
||||||
const AdminPage = lazy(() => import('./pages/AdminPage'))
|
import AccountPage from './pages/AccountPage'
|
||||||
const AccountPage = lazy(() => import('./pages/AccountPage'))
|
import SettingsPage from './pages/SettingsPage'
|
||||||
const SettingsPage = lazy(() => import('./pages/SettingsPage'))
|
import QuestionBankPage from './pages/QuestionBankPage'
|
||||||
const QuestionBankPage = lazy(() => import('./pages/QuestionBankPage'))
|
import JobsPage from './pages/JobsPage'
|
||||||
const JobsPage = lazy(() => import('./pages/JobsPage'))
|
import TrashPage from './pages/TrashPage'
|
||||||
const TrashPage = lazy(() => import('./pages/TrashPage'))
|
import QuizEditPage from './pages/QuizEditPage'
|
||||||
const QuizEditPage = lazy(() => import('./pages/QuizEditPage'))
|
import VerifyEmailPage from './pages/VerifyEmailPage'
|
||||||
const VerifyEmailPage = lazy(() => import('./pages/VerifyEmailPage'))
|
import ForgotPasswordPage from './pages/ForgotPasswordPage'
|
||||||
const ForgotPasswordPage = lazy(() => import('./pages/ForgotPasswordPage'))
|
import ResetPasswordPage from './pages/ResetPasswordPage'
|
||||||
const ResetPasswordPage = lazy(() => import('./pages/ResetPasswordPage'))
|
import NotFoundPage from './pages/NotFoundPage'
|
||||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'))
|
import LandingPage from './pages/LandingPage'
|
||||||
const LandingPage = lazy(() => import('./pages/LandingPage'))
|
import FlashcardsPage from './pages/FlashcardsPage'
|
||||||
const FlashcardsPage = lazy(() => import('./pages/FlashcardsPage'))
|
import FlashcardStudyPage from './pages/FlashcardStudyPage'
|
||||||
const FlashcardStudyPage = lazy(() => import('./pages/FlashcardStudyPage'))
|
import CoursesPage from './pages/CoursesPage'
|
||||||
const CoursesPage = lazy(() => import('./pages/CoursesPage'))
|
import CourseDetailPage from './pages/CourseDetailPage'
|
||||||
const CourseDetailPage = lazy(() => import('./pages/CourseDetailPage'))
|
import CourseEditorPage from './pages/CourseEditorPage'
|
||||||
const CourseEditorPage = lazy(() => import('./pages/CourseEditorPage'))
|
import SsoCallbackPage from './pages/SsoCallbackPage'
|
||||||
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() {
|
||||||
|
|
@ -53,7 +47,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 <LoadingFallback />
|
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||||
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 />
|
||||||
|
|
@ -61,54 +55,52 @@ function RequireAuth({ moderator = false }) {
|
||||||
|
|
||||||
function AppRoutes() {
|
function AppRoutes() {
|
||||||
const { user, loading } = useAuth()
|
const { user, loading } = useAuth()
|
||||||
if (loading) return <LoadingFallback />
|
if (loading) return <div className="loading"><div className="spinner" /></div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Routes>
|
||||||
<Routes>
|
{/* Always public */}
|
||||||
{/* Always public */}
|
<Route path="/home" element={<LandingPage />} />
|
||||||
<Route path="/home" element={<LandingPage />} />
|
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
||||||
<Route path="/login" element={user ? <Navigate to="/" replace /> : <LoginPage />} />
|
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
||||||
<Route path="/register" element={user ? <Navigate to="/" replace /> : <RegisterPage />} />
|
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
||||||
<Route path="/verify-email" element={<VerifyEmailPage />} />
|
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
||||||
<Route path="/forgot-password" element={<ForgotPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/sso-callback" element={<SsoCallbackPage />} />
|
||||||
<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>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,245 +0,0 @@
|
||||||
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -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: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
|
--font-heading: 'Playfair Display', 'Source Serif 4', 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.02em; }
|
[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: 0; }
|
||||||
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 760; letter-spacing: -0.03em; line-height: 1.12; }
|
[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 700; letter-spacing: -0.02em; }
|
||||||
[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"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 600; }
|
||||||
[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,34 +135,6 @@ 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;
|
||||||
|
|
@ -210,264 +182,6 @@ 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; }
|
||||||
|
|
@ -475,7 +189,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: text;
|
background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none;
|
||||||
-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); }
|
||||||
|
|
@ -635,43 +349,6 @@ 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) ───────────────────────────── */
|
||||||
|
|
|
||||||
|
|
@ -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', 'stt', 'teach', 'keyword', 'flashcard']
|
const TASKS = ['extraction', 'tts', 'teach', 'keyword', 'flashcard']
|
||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
|
@ -14,15 +14,17 @@ 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: '' })
|
const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true })
|
||||||
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', is_active: true, is_default: false })
|
const [newModel, setNewModel] = useState({ name: '', model_id: '', task: 'extraction', api_key: '', 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('')
|
||||||
|
|
@ -30,7 +32,8 @@ 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('litellm')
|
const [ttsProvider, setTtsProvider] = useState('elevenlabs')
|
||||||
|
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('')
|
||||||
|
|
@ -112,7 +115,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', is_active: true, is_default: false })
|
setNewModel({ name: '', model_id: '', task: 'extraction', api_key: '', 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')
|
||||||
|
|
@ -170,7 +173,10 @@ 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')
|
||||||
|
|
@ -182,11 +188,12 @@ 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', { mode: 'embedding' })
|
const res = await api.post('/admin/litellm/models', {})
|
||||||
setEmbedSearchResults(res.data.models || [])
|
const all = 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 {
|
||||||
|
|
@ -248,6 +255,7 @@ 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) {
|
||||||
|
|
@ -317,9 +325,27 @@ 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 the configured LiteLLM proxy from the server environment. Works for chat, extraction, STT, and other configured tasks.
|
Query your LiteLLM proxy or any OpenAI-compatible API. Works for <strong>extraction</strong>, <strong>teach</strong>, and <strong>general</strong> 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)}>
|
||||||
|
|
@ -377,11 +403,9 @@ 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 === 'stt' ? '— Speech-to-text models' :
|
task === 'teach' ? '— AI tutor shown in study mode chat' :
|
||||||
task === 'teach' ? '— AI tutor shown in study mode chat' :
|
'— General purpose AI'}
|
||||||
task === 'keyword' ? '— Keyword and topic classification' :
|
|
||||||
'— Flashcard generation'}
|
|
||||||
</span>
|
</span>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
|
|
@ -392,8 +416,13 @@ 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="litellm">LiteLLM Local</option>
|
<option value="elevenlabs">ElevenLabs</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>
|
||||||
|
|
@ -517,6 +546,10 @@ 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 }))} />
|
||||||
|
|
@ -646,6 +679,37 @@ 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)' }}>
|
||||||
|
|
|
||||||
|
|
@ -2,8 +2,6 @@ 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) {
|
||||||
|
|
@ -15,6 +13,7 @@ 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)
|
||||||
|
|
@ -48,7 +47,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>
|
||||||
|
|
||||||
|
|
@ -76,10 +75,6 @@ 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">
|
||||||
|
|
@ -158,6 +153,12 @@ 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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,6 @@ 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',
|
||||||
|
|
@ -435,7 +430,7 @@ export default function LandingPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* ── Clinical tools section ─────────────────────────────────────────── */}
|
{/* ── AI Scribe 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>
|
||||||
|
|
@ -443,23 +438,22 @@ 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 Clinical Tools
|
Pediatric AI Scribe
|
||||||
</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 pediatric clinical assistant for daily practice: well visit workflows,
|
A clinical assistant built for pediatric providers. Voice-to-note documentation,
|
||||||
developmental milestones, vaccine schedules, catch-up planning,
|
age-specific well visit workflows, developmental milestones, vaccine schedules,
|
||||||
bedside calculators, dosing support, emergency pathways, and concise
|
catch-up planners, automatic ICD-10 billing codes, and a full pediatric
|
||||||
clinical references in one place, powered by Peds-AI clinical assistant tools.
|
calculators & bedside emergency reference — all in one tool.
|
||||||
</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: '💊 Weight-based dosing and pediatric calculators' },
|
{ text: '🎙 AI scribe — speak, get a structured note' },
|
||||||
{ text: '🤖 Peds-AI clinical assistant for quick pediatric guidance' },
|
{ text: '💊 Automatic ICD-10 and CPT billing codes' },
|
||||||
{ text: '🚨 Emergency pathways for sepsis, status epilepticus, RSI, burns, and anaphylaxis', badge: 'NEW' },
|
{ text: '🚨 Bedside calculators — weight-based dosing, emergency pathways (sepsis, status epilepticus, RSI, burns, 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>
|
||||||
|
|
@ -478,7 +472,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 Clinical Tools ↗
|
Open AI Scribe ↗
|
||||||
</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 }}>
|
||||||
|
|
@ -486,8 +480,9 @@ 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: 'Dosing', sub: 'Weight-based' },
|
{ icon: '🎙', label: 'AI Scribe', sub: 'Voice-to-note' },
|
||||||
{ icon: '📋', label: 'Clinical Assistant', sub: 'References + plans' },
|
{ icon: '💊', label: 'ICD-10', sub: 'Auto-suggested' },
|
||||||
|
{ 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={{
|
||||||
|
|
@ -537,7 +532,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' }}>Clinical Tools</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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
|
||||||
|
|
@ -532,8 +532,6 @@ 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()
|
||||||
|
|
@ -702,49 +700,6 @@ 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 {
|
||||||
|
|
@ -853,12 +808,27 @@ 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={handleQtiImport} disabled={qtiImporting}>
|
<button className="btn btn-secondary btn-sm" onClick={async () => {
|
||||||
{qtiImporting ? 'Importing QTI...' : 'Import QTI'}
|
const input = document.createElement('input'); input.type = 'file'; input.accept = '.xml'
|
||||||
</button>
|
input.onchange = async (e) => {
|
||||||
<button className="btn btn-secondary btn-sm" onClick={handleQtiExport} disabled={qtiExporting}>
|
const f = e.target.files[0]; if (!f) return
|
||||||
{qtiExporting ? 'Exporting QTI...' : `Export QTI${selectedIds.size > 0 ? ` (${selectedIds.size})` : ''}`}
|
const fd = new FormData(); fd.append('file', f)
|
||||||
</button>
|
try {
|
||||||
|
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>
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -2,197 +2,24 @@ 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'))
|
||||||
|
|
||||||
const OPTION_LETTERS = ['A', 'B', 'C', 'D', 'E', 'F']
|
function TTSButton({ text, voice, onActiveChange }) {
|
||||||
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 () => {
|
||||||
|
|
@ -204,29 +31,14 @@ function TTSButton({ text, voice, segments = [], onActiveChange, onSegmentChange
|
||||||
if (state === 'loading') return
|
if (state === 'loading') return
|
||||||
try {
|
try {
|
||||||
setStateAndNotify('loading')
|
setStateAndNotify('loading')
|
||||||
let url
|
const res = await api.post('/tts/speak', { text, voice: voice || null }, { responseType: 'blob' })
|
||||||
let revokeOnDone = false
|
const url = URL.createObjectURL(res.data)
|
||||||
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
|
||||||
const updateSegment = () => {
|
audio.onended = () => { setStateAndNotify('idle'); URL.revokeObjectURL(url) }
|
||||||
const activeIndex = getActiveSpeechSegment(audio, segments)
|
audio.onerror = () => setStateAndNotify('idle')
|
||||||
onSegmentChange?.(activeIndex === null ? null : segments[activeIndex] || null)
|
audio.play()
|
||||||
}
|
|
||||||
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') }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,21 +99,6 @@ 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' }}>
|
||||||
|
|
@ -318,22 +115,17 @@ 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={() => !startingMode && handleStart(mode)}
|
<div key={mode} onClick={() => onStart(mode, selectedVoice, mode === 'exam' && customTimer ? parseInt(customTimer) : null)}
|
||||||
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 }}
|
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }}
|
||||||
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 }}>{startingMode === mode ? 'Starting...' : desc}</div>
|
<div style={{ fontSize: '0.8rem', color }}>{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>
|
||||||
|
|
@ -359,21 +151,8 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function getQuizSessionId() {
|
// Unique session ID per tab — used to prevent concurrent resume on multiple devices
|
||||||
const key = 'pedshub_quiz_session_id'
|
const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36)
|
||||||
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()
|
||||||
|
|
@ -386,7 +165,6 @@ 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)
|
||||||
|
|
@ -398,19 +176,11 @@ 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)
|
||||||
|
|
@ -418,133 +188,15 @@ export default function QuizPage() {
|
||||||
toastRef.current = setTimeout(() => setToast(''), 3000)
|
toastRef.current = setTimeout(() => setToast(''), 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
const adjustImageZoom = (delta) => {
|
|
||||||
setImageZoom(z => Math.max(1, Math.min(4, z + delta)))
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
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 [leaveTarget, setLeaveTarget] = useState(null)
|
||||||
const questions = quiz?.questions || []
|
|
||||||
const current = questions[currentIdx]
|
|
||||||
const isStudy = quizMode === 'study'
|
|
||||||
|
|
||||||
const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => {
|
const resumeQuiz = useCallback(async (saved) => {
|
||||||
if (!selected || !current) return
|
const mode = saved.mode || saved.quizMode
|
||||||
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 feedback modes, load quiz with correct answers BEFORE showing.
|
// For study mode, 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}` : ''}`)
|
||||||
|
|
@ -571,7 +223,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 && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice)
|
if (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) {
|
||||||
|
|
@ -614,7 +266,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, voicesRes.data)
|
await resumeQuiz(progressRes.data)
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.response?.status === 409) {
|
if (err.response?.status === 409) {
|
||||||
|
|
@ -644,7 +296,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)
|
||||||
|
|
@ -657,17 +309,6 @@ 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) }
|
||||||
}
|
}
|
||||||
|
|
@ -686,80 +327,30 @@ 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(() => { saveProgressNow() }, 500)
|
saveProgressRef.current = setTimeout(() => {
|
||||||
|
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)
|
||||||
}, [saveProgressNow, attemptId, quizMode])
|
}, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime])
|
||||||
|
|
||||||
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 clearCurrentHighlights = () => {
|
const safeNavigate = (targetIdx) => setCurrentIdx(targetIdx)
|
||||||
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
|
||||||
|
|
@ -793,7 +384,7 @@ const timerStarted = timeLeft !== null
|
||||||
if (!quizMode) return (
|
if (!quizMode) return (
|
||||||
<div>
|
<div>
|
||||||
{isModerator && (
|
{isModerator && (
|
||||||
<div style={{ textAlign: 'right', marginBottom: 8, display: 'flex', gap: 8, justifyContent: 'flex-end', flexWrap: 'wrap' }}>
|
<div style={{ textAlign: 'right', marginBottom: 8 }}>
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
|
|
@ -810,37 +401,12 @@ 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)
|
||||||
|
|
@ -875,7 +441,6 @@ 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 }}>
|
||||||
|
|
@ -934,14 +499,13 @@ const timerStarted = timeLeft !== null
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="card quiz-header-card" style={{ marginBottom: 14 }}>
|
<div className="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 className="quiz-header-title">{quiz.title}</h2>
|
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{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',
|
background: isStudy ? '#d1fae5' : '#e0e7ff', color: isStudy ? '#065f46' : '#3730a3',
|
||||||
color: isStudy ? '#065f46' : '#3730a3',
|
|
||||||
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
|
padding: '1px 8px', borderRadius: 12, fontWeight: 600,
|
||||||
}}>
|
}}>
|
||||||
{isStudy ? '📖 Study' : '🎯 Exam'}
|
{isStudy ? '📖 Study' : '🎯 Exam'}
|
||||||
|
|
@ -978,24 +542,10 @@ 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" style={{
|
<div className="question-card">
|
||||||
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 }}>
|
<h3 style={{ marginBottom: 0, flex: 1 }}>Q{currentIdx + 1}. {current.question_text.replace('[IMAGE]', '')}</h3>
|
||||||
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'}
|
||||||
|
|
@ -1014,75 +564,24 @@ const timerStarted = timeLeft !== null
|
||||||
{favorites.includes(current.id) ? '⭐' : '☆'}
|
{favorites.includes(current.id) ? '⭐' : '☆'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginBottom: 10, display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center' }}>
|
{voices.length > 0 && (
|
||||||
{voices.length > 0 && (
|
<div style={{ marginBottom: 10 }}>
|
||||||
<TTSButton
|
<TTSButton
|
||||||
key={`${current.id}_${selectedVoice || 'default'}`}
|
key={`${current.id}_${answers[current.id] || ''}`}
|
||||||
text={buildQuestionSpeechText(current, currentIdx)}
|
text={`Question ${currentIdx + 1}. ${current.question_text.replace('[IMAGE]', '')}. Options: ${(current.options || []).join(', ')}`}
|
||||||
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 && (
|
||||||
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
|
<div style={{ margin: '10px 0' }}>
|
||||||
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
|
||||||
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
|
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
|
||||||
</button>
|
onError={e => e.target.style.display = 'none'} />
|
||||||
)}
|
|
||||||
{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 ? (
|
||||||
|
|
@ -1094,31 +593,13 @@ 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 && !hasActiveTextSelection() && setAnswer(current.id, opt)}
|
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
|
||||||
style={{
|
style={{ cursor: hasAnswered ? 'default' : 'pointer' }}>
|
||||||
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 }}>
|
<span style={{ flex: 1 }}>{opt}</span>
|
||||||
<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>
|
||||||
|
|
@ -1148,7 +629,26 @@ const timerStarted = timeLeft !== null
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{quizNavigation('bottom')}
|
{/* Prev / Next + mobile nav toggle */}
|
||||||
|
<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 && (
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,56 @@ 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)
|
||||||
|
|
@ -358,24 +403,6 @@ 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' }}>
|
||||||
|
|
@ -461,7 +488,7 @@ export default function QuizzesPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* In-progress quizzes */}
|
{/* In-progress quizzes */}
|
||||||
{!isSearching && <InProgressQuizzes />}
|
{!isSearching && <InProgressSection />}
|
||||||
|
|
||||||
{/* Past attempts (loads on demand) */}
|
{/* Past attempts (loads on demand) */}
|
||||||
{!isSearching && <PastAttemptsSection />}
|
{!isSearching && <PastAttemptsSection />}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState, useRef } from 'react'
|
import { useState, useRef } from 'react'
|
||||||
import { Link, useNavigate } from 'react-router-dom'
|
import { 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.{' '}
|
||||||
<Link to="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</Link> to add one.
|
<a href="/settings" style={{ color: 'var(--primary)' }}>Go to Settings</a> to add one.
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
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')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
import '@testing-library/jest-dom/vitest'
|
|
||||||
import { cleanup } from '@testing-library/react'
|
|
||||||
import { afterEach } from 'vitest'
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup()
|
|
||||||
})
|
|
||||||
|
|
@ -7,9 +7,5 @@ export default defineConfig({
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': 'http://localhost:8000'
|
'/api': 'http://localhost:8000'
|
||||||
}
|
}
|
||||||
},
|
|
||||||
test: {
|
|
||||||
environment: 'jsdom',
|
|
||||||
setupFiles: './src/test/setup.js'
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,6 @@ 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
|
||||||
|
|
@ -19,25 +13,12 @@ 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'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"allowMixedContent": false,
|
"allowMixedContent": true,
|
||||||
"backgroundColor": "#0f172a",
|
"backgroundColor": "#0f172a",
|
||||||
"webContentsDebuggingEnabled": false,
|
"webContentsDebuggingEnabled": true,
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|
|
||||||
|
|
@ -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://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
<input type="url" id="server-url" placeholder="https://pedshub.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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(function() {
|
(function() {
|
||||||
var STORAGE_KEY = 'pedshub_server_url';
|
var STORAGE_KEY = 'pedshub_server_url';
|
||||||
var DEFAULT_URL = 'https://quiz.danvics.com';
|
var DEFAULT_URL = 'https://pedshub.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');
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"allowMixedContent": false,
|
"allowMixedContent": true,
|
||||||
"backgroundColor": "#0f172a",
|
"backgroundColor": "#0f172a",
|
||||||
"webContentsDebuggingEnabled": false,
|
"webContentsDebuggingEnabled": true,
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"allowMixedContent": false,
|
"allowMixedContent": true,
|
||||||
"backgroundColor": "#0f172a",
|
"backgroundColor": "#0f172a",
|
||||||
"webContentsDebuggingEnabled": false,
|
"webContentsDebuggingEnabled": true,
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
|
|
|
||||||
|
|
@ -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://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
<input type="url" id="server-url" placeholder="https://pedshub.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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(function() {
|
(function() {
|
||||||
var STORAGE_KEY = 'pedshub_server_url';
|
var STORAGE_KEY = 'pedshub_server_url';
|
||||||
var DEFAULT_URL = 'https://quiz.danvics.com';
|
var DEFAULT_URL = 'https://pedshub.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');
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,7 @@
|
||||||
"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",
|
||||||
|
|
|
||||||
|
|
@ -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://quiz.danvics.com" autocapitalize="none" autocorrect="off" spellcheck="false">
|
<input type="url" id="server-url" placeholder="https://pedshub.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>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
(function() {
|
(function() {
|
||||||
var STORAGE_KEY = 'pedshub_server_url';
|
var STORAGE_KEY = 'pedshub_server_url';
|
||||||
var DEFAULT_URL = 'https://quiz.danvics.com';
|
var DEFAULT_URL = 'https://pedshub.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');
|
||||||
|
|
|
||||||
|
|
@ -13,14 +13,10 @@ 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 this compose project; the Docker API filter above prevents
|
# Keep only quiz-* containers
|
||||||
# Promtail from opening non-readable log streams from other stacks.
|
- source_labels: ['__meta_docker_container_name']
|
||||||
- source_labels: ['__meta_docker_container_label_com_docker_compose_project']
|
regex: '.*quiz.*'
|
||||||
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']
|
||||||
|
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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"]
|
|
||||||
|
|
@ -1,615 +0,0 @@
|
||||||
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()
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
python-telegram-bot>=21.0,<22
|
|
||||||
psycopg[binary]>=3.1,<4
|
|
||||||
Loading…
Reference in a new issue