Improve quiz TTS and Android release build
Some checks failed
Mobile Android Release / android-release (push) Failing after 1s

This commit is contained in:
Daniel 2026-05-12 01:01:01 +02:00
parent d59c8bed6f
commit fdebda993c
22 changed files with 541 additions and 204 deletions

View file

@ -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 - <<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}"

View file

@ -10,7 +10,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
- **AI Tutor (TeachChat)**: Ask follow-up questions mid-study — AI knows the current question, correct answer, and related content. Renders markdown tables, code, and follow-up suggestion chips.
- **Tag Classification**: AI classifies questions with subjects, diseases, and keywords — filter your question bank by any combination of tags
- **Multi-Category Filtering**: Filter questions by question category, tags, or quiz source — combine multiple filters for precise study sets
- **Text-to-Speech**: OpenAI TTS, AWS Polly, ElevenLabs, Google Cloud — voice selection per quiz
- **Text-to-Speech**: LiteLLM-routed local TTS, OpenAI TTS, ElevenLabs, Google Cloud — voice selection per quiz
- **Semantic Search**: pgvector embeddings — finds questions by meaning, not just keywords
- **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
@ -36,7 +36,7 @@ AI-powered pediatric learning platform. Upload PDF study materials, automaticall
| AI/LLM | LiteLLM proxy (Claude, Gemini, GPT, Bedrock, and more) |
| Embeddings | Configurable — any LiteLLM proxy model or direct AWS Bedrock (1024-dim) |
| Document vectors | ChromaDB |
| TTS | OpenAI (direct), AWS Polly, ElevenLabs, Google Cloud TTS |
| TTS | LiteLLM-routed local TTS, OpenAI (direct), ElevenLabs, Google Cloud TTS |
| Queue | Celery + Redis (4 fork workers) |
| Email | SMTP (smtp2go or any SMTP server) |
| Bot protection | Cloudflare Turnstile (runtime-configurable, no rebuild needed) |
@ -311,7 +311,6 @@ Accessible at `/admin` for admin users. Three tabs:
### More Settings
- **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)
- **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
@ -461,9 +460,8 @@ The landing page at `/home` uses the shared `Navbar` component. When not logged
## TTS Providers
| Provider | Model ID format | Key needed |
| Provider | Model/voice ID format | Key needed |
|---|---|---|
| OpenAI | `tts-1:alloy`, `tts-1:nova`, `tts-1-hd:*` | `OPENAI_API_KEY` |
| AWS Polly | `polly/Joanna`, `polly/Matthew`, `polly/Amy` | `AWS_ACCESS_KEY_ID` + IAM `polly:SynthesizeSpeech` |
| ElevenLabs | `elevenlabs/<voice-id>` | `ELEVENLABS_API_KEY` |
| Google Cloud | `google/<voice-name>` | `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.

View file

@ -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()

View file

@ -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)

View file

@ -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)

View file

@ -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/<voice> ElevenLabs
google/<voice_name> Google Cloud TTS (e.g. google/en-US-Wavenet-D)
polly/<VoiceId> AWS Polly Neural (e.g. polly/Joanna)
sherpa/<profile>:<voice> Local speech gateway (e.g. sherpa/kokoro:am_adam)
local-<model> Local LiteLLM TTS model (e.g. local-chatterbox-turbo)
local-<model>:<voice> 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/", "")

View file

@ -705,7 +705,7 @@ Convert text to speech audio.
}
```
- **Response:** Binary `audio/mpeg` data
- **Notes:** Supports OpenAI TTS (`tts-1:alloy`), ElevenLabs (`elevenlabs/<voice_id>`), Google Cloud TTS (`google/<voice_name>`), and AWS Polly (`polly/<VoiceId>`).
- **Notes:** Supports LiteLLM-routed local TTS voices, e.g. `local-kokoro-tts:am_adam`. The prefix before `:` is the LiteLLM model route; the suffix is the speaker voice sent to that route.
---
@ -993,14 +993,14 @@ Discover available TTS voices from a provider.
Get system settings (registration enabled, embedding model, Polly enabled).
- **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

View file

@ -84,7 +84,6 @@ Multi-provider TTS generation. Provider is determined by `model_id` convention:
| `tts-1-hd:nova` | OpenAI TTS HD | Voice after colon |
| `elevenlabs/<voice_id>` | ElevenLabs | Uses `eleven_turbo_v2_5` model |
| `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`.

View file

@ -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) ───────────────────────────── */

View file

