diff --git a/backend/app/routers/courses.py b/backend/app/routers/courses.py index ffbe130..d7c61c1 100644 --- a/backend/app/routers/courses.py +++ b/backend/app/routers/courses.py @@ -1057,6 +1057,43 @@ def get_scorm_status( return {"completed": progress.status == "completed" if progress else False} +@router.get("/{course_id}/lessons/{lesson_id}/scorm/data") +def get_scorm_data( + course_id: int, + lesson_id: int, + current_user: User = Depends(get_current_user), +): + """Get saved SCORM runtime data for this user/lesson.""" + import json + try: + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}" + data = r.get(key) + return json.loads(data) if data else {} + except Exception: + return {} + + +@router.put("/{course_id}/lessons/{lesson_id}/scorm/data") +def save_scorm_data( + course_id: int, + lesson_id: int, + data: dict, + current_user: User = Depends(get_current_user), +): + """Save SCORM runtime data for this user/lesson. Persists bookmarks, scores, and state.""" + import json + try: + import redis as redis_lib + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + key = f"scorm_data:{current_user.id}:{course_id}:{lesson_id}" + r.set(key, json.dumps(data), ex=60 * 60 * 24 * 90) # 90 day expiry + return {"saved": True} + except Exception: + raise HTTPException(status_code=500, detail="Failed to save SCORM data") + + # ── Enrollment ─────────────────────────────────────────────────────── @router.get("/{course_id}/enrollees") diff --git a/frontend/src/pages/CourseDetailPage.jsx b/frontend/src/pages/CourseDetailPage.jsx index 2fd665c..053d711 100644 --- a/frontend/src/pages/CourseDetailPage.jsx +++ b/frontend/src/pages/CourseDetailPage.jsx @@ -9,73 +9,68 @@ import 'katex/dist/katex.min.css' import { useAuth } from '../context/AuthContext' import api from '../api/client' -// ── SCORM Player with API shim ────────────────────────────────────── +// ── 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) - const setupApi = useCallback(() => { + // 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 - const API = { - LMSInitialize: () => { return "true" }, - LMSFinish: () => { - const status = data["cmi.core.lesson_status"] - if ((status === "completed" || status === "passed") && !completedRef.current) { - completedRef.current = true - onComplete?.() - } - return "true" - }, - LMSGetValue: (key) => data[key] || "", + window.API = { + LMSInitialize: () => "true", + LMSFinish: () => { persist(); checkComplete(); return "true" }, + LMSGetValue: (key) => data[key] ?? "", LMSSetValue: (key, val) => { data[key] = val; return "true" }, - LMSCommit: () => { - const status = data["cmi.core.lesson_status"] - if ((status === "completed" || status === "passed") && !completedRef.current) { - completedRef.current = true - onComplete?.() - } - return "true" - }, + LMSCommit: () => { persist(); checkComplete(); return "true" }, LMSGetLastError: () => "0", LMSGetErrorString: () => "No error", LMSGetDiagnostic: () => "No diagnostic", } + // SCORM 2004 API - const API_1484_11 = { + window.API_1484_11 = { Initialize: () => "true", - Terminate: () => { - const status = data["cmi.completion_status"] - if ((status === "completed") && !completedRef.current) { - completedRef.current = true - onComplete?.() - } - return "true" - }, - GetValue: (key) => data[key] || "", + Terminate: () => { persist(); checkComplete(); return "true" }, + GetValue: (key) => data[key] ?? "", SetValue: (key, val) => { data[key] = val; return "true" }, - Commit: () => { - const status = data["cmi.completion_status"] - if ((status === "completed") && !completedRef.current) { - completedRef.current = true - onComplete?.() - } - return "true" - }, + Commit: () => { persist(); checkComplete(); return "true" }, GetLastError: () => "0", GetErrorString: () => "No error", GetDiagnostic: () => "No diagnostic", } - // Expose on window so iframe content can find it by walking up parent frames - window.API = API - window.API_1484_11 = API_1484_11 - }, [onComplete]) - useEffect(() => { - setupApi() - return () => { delete window.API; delete window.API_1484_11 } - }, [setupApi]) + return () => { persist(); delete window.API; delete window.API_1484_11 } + }, [ready, persist, checkComplete]) + + if (!ready) return
Loading SCORM content...
return (