Compare commits

...

14 commits
v2 ... master

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

View file

@ -10,26 +10,33 @@ on:
description: 'Forgejo release name'
required: false
default: 'Manual Android Build'
release_tag:
description: 'Forgejo release tag'
required: false
default: ''
jobs:
android-release:
runs-on: ubuntu-latest
runs-on: forgejo-local
steps:
- name: Checkout
uses: actions/checkout@v4
uses: https://github.com/actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
uses: https://github.com/actions/setup-node@v4
with:
node-version: '20'
- name: Set up Java
uses: actions/setup-java@v4
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 }}
@ -70,19 +77,22 @@ jobs:
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-${GITHUB_REF_NAME:-manual}.apk
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="${GITHUB_REF_NAME:-manual-${GITHUB_RUN_NUMBER}}"
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}."

1
.gitignore vendored
View file

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

View file

@ -46,7 +46,7 @@ For detailed architecture documentation, see [docs/architecture.md](docs/archite
## Quick Start
```bash
git clone https://github.com/ifedan-ed/pdf-quiz-generator.git
git clone ssh://git.danvics.com:2222/danvics/pdf-quiz-generator.git
cd pdf-quiz-generator
# Configure environment

View file

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

View file

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

View file

@ -11,7 +11,7 @@ from app.logging_config import setup_logging
# Configure structured JSON logging before anything else
setup_logging(settings.LOG_LEVEL)
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
from app.routers import auth, documents, quizzes, attempts, admin, tts, nextcloud, categories, questions, question_categories, favorites, teach, contact, tags, flashcards, courses, mobile, mynote
from app.utils.auth import get_password_hash
from app.utils.scheduler import start_scheduler, stop_scheduler
@ -607,6 +607,7 @@ app.include_router(tags.router, prefix="/api/tags", tags=["tags"])
app.include_router(flashcards.router, prefix="/api/flashcards", tags=["flashcards"])
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")

View file

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

View file

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

View file

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

View file

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

View file

@ -37,7 +37,7 @@ services:
- chroma_data:/app/chroma_data
networks:
- default
- speech_net
- danvics_speech
depends_on:
postgres:
condition: service_healthy
@ -69,6 +69,18 @@ services:
- redis_data:/data
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 ──────────────────────────────
loki:
image: grafana/loki:3.3.2
@ -135,5 +147,5 @@ volumes:
promtail_positions:
networks:
speech_net:
danvics_speech:
external: true

View file

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

View file

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

View file

@ -210,11 +210,248 @@ 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; }
.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;
}
@ -401,6 +638,40 @@ body {
.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) ───────────────────────────── */

View file

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

View file

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

View file

@ -2,6 +2,7 @@ import { useState, useEffect, useRef, useCallback, lazy, Suspense } from 'react'
import { useParams, useNavigate, useSearchParams, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import MyNote from '../components/MyNote'
const TeachChat = lazy(() => import('../components/TeachChat'))
@ -51,17 +52,20 @@ function getSpeechChunkRange(text, maxWords, activeChunk) {
return { start: words[startWord].start, end: words[endWord].end }
}
function getManualHighlightSelection() {
const selection = window.getSelection?.()
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) + offset,
offset: Number(span.dataset.start || 0) + charOffset,
}
}
@ -74,7 +78,7 @@ function getManualHighlightSelection() {
return { id: start.id, ...ordered }
}
function ManualHighlightText({ text, textId, highlights = [], speechRange = null }) {
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) })
@ -94,7 +98,18 @@ function ManualHighlightText({ text, textId, highlights = [], speechRange = null
speechHighlighted ? 'speech-highlight-active' : '',
].filter(Boolean).join(' ')
return (
<span key={`${start}-${end}`} className={className} data-manual-highlight-id={textId} data-start={start}>
<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>
)
@ -344,8 +359,21 @@ function ModeSelectScreen({ quiz, voices, onStart }) {
)
}
// Unique session ID per tab used to prevent concurrent resume on multiple devices
const SESSION_ID = Math.random().toString(36).slice(2) + Date.now().toString(36)
function getQuizSessionId() {
const key = 'pedshub_quiz_session_id'
try {
const existing = localStorage.getItem(key)
if (existing) return existing
const created = window.crypto?.randomUUID?.() || `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
localStorage.setItem(key, created)
return created
} catch {
return `${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`
}
}
// Stable per-device session ID lets the same app/webview resume after restart.
const SESSION_ID = getQuizSessionId()
export default function QuizPage() {
const { id } = useParams()
@ -370,6 +398,8 @@ export default function QuizPage() {
const [totalTime, setTotalTime] = useState(null)
const [toast, setToast] = useState('')
const [navOpen, setNavOpen] = useState(false)
const [expandedImagePath, setExpandedImagePath] = useState('')
const [imageZoom, setImageZoom] = useState(1)
const [startedAt, setStartedAt] = useState(null)
const [favorites, setFavorites] = useState([])
const [activeReadSegment, setActiveReadSegment] = useState(null)
@ -379,6 +409,8 @@ export default function QuizPage() {
const hasStarted = useRef(false)
const ttsCacheRef = useRef(new Map())
const autoAdvanceRef = useRef(null)
const savedHighlightSelectionRef = useRef(null)
const autoHighlightTimerRef = useRef(null)
const showToast = (msg) => {
setToast(msg)
@ -386,6 +418,10 @@ export default function QuizPage() {
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}`)
@ -406,6 +442,53 @@ export default function QuizPage() {
const current = questions[currentIdx]
const isStudy = quizMode === 'study'
const applyManualHighlightSelection = useCallback((selected = getManualHighlightSelection() || savedHighlightSelectionRef.current) => {
if (!selected || !current) return
const [questionKey, fieldKey] = selected.id.split('::')
if (questionKey !== String(current.id)) return
const existing = manualHighlights[current.id]?.[fieldKey] || []
const removeExisting = existing.some(range => selected.start >= range.start && selected.end <= range.end)
setManualHighlights(prev => {
const questionRanges = prev[current.id] || {}
const currentRanges = questionRanges[fieldKey] || []
const nextRanges = removeExisting
? removeTextRange(currentRanges, selected)
: mergeTextRanges([...currentRanges, { start: selected.start, end: selected.end }])
const nextQuestion = { ...questionRanges, [fieldKey]: nextRanges }
if (!nextRanges.length) delete nextQuestion[fieldKey]
const next = { ...prev, [current.id]: nextQuestion }
if (!Object.keys(nextQuestion).length) delete next[current.id]
return next
})
window.getSelection?.().removeAllRanges()
savedHighlightSelectionRef.current = null
}, [current?.id, manualHighlights])
const captureHighlightSelection = useCallback(() => {
const selected = getManualHighlightSelection()
if (!selected || !current) return
const [questionKey] = selected.id.split('::')
if (questionKey !== String(current.id)) return
savedHighlightSelectionRef.current = selected
clearTimeout(autoHighlightTimerRef.current)
autoHighlightTimerRef.current = setTimeout(() => applyManualHighlightSelection(selected), 450)
}, [current?.id, applyManualHighlightSelection])
useEffect(() => {
document.addEventListener('selectionchange', captureHighlightSelection)
document.addEventListener('mouseup', captureHighlightSelection)
document.addEventListener('touchend', captureHighlightSelection)
return () => {
clearTimeout(autoHighlightTimerRef.current)
document.removeEventListener('selectionchange', captureHighlightSelection)
document.removeEventListener('mouseup', captureHighlightSelection)
document.removeEventListener('touchend', captureHighlightSelection)
}
}, [captureHighlightSelection])
const fetchTtsAudio = useCallback(async (text, voice) => {
const cleanText = (text || '').trim()
if (!cleanText) return null
@ -439,6 +522,8 @@ export default function QuizPage() {
useEffect(() => {
setActiveReadSegment(null)
setTtsActive(false)
savedHighlightSelectionRef.current = null
clearTimeout(autoHighlightTimerRef.current)
if (!readThrough) setActiveReadSegment(null)
clearTimeout(autoAdvanceRef.current)
}, [currentIdx, readThrough])
@ -572,6 +657,17 @@ export default function QuizPage() {
}
// NOW set mode quiz data is fully loaded, safe to render
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('/') }
finally { setStarting(false) }
}
@ -590,55 +686,45 @@ const timerStarted = timeLeft !== null
if (timeLeft === 0) handleSubmit(true)
}, [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)
const saveProgressRef = useRef(null)
useEffect(() => {
if (!attemptId || !quizMode) return
clearTimeout(saveProgressRef.current)
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
saveProgressRef.current = setTimeout(() => { saveProgressNow() }, 500)
return () => clearTimeout(saveProgressRef.current)
}, [answers, currentIdx, attemptId, quizMode, selectedVoice, timeLeft, startedAt, totalTime])
}, [saveProgressNow, attemptId, quizMode])
useEffect(() => {
if (!attemptId || !quizMode) return
const flush = () => { saveProgressNow() }
const flushWhenHidden = () => { if (document.visibilityState === 'hidden') flush() }
window.addEventListener('pagehide', flush)
document.addEventListener('visibilitychange', flushWhenHidden)
return () => {
window.removeEventListener('pagehide', flush)
document.removeEventListener('visibilitychange', flushWhenHidden)
}
}, [attemptId, quizMode, saveProgressNow])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
const 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 => {
@ -646,7 +732,24 @@ const timerStarted = timeLeft !== null
delete next[current.id]
return next
})
showToast('Question highlights cleared')
}
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] || []
@ -772,6 +875,7 @@ const timerStarted = timeLeft !== null
return (
<div className="quiz-bottom">
<MyNote variant="tab" />
{/* In-app leave confirmation */}
{leaveTarget && (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.6)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
@ -870,12 +974,12 @@ 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 }}>
{quizNavigation('top')}
{current && (
<div className="question-card" style={{
boxShadow: activeReadForCurrent ? '0 0 0 3px rgba(59, 130, 246, 0.22)' : undefined,
@ -889,6 +993,7 @@ const timerStarted = timeLeft !== null
textId={`${current.id}::question`}
highlights={highlightsFor('question')}
speechRange={questionSpeechRange}
onRemoveHighlight={removeJoinedHighlight}
/>
</h3>
<button
@ -942,13 +1047,7 @@ const timerStarted = timeLeft !== null
</button>
)}
<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">
<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>
@ -957,10 +1056,33 @@ const timerStarted = timeLeft !== null
{current.question_type === 'mcq' ? 'Multiple Choice' : current.question_type === 'true_false' ? 'True / False' : 'Fill in the Blank'}
</span>
{current.image_path && (
<div style={{ margin: '10px 0' }}>
<button className="question-image-preview" onClick={() => { setImageZoom(1); setExpandedImagePath(current.image_path) }} title="Expand image" type="button">
<img src={`/uploads/${current.image_path}`} alt="Question illustration"
style={{ maxWidth: '100%', maxHeight: 280, borderRadius: 8, border: '1px solid var(--border)' }}
onError={e => e.target.style.display = 'none'} />
onError={e => e.currentTarget.closest('button').style.display = 'none'} />
</button>
)}
{expandedImagePath && (
<div className="image-lightbox" role="dialog" aria-modal="true" aria-label="Expanded question image" onClick={() => setExpandedImagePath('')}>
<div className="image-lightbox-controls" onClick={e => e.stopPropagation()}>
<button type="button" onClick={() => adjustImageZoom(-0.1)} disabled={imageZoom <= 1}>-</button>
<span>{Math.round(imageZoom * 100)}%</span>
<button type="button" onClick={() => adjustImageZoom(0.1)} disabled={imageZoom >= 4}>+</button>
{[1, 2, 3, 4].map(zoom => (
<button key={zoom} type="button" onClick={() => setImageZoom(zoom)} disabled={imageZoom === zoom}>{zoom}x</button>
))}
</div>
<button className="image-lightbox-close" onClick={() => setExpandedImagePath('')} type="button" aria-label="Close expanded image">×</button>
<div className="image-lightbox-viewport" onClick={e => e.stopPropagation()}>
<img
src={`/uploads/${expandedImagePath}`}
alt="Expanded question illustration"
style={{
maxWidth: imageZoom === 1 ? 'min(100%, 1100px)' : 'none',
maxHeight: imageZoom === 1 ? '92vh' : 'none',
width: imageZoom > 1 ? `${imageZoom * 100}%` : undefined,
}}
/>
</div>
</div>
)}
{(current.question_type === 'mcq' || current.question_type === 'true_false') && current.options ? (
@ -994,6 +1116,7 @@ const timerStarted = timeLeft !== null
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>}

View file

@ -4,56 +4,11 @@ import { useAuth } from '../context/AuthContext'
import api from '../api/client'
import ConfirmButton from '../components/ConfirmButton'
import Dialog from '../components/Dialog'
import InProgressQuizzes from '../components/InProgressQuizzes'
import { useDialog } from '../hooks/useDialog'
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() {
const [open, setOpen] = useState(false)
const [history, setHistory] = useState(null)
@ -403,6 +358,24 @@ export default function QuizzesPage() {
<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 */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap' }}>
@ -488,7 +461,7 @@ export default function QuizzesPage() {
)}
{/* In-progress quizzes */}
{!isSearching && <InProgressSection />}
{!isSearching && <InProgressQuizzes />}
{/* Past attempts (loads on demand) */}
{!isSearching && <PastAttemptsSection />}

View file

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

13
telegram-bot/Dockerfile Normal file
View file

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

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

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

View file

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