@ -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() {
</label>
</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 */}
{settings.sso_configured && (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 0', borderBottom: '1px solid var(--border)' }}>

View file

@ -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 (
<span key={`${start}-${end}`} className={className} data-manual-highlight-id={textId} data-start={start}>
{text.slice(start, end)}
</span>
)
})
}
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) => (
<span key={i} style={{
background: activeChunk === i ? '#fef08a' : 'transparent',
borderRadius: 4,
padding: activeChunk === i ? '1px 3px' : 0,
transition: 'background 0.15s ease',
}}>
{chunk}{i < chunks.length - 1 ? ' ' : ''}
</span>
))
}
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') => (
<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 isFavorited = favorites.includes(questionId)
@ -670,10 +830,10 @@ const timerStarted = timeLeft !== null
)}
{/* Header */}
<div className="card" style={{ marginBottom: 14 }}>
<div className="card quiz-header-card" style={{ marginBottom: 14 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<div>
<h2 style={{ marginBottom: 2, fontSize: '1rem' }}>{quiz.title}</h2>
<h2 className="quiz-header-title">{quiz.title}</h2>
<div style={{ fontSize: '0.82rem', color: 'var(--text-muted)', display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{
background: isStudy ? '#d1fae5' : '#e0e7ff',
@ -710,22 +870,25 @@ const timerStarted = timeLeft !== null
<div className="fill" style={{ width: `${((currentIdx + 1) / totalCount) * 100}%` }} />
</div>
{quizNavigation('top')}
{/* Two-column layout: content + desktop sidebar */}
<div className="quiz-layout">
{/* Main content */}
<div style={{ flex: 1, minWidth: 0 }}>
{current && (
<div className="question-card" style={{
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(250, 204, 21, 0.28)' : undefined,
borderColor: activeReadForCurrent ? '#facc15' : undefined,
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 }}>
<h3 style={{ marginBottom: 0, flex: 1 }}>
Q{currentIdx + 1}.{' '}
<HighlightedSpeechText
<ManualHighlightText
text={questionStem(current)}
maxWords={QUESTION_HIGHLIGHT_WORDS}
activeChunk={activeReadForCurrent && activeReadSegment.type === 'question' ? activeReadSegment.chunkIndex : null}
textId={`${current.id}::question`}
highlights={highlightsFor('question')}
speechRange={questionSpeechRange}
/>
</h3>
<button
@ -778,11 +941,17 @@ const timerStarted = timeLeft !== null
{readThrough ? 'Stop listen-through' : 'Listen through'}
</button>
)}
{voices.length > 0 && (
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>
Preloading next {Math.min(TTS_PRELOAD_AHEAD, Math.max(totalCount - currentIdx - 1, 0))} question{Math.min(TTS_PRELOAD_AHEAD, Math.max(totalCount - currentIdx - 1, 0)) === 1 ? '' : 's'}
</span>
)}
<div className="manual-highlight-toolbar" aria-label="Question highlight tools">
<button className="btn btn-secondary btn-sm" onClick={() => updateManualHighlights('add')} title="Highlight selected text">
Highlight
</button>
<button className="btn btn-secondary btn-sm" onClick={() => updateManualHighlights('remove')} title="Remove highlight from selected text">
Remove highlight
</button>
<button className="btn btn-secondary btn-sm" onClick={clearCurrentHighlights} disabled={!manualHighlights[current.id]} title="Clear all highlights on this question">
Clear
</button>
</div>
</div>
<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'}
@ -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 (
<div key={i}
className={`option ${isSelected && !hasAnswered ? 'selected' : ''} ${showCorrect ? 'correct' : ''} ${showWrong ? 'incorrect' : ''}`}
onClick={() => !hasAnswered && setAnswer(current.id, opt)}
onClick={() => !hasAnswered && !hasActiveTextSelection() && setAnswer(current.id, opt)}
style={{
cursor: hasAnswered ? 'default' : 'pointer',
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',
}}>
<span className="option-letter">{letter}</span>
<span style={{ flex: 1 }}>
<HighlightedSpeechText text={opt} maxWords={OPTION_HIGHLIGHT_WORDS} activeChunk={activeOptionChunk} />
<ManualHighlightText
text={opt}
textId={`${current.id}::${optionFieldKey}`}
highlights={highlightsFor(optionFieldKey)}
speechRange={optionSpeechRange}
/>
</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>}
@ -849,26 +1025,7 @@ const timerStarted = timeLeft !== null
</div>
)}
{/* 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>
{quizNavigation('bottom')}
{/* Mobile: collapsible number grid */}
{navOpen && (

View file

@ -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'
}
}

View file

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

View file

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

View file

@ -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');

View file

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

View file

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

View file

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

View file

@ -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');

View file

@ -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",

View file

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

View file

@ -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');