Fix mobile touch, hide docs for users, timer save/restore, Nextcloud server-side

Mobile touch fix:
- Added touch-action: manipulation and -webkit-tap-highlight-color: transparent
  to .question-card .option CSS — prevents mobile browsers from interfering
  with tap events (double-tap zoom, highlight flash, delayed clicks)

Documents hidden for regular users:
- Dashboard: stats card hides Documents count for non-moderators
- Dashboard: document list section completely hidden for non-moderators
- Documents API not called for non-moderators (saves a request)

Timer improvements:
- Custom timer input on ModeSelectScreen — set any number of minutes
  for exam mode (overrides the quiz default, or add time to untimed quizzes)
- timeLeft saved to Redis progress every 1.5s (alongside answers, mode, voice)
- On resume: timer restarts from the saved remaining time
- Timer auto-submits when it reaches 0 (already worked, now also works on resume)
- Progress bar shows remaining time relative to what was left at resume

Nextcloud settings — server-side storage:
- GET /auth/me/settings — load user settings from Redis
- PUT /auth/me/settings — save settings to Redis (linked to user_id)
- SettingsPage loads Nextcloud config from server on mount
- Saves to BOTH server (cross-browser persistent) and localStorage (for UploadPage)
- Clear function wipes both server and localStorage
- Switching browsers and logging in restores Nextcloud settings

Also fixed: saved.attemptId → saved.attempt_id in ModeSelectScreen resume check

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 17:39:43 +02:00
parent 365aaf3df0
commit 1343eb5801
7 changed files with 130 additions and 53 deletions

View file

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

View file

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

View file

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

View file

@ -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 && (
<div className="stats-grid">
{[
{ 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() {
</div>
)}
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>{isModerator ? 'Documents' : 'Your Documents'}</h2>
{isModerator && <Link to="/upload" className="btn btn-primary">Upload PDF</Link>}
</div>
{documents.length === 0 ? (
<div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div>
) : (
documents.map(doc => (
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="section-item">
<div>
<strong>{doc.original_filename}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()}
{isModerator && (
<div className="card">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2>Documents</h2>
<Link to="/upload" className="btn btn-primary">Upload PDF</Link>
</div>
{documents.length === 0 ? (
<div className="empty-state"><p>No documents yet. Upload a PDF to get started!</p></div>
) : (
documents.map(doc => (
<Link to={`/documents/${doc.id}`} key={doc.id} style={{ textDecoration: 'none', color: 'inherit' }}>
<div className="section-item">
<div>
<strong>{doc.original_filename}</strong>
<div style={{ fontSize: '0.85rem', color: '#64748b' }}>
{doc.total_pages ? `${doc.total_pages} pages` : 'Processing...'} | {new Date(doc.uploaded_at).toLocaleDateString()}
</div>
</div>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
</div>
<span className={`badge badge-${doc.status}`}>{doc.status}</span>
</div>
</Link>
))
)}
</div>
</Link>
))
)}
</div>
)}
</div>
)
}

View file

