From fdebda993c29128f326a5837b6a133ed1dc73ba6 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 12 May 2026 01:01:01 +0200 Subject: [PATCH] Improve quiz TTS and Android release build --- .forgejo/workflows/mobile-android.yml | 116 ++++++++ README.md | 14 +- backend/app/main.py | 41 ++- backend/app/routers/admin.py | 82 ++++-- backend/app/routers/tts.py | 30 +- backend/app/services/ai_service.py | 39 +-- docs/api-reference.md | 6 +- docs/services.md | 1 - frontend/src/index.css | 62 +++- frontend/src/pages/AdminPage.jsx | 40 +-- frontend/src/pages/QuizPage.jsx | 265 ++++++++++++++---- mobile/android/app/build.gradle | 21 +- .../app/src/main/assets/capacitor.config.json | 4 +- .../app/src/main/assets/public/index.html | 2 +- .../app/src/main/assets/public/launcher.js | 2 +- mobile/capacitor.config.json | 4 +- mobile/ios/App/App/capacitor.config.json | 4 +- mobile/ios/App/App/public/index.html | 2 +- mobile/ios/App/App/public/launcher.js | 2 +- mobile/package.json | 4 +- mobile/src/index.html | 2 +- mobile/src/launcher.js | 2 +- 22 files changed, 541 insertions(+), 204 deletions(-) create mode 100644 .forgejo/workflows/mobile-android.yml diff --git a/.forgejo/workflows/mobile-android.yml b/.forgejo/workflows/mobile-android.yml new file mode 100644 index 0000000..d0ff754 --- /dev/null +++ b/.forgejo/workflows/mobile-android.yml @@ -0,0 +1,116 @@ +name: Mobile Android Release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + inputs: + release_name: + description: 'Forgejo release name' + required: false + default: 'Manual Android Build' + +jobs: + android-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - 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 }} + run: | + chmod +x gradlew + ./gradlew assembleRelease + mkdir -p ../../dist + cp app/build/outputs/apk/release/app-release.apk ../../dist/pedshub-${GITHUB_REF_NAME:-manual}.apk + + - name: Create Forgejo release and upload APK + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_NAME: ${{ inputs.release_name }} + run: | + set -eu + 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 - <` | `ELEVENLABS_API_KEY` | -| Google Cloud | `google/` | `GOOGLE_TTS_API_KEY` | +| Kokoro via LiteLLM | `local-kokoro-tts:am_adam`, `local-kokoro-tts:af_bella` | `LITELLM_API_KEY` | + +The part before `:` is the LiteLLM speech model route. The part after `:` is the Kokoro speaker voice. diff --git a/backend/app/main.py b/backend/app/main.py index 179b933..2e290b5 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -68,6 +68,8 @@ def seed_admin(): def seed_default_models(): """Seed default AI model configs if none exist.""" + from sqlalchemy import text + from app.models.ai_model_config import AIModelConfig db = SessionLocal() try: @@ -91,8 +93,12 @@ def seed_default_models(): # Always ensure LiteLLM-routed local speech models exist (idempotent). tts_voices = [ - ("Kokoro TTS (LiteLLM)", "local-kokoro-tts", True), - ("Qwen3 TTS (LiteLLM)", "local-qwen3-tts", False), + ("Kokoro Adam", "local-kokoro-tts:am_adam", True), + ("Kokoro Michael", "local-kokoro-tts:am_michael", False), + ("Kokoro Bella", "local-kokoro-tts:af_bella", False), + ("Kokoro Nicole", "local-kokoro-tts:af_nicole", False), + ("Kokoro Emma", "local-kokoro-tts:bf_emma", False), + ("Kokoro Lewis", "local-kokoro-tts:bm_lewis", False), ] stt_models = [ ("Parakeet STT (LiteLLM)", "local-parakeet-v3", True), @@ -101,30 +107,31 @@ def seed_default_models(): ] # 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 old.model_id.startswith(("tts-", "openai/", "elevenlabs/", "eleven_labs/", "google/", "polly/", "sherpa/")): + 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 - - has_default_tts = db.query(AIModelConfig).filter( - AIModelConfig.task == "tts", AIModelConfig.is_default == True, AIModelConfig.is_active == True, - ).first() is not None + if old.model_id == "local-qwen3-tts": + old.is_active = False + old.is_default = False for name, model_id, _ in tts_voices: - exists = db.query(AIModelConfig).filter(AIModelConfig.model_id == model_id).first() - if not exists: - is_def = not has_default_tts - db.add(AIModelConfig(name=name, model_id=model_id, task="tts", is_active=True, is_default=is_def)) - if is_def: - has_default_tts = True + db.execute(text(""" + INSERT INTO ai_model_configs (name, model_id, task, is_active, is_default, created_at) + VALUES (:name, :model_id, 'tts', true, false, NOW()) + ON CONFLICT (model_id, task) DO NOTHING + """), {"name": name, "model_id": model_id}) # 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", + AIModelConfig.model_id == "local-kokoro-tts:am_adam", ).first() if local_default: local_default.is_active = True @@ -143,7 +150,11 @@ def seed_default_models(): ).first() if not exists: is_def = preferred_default and not has_default - db.add(AIModelConfig(name=name, model_id=model_id, task=task, is_active=True, is_default=is_def)) + 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() diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index d7b168c..a9b2429 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -139,6 +139,7 @@ def list_available_models( class LiteLLMSearchRequest(BaseModel): api_key: str | None = None api_base: str | None = None + mode: str | None = None @router.post("/litellm/models") @@ -155,8 +156,16 @@ def search_litellm_models( if base: try: headers = {"Authorization": f"Bearer {key}"} if key else {} - resp = httpx.post(f"{base}/v1/models", headers=headers, timeout=10) if False else \ - httpx.get(f"{base}/v1/models", headers=headers, timeout=10) + if data.mode: + info_base = base.rstrip("/").removesuffix("/v1") + resp = httpx.get(f"{info_base}/model/info", headers=headers, timeout=10) + resp.raise_for_status() + models = sorted([ + m.get("model_name") for m in resp.json().get("data", []) + if m.get("model_name") and (m.get("model_info") or {}).get("mode") == data.mode + ]) + return {"models": models, "source": info_base, "mode": data.mode} + resp = httpx.get(f"{base}/v1/models", headers=headers, timeout=10) resp.raise_for_status() models = sorted([m["id"] for m in resp.json().get("data", [])]) return {"models": models, "source": base} @@ -326,6 +335,47 @@ class TTSVoiceSearchRequest(BaseModel): 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") def search_tts_voices( data: TTSVoiceSearchRequest, @@ -351,15 +401,20 @@ def search_tts_voices( resp = httpx.get(f"{base}/model/info", headers=headers, timeout=10) resp.raise_for_status() models = resp.json().get("data", []) - return {"voices": [ - { - "model_id": m.get("model_name"), - "name": m.get("model_name"), + voices = [] + for m in models: + model_name = m.get("model_name") + if not model_name or (m.get("model_info") or {}).get("mode") != "audio_speech": + continue + if model_name == "local-kokoro-tts": + voices.extend(_kokoro_voice_options(model_name)) + continue + voices.append({ + "model_id": model_name, + "name": model_name, "labels": {"provider": "litellm", "mode": "audio_speech"}, - } - for m in models - if m.get("model_name") and (m.get("model_info") or {}).get("mode") == "audio_speech" - ]} + }) + return {"voices": voices} except HTTPException: raise except Exception as e: @@ -379,12 +434,10 @@ def get_settings(admin: User = Depends(require_admin)): r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) registration_enabled = r.get("settings:registration_enabled") embedding_model = r.get("settings:embedding_model") - polly_enabled = r.get("settings:polly_enabled") sso_only = r.get("settings:sso_only") return { "registration_enabled": registration_enabled != "false", "embedding_model": embedding_model or settings.LITELLM_EMBEDDING_MODEL or "", - "polly_enabled": polly_enabled != "false", "sso_only": sso_only == "true", "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID), "sso_provider_name": settings.OIDC_PROVIDER_NAME, @@ -393,7 +446,6 @@ def get_settings(admin: User = Depends(require_admin)): return { "registration_enabled": True, "embedding_model": settings.LITELLM_EMBEDDING_MODEL or "", - "polly_enabled": True, "sso_only": False, "sso_configured": bool(settings.OIDC_PROVIDER_URL and settings.OIDC_CLIENT_ID), "sso_provider_name": settings.OIDC_PROVIDER_NAME, @@ -417,10 +469,6 @@ def update_settings( if "embedding_model" in settings_data: 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: value = "true" if settings_data["sso_only"] else "false" r.set("settings:sso_only", value) diff --git a/backend/app/routers/tts.py b/backend/app/routers/tts.py index d3da06e..e434441 100644 --- a/backend/app/routers/tts.py +++ b/backend/app/routers/tts.py @@ -3,7 +3,6 @@ from fastapi.responses import Response from pydantic import BaseModel from sqlalchemy.orm import Session -from app.config import settings from app.database import get_db from app.models.user import User from app.models.ai_model_config import AIModelConfig @@ -32,16 +31,6 @@ def _task_model(db: Session, task: str, fallback: str) -> tuple[str, str | None] return fallback, None -def _polly_enabled() -> bool: - try: - import redis as redis_lib - r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) - val = r.get("settings:polly_enabled") - return val != "false" - except Exception: - return True - - def _default_tts_model(db: Session) -> tuple[str, str | None]: config = db.query(AIModelConfig).filter( AIModelConfig.task == "tts", @@ -51,7 +40,7 @@ def _default_tts_model(db: Session) -> tuple[str, str | None]: ).first() if config: return config.model_id, config.api_key or None - return "local-kokoro-tts", None + return "local-kokoro-tts:am_adam", None @router.get("/voices") @@ -65,8 +54,6 @@ def get_voices( 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() return [{"id": m.model_id, "name": m.name, "is_default": m.is_default} for m in db_models] @@ -92,11 +79,16 @@ def text_to_speech( text = request.text[:2000] if request.voice and request.voice.startswith("local-"): - if request.voice.startswith("polly/") and not _polly_enabled(): - raise HTTPException(status_code=400, detail="AWS Polly is currently disabled") - config = db.query(AIModelConfig).filter(AIModelConfig.model_id == request.voice).first() - model_id = config.model_id if config else request.voice - api_key = config.api_key if (config and config.api_key) else None + config = db.query(AIModelConfig).filter( + AIModelConfig.task == "tts", + AIModelConfig.is_active == True, + AIModelConfig.model_id == request.voice, + ).first() + if not config: + model_id, api_key = _default_tts_model(db) + else: + model_id = config.model_id + api_key = config.api_key or None else: model_id, api_key = _default_tts_model(db) diff --git a/backend/app/services/ai_service.py b/backend/app/services/ai_service.py index 6245810..3baaeea 100644 --- a/backend/app/services/ai_service.py +++ b/backend/app/services/ai_service.py @@ -380,16 +380,15 @@ def generate_tts_audio( model_id: str | None = None, api_key: str | None = None, ) -> bytes | None: - """Generate TTS audio. Supports local Sherpa, OpenAI, ElevenLabs, Google Cloud TTS, and AWS Polly. + """Generate TTS audio. Supports local LiteLLM, local Sherpa, OpenAI, ElevenLabs, and Google Cloud TTS. model_id conventions: tts-1:alloy → OpenAI TTS (voice after colon) tts-1-hd:nova → OpenAI TTS HD elevenlabs/ → ElevenLabs google/ → Google Cloud TTS (e.g. google/en-US-Wavenet-D) - polly/ → AWS Polly Neural (e.g. polly/Joanna) sherpa/: → Local speech gateway (e.g. sherpa/kokoro:am_adam) - local- → Local LiteLLM TTS model (e.g. local-chatterbox-turbo) + local-: → Local LiteLLM TTS model + voice (e.g. local-kokoro-tts:am_adam) """ import httpx, base64 @@ -397,6 +396,10 @@ def generate_tts_audio( # ── 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: @@ -406,7 +409,7 @@ def generate_tts_audio( 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": use_model, "input": text, "voice": "alloy", "response_format": "mp3"}, + json={"model": local_model, "input": text, "voice": local_voice, "response_format": "mp3"}, timeout=90, ) resp.raise_for_status() @@ -480,34 +483,6 @@ def generate_tts_audio( logger.error(f"Google TTS failed: {e}") 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) ──────────────────────────────────────── # model_id may encode voice as "tts-1:nova", "tts-1-hd:alloy", etc. clean_model = use_model.replace("openai/", "") diff --git a/docs/api-reference.md b/docs/api-reference.md index 4faced3..78b31b4 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -705,7 +705,7 @@ Convert text to speech audio. } ``` - **Response:** Binary `audio/mpeg` data -- **Notes:** Supports OpenAI TTS (`tts-1:alloy`), ElevenLabs (`elevenlabs/`), Google Cloud TTS (`google/`), and AWS Polly (`polly/`). +- **Notes:** Supports LiteLLM-routed local TTS voices, e.g. `local-kokoro-tts:am_adam`. The prefix before `:` is the LiteLLM model route; the suffix is the speaker voice sent to that route. --- @@ -993,14 +993,14 @@ Discover available TTS voices from a provider. Get system settings (registration enabled, embedding model, Polly enabled). - **Auth:** Admin -- **Response:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}` +- **Response:** `{"registration_enabled": bool, "embedding_model": "string"}` #### PUT `/api/admin/settings` Update system settings. - **Auth:** Admin -- **Request body:** `{"registration_enabled": bool, "embedding_model": "string", "polly_enabled": bool}` +- **Request body:** `{"registration_enabled": bool, "embedding_model": "string"}` - **Response:** `{"success": true, "message": "Settings updated"}` ### Embedding Management diff --git a/docs/services.md b/docs/services.md index b5df17f..9bf6a26 100644 --- a/docs/services.md +++ b/docs/services.md @@ -84,7 +84,6 @@ Multi-provider TTS generation. Provider is determined by `model_id` convention: | `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon | | `elevenlabs/` | ElevenLabs | Uses `eleven_turbo_v2_5` model | | `google/` | Google Cloud TTS | Parses language code from voice name | -| `polly/` | 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`. diff --git a/frontend/src/index.css b/frontend/src/index.css index 4985aa2..af02efc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -61,7 +61,7 @@ --navbar-fg: #f0e8d8; --badge-radius: 4px; --font-body: 'Source Serif 4', Georgia, serif; - --font-heading: 'Playfair Display', 'Source Serif 4', serif; + --font-heading: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif; --font-ui: 'Inter', system-ui, sans-serif; } @@ -71,9 +71,9 @@ body[data-theme="markdown"] { font-size: 15.5px; line-height: 1.7; } -[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: 700; letter-spacing: -0.02em; } -[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 600; } +[data-theme="markdown"] .navbar .logo { color: #e0a84a; font-family: var(--font-heading); letter-spacing: -0.02em; } +[data-theme="markdown"] h1 { font-family: var(--font-heading); font-weight: 760; letter-spacing: -0.03em; line-height: 1.12; } +[data-theme="markdown"] h2, [data-theme="markdown"] .card h2 { font-family: var(--font-heading); font-weight: 680; letter-spacing: -0.02em; line-height: 1.2; } [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"] .question-card { border-left: 3px solid var(--primary); } @@ -135,6 +135,34 @@ body { } .card h2 { margin-bottom: 16px; font-size: 1.15rem; color: var(--text); font-weight: 600; } +.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 ────────────────────────────────────────────────── */ .btn { display: inline-flex; align-items: center; gap: 6px; @@ -182,6 +210,27 @@ body { 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; } +.manual-highlight-toolbar { display: inline-flex; gap: 6px; flex-wrap: wrap; align-items: center; } +.manual-highlight-segment { user-select: text; -webkit-user-select: text; } +.manual-highlight-active { + background: linear-gradient(transparent 38%, rgba(253, 224, 71, 0.72) 38%); + border-radius: 2px; + 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 */ .question-card .options { display: flex; flex-direction: column; gap: 10px; } @@ -189,7 +238,7 @@ body { display: flex; align-items: center; gap: 12px; padding: 13px 16px; border: 1.5px solid var(--border); border-radius: 8px; cursor: pointer; transition: border-color 0.12s, background 0.12s; - background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: none; + background: var(--option-bg); font-size: 0.9rem; color: var(--text); user-select: text; -webkit-tap-highlight-color: transparent; touch-action: manipulation; } .question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); } @@ -349,6 +398,9 @@ body { /* Mobile quiz: hide sidebar, show toggle */ .quiz-sidebar { display: none; } .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; } } /* ── Lesson content (markdown/HTML) ───────────────────────────── */ diff --git a/frontend/src/pages/AdminPage.jsx b/frontend/src/pages/AdminPage.jsx index b5d8abe..f44dfc4 100644 --- a/frontend/src/pages/AdminPage.jsx +++ b/frontend/src/pages/AdminPage.jsx @@ -14,7 +14,7 @@ export default function AdminPage() { const [tab, setTab] = useState('models') const [users, setUsers] = useState([]) const [models, setModels] = useState([]) - const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '', polly_enabled: true }) + const [settings, setSettings] = useState({ registration_enabled: true, embedding_model: '' }) const [loading, setLoading] = useState(true) const [error, setError] = useState('') const [success, setSuccess] = useState('') @@ -182,12 +182,11 @@ export default function AdminPage() { const searchEmbeddingModels = async () => { setEmbedSearchError('') setEmbedSearchResults([]) + setEmbedSearchFilter('') setEmbedSearchLoading(true) try { - const res = await api.post('/admin/litellm/models', {}) - const all = res.data.models || [] - // Filter to likely embedding models - setEmbedSearchResults(all) + const res = await api.post('/admin/litellm/models', { mode: 'embedding' }) + setEmbedSearchResults(res.data.models || []) } catch (err) { setEmbedSearchError(err.response?.data?.detail || 'Failed to query models') } finally { @@ -647,37 +646,6 @@ export default function AdminPage() { - {/* AWS Polly */} -
-
- AWS Polly Voices - - Enable or disable AWS Polly TTS voices globally. Individual voices can still be toggled in the AI Models tab. - -
- -
- {/* SSO Only */} {settings.sso_configured && (
diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 17254c5..ce28767 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -10,6 +10,97 @@ 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() { + const 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 + return { + id: span.dataset.manualHighlightId, + offset: Number(span.dataset.start || 0) + offset, + } + } + + 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 }) { + 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 ( + + {text.slice(start, end)} + + ) + }) +} + function splitSpeechChunks(text, maxWords) { const words = (text || '').trim().split(/\s+/).filter(Boolean) if (!words.length) return [] @@ -53,20 +144,6 @@ function buildQuestionSpeechText(question, index) { return segments.map(s => s.text).join(' ') } -function HighlightedSpeechText({ text, activeChunk, maxWords }) { - const chunks = splitSpeechChunks(text, maxWords) - return chunks.map((chunk, i) => ( - - {chunk}{i < chunks.length - 1 ? ' ' : ''} - - )) -} - function getActiveSpeechSegment(audio, segments) { if (!segments.length) return null if (!audio || !Number.isFinite(audio.duration) || audio.duration <= 0) return 0 @@ -296,6 +373,7 @@ export default function QuizPage() { const [startedAt, setStartedAt] = useState(null) const [favorites, setFavorites] = useState([]) const [activeReadSegment, setActiveReadSegment] = useState(null) + const [manualHighlights, setManualHighlights] = useState({}) const timerRef = useRef(null) const toastRef = useRef(null) const hasStarted = useRef(false) @@ -308,6 +386,21 @@ export default function QuizPage() { toastRef.current = setTimeout(() => setToast(''), 3000) } + 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 questions = quiz?.questions || [] const current = questions[currentIdx] @@ -359,7 +452,7 @@ export default function QuizPage() { } }, [quizMode, questions, currentIdx, selectedVoice, voices.length, fetchTtsAudio]) - const resumeQuiz = useCallback(async (saved) => { + 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 @@ -393,7 +486,7 @@ export default function QuizPage() { setAnswers(savedAnswers) setCurrentIdx(savedIdx) setAttemptId(saved.attempt_id || saved.attemptId) - if (saved.voice) setSelectedVoice(saved.voice) + if (saved.voice && availableVoices.some(v => v.id === saved.voice)) setSelectedVoice(saved.voice) if (saved.started_at) setStartedAt(saved.started_at) // Restore timer — calculate remaining from started_at + total_time if (saved.total_time && saved.started_at) { @@ -436,7 +529,7 @@ export default function QuizPage() { headers: { 'x-quiz-session': SESSION_ID }, }) if (progressRes.data) { - await resumeQuiz(progressRes.data) + await resumeQuiz(progressRes.data, voicesRes.data) } } catch (err) { if (err.response?.status === 409) { @@ -516,10 +609,50 @@ const timerStarted = timeLeft !== null }, { headers: { 'x-quiz-session': SESSION_ID } }).catch(() => {}) }, 1500) // debounce 1.5s return () => clearTimeout(saveProgressRef.current) - }, [answers, currentIdx, attemptId, timeLeft, startedAt, totalTime]) + }, [answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime]) const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value })) + const updateManualHighlights = (action) => { + if (!current) return + const selected = getManualHighlightSelection() + if (!selected) { + showToast('Select question or option text first') + return + } + const [questionKey, fieldKey] = selected.id.split('::') + if (questionKey !== String(current.id)) { + showToast('Select text from the current question') + return + } + setManualHighlights(prev => { + const questionRanges = prev[current.id] || {} + const existing = questionRanges[fieldKey] || [] + const nextRanges = action === 'remove' + ? removeTextRange(existing, selected) + : mergeTextRanges([...existing, { start: selected.start, end: selected.end }]) + const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges } + if (!nextRanges.length) delete nextQuestion[fieldKey] + return { ...prev, [current.id]: nextQuestion } + }) + window.getSelection?.().removeAllRanges() + showToast(action === 'remove' ? 'Highlight removed' : 'Highlighted') + } + + const clearCurrentHighlights = () => { + if (!current || !manualHighlights[current.id]) return + setManualHighlights(prev => { + const next = { ...prev } + delete next[current.id] + return next + }) + showToast('Question highlights cleared') + } + + 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) @@ -577,7 +710,34 @@ const timerStarted = timeLeft !== null const answeredCount = Object.keys(answers).length const totalCount = questions.length const isLast = currentIdx === totalCount - 1 + const quizNavigation = (position = 'bottom') => ( +
+ + + + + {isLast ? ( + + ) : ( + + )} +
+ ) 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 isFavorited = favorites.includes(questionId) @@ -670,10 +830,10 @@ const timerStarted = timeLeft !== null )} {/* Header */} -
+
-

{quiz.title}

+

{quiz.title}

+ {quizNavigation('top')} + {/* Two-column layout: content + desktop sidebar */}
{/* Main content */}
{current && (

Q{currentIdx + 1}.{' '} -

+ + +
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'} @@ -806,19 +975,26 @@ const timerStarted = timeLeft !== null 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 (
!hasAnswered && setAnswer(current.id, opt)} + onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)} style={{ cursor: hasAnswered ? 'default' : 'pointer', - borderColor: activeOptionChunk !== null ? '#facc15' : undefined, - boxShadow: activeOptionChunk !== null ? '0 0 0 3px rgba(250, 204, 21, 0.25)' : undefined, + 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', }}> {letter} - + {showCorrect && ✓ Correct} {showWrong && ✗ Wrong} @@ -849,26 +1025,7 @@ const timerStarted = timeLeft !== null
)} - {/* Prev / Next + mobile nav toggle */} -
- - - {/* Mobile-only nav toggle */} - - - {isLast ? ( - - ) : ( - - )} -
+ {quizNavigation('bottom')} {/* Mobile: collapsible number grid */} {navOpen && ( diff --git a/mobile/android/app/build.gradle b/mobile/android/app/build.gradle index 19733cf..d57b8e1 100644 --- a/mobile/android/app/build.gradle +++ b/mobile/android/app/build.gradle @@ -3,6 +3,12 @@ apply plugin: 'com.android.application' android { namespace "com.pedshub.quiz" 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 { applicationId "com.pedshub.quiz" minSdkVersion rootProject.ext.minSdkVersion @@ -13,12 +19,25 @@ android { aaptOptions { // 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 - 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 { release { minifyEnabled false + if (hasReleaseSigning) { + signingConfig signingConfigs.release + } proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' } } diff --git a/mobile/android/app/src/main/assets/capacitor.config.json b/mobile/android/app/src/main/assets/capacitor.config.json index c70bfda..1ea7024 100644 --- a/mobile/android/app/src/main/assets/capacitor.config.json +++ b/mobile/android/app/src/main/assets/capacitor.config.json @@ -20,9 +20,9 @@ } }, "android": { - "allowMixedContent": true, + "allowMixedContent": false, "backgroundColor": "#0f172a", - "webContentsDebuggingEnabled": true, + "webContentsDebuggingEnabled": false, "androidScheme": "https" }, "ios": { diff --git a/mobile/android/app/src/main/assets/public/index.html b/mobile/android/app/src/main/assets/public/index.html index 9b625cd..14fcae0 100644 --- a/mobile/android/app/src/main/assets/public/index.html +++ b/mobile/android/app/src/main/assets/public/index.html @@ -21,7 +21,7 @@
- +
diff --git a/mobile/android/app/src/main/assets/public/launcher.js b/mobile/android/app/src/main/assets/public/launcher.js index 0ec19ce..c8d2aee 100644 --- a/mobile/android/app/src/main/assets/public/launcher.js +++ b/mobile/android/app/src/main/assets/public/launcher.js @@ -1,6 +1,6 @@ (function() { var STORAGE_KEY = 'pedshub_server_url'; - var DEFAULT_URL = 'https://pedshub.com'; + var DEFAULT_URL = 'https://quiz.danvics.com'; var setupScreen = document.getElementById('setup-screen'); var connectingScreen = document.getElementById('connecting-screen'); diff --git a/mobile/capacitor.config.json b/mobile/capacitor.config.json index 258cb25..c50f5eb 100644 --- a/mobile/capacitor.config.json +++ b/mobile/capacitor.config.json @@ -18,9 +18,9 @@ } }, "android": { - "allowMixedContent": true, + "allowMixedContent": false, "backgroundColor": "#0f172a", - "webContentsDebuggingEnabled": true, + "webContentsDebuggingEnabled": false, "androidScheme": "https" }, "ios": { diff --git a/mobile/ios/App/App/capacitor.config.json b/mobile/ios/App/App/capacitor.config.json index d63e69e..88e89db 100644 --- a/mobile/ios/App/App/capacitor.config.json +++ b/mobile/ios/App/App/capacitor.config.json @@ -20,9 +20,9 @@ } }, "android": { - "allowMixedContent": true, + "allowMixedContent": false, "backgroundColor": "#0f172a", - "webContentsDebuggingEnabled": true, + "webContentsDebuggingEnabled": false, "androidScheme": "https" }, "ios": { diff --git a/mobile/ios/App/App/public/index.html b/mobile/ios/App/App/public/index.html index 9b625cd..14fcae0 100644 --- a/mobile/ios/App/App/public/index.html +++ b/mobile/ios/App/App/public/index.html @@ -21,7 +21,7 @@
- +
diff --git a/mobile/ios/App/App/public/launcher.js b/mobile/ios/App/App/public/launcher.js index 0ec19ce..c8d2aee 100644 --- a/mobile/ios/App/App/public/launcher.js +++ b/mobile/ios/App/App/public/launcher.js @@ -1,6 +1,6 @@ (function() { var STORAGE_KEY = 'pedshub_server_url'; - var DEFAULT_URL = 'https://pedshub.com'; + var DEFAULT_URL = 'https://quiz.danvics.com'; var setupScreen = document.getElementById('setup-screen'); var connectingScreen = document.getElementById('connecting-screen'); diff --git a/mobile/package.json b/mobile/package.json index b937fc1..9d67c44 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -7,7 +7,9 @@ "sync": "npx cap sync", "android": "npx cap open android", "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": { "@capacitor/android": "^6.0.0", diff --git a/mobile/src/index.html b/mobile/src/index.html index 9b625cd..14fcae0 100644 --- a/mobile/src/index.html +++ b/mobile/src/index.html @@ -21,7 +21,7 @@
- +
diff --git a/mobile/src/launcher.js b/mobile/src/launcher.js index 0ec19ce..c8d2aee 100644 --- a/mobile/src/launcher.js +++ b/mobile/src/launcher.js @@ -1,6 +1,6 @@ (function() { var STORAGE_KEY = 'pedshub_server_url'; - var DEFAULT_URL = 'https://pedshub.com'; + var DEFAULT_URL = 'https://quiz.danvics.com'; var setupScreen = document.getElementById('setup-screen'); var connectingScreen = document.getElementById('connecting-screen');