refactor(admin): split controls, training, control-provenance, iace/verification pages
Each page.tsx exceeded the 500-LOC hard cap. Extracted components and hooks into colocated _components/ and _hooks/ directories; page.tsx is now a thin orchestrator. - controls/page.tsx: 944 → 180 LOC; extracted ControlCard, AddControlForm, LoadingSkeleton, TransitionErrorBanner, StatsCards, FilterBar, RAGPanel into _components/ and useControlsData, useRAGSuggestions into _hooks/; types into _types.ts - training/page.tsx: 780 → 288 LOC; extracted ContentTab (inline content generator tab) into _components/ContentTab.tsx - control-provenance/page.tsx: 739 → 122 LOC; extracted MarkdownRenderer, UsageBadge, PermBadge, LicenseMatrix, SourceRegistry into _components/; PROVENANCE_SECTIONS static data into _data/provenance-sections.ts - iace/[projectId]/verification/page.tsx: 673 → 196 LOC; extracted StatusBadge, VerificationForm, CompleteModal, SuggestEvidenceModal, VerificationTable into _components/ Zero behavior changes; logic relocated verbatim. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
392
admin-compliance/app/sdk/training/_components/ContentTab.tsx
Normal file
392
admin-compliance/app/sdk/training/_components/ContentTab.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
'use client'
|
||||
|
||||
import AudioPlayer from '@/components/training/AudioPlayer'
|
||||
import VideoPlayer from '@/components/training/VideoPlayer'
|
||||
import ScriptPreview from '@/components/training/ScriptPreview'
|
||||
import type {
|
||||
TrainingModule, ModuleContent, TrainingMedia,
|
||||
TrainingBlockConfig, CanonicalControlMeta, BlockPreview, BlockGenerateResult,
|
||||
} from '@/lib/sdk/training/types'
|
||||
import { TARGET_AUDIENCE_LABELS, ROLE_LABELS, REGULATION_LABELS } from '@/lib/sdk/training/types'
|
||||
|
||||
export function ContentTab({
|
||||
modules,
|
||||
blocks,
|
||||
canonicalMeta,
|
||||
selectedModuleId,
|
||||
onSelectedModuleIdChange,
|
||||
generatedContent,
|
||||
generating,
|
||||
bulkGenerating,
|
||||
bulkResult,
|
||||
moduleMedia,
|
||||
interactiveGenerating,
|
||||
blockPreview,
|
||||
blockPreviewId,
|
||||
blockGenerating,
|
||||
blockResult,
|
||||
showBlockCreate,
|
||||
onShowBlockCreate,
|
||||
onGenerateContent,
|
||||
onGenerateQuiz,
|
||||
onGenerateInteractiveVideo,
|
||||
onPublishContent,
|
||||
onBulkContent,
|
||||
onBulkQuiz,
|
||||
onPreviewBlock,
|
||||
onGenerateBlock,
|
||||
onDeleteBlock,
|
||||
onCreateBlock,
|
||||
}: {
|
||||
modules: TrainingModule[]
|
||||
blocks: TrainingBlockConfig[]
|
||||
canonicalMeta: CanonicalControlMeta | null
|
||||
selectedModuleId: string
|
||||
onSelectedModuleIdChange: (id: string) => void
|
||||
generatedContent: ModuleContent | null
|
||||
generating: boolean
|
||||
bulkGenerating: boolean
|
||||
bulkResult: { generated: number; skipped: number; errors: string[] } | null
|
||||
moduleMedia: TrainingMedia[]
|
||||
interactiveGenerating: boolean
|
||||
blockPreview: BlockPreview | null
|
||||
blockPreviewId: string
|
||||
blockGenerating: boolean
|
||||
blockResult: BlockGenerateResult | null
|
||||
showBlockCreate: boolean
|
||||
onShowBlockCreate: (show: boolean) => void
|
||||
onGenerateContent: () => void
|
||||
onGenerateQuiz: () => void
|
||||
onGenerateInteractiveVideo: () => void
|
||||
onPublishContent: (id: string) => void
|
||||
onBulkContent: () => void
|
||||
onBulkQuiz: () => void
|
||||
onPreviewBlock: (id: string) => void
|
||||
onGenerateBlock: (id: string) => void
|
||||
onDeleteBlock: (id: string) => void
|
||||
onCreateBlock: (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;
|
||||
}) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Training Blocks */}
|
||||
<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={() => onShowBlockCreate(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>
|
||||
|
||||
{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={() => onPreviewBlock(block.id)} className="px-2 py-1 text-xs bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Preview</button>
|
||||
<button onClick={() => onGenerateBlock(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={() => onDeleteBlock(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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
|
||||
{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)
|
||||
onCreateBlock({
|
||||
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={() => onShowBlockCreate(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>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Inhalte und Quiz-Fragen fuer alle Module auf einmal</p>
|
||||
<div className="flex gap-3">
|
||||
<button onClick={onBulkContent} disabled={bulkGenerating} className="px-4 py-2 text-sm bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50">
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Inhalte generieren'}
|
||||
</button>
|
||||
<button onClick={onBulkQuiz} disabled={bulkGenerating} className="px-4 py-2 text-sm bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50">
|
||||
{bulkGenerating ? 'Generiere...' : 'Alle Quizfragen generieren'}
|
||||
</button>
|
||||
</div>
|
||||
{bulkResult && (
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg text-sm">
|
||||
<div className="flex gap-6">
|
||||
<span className="text-green-700">Generiert: {bulkResult.generated}</span>
|
||||
<span className="text-gray-500">Uebersprungen: {bulkResult.skipped}</span>
|
||||
{bulkResult.errors?.length > 0 && <span className="text-red-600">Fehler: {bulkResult.errors.length}</span>}
|
||||
</div>
|
||||
{bulkResult.errors?.length > 0 && (
|
||||
<div className="mt-2 text-xs text-red-600">{bulkResult.errors.map((err, i) => <div key={i}>{err}</div>)}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* LLM Content Generator */}
|
||||
<div className="bg-white border rounded-lg p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 mb-3">LLM-Content-Generator</h3>
|
||||
<p className="text-xs text-gray-500 mb-4">Generiere Schulungsinhalte und Quiz-Fragen automatisch via KI</p>
|
||||
<div className="flex gap-3 items-end">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs text-gray-600 block mb-1">Modul auswaehlen</label>
|
||||
<select
|
||||
value={selectedModuleId}
|
||||
onChange={e => onSelectedModuleIdChange(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border rounded-lg bg-white"
|
||||
>
|
||||
<option value="">Modul waehlen...</option>
|
||||
{modules.map(m => <option key={m.id} value={m.id}>{m.module_code} - {m.title}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<button onClick={onGenerateContent} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Inhalt generieren'}
|
||||
</button>
|
||||
<button onClick={onGenerateQuiz} disabled={!selectedModuleId || generating} className="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50">
|
||||
{generating ? 'Generiere...' : 'Quiz generieren'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedContent && (
|
||||
<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">Generierter Inhalt (v{generatedContent.version})</h3>
|
||||
<p className="text-xs text-gray-500">Generiert von: {generatedContent.generated_by} ({generatedContent.llm_model})</p>
|
||||
</div>
|
||||
{!generatedContent.is_published ? (
|
||||
<button onClick={() => onPublishContent(generatedContent.id)} className="px-3 py-1.5 text-xs bg-green-600 text-white rounded hover:bg-green-700">Veroeffentlichen</button>
|
||||
) : (
|
||||
<span className="px-3 py-1.5 text-xs bg-green-100 text-green-700 rounded">Veroeffentlicht</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="prose prose-sm max-w-none border rounded p-4 bg-gray-50 max-h-96 overflow-y-auto">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800">{generatedContent.content_body}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<AudioPlayer
|
||||
moduleId={selectedModuleId}
|
||||
audio={moduleMedia.find(m => m.media_type === 'audio') || null}
|
||||
onMediaUpdate={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<VideoPlayer
|
||||
moduleId={selectedModuleId}
|
||||
video={moduleMedia.find(m => m.media_type === 'video') || null}
|
||||
onMediaUpdate={() => {}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{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={onGenerateInteractiveVideo} 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>
|
||||
)}
|
||||
|
||||
{selectedModuleId && generatedContent?.is_published && (
|
||||
<ScriptPreview moduleId={selectedModuleId} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user