learn-platform-deploy/Frontend/src/app/pages/LearnerScormLaunchPage.tsx

141 lines
7.2 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, ChevronRight, PlayCircle, Save } from 'lucide-react'
import { useParams } from 'react-router'
import { ApiError } from '../api/client'
import { commitLearnerScormRuntime, fetchLearnerScormLaunch, fetchLearnerScormRuntime, type LearnerScormLaunchDetail, type LearnerScormRuntime } from '../learner-scorm'
import { generateSCOHtml } from './scorm-shared'
type LoadState = 'loading' | 'ready' | 'error'
export function LearnerScormLaunchPage() {
const { id = '' } = useParams<{ id: string }>()
const courseID = id.trim()
const [detail, setDetail] = useState<LearnerScormLaunchDetail | null>(null)
const [runtime, setRuntime] = useState<LearnerScormRuntime | null>(null)
const [scoIndex, setScoIndex] = useState(0)
const [state, setState] = useState<LoadState>('loading')
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
const valuesRef = useRef<Record<string, unknown>>({})
useEffect(() => {
if (!courseID) { setError('A SCORM course id is required.'); setState('error'); return }
let cancelled = false
void fetchLearnerScormLaunch(courseID).then(result => {
if (cancelled) return
setDetail(result); setState('ready')
}).catch(err => {
if (cancelled) return
setError(err instanceof ApiError ? err.message : 'Failed to load SCORM course'); setState('error')
})
return () => { cancelled = true }
}, [courseID])
const selectedSCO = detail?.course.scos[scoIndex] ?? null
useEffect(() => {
if (!selectedSCO || !courseID) return
void fetchLearnerScormRuntime(courseID, selectedSCO.id).then(next => {
setRuntime(next)
valuesRef.current = { ...next.values }
}).catch(err => setError(err instanceof Error ? err.message : 'Unable to load runtime state'))
}, [courseID, selectedSCO?.id])
const commit = useCallback(async () => {
if (!selectedSCO || !courseID) return
setSaving(true)
try {
const values = { ...valuesRef.current }
const lessonStatus = String(values['cmi.core.lesson_status'] ?? runtime?.lessonStatus ?? '')
const completionStatus = String(values['cmi.completion_status'] ?? runtime?.completionStatus ?? '')
const successStatus = String(values['cmi.success_status'] ?? runtime?.successStatus ?? '')
const score = Number(values['cmi.core.score.raw'] ?? values['cmi.score.raw'] ?? runtime?.score ?? 0)
const progress = Number(values['cmi.progress_measure'] ?? runtime?.progress ?? 0)
const result = await commitLearnerScormRuntime(courseID, {
scoId: selectedSCO.id,
status: 'committed',
location: String(values['cmi.core.lesson_location'] ?? values['cmi.location'] ?? ''),
suspendData: String(values['cmi.suspend_data'] ?? ''),
lessonStatus,
completionStatus,
successStatus,
score: Number.isFinite(score) ? score : 0,
progress: Number.isFinite(progress) ? (progress <= 1 ? progress * 100 : progress) : 0,
values,
})
setRuntime(result.runtime)
} finally {
setSaving(false)
}
}, [courseID, runtime, selectedSCO])
useEffect(() => {
if (!selectedSCO) return
const getValue = (key: string) => String(valuesRef.current[key] ?? '')
const setValue = (key: string, value: string) => { valuesRef.current[key] = value; return 'true' }
const api12 = {
LMSInitialize: () => 'true',
LMSFinish: () => { void commit(); return 'true' },
LMSGetValue: getValue,
LMSSetValue: setValue,
LMSCommit: () => { void commit(); return 'true' },
LMSGetLastError: () => '0',
LMSGetErrorString: () => 'No error',
LMSGetDiagnostic: () => '',
}
const api2004 = {
Initialize: () => 'true',
Terminate: () => { void commit(); return 'true' },
GetValue: getValue,
SetValue: setValue,
Commit: () => { void commit(); return 'true' },
GetLastError: () => '0',
GetErrorString: () => 'No error',
GetDiagnostic: () => '',
}
const globalWindow = window as unknown as Record<string, unknown>
globalWindow.API = api12
globalWindow.API_1484_11 = api2004
return () => {
delete globalWindow.API
delete globalWindow.API_1484_11
}
}, [commit, selectedSCO])
const html = useMemo(() => detail && selectedSCO ? generateSCOHtml(detail.course, selectedSCO) : '', [detail, selectedSCO])
const percent = detail?.enrollment.progress ?? 0
return (
<div data-testid="learner-scorm-launch-page" className="space-y-4">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-stone-900">{detail?.course.title ?? 'SCORM Launch'}</h1>
<p className="mt-2 text-sm text-stone-600">SCORM progress, bookmark data, scores, and suspend data are persisted to your learner record.</p>
</header>
{state === 'loading' && <p className="text-sm text-stone-600">Loading launch details...</p>}
{state === 'error' && <p className="text-sm text-red-600">{error}</p>}
{state === 'ready' && detail && !detail.launchState.allowed && <div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">{detail.launchState.message || 'This package is not available yet.'}</div>}
{state === 'ready' && detail && detail.launchState.allowed && selectedSCO && (
<>
<div className="flex flex-wrap items-center justify-between gap-3 rounded-2xl border border-stone-200 bg-white p-3">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-stone-500">Lesson {scoIndex + 1} of {detail.course.scos.length}</p>
<p className="font-semibold text-stone-900">{selectedSCO.title}</p>
</div>
<div className="flex items-center gap-2">
<button type="button" disabled={scoIndex === 0} onClick={() => setScoIndex(index => Math.max(0, index - 1))} className="rounded-lg border border-stone-200 p-2 disabled:opacity-40"><ChevronLeft size={16} /></button>
<button data-testid="learner-scorm-launch-primary" type="button" onClick={() => void commit()} disabled={saving} className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 px-3 py-2 text-sm font-semibold text-white disabled:opacity-60">{saving ? <Save size={15} /> : <PlayCircle size={15} />}{saving ? 'Saving…' : 'Save progress'}</button>
<button type="button" disabled={scoIndex >= detail.course.scos.length - 1} onClick={() => setScoIndex(index => Math.min(detail.course.scos.length - 1, index + 1))} className="rounded-lg border border-stone-200 p-2 disabled:opacity-40"><ChevronRight size={16} /></button>
</div>
</div>
<div className="rounded-2xl border border-stone-200 bg-white p-2">
<iframe title={selectedSCO.title} srcDoc={html} sandbox="allow-scripts allow-same-origin" className="h-[65vh] min-h-[520px] w-full rounded-xl border-0 bg-white" />
</div>
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-600">
Course progress: {Math.round(percent)}% · Runtime status: {runtime?.lessonStatus || runtime?.completionStatus || 'incomplete'}
</div>
</>
)}
</div>
)
}