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>
573 lines
25 KiB
JavaScript
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)' }}>
|
|
▶
|
|
</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 && ` · ${completedCount}/${lessons.length} completed`}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{lessons.length > 0 && completedCount === lessons.length && (
|
|
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.85rem' }}>✓ 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> · {new Date(lesson.live_session_start).toLocaleString()}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
{isCompleted && <span style={{ color: 'var(--correct-fg)', fontWeight: 700 }}>✓</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 ✓
|
|
</button>
|
|
)}
|
|
{enrolled && completedLessons.has(activeLesson.id) && (
|
|
<span style={{ color: 'var(--correct-fg)', fontWeight: 600, fontSize: '0.88rem', padding: '6px 0' }}>
|
|
✓ 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>
|
|
)
|
|
}
|