[split-required] Split website + studio-v2 monoliths (Phase 3 continued)

Website (14 monoliths split):
- compliance/page.tsx (1,519 → 9), docs/audit (1,262 → 20)
- quality (1,231 → 16), alerts (1,203 → 10), docs (1,202 → 11)
- i18n.ts (1,173 → 8 language files)
- unity-bridge (1,094 → 12), backlog (1,087 → 6)
- training (1,066 → 8), rag (1,063 → 8)
- Deleted index_original.ts (4,899 LOC dead backup)

Studio-v2 (5 monoliths split):
- meet/page.tsx (1,481 → 9), messages (1,166 → 9)
- AlertsB2BContext.tsx (1,165 → 5 modules)
- alerts-b2b/page.tsx (1,019 → 6), korrektur/archiv (1,001 → 6)

All existing imports preserved. Zero new TypeScript errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-24 17:52:36 +02:00
parent b681ddb131
commit 0b37c5e692
143 changed files with 15822 additions and 15889 deletions

View File

@@ -0,0 +1,92 @@
'use client'
import type { AnalyticsOverview } from './types'
import { StatCard } from './StatCard'
export function AnalyticsTab({
analyticsOverview,
isLoadingAnalytics,
onFetchAnalytics,
}: {
analyticsOverview: AnalyticsOverview | null
isLoadingAnalytics: boolean
onFetchAnalytics: () => void
}) {
return (
<div className="space-y-6">
{/* Analytics Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Learning Analytics</h2>
<button
onClick={onFetchAnalytics}
disabled={isLoadingAnalytics}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoadingAnalytics ? 'Laden...' : 'Aktualisieren'}
</button>
</div>
{analyticsOverview ? (
<>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard title="Sessions" value={analyticsOverview.total_sessions} color="blue" />
<StatCard title="Schüler" value={analyticsOverview.unique_students} color="green" />
<StatCard
title="Ø Abschluss"
value={`${Math.round(analyticsOverview.avg_completion_rate * 100)}%`}
color={analyticsOverview.avg_completion_rate > 0.7 ? 'green' : 'yellow'}
/>
<StatCard
title="Ø Learning Gain"
value={analyticsOverview.avg_learning_gain !== null
? `${Math.round(analyticsOverview.avg_learning_gain * 100)}%`
: '-'
}
color={analyticsOverview.avg_learning_gain && analyticsOverview.avg_learning_gain > 0.1 ? 'green' : 'gray'}
/>
</div>
{/* Most Played Units */}
{analyticsOverview.most_played_units.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-semibold text-gray-900 mb-3">Meistgespielte Units</h3>
<div className="space-y-2">
{analyticsOverview.most_played_units.map((item, i) => (
<div key={i} className="flex items-center justify-between p-2 bg-gray-50 rounded">
<span className="text-sm text-gray-900">{item.unit_id}</span>
<span className="text-sm font-medium text-gray-600">{item.count} Sessions</span>
</div>
))}
</div>
</div>
)}
{/* Struggling Concepts */}
{analyticsOverview.struggling_concepts.length > 0 && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-semibold text-gray-900 mb-3">Schwierige Konzepte</h3>
<div className="space-y-2">
{analyticsOverview.struggling_concepts.map((item, i) => (
<div key={i} className="flex items-center justify-between p-2 bg-red-50 rounded">
<span className="text-sm text-gray-900">{item.concept}</span>
<span className="text-sm font-medium text-red-600">{item.count} Fehler</span>
</div>
))}
</div>
</div>
)}
</>
) : (
<div className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
<p className="text-gray-500">
{isLoadingAnalytics ? 'Lade Analytics...' : 'Keine Analytics-Daten verfügbar'}
</p>
<p className="text-sm text-gray-400 mt-2">
Analytics werden nach abgeschlossenen Sessions verfügbar.
</p>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,89 @@
'use client'
import { useState } from 'react'
import type { LogEntry } from './types'
function LogEntryRow({ log }: { log: LogEntry }) {
const [expanded, setExpanded] = useState(false)
const typeColors = {
error: 'bg-red-100 text-red-800',
exception: 'bg-red-100 text-red-800',
warning: 'bg-yellow-100 text-yellow-800',
info: 'bg-blue-100 text-blue-800',
}
const typeColor = typeColors[log.type as keyof typeof typeColors] || 'bg-gray-100 text-gray-800'
return (
<div
className="border-b border-gray-100 py-2 px-3 hover:bg-gray-50 cursor-pointer"
onClick={() => setExpanded(!expanded)}
>
<div className="flex items-start gap-3">
<span className="text-xs text-gray-400 font-mono whitespace-nowrap">{log.time}</span>
<span className={`text-xs px-2 py-0.5 rounded ${typeColor} font-medium uppercase`}>
{log.type}
</span>
<span className="text-sm text-gray-700 flex-1 break-all">
{log.message.length > 150 && !expanded
? log.message.substring(0, 150) + '...'
: log.message}
</span>
</div>
{expanded && log.stack && (
<pre className="mt-2 text-xs bg-gray-900 text-gray-100 p-2 rounded overflow-x-auto">
{log.stack}
</pre>
)}
</div>
)
}
export function ConsoleLogPanel({
logs,
onRefresh,
onClear,
isLoading,
}: {
logs: LogEntry[]
onRefresh: () => void
onClear: () => void
isLoading: boolean
}) {
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Console Logs</h3>
<div className="flex gap-2">
<button
onClick={onClear}
className="px-3 py-1.5 text-sm text-gray-600 hover:text-gray-900 hover:bg-gray-100 rounded transition-colors"
>
Clear
</button>
<button
onClick={onRefresh}
disabled={isLoading}
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoading ? 'Laden...' : 'Aktualisieren'}
</button>
</div>
</div>
<div className="max-h-96 overflow-y-auto">
{logs.length === 0 ? (
<div className="p-8 text-center text-gray-500">
<svg className="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>Keine Logs vorhanden</p>
<p className="text-sm mt-1">Logs erscheinen, wenn Unity Nachrichten generiert</p>
</div>
) : (
logs.map((log, index) => <LogEntryRow key={index} log={log} />)
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,133 @@
'use client'
import type { UnitDefinition, GeneratedContent } from './types'
export function ContentTab({
units,
selectedUnit,
generatedContent,
isGenerating,
onSelectUnit,
onFetchUnits,
onGenerateH5P,
onGenerateWorksheet,
onDownloadPdf,
onClearContent,
}: {
units: UnitDefinition[]
selectedUnit: UnitDefinition | null
generatedContent: GeneratedContent | null
isGenerating: boolean
onSelectUnit: (unit: UnitDefinition) => void
onFetchUnits: () => void
onGenerateH5P: (unitId: string) => void
onGenerateWorksheet: (unitId: string) => void
onDownloadPdf: (unitId: string) => void
onClearContent: () => void
}) {
return (
<div className="space-y-6">
<h2 className="text-lg font-semibold text-gray-900">Content Generator</h2>
{/* Unit Selector */}
{units.length > 0 ? (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-medium text-gray-900 mb-3">Unit auswählen</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
{units.map((unit) => (
<button
key={unit.unit_id}
onClick={() => onSelectUnit(unit)}
className={`
p-3 text-left rounded-lg border transition-colors
${selectedUnit?.unit_id === unit.unit_id
? 'border-primary-500 bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}
`}
>
<span className="font-medium text-gray-900">{unit.unit_id}</span>
<span className="text-xs text-gray-500 ml-2">{unit.template}</span>
</button>
))}
</div>
</div>
) : (
<button
onClick={onFetchUnits}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700"
>
Units laden
</button>
)}
{/* Generate Buttons */}
{selectedUnit && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<h3 className="font-medium text-gray-900 mb-3">
Content generieren für: <span className="text-primary-600">{selectedUnit.unit_id}</span>
</h3>
<div className="flex flex-wrap gap-3">
<button
onClick={() => onGenerateH5P(selectedUnit.unit_id)}
disabled={isGenerating}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
H5P generieren
</button>
<button
onClick={() => onGenerateWorksheet(selectedUnit.unit_id)}
disabled={isGenerating}
className="px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 disabled:opacity-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
Arbeitsblatt HTML
</button>
<button
onClick={() => onDownloadPdf(selectedUnit.unit_id)}
disabled={isGenerating}
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
PDF Download
</button>
</div>
</div>
)}
{/* Generated Content Preview */}
{generatedContent && (
<div className="bg-white rounded-lg border border-gray-200 p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-medium text-gray-900">Generierter Content</h3>
<button
onClick={onClearContent}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{generatedContent.html ? (
<div
className="prose prose-sm max-w-none p-4 bg-gray-50 rounded-lg border"
dangerouslySetInnerHTML={{ __html: generatedContent.html }}
/>
) : (
<pre className="p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto max-h-96">
{JSON.stringify(generatedContent, null, 2)}
</pre>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,60 @@
'use client'
import type { DiagnosticEntry } from './types'
export function DiagnosticPanel({
diagnostics,
isLoading,
onRun,
}: {
diagnostics: DiagnosticEntry[]
isLoading: boolean
onRun: () => void
}) {
const severityColors = {
ok: 'text-green-600 bg-green-50',
warning: 'text-yellow-600 bg-yellow-50',
error: 'text-red-600 bg-red-50',
}
const severityIcons = {
ok: '✓',
warning: '⚠',
error: '✕',
}
return (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm">
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200">
<h3 className="font-semibold text-gray-900">Diagnostik</h3>
<button
onClick={onRun}
disabled={isLoading}
className="px-3 py-1.5 text-sm bg-primary-600 text-white rounded hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoading ? 'Prüfe...' : 'Diagnose starten'}
</button>
</div>
<div className="p-4">
{diagnostics.length === 0 ? (
<p className="text-gray-500 text-center py-4">
Klicke auf &quot;Diagnose starten&quot; um die Szene zu prüfen
</p>
) : (
<div className="space-y-2">
{diagnostics.map((d, index) => (
<div
key={index}
className={`flex items-center gap-3 p-2 rounded ${severityColors[d.severity]}`}
>
<span className="font-bold">{severityIcons[d.severity]}</span>
<span className="text-sm font-medium">{d.category}</span>
<span className="text-sm flex-1">{d.message}</span>
</div>
))}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,132 @@
'use client'
import GameView from '@/components/admin/GameView'
import type { BridgeStatus, LogEntry, DiagnosticEntry } from './types'
import { StatCard } from './StatCard'
import { ConsoleLogPanel } from './ConsoleLogPanel'
import { DiagnosticPanel } from './DiagnosticPanel'
export function EditorTab({
status,
logs,
diagnostics,
isLoadingLogs,
isLoadingDiagnose,
error,
onSendCommand,
onFetchLogs,
onClearLogs,
onRunDiagnose,
}: {
status: BridgeStatus | null
logs: LogEntry[]
diagnostics: DiagnosticEntry[]
isLoadingLogs: boolean
isLoadingDiagnose: boolean
error: string | null
onSendCommand: (command: string) => void
onFetchLogs: () => void
onClearLogs: () => void
onRunDiagnose: () => void
}) {
return (
<>
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<StatCard
title="Fehler"
value={status?.errors ?? '-'}
color={status?.errors && status.errors > 0 ? 'red' : 'green'}
/>
<StatCard
title="Warnungen"
value={status?.warnings ?? '-'}
color={status?.warnings && status.warnings > 0 ? 'yellow' : 'green'}
/>
<StatCard title="Szene" value={status?.scene ?? '-'} color="blue" />
<StatCard
title="Play Mode"
value={status?.is_playing ? 'Aktiv' : 'Inaktiv'}
color={status?.is_playing ? 'green' : 'gray'}
/>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-4 mb-6">
<h3 className="font-semibold text-gray-900 mb-3">Quick Actions</h3>
<div className="flex flex-wrap gap-2">
<button
onClick={() => onSendCommand('play')}
disabled={!status || status.is_playing}
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
Play
</button>
<button
onClick={() => onSendCommand('stop')}
disabled={!status || !status.is_playing}
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors flex items-center gap-2"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<rect x="6" y="6" width="12" height="12" />
</svg>
Stop
</button>
<button
onClick={() => onSendCommand('quicksetup')}
disabled={!status || status.is_playing}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Quick Setup
</button>
</div>
</div>
{/* Game View */}
<div className="mb-6">
<GameView
isUnityOnline={!!status && !error}
isPlaying={status?.is_playing}
/>
</div>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ConsoleLogPanel
logs={logs}
onRefresh={onFetchLogs}
onClear={onClearLogs}
isLoading={isLoadingLogs}
/>
<DiagnosticPanel
diagnostics={diagnostics}
isLoading={isLoadingDiagnose}
onRun={onRunDiagnose}
/>
</div>
{/* API Info */}
<div className="mt-6 bg-slate-50 rounded-lg border border-slate-200 p-4">
<h3 className="font-semibold text-slate-900 mb-2">API Endpoints</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-2 text-sm font-mono">
<code className="bg-slate-100 px-2 py-1 rounded">GET /status</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /logs/errors</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /scene</code>
<code className="bg-slate-100 px-2 py-1 rounded">POST /diagnose</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /play</code>
<code className="bg-slate-100 px-2 py-1 rounded">GET /stop</code>
<code className="bg-blue-100 text-blue-800 px-2 py-1 rounded">GET /screenshot</code>
<code className="bg-blue-100 text-blue-800 px-2 py-1 rounded">GET /stream/start</code>
<code className="bg-blue-100 text-blue-800 px-2 py-1 rounded">GET /stream/frame</code>
</div>
<p className="text-sm text-slate-600 mt-3">
Basis-URL: <code className="bg-slate-100 px-1 rounded">http://localhost:8090</code>
<span className="text-blue-600 ml-2">(Streaming-Endpoints blau markiert)</span>
</p>
</div>
</>
)
}

View File

@@ -0,0 +1,31 @@
'use client'
export function StatCard({
title,
value,
color = 'gray',
icon,
}: {
title: string
value: string | number
color?: 'red' | 'yellow' | 'green' | 'blue' | 'gray'
icon?: React.ReactNode
}) {
const colorClasses = {
red: 'bg-red-50 border-red-200 text-red-700',
yellow: 'bg-yellow-50 border-yellow-200 text-yellow-700',
green: 'bg-green-50 border-green-200 text-green-700',
blue: 'bg-blue-50 border-blue-200 text-blue-700',
gray: 'bg-gray-50 border-gray-200 text-gray-700',
}
return (
<div className={`rounded-lg border p-4 ${colorClasses[color]}`}>
<div className="flex items-center justify-between">
<p className="text-sm font-medium opacity-80">{title}</p>
{icon}
</div>
<p className="text-2xl font-bold mt-1">{value}</p>
</div>
)
}

View File

@@ -0,0 +1,39 @@
'use client'
import type { BridgeStatus } from './types'
export function StatusBadge({ status, error }: { status: BridgeStatus | null; error: string | null }) {
if (error) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-100 text-red-800 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-red-500 rounded-full animate-pulse" />
Offline
</div>
)
}
if (!status) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-gray-100 text-gray-600 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-gray-400 rounded-full animate-pulse" />
Verbinde...
</div>
)
}
if (status.is_compiling) {
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-100 text-yellow-800 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-yellow-500 rounded-full animate-pulse" />
Kompiliert...
</div>
)
}
return (
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-100 text-green-800 rounded-full text-sm font-medium">
<span className="w-2 h-2 bg-green-500 rounded-full" />
Online - Port 8090
</div>
)
}

View File

@@ -0,0 +1,145 @@
'use client'
import type { UnitDefinition } from './types'
export function UnitsTab({
units,
selectedUnit,
isLoadingUnits,
unitsError,
onFetchUnits,
onFetchUnitDetails,
onClearSelection,
}: {
units: UnitDefinition[]
selectedUnit: UnitDefinition | null
isLoadingUnits: boolean
unitsError: string | null
onFetchUnits: () => void
onFetchUnitDetails: (unitId: string) => void
onClearSelection: () => void
}) {
return (
<div className="space-y-6">
{/* Units Header */}
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-gray-900">Unit-Definitionen</h2>
<button
onClick={onFetchUnits}
disabled={isLoadingUnits}
className="px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 disabled:opacity-50 transition-colors"
>
{isLoadingUnits ? 'Laden...' : 'Aktualisieren'}
</button>
</div>
{/* Units Error */}
{unitsError && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<p className="text-red-800">{unitsError}</p>
<p className="text-sm text-red-600 mt-1">
Backend starten: <code className="bg-red-100 px-1 rounded">cd backend && python main.py</code>
</p>
</div>
)}
{/* Units Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{units.map((unit) => (
<div
key={unit.unit_id}
className={`
p-4 bg-white rounded-lg border-2 cursor-pointer transition-all
${selectedUnit?.unit_id === unit.unit_id
? 'border-primary-500 shadow-md'
: 'border-gray-200 hover:border-gray-300'
}
`}
onClick={() => onFetchUnitDetails(unit.unit_id)}
>
<div className="flex items-start justify-between mb-2">
<h3 className="font-semibold text-gray-900">{unit.unit_id}</h3>
<span className={`
px-2 py-0.5 text-xs rounded-full
${unit.template === 'flight_path' ? 'bg-blue-100 text-blue-700' : 'bg-purple-100 text-purple-700'}
`}>
{unit.template}
</span>
</div>
<p className="text-sm text-gray-600 mb-2">{unit.topic || unit.subject || 'Keine Beschreibung'}</p>
<div className="flex items-center gap-4 text-xs text-gray-500">
<span>{unit.duration_minutes} min</span>
<span>{unit.difficulty}</span>
<span>{unit.grade_band?.join(', ') || '-'}</span>
</div>
</div>
))}
</div>
{/* Empty State */}
{!isLoadingUnits && units.length === 0 && !unitsError && (
<div className="text-center py-12 text-gray-500">
<p>Keine Units gefunden</p>
<p className="text-sm mt-1">Units werden unter <code className="bg-gray-100 px-1 rounded">backend/data/units/</code> gespeichert</p>
</div>
)}
{/* Selected Unit Details */}
{selectedUnit && (
<div className="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">{selectedUnit.unit_id}</h3>
<button
onClick={onClearSelection}
className="text-gray-400 hover:text-gray-600"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Learning Objectives */}
{selectedUnit.learning_objectives && selectedUnit.learning_objectives.length > 0 && (
<div className="mb-4">
<h4 className="text-sm font-medium text-gray-700 mb-2">Lernziele</h4>
<ul className="list-disc list-inside text-sm text-gray-600 space-y-1">
{selectedUnit.learning_objectives.map((obj, i) => (
<li key={i}>{obj}</li>
))}
</ul>
</div>
)}
{/* Stops */}
{selectedUnit.stops && selectedUnit.stops.length > 0 && (
<div>
<h4 className="text-sm font-medium text-gray-700 mb-2">Stops ({selectedUnit.stops.length})</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-2">
{selectedUnit.stops.map((stop) => (
<div key={stop.stop_id} className="flex items-center gap-2 p-2 bg-gray-50 rounded text-sm">
<span className="w-6 h-6 flex items-center justify-center bg-primary-100 text-primary-700 rounded-full text-xs font-medium">
{stop.order + 1}
</span>
<span className="text-gray-900">{stop.label?.['de-DE'] || stop.stop_id}</span>
{stop.interaction && (
<span className="text-xs text-gray-500">({stop.interaction.type})</span>
)}
</div>
))}
</div>
</div>
)}
{/* JSON Preview */}
<details className="mt-4">
<summary className="text-sm font-medium text-gray-700 cursor-pointer">JSON anzeigen</summary>
<pre className="mt-2 p-4 bg-gray-900 text-gray-100 rounded-lg text-xs overflow-x-auto max-h-96">
{JSON.stringify(selectedUnit, null, 2)}
</pre>
</details>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,49 @@
import type { Tab } from './types'
export const tabs: Tab[] = [
{
id: 'editor',
label: 'Editor',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
},
{
id: 'units',
label: 'Units',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10" />
</svg>
),
},
{
id: 'sessions',
label: 'Sessions',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
),
},
{
id: 'analytics',
label: 'Analytics',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
},
{
id: 'content',
label: 'Content',
icon: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
]

View File

@@ -0,0 +1,92 @@
// Tab definitions
export type TabId = 'editor' | 'units' | 'sessions' | 'analytics' | 'content'
export interface Tab {
id: TabId
label: string
icon: React.ReactNode
}
// Type definitions
export interface BridgeStatus {
status: string
unity_version: string
project: string
scene: string
is_playing: boolean
is_compiling: boolean
errors: number
warnings: number
}
// Unit System types
export interface UnitDefinition {
unit_id: string
template: string
version: string
locale: string[]
grade_band: string[]
duration_minutes: number
difficulty: string
subject?: string
topic?: string
learning_objectives?: string[]
stops?: UnitStop[]
}
export interface UnitStop {
stop_id: string
order: number
label: { [key: string]: string }
interaction?: {
type: string
params?: Record<string, unknown>
}
}
export interface AnalyticsOverview {
time_range: string
total_sessions: number
unique_students: number
avg_completion_rate: number
avg_learning_gain: number | null
most_played_units: Array<{ unit_id: string; count: number }>
struggling_concepts: Array<{ concept: string; count: number }>
active_classes: number
}
export interface GeneratedContent {
unit_id: string
locale: string
generated_count?: number
html?: string
title?: string
}
export interface LogEntry {
time: string
type: string
message: string
frame: number
stack?: string
}
export interface LogsResponse {
count: number
total_errors: number
total_warnings: number
total_info: number
logs: LogEntry[]
}
export interface DiagnosticEntry {
category: string
severity: 'ok' | 'warning' | 'error'
message: string
}
export interface DiagnoseResponse {
diagnostics: DiagnosticEntry[]
errors: number
warnings: number
}

View File

@@ -0,0 +1,258 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import type {
BridgeStatus,
LogEntry,
DiagnosticEntry,
UnitDefinition,
AnalyticsOverview,
GeneratedContent,
LogsResponse,
DiagnoseResponse,
TabId,
} from './types'
export function useUnityBridge() {
// Tab state
const [activeTab, setActiveTab] = useState<TabId>('editor')
// Editor tab state
const [status, setStatus] = useState<BridgeStatus | null>(null)
const [logs, setLogs] = useState<LogEntry[]>([])
const [diagnostics, setDiagnostics] = useState<DiagnosticEntry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isLoadingLogs, setIsLoadingLogs] = useState(false)
const [isLoadingDiagnose, setIsLoadingDiagnose] = useState(false)
const [error, setError] = useState<string | null>(null)
// Units tab state
const [units, setUnits] = useState<UnitDefinition[]>([])
const [selectedUnit, setSelectedUnit] = useState<UnitDefinition | null>(null)
const [isLoadingUnits, setIsLoadingUnits] = useState(false)
const [unitsError, setUnitsError] = useState<string | null>(null)
// Analytics tab state
const [analyticsOverview, setAnalyticsOverview] = useState<AnalyticsOverview | null>(null)
const [isLoadingAnalytics, setIsLoadingAnalytics] = useState(false)
// Content tab state
const [generatedContent, setGeneratedContent] = useState<GeneratedContent | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
// Fetch status
const fetchStatus = useCallback(async () => {
try {
const res = await fetch('/api/admin/unity-bridge?action=status')
if (res.ok) {
const data = await res.json()
if (!data.offline) {
setStatus(data)
setError(null)
} else {
setError(data.error)
setStatus(null)
}
} else {
setError('Bridge nicht erreichbar')
setStatus(null)
}
} catch {
setError('Bridge offline - Server in Unity starten')
setStatus(null)
}
setIsLoading(false)
}, [])
// Fetch logs
const fetchLogs = useCallback(async () => {
setIsLoadingLogs(true)
try {
const res = await fetch('/api/admin/unity-bridge?action=logs&limit=50')
if (res.ok) {
const data: LogsResponse = await res.json()
if (data.logs) {
setLogs(data.logs)
}
}
} catch {
// Ignore errors for logs
}
setIsLoadingLogs(false)
}, [])
// Clear logs
const clearLogs = async () => {
try {
await fetch('/api/admin/unity-bridge?action=clear-logs', { method: 'POST' })
setLogs([])
} catch {
// Ignore errors
}
}
// Run diagnostics
const runDiagnose = async () => {
setIsLoadingDiagnose(true)
try {
const res = await fetch('/api/admin/unity-bridge?action=diagnose', { method: 'POST' })
if (res.ok) {
const data: DiagnoseResponse = await res.json()
if (data.diagnostics) {
setDiagnostics(data.diagnostics)
}
}
} catch {
// Ignore errors
}
setIsLoadingDiagnose(false)
}
// Send command
const sendCommand = async (command: string) => {
try {
await fetch(`/api/admin/unity-bridge?action=${command}`)
setTimeout(fetchStatus, 500)
} catch {
// Ignore errors
}
}
// Units Tab Functions
const fetchUnits = useCallback(async () => {
setIsLoadingUnits(true)
setUnitsError(null)
try {
const res = await fetch('/api/admin/unity-bridge?action=units-list')
if (res.ok) {
const data = await res.json()
if (Array.isArray(data)) {
setUnits(data)
} else if (data.offline) {
setUnitsError(data.error)
}
} else {
setUnitsError('Fehler beim Laden der Units')
}
} catch {
setUnitsError('Backend nicht erreichbar')
}
setIsLoadingUnits(false)
}, [])
const fetchUnitDetails = async (unitId: string) => {
try {
const res = await fetch(`/api/admin/unity-bridge?action=units-get&unit_id=${unitId}`)
if (res.ok) {
const data = await res.json()
setSelectedUnit(data.definition || data)
}
} catch {
// Ignore errors
}
}
// Analytics Tab Functions
const fetchAnalytics = useCallback(async () => {
setIsLoadingAnalytics(true)
try {
const res = await fetch('/api/admin/unity-bridge?action=analytics-overview')
if (res.ok) {
const data = await res.json()
if (!data.offline) {
setAnalyticsOverview(data)
}
}
} catch {
// Ignore errors
}
setIsLoadingAnalytics(false)
}, [])
// Content Tab Functions
const generateH5P = async (unitId: string) => {
setIsGenerating(true)
try {
const res = await fetch(`/api/admin/unity-bridge?action=content-h5p&unit_id=${unitId}`)
if (res.ok) {
const data = await res.json()
setGeneratedContent(data)
}
} catch {
// Ignore errors
}
setIsGenerating(false)
}
const generateWorksheet = async (unitId: string) => {
setIsGenerating(true)
try {
const res = await fetch(`/api/admin/unity-bridge?action=content-worksheet&unit_id=${unitId}`)
if (res.ok) {
const data = await res.json()
setGeneratedContent(data)
}
} catch {
// Ignore errors
}
setIsGenerating(false)
}
const downloadPdf = (unitId: string) => {
window.open(`/api/admin/unity-bridge?action=content-pdf&unit_id=${unitId}`, '_blank')
}
// Poll status every 5 seconds (Editor tab)
useEffect(() => {
fetchStatus()
fetchLogs()
const statusInterval = setInterval(fetchStatus, 5000)
const logsInterval = setInterval(fetchLogs, 10000)
return () => {
clearInterval(statusInterval)
clearInterval(logsInterval)
}
}, [fetchStatus, fetchLogs])
// Fetch data when tab changes
useEffect(() => {
if (activeTab === 'units' && units.length === 0) {
fetchUnits()
}
if (activeTab === 'analytics' && !analyticsOverview) {
fetchAnalytics()
}
}, [activeTab, units.length, analyticsOverview, fetchUnits, fetchAnalytics])
return {
activeTab,
setActiveTab,
status,
logs,
diagnostics,
isLoading,
isLoadingLogs,
isLoadingDiagnose,
error,
units,
selectedUnit,
setSelectedUnit,
isLoadingUnits,
unitsError,
analyticsOverview,
isLoadingAnalytics,
generatedContent,
setGeneratedContent,
isGenerating,
fetchLogs,
clearLogs,
runDiagnose,
sendCommand,
fetchUnits,
fetchUnitDetails,
fetchAnalytics,
generateH5P,
generateWorksheet,
downloadPdf,
}
}

File diff suppressed because it is too large Load Diff