@ -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 }) {
</p>
{saved && saved.attemptId && (
{saved && saved.attempt_id && (
<div style={{ background: 'var(--option-sel-bg)', border: '1px solid var(--option-sel-bd)', borderRadius: 8, padding: '12px 14px', marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<div style={{ fontWeight: 600, fontSize: '0.875rem' }}>You have an in-progress attempt</div>
@ -107,9 +108,9 @@ function ModeSelectScreen({ quiz, voices, onStart, onResume }) {
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginBottom: 20 }}>
{[
{ 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 }) => (
<div key={mode} onClick={() => onStart(mode, selectedVoice)}
<div key={mode} onClick={() => onStart(mode, selectedVoice, mode === 'exam' && customTimer ? parseInt(customTimer) : null)}
style={{ flex: 1, border: `2px solid ${color}`, borderRadius: 12, padding: '18px 12px', cursor: '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 }) {
))}
</div>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14, marginBottom: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}> Timer for Exam Mode <span style={{ fontWeight: 400 }}>(minutes, optional)</span></label>
<input type="number" min={1} value={customTimer}
onChange={e => 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)' }} />
<p style={{ fontSize: '0.75rem', color: 'var(--text-subtle)', marginTop: 4 }}>Auto-submits when timer expires. Timer pauses if you leave and resumes when you come back.</p>
</div>
{voices.length > 0 && (
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: 16 }}>
<label style={{ fontSize: '0.85rem', color: '#64748b', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
<div style={{ borderTop: '1px solid var(--border)', paddingTop: 14 }}>
<label style={{ fontSize: '0.85rem', color: 'var(--text-muted)', display: 'block', marginBottom: 6 }}>🔊 Voice for read-aloud</label>
<select value={selectedVoice} onChange={e => setSelectedVoice(e.target.value)}
style={{ padding: '6px 10px', borderRadius: 8, border: '1px solid #d1d5db', fontSize: '0.9rem', width: '100%' }}>
{voices.map(v => <option key={v.id} value={v.id}>{v.name}{v.is_default ? ' (default)' : ''}</option>)}
@ -192,20 +203,21 @@ export default function QuizPage() {
return () => clearInterval(timerRef.current)
}, [id])
const startQuiz = async (mode, voice) => {
const startQuiz = async (mode, voice, timerMinutes = null) => {
if (hasStarted.current) return
hasStarted.current = true
setSelectedVoice(voice)
setQuizMode(mode)
// Fetch full question data with study mode if needed
try {
const quizRes = await api.get(`/quizzes/${id}${mode === 'study' ? '?study=true' : ''}`)
setQuiz(quizRes.data)
const attemptRes = await api.post(`/attempts/start?quiz_id=${id}`)
setAttemptId(attemptRes.data.id)
if (mode === 'exam' && quizRes.data.time_limit_minutes) {
const secs = quizRes.data.time_limit_minutes * 60
// Timer: use custom input quiz default no timer
const mins = timerMinutes || quizRes.data.time_limit_minutes
if (mode === 'exam' && mins) {
const secs = mins * 60
setTimeLeft(secs); setTotalTime(secs)
}
} catch { navigate('/') }
@ -233,10 +245,11 @@ useEffect(() => {
current_idx: currentIdx,
mode: quizMode,
voice: selectedVoice || null,
time_left: timeLeft,
}).catch(() => {})
}, 1500) // debounce 1.5s
return () => clearTimeout(saveProgressRef.current)
}, [answers, currentIdx, attemptId])
}, [answers, currentIdx, attemptId, timeLeft])
const setAnswer = (questionId, value) => setAnswers(prev => ({ ...prev, [questionId]: value }))
@ -269,7 +282,6 @@ useEffect(() => {
const resumeQuiz = async (saved) => {
const mode = saved.mode || saved.quizMode
const savedIdx = saved.current_idx ?? saved.currentIdx ?? 0
// Restore answers ensure string keys (Redis JSON returns strings)
const savedAnswers = saved.answers || {}
// For study mode, load quiz with correct answers BEFORE showing
@ -278,6 +290,12 @@ useEffect(() => {
const quizRes = await api.get(`/quizzes/${id}?study=true`)
setQuiz(quizRes.data)
} catch { }
} else {
// For exam mode, ensure quiz data is loaded
try {
const quizRes = await api.get(`/quizzes/${id}`)
setQuiz(quizRes.data)
} catch { }
}
hasStarted.current = true
@ -285,8 +303,12 @@ useEffect(() => {
setAnswers(savedAnswers)
setCurrentIdx(savedIdx)
setAttemptId(saved.attempt_id || saved.attemptId)
// Restore voice if saved
if (saved.voice) setSelectedVoice(saved.voice)
// Restore timer (paused time restarts from where it was)
if (saved.time_left != null && saved.time_left > 0) {
setTimeLeft(saved.time_left)
setTotalTime(saved.time_left) // use remaining as new total for progress bar
}
}
if (!quizMode) return (

View file

@ -109,16 +109,35 @@ function AppearanceSection() {
}
function NextcloudSection() {
const [server, setServer] = useState(sessionStorage.getItem('nc_server') || 'https://cloud.danvics.com')
const [username, setUsername] = useState(sessionStorage.getItem('nc_username') || '')
const [password, setPassword] = useState(sessionStorage.getItem('nc_password') || '')
const [status, setStatus] = useState(null) // null | 'testing' | 'ok' | 'error'
const [server, setServer] = useState('https://cloud.danvics.com')
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [status, setStatus] = useState(null)
const [statusMsg, setStatusMsg] = useState('')
const [loaded, setLoaded] = useState(false)
const save = () => {
sessionStorage.setItem('nc_server', server)
sessionStorage.setItem('nc_username', username)
sessionStorage.setItem('nc_password', password)
// Load from server on mount
useEffect(() => {
api.get('/auth/me/settings').then(res => {
const s = res.data
if (s.nc_server) setServer(s.nc_server)
if (s.nc_username) setUsername(s.nc_username)
if (s.nc_password) setPassword(s.nc_password)
// Also sync to localStorage for UploadPage
if (s.nc_server) localStorage.setItem('nc_server', s.nc_server)
if (s.nc_username) localStorage.setItem('nc_username', s.nc_username)
if (s.nc_password) localStorage.setItem('nc_password', s.nc_password)
}).catch(() => {}).finally(() => setLoaded(true))
}, [])
const save = async () => {
// Save to both server (cross-browser) and localStorage (for UploadPage)
localStorage.setItem('nc_server', server)
localStorage.setItem('nc_username', username)
localStorage.setItem('nc_password', password)
try {
await api.put('/auth/me/settings', { nc_server: server, nc_username: username, nc_password: password })
} catch { }
setStatus('saved')
setTimeout(() => setStatus(null), 2000)
}
@ -136,10 +155,11 @@ function NextcloudSection() {
}
}
const clear = () => {
sessionStorage.removeItem('nc_server')
sessionStorage.removeItem('nc_username')
sessionStorage.removeItem('nc_password')
const clear = async () => {
localStorage.removeItem('nc_server')
localStorage.removeItem('nc_username')
localStorage.removeItem('nc_password')
try { await api.put('/auth/me/settings', {}) } catch { }
setServer('https://cloud.danvics.com'); setUsername(''); setPassword('')
setStatus(null)
}

View file

@ -3,9 +3,9 @@ import { useNavigate } from 'react-router-dom'
import api from '../api/client'
function NextcloudBrowser({ onFile }) {
const ncServer = sessionStorage.getItem('nc_server') || ''
const ncUser = sessionStorage.getItem('nc_username') || ''
const ncPass = sessionStorage.getItem('nc_password') || ''
const ncServer = localStorage.getItem('nc_server') || ''
const ncUser = localStorage.getItem('nc_username') || ''
const ncPass = localStorage.getItem('nc_password') || ''
const [path, setPath] = useState('/')
const [items, setItems] = useState(null)
@ -125,7 +125,7 @@ export default function UploadPage() {
const fileRef = useRef()
const navigate = useNavigate()
const hasNextcloud = !!sessionStorage.getItem('nc_username')
const hasNextcloud = !!localStorage.getItem('nc_username')
const handleFile = (f, fromNextcloud = false) => {
if (f && f.type === 'application/pdf') {