This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/website/app/admin/unity-bridge/page.tsx
Benjamin Admin 21a844cb8a fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.

This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).

Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 09:51:32 +01:00

1095 lines
40 KiB
TypeScript

'use client'
import AdminLayout from '@/components/admin/AdminLayout'
import SystemInfoSection, { SYSTEM_INFO_CONFIGS } from '@/components/admin/SystemInfoSection'
import GameView from '@/components/admin/GameView'
import Link from 'next/link'
import { useState, useEffect, useCallback } from 'react'
// Tab definitions
type TabId = 'editor' | 'units' | 'sessions' | 'analytics' | 'content'
interface Tab {
id: TabId
label: string
icon: React.ReactNode
}
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>
),
},
]
// Type definitions
interface BridgeStatus {
status: string
unity_version: string
project: string
scene: string
is_playing: boolean
is_compiling: boolean
errors: number
warnings: number
}
// Unit System types
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[]
}
interface UnitStop {
stop_id: string
order: number
label: { [key: string]: string }
interaction?: {
type: string
params?: Record<string, unknown>
}
}
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
}
interface GeneratedContent {
unit_id: string
locale: string
generated_count?: number
html?: string
title?: string
}
interface LogEntry {
time: string
type: string
message: string
frame: number
stack?: string
}
interface LogsResponse {
count: number
total_errors: number
total_warnings: number
total_info: number
logs: LogEntry[]
}
interface DiagnosticEntry {
category: string
severity: 'ok' | 'warning' | 'error'
message: string
}
interface DiagnoseResponse {
diagnostics: DiagnosticEntry[]
errors: number
warnings: number
}
// Status Badge Component
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>
)
}
// Stat Card Component
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>
)
}
// Log Entry Component
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>
)
}
// Console Log Panel Component
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>
)
}
// Diagnostic Panel Component
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 "Diagnose starten" 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>
)
}
export default function UnityBridgePage() {
// 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}`)
// Refresh status after 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 (
<AdminLayout title="Unity AI Bridge" description="Externe Steuerung des Unity Editors">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div className="flex items-center gap-4">
<StatusBadge status={status} error={error} />
{status && (
<span className="text-sm text-gray-500">
Unity {status.unity_version} - {status.project}
</span>
)}
</div>
<Link
href="/admin/unity-bridge/wizard"
className="inline-flex items-center gap-2 px-4 py-2 bg-primary-600 text-white rounded-lg hover:bg-primary-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
Wizard starten
</Link>
</div>
{/* Offline Warning - only show on Editor tab */}
{activeTab === 'editor' && error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="flex items-start gap-3">
<svg className="w-5 h-5 text-red-500 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<div>
<p className="font-medium text-red-800">{error}</p>
<p className="text-sm text-red-600 mt-1">
Starte den Server in Unity: <code className="bg-red-100 px-1 rounded">BreakpilotDrive AI Bridge Start Server</code>
</p>
</div>
</div>
</div>
)}
{/* Tab Navigation */}
<div className="mb-6 border-b border-gray-200">
<nav className="flex gap-1 -mb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`
flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors
${activeTab === tab.id
? 'border-primary-500 text-primary-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}
`}
>
{tab.icon}
{tab.label}
</button>
))}
</nav>
</div>
{/* ========================================
EDITOR TAB
======================================== */}
{activeTab === 'editor' && (
<>
{/* 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={() => sendCommand('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={() => sendCommand('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={() => sendCommand('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">
{/* Console Logs */}
<ConsoleLogPanel
logs={logs}
onRefresh={fetchLogs}
onClear={clearLogs}
isLoading={isLoadingLogs}
/>
{/* Diagnostics */}
<DiagnosticPanel
diagnostics={diagnostics}
isLoading={isLoadingDiagnose}
onRun={runDiagnose}
/>
</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>
</>
)}
{/* ========================================
UNITS TAB
======================================== */}
{activeTab === 'units' && (
<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={fetchUnits}
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={() => fetchUnitDetails(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={() => setSelectedUnit(null)}
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>
)}
{/* ========================================
SESSIONS TAB
======================================== */}
{activeTab === 'sessions' && (
<div className="space-y-6">
<div className="text-center py-12 bg-gray-50 rounded-lg border border-gray-200">
<svg className="w-12 h-12 mx-auto mb-4 text-gray-400" 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>
<h3 className="text-lg font-medium text-gray-900 mb-2">Session Monitor</h3>
<p className="text-gray-500 mb-4">Zeigt aktive Lern-Sessions in Echtzeit an</p>
<p className="text-sm text-gray-400">
Sessions werden erstellt, wenn Spieler ein UnitGate im Spiel passieren.
</p>
</div>
</div>
)}
{/* ========================================
ANALYTICS TAB
======================================== */}
{activeTab === 'analytics' && (
<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={fetchAnalytics}
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>
)}
{/* ========================================
CONTENT TAB
======================================== */}
{activeTab === 'content' && (
<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={() => setSelectedUnit(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={fetchUnits}
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={() => generateH5P(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={() => generateWorksheet(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={() => downloadPdf(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={() => setGeneratedContent(null)}
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>
)}
{/* System Info Section - For Internal/External Audits */}
<div className="mt-8 border-t border-slate-200 pt-8">
<SystemInfoSection config={SYSTEM_INFO_CONFIGS.unityBridge} />
</div>
</AdminLayout>
)
}