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:
parent
365aaf3df0
commit
1343eb5801
7 changed files with 130 additions and 53 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
Loading…
Reference in a new issue