diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index 0d39c28..d5ee41c 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -192,6 +192,7 @@ class ProgressSave(BaseModel): current_idx: int mode: str voice: str | None = None + time_left: int | None = None # remaining seconds for timed mode @router.post("/progress") @@ -211,6 +212,7 @@ def save_progress( "current_idx": data.current_idx, "mode": data.mode, "voice": data.voice, + "time_left": data.time_left, })) except Exception: pass # Redis unavailable — degrade gracefully diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index b8cb2cf..68ef9a7 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -204,6 +204,35 @@ def reset_password(data: ResetPasswordRequest, db: Session = Depends(get_db)): return {"message": "Password reset successfully. You can now log in."} +@router.get("/me/settings") +def get_user_settings(current_user: User = Depends(get_current_user)): + """Get user settings stored in Redis (Nextcloud config etc.).""" + try: + import redis as redis_lib, json + from app.config import settings as cfg + r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True) + data = r.get(f"user_settings:{current_user.id}") + return json.loads(data) if data else {} + except Exception: + return {} + + +@router.put("/me/settings") +def save_user_settings( + settings_data: dict, + current_user: User = Depends(get_current_user), +): + """Save user settings to Redis.""" + try: + import redis as redis_lib, json + from app.config import settings as cfg + r = redis_lib.from_url(cfg.REDIS_URL, decode_responses=True) + r.set(f"user_settings:{current_user.id}", json.dumps(settings_data)) + except Exception: + pass + return {"saved": True} + + @router.get("/me", response_model=UserResponse) def get_me(current_user: User = Depends(get_current_user)): return current_user diff --git a/frontend/src/index.css b/frontend/src/index.css index 631995a..b6471ca 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -190,6 +190,7 @@ body { 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; + -webkit-tap-highlight-color: transparent; touch-action: manipulation; } .question-card .option:hover { background: var(--option-hover); border-color: var(--text-subtle); } .question-card .option.selected { border-color: var(--option-sel-bd); background: var(--option-sel-bg); } diff --git a/frontend/src/pages/DashboardPage.jsx b/frontend/src/pages/DashboardPage.jsx index 2b8612f..0b3a49e 100644 --- a/frontend/src/pages/DashboardPage.jsx +++ b/frontend/src/pages/DashboardPage.jsx @@ -21,11 +21,12 @@ export default function DashboardPage() { const [loading, setLoading] = useState(true) useEffect(() => { - Promise.all([ - api.get('/documents/'), + const promises = [ + isModerator ? api.get('/documents/') : Promise.resolve({ data: [] }), api.get('/attempts/stats/dashboard'), api.get('/attempts/history'), - ]).then(([docsRes, statsRes, histRes]) => { + ] + Promise.all(promises).then(([docsRes, statsRes, histRes]) => { setDocuments(docsRes.data) setStats(statsRes.data) setHistory(histRes.data) @@ -48,7 +49,7 @@ export default function DashboardPage() { {stats && (
{[ - { value: stats.total_documents, label: 'Documents' }, + ...(isModerator ? [{ value: stats.total_documents, label: 'Documents' }] : []), { value: stats.total_quizzes, label: 'Quizzes' }, { value: stats.total_attempts, label: 'Attempts' }, { value: `${stats.average_score}%`, label: 'Avg Score' }, @@ -102,29 +103,31 @@ export default function DashboardPage() {
)} -
-
-

{isModerator ? 'Documents' : 'Your Documents'}

- {isModerator && Upload PDF} -
- {documents.length === 0 ? ( -

No documents yet. Upload a PDF to get started!

- ) : ( - documents.map(doc => ( - -
-
- {doc.original_filename} -
- {doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()} + {isModerator && ( +
+
+

Documents

+ Upload PDF +
+ {documents.length === 0 ? ( +

No documents yet. Upload a PDF to get started!

+ ) : ( + documents.map(doc => ( + +
+
+ {doc.original_filename} +
+ {doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()} +
+ {doc.status}
- {doc.status} -
- - )) - )} -
+ + )) + )} +
+ )}
) } diff --git a/frontend/src/pages/QuizPage.jsx b/frontend/src/pages/QuizPage.jsx index 2eaef1e..1acef6e 100644 --- a/frontend/src/pages/QuizPage.jsx +++ b/frontend/src/pages/QuizPage.jsx @@ -73,6 +73,7 @@ function TimerDisplay({ seconds, total }) { function ModeSelectScreen({ quiz, voices, onStart, onResume }) { const [selectedVoice, setSelectedVoice] = useState(voices.find(v => v.is_default)?.id || voices[0]?.id || '') + const [customTimer, setCustomTimer] = useState(quiz.time_limit_minutes || '') const [saved, setSaved] = useState(null) useEffect(() => { @@ -92,7 +93,7 @@ function ModeSelectScreen({ quiz, voices, onStart, onResume }) {

- {saved && saved.attemptId && ( + {saved && saved.attempt_id && (
You have an in-progress attempt
@@ -107,9 +108,9 @@ function ModeSelectScreen({ quiz, voices, onStart, onResume }) {
{[ { mode: 'study', icon: '📖', label: 'Study Mode', desc: 'Answers & explanations shown as you go', color: '#22c55e', bg: '#f0fdf4' }, - { mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Timed, answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' }, + { mode: 'exam', icon: '🎯', label: 'Exam Mode', desc: 'Answers hidden until submitted', color: '#3b82f6', bg: '#eff6ff' }, ].map(({ mode, icon, label, desc, color, bg }) => ( -
onStart(mode, selectedVoice)} +
onStart(mode, selectedVoice, mode === 'exam' && customTimer ? parseInt(customTimer) : null)} style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: 'pointer', background: bg, transition: 'transform 0.1s' }} onMouseEnter={e => e.currentTarget.style.transform = 'scale(1.03)'} onMouseLeave={e => e.currentTarget.style.transform = 'none'} @@ -121,9 +122,19 @@ function ModeSelectScreen({ quiz, voices, onStart, onResume }) { ))}
+
+ + setCustomTimer(e.target.value)} + placeholder="No time limit" + onClick={e => e.stopPropagation()} + style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border)', fontSize: '0.9rem', width: '100%', background: 'var(--input-bg)', color: 'var(--text)' }} /> +

Auto-submits when timer expires. Timer pauses if you leave and resumes when you come back.

+
+ {voices.length > 0 && ( -
- +
+