pdf-quiz-generator/frontend/src/pages/CourseDetailPage.jsx
Daniel b32fc65236 Fix mark-complete 403 for course creators previewing lessons
Creators preview courses without enrolling, so markComplete and SCORM
onComplete were hitting the progress endpoint and getting 403. Now
guarded by enrollment check — buttons hidden and callbacks skipped
for non-enrolled users.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 02:25:21 +02:00

573 lines
25 KiB
JavaScript

import { useState, useEffect, useRef, useCallback } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import Markdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import remarkMath from 'remark-math'
import rehypeRaw from 'rehype-raw'
import rehypeKatex from 'rehype-katex'
import 'katex/dist/katex.min.css'
import { useAuth } from '../context/AuthContext'
import api from '../api/client'
// ── SCORM Player with API shim + persistence ───────────────────────
function ScormPlayer({ src, title, lessonId, courseId, onComplete }) {
const iframeRef = useRef(null)
const dataRef = useRef({})
const completedRef = useRef(false)
const [ready, setReady] = useState(false)
// Load saved SCORM data from backend before exposing API
useEffect(() => {
api.get(`/courses/${courseId}/lessons/${lessonId}/scorm/data`)
.then(res => { dataRef.current = res.data || {} })
.catch(() => {})
.finally(() => setReady(true))
}, [courseId, lessonId])
// Save SCORM data to backend (debounced — called on Commit/Finish/Terminate)
const persist = useCallback(() => {
api.put(`/courses/${courseId}/lessons/${lessonId}/scorm/data`, dataRef.current).catch(() => {})
}, [courseId, lessonId])
const checkComplete = useCallback(() => {
const data = dataRef.current
const s12 = data["cmi.core.lesson_status"]
const s04 = data["cmi.completion_status"]
if ((s12 === "completed" || s12 === "passed" || s04 === "completed") && !completedRef.current) {
completedRef.current = true
onComplete?.()
}
}, [onComplete])
useEffect(() => {
if (!ready) return
const data = dataRef.current
// SCORM 1.2 API
window.API = {
LMSInitialize: () => "true",
LMSFinish: () => { persist(); checkComplete(); return "true" },
LMSGetValue: (key) => data[key] ?? "",
LMSSetValue: (key, val) => { data[key] = val; return "true" },
LMSCommit: () => { persist(); checkComplete(); return "true" },
LMSGetLastError: () => "0",
LMSGetErrorString: () => "No error",
LMSGetDiagnostic: () => "No diagnostic",
}
// SCORM 2004 API
window.API_1484_11 = {
Initialize: () => "true",
Terminate: () => { persist(); checkComplete(); return "true" },
GetValue: (key) => data[key] ?? "",
SetValue: (key, val) => { data[key] = val; return "true" },
Commit: () => { persist(); checkComplete(); return "true" },
GetLastError: () => "0",
GetErrorString: () => "No error",
GetDiagnostic: () => "No diagnostic",
}
return () => { persist(); delete window.API; delete window.API_1484_11 }
}, [ready, persist, checkComplete])
if (!ready) return <div className="loading"><div className="spinner" /> Loading SCORM content...</div>
return (
<iframe
ref={iframeRef}
src={src}
style={{ width: '100%', height: 600, border: '1px solid var(--border)', borderRadius: 8 }}
title={title}
allow="fullscreen"
/>
)
}
const LESSON_ICONS = {
text: '\u{1F4C4}',
video: '\u{1F3AC}',
document: '\u{1F4CE}',
quiz: '\u{2753}',
live_session: '\u{1F534}',
scorm_package: '\u{1F4E6}',
}
function getVideoEmbed(url, provider) {
if (provider === 'youtube') {
const m = url.match(/(?:youtube\.com\/watch\?v=|youtu\.be\/)([^&?#]+)/)
return m ? `https://www.youtube.com/embed/${m[1]}` : url
}
if (provider === 'vimeo') {
const m = url.match(/vimeo\.com\/(\d+)/)
return m ? `https://player.vimeo.com/video/${m[1]}` : url
}
return url
}
export default function CourseDetailPage() {
const { courseId } = useParams()
const navigate = useNavigate()
const [course, setCourse] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedModules, setExpandedModules] = useState({})
const [activeLesson, setActiveLesson] = useState(null)
const [lessonContent, setLessonContent] = useState(null)
const [loadingLesson, setLoadingLesson] = useState(false)
const [enrolling, setEnrolling] = useState(false)
const [unenrolling, setUnenrolling] = useState(false)
const [completedLessons, setCompletedLessons] = useState(new Set())
const [inProgressLessons, setInProgressLessons] = useState(new Set())
const fetchCourse = () => {
api.get(`/courses/${courseId}`).then(res => {
const data = res.data
setCourse(data)
// Build completed/in-progress sets from enrollment lesson progress if available
const completed = new Set()
const inProgress = new Set()
if (data.my_enrollment?.lesson_progress) {
const lp = data.my_enrollment.lesson_progress
// lesson_progress is { lesson_id: { status, ... } }
for (const [lessonId, info] of Object.entries(lp)) {
if (info.status === 'completed') completed.add(parseInt(lessonId))
else if (info.status === 'in_progress') inProgress.add(parseInt(lessonId))
}
}
setCompletedLessons(completed)
setInProgressLessons(inProgress)
setLoading(false)
}).catch(() => {
setError('Failed to load course')
setLoading(false)
})
}
useEffect(() => { fetchCourse() }, [courseId])
const enroll = async () => {
setEnrolling(true)
setError('')
try {
await api.post(`/courses/${courseId}/enroll`)
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to enroll')
} finally {
setEnrolling(false)
}
}
const unenroll = async () => {
setUnenrolling(true)
setError('')
try {
await api.delete(`/courses/${courseId}/enroll`)
fetchCourse()
setActiveLesson(null)
setLessonContent(null)
} catch (err) {
setError(err.response?.data?.detail || 'Failed to unenroll')
} finally {
setUnenrolling(false)
}
}
const toggleModule = (moduleId) => {
setExpandedModules(prev => ({ ...prev, [moduleId]: !prev[moduleId] }))
}
const openLesson = async (lesson) => {
setActiveLesson(lesson)
setLessonContent(lesson)
setLoadingLesson(true)
// Mark as in_progress
if (!completedLessons.has(lesson.id)) {
try {
await api.put(`/courses/${courseId}/lessons/${lesson.id}/progress`, { status: 'in_progress' })
setInProgressLessons(prev => { const n = new Set(prev); n.add(lesson.id); return n })
} catch { /* silent */ }
}
setLoadingLesson(false)
}
const markComplete = async (lessonId) => {
if (!course.my_enrollment) return
try {
await api.put(`/courses/${courseId}/lessons/${lessonId}/progress`, { status: 'completed' })
setCompletedLessons(prev => { const n = new Set(prev); n.add(lessonId); return n })
setInProgressLessons(prev => { const n = new Set(prev); n.delete(lessonId); return n })
// Re-fetch to update overall progress
fetchCourse()
} catch (err) {
setError(err.response?.data?.detail || 'Failed to mark lesson complete')
}
}
if (loading) return <div className="loading"><div className="spinner" /></div>
if (!course) return <div className="card"><div className="empty-state">Course not found.</div></div>
const { user } = useAuth()
const enrolled = !!course.my_enrollment
const isCreator = user && (course.user_id === user.id || user.role === 'admin')
const canView = enrolled || isCreator // creators can preview without enrolling
const progressPct = course.my_enrollment?.progress_pct || 0
return (
<div>
{/* Header */}
<div className="card" style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', flexWrap: 'wrap', gap: 12 }}>
<div style={{ flex: 1, minWidth: 200 }}>
<h2 style={{ marginBottom: 4 }}>{course.title}</h2>
{course.description && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.9rem', marginBottom: 8 }}>{course.description}</p>
)}
<p style={{ color: 'var(--text-muted)', fontSize: '0.82rem' }}>
Created by {course.creator_name || 'Unknown'}
</p>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
{enrolled ? (
<button className="btn btn-secondary" onClick={unenroll} disabled={unenrolling}>
{unenrolling ? 'Unenrolling...' : 'Unenroll'}
</button>
) : (
<button className="btn btn-primary" onClick={enroll} disabled={enrolling}>
{enrolling ? 'Enrolling...' : 'Enroll in Course'}
</button>
)}
<button className="btn btn-secondary" onClick={() => navigate('/courses')}>Back</button>
</div>
</div>
{enrolled && (
<div style={{ marginTop: 12 }}>
<div className="progress-bar" style={{ height: 6 }}>
<div className="fill" style={{ width: `${progressPct}%`, transition: 'width 0.3s' }} />
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 4 }}>
<span style={{ fontSize: '0.75rem', color: 'var(--text-muted)' }}>{progressPct}% complete</span>
{course.my_enrollment?.completed_at && (
<button className="btn btn-primary btn-sm" onClick={async () => {
try {
const res = await api.get(`/courses/${courseId}/certificate`, { responseType: 'blob' })
const url = URL.createObjectURL(res.data)
const a = document.createElement('a')
a.href = url; a.download = `Certificate - ${course.title}.pdf`; a.click()
URL.revokeObjectURL(url)
} catch { }
}}>Download Certificate</button>
)}
</div>
</div>
)}
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 16 }}>{error}</div>}
{!canView && (
<div className="card">
<div className="empty-state">
<p>Enroll in this course to access modules and lessons.</p>
</div>
</div>
)}
{isCreator && !enrolled && (
<div style={{ background: 'var(--option-sel-bg)', border: '1px solid var(--primary)', borderRadius: 8, padding: '8px 14px', marginBottom: 12, fontSize: '0.85rem', color: 'var(--primary)' }}>
Preview mode you are viewing as the course creator.
</div>
)}
{/* Modules accordion */}
{canView && course.modules && course.modules.length > 0 && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: activeLesson ? 16 : 0 }}>
{course.modules.map(mod => {
const isExpanded = !!expandedModules[mod.id]
const lessons = mod.lessons || []
const completedCount = lessons.filter(l => completedLessons.has(l.id)).length
return (
<div className="card" key={mod.id} style={{ padding: 0, overflow: 'hidden' }}>
{/* Module header */}
<div
onClick={() => toggleModule(mod.id)}
style={{
padding: '14px 20px', cursor: 'pointer', display: 'flex',
justifyContent: 'space-between', alignItems: 'center',
background: isExpanded ? 'var(--bg)' : 'transparent',
transition: 'background 0.15s',
userSelect: 'none',
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '0.85rem', color: 'var(--text-muted)', transition: 'transform 0.2s', transform: isExpanded ? 'rotate(90deg)' : 'rotate(0deg)' }}>
&#9654;
</span>
<div>
<strong style={{ fontSize: '0.95rem' }}>{mod.title}</strong>
<div style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginTop: 2 }}>
{lessons.length} lesson{lessons.length !== 1 ? 's' : ''}
{lessons.length > 0 && ` &middot; ${completedCount}/${lessons.length} completed`}
</div>
</div>
</div>
{lessons.length > 0 && completedCount === lessons.length && (
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.85rem' }}>&#10003; Done</span>
)}
</div>
{/* Lessons list */}
{isExpanded && (
<div style={{ borderTop: '1px solid var(--border)' }}>
{lessons.length === 0 && (
<div style={{ padding: '12px 20px', color: 'var(--text-muted)', fontSize: '0.85rem' }}>No lessons in this module.</div>
)}
{lessons.map(lesson => {
const isActive = activeLesson?.id === lesson.id
const isCompleted = completedLessons.has(lesson.id)
const icon = LESSON_ICONS[lesson.lesson_type] || '\u{1F4C4}'
return (
<div
key={lesson.id}
onClick={() => openLesson(lesson)}
style={{
padding: '10px 20px 10px 44px', cursor: 'pointer',
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
background: isActive ? 'var(--primary-bg, rgba(59,130,246,0.08))' : 'transparent',
borderBottom: '1px solid var(--border)',
transition: 'background 0.1s',
}}
onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--hover-bg, rgba(0,0,0,0.03))' }}
onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'transparent' }}
>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<span style={{ fontSize: '1rem' }}>{icon}</span>
<div>
<span style={{ fontSize: '0.88rem', fontWeight: isActive ? 600 : 400 }}>{lesson.title}</span>
<div style={{ fontSize: '0.75rem', color: 'var(--text-muted)', marginTop: 1 }}>
{lesson.duration_minutes ? `${lesson.duration_minutes} min` : ''}
{lesson.lesson_type === 'live_session' && lesson.live_session_start && (
<span> &middot; {new Date(lesson.live_session_start).toLocaleString()}</span>
)}
</div>
</div>
</div>
<div>
{isCompleted && <span style={{ color: 'var(--correct-fg)', fontWeight: 700 }}>&#10003;</span>}
</div>
</div>
)
})}
</div>
)}
</div>
)
})}
</div>
)}
{canView && (!course.modules || course.modules.length === 0) && (
<div className="card"><div className="empty-state">This course has no modules yet.</div></div>
)}
{/* Lesson viewer */}
{canView && activeLesson && (
<div className="card" style={{ marginTop: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<div>
<h3 style={{ margin: 0 }}>
{LESSON_ICONS[activeLesson.lesson_type] || '\u{1F4C4}'} {activeLesson.title}
</h3>
{activeLesson.duration_minutes && (
<p style={{ color: 'var(--text-muted)', fontSize: '0.82rem', marginTop: 4 }}>{activeLesson.duration_minutes} min</p>
)}
</div>
<div style={{ display: 'flex', gap: 8 }}>
{enrolled && !completedLessons.has(activeLesson.id) && (
<button className="btn btn-primary btn-sm" onClick={() => markComplete(activeLesson.id)}>
Mark Complete &#10003;
</button>
)}
{enrolled && completedLessons.has(activeLesson.id) && (
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.88rem', padding: '6px 0' }}>
&#10003; Completed
</span>
)}
<button className="btn btn-secondary btn-sm" onClick={() => { setActiveLesson(null); setLessonContent(null) }}>Close</button>
</div>
</div>
{loadingLesson && <div className="loading"><div className="spinner" /></div>}
{!loadingLesson && activeLesson.lesson_type === 'text' && (
<div className="lesson-content" style={{ lineHeight: 1.7 }}>
{activeLesson.content_text ? (
<Markdown remarkPlugins={[remarkGfm, remarkMath]} rehypePlugins={[rehypeRaw, rehypeKatex]}>
{activeLesson.content_text}
</Markdown>
) : (
<p style={{ color: 'var(--text-muted)' }}>No content available.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'video' && (
<div>
{activeLesson.video_provider && activeLesson.video_url ? (
<div style={{ position: 'relative', paddingBottom: '56.25%', height: 0, overflow: 'hidden', borderRadius: 8 }}>
<iframe
src={getVideoEmbed(activeLesson.video_url, activeLesson.video_provider)}
style={{ position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' }}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
</div>
) : activeLesson.local_file_path ? (
<video
src={'/uploads/' + activeLesson.local_file_path}
controls
style={{ width: '100%', borderRadius: 8 }}
/>
) : (
<p style={{ color: 'var(--text-muted)' }}>No video source configured.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'quiz' && (
<div style={{ padding: 20 }}>
{activeLesson.quiz_id ? (() => {
const attempts = activeLesson.quiz_attempts || []
const maxAttempts = activeLesson.quiz_max_attempts
const attemptsLeft = maxAttempts ? maxAttempts - attempts.length : null
const canAttempt = !maxAttempts || attempts.length < maxAttempts
const lastAttempt = attempts[0]
return (
<div>
{activeLesson.quiz_title && (
<h3 style={{ fontSize: '1.05rem', marginBottom: 8 }}>{activeLesson.quiz_title}</h3>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16, flexWrap: 'wrap', gap: 8 }}>
<div>
<p style={{ fontSize: '0.85rem', color: 'var(--text-muted)', margin: 0 }}>
{activeLesson.quiz_questions_per_attempt
? `${activeLesson.quiz_questions_per_attempt} questions`
: `${activeLesson.quiz_questions_count || '?'} questions`}
{activeLesson.quiz_mode === 'timed' && activeLesson.quiz_time_limit ? ` · ${activeLesson.quiz_time_limit} min` : ''}
{activeLesson.quiz_mode === 'timed' ? ' · Exam mode' : ' · Study mode'}
</p>
{maxAttempts && (
<p style={{ fontSize: '0.82rem', color: 'var(--text-muted)', marginTop: 4 }}>
{attemptsLeft > 0 ? `${attemptsLeft} attempt${attemptsLeft !== 1 ? 's' : ''} remaining` : 'No attempts remaining'}
</p>
)}
</div>
{canAttempt && (
<button className="btn btn-primary" onClick={() => navigate(`/quizzes/${activeLesson.quiz_id}?return_to=/courses/${courseId}`)}>
{attempts.length > 0 ? 'Retake Quiz' : 'Start Quiz'}
</button>
)}
</div>
{attempts.length > 0 && (
<div>
<h4 style={{ fontSize: '0.88rem', marginBottom: 8 }}>Your Attempts</h4>
{attempts.map((a, i) => (
<div key={a.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '6px 10px', borderBottom: '1px solid var(--border)', fontSize: '0.85rem' }}>
<span style={{ color: 'var(--text-muted)' }}>
{i === 0 ? 'Latest' : `Attempt ${attempts.length - i}`}
{a.completed_at ? ` · ${new Date(a.completed_at).toLocaleDateString()}` : ''}
</span>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontWeight: 600, color: a.percentage >= 75 ? 'var(--correct-fg)' : a.percentage >= 50 ? '#d97706' : 'var(--wrong-fg)' }}>
{a.score}/{a.total} ({a.percentage}%)
</span>
{a.completed_at && activeLesson.quiz_allow_review !== 0 && (
<Link to={`/results/${a.id}?return_to=/courses/${courseId}`} style={{ fontSize: '0.78rem', color: 'var(--primary)', textDecoration: 'none' }}>Review</Link>
)}
</div>
</div>
))}
</div>
)}
</div>
)
})() : (
<p style={{ color: 'var(--text-muted)', textAlign: 'center' }}>No quiz linked to this lesson.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'live_session' && (
<div style={{ textAlign: 'center', padding: 20 }}>
{activeLesson.live_session_start && (
<p style={{ marginBottom: 12, fontSize: '0.95rem' }}>
Scheduled: <strong>{new Date(activeLesson.live_session_start).toLocaleString()}</strong>
</p>
)}
{activeLesson.live_session_url ? (
<a
href={activeLesson.live_session_url}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
Join Session
</a>
) : activeLesson.bbb_meeting_id ? (
<button
className="btn btn-primary"
onClick={() => window.open(`/api/bbb/join/${activeLesson.bbb_meeting_id}`, '_blank')}
>
Join BBB Meeting
</button>
) : (
<p style={{ color: 'var(--text-muted)' }}>No session link available yet.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'document' && (
<div style={{ textAlign: 'center', padding: 20 }}>
{activeLesson.local_file_path ? (
<a
href={'/uploads/' + activeLesson.local_file_path}
target="_blank"
rel="noopener noreferrer"
className="btn btn-primary"
>
Download Document
</a>
) : (
<p style={{ color: 'var(--text-muted)' }}>No document attached.</p>
)}
</div>
)}
{!loadingLesson && activeLesson.lesson_type === 'scorm_package' && (
<div>
{activeLesson.local_file_path ? (
<ScormPlayer
src={activeLesson.local_file_path}
title={activeLesson.title}
lessonId={activeLesson.id}
courseId={courseId}
onComplete={() => {
if (enrolled && !completedLessons.has(activeLesson.id)) {
markComplete(activeLesson.id)
}
}}
/>
) : (
<p style={{ color: 'var(--text-muted)', textAlign: 'center', padding: 20 }}>No SCORM package uploaded.</p>
)}
</div>
)}
</div>
)}
</div>
)
}