Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1095 lines
40 KiB
TypeScript
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>
|
|
)
|
|
}
|