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>
502 lines
19 KiB
TypeScript
502 lines
19 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect, useCallback } from 'react'
|
||
import dynamic from 'next/dynamic'
|
||
import {
|
||
AOIResponse,
|
||
AOITheme,
|
||
AOIQuality,
|
||
Difficulty,
|
||
GeoJSONPolygon,
|
||
LearningNode,
|
||
GeoServiceHealth,
|
||
DemoTemplate,
|
||
} from './types'
|
||
|
||
// Dynamic imports for map components (no SSR)
|
||
const AOISelector = dynamic(
|
||
() => import('@/components/geo-lernwelt/AOISelector'),
|
||
{ ssr: false, loading: () => <MapLoadingPlaceholder /> }
|
||
)
|
||
|
||
const UnityViewer = dynamic(
|
||
() => import('@/components/geo-lernwelt/UnityViewer'),
|
||
{ ssr: false }
|
||
)
|
||
|
||
// API base URL
|
||
const GEO_SERVICE_URL = process.env.NEXT_PUBLIC_GEO_SERVICE_URL || 'http://localhost:8088'
|
||
|
||
// Loading placeholder for map
|
||
function MapLoadingPlaceholder() {
|
||
return (
|
||
<div className="w-full h-[400px] bg-slate-800 rounded-xl flex items-center justify-center">
|
||
<div className="text-white/60 flex flex-col items-center gap-2">
|
||
<div className="w-8 h-8 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||
<span>Karte wird geladen...</span>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
// Theme icons and colors
|
||
const THEME_CONFIG: Record<AOITheme, { icon: string; color: string; label: string }> = {
|
||
topographie: { icon: '🏔️', color: 'bg-amber-500', label: 'Topographie' },
|
||
landnutzung: { icon: '🏘️', color: 'bg-green-500', label: 'Landnutzung' },
|
||
orientierung: { icon: '🧭', color: 'bg-blue-500', label: 'Orientierung' },
|
||
geologie: { icon: '🪨', color: 'bg-stone-500', label: 'Geologie' },
|
||
hydrologie: { icon: '💧', color: 'bg-cyan-500', label: 'Hydrologie' },
|
||
vegetation: { icon: '🌲', color: 'bg-emerald-500', label: 'Vegetation' },
|
||
}
|
||
|
||
export default function GeoLernweltPage() {
|
||
// State
|
||
const [serviceHealth, setServiceHealth] = useState<GeoServiceHealth | null>(null)
|
||
const [currentAOI, setCurrentAOI] = useState<AOIResponse | null>(null)
|
||
const [drawnPolygon, setDrawnPolygon] = useState<GeoJSONPolygon | null>(null)
|
||
const [selectedTheme, setSelectedTheme] = useState<AOITheme>('topographie')
|
||
const [quality, setQuality] = useState<AOIQuality>('medium')
|
||
const [difficulty, setDifficulty] = useState<Difficulty>('mittel')
|
||
const [learningNodes, setLearningNodes] = useState<LearningNode[]>([])
|
||
const [isLoading, setIsLoading] = useState(false)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [activeTab, setActiveTab] = useState<'map' | 'unity'>('map')
|
||
const [demoTemplate, setDemoTemplate] = useState<DemoTemplate | null>(null)
|
||
|
||
// Check service health on mount
|
||
useEffect(() => {
|
||
checkServiceHealth()
|
||
loadMainauTemplate()
|
||
}, [])
|
||
|
||
const checkServiceHealth = async () => {
|
||
try {
|
||
const res = await fetch(`${GEO_SERVICE_URL}/health`)
|
||
if (res.ok) {
|
||
const health = await res.json()
|
||
setServiceHealth(health)
|
||
}
|
||
} catch (e) {
|
||
console.error('Service health check failed:', e)
|
||
setServiceHealth(null)
|
||
}
|
||
}
|
||
|
||
const loadMainauTemplate = async () => {
|
||
try {
|
||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/templates/mainau`)
|
||
if (res.ok) {
|
||
const template = await res.json()
|
||
setDemoTemplate(template)
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to load Mainau template:', e)
|
||
}
|
||
}
|
||
|
||
const handlePolygonDrawn = useCallback((polygon: GeoJSONPolygon) => {
|
||
setDrawnPolygon(polygon)
|
||
setError(null)
|
||
}, [])
|
||
|
||
const handleCreateAOI = async () => {
|
||
if (!drawnPolygon) {
|
||
setError('Bitte zeichne zuerst ein Gebiet auf der Karte.')
|
||
return
|
||
}
|
||
|
||
setIsLoading(true)
|
||
setError(null)
|
||
|
||
try {
|
||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
polygon: drawnPolygon,
|
||
theme: selectedTheme,
|
||
quality,
|
||
}),
|
||
})
|
||
|
||
if (!res.ok) {
|
||
const errorData = await res.json()
|
||
throw new Error(errorData.detail || 'Fehler beim Erstellen des Gebiets')
|
||
}
|
||
|
||
const aoi = await res.json()
|
||
setCurrentAOI(aoi)
|
||
|
||
// Poll for completion
|
||
pollAOIStatus(aoi.aoi_id)
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : 'Unbekannter Fehler')
|
||
} finally {
|
||
setIsLoading(false)
|
||
}
|
||
}
|
||
|
||
const pollAOIStatus = async (aoiId: string) => {
|
||
const poll = async () => {
|
||
try {
|
||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/aoi/${aoiId}`)
|
||
if (res.ok) {
|
||
const aoi = await res.json()
|
||
setCurrentAOI(aoi)
|
||
|
||
if (aoi.status === 'completed') {
|
||
// Load learning nodes
|
||
generateLearningNodes(aoiId)
|
||
} else if (aoi.status === 'failed') {
|
||
setError('Verarbeitung fehlgeschlagen')
|
||
} else {
|
||
// Continue polling
|
||
setTimeout(poll, 2000)
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.error('Polling error:', e)
|
||
}
|
||
}
|
||
|
||
poll()
|
||
}
|
||
|
||
const generateLearningNodes = async (aoiId: string) => {
|
||
try {
|
||
const res = await fetch(`${GEO_SERVICE_URL}/api/v1/learning/generate`, {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
aoi_id: aoiId,
|
||
theme: selectedTheme,
|
||
difficulty,
|
||
node_count: 5,
|
||
language: 'de',
|
||
}),
|
||
})
|
||
|
||
if (res.ok) {
|
||
const data = await res.json()
|
||
setLearningNodes(data.nodes)
|
||
}
|
||
} catch (e) {
|
||
console.error('Failed to generate learning nodes:', e)
|
||
}
|
||
}
|
||
|
||
const handleLoadDemo = () => {
|
||
if (demoTemplate) {
|
||
setDrawnPolygon(demoTemplate.polygon)
|
||
setSelectedTheme(demoTemplate.suggested_themes[0] || 'topographie')
|
||
}
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900">
|
||
{/* Header */}
|
||
<header className="border-b border-white/10 bg-black/20 backdrop-blur-lg">
|
||
<div className="max-w-7xl mx-auto px-4 py-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-3">
|
||
<span className="text-3xl">🌍</span>
|
||
<div>
|
||
<h1 className="text-xl font-semibold text-white">Geo-Lernwelt</h1>
|
||
<p className="text-sm text-white/60">Interaktive Erdkunde-Lernplattform</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Service Status */}
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className={`w-2 h-2 rounded-full ${
|
||
serviceHealth?.status === 'healthy'
|
||
? 'bg-green-500'
|
||
: 'bg-yellow-500 animate-pulse'
|
||
}`}
|
||
/>
|
||
<span className="text-sm text-white/60">
|
||
{serviceHealth?.status === 'healthy' ? 'Verbunden' : 'Verbinde...'}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
<main className="max-w-7xl mx-auto px-4 py-6">
|
||
{/* Tab Navigation */}
|
||
<div className="flex gap-2 mb-6">
|
||
<button
|
||
onClick={() => setActiveTab('map')}
|
||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||
activeTab === 'map'
|
||
? 'bg-white/10 text-white'
|
||
: 'text-white/60 hover:text-white hover:bg-white/5'
|
||
}`}
|
||
>
|
||
🗺️ Gebiet waehlen
|
||
</button>
|
||
<button
|
||
onClick={() => setActiveTab('unity')}
|
||
disabled={!currentAOI || currentAOI.status !== 'completed'}
|
||
className={`px-4 py-2 rounded-lg font-medium transition-all ${
|
||
activeTab === 'unity'
|
||
? 'bg-white/10 text-white'
|
||
: 'text-white/60 hover:text-white hover:bg-white/5'
|
||
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
||
>
|
||
🎮 3D-Lernwelt
|
||
</button>
|
||
</div>
|
||
|
||
{/* Error Message */}
|
||
{error && (
|
||
<div className="mb-6 p-4 bg-red-500/20 border border-red-500/50 rounded-xl text-red-200">
|
||
{error}
|
||
</div>
|
||
)}
|
||
|
||
{activeTab === 'map' ? (
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||
{/* Map Section (2/3) */}
|
||
<div className="lg:col-span-2 space-y-4">
|
||
{/* Map Card */}
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
|
||
<div className="p-4 border-b border-white/10">
|
||
<div className="flex items-center justify-between">
|
||
<h2 className="text-lg font-medium text-white">Gebiet auf der Karte waehlen</h2>
|
||
{demoTemplate && (
|
||
<button
|
||
onClick={handleLoadDemo}
|
||
className="px-3 py-1.5 text-sm bg-blue-500/20 hover:bg-blue-500/30 text-blue-300 rounded-lg transition-colors"
|
||
>
|
||
📍 Demo: Insel Mainau
|
||
</button>
|
||
)}
|
||
</div>
|
||
<p className="text-sm text-white/60 mt-1">
|
||
Zeichne ein Polygon (max. 4 km²) um das gewuenschte Lerngebiet
|
||
</p>
|
||
</div>
|
||
|
||
<div className="h-[500px]">
|
||
<AOISelector
|
||
onPolygonDrawn={handlePolygonDrawn}
|
||
initialPolygon={drawnPolygon}
|
||
maxAreaKm2={4}
|
||
geoServiceUrl={GEO_SERVICE_URL}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Attribution */}
|
||
<div className="text-xs text-white/40 text-center">
|
||
Kartendaten: © OpenStreetMap contributors (ODbL) | Hoehenmodell: © Copernicus DEM
|
||
</div>
|
||
</div>
|
||
|
||
{/* Settings Panel (1/3) */}
|
||
<div className="space-y-4">
|
||
{/* Theme Selection */}
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||
<h3 className="text-white font-medium mb-3">Lernthema</h3>
|
||
<div className="grid grid-cols-2 gap-2">
|
||
{(Object.keys(THEME_CONFIG) as AOITheme[]).map((theme) => {
|
||
const config = THEME_CONFIG[theme]
|
||
return (
|
||
<button
|
||
key={theme}
|
||
onClick={() => setSelectedTheme(theme)}
|
||
className={`p-3 rounded-xl text-left transition-all ${
|
||
selectedTheme === theme
|
||
? 'bg-white/15 border border-white/30'
|
||
: 'bg-white/5 border border-transparent hover:bg-white/10'
|
||
}`}
|
||
>
|
||
<span className="text-2xl">{config.icon}</span>
|
||
<div className="text-sm text-white mt-1">{config.label}</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Quality Selection */}
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||
<h3 className="text-white font-medium mb-3">Qualitaet</h3>
|
||
<div className="flex gap-2">
|
||
{(['low', 'medium', 'high'] as AOIQuality[]).map((q) => (
|
||
<button
|
||
key={q}
|
||
onClick={() => setQuality(q)}
|
||
className={`flex-1 py-2 rounded-lg text-sm transition-all ${
|
||
quality === q
|
||
? 'bg-white/15 text-white border border-white/30'
|
||
: 'bg-white/5 text-white/60 hover:bg-white/10'
|
||
}`}
|
||
>
|
||
{q === 'low' ? 'Schnell' : q === 'medium' ? 'Standard' : 'Hoch'}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Difficulty Selection */}
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||
<h3 className="text-white font-medium mb-3">Schwierigkeitsgrad</h3>
|
||
<div className="flex gap-2">
|
||
{(['leicht', 'mittel', 'schwer'] as Difficulty[]).map((d) => (
|
||
<button
|
||
key={d}
|
||
onClick={() => setDifficulty(d)}
|
||
className={`flex-1 py-2 rounded-lg text-sm capitalize transition-all ${
|
||
difficulty === d
|
||
? 'bg-white/15 text-white border border-white/30'
|
||
: 'bg-white/5 text-white/60 hover:bg-white/10'
|
||
}`}
|
||
>
|
||
{d}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Area Info */}
|
||
{drawnPolygon && (
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||
<h3 className="text-white font-medium mb-2">Ausgewaehltes Gebiet</h3>
|
||
<div className="text-sm text-white/60">
|
||
<p>Polygon gezeichnet ✓</p>
|
||
<p className="text-white/40 text-xs mt-1">
|
||
Klicke "Lernwelt erstellen" um fortzufahren
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Create Button */}
|
||
<button
|
||
onClick={handleCreateAOI}
|
||
disabled={!drawnPolygon || isLoading}
|
||
className={`w-full py-4 rounded-xl font-medium text-lg transition-all ${
|
||
drawnPolygon && !isLoading
|
||
? 'bg-gradient-to-r from-blue-500 to-purple-500 text-white hover:from-blue-600 hover:to-purple-600'
|
||
: 'bg-white/10 text-white/40 cursor-not-allowed'
|
||
}`}
|
||
>
|
||
{isLoading ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<span className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||
Wird erstellt...
|
||
</span>
|
||
) : (
|
||
'🚀 Lernwelt erstellen'
|
||
)}
|
||
</button>
|
||
|
||
{/* AOI Status */}
|
||
{currentAOI && (
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||
<h3 className="text-white font-medium mb-2">Status</h3>
|
||
<div className="space-y-2">
|
||
<div className="flex items-center gap-2">
|
||
<div
|
||
className={`w-2 h-2 rounded-full ${
|
||
currentAOI.status === 'completed'
|
||
? 'bg-green-500'
|
||
: currentAOI.status === 'failed'
|
||
? 'bg-red-500'
|
||
: 'bg-yellow-500 animate-pulse'
|
||
}`}
|
||
/>
|
||
<span className="text-sm text-white/80 capitalize">
|
||
{currentAOI.status === 'queued'
|
||
? 'In Warteschlange...'
|
||
: currentAOI.status === 'processing'
|
||
? 'Wird verarbeitet...'
|
||
: currentAOI.status === 'completed'
|
||
? 'Fertig!'
|
||
: 'Fehlgeschlagen'}
|
||
</span>
|
||
</div>
|
||
{currentAOI.area_km2 > 0 && (
|
||
<p className="text-xs text-white/50">
|
||
Flaeche: {currentAOI.area_km2.toFixed(2)} km²
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
/* Unity 3D Viewer Tab */
|
||
<div className="space-y-4">
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 overflow-hidden">
|
||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||
<div>
|
||
<h2 className="text-lg font-medium text-white">3D-Lernwelt</h2>
|
||
<p className="text-sm text-white/60">
|
||
Erkunde das Gebiet und bearbeite die Lernstationen
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center gap-2">
|
||
<span className="text-sm text-white/60">
|
||
{learningNodes.length} Lernstationen
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="h-[600px]">
|
||
{currentAOI && currentAOI.status === 'completed' ? (
|
||
<UnityViewer
|
||
aoiId={currentAOI.aoi_id}
|
||
manifestUrl={currentAOI.manifest_url}
|
||
learningNodes={learningNodes}
|
||
geoServiceUrl={GEO_SERVICE_URL}
|
||
/>
|
||
) : (
|
||
<div className="h-full flex items-center justify-center text-white/60">
|
||
Erstelle zuerst ein Lerngebiet im Tab "Gebiet waehlen"
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Learning Nodes List */}
|
||
{learningNodes.length > 0 && (
|
||
<div className="bg-white/5 backdrop-blur-lg rounded-2xl border border-white/10 p-4">
|
||
<h3 className="text-white font-medium mb-3">Lernstationen</h3>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||
{learningNodes.map((node, idx) => (
|
||
<div
|
||
key={node.id}
|
||
className="p-3 bg-white/5 rounded-xl border border-white/10"
|
||
>
|
||
<div className="flex items-center gap-2 mb-2">
|
||
<span className="w-6 h-6 bg-blue-500/30 rounded-full flex items-center justify-center text-xs text-white">
|
||
{idx + 1}
|
||
</span>
|
||
<span className="text-white font-medium text-sm">{node.title}</span>
|
||
</div>
|
||
<p className="text-xs text-white/60 line-clamp-2">{node.question}</p>
|
||
<div className="mt-2 flex items-center gap-2">
|
||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded text-white/60">
|
||
{node.points} Punkte
|
||
</span>
|
||
{node.approved && (
|
||
<span className="text-xs text-green-400">✓ Freigegeben</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</main>
|
||
</div>
|
||
)
|
||
}
|