Files
breakpilot-compliance/admin-compliance/components/training/InteractiveVideoPlayer.tsx
Benjamin Admin 4f6bc8f6f6
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
feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
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>
2026-03-16 21:41:48 +01:00

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>
)
}