website (17 pages + 3 components): - multiplayer/wizard, middleware/wizard+test-wizard, communication - builds/wizard, staff-search, voice, sbom/wizard - foerderantrag, mail/tasks, tools/communication, sbom - compliance/evidence, uni-crawler, brandbook (already done) - CollectionsTab, IngestionTab, RiskHeatmap backend-lehrer (5 files): - letters_api (641 → 2), certificates_api (636 → 2) - alerts_agent/db/models (636 → 3) - llm_gateway/communication_service (614 → 2) - game/database already done in prior batch klausur-service (2 files): - hybrid_vocab_extractor (664 → 2) - klausur-service/frontend: api.ts (620 → 3), EHUploadWizard (591 → 2) voice-service (3 files): - bqas/rag_judge (618 → 3), runner (529 → 2) - enhanced_task_orchestrator (519 → 2) studio-v2 (6 files): - korrektur/[klausurId] (578 → 4), fairness (569 → 2) - AlertsWizard (552 → 2), OnboardingWizard (513 → 2) - korrektur/api.ts (506 → 3), geo-lernwelt (501 → 2) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
374 lines
13 KiB
TypeScript
374 lines
13 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>
|
||
)
|
||
}
|
||
|
||
import { GeoSettings } from './GeoSettings'
|
||
|
||
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) */}
|
||
<GeoSettings
|
||
selectedTheme={selectedTheme}
|
||
onThemeChange={setSelectedTheme}
|
||
quality={quality}
|
||
onQualityChange={setQuality}
|
||
difficulty={difficulty}
|
||
onDifficultyChange={setDifficulty}
|
||
drawnPolygon={drawnPolygon}
|
||
currentAOI={currentAOI}
|
||
isLoading={isLoading}
|
||
onCreateAOI={handleCreateAOI}
|
||
/>
|
||
</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>
|
||
)
|
||
}
|