feat(training+controls): interactive video pipeline, training blocks, control generator, CE libraries
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
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>
This commit is contained in:
@@ -9,14 +9,18 @@ import {
|
||||
createModule, updateModule, deleteModule,
|
||||
deleteMatrixEntry, setMatrixEntry,
|
||||
startAssignment, completeAssignment, updateAssignment,
|
||||
listBlockConfigs, createBlockConfig, deleteBlockConfig,
|
||||
previewBlock, generateBlock, getCanonicalMeta,
|
||||
generateInteractiveVideo,
|
||||
} from '@/lib/sdk/training/api'
|
||||
import type {
|
||||
TrainingModule, TrainingAssignment,
|
||||
MatrixResponse, TrainingStats, DeadlineInfo, AuditLogEntry, ModuleContent, TrainingMedia,
|
||||
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import {
|
||||
REGULATION_LABELS, REGULATION_COLORS, FREQUENCY_LABELS,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES,
|
||||
STATUS_LABELS, STATUS_COLORS, ROLE_LABELS, ALL_ROLES, TARGET_AUDIENCE_LABELS,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
@@ -41,6 +45,7 @@ export default function TrainingPage() {
|
||||
const [bulkGenerating, setBulkGenerating] = useState(false)
|
||||
const [bulkResult, setBulkResult] = useState<{ generated: number; skipped: number; errors: string[] } | null>(null)
|
||||
const [moduleMedia, setModuleMedia] = useState<TrainingMedia[]>([])
|
||||
const [interactiveGenerating, setInteractiveGenerating] = useState(false)
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [regulationFilter, setRegulationFilter] = useState<string>('')
|
||||
@@ -52,6 +57,15 @@ export default function TrainingPage() {
|
||||
const [selectedAssignment, setSelectedAssignment] = useState<TrainingAssignment | null>(null)
|
||||
const [escalationResult, setEscalationResult] = useState<{ total_checked: number; escalated: number } | null>(null)
|
||||
|
||||
// Block (Controls → Module) state
|
||||
const [blocks, setBlocks] = useState<TrainingBlockConfig[]>([])
|
||||
const [canonicalMeta, setCanonicalMeta] = useState<CanonicalControlMeta | null>(null)
|
||||
const [showBlockCreate, setShowBlockCreate] = useState(false)
|
||||
const [blockPreview, setBlockPreview] = useState<BlockPreview | null>(null)
|
||||
const [blockPreviewId, setBlockPreviewId] = useState<string>('')
|
||||
const [blockGenerating, setBlockGenerating] = useState(false)
|
||||
const [blockResult, setBlockResult] = useState<BlockGenerateResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
loadData()
|
||||
}, [])
|
||||
@@ -66,13 +80,15 @@ export default function TrainingPage() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes] = await Promise.allSettled([
|
||||
const [statsRes, modulesRes, matrixRes, assignmentsRes, deadlinesRes, auditRes, blocksRes, metaRes] = await Promise.allSettled([
|
||||
getStats(),
|
||||
getModules(),
|
||||
getMatrix(),
|
||||
getAssignments({ limit: 50 }),
|
||||
getDeadlines(10),
|
||||
getAuditLog({ limit: 30 }),
|
||||
listBlockConfigs(),
|
||||
getCanonicalMeta(),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||
@@ -81,6 +97,8 @@ export default function TrainingPage() {
|
||||
if (assignmentsRes.status === 'fulfilled') setAssignments(assignmentsRes.value.assignments)
|
||||
if (deadlinesRes.status === 'fulfilled') setDeadlines(deadlinesRes.value.deadlines)
|
||||
if (auditRes.status === 'fulfilled') setAuditLog(auditRes.value.entries)
|
||||
if (blocksRes.status === 'fulfilled') setBlocks(blocksRes.value.blocks)
|
||||
if (metaRes.status === 'fulfilled') setCanonicalMeta(metaRes.value)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Laden')
|
||||
} finally {
|
||||
@@ -114,6 +132,19 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateInteractiveVideo() {
|
||||
if (!selectedModuleId) return
|
||||
setInteractiveGenerating(true)
|
||||
try {
|
||||
await generateInteractiveVideo(selectedModuleId)
|
||||
await loadModuleMedia(selectedModuleId)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der interaktiven Video-Generierung')
|
||||
} finally {
|
||||
setInteractiveGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublishContent(contentId: string) {
|
||||
try {
|
||||
await publishContent(contentId)
|
||||
@@ -190,6 +221,59 @@ export default function TrainingPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Block handlers
|
||||
async function handleCreateBlock(data: {
|
||||
name: string; description?: string; domain_filter?: string; category_filter?: string;
|
||||
severity_filter?: string; target_audience_filter?: string; regulation_area: string;
|
||||
module_code_prefix: string; max_controls_per_module?: number;
|
||||
}) {
|
||||
try {
|
||||
await createBlockConfig(data)
|
||||
setShowBlockCreate(false)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteBlock(id: string) {
|
||||
if (!confirm('Block-Konfiguration wirklich loeschen?')) return
|
||||
try {
|
||||
await deleteBlockConfig(id)
|
||||
const res = await listBlockConfigs()
|
||||
setBlocks(res.blocks)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Loeschen')
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePreviewBlock(id: string) {
|
||||
setBlockPreviewId(id)
|
||||
setBlockPreview(null)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const preview = await previewBlock(id)
|
||||
setBlockPreview(preview)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler beim Preview')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerateBlock(id: string) {
|
||||
setBlockGenerating(true)
|
||||
setBlockResult(null)
|
||||
try {
|
||||
const result = await generateBlock(id, { language: 'de', auto_matrix: true })
|
||||
setBlockResult(result)
|
||||
await loadData()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Fehler bei der Block-Generierung')
|
||||
} finally {
|
||||
setBlockGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
const tabs: { id: Tab; label: string }[] = [
|
||||
{ id: 'overview', label: 'Uebersicht' },
|
||||
{ id: 'modules', label: 'Modulkatalog' },
|
||||
@@ -521,6 +605,228 @@ export default function TrainingPage() {
|
||||
|
||||
{activeTab === 'content' && (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Training Blocks — Controls → Schulungsmodule */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Schulungsbloecke aus Controls</h3>
|
||||
<p className="text-xs text-gray-500">
|
||||
Canonical Controls nach Kriterien filtern und automatisch Schulungsmodule generieren
|
||||
{canonicalMeta && <span className="ml-2 text-gray-400">({canonicalMeta.total} Controls verfuegbar)</span>}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowBlockCreate(true)}
|
||||
className="px-3 py-1.5 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700"
|
||||
>
|
||||
+ Neuen Block erstellen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Block list */}
|
||||
{blocks.length > 0 ? (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Name</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Domain</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Zielgruppe</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Severity</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Prefix</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-gray-600">Letzte Generierung</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-gray-600">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{blocks.map(block => (
|
||||
<tr key={block.id} className="hover:bg-gray-50">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-gray-900">{block.name}</div>
|
||||
{block.description && <div className="text-xs text-gray-500">{block.description}</div>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.domain_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.target_audience_filter ? (TARGET_AUDIENCE_LABELS[block.target_audience_filter] || block.target_audience_filter) : 'Alle'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{block.severity_filter || 'Alle'}</td>
|
||||
<td className="px-3 py-2"><code className="text-xs bg-gray-100 px-1.5 py-0.5 rounded">{block.module_code_prefix}</code></td>
|
||||
<td className="px-3 py-2 text-gray-500 text-xs">{block.last_generated_at ? new Date(block.last_generated_at).toLocaleString('de-DE') : 'Noch nie'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex gap-1 justify-end">
|
||||
<button
|
||||
onClick={() => handlePreviewBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200"
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleGenerateBlock(block.id)}
|
||||
disabled={blockGenerating}
|
||||
className="px-2 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50"
|
||||
>
|
||||
{blockGenerating ? 'Generiert...' : 'Generieren'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteBlock(block.id)}
|
||||
className="px-2 py-1 text-xs bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Loeschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-gray-500 text-sm">
|
||||
Noch keine Schulungsbloecke konfiguriert. Erstelle einen Block, um Controls automatisch in Module umzuwandeln.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview result */}
|
||||
{blockPreview && blockPreviewId && (
|
||||
<div className="mt-4 p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-blue-800 mb-2">Preview: {blocks.find(b => b.id === blockPreviewId)?.name}</h4>
|
||||
<div className="flex gap-6 text-sm mb-3">
|
||||
<span className="text-blue-700">Controls: <strong>{blockPreview.control_count}</strong></span>
|
||||
<span className="text-blue-700">Module: <strong>{blockPreview.module_count}</strong></span>
|
||||
<span className="text-blue-700">Rollen: <strong>{blockPreview.proposed_roles.map(r => ROLE_LABELS[r] || r).join(', ')}</strong></span>
|
||||
</div>
|
||||
{blockPreview.controls.length > 0 && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-blue-600 hover:text-blue-800">Passende Controls anzeigen ({blockPreview.control_count})</summary>
|
||||
<div className="mt-2 max-h-48 overflow-y-auto">
|
||||
{blockPreview.controls.slice(0, 50).map(ctrl => (
|
||||
<div key={ctrl.control_id} className="flex gap-2 py-1 border-b border-blue-100">
|
||||
<code className="text-xs bg-blue-100 px-1 rounded shrink-0">{ctrl.control_id}</code>
|
||||
<span className="text-gray-700 truncate">{ctrl.title}</span>
|
||||
<span className={`text-xs px-1.5 rounded shrink-0 ${ctrl.severity === 'critical' ? 'bg-red-100 text-red-700' : ctrl.severity === 'high' ? 'bg-orange-100 text-orange-700' : 'bg-gray-100 text-gray-600'}`}>{ctrl.severity}</span>
|
||||
</div>
|
||||
))}
|
||||
{blockPreview.control_count > 50 && <div className="text-gray-500 py-1">... und {blockPreview.control_count - 50} weitere</div>}
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Generate result */}
|
||||
{blockResult && (
|
||||
<div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<h4 className="text-sm font-medium text-green-800 mb-2">Generierung abgeschlossen</h4>
|
||||
<div className="flex gap-6 text-sm">
|
||||
<span className="text-green-700">Module erstellt: <strong>{blockResult.modules_created}</strong></span>
|
||||
<span className="text-green-700">Controls verknuepft: <strong>{blockResult.controls_linked}</strong></span>
|
||||
<span className="text-green-700">Matrix-Eintraege: <strong>{blockResult.matrix_entries_created}</strong></span>
|
||||
<span className="text-green-700">Content generiert: <strong>{blockResult.content_generated}</strong></span>
|
||||
</div>
|
||||
{blockResult.errors && blockResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">
|
||||
{blockResult.errors.map((err, i) => <div key={i}>{err}</div>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Block Create Modal */}
|
||||
{showBlockCreate && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">Neuen Schulungsblock erstellen</h3>
|
||||
<form onSubmit={e => {
|
||||
e.preventDefault()
|
||||
const fd = new FormData(e.currentTarget)
|
||||
handleCreateBlock({
|
||||
name: fd.get('name') as string,
|
||||
description: fd.get('description') as string || undefined,
|
||||
domain_filter: fd.get('domain_filter') as string || undefined,
|
||||
category_filter: fd.get('category_filter') as string || undefined,
|
||||
severity_filter: fd.get('severity_filter') as string || undefined,
|
||||
target_audience_filter: fd.get('target_audience_filter') as string || undefined,
|
||||
regulation_area: fd.get('regulation_area') as string,
|
||||
module_code_prefix: fd.get('module_code_prefix') as string,
|
||||
max_controls_per_module: parseInt(fd.get('max_controls_per_module') as string) || 20,
|
||||
})
|
||||
}} className="space-y-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Name *</label>
|
||||
<input name="name" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. Authentifizierung fuer Geschaeftsfuehrung" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Beschreibung</label>
|
||||
<textarea name="description" className="w-full px-3 py-2 text-sm border rounded-lg" rows={2} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Domain-Filter</label>
|
||||
<select name="domain_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Domains</option>
|
||||
{canonicalMeta?.domains.map(d => (
|
||||
<option key={d.domain} value={d.domain}>{d.domain} ({d.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Kategorie-Filter</label>
|
||||
<select name="category_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Kategorien</option>
|
||||
{canonicalMeta?.categories.filter(c => c.category !== 'uncategorized').map(c => (
|
||||
<option key={c.category} value={c.category}>{c.category} ({c.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Zielgruppe</label>
|
||||
<select name="target_audience_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle Zielgruppen</option>
|
||||
{canonicalMeta?.audiences.filter(a => a.audience !== 'unset').map(a => (
|
||||
<option key={a.audience} value={a.audience}>{TARGET_AUDIENCE_LABELS[a.audience] || a.audience} ({a.count})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Severity</label>
|
||||
<select name="severity_filter" className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
<option value="">Alle</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Regulierungsbereich *</label>
|
||||
<select name="regulation_area" required className="w-full px-3 py-2 text-sm border rounded-lg bg-white">
|
||||
{Object.entries(REGULATION_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul-Code-Prefix *</label>
|
||||
<input name="module_code_prefix" required className="w-full px-3 py-2 text-sm border rounded-lg" placeholder="z.B. CB-AUTH" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-gray-600 block mb-1">Max. Controls pro Modul</label>
|
||||
<input name="max_controls_per_module" type="number" defaultValue={20} min={1} max={50} className="w-full px-3 py-2 text-sm border rounded-lg" />
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="submit" className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">Erstellen</button>
|
||||
<button type="button" onClick={() => setShowBlockCreate(false)} className="px-4 py-2 text-sm bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200">Abbrechen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Generation */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">Bulk-Generierung</h3>
|
||||
@@ -620,6 +926,35 @@ export default function TrainingPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Interactive Video */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-gray-700">Interaktives Video</h3>
|
||||
<p className="text-xs text-gray-500">Video mit Narrator-Persona und Checkpoint-Quizzes</p>
|
||||
</div>
|
||||
{moduleMedia.some(m => m.media_type === 'interactive_video' && m.status === 'completed') ? (
|
||||
<span className="px-3 py-1.5 text-xs bg-purple-100 text-purple-700 rounded-full">Interaktiv erstellt</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleGenerateInteractiveVideo}
|
||||
disabled={interactiveGenerating}
|
||||
className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
|
||||
>
|
||||
{interactiveGenerating ? 'Generiere interaktives Video...' : 'Interaktives Video generieren'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{moduleMedia.filter(m => m.media_type === 'interactive_video' && m.status === 'completed').map(m => (
|
||||
<div key={m.id} className="text-xs text-gray-500 space-y-1 bg-gray-50 rounded p-3">
|
||||
<p>Dauer: {Math.round(m.duration_seconds / 60)} Min | Groesse: {(m.file_size_bytes / 1024 / 1024).toFixed(1)} MB</p>
|
||||
<p>Generiert: {new Date(m.created_at).toLocaleString('de-DE')}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Script Preview */}
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
|
||||
Reference in New Issue
Block a user