141 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|