Some checks failed
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Failing after 37s
CI/CD / test-python-backend-compliance (push) Successful in 39s
CI/CD / test-python-document-crawler (push) Successful in 26s
CI/CD / test-python-dsms-gateway (push) Successful in 23s
CI/CD / validate-canonical-controls (push) Successful in 12s
CI/CD / Deploy (push) Has been skipped
Interactive Training Videos (CP-TRAIN): - DB migration 022: training_checkpoints + checkpoint_progress tables - NarratorScript generation via Anthropic (AI Teacher persona, German) - TTS batch synthesis + interactive video pipeline (slides + checkpoint slides + FFmpeg) - 4 new API endpoints: generate-interactive, interactive-manifest, checkpoint submit, checkpoint progress - InteractiveVideoPlayer component (HTML5 Video, quiz overlay, seek protection, progress tracking) - Learner portal integration with automatic completion on all checkpoints passed - 30 new tests (handler validation + grading logic + manifest/progress + seek protection) Training Blocks: - Block generator, block store, block config CRUD + preview/generate endpoints - Migration 021: training_blocks schema Control Generator + Canonical Library: - Control generator routes + service enhancements - Canonical control library helpers, sidebar entry - Citation backfill service + tests - CE libraries data (hazard, protection, evidence, lifecycle, components) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
322 lines
12 KiB
TypeScript
322 lines
12 KiB
TypeScript
'use client'
|
|
|
|
import { useRef, useState, useEffect, useCallback } from 'react'
|
|
import type {
|
|
InteractiveVideoManifest,
|
|
CheckpointEntry,
|
|
CheckpointQuizResult,
|
|
} from '@/lib/sdk/training/types'
|
|
import { submitCheckpointQuiz } from '@/lib/sdk/training/api'
|
|
|
|
interface Props {
|
|
manifest: InteractiveVideoManifest
|
|
assignmentId: string
|
|
onAllCheckpointsPassed?: () => void
|
|
}
|
|
|
|
export default function InteractiveVideoPlayer({ manifest, assignmentId, onAllCheckpointsPassed }: Props) {
|
|
const videoRef = useRef<HTMLVideoElement>(null)
|
|
const [currentCheckpoint, setCurrentCheckpoint] = useState<CheckpointEntry | null>(null)
|
|
const [showOverlay, setShowOverlay] = useState(false)
|
|
const [answers, setAnswers] = useState<Record<number, number>>({})
|
|
const [quizResult, setQuizResult] = useState<CheckpointQuizResult | null>(null)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const [passedCheckpoints, setPassedCheckpoints] = useState<Set<string>>(new Set())
|
|
const [currentTime, setCurrentTime] = useState(0)
|
|
const [duration, setDuration] = useState(0)
|
|
|
|
// Initialize passed checkpoints from manifest progress
|
|
useEffect(() => {
|
|
const passed = new Set<string>()
|
|
for (const cp of manifest.checkpoints) {
|
|
if (cp.progress?.passed) {
|
|
passed.add(cp.checkpoint_id)
|
|
}
|
|
}
|
|
setPassedCheckpoints(passed)
|
|
}, [manifest])
|
|
|
|
// Find next unpassed checkpoint
|
|
const getNextUnpassedCheckpoint = useCallback((): CheckpointEntry | null => {
|
|
for (const cp of manifest.checkpoints) {
|
|
if (!passedCheckpoints.has(cp.checkpoint_id)) {
|
|
return cp
|
|
}
|
|
}
|
|
return null
|
|
}, [manifest.checkpoints, passedCheckpoints])
|
|
|
|
// Time update handler — check for checkpoint triggers
|
|
const handleTimeUpdate = useCallback(() => {
|
|
if (!videoRef.current || showOverlay) return
|
|
const time = videoRef.current.currentTime
|
|
setCurrentTime(time)
|
|
|
|
for (const cp of manifest.checkpoints) {
|
|
if (passedCheckpoints.has(cp.checkpoint_id)) continue
|
|
// Trigger checkpoint when video reaches its timestamp (within 0.5s)
|
|
if (time >= cp.timestamp_seconds && time < cp.timestamp_seconds + 1.0) {
|
|
videoRef.current.pause()
|
|
setCurrentCheckpoint(cp)
|
|
setShowOverlay(true)
|
|
setAnswers({})
|
|
setQuizResult(null)
|
|
break
|
|
}
|
|
}
|
|
}, [manifest.checkpoints, passedCheckpoints, showOverlay])
|
|
|
|
// Seek protection — prevent skipping past unpassed checkpoints
|
|
const handleSeeking = useCallback(() => {
|
|
if (!videoRef.current) return
|
|
const seekTarget = videoRef.current.currentTime
|
|
const nextUnpassed = getNextUnpassedCheckpoint()
|
|
if (nextUnpassed && seekTarget > nextUnpassed.timestamp_seconds) {
|
|
videoRef.current.currentTime = nextUnpassed.timestamp_seconds - 0.5
|
|
}
|
|
}, [getNextUnpassedCheckpoint])
|
|
|
|
// Submit checkpoint quiz
|
|
async function handleSubmitQuiz() {
|
|
if (!currentCheckpoint) return
|
|
setSubmitting(true)
|
|
try {
|
|
const answerList = currentCheckpoint.questions.map((_, i) => answers[i] ?? -1)
|
|
const result = await submitCheckpointQuiz(
|
|
currentCheckpoint.checkpoint_id,
|
|
assignmentId,
|
|
answerList,
|
|
)
|
|
setQuizResult(result)
|
|
|
|
if (result.passed) {
|
|
setPassedCheckpoints(prev => {
|
|
const next = new Set(prev)
|
|
next.add(currentCheckpoint.checkpoint_id)
|
|
return next
|
|
})
|
|
}
|
|
} catch (e) {
|
|
console.error('Checkpoint quiz submission failed:', e)
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
// Continue video after passing checkpoint
|
|
function handleContinue() {
|
|
setShowOverlay(false)
|
|
setCurrentCheckpoint(null)
|
|
setQuizResult(null)
|
|
setAnswers({})
|
|
if (videoRef.current) {
|
|
videoRef.current.play()
|
|
}
|
|
|
|
// Check if all checkpoints passed
|
|
const allPassed = manifest.checkpoints.every(cp => passedCheckpoints.has(cp.checkpoint_id))
|
|
if (allPassed && onAllCheckpointsPassed) {
|
|
onAllCheckpointsPassed()
|
|
}
|
|
}
|
|
|
|
// Retry quiz
|
|
function handleRetry() {
|
|
setQuizResult(null)
|
|
setAnswers({})
|
|
}
|
|
|
|
// Resume to last unpassed checkpoint
|
|
useEffect(() => {
|
|
if (!videoRef.current || !manifest.checkpoints.length) return
|
|
const nextUnpassed = getNextUnpassedCheckpoint()
|
|
if (nextUnpassed && nextUnpassed.timestamp_seconds > 0) {
|
|
// Start a bit before the checkpoint
|
|
const startTime = Math.max(0, nextUnpassed.timestamp_seconds - 2)
|
|
videoRef.current.currentTime = startTime
|
|
}
|
|
}, []) // Only on mount
|
|
|
|
const handleLoadedMetadata = useCallback(() => {
|
|
if (videoRef.current) {
|
|
setDuration(videoRef.current.duration)
|
|
}
|
|
}, [])
|
|
|
|
// Progress bar percentage
|
|
const progressPercent = duration > 0 ? (currentTime / duration) * 100 : 0
|
|
|
|
return (
|
|
<div className="relative bg-black rounded-lg overflow-hidden">
|
|
{/* Video element */}
|
|
<video
|
|
ref={videoRef}
|
|
className="w-full"
|
|
src={manifest.stream_url}
|
|
onTimeUpdate={handleTimeUpdate}
|
|
onSeeking={handleSeeking}
|
|
onLoadedMetadata={handleLoadedMetadata}
|
|
controls={!showOverlay}
|
|
/>
|
|
|
|
{/* Custom progress bar with checkpoint markers */}
|
|
<div className="relative h-2 bg-gray-700">
|
|
{/* Progress fill */}
|
|
<div
|
|
className="h-full bg-indigo-500 transition-all"
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
{/* Checkpoint markers */}
|
|
{manifest.checkpoints.map(cp => {
|
|
const pos = duration > 0 ? (cp.timestamp_seconds / duration) * 100 : 0
|
|
const isPassed = passedCheckpoints.has(cp.checkpoint_id)
|
|
return (
|
|
<div
|
|
key={cp.checkpoint_id}
|
|
className={`absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full border-2 border-white ${
|
|
isPassed ? 'bg-green-500' : 'bg-red-500'
|
|
}`}
|
|
style={{ left: `${pos}%` }}
|
|
title={`${cp.title} (${isPassed ? 'Bestanden' : 'Ausstehend'})`}
|
|
/>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Checkpoint overlay */}
|
|
{showOverlay && currentCheckpoint && (
|
|
<div className="absolute inset-0 bg-black/80 flex items-center justify-center p-6 overflow-y-auto">
|
|
<div className="bg-white rounded-xl p-6 max-w-2xl w-full max-h-[90%] overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center gap-3 mb-4 pb-3 border-b border-gray-200">
|
|
<div className="w-8 h-8 bg-red-100 rounded-full flex items-center justify-center">
|
|
<span className="text-red-600 font-bold text-sm">
|
|
{currentCheckpoint.index + 1}
|
|
</span>
|
|
</div>
|
|
<div>
|
|
<h3 className="font-semibold text-gray-900">Checkpoint: {currentCheckpoint.title}</h3>
|
|
<p className="text-xs text-gray-500">
|
|
Beantworten Sie die Fragen, um fortzufahren
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{quizResult ? (
|
|
/* Quiz result */
|
|
<div>
|
|
<div className={`text-center p-6 rounded-lg mb-4 ${
|
|
quizResult.passed ? 'bg-green-50 border border-green-200' : 'bg-red-50 border border-red-200'
|
|
}`}>
|
|
<div className="text-3xl mb-2">{quizResult.passed ? '\u2705' : '\u274C'}</div>
|
|
<h4 className="text-lg font-bold mb-1">
|
|
{quizResult.passed ? 'Checkpoint bestanden!' : 'Nicht bestanden'}
|
|
</h4>
|
|
<p className="text-sm text-gray-600">
|
|
Ergebnis: {Math.round(quizResult.score)}%
|
|
</p>
|
|
</div>
|
|
|
|
{/* Feedback */}
|
|
<div className="space-y-3 mb-4">
|
|
{quizResult.feedback.map((fb, i) => (
|
|
<div key={i} className={`p-3 rounded-lg text-sm ${
|
|
fb.correct ? 'bg-green-50 border-l-4 border-green-400' : 'bg-red-50 border-l-4 border-red-400'
|
|
}`}>
|
|
<p className="font-medium">{fb.question}</p>
|
|
{!fb.correct && (
|
|
<p className="text-gray-600 mt-1">{fb.explanation}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{quizResult.passed ? (
|
|
<button
|
|
onClick={handleContinue}
|
|
className="w-full px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
|
|
>
|
|
Video fortsetzen
|
|
</button>
|
|
) : (
|
|
<button
|
|
onClick={handleRetry}
|
|
className="w-full px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
|
>
|
|
Erneut versuchen
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Quiz questions */
|
|
<div>
|
|
<div className="space-y-4">
|
|
{currentCheckpoint.questions.map((q, qIdx) => (
|
|
<div key={qIdx} className="bg-gray-50 rounded-lg p-4">
|
|
<p className="font-medium text-gray-900 mb-3 text-sm">
|
|
<span className="text-indigo-600 mr-1">Frage {qIdx + 1}.</span>
|
|
{q.question}
|
|
</p>
|
|
<div className="space-y-2">
|
|
{q.options.map((opt, oIdx) => (
|
|
<label
|
|
key={oIdx}
|
|
className={`flex items-center gap-3 p-2.5 rounded-lg border cursor-pointer transition-colors text-sm ${
|
|
answers[qIdx] === oIdx
|
|
? 'border-indigo-500 bg-indigo-50'
|
|
: 'border-gray-200 hover:bg-white'
|
|
}`}
|
|
>
|
|
<input
|
|
type="radio"
|
|
name={`checkpoint-q-${qIdx}`}
|
|
checked={answers[qIdx] === oIdx}
|
|
onChange={() => setAnswers(prev => ({ ...prev, [qIdx]: oIdx }))}
|
|
className="text-indigo-600"
|
|
/>
|
|
<span className="text-gray-700">{opt}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<button
|
|
onClick={handleSubmitQuiz}
|
|
disabled={submitting || Object.keys(answers).length < currentCheckpoint.questions.length}
|
|
className="w-full mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
|
|
>
|
|
{submitting ? 'Wird ausgewertet...' : `Antworten absenden (${Object.keys(answers).length}/${currentCheckpoint.questions.length})`}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Checkpoint status bar */}
|
|
<div className="bg-gray-800 px-4 py-2 flex items-center gap-2 text-xs text-gray-300">
|
|
<span>Checkpoints:</span>
|
|
{manifest.checkpoints.map(cp => (
|
|
<span
|
|
key={cp.checkpoint_id}
|
|
className={`px-2 py-0.5 rounded-full ${
|
|
passedCheckpoints.has(cp.checkpoint_id)
|
|
? 'bg-green-700 text-green-100'
|
|
: 'bg-gray-600 text-gray-300'
|
|
}`}
|
|
>
|
|
{cp.title}
|
|
</span>
|
|
))}
|
|
{manifest.checkpoints.length > 0 && (
|
|
<span className="ml-auto">
|
|
{passedCheckpoints.size}/{manifest.checkpoints.length} bestanden
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|