Initial commit: breakpilot-lehrer - Lehrer KI Platform

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>
This commit is contained in:
Benjamin Boenisch
2026-02-11 23:47:26 +01:00
commit 5a31f52310
1224 changed files with 425430 additions and 0 deletions
+191
View File
@@ -0,0 +1,191 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
export interface UploadedFile {
id: string
sessionId: string
name: string
type: string
size: number
uploadedAt: string
dataUrl: string
}
interface QRCodeUploadProps {
sessionId?: string
onClose?: () => void
onFileUploaded?: (file: UploadedFile) => void
onFilesChanged?: (files: UploadedFile[]) => void
className?: string
}
export function QRCodeUpload({
sessionId,
onClose,
onFileUploaded,
onFilesChanged,
className = ''
}: QRCodeUploadProps) {
const [qrCodeUrl, setQrCodeUrl] = useState<string | null>(null)
const [uploadUrl, setUploadUrl] = useState<string>('')
const [isLoading, setIsLoading] = useState(true)
const [uploadedFiles, setUploadedFiles] = useState<UploadedFile[]>([])
const [isPolling, setIsPolling] = useState(false)
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
}
const fetchUploads = useCallback(async () => {
if (!sessionId) return
try {
const response = await fetch(`/api/uploads?sessionId=${sessionId}`)
if (response.ok) {
const data = await response.json()
const newFiles = data.uploads || []
if (newFiles.length > uploadedFiles.length) {
const newlyAdded = newFiles.slice(uploadedFiles.length)
newlyAdded.forEach((file: UploadedFile) => {
if (onFileUploaded) onFileUploaded(file)
})
}
setUploadedFiles(newFiles)
if (onFilesChanged) onFilesChanged(newFiles)
}
} catch (error) {
console.error('Failed to fetch uploads:', error)
}
}, [sessionId, uploadedFiles.length, onFileUploaded, onFilesChanged])
useEffect(() => {
let baseUrl = typeof window !== 'undefined' ? window.location.origin : ''
const hostnameToIP: Record<string, string> = {
'macmini': '192.168.178.100',
'macmini.local': '192.168.178.100',
}
Object.entries(hostnameToIP).forEach(([hostname, ip]) => {
if (baseUrl.includes(hostname)) baseUrl = baseUrl.replace(hostname, ip)
})
const uploadPath = `/upload/${sessionId || 'new'}`
const fullUrl = `${baseUrl}${uploadPath}`
setUploadUrl(fullUrl)
const qrApiUrl = `https://api.qrserver.com/v1/create-qr-code/?size=300x300&data=${encodeURIComponent(fullUrl)}`
setQrCodeUrl(qrApiUrl)
setIsLoading(false)
fetchUploads()
setIsPolling(true)
const pollInterval = setInterval(() => fetchUploads(), 3000)
return () => { clearInterval(pollInterval); setIsPolling(false) }
}, [sessionId])
const copyToClipboard = async () => {
try {
await navigator.clipboard.writeText(uploadUrl)
alert('Link kopiert!')
} catch (err) {
console.error('Kopieren fehlgeschlagen:', err)
}
}
const deleteUpload = async (id: string) => {
try {
const response = await fetch(`/api/uploads?id=${id}`, { method: 'DELETE' })
if (response.ok) {
const newFiles = uploadedFiles.filter(f => f.id !== id)
setUploadedFiles(newFiles)
if (onFilesChanged) onFilesChanged(newFiles)
}
} catch (error) {
console.error('Failed to delete upload:', error)
}
}
return (
<div className={className}>
<div className="rounded-3xl border bg-white border-slate-200 shadow-lg p-6">
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-purple-100">
<span className="text-xl">📱</span>
</div>
<div>
<h3 className="font-semibold text-slate-900">Mit Mobiltelefon hochladen</h3>
<p className="text-sm text-slate-500">QR-Code scannen oder Link teilen</p>
</div>
</div>
{onClose && (
<button onClick={onClose} className="p-2 rounded-lg transition-colors hover:bg-slate-100 text-slate-400">
<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>
<div className="flex flex-col items-center">
<div className="p-4 rounded-2xl bg-slate-50">
{isLoading ? (
<div className="w-[200px] h-[200px] flex items-center justify-center">
<div className="w-8 h-8 border-2 border-purple-500 border-t-transparent rounded-full animate-spin" />
</div>
) : qrCodeUrl ? (
<img src={qrCodeUrl} alt="QR Code zum Hochladen" className="w-[200px] h-[200px]" />
) : (
<div className="w-[200px] h-[200px] flex items-center justify-center text-slate-400">
QR-Code nicht verfuegbar
</div>
)}
</div>
<p className="mt-4 text-center text-sm text-slate-600">
Scannen Sie diesen Code mit Ihrem Handy,<br />um Dokumente direkt hochzuladen.
</p>
{isPolling && (
<div className="mt-2 flex items-center gap-2 text-xs text-slate-400">
<div className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
Warte auf Uploads...
</div>
)}
</div>
{uploadedFiles.length > 0 && (
<div className="mt-6 p-4 rounded-xl bg-green-50 border border-green-200">
<p className="text-sm font-medium text-green-700 mb-3">
{uploadedFiles.length} Datei{uploadedFiles.length !== 1 ? 'en' : ''} empfangen
</p>
<div className="space-y-2 max-h-40 overflow-y-auto">
{uploadedFiles.map((file) => (
<div key={file.id} className="flex items-center gap-3 p-2 rounded-lg bg-white">
<span className="text-lg">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate text-slate-900">{file.name}</p>
<p className="text-xs text-slate-500">{formatFileSize(file.size)}</p>
</div>
<button onClick={() => deleteUpload(file.id)} className="p-1 rounded transition-colors hover:bg-red-100 text-red-500">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</div>
)}
<div className="mt-6">
<p className="text-xs mb-2 text-slate-400">Oder Link teilen:</p>
<div className="flex items-center gap-2">
<input type="text" value={uploadUrl} readOnly className="flex-1 px-3 py-2 rounded-xl text-sm border bg-slate-50 border-slate-200 text-slate-700" />
<button onClick={copyToClipboard} className="px-4 py-2 rounded-xl text-sm font-medium transition-colors bg-slate-200 text-slate-700 hover:bg-slate-300">
Kopieren
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,405 @@
'use client'
/**
* AI Module Sidebar
*
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen AI-Modulen.
* Zeigt den Datenfluss und ermöglicht schnelle Navigation.
*
* Features:
* - Desktop: Fixierte Sidebar rechts
* - Mobile: Floating Action Button mit Slide-In Drawer
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
import { AI_MODULES, DATA_FLOW_CONNECTIONS, type AIModuleLink } from '@/types/ai-modules'
export interface AIModuleSidebarProps {
/** ID des aktuell aktiven Moduls */
currentModule: 'magic-help' | 'ocr-labeling' | 'rag-pipeline' | 'rag'
/** Optional: Kompakter Modus (nur Icons) */
compact?: boolean
/** Optional: Zusätzliche CSS-Klassen */
className?: string
}
export interface AIModuleSidebarResponsiveProps extends AIModuleSidebarProps {
/** Position des FAB auf Mobile */
fabPosition?: 'bottom-right' | 'bottom-left'
}
// Icons für die Module
const ModuleIcon = ({ id }: { id: string }) => {
switch (id) {
case 'magic-help':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
)
case 'ocr-labeling':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
</svg>
)
case 'rag-pipeline':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
case 'rag':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)
default:
return null
}
}
// Pfeil-Icon für Datenfluss
const ArrowIcon = () => (
<svg className="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
)
export function AIModuleSidebar({
currentModule,
compact = false,
className = '',
}: AIModuleSidebarProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-teal-600 dark:text-teal-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
KI-Daten-Pipeline
</span>
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Module Links */}
<div className="space-y-1">
{AI_MODULES.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentModule === module.id
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<ModuleIcon id={module.id} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{module.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
{module.description}
</div>
</div>
{currentModule === module.id && (
<span className="flex-shrink-0 w-2 h-2 bg-teal-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Datenfluss-Visualisierung */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Datenfluss
</div>
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-1.5 text-xs">
<span title="Magic Help"></span>
<span className="text-slate-400" title="Bidirektional"></span>
<span title="OCR-Labeling">🏷</span>
<ArrowIcon />
<span title="RAG Pipeline">🔄</span>
<ArrowIcon />
<span title="Daten & RAG">🔍</span>
</div>
</div>
</div>
{/* Quick Info zum aktuellen Modul */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentModule === 'magic-help' && (
<span>Testen und verbessern Sie die TrOCR-Handschrifterkennung</span>
)}
{currentModule === 'ocr-labeling' && (
<span>Erstellen Sie Ground Truth Daten für das OCR-Training</span>
)}
{currentModule === 'rag-pipeline' && (
<span>Indexieren Sie Dokumente für die semantische Suche</span>
)}
{currentModule === 'rag' && (
<span>Verwalten und durchsuchen Sie indexierte Dokumente</span>
)}
</div>
</div>
</div>
)}
</div>
)
}
/**
* Kompakte Inline-Version für mobile Ansichten
*/
export function AIModuleNav({ currentModule }: { currentModule: string }) {
return (
<div className="flex items-center gap-1 p-1 bg-slate-100 dark:bg-gray-800 rounded-lg">
{AI_MODULES.map((module) => (
<Link
key={module.id}
href={module.href}
className={`px-3 py-1.5 text-sm rounded-md transition-colors ${
currentModule === module.id
? 'bg-white dark:bg-gray-700 text-teal-600 dark:text-teal-400 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-slate-200'
}`}
title={module.description}
>
{module.name}
</Link>
))}
</div>
)
}
/**
* Responsive Sidebar mit Mobile FAB + Drawer
*
* Desktop (xl+): Fixierte Sidebar rechts
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
*/
export function AIModuleSidebarResponsive({
currentModule,
compact = false,
className = '',
fabPosition = 'bottom-right',
}: AIModuleSidebarResponsiveProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
// Close drawer on route change or escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMobileOpen(false)
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
return (
<>
{/* Desktop: Fixed Sidebar */}
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
<AIModuleSidebar currentModule={currentModule} compact={compact} />
</div>
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-teal-500 to-cyan-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="KI-Daten-Pipeline Navigation öffnen"
>
<svg
className="w-6 h-6 group-hover:scale-110 transition-transform"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
{/* Pulse indicator */}
<span className="absolute -top-1 -right-1 w-3 h-3 bg-orange-400 rounded-full animate-pulse" />
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-teal-50 to-cyan-50 dark:from-teal-900/20 dark:to-cyan-900/20">
<div className="flex items-center gap-2">
<span className="text-teal-600 dark:text-teal-400">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">
KI-Daten-Pipeline
</span>
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schließen"
>
<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>
{/* Drawer Content */}
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
{/* Module Links */}
<div className="space-y-2">
{AI_MODULES.map((module) => (
<Link
key={module.id}
href={module.href}
onClick={() => setIsMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
currentModule === module.id
? 'bg-teal-100 dark:bg-teal-900/30 text-teal-700 dark:text-teal-300 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<ModuleIcon id={module.id} />
<div className="flex-1">
<div className="text-base font-medium">{module.name}</div>
<div className="text-sm text-slate-500 dark:text-slate-500">
{module.description}
</div>
</div>
{currentModule === module.id && (
<span className="flex-shrink-0 w-2.5 h-2.5 bg-teal-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Datenfluss-Visualisierung */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Datenfluss
</div>
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
<div className="flex flex-col items-center">
<span className="text-2xl"></span>
<span className="text-xs text-slate-500 mt-1">Magic</span>
</div>
<span className="text-slate-400 text-lg" title="Bidirektional"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🏷</span>
<span className="text-xs text-slate-500 mt-1">OCR</span>
</div>
<ArrowIcon />
<div className="flex flex-col items-center">
<span className="text-2xl">🔄</span>
<span className="text-xs text-slate-500 mt-1">Pipeline</span>
</div>
<ArrowIcon />
<div className="flex flex-col items-center">
<span className="text-2xl">🔍</span>
<span className="text-xs text-slate-500 mt-1">RAG</span>
</div>
</div>
</div>
{/* Quick Info */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
{currentModule === 'magic-help' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> TrOCR-Handschrifterkennung testen und verbessern
</>
)}
{currentModule === 'ocr-labeling' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Ground Truth Daten für OCR-Training erstellen
</>
)}
{currentModule === 'rag-pipeline' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Dokumente für semantische Suche indexieren
</>
)}
{currentModule === 'rag' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Indexierte Dokumente verwalten und durchsuchen
</>
)}
</div>
</div>
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default AIModuleSidebar
@@ -0,0 +1,462 @@
'use client'
/**
* AI Tools Sidebar
*
* Kompakte Sidebar-Komponente für Cross-Navigation zwischen KI-Werkzeugen.
* Zeigt Shared Resources und ermöglicht schnelle Navigation.
*
* Features:
* - Desktop: Fixierte Sidebar rechts
* - Mobile: Floating Action Button mit Slide-In Drawer
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
export type AIToolId = 'llm-compare' | 'test-quality' | 'gpu' | 'ocr-compare' | 'ocr-labeling' | 'rag-pipeline' | 'magic-help'
export interface AIToolModule {
id: AIToolId
name: string
href: string
description: string
icon: string
}
export const AI_TOOLS_MODULES: AIToolModule[] = [
{
id: 'llm-compare',
name: 'LLM Vergleich',
href: '/ai/llm-compare',
description: 'KI-Provider vergleichen',
icon: '⚖️',
},
{
id: 'test-quality',
name: 'Test Quality (BQAS)',
href: '/ai/test-quality',
description: 'Golden Suite & Tests',
icon: '🧪',
},
{
id: 'gpu',
name: 'GPU Infrastruktur',
href: '/ai/gpu',
description: 'vast.ai Management',
icon: '🖥️',
},
{
id: 'ocr-compare',
name: 'OCR Vergleich',
href: '/ai/ocr-compare',
description: 'OCR-Methoden & Vokabel-Extraktion',
icon: '🔍',
},
{
id: 'ocr-labeling',
name: 'OCR Labeling',
href: '/ai/ocr-labeling',
description: 'Trainingsdaten erstellen',
icon: '🏷️',
},
{
id: 'rag-pipeline',
name: 'RAG Pipeline',
href: '/ai/rag-pipeline',
description: 'Retrieval Augmented Generation',
icon: '🔗',
},
{
id: 'magic-help',
name: 'Magic Help',
href: '/ai/magic-help',
description: 'KI-Assistent',
icon: '✨',
},
]
export interface AIToolsSidebarProps {
/** ID des aktuell aktiven Tools */
currentTool: AIToolId
/** Optional: Kompakter Modus (nur Icons) */
compact?: boolean
/** Optional: Zusätzliche CSS-Klassen */
className?: string
}
export interface AIToolsSidebarResponsiveProps extends AIToolsSidebarProps {
/** Position des FAB auf Mobile */
fabPosition?: 'bottom-right' | 'bottom-left'
}
// Icons für die Tools
const ToolIcon = ({ id }: { id: string }) => {
switch (id) {
case 'llm-compare':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M3 6l3 1m0 0l-3 9a5.002 5.002 0 006.001 0M6 7l3 9M6 7l6-2m6 2l3-1m-3 1l-3 9a5.002 5.002 0 006.001 0M18 7l3 9m-3-9l-6-2m0-2v2m0 16V5m0 16H9m3 0h3" />
</svg>
)
case 'test-quality':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
case 'gpu':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
</svg>
)
case 'ocr-compare':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
)
case 'ocr-labeling':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
</svg>
)
case 'rag-pipeline':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
)
case 'magic-help':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z" />
</svg>
)
default:
return null
}
}
// Werkzeug-Icon für Header
const WrenchIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
)
export function AIToolsSidebar({
currentTool,
compact = false,
className = '',
}: AIToolsSidebarProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-violet-600 dark:text-violet-400">
<WrenchIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
KI-Werkzeuge
</span>
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Tool Links */}
<div className="space-y-1">
{AI_TOOLS_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentTool === tool.id
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{tool.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
{tool.description}
</div>
</div>
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2 h-2 bg-violet-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Shared Resources Visualisierung */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Shared Resources
</div>
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-2 text-xs">
<span title="GPU Infrastruktur">🖥</span>
<span className="text-slate-400"></span>
<span title="LLM Vergleich"></span>
<span className="text-slate-400"></span>
<span title="Test Quality">🧪</span>
</div>
</div>
<div className="text-xs text-slate-400 mt-1 px-1">
GPU-Ressourcen fuer Modelle & Tests
</div>
</div>
{/* Quick Info zum aktuellen Tool */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentTool === 'llm-compare' && (
<span>Vergleichen Sie LLM-Antworten verschiedener Provider</span>
)}
{currentTool === 'test-quality' && (
<span>Ueberwachen Sie die Qualitaet der KI-Ausgaben</span>
)}
{currentTool === 'gpu' && (
<span>Verwalten Sie GPU-Instanzen fuer ML-Training</span>
)}
</div>
</div>
</div>
)}
</div>
)
}
/**
* Responsive Tools Sidebar mit Mobile FAB + Drawer
*
* Desktop (xl+): Fixierte Sidebar rechts
* Mobile/Tablet: Floating Action Button unten rechts, öffnet Drawer
*/
export function AIToolsSidebarResponsive({
currentTool,
compact = false,
className = '',
fabPosition = 'bottom-right',
}: AIToolsSidebarResponsiveProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
// Close drawer on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMobileOpen(false)
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
return (
<>
{/* Desktop: Fixed Sidebar */}
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
<AIToolsSidebar currentTool={currentTool} compact={compact} />
</div>
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-violet-500 to-purple-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="KI-Werkzeuge Navigation oeffnen"
>
<WrenchIcon />
{/* Pulse indicator */}
<span className="absolute -top-1 -right-1 w-3 h-3 bg-amber-400 rounded-full animate-pulse" />
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-violet-50 to-purple-50 dark:from-violet-900/20 dark:to-purple-900/20">
<div className="flex items-center gap-2">
<span className="text-violet-600 dark:text-violet-400">
<WrenchIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">
KI-Werkzeuge
</span>
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schliessen"
>
<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>
{/* Drawer Content */}
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
{/* Tool Links */}
<div className="space-y-2">
{AI_TOOLS_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
onClick={() => setIsMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
currentTool === tool.id
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1">
<div className="text-base font-medium">{tool.name}</div>
<div className="text-sm text-slate-500 dark:text-slate-500">
{tool.description}
</div>
</div>
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2.5 h-2.5 bg-violet-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Shared Resources Visualisierung */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Shared Resources
</div>
<div className="flex items-center justify-center gap-3 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
<div className="flex flex-col items-center">
<span className="text-2xl">🖥</span>
<span className="text-xs text-slate-500 mt-1">GPU</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl"></span>
<span className="text-xs text-slate-500 mt-1">LLM</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🧪</span>
<span className="text-xs text-slate-500 mt-1">BQAS</span>
</div>
</div>
<div className="text-xs text-slate-400 mt-2 text-center">
GPU-Ressourcen fuer Modelle & Tests
</div>
</div>
{/* Quick Info */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
{currentTool === 'llm-compare' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> LLM-Antworten verschiedener Provider vergleichen
</>
)}
{currentTool === 'test-quality' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Qualitaet der KI-Ausgaben ueberwachen
</>
)}
{currentTool === 'gpu' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> GPU-Instanzen fuer ML-Training verwalten
</>
)}
</div>
</div>
{/* Link zur Pipeline */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<Link
href="/ai/magic-help"
onClick={() => setIsMobileOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-teal-600 dark:text-teal-400 hover:bg-teal-50 dark:hover:bg-teal-900/20 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<span>Zur KI-Daten-Pipeline</span>
</Link>
</div>
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default AIToolsSidebar
@@ -0,0 +1,454 @@
/**
* Batch Uploader Component
*
* Multi-file upload with drag & drop, progress tracking, and SSE integration.
* Supports batch OCR processing for multiple images.
*
* Phase 2.1: Batch Processing UI
*/
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
interface UploadedFile {
id: string
file: File
preview: string
status: 'pending' | 'processing' | 'completed' | 'error'
result?: {
text: string
confidence: number
processing_time_ms: number
}
error?: string
}
interface BatchUploaderProps {
/** API base URL */
apiBase: string
/** Maximum files allowed */
maxFiles?: number
/** Whether to auto-start OCR after upload */
autoProcess?: boolean
/** Callback when all files are processed */
onComplete?: (results: UploadedFile[]) => void
/** Callback when a single file is processed */
onFileProcessed?: (file: UploadedFile) => void
/** Custom class names */
className?: string
}
/**
* Generate unique ID
*/
function generateId(): string {
return Math.random().toString(36).substring(2, 11)
}
/**
* Batch Uploader Component
*/
export function BatchUploader({
apiBase,
maxFiles = 20,
autoProcess = false,
onComplete,
onFileProcessed,
className = ''
}: BatchUploaderProps) {
const [files, setFiles] = useState<UploadedFile[]>([])
const [isProcessing, setIsProcessing] = useState(false)
const [isDragActive, setIsDragActive] = useState(false)
const [progress, setProgress] = useState({ current: 0, total: 0 })
const fileInputRef = useRef<HTMLInputElement>(null)
const eventSourceRef = useRef<EventSource | null>(null)
// Add files to the list
const addFiles = useCallback((newFiles: FileList | File[]) => {
const fileArray = Array.from(newFiles)
const imageFiles = fileArray.filter(f => f.type.startsWith('image/'))
if (imageFiles.length === 0) return
const uploadedFiles: UploadedFile[] = imageFiles
.slice(0, maxFiles - files.length)
.map(file => ({
id: generateId(),
file,
preview: URL.createObjectURL(file),
status: 'pending' as const
}))
setFiles(prev => [...prev, ...uploadedFiles].slice(0, maxFiles))
// Auto-process if enabled
if (autoProcess && uploadedFiles.length > 0) {
setTimeout(() => startProcessing(), 100)
}
}, [files.length, maxFiles, autoProcess])
// Remove a file
const removeFile = useCallback((id: string) => {
setFiles(prev => {
const file = prev.find(f => f.id === id)
if (file) {
URL.revokeObjectURL(file.preview)
}
return prev.filter(f => f.id !== id)
})
}, [])
// Clear all files
const clearAll = useCallback(() => {
files.forEach(f => URL.revokeObjectURL(f.preview))
setFiles([])
setProgress({ current: 0, total: 0 })
}, [files])
// Start batch processing with SSE
const startProcessing = useCallback(async () => {
if (isProcessing || files.length === 0) return
const pendingFiles = files.filter(f => f.status === 'pending')
if (pendingFiles.length === 0) return
setIsProcessing(true)
setProgress({ current: 0, total: pendingFiles.length })
// Close any existing connection
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
// Use SSE for progress updates
const eventSource = new EventSource(
`${apiBase}/api/v1/admin/training/ocr/stream?images_count=${pendingFiles.length}`
)
eventSourceRef.current = eventSource
let processedIndex = 0
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data)
if (data.type === 'progress') {
setProgress({ current: data.current, total: data.total })
// Update file status
if (processedIndex < pendingFiles.length) {
const currentFile = pendingFiles[processedIndex]
setFiles(prev => prev.map(f =>
f.id === currentFile.id
? {
...f,
status: 'completed' as const,
result: data.result
}
: f
))
onFileProcessed?.({
...currentFile,
status: 'completed',
result: data.result
})
processedIndex++
}
} else if (data.type === 'error') {
if (processedIndex < pendingFiles.length) {
const currentFile = pendingFiles[processedIndex]
setFiles(prev => prev.map(f =>
f.id === currentFile.id
? { ...f, status: 'error' as const, error: data.error }
: f
))
processedIndex++
}
} else if (data.type === 'complete') {
eventSource.close()
setIsProcessing(false)
onComplete?.(files)
}
} catch (e) {
console.error('SSE parse error:', e)
}
}
eventSource.onerror = () => {
eventSource.close()
setIsProcessing(false)
// Mark remaining as error
setFiles(prev => prev.map(f =>
f.status === 'pending' || f.status === 'processing'
? { ...f, status: 'error' as const, error: 'Verbindung unterbrochen' }
: f
))
}
// Mark files as processing
setFiles(prev => prev.map(f =>
f.status === 'pending' ? { ...f, status: 'processing' as const } : f
))
}, [apiBase, files, isProcessing, onComplete, onFileProcessed])
// Stop processing
const stopProcessing = useCallback(() => {
if (eventSourceRef.current) {
eventSourceRef.current.close()
eventSourceRef.current = null
}
setIsProcessing(false)
setFiles(prev => prev.map(f =>
f.status === 'processing' ? { ...f, status: 'pending' as const } : f
))
}, [])
// Drag handlers
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
setIsDragActive(false)
addFiles(e.dataTransfer.files)
}
// Cleanup on unmount
useEffect(() => {
return () => {
files.forEach(f => URL.revokeObjectURL(f.preview))
if (eventSourceRef.current) {
eventSourceRef.current.close()
}
}
}, [])
const completedCount = files.filter(f => f.status === 'completed').length
const errorCount = files.filter(f => f.status === 'error').length
const avgConfidence = files
.filter(f => f.result)
.reduce((sum, f) => sum + (f.result?.confidence || 0), 0) /
Math.max(1, files.filter(f => f.result).length)
return (
<div className={`space-y-4 ${className}`}>
{/* Upload area */}
<div
className={`border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all ${
isDragActive
? 'border-purple-500 bg-purple-50'
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/50'
}`}
onDragEnter={handleDragEnter}
onDragOver={(e) => e.preventDefault()}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
>
<div className="text-4xl mb-3">📁</div>
<div className="text-slate-700 font-medium">
{isDragActive
? 'Loslassen zum Hochladen'
: 'Bilder hierher ziehen oder klicken'}
</div>
<div className="text-sm text-slate-400 mt-1">
Bis zu {maxFiles} Bilder (PNG, JPG)
</div>
<input
ref={fileInputRef}
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => e.target.files && addFiles(e.target.files)}
/>
</div>
{/* File list */}
{files.length > 0 && (
<div className="space-y-3">
{/* Header with stats */}
<div className="flex items-center justify-between">
<div className="text-sm text-slate-600">
{files.length} Bilder ausgewaehlt
{completedCount > 0 && (
<span className="text-green-600 ml-2">
({completedCount} verarbeitet, {(avgConfidence * 100).toFixed(0)}% Konfidenz)
</span>
)}
{errorCount > 0 && (
<span className="text-red-600 ml-2">({errorCount} Fehler)</span>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={clearAll}
className="px-3 py-1 text-sm text-slate-600 hover:text-slate-900 transition-colors"
>
Alle entfernen
</button>
{isProcessing ? (
<button
onClick={stopProcessing}
className="px-4 py-1.5 bg-red-600 hover:bg-red-700 text-white rounded-lg text-sm font-medium transition-colors"
>
Stoppen
</button>
) : (
<button
onClick={startProcessing}
disabled={files.filter(f => f.status === 'pending').length === 0}
className="px-4 py-1.5 bg-purple-600 hover:bg-purple-700 disabled:bg-slate-300 text-white rounded-lg text-sm font-medium transition-colors"
>
OCR starten
</button>
)}
</div>
</div>
{/* Progress bar */}
{isProcessing && (
<div className="bg-slate-100 rounded-lg p-3">
<div className="flex items-center justify-between text-sm mb-2">
<span className="text-slate-600">
Verarbeite Bild {progress.current} von {progress.total}
</span>
<span className="text-purple-600 font-medium">
{((progress.current / progress.total) * 100).toFixed(0)}%
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-purple-600 transition-all duration-300"
style={{ width: `${(progress.current / progress.total) * 100}%` }}
/>
</div>
</div>
)}
{/* File thumbnails */}
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{files.map((file) => (
<div
key={file.id}
className={`relative group rounded-lg overflow-hidden border-2 ${
file.status === 'completed'
? 'border-green-300'
: file.status === 'error'
? 'border-red-300'
: file.status === 'processing'
? 'border-purple-300'
: 'border-slate-200'
}`}
>
{/* Thumbnail */}
<img
src={file.preview}
alt={file.file.name}
className="w-full h-24 object-cover"
/>
{/* Overlay for status */}
{file.status === 'processing' && (
<div className="absolute inset-0 bg-purple-900/50 flex items-center justify-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
)}
{file.status === 'completed' && file.result && (
<div className="absolute inset-0 bg-green-900/70 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="text-center text-white text-xs p-2">
<div className="font-medium">
{(file.result.confidence * 100).toFixed(0)}%
</div>
<div className="text-green-200 truncate max-w-full">
{file.result.text.substring(0, 30)}...
</div>
</div>
</div>
)}
{file.status === 'error' && (
<div className="absolute inset-0 bg-red-900/70 flex items-center justify-center">
<div className="text-white text-xs text-center px-2">
Fehler
</div>
</div>
)}
{/* Status badge */}
<div className={`absolute top-1 right-1 w-3 h-3 rounded-full ${
file.status === 'completed'
? 'bg-green-500'
: file.status === 'error'
? 'bg-red-500'
: file.status === 'processing'
? 'bg-purple-500 animate-pulse'
: 'bg-slate-400'
}`} />
{/* Remove button */}
{!isProcessing && (
<button
onClick={(e) => {
e.stopPropagation()
removeFile(file.id)
}}
className="absolute top-1 left-1 w-5 h-5 bg-red-500 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-xs"
>
×
</button>
)}
{/* File name */}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 text-white text-xs px-2 py-1 truncate">
{file.file.name}
</div>
</div>
))}
</div>
</div>
)}
{/* Results summary */}
{completedCount > 0 && !isProcessing && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<h4 className="font-medium text-green-800 mb-2">
Verarbeitung abgeschlossen
</h4>
<div className="grid grid-cols-3 gap-4 text-center text-sm">
<div>
<div className="text-2xl font-bold text-green-700">{completedCount}</div>
<div className="text-green-600">Erfolgreich</div>
</div>
<div>
<div className="text-2xl font-bold text-slate-700">
{(avgConfidence * 100).toFixed(0)}%
</div>
<div className="text-slate-600">Durchschnittl. Konfidenz</div>
</div>
<div>
<div className="text-2xl font-bold text-red-700">{errorCount}</div>
<div className="text-red-600">Fehler</div>
</div>
</div>
</div>
)}
</div>
)
}
export default BatchUploader
@@ -0,0 +1,439 @@
/**
* Confidence Heatmap Component
*
* Displays an OCR result with visual confidence overlay on the original image.
* Shows word-level or character-level confidence using color gradients.
*
* Phase 3.1: Wow-Feature for Magic Help
*/
'use client'
import { useState, useRef, useEffect, useMemo } from 'react'
interface WordBox {
text: string
confidence: number
bbox: [number, number, number, number] // [x, y, width, height] as percentages (0-100)
}
interface ConfidenceHeatmapProps {
/** Image source URL or data URL */
imageSrc: string
/** Detected text result */
text: string
/** Overall confidence score (0-1) */
confidence: number
/** Word-level boxes with confidence */
wordBoxes?: WordBox[]
/** Character-level confidences (aligned with text) */
charConfidences?: number[]
/** Custom class names */
className?: string
/** Show legend */
showLegend?: boolean
/** Allow toggling overlay visibility */
toggleable?: boolean
/** Callback when a word box is clicked */
onWordClick?: (word: WordBox) => void
}
/**
* Get color based on confidence value
* Green (high) -> Yellow (medium) -> Red (low)
*/
function getConfidenceColor(confidence: number, opacity = 0.5): string {
if (confidence >= 0.9) {
return `rgba(34, 197, 94, ${opacity})` // green-500
} else if (confidence >= 0.7) {
return `rgba(234, 179, 8, ${opacity})` // yellow-500
} else if (confidence >= 0.5) {
return `rgba(249, 115, 22, ${opacity})` // orange-500
} else {
return `rgba(239, 68, 68, ${opacity})` // red-500
}
}
/**
* Get border color (more saturated version)
*/
function getConfidenceBorderColor(confidence: number): string {
if (confidence >= 0.9) return '#22c55e' // green-500
if (confidence >= 0.7) return '#eab308' // yellow-500
if (confidence >= 0.5) return '#f97316' // orange-500
return '#ef4444' // red-500
}
/**
* Confidence Heatmap Component
*/
export function ConfidenceHeatmap({
imageSrc,
text,
confidence,
wordBoxes = [],
charConfidences = [],
className = '',
showLegend = true,
toggleable = true,
onWordClick
}: ConfidenceHeatmapProps) {
const [showOverlay, setShowOverlay] = useState(true)
const [hoveredWord, setHoveredWord] = useState<WordBox | null>(null)
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const [isPanning, setIsPanning] = useState(false)
const [lastPanPoint, setLastPanPoint] = useState({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
// Generate simulated word boxes if not provided
const displayBoxes = useMemo(() => {
if (wordBoxes.length > 0) return wordBoxes
// Simulate word boxes from text and char confidences
if (!text || charConfidences.length === 0) return []
const words = text.split(/\s+/).filter(w => w.length > 0)
const boxes: WordBox[] = []
let charIndex = 0
words.forEach((word, idx) => {
// Calculate average confidence for this word
const wordConfidences = charConfidences.slice(charIndex, charIndex + word.length)
const avgConfidence = wordConfidences.length > 0
? wordConfidences.reduce((a, b) => a + b, 0) / wordConfidences.length
: confidence
// Simulate bbox positions (simple grid layout for demo)
const wordsPerRow = 5
const row = Math.floor(idx / wordsPerRow)
const col = idx % wordsPerRow
const wordWidth = Math.min(18, 5 + word.length * 1.5)
boxes.push({
text: word,
confidence: avgConfidence,
bbox: [
5 + col * 19, // x
10 + row * 12, // y
wordWidth, // width
8 // height
]
})
charIndex += word.length + 1 // +1 for space
})
return boxes
}, [text, wordBoxes, charConfidences, confidence])
// Handle mouse wheel zoom
const handleWheel = (e: React.WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
const delta = e.deltaY > 0 ? -0.1 : 0.1
setZoom(prev => Math.max(1, Math.min(3, prev + delta)))
}
}
// Handle panning
const handleMouseDown = (e: React.MouseEvent) => {
if (zoom > 1) {
setIsPanning(true)
setLastPanPoint({ x: e.clientX, y: e.clientY })
}
}
const handleMouseMove = (e: React.MouseEvent) => {
if (isPanning && zoom > 1) {
const dx = e.clientX - lastPanPoint.x
const dy = e.clientY - lastPanPoint.y
setPan(prev => ({
x: Math.max(-100, Math.min(100, prev.x + dx / zoom)),
y: Math.max(-100, Math.min(100, prev.y + dy / zoom))
}))
setLastPanPoint({ x: e.clientX, y: e.clientY })
}
}
const handleMouseUp = () => {
setIsPanning(false)
}
// Reset zoom and pan
const resetView = () => {
setZoom(1)
setPan({ x: 0, y: 0 })
}
return (
<div className={`relative ${className}`}>
{/* Controls */}
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<div className="flex items-center gap-2">
{toggleable && (
<button
onClick={() => setShowOverlay(prev => !prev)}
className={`px-3 py-1 rounded text-sm font-medium transition-colors ${
showOverlay
? 'bg-purple-600 text-white'
: 'bg-slate-200 text-slate-700 hover:bg-slate-300'
}`}
>
{showOverlay ? 'Overlay An' : 'Overlay Aus'}
</button>
)}
<div className="flex items-center gap-1 bg-slate-100 rounded p-1">
<button
onClick={() => setZoom(prev => Math.max(1, prev - 0.25))}
className="p-1 hover:bg-slate-200 rounded transition-colors"
title="Verkleinern"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 12H4" />
</svg>
</button>
<span className="text-xs font-mono w-12 text-center">{(zoom * 100).toFixed(0)}%</span>
<button
onClick={() => setZoom(prev => Math.min(3, prev + 0.25))}
className="p-1 hover:bg-slate-200 rounded transition-colors"
title="Vergroessern"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
</button>
{zoom > 1 && (
<button
onClick={resetView}
className="p-1 hover:bg-slate-200 rounded transition-colors ml-1"
title="Zuruecksetzen"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</button>
)}
</div>
</div>
{/* Overall confidence badge */}
<div className={`px-3 py-1 rounded-full text-sm font-medium ${
confidence >= 0.9 ? 'bg-green-100 text-green-700' :
confidence >= 0.7 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
Gesamt: {(confidence * 100).toFixed(0)}%
</div>
</div>
{/* Image container with overlay */}
<div
ref={containerRef}
className="relative overflow-hidden rounded-lg border border-slate-200 bg-slate-100"
style={{ cursor: zoom > 1 ? (isPanning ? 'grabbing' : 'grab') : 'default' }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div
className="relative transition-transform duration-200"
style={{
transform: `scale(${zoom}) translate(${pan.x}px, ${pan.y}px)`,
transformOrigin: 'center center'
}}
>
{/* Original image */}
<img
src={imageSrc}
alt="OCR Dokument"
className="w-full h-auto"
draggable={false}
/>
{/* SVG Overlay */}
{showOverlay && displayBoxes.length > 0 && (
<svg
className="absolute inset-0 w-full h-full pointer-events-none"
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{displayBoxes.map((box, idx) => (
<g
key={idx}
className="pointer-events-auto cursor-pointer"
onMouseEnter={() => setHoveredWord(box)}
onMouseLeave={() => setHoveredWord(null)}
onClick={() => onWordClick?.(box)}
>
{/* Background fill */}
<rect
x={box.bbox[0]}
y={box.bbox[1]}
width={box.bbox[2]}
height={box.bbox[3]}
fill={getConfidenceColor(box.confidence, 0.3)}
stroke={getConfidenceBorderColor(box.confidence)}
strokeWidth="0.3"
rx="0.5"
className="transition-all duration-150"
style={{
filter: hoveredWord === box ? 'brightness(1.2)' : 'none'
}}
/>
{/* Hover highlight */}
{hoveredWord === box && (
<rect
x={box.bbox[0] - 0.5}
y={box.bbox[1] - 0.5}
width={box.bbox[2] + 1}
height={box.bbox[3] + 1}
fill="none"
stroke="#7c3aed"
strokeWidth="0.5"
rx="0.5"
className="animate-pulse"
/>
)}
</g>
))}
</svg>
)}
</div>
</div>
{/* Hovered word tooltip */}
{hoveredWord && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-slate-900 text-white text-sm rounded-lg shadow-lg z-10">
<div className="font-mono">&quot;{hoveredWord.text}&quot;</div>
<div className="text-slate-300 text-xs">
Konfidenz: {(hoveredWord.confidence * 100).toFixed(1)}%
</div>
<div
className="absolute top-full left-1/2 -translate-x-1/2 w-2 h-2 bg-slate-900 rotate-45 -mt-1"
/>
</div>
)}
{/* Legend */}
{showLegend && (
<div className="mt-3 flex items-center justify-center gap-4 text-xs text-slate-600">
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(34, 197, 94, 0.5)' }} />
<span>&gt;90%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(234, 179, 8, 0.5)' }} />
<span>70-90%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(249, 115, 22, 0.5)' }} />
<span>50-70%</span>
</div>
<div className="flex items-center gap-1">
<div className="w-4 h-3 rounded" style={{ backgroundColor: 'rgba(239, 68, 68, 0.5)' }} />
<span>&lt;50%</span>
</div>
</div>
)}
{/* Instructions */}
<p className="mt-2 text-xs text-slate-400 text-center">
Fahre ueber markierte Bereiche fuer Details. Strg+Scroll zum Zoomen.
</p>
</div>
)
}
/**
* Inline character confidence display
* Shows text with color-coded background for each character
*/
interface InlineConfidenceTextProps {
text: string
charConfidences: number[]
className?: string
}
export function InlineConfidenceText({
text,
charConfidences,
className = ''
}: InlineConfidenceTextProps) {
if (charConfidences.length === 0) {
return <span className={className}>{text}</span>
}
return (
<span className={`font-mono ${className}`}>
{text.split('').map((char, idx) => {
const conf = charConfidences[idx] ?? 0.5
return (
<span
key={idx}
className="relative group"
style={{ backgroundColor: getConfidenceColor(conf, 0.3) }}
title={`'${char}': ${(conf * 100).toFixed(0)}%`}
>
{char}
</span>
)
})}
</span>
)
}
/**
* Confidence Statistics Summary
*/
interface ConfidenceStatsProps {
wordBoxes: WordBox[]
className?: string
}
export function ConfidenceStats({ wordBoxes, className = '' }: ConfidenceStatsProps) {
const stats = useMemo(() => {
if (wordBoxes.length === 0) return null
const confidences = wordBoxes.map(w => w.confidence)
const avg = confidences.reduce((a, b) => a + b, 0) / confidences.length
const min = Math.min(...confidences)
const max = Math.max(...confidences)
const highConf = confidences.filter(c => c >= 0.9).length
const lowConf = confidences.filter(c => c < 0.7).length
return { avg, min, max, highConf, lowConf, total: wordBoxes.length }
}, [wordBoxes])
if (!stats) return null
return (
<div className={`grid grid-cols-2 md:grid-cols-5 gap-3 ${className}`}>
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{(stats.avg * 100).toFixed(0)}%</div>
<div className="text-xs text-slate-500">Durchschnitt</div>
</div>
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{(stats.min * 100).toFixed(0)}%</div>
<div className="text-xs text-slate-500">Minimum</div>
</div>
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{(stats.max * 100).toFixed(0)}%</div>
<div className="text-xs text-slate-500">Maximum</div>
</div>
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-green-700">{stats.highConf}</div>
<div className="text-xs text-slate-500">Sicher (&gt;90%)</div>
</div>
<div className="bg-red-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-red-700">{stats.lowConf}</div>
<div className="text-xs text-slate-500">Unsicher (&lt;70%)</div>
</div>
</div>
)
}
export default ConfidenceHeatmap
@@ -0,0 +1,613 @@
/**
* Training Metrics Component
*
* Real-time visualization of training progress with loss curves and metrics.
* Supports SSE (Server-Sent Events) for live updates during LoRA fine-tuning.
*
* Phase 3.3: Training Dashboard with Live Metrics
*/
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
// Training data point for charts
interface TrainingDataPoint {
epoch: number
step: number
loss: number
val_loss?: number
learning_rate: number
timestamp: number
}
// Training job status
interface TrainingStatus {
job_id: string
status: 'queued' | 'running' | 'completed' | 'failed' | 'cancelled'
progress: number // 0-100
current_epoch: number
total_epochs: number
current_step: number
total_steps: number
elapsed_time_ms: number
estimated_remaining_ms: number
metrics: {
loss: number
val_loss?: number
accuracy?: number
learning_rate: number
}
history: TrainingDataPoint[]
error?: string
}
interface TrainingMetricsProps {
/** API base URL */
apiBase: string
/** Job ID to track (null for simulation mode) */
jobId?: string | null
/** Simulate training progress for demo */
simulateMode?: boolean
/** Custom class names */
className?: string
/** Callback when training completes */
onComplete?: (status: TrainingStatus) => void
/** Callback on error */
onError?: (error: string) => void
}
/**
* Custom hook for SSE-based training metrics
*/
export function useTrainingMetricsSSE(
apiBase: string,
jobId: string | null,
onUpdate?: (status: TrainingStatus) => void
) {
const [status, setStatus] = useState<TrainingStatus | null>(null)
const [connected, setConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
const eventSourceRef = useRef<EventSource | null>(null)
useEffect(() => {
if (!jobId) return
const url = `${apiBase}/api/klausur/trocr/training/metrics/stream?job_id=${jobId}`
const eventSource = new EventSource(url)
eventSourceRef.current = eventSource
eventSource.onopen = () => {
setConnected(true)
setError(null)
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as TrainingStatus
setStatus(data)
onUpdate?.(data)
// Close connection when training is done
if (data.status === 'completed' || data.status === 'failed' || data.status === 'cancelled') {
eventSource.close()
setConnected(false)
}
} catch (e) {
console.error('Failed to parse SSE message:', e)
}
}
eventSource.onerror = () => {
setError('Verbindung zum Server verloren')
setConnected(false)
eventSource.close()
}
return () => {
eventSource.close()
setConnected(false)
}
}, [apiBase, jobId, onUpdate])
const disconnect = useCallback(() => {
eventSourceRef.current?.close()
setConnected(false)
}, [])
return { status, connected, error, disconnect }
}
/**
* Simple line chart component for loss visualization
*/
function LossChart({
data,
height = 200,
className = ''
}: {
data: TrainingDataPoint[]
height?: number
className?: string
}) {
if (data.length === 0) {
return (
<div className={`flex items-center justify-center bg-slate-50 rounded-lg ${className}`} style={{ height }}>
<span className="text-slate-400 text-sm">Keine Daten</span>
</div>
)
}
// Calculate bounds
const losses = data.map(d => d.loss)
const valLosses = data.filter(d => d.val_loss !== undefined).map(d => d.val_loss!)
const allLosses = [...losses, ...valLosses]
const minLoss = Math.min(...allLosses) * 0.9
const maxLoss = Math.max(...allLosses) * 1.1
const lossRange = maxLoss - minLoss || 1
// SVG dimensions
const width = 400
const padding = { top: 20, right: 20, bottom: 30, left: 50 }
const chartWidth = width - padding.left - padding.right
const chartHeight = height - padding.top - padding.bottom
// Generate path for loss line
const generatePath = (values: number[]) => {
if (values.length === 0) return ''
return values
.map((loss, idx) => {
const x = padding.left + (idx / (values.length - 1 || 1)) * chartWidth
const y = padding.top + chartHeight - ((loss - minLoss) / lossRange) * chartHeight
return `${idx === 0 ? 'M' : 'L'} ${x} ${y}`
})
.join(' ')
}
const lossPath = generatePath(losses)
const valLossPath = valLosses.length > 0 ? generatePath(valLosses) : ''
// Y-axis labels
const yLabels = [maxLoss, (maxLoss + minLoss) / 2, minLoss].map(v => v.toFixed(3))
return (
<svg viewBox={`0 0 ${width} ${height}`} className={`w-full ${className}`} preserveAspectRatio="xMidYMid meet">
{/* Grid lines */}
{[0, 0.5, 1].map((ratio, idx) => (
<line
key={idx}
x1={padding.left}
y1={padding.top + chartHeight * ratio}
x2={width - padding.right}
y2={padding.top + chartHeight * ratio}
stroke="#e2e8f0"
strokeWidth="1"
/>
))}
{/* Y-axis labels */}
{yLabels.map((label, idx) => (
<text
key={idx}
x={padding.left - 5}
y={padding.top + (idx / 2) * chartHeight + 4}
textAnchor="end"
className="fill-slate-500 text-xs"
>
{label}
</text>
))}
{/* X-axis label */}
<text
x={width / 2}
y={height - 5}
textAnchor="middle"
className="fill-slate-500 text-xs"
>
Epoche / Schritt
</text>
{/* Y-axis label */}
<text
x={12}
y={height / 2}
textAnchor="middle"
className="fill-slate-500 text-xs"
transform={`rotate(-90, 12, ${height / 2})`}
>
Loss
</text>
{/* Training loss line */}
<path
d={lossPath}
fill="none"
stroke="#8b5cf6"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Validation loss line (dashed) */}
{valLossPath && (
<path
d={valLossPath}
fill="none"
stroke="#22c55e"
strokeWidth="2"
strokeDasharray="5,5"
strokeLinecap="round"
strokeLinejoin="round"
/>
)}
{/* Data points */}
{data.map((point, idx) => {
const x = padding.left + (idx / (data.length - 1 || 1)) * chartWidth
const y = padding.top + chartHeight - ((point.loss - minLoss) / lossRange) * chartHeight
return (
<circle
key={idx}
cx={x}
cy={y}
r="3"
fill="#8b5cf6"
className="hover:r-4 transition-all"
>
<title>Epoch {point.epoch}, Step {point.step}: {point.loss.toFixed(4)}</title>
</circle>
)
})}
{/* Legend */}
<g transform={`translate(${padding.left}, ${height - 25})`}>
<line x1="0" y1="0" x2="20" y2="0" stroke="#8b5cf6" strokeWidth="2" />
<text x="25" y="4" className="fill-slate-600 text-xs">Training Loss</text>
{valLossPath && (
<>
<line x1="120" y1="0" x2="140" y2="0" stroke="#22c55e" strokeWidth="2" strokeDasharray="5,5" />
<text x="145" y="4" className="fill-slate-600 text-xs">Val Loss</text>
</>
)}
</g>
</svg>
)
}
/**
* Progress ring component
*/
function ProgressRing({
progress,
size = 80,
strokeWidth = 6,
className = ''
}: {
progress: number
size?: number
strokeWidth?: number
className?: string
}) {
const radius = (size - strokeWidth) / 2
const circumference = radius * 2 * Math.PI
const offset = circumference - (progress / 100) * circumference
return (
<svg width={size} height={size} className={className}>
{/* Background circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#e2e8f0"
strokeWidth={strokeWidth}
/>
{/* Progress circle */}
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="#8b5cf6"
strokeWidth={strokeWidth}
strokeDasharray={circumference}
strokeDashoffset={offset}
strokeLinecap="round"
transform={`rotate(-90 ${size / 2} ${size / 2})`}
className="transition-all duration-300"
/>
{/* Center text */}
<text
x={size / 2}
y={size / 2}
textAnchor="middle"
dominantBaseline="middle"
className="fill-slate-900 font-bold text-lg"
>
{progress.toFixed(0)}%
</text>
</svg>
)
}
/**
* Format time duration
*/
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
const seconds = Math.floor(ms / 1000)
if (seconds < 60) return `${seconds}s`
const minutes = Math.floor(seconds / 60)
const remainingSeconds = seconds % 60
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
return `${hours}h ${remainingMinutes}m`
}
/**
* Training Metrics Component - Full Dashboard
*/
export function TrainingMetrics({
apiBase,
jobId = null,
simulateMode = false,
className = '',
onComplete,
onError
}: TrainingMetricsProps) {
const [status, setStatus] = useState<TrainingStatus | null>(null)
const [simulationInterval, setSimulationInterval] = useState<NodeJS.Timeout | null>(null)
// SSE hook for real connection
const { status: sseStatus, connected, error } = useTrainingMetricsSSE(
apiBase,
simulateMode ? null : jobId,
(newStatus) => {
if (newStatus.status === 'completed') {
onComplete?.(newStatus)
} else if (newStatus.status === 'failed' && newStatus.error) {
onError?.(newStatus.error)
}
}
)
// Use SSE status if available
useEffect(() => {
if (sseStatus) {
setStatus(sseStatus)
}
}, [sseStatus])
// Simulation mode for demo
useEffect(() => {
if (!simulateMode) return
let step = 0
const totalSteps = 100
const history: TrainingDataPoint[] = []
const interval = setInterval(() => {
step++
const epoch = Math.floor((step / totalSteps) * 3) + 1
const progress = (step / totalSteps) * 100
// Simulate decreasing loss with noise
const baseLoss = 2.5 * Math.exp(-step / 30) + 0.1
const noise = (Math.random() - 0.5) * 0.1
const loss = Math.max(0.05, baseLoss + noise)
const dataPoint: TrainingDataPoint = {
epoch,
step,
loss,
val_loss: step % 10 === 0 ? loss * (1 + Math.random() * 0.2) : undefined,
learning_rate: 0.00005 * Math.pow(0.95, epoch - 1),
timestamp: Date.now()
}
history.push(dataPoint)
const newStatus: TrainingStatus = {
job_id: 'simulation',
status: step >= totalSteps ? 'completed' : 'running',
progress,
current_epoch: epoch,
total_epochs: 3,
current_step: step,
total_steps: totalSteps,
elapsed_time_ms: step * 500,
estimated_remaining_ms: (totalSteps - step) * 500,
metrics: {
loss,
val_loss: dataPoint.val_loss,
accuracy: 0.7 + (step / totalSteps) * 0.25,
learning_rate: dataPoint.learning_rate
},
history: [...history]
}
setStatus(newStatus)
if (step >= totalSteps) {
clearInterval(interval)
onComplete?.(newStatus)
}
}, 500)
setSimulationInterval(interval)
return () => {
clearInterval(interval)
}
}, [simulateMode, onComplete])
// Stop simulation
const stopSimulation = () => {
if (simulationInterval) {
clearInterval(simulationInterval)
setSimulationInterval(null)
}
}
if (error) {
return (
<div className={`bg-red-50 border border-red-200 rounded-xl p-6 ${className}`}>
<div className="flex items-center gap-3 text-red-700">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{error}</span>
</div>
</div>
)
}
if (!status) {
return (
<div className={`bg-slate-50 rounded-xl p-6 ${className}`}>
<div className="flex items-center justify-center gap-3 text-slate-500">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-purple-600"></div>
<span>Warte auf Training-Daten...</span>
</div>
</div>
)
}
return (
<div className={`bg-white rounded-xl shadow-sm border ${className}`}>
{/* Header */}
<div className="flex items-center justify-between p-4 border-b">
<div className="flex items-center gap-3">
<h3 className="text-lg font-semibold text-slate-900">Training Dashboard</h3>
<div className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${
status.status === 'running' ? 'bg-blue-100 text-blue-700' :
status.status === 'completed' ? 'bg-green-100 text-green-700' :
status.status === 'failed' ? 'bg-red-100 text-red-700' :
'bg-slate-100 text-slate-700'
}`}>
{status.status === 'running' && (
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
)}
{status.status === 'running' ? 'Laeuft' :
status.status === 'completed' ? 'Abgeschlossen' :
status.status === 'failed' ? 'Fehlgeschlagen' :
status.status}
</div>
{connected && (
<span className="text-xs text-green-600 flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-green-500"></span>
Live
</span>
)}
</div>
{simulateMode && status.status === 'running' && (
<button
onClick={stopSimulation}
className="px-3 py-1 text-sm bg-slate-200 hover:bg-slate-300 text-slate-700 rounded transition-colors"
>
Stoppen
</button>
)}
</div>
{/* Main content */}
<div className="p-4 grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Progress section */}
<div className="flex flex-col items-center justify-center gap-4">
<ProgressRing progress={status.progress} size={120} strokeWidth={8} />
<div className="text-center">
<div className="text-sm text-slate-500">
Epoche {status.current_epoch} / {status.total_epochs}
</div>
<div className="text-xs text-slate-400">
Schritt {status.current_step} / {status.total_steps}
</div>
</div>
</div>
{/* Loss chart */}
<div className="lg:col-span-2">
<h4 className="text-sm font-medium text-slate-700 mb-2">Loss-Verlauf</h4>
<LossChart data={status.history} height={180} />
</div>
</div>
{/* Metrics grid */}
<div className="px-4 pb-4 grid grid-cols-2 md:grid-cols-4 gap-3">
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{status.metrics.loss.toFixed(4)}</div>
<div className="text-xs text-slate-500">Aktueller Loss</div>
</div>
{status.metrics.val_loss !== undefined && (
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{status.metrics.val_loss.toFixed(4)}</div>
<div className="text-xs text-slate-500">Validation Loss</div>
</div>
)}
{status.metrics.accuracy !== undefined && (
<div className="bg-green-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-green-700">{(status.metrics.accuracy * 100).toFixed(1)}%</div>
<div className="text-xs text-slate-500">Genauigkeit</div>
</div>
)}
<div className="bg-slate-50 rounded-lg p-3 text-center">
<div className="text-lg font-bold text-slate-900">{status.metrics.learning_rate.toExponential(1)}</div>
<div className="text-xs text-slate-500">Learning Rate</div>
</div>
</div>
{/* Time info */}
<div className="px-4 pb-4 flex items-center justify-between text-sm text-slate-500">
<div>Vergangen: {formatDuration(status.elapsed_time_ms)}</div>
{status.status === 'running' && (
<div>Geschaetzt: {formatDuration(status.estimated_remaining_ms)} verbleibend</div>
)}
</div>
</div>
)
}
/**
* Compact Training Metrics for inline display
*/
export function TrainingMetricsCompact({
progress,
currentEpoch,
totalEpochs,
loss,
status,
className = ''
}: {
progress: number
currentEpoch: number
totalEpochs: number
loss: number
status: 'running' | 'completed' | 'failed'
className?: string
}) {
return (
<div className={`flex items-center gap-4 ${className}`}>
<ProgressRing progress={progress} size={48} strokeWidth={4} />
<div className="flex-1">
<div className="flex items-center gap-2 text-sm">
<span className="font-medium text-slate-900">Epoche {currentEpoch}/{totalEpochs}</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${
status === 'running' ? 'bg-blue-100 text-blue-700' :
status === 'completed' ? 'bg-green-100 text-green-700' :
'bg-red-100 text-red-700'
}`}>
{status === 'running' ? 'Laeuft' : status === 'completed' ? 'Fertig' : 'Fehler'}
</span>
</div>
<div className="text-xs text-slate-500 mt-1">
Loss: {loss.toFixed(4)}
</div>
</div>
</div>
)
}
export default TrainingMetrics
+13
View File
@@ -0,0 +1,13 @@
/**
* AI Components Index
*
* Exports für alle AI-spezifischen Komponenten
*/
export { AIModuleSidebar, AIModuleNav, AIModuleSidebarResponsive } from './AIModuleSidebar'
export type { AIModuleSidebarProps, AIModuleSidebarResponsiveProps } from './AIModuleSidebar'
// Magic Help Components
export { ConfidenceHeatmap, InlineConfidenceText, ConfidenceStats } from './ConfidenceHeatmap'
export { TrainingMetrics, TrainingMetricsCompact, useTrainingMetricsSSE } from './TrainingMetrics'
export { BatchUploader } from './BatchUploader'
@@ -0,0 +1,392 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { X } from 'lucide-react'
import type {
CatalogMeta,
CatalogEntry,
CatalogFieldSchema,
} from '@/lib/sdk/catalog-manager/types'
interface CatalogEntryFormProps {
catalog: CatalogMeta
entry?: CatalogEntry | null
onSave: (data: Record<string, unknown>) => void
onCancel: () => void
}
export default function CatalogEntryForm({
catalog,
entry,
onSave,
onCancel,
}: CatalogEntryFormProps) {
const isEditMode = entry !== null && entry !== undefined
const isSystemEntry = isEditMode && entry?.source === 'system'
const isCreateMode = !isEditMode
const title = isSystemEntry
? 'Details'
: isEditMode
? 'Eintrag bearbeiten'
: 'Neuer Eintrag'
const [formData, setFormData] = useState<Record<string, unknown>>({})
const [errors, setErrors] = useState<Record<string, string>>({})
// Initialize form data
useEffect(() => {
if (isEditMode && entry) {
const initialData: Record<string, unknown> = {}
for (const field of catalog.fields) {
initialData[field.key] = entry.data?.[field.key] ?? getDefaultValue(field)
}
setFormData(initialData)
} else {
const initialData: Record<string, unknown> = {}
for (const field of catalog.fields) {
initialData[field.key] = getDefaultValue(field)
}
setFormData(initialData)
}
}, [entry, catalog.fields, isEditMode])
function getDefaultValue(field: CatalogFieldSchema): unknown {
switch (field.type) {
case 'text':
case 'textarea':
return ''
case 'number':
return field.min ?? 0
case 'select':
return ''
case 'multiselect':
return []
case 'boolean':
return false
case 'tags':
return ''
default:
return ''
}
}
const handleFieldChange = useCallback(
(key: string, value: unknown) => {
setFormData(prev => ({ ...prev, [key]: value }))
// Clear error on change
if (errors[key]) {
setErrors(prev => {
const next = { ...prev }
delete next[key]
return next
})
}
},
[errors]
)
const handleMultiselectToggle = useCallback(
(key: string, option: string) => {
setFormData(prev => {
const current = (prev[key] as string[]) || []
const updated = current.includes(option)
? current.filter(v => v !== option)
: [...current, option]
return { ...prev, [key]: updated }
})
},
[]
)
const validate = (): boolean => {
const newErrors: Record<string, string> = {}
for (const field of catalog.fields) {
if (field.required) {
const value = formData[field.key]
if (value === undefined || value === null || value === '') {
newErrors[field.key] = `${field.label} ist ein Pflichtfeld`
} else if (Array.isArray(value) && value.length === 0) {
newErrors[field.key] = `Mindestens ein Wert erforderlich`
}
}
}
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (isSystemEntry) return
if (validate()) {
// Convert tags string to array before saving
const processedData = { ...formData }
for (const field of catalog.fields) {
if (field.type === 'tags' && typeof processedData[field.key] === 'string') {
processedData[field.key] = (processedData[field.key] as string)
.split(',')
.map(t => t.trim())
.filter(Boolean)
}
}
onSave(processedData)
}
}
// Close on Escape key
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') onCancel()
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [onCancel])
const renderField = (field: CatalogFieldSchema) => {
const value = formData[field.key]
const hasError = !!errors[field.key]
const isDisabled = isSystemEntry
const baseInputClasses = `w-full px-3 py-2 text-sm border rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent transition-colors ${
hasError
? 'border-red-400 dark:border-red-500'
: 'border-gray-300 dark:border-gray-600'
} ${isDisabled ? 'opacity-60 cursor-not-allowed bg-gray-50 dark:bg-gray-800' : ''}`
switch (field.type) {
case 'text':
return (
<input
type="text"
value={(value as string) || ''}
onChange={e => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder || ''}
disabled={isDisabled}
className={baseInputClasses}
/>
)
case 'textarea':
return (
<textarea
value={(value as string) || ''}
onChange={e => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder || ''}
disabled={isDisabled}
rows={3}
className={`${baseInputClasses} resize-y`}
/>
)
case 'number':
return (
<input
type="number"
value={(value as number) ?? ''}
onChange={e =>
handleFieldChange(
field.key,
e.target.value === '' ? '' : Number(e.target.value)
)
}
min={field.min}
max={field.max}
step={field.step ?? 1}
disabled={isDisabled}
className={baseInputClasses}
/>
)
case 'select':
return (
<select
value={(value as string) || ''}
onChange={e => handleFieldChange(field.key, e.target.value)}
disabled={isDisabled}
className={baseInputClasses}
>
<option value="">-- Auswaehlen --</option>
{(field.options || []).map(opt => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
)
case 'multiselect':
return (
<div className="space-y-1.5">
{(field.options || []).map(opt => {
const checked = ((value as string[]) || []).includes(opt.value)
return (
<label
key={opt.value}
className={`flex items-center gap-2 text-sm cursor-pointer ${
isDisabled ? 'opacity-60 cursor-not-allowed' : ''
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => handleMultiselectToggle(field.key, opt.value)}
disabled={isDisabled}
className="h-4 w-4 rounded border-gray-300 dark:border-gray-600 text-violet-600 focus:ring-violet-500"
/>
<span className="text-gray-700 dark:text-gray-300">
{opt.label}
</span>
</label>
)
})}
</div>
)
case 'boolean':
return (
<label
className={`flex items-center gap-3 cursor-pointer ${
isDisabled ? 'opacity-60 cursor-not-allowed' : ''
}`}
>
<button
type="button"
role="switch"
aria-checked={!!value}
disabled={isDisabled}
onClick={() => !isDisabled && handleFieldChange(field.key, !value)}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-violet-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800 ${
value
? 'bg-violet-600'
: 'bg-gray-200 dark:bg-gray-600'
}`}
>
<span
className={`pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition-transform ${
value ? 'translate-x-5' : 'translate-x-0'
}`}
/>
</button>
<span className="text-sm text-gray-700 dark:text-gray-300">
{value ? 'Ja' : 'Nein'}
</span>
</label>
)
case 'tags':
return (
<div>
<input
type="text"
value={
Array.isArray(value)
? (value as string[]).join(', ')
: (value as string) || ''
}
onChange={e => handleFieldChange(field.key, e.target.value)}
placeholder={field.placeholder || 'Komma-getrennte Tags eingeben...'}
disabled={isDisabled}
className={baseInputClasses}
/>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
Mehrere Tags durch Komma trennen
</p>
</div>
)
default:
return null
}
}
return (
// Backdrop
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onClick={e => {
if (e.target === e.currentTarget) onCancel()
}}
>
{/* Modal */}
<div className="relative w-full max-w-lg max-h-[90vh] bg-white dark:bg-gray-800 rounded-xl shadow-2xl flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700 shrink-0">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{title}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{catalog.name}
</p>
</div>
<button
onClick={onCancel}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors"
title="Schliessen"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto">
<div className="px-6 py-4 space-y-5">
{catalog.fields.map(field => (
<div key={field.key}>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1.5">
{field.label}
{field.required && !isSystemEntry && (
<span className="text-red-500 ml-0.5">*</span>
)}
</label>
{field.description && (
<p className="text-xs text-gray-400 dark:text-gray-500 mb-1.5">
{field.description}
</p>
)}
{renderField(field)}
{errors[field.key] && (
<p className="mt-1 text-xs text-red-500 dark:text-red-400">
{errors[field.key]}
</p>
)}
</div>
))}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-200 dark:border-gray-700 shrink-0">
{isSystemEntry ? (
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Schliessen
</button>
) : (
<>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
className="px-4 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors"
>
{isCreateMode ? 'Erstellen' : 'Speichern'}
</button>
</>
)}
</div>
</form>
</div>
</div>
)
}
@@ -0,0 +1,381 @@
'use client'
import { useState, useMemo, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import {
Database,
Search,
Plus,
ChevronDown,
ChevronRight,
BarChart3,
Layers,
Users,
Settings,
} from 'lucide-react'
import type {
CatalogId,
CatalogModule,
CatalogEntry,
CatalogMeta,
CustomCatalogEntry,
} from '@/lib/sdk/catalog-manager/types'
import { CATALOG_MODULE_LABELS } from '@/lib/sdk/catalog-manager/types'
import {
CATALOG_REGISTRY,
getAllEntries,
getCatalogsByModule,
getOverviewStats,
searchCatalog,
} from '@/lib/sdk/catalog-manager/catalog-registry'
import CatalogModuleTabs from '@/components/catalog-manager/CatalogModuleTabs'
import CatalogTable from '@/components/catalog-manager/CatalogTable'
import CatalogEntryForm from '@/components/catalog-manager/CatalogEntryForm'
// =============================================================================
// STAT CARD COMPONENT
// =============================================================================
interface StatCardProps {
icon: React.ReactNode
label: string
value: number
color: 'violet' | 'blue' | 'emerald' | 'amber'
}
const colorMap = {
violet: {
bg: 'bg-violet-50 dark:bg-violet-900/20',
text: 'text-violet-600 dark:text-violet-400',
},
blue: {
bg: 'bg-blue-50 dark:bg-blue-900/20',
text: 'text-blue-600 dark:text-blue-400',
},
emerald: {
bg: 'bg-emerald-50 dark:bg-emerald-900/20',
text: 'text-emerald-600 dark:text-emerald-400',
},
amber: {
bg: 'bg-amber-50 dark:bg-amber-900/20',
text: 'text-amber-600 dark:text-amber-400',
},
}
function StatCard({ icon, label, value, color }: StatCardProps) {
const colors = colorMap[color]
return (
<div className={`flex items-center gap-3 px-4 py-3 rounded-xl ${colors.bg}`}>
<div className={colors.text}>{icon}</div>
<div>
<p className="text-xs text-gray-500 dark:text-gray-400">{label}</p>
<p className={`text-lg font-bold ${colors.text}`}>{value.toLocaleString('de-DE')}</p>
</div>
</div>
)
}
// =============================================================================
// MAIN CONTENT COMPONENT
// =============================================================================
export function CatalogManagerContent() {
const { state, dispatch } = useSDK()
const customCatalogs = state.customCatalogs ?? {}
// UI State
const [activeModule, setActiveModule] = useState<CatalogModule | 'all'>('all')
const [selectedCatalogId, setSelectedCatalogId] = useState<CatalogId | null>(null)
const [searchQuery, setSearchQuery] = useState('')
const [expandedCatalogs, setExpandedCatalogs] = useState<Set<CatalogId>>(new Set())
const [formState, setFormState] = useState<{
open: boolean
catalog: CatalogMeta | null
entry: CatalogEntry | null
}>({ open: false, catalog: null, entry: null })
// Computed data
const overviewStats = useMemo(() => getOverviewStats(customCatalogs), [customCatalogs])
const visibleCatalogs = useMemo(() => {
if (activeModule === 'all') {
return Object.values(CATALOG_REGISTRY)
}
return getCatalogsByModule(activeModule)
}, [activeModule])
const selectedCatalog = selectedCatalogId ? CATALOG_REGISTRY[selectedCatalogId] : null
const catalogEntries = useMemo(() => {
if (!selectedCatalogId) return []
const custom = customCatalogs[selectedCatalogId] || []
if (searchQuery.trim()) {
return searchCatalog(selectedCatalogId, searchQuery, custom)
}
return getAllEntries(selectedCatalogId, custom)
}, [selectedCatalogId, customCatalogs, searchQuery])
// Handlers
const toggleCatalogExpand = useCallback((id: CatalogId) => {
setExpandedCatalogs(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
next.add(id)
}
return next
})
}, [])
const handleSelectCatalog = useCallback((id: CatalogId) => {
setSelectedCatalogId(id)
setSearchQuery('')
}, [])
const handleAddEntry = useCallback((catalog: CatalogMeta) => {
setFormState({ open: true, catalog, entry: null })
}, [])
const handleEditEntry = useCallback((catalog: CatalogMeta, entry: CatalogEntry) => {
setFormState({ open: true, catalog, entry })
}, [])
const handleCloseForm = useCallback(() => {
setFormState({ open: false, catalog: null, entry: null })
}, [])
const handleSaveEntry = useCallback((data: Record<string, unknown>) => {
if (!formState.catalog) return
if (formState.entry && formState.entry.source === 'custom') {
// Update existing custom entry
dispatch({
type: 'UPDATE_CUSTOM_CATALOG_ENTRY',
payload: {
catalogId: formState.catalog.id,
entryId: formState.entry.id,
data,
},
})
} else {
// Create new custom entry
const newEntry: CustomCatalogEntry = {
id: crypto.randomUUID(),
catalogId: formState.catalog.id,
data,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
dispatch({
type: 'ADD_CUSTOM_CATALOG_ENTRY',
payload: newEntry,
})
}
handleCloseForm()
}, [formState, dispatch, handleCloseForm])
const handleDeleteEntry = useCallback((catalogId: CatalogId, entryId: string) => {
dispatch({
type: 'DELETE_CUSTOM_CATALOG_ENTRY',
payload: { catalogId, entryId },
})
}, [dispatch])
// =============================================================================
// RENDER
// =============================================================================
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
{/* Header */}
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-violet-100 dark:bg-violet-900/30 rounded-xl">
<Database className="h-6 w-6 text-violet-600 dark:text-violet-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
Katalogverwaltung
</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Alle SDK-Kataloge und Auswahltabellen zentral verwalten
</p>
</div>
</div>
</div>
{/* Stats Bar */}
<div className="grid grid-cols-4 gap-4 mt-6">
<StatCard
icon={<Layers className="h-5 w-5" />}
label="Kataloge"
value={overviewStats.totalCatalogs}
color="violet"
/>
<StatCard
icon={<Database className="h-5 w-5" />}
label="System-Eintraege"
value={overviewStats.totalSystemEntries}
color="blue"
/>
<StatCard
icon={<Users className="h-5 w-5" />}
label="Eigene Eintraege"
value={overviewStats.totalCustomEntries}
color="emerald"
/>
<StatCard
icon={<BarChart3 className="h-5 w-5" />}
label="Gesamt"
value={overviewStats.totalEntries}
color="amber"
/>
</div>
</div>
</div>
{/* Module Tabs */}
<div className="max-w-7xl mx-auto px-6 mt-6">
<CatalogModuleTabs
activeModule={activeModule}
onModuleChange={setActiveModule}
stats={overviewStats}
/>
</div>
{/* Content Area */}
<div className="max-w-7xl mx-auto px-6 py-6">
<div className="grid grid-cols-12 gap-6">
{/* Left: Catalog List */}
<div className="col-span-4">
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300">
{activeModule === 'all'
? 'Alle Kataloge'
: CATALOG_MODULE_LABELS[activeModule]}
<span className="ml-2 text-gray-400 font-normal">
({visibleCatalogs.length})
</span>
</h2>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700/50 max-h-[calc(100vh-400px)] overflow-y-auto">
{visibleCatalogs.map(catalog => {
const customCount = customCatalogs[catalog.id]?.length ?? 0
const isSelected = selectedCatalogId === catalog.id
return (
<button
key={catalog.id}
onClick={() => handleSelectCatalog(catalog.id)}
className={`w-full text-left px-4 py-3 transition-colors ${
isSelected
? 'bg-violet-50 dark:bg-violet-900/20 border-l-3 border-l-violet-600'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex items-center justify-between">
<div className="min-w-0">
<p className={`text-sm font-medium truncate ${
isSelected
? 'text-violet-700 dark:text-violet-300'
: 'text-gray-900 dark:text-white'
}`}>
{catalog.name}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 truncate">
{catalog.description}
</p>
</div>
<div className="flex items-center gap-2 ml-2 shrink-0">
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400">
{catalog.systemCount}
</span>
{customCount > 0 && (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400">
+{customCount}
</span>
)}
</div>
</div>
</button>
)
})}
</div>
</div>
</div>
{/* Right: Catalog Detail / Table */}
<div className="col-span-8">
{selectedCatalog ? (
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 overflow-hidden">
{/* Catalog Header */}
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{selectedCatalog.name}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{selectedCatalog.description}
</p>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 dark:text-gray-400">
{CATALOG_MODULE_LABELS[selectedCatalog.module]}
</span>
{selectedCatalog.allowCustom && (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium rounded-full bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400">
Erweiterbar
</span>
)}
</div>
</div>
</div>
{/* Table */}
<CatalogTable
catalog={selectedCatalog}
entries={catalogEntries}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onEditCustomEntry={(entry) => handleEditEntry(selectedCatalog, entry)}
onDeleteCustomEntry={(entryId) => handleDeleteEntry(selectedCatalog.id, entryId)}
onAddEntry={() => handleAddEntry(selectedCatalog)}
/>
</div>
) : (
/* Empty State */
<div className="bg-white dark:bg-gray-800 rounded-xl shadow border border-gray-200 dark:border-gray-700 p-12">
<div className="text-center">
<Settings className="h-12 w-12 text-gray-300 dark:text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-white mb-2">
Katalog auswaehlen
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
Waehlen Sie einen Katalog aus der Liste links, um dessen Eintraege anzuzeigen und zu verwalten.
</p>
</div>
</div>
)}
</div>
</div>
</div>
{/* Form Modal */}
{formState.open && formState.catalog && (
<CatalogEntryForm
catalog={formState.catalog}
entry={formState.entry}
onSave={handleSaveEntry}
onCancel={handleCloseForm}
/>
)}
</div>
)
}
@@ -0,0 +1,121 @@
'use client'
import { useRef, useEffect } from 'react'
import {
ShieldAlert,
FileText,
Building2,
Bot,
BookOpen,
Database,
} from 'lucide-react'
import type {
CatalogModule,
CatalogOverviewStats,
} from '@/lib/sdk/catalog-manager/types'
import { CATALOG_MODULE_LABELS } from '@/lib/sdk/catalog-manager/types'
interface CatalogModuleTabsProps {
activeModule: CatalogModule | 'all'
onModuleChange: (module: CatalogModule | 'all') => void
stats: CatalogOverviewStats
}
const MODULE_ICON_MAP: Record<CatalogModule, React.ComponentType<{ className?: string }>> = {
dsfa: ShieldAlert,
vvt: FileText,
vendor: Building2,
ai_act: Bot,
reference: BookOpen,
}
interface TabDefinition {
key: CatalogModule | 'all'
label: string
icon: React.ComponentType<{ className?: string }>
}
export default function CatalogModuleTabs({
activeModule,
onModuleChange,
stats,
}: CatalogModuleTabsProps) {
const scrollRef = useRef<HTMLDivElement>(null)
const activeTabRef = useRef<HTMLButtonElement>(null)
// Scroll active tab into view on mount and when active changes
useEffect(() => {
if (activeTabRef.current && scrollRef.current) {
const container = scrollRef.current
const tab = activeTabRef.current
const containerRect = container.getBoundingClientRect()
const tabRect = tab.getBoundingClientRect()
if (tabRect.left < containerRect.left || tabRect.right > containerRect.right) {
tab.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
}
}, [activeModule])
const tabs: TabDefinition[] = [
{ key: 'all', label: 'Alle', icon: Database },
...Object.entries(CATALOG_MODULE_LABELS).map(([key, label]) => ({
key: key as CatalogModule,
label,
icon: MODULE_ICON_MAP[key as CatalogModule],
})),
]
const getCount = (key: CatalogModule | 'all'): number => {
if (key === 'all') {
return stats.totalEntries
}
return stats.byModule?.[key]?.entries ?? 0
}
return (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow">
<div
ref={scrollRef}
className="flex overflow-x-auto scrollbar-hide border-b border-gray-200 dark:border-gray-700"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{tabs.map(tab => {
const isActive = activeModule === tab.key
const Icon = tab.icon
const count = getCount(tab.key)
return (
<button
key={tab.key}
ref={isActive ? activeTabRef : undefined}
onClick={() => onModuleChange(tab.key)}
className={`relative flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap transition-colors shrink-0 ${
isActive
? 'text-violet-700 dark:text-violet-400 font-semibold'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200'
}`}
>
<Icon className="h-4 w-4 shrink-0" />
<span>{tab.label}</span>
<span
className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-medium rounded-full ${
isActive
? 'bg-violet-100 dark:bg-violet-900/40 text-violet-700 dark:text-violet-300'
: 'bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400'
}`}
>
{count}
</span>
{/* Active indicator line */}
{isActive && (
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-violet-600 dark:bg-violet-400 rounded-t" />
)}
</button>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,254 @@
'use client'
import { useState, useMemo } from 'react'
import {
Search,
Plus,
Pencil,
Trash2,
Eye,
ChevronUp,
ChevronDown,
Database,
} from 'lucide-react'
import type { CatalogMeta, CatalogEntry } from '@/lib/sdk/catalog-manager/types'
interface CatalogTableProps {
catalog: CatalogMeta
entries: CatalogEntry[]
searchQuery: string
onSearchChange: (query: string) => void
onEditCustomEntry: (entry: CatalogEntry) => void
onDeleteCustomEntry: (entryId: string) => void
onAddEntry: () => void
}
type SortField = 'id' | 'name' | 'category' | 'type'
type SortDirection = 'asc' | 'desc'
export default function CatalogTable({
catalog,
entries,
searchQuery,
onSearchChange,
onEditCustomEntry,
onDeleteCustomEntry,
onAddEntry,
}: CatalogTableProps) {
const [sortField, setSortField] = useState<SortField>('name')
const [sortDirection, setSortDirection] = useState<SortDirection>('asc')
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(prev => (prev === 'asc' ? 'desc' : 'asc'))
} else {
setSortField(field)
setSortDirection('asc')
}
}
const sortedEntries = useMemo(() => {
const sorted = [...entries].sort((a, b) => {
let aVal = ''
let bVal = ''
switch (sortField) {
case 'id':
aVal = a.id
bVal = b.id
break
case 'name':
aVal = a.displayName
bVal = b.displayName
break
case 'category':
aVal = a.category || ''
bVal = b.category || ''
break
case 'type':
aVal = a.source
bVal = b.source
break
}
const cmp = aVal.localeCompare(bVal, 'de')
return sortDirection === 'asc' ? cmp : -cmp
})
return sorted
}, [entries, sortField, sortDirection])
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) {
return (
<span className="ml-1 inline-flex flex-col opacity-30">
<ChevronUp className="h-3 w-3 -mb-1" />
<ChevronDown className="h-3 w-3" />
</span>
)
}
return sortDirection === 'asc' ? (
<ChevronUp className="ml-1 h-3.5 w-3.5 inline" />
) : (
<ChevronDown className="ml-1 h-3.5 w-3.5 inline" />
)
}
return (
<div>
{/* Search & Add Header */}
<div className="p-4 border-b border-gray-200 dark:border-gray-700 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3">
<div className="relative w-full sm:w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={e => onSearchChange(e.target.value)}
placeholder="Eintraege durchsuchen..."
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-violet-500 focus:border-transparent"
/>
</div>
{catalog.allowCustom && (
<button
onClick={onAddEntry}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-violet-600 hover:bg-violet-700 rounded-lg transition-colors shrink-0"
>
<Plus className="h-4 w-4" />
Eintrag hinzufuegen
</button>
)}
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900/50">
<th
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
onClick={() => handleSort('name')}
>
Name
<SortIcon field="name" />
</th>
{catalog.categoryField && (
<th
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
onClick={() => handleSort('category')}
>
Kategorie
<SortIcon field="category" />
</th>
)}
<th
className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-200 whitespace-nowrap"
onClick={() => handleSort('type')}
>
Quelle
<SortIcon field="type" />
</th>
<th className="px-4 py-3 text-right font-medium text-gray-500 dark:text-gray-400 whitespace-nowrap">
Aktionen
</th>
</tr>
</thead>
<tbody>
{sortedEntries.length === 0 ? (
<tr>
<td
colSpan={catalog.categoryField ? 4 : 3}
className="px-4 py-12 text-center text-gray-500 dark:text-gray-400"
>
<div className="flex flex-col items-center gap-2">
<Database className="h-8 w-8 opacity-40" />
<p className="text-sm">
{searchQuery
? `Keine Eintraege gefunden fuer "${searchQuery}"`
: 'Keine Eintraege vorhanden'}
</p>
</div>
</td>
</tr>
) : (
sortedEntries.map((entry) => (
<tr
key={`${entry.source}-${entry.id}`}
className="border-b border-gray-100 dark:border-gray-700/50 hover:bg-gray-50 dark:hover:bg-gray-700/30 transition-colors"
>
<td className="px-4 py-2.5">
<div>
<p className="text-gray-900 dark:text-gray-100 font-medium truncate max-w-xs">
{entry.displayName}
</p>
{entry.displayDescription && (
<p className="text-xs text-gray-500 dark:text-gray-400 truncate max-w-xs mt-0.5">
{entry.displayDescription}
</p>
)}
</div>
</td>
{catalog.categoryField && (
<td className="px-4 py-2.5 text-gray-600 dark:text-gray-300">
{entry.category || '\u2014'}
</td>
)}
<td className="px-4 py-2.5">
{entry.source === 'system' ? (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300">
System
</span>
) : (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900/40 text-blue-700 dark:text-blue-300">
Benutzerdefiniert
</span>
)}
</td>
<td className="px-4 py-2.5 text-right">
<div className="flex items-center justify-end gap-1">
{entry.source === 'system' ? (
<button
onClick={() => onEditCustomEntry(entry)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-gray-600 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
title="Details anzeigen"
>
<Eye className="h-3.5 w-3.5" />
Details
</button>
) : (
<>
<button
onClick={() => onEditCustomEntry(entry)}
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-violet-600 dark:hover:text-violet-400 hover:bg-violet-50 dark:hover:bg-violet-900/20 rounded transition-colors"
title="Bearbeiten"
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
onClick={() => onDeleteCustomEntry(entry.id)}
className="inline-flex items-center p-1.5 text-gray-500 dark:text-gray-400 hover:text-red-600 dark:hover:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 rounded transition-colors"
title="Loeschen"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Footer with count */}
{sortedEntries.length > 0 && (
<div className="px-4 py-3 border-t border-gray-200 dark:border-gray-700 text-xs text-gray-500 dark:text-gray-400">
{sortedEntries.length} {sortedEntries.length === 1 ? 'Eintrag' : 'Eintraege'}
{searchQuery && ` (gefiltert)`}
</div>
)}
</div>
)
}
@@ -0,0 +1,339 @@
'use client'
/**
* ArchitectureView - Shows backend modules and their connection status
*
* This component helps track which backend modules are connected to the frontend
* during migration and ensures no modules get lost.
*/
import { useState } from 'react'
import Link from 'next/link'
import {
MODULE_REGISTRY,
getModulesByCategory,
getModuleStats,
getCategoryStats,
type BackendModule
} from '@/lib/module-registry'
interface ArchitectureViewProps {
category?: BackendModule['category']
showAllCategories?: boolean
}
const STATUS_CONFIG = {
connected: {
label: 'Verbunden',
color: 'bg-green-100 text-green-700 border-green-200',
dot: 'bg-green-500'
},
partial: {
label: 'Teilweise',
color: 'bg-yellow-100 text-yellow-700 border-yellow-200',
dot: 'bg-yellow-500'
},
'not-connected': {
label: 'Nicht verbunden',
color: 'bg-red-100 text-red-700 border-red-200',
dot: 'bg-red-500'
},
deprecated: {
label: 'Veraltet',
color: 'bg-slate-100 text-slate-700 border-slate-200',
dot: 'bg-slate-500'
}
}
const PRIORITY_CONFIG = {
critical: { label: 'Kritisch', color: 'text-red-600' },
high: { label: 'Hoch', color: 'text-orange-600' },
medium: { label: 'Mittel', color: 'text-yellow-600' },
low: { label: 'Niedrig', color: 'text-slate-600' }
}
const CATEGORY_CONFIG: Record<BackendModule['category'], { name: string; icon: string; color: string }> = {
compliance: { name: 'DSGVO & Compliance', icon: 'shield', color: 'purple' },
ai: { name: 'KI & Automatisierung', icon: 'brain', color: 'teal' },
infrastructure: { name: 'Infrastruktur & DevOps', icon: 'server', color: 'orange' },
education: { name: 'Bildung & Schule', icon: 'graduation', color: 'blue' },
communication: { name: 'Kommunikation & Alerts', icon: 'mail', color: 'green' },
development: { name: 'Entwicklung & Produkte', icon: 'code', color: 'slate' }
}
export function ArchitectureView({ category, showAllCategories = false }: ArchitectureViewProps) {
const [expandedModule, setExpandedModule] = useState<string | null>(null)
const [filterStatus, setFilterStatus] = useState<string>('all')
const modules = category && !showAllCategories
? getModulesByCategory(category)
: MODULE_REGISTRY
const filteredModules = filterStatus === 'all'
? modules
: modules.filter(m => m.frontend.status === filterStatus)
const stats = category && !showAllCategories
? getCategoryStats(category)
: getModuleStats()
return (
<div className="space-y-6">
{/* Stats Overview */}
<div className="bg-white rounded-xl shadow-sm border p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-slate-900">
Migrations-Fortschritt
{category && !showAllCategories && (
<span className="ml-2 text-slate-500 font-normal">
- {CATEGORY_CONFIG[category].name}
</span>
)}
</h2>
<span className="text-2xl font-bold text-purple-600">
{stats.percentComplete}%
</span>
</div>
{/* Progress Bar */}
<div className="h-4 bg-slate-200 rounded-full overflow-hidden mb-4">
<div
className="h-full bg-gradient-to-r from-green-500 to-emerald-500 transition-all duration-500"
style={{ width: `${stats.percentComplete}%` }}
/>
</div>
{/* Status Counts */}
<div className="grid grid-cols-4 gap-4">
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{stats.connected}</div>
<div className="text-sm text-slate-500">Verbunden</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-600">{stats.partial}</div>
<div className="text-sm text-slate-500">Teilweise</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-red-600">{stats.notConnected}</div>
<div className="text-sm text-slate-500">Nicht verbunden</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-slate-600">{stats.total}</div>
<div className="text-sm text-slate-500">Gesamt</div>
</div>
</div>
</div>
{/* Filter */}
<div className="flex items-center gap-2">
<span className="text-sm text-slate-500">Filter:</span>
<button
onClick={() => setFilterStatus('all')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'all' ? 'bg-purple-100 text-purple-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Alle ({modules.length})
</button>
<button
onClick={() => setFilterStatus('connected')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'connected' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Verbunden
</button>
<button
onClick={() => setFilterStatus('partial')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'partial' ? 'bg-yellow-100 text-yellow-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Teilweise
</button>
<button
onClick={() => setFilterStatus('not-connected')}
className={`px-3 py-1 rounded-full text-sm ${
filterStatus === 'not-connected' ? 'bg-red-100 text-red-700' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
Nicht verbunden
</button>
</div>
{/* Module List */}
<div className="space-y-3">
{filteredModules.map((module) => (
<div
key={module.id}
className="bg-white rounded-xl shadow-sm border overflow-hidden"
>
{/* Module Header */}
<button
onClick={() => setExpandedModule(expandedModule === module.id ? null : module.id)}
className="w-full px-4 py-3 flex items-center justify-between hover:bg-slate-50 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${STATUS_CONFIG[module.frontend.status].dot}`} />
<div className="text-left">
<div className="font-medium text-slate-900">{module.name}</div>
<div className="text-sm text-slate-500">{module.description}</div>
</div>
</div>
<div className="flex items-center gap-3">
<span className={`px-2 py-1 rounded text-xs border ${STATUS_CONFIG[module.frontend.status].color}`}>
{STATUS_CONFIG[module.frontend.status].label}
</span>
<span className={`text-xs ${PRIORITY_CONFIG[module.priority].color}`}>
{PRIORITY_CONFIG[module.priority].label}
</span>
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${expandedModule === module.id ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</button>
{/* Module Details */}
{expandedModule === module.id && (
<div className="px-4 py-4 border-t border-slate-200 bg-slate-50">
<div className="grid grid-cols-2 gap-6">
{/* Backend Info */}
<div>
<h4 className="font-medium text-slate-900 mb-2">Backend</h4>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<span className="text-slate-500">Service:</span>
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.backend.service}
</code>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500">Port:</span>
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.backend.port}
</code>
</div>
<div className="flex items-center gap-2">
<span className="text-slate-500">Base Path:</span>
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.backend.basePath}
</code>
</div>
</div>
<h5 className="font-medium text-slate-700 mt-4 mb-2">Endpoints</h5>
<div className="space-y-1 max-h-48 overflow-y-auto">
{module.backend.endpoints.map((ep, idx) => (
<div key={idx} className="flex items-center gap-2 text-sm">
<span className={`px-1.5 py-0.5 rounded text-xs font-mono ${
ep.method === 'GET' ? 'bg-blue-100 text-blue-700' :
ep.method === 'POST' ? 'bg-green-100 text-green-700' :
ep.method === 'PUT' ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
{ep.method}
</span>
<code className="text-slate-600 text-xs">{ep.path}</code>
<span className="text-slate-400 text-xs">- {ep.description}</span>
</div>
))}
</div>
</div>
{/* Frontend Info */}
<div>
<h4 className="font-medium text-slate-900 mb-2">Frontend</h4>
<div className="space-y-3 text-sm">
<div>
<span className="text-slate-500 block mb-1">Admin v2 Seite:</span>
{module.frontend.adminV2Page ? (
<Link
href={module.frontend.adminV2Page}
className="text-purple-600 hover:text-purple-800 hover:underline"
>
{module.frontend.adminV2Page}
</Link>
) : (
<span className="text-red-500 italic">Noch nicht angelegt</span>
)}
</div>
<div>
<span className="text-slate-500 block mb-1">Altes Admin (Referenz):</span>
{module.frontend.oldAdminPage ? (
<code className="px-2 py-0.5 bg-slate-200 rounded text-slate-700">
{module.frontend.oldAdminPage}
</code>
) : (
<span className="text-slate-400 italic">-</span>
)}
</div>
{module.notes && (
<div className="mt-3 p-3 bg-yellow-50 border border-yellow-200 rounded-lg">
<span className="text-yellow-700 text-sm">{module.notes}</span>
</div>
)}
{module.dependencies && module.dependencies.length > 0 && (
<div>
<span className="text-slate-500 block mb-1">Abhaengigkeiten:</span>
<div className="flex flex-wrap gap-1">
{module.dependencies.map((dep) => (
<span key={dep} className="px-2 py-0.5 bg-purple-100 text-purple-700 rounded text-xs">
{dep}
</span>
))}
</div>
</div>
)}
</div>
</div>
</div>
</div>
)}
</div>
))}
</div>
{/* Category Summary (if showing all) */}
{showAllCategories && (
<div className="bg-white rounded-xl shadow-sm border p-6 mt-8">
<h3 className="text-lg font-semibold text-slate-900 mb-4">Kategorie-Uebersicht</h3>
<div className="grid grid-cols-2 gap-4">
{(Object.keys(CATEGORY_CONFIG) as BackendModule['category'][]).map((cat) => {
const catStats = getCategoryStats(cat)
return (
<div key={cat} className="p-4 border border-slate-200 rounded-lg">
<div className="flex items-center justify-between mb-2">
<span className="font-medium text-slate-900">{CATEGORY_CONFIG[cat].name}</span>
<span className={`text-sm ${catStats.percentComplete === 100 ? 'text-green-600' : 'text-slate-500'}`}>
{catStats.percentComplete}%
</span>
</div>
<div className="h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className={`h-full transition-all duration-500 ${
catStats.percentComplete === 100 ? 'bg-green-500' :
catStats.percentComplete > 50 ? 'bg-yellow-500' : 'bg-red-500'
}`}
style={{ width: `${catStats.percentComplete}%` }}
/>
</div>
<div className="flex justify-between mt-2 text-xs text-slate-500">
<span>{catStats.connected}/{catStats.total} verbunden</span>
{catStats.notConnected > 0 && (
<span className="text-red-500">{catStats.notConnected} offen</span>
)}
</div>
</div>
)
})}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,73 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { navigation, metaModules, getModuleByHref, getCategoryById, CategoryId } from '@/lib/navigation'
export function Breadcrumbs() {
const pathname = usePathname()
// Build breadcrumb items from path
const items: Array<{ label: string; href: string }> = []
// Always start with Dashboard (Home)
items.push({ label: 'Dashboard', href: '/dashboard' })
// Parse the path
const pathParts = pathname.split('/').filter(Boolean)
if (pathParts.length > 0) {
// Check if it's a category
const categoryId = pathParts[0] as CategoryId
const category = getCategoryById(categoryId)
if (category) {
// Add category
items.push({ label: category.name, href: `/${category.id}` })
// Check if there's a module
if (pathParts.length > 1) {
const moduleHref = `/${pathParts[0]}/${pathParts[1]}`
const result = getModuleByHref(moduleHref)
if (result) {
items.push({ label: result.module.name, href: moduleHref })
}
}
} else {
// Check meta modules (but skip dashboard as it's already added)
const metaModule = metaModules.find(m => m.href === `/${pathParts[0]}`)
if (metaModule && metaModule.href !== '/dashboard') {
items.push({ label: metaModule.name, href: metaModule.href })
}
}
}
// Don't show breadcrumbs for just dashboard
if (items.length <= 1) {
return null
}
return (
<nav className="flex items-center gap-2 text-sm text-slate-500 mb-4">
{items.map((item, index) => (
<span key={`${index}-${item.href}`} className="flex items-center gap-2">
{index > 0 && (
<svg className="w-4 h-4 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
{index === items.length - 1 ? (
<span className="text-slate-900 font-medium">{item.label}</span>
) : (
<Link
href={item.href}
className="hover:text-primary-600 transition-colors"
>
{item.label}
</Link>
)}
</span>
))}
</nav>
)
}
@@ -0,0 +1,510 @@
'use client'
/**
* DataFlowDiagram - Visual representation of module dependencies
*
* Shows how backend services, modules, and frontend pages are connected.
* Uses SVG for rendering connections.
*/
import { useState, useRef, useEffect } from 'react'
import {
MODULE_REGISTRY,
type BackendModule
} from '@/lib/module-registry'
interface NodePosition {
x: number
y: number
width: number
height: number
}
interface ServiceGroup {
name: string
port: number
modules: BackendModule[]
}
const SERVICE_COLORS: Record<string, string> = {
'consent-service': '#8b5cf6', // purple
'python-backend': '#f59e0b', // amber
'klausur-service': '#10b981', // emerald
'voice-service': '#3b82f6', // blue
}
const STATUS_COLORS = {
connected: '#22c55e',
partial: '#eab308',
'not-connected': '#ef4444',
deprecated: '#6b7280'
}
export function DataFlowDiagram() {
const [selectedModule, setSelectedModule] = useState<string | null>(null)
const [hoveredModule, setHoveredModule] = useState<string | null>(null)
const [showLegend, setShowLegend] = useState(true)
const containerRef = useRef<HTMLDivElement>(null)
// Group modules by backend service
const serviceGroups: ServiceGroup[] = []
const seenServices = new Set<string>()
MODULE_REGISTRY.forEach(module => {
if (!seenServices.has(module.backend.service)) {
seenServices.add(module.backend.service)
serviceGroups.push({
name: module.backend.service,
port: module.backend.port,
modules: MODULE_REGISTRY.filter(m => m.backend.service === module.backend.service)
})
}
})
// Calculate positions
const serviceWidth = 280
const serviceSpacing = 40
const moduleHeight = 60
const moduleSpacing = 10
const headerHeight = 50
const padding = 20
const totalWidth = serviceGroups.length * serviceWidth + (serviceGroups.length - 1) * serviceSpacing + padding * 2
const maxModulesInService = Math.max(...serviceGroups.map(s => s.modules.length))
const totalHeight = headerHeight + maxModulesInService * (moduleHeight + moduleSpacing) + padding * 2 + 100
// Get connections between modules (dependencies)
const connections: { from: string; to: string }[] = []
MODULE_REGISTRY.forEach(module => {
if (module.dependencies) {
module.dependencies.forEach(dep => {
connections.push({ from: module.id, to: dep })
})
}
})
// Get module position
const getModulePosition = (moduleId: string): NodePosition | null => {
let serviceIndex = 0
for (const service of serviceGroups) {
const moduleIndex = service.modules.findIndex(m => m.id === moduleId)
if (moduleIndex !== -1) {
return {
x: padding + serviceIndex * (serviceWidth + serviceSpacing) + serviceWidth / 2,
y: headerHeight + moduleIndex * (moduleHeight + moduleSpacing) + moduleHeight / 2 + 40,
width: serviceWidth - 40,
height: moduleHeight
}
}
serviceIndex++
}
return null
}
// Check if module is related to selected/hovered module
const isRelated = (moduleId: string): boolean => {
const target = selectedModule || hoveredModule
if (!target) return false
// Direct match
if (moduleId === target) return true
// Check dependencies
const targetModule = MODULE_REGISTRY.find(m => m.id === target)
if (targetModule?.dependencies?.includes(moduleId)) return true
// Check reverse dependencies
const module = MODULE_REGISTRY.find(m => m.id === moduleId)
if (module?.dependencies?.includes(target)) return true
return false
}
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-slate-900">Datenfluss-Diagramm</h3>
<div className="flex items-center gap-4">
<button
onClick={() => setShowLegend(!showLegend)}
className="text-sm text-slate-600 hover:text-slate-900"
>
{showLegend ? 'Legende ausblenden' : 'Legende anzeigen'}
</button>
{selectedModule && (
<button
onClick={() => setSelectedModule(null)}
className="px-3 py-1 bg-purple-100 text-purple-700 rounded-full text-sm"
>
Auswahl aufheben
</button>
)}
</div>
</div>
{/* Legend */}
{showLegend && (
<div className="bg-white rounded-xl shadow-sm border p-4 flex flex-wrap items-center gap-6">
<span className="text-sm font-medium text-slate-700">Services:</span>
{Object.entries(SERVICE_COLORS).map(([service, color]) => (
<div key={service} className="flex items-center gap-2">
<div className="w-3 h-3 rounded" style={{ backgroundColor: color }} />
<span className="text-sm text-slate-600">{service}</span>
</div>
))}
<span className="text-sm font-medium text-slate-700 ml-4">Status:</span>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-green-500" />
<span className="text-sm text-slate-600">Verbunden</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-yellow-500" />
<span className="text-sm text-slate-600">Teilweise</span>
</div>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-sm text-slate-600">Nicht verbunden</span>
</div>
</div>
)}
{/* Diagram */}
<div
ref={containerRef}
className="bg-white rounded-xl shadow-sm border overflow-x-auto"
>
<svg
width={totalWidth}
height={totalHeight}
className="min-w-full"
>
<defs>
{/* Arrow marker */}
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#a78bfa" />
</marker>
<marker
id="arrowhead-highlight"
markerWidth="10"
markerHeight="7"
refX="9"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#8b5cf6" />
</marker>
</defs>
{/* Background Grid */}
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#f1f5f9" strokeWidth="1"/>
</pattern>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Service Groups */}
{serviceGroups.map((service, serviceIdx) => {
const x = padding + serviceIdx * (serviceWidth + serviceSpacing)
const serviceColor = SERVICE_COLORS[service.name] || '#6b7280'
return (
<g key={service.name}>
{/* Service Container */}
<rect
x={x}
y={padding}
width={serviceWidth}
height={totalHeight - padding * 2}
fill={`${serviceColor}10`}
stroke={serviceColor}
strokeWidth="2"
rx="12"
/>
{/* Service Header */}
<rect
x={x}
y={padding}
width={serviceWidth}
height={headerHeight}
fill={serviceColor}
rx="12"
/>
<rect
x={x}
y={padding + headerHeight - 12}
width={serviceWidth}
height={12}
fill={serviceColor}
/>
<text
x={x + serviceWidth / 2}
y={padding + headerHeight / 2 + 5}
textAnchor="middle"
fill="white"
fontSize="14"
fontWeight="600"
>
{service.name}
</text>
<text
x={x + serviceWidth / 2}
y={padding + headerHeight - 8}
textAnchor="middle"
fill="rgba(255,255,255,0.7)"
fontSize="11"
>
Port {service.port}
</text>
{/* Modules */}
{service.modules.map((module, moduleIdx) => {
const moduleX = x + 20
const moduleY = padding + headerHeight + 20 + moduleIdx * (moduleHeight + moduleSpacing)
const isSelected = selectedModule === module.id
const isHovered = hoveredModule === module.id
const related = isRelated(module.id)
const statusColor = STATUS_COLORS[module.frontend.status]
const opacity = (selectedModule || hoveredModule)
? (related ? 1 : 0.3)
: 1
return (
<g
key={module.id}
onClick={() => setSelectedModule(isSelected ? null : module.id)}
onMouseEnter={() => setHoveredModule(module.id)}
onMouseLeave={() => setHoveredModule(null)}
style={{ cursor: 'pointer', opacity }}
className="transition-opacity duration-200"
>
{/* Module Box */}
<rect
x={moduleX}
y={moduleY}
width={serviceWidth - 40}
height={moduleHeight}
fill="white"
stroke={isSelected || isHovered ? serviceColor : '#e2e8f0'}
strokeWidth={isSelected || isHovered ? 2 : 1}
rx="8"
filter={isSelected ? 'drop-shadow(0 4px 6px rgba(0,0,0,0.1))' : undefined}
/>
{/* Status Indicator */}
<circle
cx={moduleX + 16}
cy={moduleY + moduleHeight / 2}
r="5"
fill={statusColor}
/>
{/* Module Name */}
<text
x={moduleX + 30}
y={moduleY + 24}
fill="#1e293b"
fontSize="12"
fontWeight="500"
>
{module.name.length > 25 ? module.name.slice(0, 25) + '...' : module.name}
</text>
{/* Module ID */}
<text
x={moduleX + 30}
y={moduleY + 42}
fill="#94a3b8"
fontSize="10"
>
{module.id}
</text>
{/* Priority Badge */}
<rect
x={moduleX + serviceWidth - 90}
y={moduleY + 20}
width={40}
height={18}
fill={
module.priority === 'critical' ? '#fef2f2' :
module.priority === 'high' ? '#fff7ed' :
module.priority === 'medium' ? '#fefce8' :
'#f1f5f9'
}
rx="4"
/>
<text
x={moduleX + serviceWidth - 70}
y={moduleY + 33}
textAnchor="middle"
fill={
module.priority === 'critical' ? '#dc2626' :
module.priority === 'high' ? '#ea580c' :
module.priority === 'medium' ? '#ca8a04' :
'#64748b'
}
fontSize="9"
fontWeight="500"
>
{module.priority.toUpperCase()}
</text>
{/* Dependency indicator */}
{module.dependencies && module.dependencies.length > 0 && (
<g>
<circle
cx={moduleX + serviceWidth - 55}
cy={moduleY + moduleHeight - 12}
r="8"
fill="#f3e8ff"
/>
<text
x={moduleX + serviceWidth - 55}
y={moduleY + moduleHeight - 8}
textAnchor="middle"
fill="#8b5cf6"
fontSize="9"
fontWeight="600"
>
{module.dependencies.length}
</text>
</g>
)}
</g>
)
})}
</g>
)
})}
{/* Connections (Dependencies) */}
{connections.map((conn, idx) => {
const fromPos = getModulePosition(conn.from)
const toPos = getModulePosition(conn.to)
if (!fromPos || !toPos) return null
const isHighlighted = (selectedModule || hoveredModule) &&
(conn.from === (selectedModule || hoveredModule) || conn.to === (selectedModule || hoveredModule))
const opacity = (selectedModule || hoveredModule)
? (isHighlighted ? 1 : 0.1)
: 0.4
// Calculate curved path
const startX = fromPos.x
const startY = fromPos.y
const endX = toPos.x
const endY = toPos.y
const midX = (startX + endX) / 2
const controlOffset = Math.abs(startX - endX) * 0.3
const path = startX < endX
? `M ${startX + fromPos.width / 2} ${startY}
C ${startX + fromPos.width / 2 + controlOffset} ${startY},
${endX - toPos.width / 2 - controlOffset} ${endY},
${endX - toPos.width / 2} ${endY}`
: `M ${startX - fromPos.width / 2} ${startY}
C ${startX - fromPos.width / 2 - controlOffset} ${startY},
${endX + toPos.width / 2 + controlOffset} ${endY},
${endX + toPos.width / 2} ${endY}`
return (
<path
key={idx}
d={path}
fill="none"
stroke={isHighlighted ? '#8b5cf6' : '#a78bfa'}
strokeWidth={isHighlighted ? 2 : 1.5}
strokeDasharray={isHighlighted ? undefined : '4 2'}
markerEnd={isHighlighted ? 'url(#arrowhead-highlight)' : 'url(#arrowhead)'}
opacity={opacity}
className="transition-opacity duration-200"
/>
)
})}
{/* Frontend Layer (Bottom) */}
<g>
<rect
x={padding}
y={totalHeight - 80}
width={totalWidth - padding * 2}
height={60}
fill="#f8fafc"
stroke="#e2e8f0"
strokeWidth="1"
rx="8"
/>
<text
x={totalWidth / 2}
y={totalHeight - 55}
textAnchor="middle"
fill="#64748b"
fontSize="12"
fontWeight="500"
>
Admin v2 Frontend (Next.js - Port 3002)
</text>
<text
x={totalWidth / 2}
y={totalHeight - 35}
textAnchor="middle"
fill="#94a3b8"
fontSize="11"
>
/compliance | /ai | /infrastructure | /education | /communication | /development
</text>
</g>
</svg>
</div>
{/* Selected Module Details */}
{selectedModule && (
<div className="bg-purple-50 border border-purple-200 rounded-xl p-4">
<h4 className="font-medium text-purple-900 mb-2">
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.name}
</h4>
<div className="text-sm text-purple-700 space-y-1">
<p>ID: <code className="bg-purple-100 px-1 rounded">{selectedModule}</code></p>
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies && (
<p>
Abhaengigkeiten:
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.dependencies?.map(dep => (
<button
key={dep}
onClick={() => setSelectedModule(dep)}
className="ml-2 px-2 py-0.5 bg-purple-200 text-purple-800 rounded hover:bg-purple-300"
>
{dep}
</button>
))}
</p>
)}
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page && (
<p>
Frontend:
<a
href={MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
className="ml-2 text-purple-600 hover:underline"
>
{MODULE_REGISTRY.find(m => m.id === selectedModule)?.frontend.adminV2Page}
</a>
</p>
)}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,72 @@
interface InfoBoxProps {
variant: 'info' | 'tip' | 'warning' | 'error'
title?: string
children: React.ReactNode
className?: string
}
const variants = {
info: {
bg: 'bg-blue-50',
border: 'border-blue-200',
icon: '💡',
titleColor: 'text-blue-800',
textColor: 'text-blue-700',
},
tip: {
bg: 'bg-green-50',
border: 'border-green-200',
icon: '✨',
titleColor: 'text-green-800',
textColor: 'text-green-700',
},
warning: {
bg: 'bg-amber-50',
border: 'border-amber-200',
icon: '⚠️',
titleColor: 'text-amber-800',
textColor: 'text-amber-700',
},
error: {
bg: 'bg-red-50',
border: 'border-red-200',
icon: '❌',
titleColor: 'text-red-800',
textColor: 'text-red-700',
},
}
export function InfoBox({ variant, title, children, className = '' }: InfoBoxProps) {
const style = variants[variant]
return (
<div className={`${style.bg} ${style.border} border rounded-xl p-4 ${className}`}>
<div className="flex gap-3">
<span className="text-xl flex-shrink-0">{style.icon}</span>
<div>
{title && (
<h4 className={`font-semibold ${style.titleColor} mb-1`}>{title}</h4>
)}
<div className={`text-sm ${style.textColor}`}>{children}</div>
</div>
</div>
</div>
)
}
// Convenience components
export function InfoTip({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="tip" title={title} className={className}>{children}</InfoBox>
}
export function InfoWarning({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="warning" title={title} className={className}>{children}</InfoBox>
}
export function InfoNote({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="info" title={title} className={className}>{children}</InfoBox>
}
export function InfoError({ title, children, className }: Omit<InfoBoxProps, 'variant'>) {
return <InfoBox variant="error" title={title} className={className}>{children}</InfoBox>
}
@@ -0,0 +1,113 @@
import Link from 'next/link'
import { NavModule, NavCategory } from '@/lib/navigation'
interface ModuleCardProps {
module: NavModule
category: NavCategory
showDescription?: boolean
}
export function ModuleCard({ module, category, showDescription = true }: ModuleCardProps) {
return (
<Link
href={module.href}
className={`block p-4 rounded-xl border-2 transition-all hover:shadow-md bg-${category.colorClass}-50 border-${category.colorClass}-200 hover:border-${category.colorClass}-400`}
style={{
backgroundColor: `${category.color}10`,
borderColor: `${category.color}40`,
}}
>
<div className="flex items-start gap-3">
{/* Color indicator */}
<div
className="w-1.5 h-12 rounded-full flex-shrink-0"
style={{ backgroundColor: category.color }}
/>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-slate-900 truncate">{module.name}</h3>
{showDescription && (
<p className="text-sm text-slate-500 mt-1 line-clamp-2">{module.description}</p>
)}
{/* Audience tags */}
<div className="flex flex-wrap gap-1 mt-2">
{module.audience.slice(0, 2).map((a) => (
<span
key={a}
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-slate-600 bg-slate-100"
>
{a}
</span>
))}
</div>
</div>
{/* Arrow */}
<svg
className="w-5 h-5 text-slate-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
)
}
// Category Card for overview pages
interface CategoryCardProps {
category: NavCategory
showModuleCount?: boolean
}
export function CategoryCard({ category, showModuleCount = true }: CategoryCardProps) {
return (
<Link
href={`/${category.id}`}
className="block p-6 rounded-xl border-2 transition-all hover:shadow-lg bg-white"
style={{
borderColor: `${category.color}40`,
}}
>
<div className="flex items-center gap-4">
{/* Icon */}
<div
className="w-12 h-12 rounded-xl flex items-center justify-center"
style={{ backgroundColor: `${category.color}20` }}
>
<span style={{ color: category.color }} className="text-2xl">
{category.icon === 'shield' && '🛡️'}
{category.icon === 'brain' && '🧠'}
{category.icon === 'server' && '🖥️'}
{category.icon === 'graduation' && '🎓'}
{category.icon === 'mail' && '📬'}
{category.icon === 'code' && '💻'}
</span>
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-lg text-slate-900">{category.name}</h3>
<p className="text-sm text-slate-500 line-clamp-1">{category.description}</p>
{showModuleCount && (
<span className="text-xs text-slate-400 mt-1">
{category.modules.length} Module
</span>
)}
</div>
{/* Arrow */}
<svg
className="w-6 h-6 text-slate-400 flex-shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div>
</Link>
)
}
@@ -0,0 +1,169 @@
'use client'
import { useState } from 'react'
import Link from 'next/link'
export interface PagePurposeProps {
title: string
purpose: string
audience: string[]
gdprArticles?: string[]
architecture?: {
services: string[]
databases: string[]
diagram?: string
}
relatedPages?: Array<{
name: string
href: string
description: string
}>
collapsible?: boolean
defaultCollapsed?: boolean
}
export function PagePurpose({
title,
purpose,
audience,
gdprArticles,
architecture,
relatedPages,
collapsible = true,
defaultCollapsed = false,
}: PagePurposeProps) {
const [collapsed, setCollapsed] = useState(defaultCollapsed)
const [showArchitecture, setShowArchitecture] = useState(false)
return (
<div className="bg-gradient-to-r from-slate-50 to-slate-100 rounded-xl border border-slate-200 mb-6 overflow-hidden">
{/* Header */}
<div
className={`flex items-center justify-between px-4 py-3 ${
collapsible ? 'cursor-pointer hover:bg-slate-100' : ''
}`}
onClick={collapsible ? () => setCollapsed(!collapsed) : undefined}
>
<div className="flex items-center gap-2">
<span className="text-lg">🎯</span>
<span className="font-semibold text-slate-700">Warum gibt es diese Seite?</span>
</div>
{collapsible && (
<svg
className={`w-5 h-5 text-slate-400 transition-transform ${collapsed ? '' : 'rotate-180'}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
)}
</div>
{/* Content */}
{!collapsed && (
<div className="px-4 pb-4 space-y-4">
{/* Purpose */}
<p className="text-slate-600">{purpose}</p>
{/* Metadata */}
<div className="flex flex-wrap gap-4 text-sm">
{/* Audience */}
<div className="flex items-center gap-2">
<span className="text-slate-400">👥</span>
<span className="text-slate-500">Zielgruppe:</span>
<span className="text-slate-700">{audience.join(', ')}</span>
</div>
{/* GDPR Articles */}
{gdprArticles && gdprArticles.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-slate-400">📋</span>
<span className="text-slate-500">DSGVO-Bezug:</span>
<span className="text-slate-700">{gdprArticles.join(', ')}</span>
</div>
)}
</div>
{/* Architecture (expandable) */}
{architecture && (
<div className="border-t border-slate-200 pt-3">
<button
onClick={(e) => {
e.stopPropagation()
setShowArchitecture(!showArchitecture)
}}
className="flex items-center gap-2 text-sm text-slate-500 hover:text-slate-700"
>
<span>🏗</span>
<span>Architektur</span>
<svg
className={`w-4 h-4 transition-transform ${showArchitecture ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showArchitecture && (
<div className="mt-2 p-3 bg-white rounded-lg border border-slate-200 text-sm">
<div className="grid grid-cols-2 gap-4">
<div>
<span className="font-medium text-slate-600">Services:</span>
<ul className="mt-1 space-y-1 text-slate-500">
{architecture.services.map((service) => (
<li key={service} className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-primary-400 rounded-full" />
{service}
</li>
))}
</ul>
</div>
<div>
<span className="font-medium text-slate-600">Datenbanken:</span>
<ul className="mt-1 space-y-1 text-slate-500">
{architecture.databases.map((db) => (
<li key={db} className="flex items-center gap-1">
<span className="w-1.5 h-1.5 bg-green-400 rounded-full" />
{db}
</li>
))}
</ul>
</div>
</div>
</div>
)}
</div>
)}
{/* Related Pages */}
{relatedPages && relatedPages.length > 0 && (
<div className="border-t border-slate-200 pt-3">
<span className="text-sm text-slate-500 flex items-center gap-2 mb-2">
<span>🔗</span>
<span>Verwandte Seiten</span>
</span>
<div className="flex flex-wrap gap-2">
{relatedPages.map((page) => (
<Link
key={page.href}
href={page.href}
className="inline-flex items-center gap-1 px-3 py-1.5 bg-white border border-slate-200 rounded-lg text-sm text-slate-600 hover:border-primary-300 hover:text-primary-600 transition-colors"
title={page.description}
>
<span>{page.name}</span>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</Link>
))}
</div>
</div>
)}
</div>
)}
</div>
)
}
@@ -0,0 +1,203 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
interface ServiceHealth {
name: string
port: number
status: 'online' | 'offline' | 'checking' | 'degraded'
responseTime?: number
details?: string
category: 'core' | 'ai' | 'database' | 'storage'
}
// Initial services list for loading state
const INITIAL_SERVICES: Omit<ServiceHealth, 'status' | 'responseTime' | 'details'>[] = [
{ name: 'Backend API', port: 8000, category: 'core' },
{ name: 'Consent Service', port: 8081, category: 'core' },
{ name: 'Voice Service', port: 8091, category: 'core' },
{ name: 'Klausur Service', port: 8086, category: 'core' },
{ name: 'Mail Service (Mailpit)', port: 8025, category: 'core' },
{ name: 'Edu Search', port: 8088, category: 'core' },
{ name: 'H5P Service', port: 8092, category: 'core' },
{ name: 'Ollama/LLM', port: 11434, category: 'ai' },
{ name: 'Embedding Service', port: 8087, category: 'ai' },
{ name: 'PostgreSQL', port: 5432, category: 'database' },
{ name: 'Qdrant (Vector DB)', port: 6333, category: 'database' },
{ name: 'Valkey (Cache)', port: 6379, category: 'database' },
{ name: 'MinIO (S3)', port: 9000, category: 'storage' },
]
export function ServiceStatus() {
const [services, setServices] = useState<ServiceHealth[]>(
INITIAL_SERVICES.map(s => ({ ...s, status: 'checking' as const }))
)
const [lastChecked, setLastChecked] = useState<Date | null>(null)
const [isRefreshing, setIsRefreshing] = useState(false)
const checkServices = useCallback(async () => {
setIsRefreshing(true)
try {
// Use server-side API route to avoid mixed-content issues
const response = await fetch('/api/admin/health', {
method: 'GET',
signal: AbortSignal.timeout(15000),
})
if (response.ok) {
const data = await response.json()
setServices(data.services.map((s: ServiceHealth) => ({
...s,
status: s.status as 'online' | 'offline' | 'degraded'
})))
} else {
// If API fails, mark all as offline
setServices(prev => prev.map(s => ({
...s,
status: 'offline' as const,
details: 'Health-Check API nicht erreichbar'
})))
}
} catch (error) {
// Network error - mark all as offline
setServices(prev => prev.map(s => ({
...s,
status: 'offline' as const,
details: error instanceof Error ? error.message : 'Verbindungsfehler'
})))
}
setLastChecked(new Date())
setIsRefreshing(false)
}, [])
useEffect(() => {
checkServices()
// Auto-refresh every 30 seconds
const interval = setInterval(checkServices, 30000)
return () => clearInterval(interval)
}, [checkServices])
const getStatusColor = (status: ServiceHealth['status']) => {
switch (status) {
case 'online': return 'bg-green-500'
case 'offline': return 'bg-red-500'
case 'degraded': return 'bg-yellow-500'
case 'checking': return 'bg-slate-300 animate-pulse'
}
}
const getStatusText = (status: ServiceHealth['status']) => {
switch (status) {
case 'online': return 'Online'
case 'offline': return 'Offline'
case 'degraded': return 'Eingeschränkt'
case 'checking': return 'Prüfe...'
}
}
const getCategoryIcon = (category: ServiceHealth['category']) => {
switch (category) {
case 'core': return '⚙️'
case 'ai': return '🤖'
case 'database': return '🗄️'
case 'storage': return '📦'
}
}
const getCategoryLabel = (category: ServiceHealth['category']) => {
switch (category) {
case 'core': return 'Core Services'
case 'ai': return 'AI / LLM'
case 'database': return 'Datenbanken'
case 'storage': return 'Storage'
}
}
const groupedServices = services.reduce((acc, service) => {
if (!acc[service.category]) {
acc[service.category] = []
}
acc[service.category].push(service)
return acc
}, {} as Record<string, ServiceHealth[]>)
const onlineCount = services.filter(s => s.status === 'online').length
const totalCount = services.length
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm">
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<h3 className="font-semibold text-slate-900">System Status</h3>
<span className={`text-xs px-2 py-0.5 rounded-full ${
onlineCount === totalCount
? 'bg-green-100 text-green-700'
: onlineCount > totalCount / 2
? 'bg-yellow-100 text-yellow-700'
: 'bg-red-100 text-red-700'
}`}>
{onlineCount}/{totalCount} online
</span>
</div>
<button
onClick={checkServices}
disabled={isRefreshing}
className="text-sm text-primary-600 hover:text-primary-700 disabled:opacity-50 flex items-center gap-1"
>
<svg className={`w-4 h-4 ${isRefreshing ? 'animate-spin' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
Aktualisieren
</button>
</div>
<div className="p-4 space-y-4">
{(['ai', 'core', 'database', 'storage'] as const).map(category => (
<div key={category}>
<div className="flex items-center gap-2 mb-2">
<span>{getCategoryIcon(category)}</span>
<span className="text-xs font-medium text-slate-500 uppercase tracking-wider">
{getCategoryLabel(category)}
</span>
</div>
<div className="space-y-2">
{groupedServices[category]?.map((service) => (
<div key={service.name} className="flex items-center justify-between py-1">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${getStatusColor(service.status)}`}></span>
<span className="text-sm text-slate-700">{service.name}</span>
<span className="text-xs text-slate-400">:{service.port}</span>
</div>
<div className="flex items-center gap-2">
{service.details && (
<span className="text-xs text-slate-500">{service.details}</span>
)}
{service.responseTime !== undefined && service.status === 'online' && (
<span className="text-xs text-slate-400">{service.responseTime}ms</span>
)}
<span className={`text-xs ${
service.status === 'online' ? 'text-green-600' :
service.status === 'offline' ? 'text-red-600' :
service.status === 'degraded' ? 'text-yellow-600' :
'text-slate-400'
}`}>
{getStatusText(service.status)}
</span>
</div>
</div>
))}
</div>
</div>
))}
</div>
{lastChecked && (
<div className="px-4 py-2 border-t border-slate-100 text-xs text-slate-400">
Zuletzt geprüft: {lastChecked.toLocaleTimeString('de-DE')}
</div>
)}
</div>
)
}
@@ -0,0 +1,217 @@
/**
* Skeleton Loading Components
*
* Animated placeholder components for loading states.
* Used throughout the app for smoother UX during async operations.
*/
import { ReactNode } from 'react'
interface SkeletonTextProps {
/** Number of lines to display */
lines?: number
/** Width variants for varied line lengths */
variant?: 'uniform' | 'varied' | 'paragraph'
/** Custom class names */
className?: string
}
/**
* Animated skeleton text placeholder
*/
export function SkeletonText({ lines = 3, variant = 'varied', className = '' }: SkeletonTextProps) {
const getLineWidth = (index: number) => {
if (variant === 'uniform') return 'w-full'
if (variant === 'paragraph') {
// Last line is shorter for paragraph effect
if (index === lines - 1) return 'w-3/5'
return 'w-full'
}
// Varied widths
const widths = ['w-full', 'w-4/5', 'w-3/4', 'w-5/6', 'w-2/3']
return widths[index % widths.length]
}
return (
<div className={`space-y-3 ${className}`}>
{Array.from({ length: lines }).map((_, i) => (
<div
key={i}
className={`h-4 bg-slate-200 rounded animate-pulse ${getLineWidth(i)}`}
style={{ animationDelay: `${i * 100}ms` }}
/>
))}
</div>
)
}
interface SkeletonBoxProps {
/** Width (Tailwind class or px) */
width?: string
/** Height (Tailwind class or px) */
height?: string
/** Custom class names */
className?: string
/** Border radius */
rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'
}
/**
* Animated skeleton box for images, avatars, cards
*/
export function SkeletonBox({
width = 'w-full',
height = 'h-32',
className = '',
rounded = 'lg'
}: SkeletonBoxProps) {
const roundedClass = {
none: '',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
full: 'rounded-full'
}[rounded]
return (
<div
className={`bg-slate-200 animate-pulse ${width} ${height} ${roundedClass} ${className}`}
/>
)
}
interface SkeletonCardProps {
/** Show image placeholder */
showImage?: boolean
/** Number of text lines */
lines?: number
/** Custom class names */
className?: string
}
/**
* Skeleton card with optional image and text lines
*/
export function SkeletonCard({ showImage = true, lines = 3, className = '' }: SkeletonCardProps) {
return (
<div className={`bg-white rounded-xl shadow-sm border p-4 ${className}`}>
{showImage && (
<SkeletonBox height="h-32" className="mb-4" />
)}
<SkeletonBox width="w-2/3" height="h-5" className="mb-3" />
<SkeletonText lines={lines} variant="paragraph" />
</div>
)
}
interface SkeletonOCRResultProps {
/** Custom class names */
className?: string
}
/**
* Skeleton specifically for OCR results display
* Shows loading state while OCR is processing
*/
export function SkeletonOCRResult({ className = '' }: SkeletonOCRResultProps) {
return (
<div className={`bg-slate-50 rounded-lg p-4 ${className}`}>
<div className="flex items-center gap-2 mb-4">
<div className="w-4 h-4 rounded-full bg-purple-200 animate-pulse" />
<SkeletonBox width="w-32" height="h-4" rounded="md" />
</div>
{/* Text area skeleton */}
<div className="bg-white border p-3 rounded space-y-2 mb-4">
<SkeletonText lines={4} variant="paragraph" />
</div>
{/* Metrics grid skeleton */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="bg-white border rounded p-2">
<SkeletonBox width="w-16" height="h-3" className="mb-2" rounded="sm" />
<SkeletonBox width="w-12" height="h-5" rounded="sm" />
</div>
))}
</div>
</div>
)
}
interface SkeletonWrapperProps {
/** Whether to show skeleton or children */
loading: boolean
/** Content to show when not loading */
children: ReactNode
/** Skeleton component or elements to show */
skeleton?: ReactNode
/** Fallback skeleton lines (if skeleton prop not provided) */
lines?: number
/** Custom class names */
className?: string
}
/**
* Wrapper component that toggles between skeleton and content
*/
export function SkeletonWrapper({
loading,
children,
skeleton,
lines = 3,
className = ''
}: SkeletonWrapperProps) {
if (loading) {
return skeleton ? <>{skeleton}</> : <SkeletonText lines={lines} className={className} />
}
return <>{children}</>
}
interface SkeletonProgressProps {
/** Animation speed in seconds */
speed?: number
/** Custom class names */
className?: string
}
/**
* Animated progress skeleton with shimmer effect
*/
export function SkeletonProgress({ speed = 1.5, className = '' }: SkeletonProgressProps) {
return (
<div className={`relative overflow-hidden bg-slate-200 rounded-full h-2 ${className}`}>
<div
className="absolute inset-0 bg-gradient-to-r from-slate-200 via-slate-300 to-slate-200 animate-shimmer"
style={{
backgroundSize: '200% 100%',
animation: `shimmer ${speed}s infinite linear`
}}
/>
<style jsx>{`
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
`}</style>
</div>
)
}
/**
* Pulsing dot indicator for inline loading
*/
export function SkeletonDots({ className = '' }: { className?: string }) {
return (
<span className={`inline-flex items-center gap-1 ${className}`}>
{[0, 1, 2].map((i) => (
<span
key={i}
className="w-1.5 h-1.5 rounded-full bg-purple-500 animate-pulse"
style={{ animationDelay: `${i * 200}ms` }}
/>
))}
</span>
)
}
@@ -0,0 +1,312 @@
'use client'
import { useState, useEffect, useCallback } from 'react'
import { Settings, MessageSquare, HelpCircle, RefreshCw } from 'lucide-react'
import { CompanionMode, TeacherSettings, FeedbackType } from '@/lib/companion/types'
import { DEFAULT_TEACHER_SETTINGS, STORAGE_KEYS } from '@/lib/companion/constants'
// Components
import { ModeToggle } from './ModeToggle'
import { PhaseTimeline } from './companion-mode/PhaseTimeline'
import { StatsGrid } from './companion-mode/StatsGrid'
import { SuggestionList } from './companion-mode/SuggestionList'
import { EventsCard } from './companion-mode/EventsCard'
import { LessonContainer } from './lesson-mode/LessonContainer'
import { SettingsModal } from './modals/SettingsModal'
import { FeedbackModal } from './modals/FeedbackModal'
import { OnboardingModal } from './modals/OnboardingModal'
// Hooks
import { useCompanionData } from '@/hooks/companion/useCompanionData'
import { useLessonSession } from '@/hooks/companion/useLessonSession'
import { useKeyboardShortcuts } from '@/hooks/companion/useKeyboardShortcuts'
export function CompanionDashboard() {
// Mode state
const [mode, setMode] = useState<CompanionMode>('companion')
// Modal states
const [showSettings, setShowSettings] = useState(false)
const [showFeedback, setShowFeedback] = useState(false)
const [showOnboarding, setShowOnboarding] = useState(false)
// Settings
const [settings, setSettings] = useState<TeacherSettings>(DEFAULT_TEACHER_SETTINGS)
// Load settings from localStorage
useEffect(() => {
const stored = localStorage.getItem(STORAGE_KEYS.SETTINGS)
if (stored) {
try {
const parsed = JSON.parse(stored)
setSettings({ ...DEFAULT_TEACHER_SETTINGS, ...parsed })
} catch {
// Invalid stored settings
}
}
// Check if onboarding needed
const onboardingStored = localStorage.getItem(STORAGE_KEYS.ONBOARDING_STATE)
if (!onboardingStored) {
setShowOnboarding(true)
}
// Restore last mode
const lastMode = localStorage.getItem(STORAGE_KEYS.LAST_MODE) as CompanionMode
if (lastMode && ['companion', 'lesson', 'classic'].includes(lastMode)) {
setMode(lastMode)
}
}, [])
// Save mode to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.LAST_MODE, mode)
}, [mode])
// Companion data hook
const { data: companionData, loading: companionLoading, refresh } = useCompanionData()
// Lesson session hook
const {
session,
startLesson,
endLesson,
pauseLesson,
resumeLesson,
extendTime,
skipPhase,
saveReflection,
addHomework,
removeHomework,
isPaused,
} = useLessonSession({
onOvertimeStart: () => {
// Play sound if enabled
if (settings.soundNotifications) {
// TODO: Play notification sound
}
},
})
// Handle pause/resume toggle
const handlePauseToggle = useCallback(() => {
if (isPaused) {
resumeLesson()
} else {
pauseLesson()
}
}, [isPaused, pauseLesson, resumeLesson])
// Keyboard shortcuts
useKeyboardShortcuts({
onPauseResume: mode === 'lesson' && session ? handlePauseToggle : undefined,
onExtend: mode === 'lesson' && session && !isPaused ? () => extendTime(5) : undefined,
onNextPhase: mode === 'lesson' && session && !isPaused ? skipPhase : undefined,
onCloseModal: () => {
setShowSettings(false)
setShowFeedback(false)
setShowOnboarding(false)
},
enabled: settings.showKeyboardShortcuts,
})
// Handle settings save
const handleSaveSettings = (newSettings: TeacherSettings) => {
setSettings(newSettings)
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(newSettings))
}
// Handle feedback submit
const handleFeedbackSubmit = async (type: FeedbackType, title: string, description: string) => {
const response = await fetch('/api/admin/companion/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
type,
title,
description,
sessionId: session?.sessionId,
}),
})
if (!response.ok) {
throw new Error('Failed to submit feedback')
}
}
// Handle onboarding complete
const handleOnboardingComplete = (data: { state?: string; schoolType?: string }) => {
localStorage.setItem(STORAGE_KEYS.ONBOARDING_STATE, JSON.stringify({
...data,
completed: true,
completedAt: new Date().toISOString(),
}))
setShowOnboarding(false)
setSettings({ ...settings, onboardingCompleted: true })
}
// Handle lesson start
const handleStartLesson = (data: { classId: string; subject: string; topic?: string; templateId?: string }) => {
startLesson(data)
setMode('lesson')
}
return (
<div className={`min-h-[calc(100vh-200px)] ${settings.highContrastMode ? 'high-contrast' : ''}`}>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<ModeToggle
currentMode={mode}
onModeChange={setMode}
disabled={!!session && session.status === 'in_progress'}
/>
<div className="flex items-center gap-2">
{/* Refresh Button */}
{mode === 'companion' && (
<button
onClick={refresh}
disabled={companionLoading}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Aktualisieren"
>
<RefreshCw className={`w-5 h-5 ${companionLoading ? 'animate-spin' : ''}`} />
</button>
)}
{/* Feedback Button */}
<button
onClick={() => setShowFeedback(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Feedback"
>
<MessageSquare className="w-5 h-5" />
</button>
{/* Settings Button */}
<button
onClick={() => setShowSettings(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Einstellungen"
>
<Settings className="w-5 h-5" />
</button>
{/* Help Button */}
<button
onClick={() => setShowOnboarding(true)}
className="p-2 text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
title="Hilfe"
>
<HelpCircle className="w-5 h-5" />
</button>
</div>
</div>
{/* Main Content */}
{mode === 'companion' && (
<div className="space-y-6">
{/* Phase Timeline */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Aktuelle Phase</h3>
{companionData ? (
<PhaseTimeline
phases={companionData.phases}
currentPhaseIndex={companionData.phases.findIndex(p => p.status === 'active')}
/>
) : (
<div className="h-10 bg-slate-100 rounded animate-pulse" />
)}
</div>
{/* Stats */}
<StatsGrid
stats={companionData?.stats || { classesCount: 0, studentsCount: 0, learningUnitsCreated: 0, gradesEntered: 0 }}
loading={companionLoading}
/>
{/* Two Column Layout */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Suggestions */}
<SuggestionList
suggestions={companionData?.suggestions || []}
loading={companionLoading}
onSuggestionClick={(suggestion) => {
// Navigate to action target
window.location.href = suggestion.actionTarget
}}
/>
{/* Events */}
<EventsCard
events={companionData?.upcomingEvents || []}
loading={companionLoading}
/>
</div>
{/* Quick Start Lesson Button */}
<div className="bg-gradient-to-r from-blue-500 to-blue-600 rounded-xl p-6 text-white">
<div className="flex items-center justify-between">
<div>
<h3 className="text-xl font-semibold mb-1">Bereit fuer die naechste Stunde?</h3>
<p className="text-blue-100">Starten Sie den Lesson-Modus fuer strukturierten Unterricht.</p>
</div>
<button
onClick={() => setMode('lesson')}
className="px-6 py-3 bg-white text-blue-600 rounded-xl font-semibold hover:bg-blue-50 transition-colors"
>
Stunde starten
</button>
</div>
</div>
</div>
)}
{mode === 'lesson' && (
<LessonContainer
session={session}
onStartLesson={handleStartLesson}
onEndLesson={endLesson}
onPauseToggle={handlePauseToggle}
onExtendTime={extendTime}
onSkipPhase={skipPhase}
onSaveReflection={saveReflection}
onAddHomework={addHomework}
onRemoveHomework={removeHomework}
/>
)}
{mode === 'classic' && (
<div className="bg-white border border-slate-200 rounded-xl p-8 text-center">
<h2 className="text-xl font-semibold text-slate-800 mb-2">Classic Mode</h2>
<p className="text-slate-600 mb-4">
Die klassische Ansicht ohne Timer und Phasenstruktur.
</p>
<p className="text-sm text-slate-500">
Dieser Modus ist fuer flexible Unterrichtsgestaltung gedacht.
</p>
</div>
)}
{/* Modals */}
<SettingsModal
isOpen={showSettings}
onClose={() => setShowSettings(false)}
settings={settings}
onSave={handleSaveSettings}
/>
<FeedbackModal
isOpen={showFeedback}
onClose={() => setShowFeedback(false)}
onSubmit={handleFeedbackSubmit}
/>
<OnboardingModal
isOpen={showOnboarding}
onClose={() => setShowOnboarding(false)}
onComplete={handleOnboardingComplete}
/>
</div>
)
}
@@ -0,0 +1,61 @@
'use client'
import { GraduationCap, Timer, Layout } from 'lucide-react'
import { CompanionMode } from '@/lib/companion/types'
interface ModeToggleProps {
currentMode: CompanionMode
onModeChange: (mode: CompanionMode) => void
disabled?: boolean
}
const modes: { id: CompanionMode; label: string; icon: React.ReactNode; description: string }[] = [
{
id: 'companion',
label: 'Companion',
icon: <GraduationCap className="w-4 h-4" />,
description: 'Dashboard mit Vorschlaegen',
},
{
id: 'lesson',
label: 'Lesson',
icon: <Timer className="w-4 h-4" />,
description: 'Timer und Phasen',
},
{
id: 'classic',
label: 'Classic',
icon: <Layout className="w-4 h-4" />,
description: 'Klassische Ansicht',
},
]
export function ModeToggle({ currentMode, onModeChange, disabled }: ModeToggleProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-1 inline-flex gap-1">
{modes.map((mode) => {
const isActive = currentMode === mode.id
return (
<button
key={mode.id}
onClick={() => onModeChange(mode.id)}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium
transition-all duration-200
${isActive
? 'bg-slate-900 text-white shadow-sm'
: 'text-slate-600 hover:bg-slate-100 hover:text-slate-900'
}
${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
`}
title={mode.description}
>
{mode.icon}
<span>{mode.label}</span>
</button>
)
})}
</div>
)
}
@@ -0,0 +1,173 @@
'use client'
import { Calendar, FileQuestion, Users, Clock, ChevronRight } from 'lucide-react'
import { UpcomingEvent, EventType } from '@/lib/companion/types'
import { EVENT_TYPE_CONFIG } from '@/lib/companion/constants'
interface EventsCardProps {
events: UpcomingEvent[]
onEventClick?: (event: UpcomingEvent) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
FileQuestion,
Users,
Clock,
Calendar,
}
function getEventIcon(type: EventType) {
const config = EVENT_TYPE_CONFIG[type]
const Icon = iconMap[config.icon] || Calendar
return { Icon, ...config }
}
function formatEventDate(dateStr: string, inDays: number): string {
if (inDays === 0) return 'Heute'
if (inDays === 1) return 'Morgen'
if (inDays < 7) return `In ${inDays} Tagen`
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
interface EventItemProps {
event: UpcomingEvent
onClick?: () => void
}
function EventItem({ event, onClick }: EventItemProps) {
const { Icon, color, bg } = getEventIcon(event.type)
const isUrgent = event.inDays <= 2
return (
<button
onClick={onClick}
className={`
w-full flex items-center gap-3 p-3 rounded-lg
transition-all duration-200
hover:bg-slate-50
${isUrgent ? 'bg-red-50/50' : ''}
`}
>
<div className={`p-2 rounded-lg ${bg}`}>
<Icon className={`w-4 h-4 ${color}`} />
</div>
<div className="flex-1 text-left min-w-0">
<p className="font-medium text-slate-900 truncate">{event.title}</p>
<p className={`text-sm ${isUrgent ? 'text-red-600 font-medium' : 'text-slate-500'}`}>
{formatEventDate(event.date, event.inDays)}
</p>
</div>
<ChevronRight className="w-4 h-4 text-slate-400 flex-shrink-0" />
</button>
)
}
export function EventsCard({
events,
onEventClick,
loading,
maxItems = 5,
}: EventsCardProps) {
const displayEvents = events.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-14 bg-slate-100 rounded-lg animate-pulse" />
))}
</div>
</div>
)
}
if (events.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<div className="text-center py-6">
<Calendar className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-sm text-slate-500">Keine anstehenden Termine</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Calendar className="w-5 h-5 text-blue-500" />
<h3 className="font-semibold text-slate-900">Termine</h3>
</div>
<span className="text-sm text-slate-500">
{events.length} Termin{events.length !== 1 ? 'e' : ''}
</span>
</div>
<div className="space-y-1">
{displayEvents.map((event) => (
<EventItem
key={event.id}
event={event}
onClick={() => onEventClick?.(event)}
/>
))}
</div>
{events.length > maxItems && (
<button className="w-full mt-3 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {events.length} anzeigen
</button>
)}
</div>
)
}
/**
* Compact inline version for header/toolbar
*/
export function EventsInline({ events }: { events: UpcomingEvent[] }) {
const nextEvent = events[0]
if (!nextEvent) {
return (
<div className="flex items-center gap-2 text-sm text-slate-500">
<Calendar className="w-4 h-4" />
<span>Keine Termine</span>
</div>
)
}
const { Icon, color } = getEventIcon(nextEvent.type)
const isUrgent = nextEvent.inDays <= 2
return (
<div className={`flex items-center gap-2 text-sm ${isUrgent ? 'text-red-600' : 'text-slate-600'}`}>
<Icon className={`w-4 h-4 ${color}`} />
<span className="truncate max-w-[150px]">{nextEvent.title}</span>
<span className="text-slate-400">-</span>
<span className={isUrgent ? 'font-medium' : ''}>
{formatEventDate(nextEvent.date, nextEvent.inDays)}
</span>
</div>
)
}
@@ -0,0 +1,203 @@
'use client'
import { Check } from 'lucide-react'
import { Phase } from '@/lib/companion/types'
import { PHASE_COLORS, formatMinutes } from '@/lib/companion/constants'
interface PhaseTimelineProps {
phases: Phase[]
currentPhaseIndex: number
onPhaseClick?: (index: number) => void
compact?: boolean
}
export function PhaseTimeline({
phases,
currentPhaseIndex,
onPhaseClick,
compact = false,
}: PhaseTimelineProps) {
return (
<div className={`flex items-center ${compact ? 'gap-2' : 'gap-3'}`}>
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex items-center">
{/* Phase Dot/Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative flex items-center justify-center
${compact ? 'w-8 h-8' : 'w-10 h-10'}
rounded-full font-semibold text-sm
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive
? `ring-4 ring-offset-2 ${colors.tailwind} text-white`
: isCompleted || isPast
? `${colors.tailwind} text-white opacity-80`
: 'bg-slate-200 text-slate-500'
}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : undefined,
// Use CSS custom property for ring color with Tailwind
'--tw-ring-color': isActive ? colors.hex : undefined,
} as React.CSSProperties}
title={`${phase.displayName} (${formatMinutes(phase.duration)})`}
>
{isCompleted ? (
<Check className={compact ? 'w-4 h-4' : 'w-5 h-5'} />
) : (
phase.shortName
)}
{/* Active indicator pulse */}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-30"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{/* Connector Line */}
{index < phases.length - 1 && (
<div
className={`
${compact ? 'w-4' : 'w-8'} h-1 mx-1
${isPast || isCompleted
? 'bg-gradient-to-r'
: 'bg-slate-200'
}
`}
style={{
background: isPast || isCompleted
? `linear-gradient(to right, ${colors.hex}, ${PHASE_COLORS[phases[index + 1].id].hex})`
: undefined,
}}
/>
)}
</div>
)
})}
</div>
)
}
/**
* Detailed Phase Timeline with labels and durations
*/
export function PhaseTimelineDetailed({
phases,
currentPhaseIndex,
onPhaseClick,
}: PhaseTimelineProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="text-sm font-medium text-slate-500 mb-4">Unterrichtsphasen</h3>
<div className="flex items-start justify-between">
{phases.map((phase, index) => {
const isActive = index === currentPhaseIndex
const isCompleted = phase.status === 'completed'
const isPast = index < currentPhaseIndex
const colors = PHASE_COLORS[phase.id]
return (
<div key={phase.id} className="flex flex-col items-center flex-1">
{/* Top connector line */}
<div className="w-full flex items-center mb-2">
{index > 0 && (
<div
className="flex-1 h-1"
style={{
background: isPast || isCompleted
? PHASE_COLORS[phases[index - 1].id].hex
: '#e2e8f0',
}}
/>
)}
{index === 0 && <div className="flex-1" />}
{/* Phase Circle */}
<button
onClick={() => onPhaseClick?.(index)}
disabled={!onPhaseClick}
className={`
relative w-12 h-12 rounded-full
flex items-center justify-center
font-bold text-lg
transition-all duration-300
${onPhaseClick ? 'cursor-pointer hover:scale-110' : 'cursor-default'}
${isActive ? 'ring-4 ring-offset-2 shadow-lg' : ''}
`}
style={{
backgroundColor: isActive || isCompleted || isPast ? colors.hex : '#e2e8f0',
color: isActive || isCompleted || isPast ? 'white' : '#64748b',
'--tw-ring-color': isActive ? `${colors.hex}40` : undefined,
} as React.CSSProperties}
>
{isCompleted ? (
<Check className="w-6 h-6" />
) : (
phase.shortName
)}
{isActive && (
<span
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: colors.hex }}
/>
)}
</button>
{index < phases.length - 1 && (
<div
className="flex-1 h-1"
style={{
background: isCompleted ? colors.hex : '#e2e8f0',
}}
/>
)}
{index === phases.length - 1 && <div className="flex-1" />}
</div>
{/* Phase Label */}
<span
className={`
text-sm font-medium mt-2
${isActive ? 'text-slate-900' : 'text-slate-500'}
`}
>
{phase.displayName}
</span>
{/* Duration */}
<span
className={`
text-xs mt-1
${isActive ? 'text-slate-700' : 'text-slate-400'}
`}
>
{formatMinutes(phase.duration)}
</span>
{/* Actual time if completed */}
{phase.actualTime !== undefined && phase.actualTime > 0 && (
<span className="text-xs text-slate-400 mt-0.5">
(tatsaechlich: {Math.round(phase.actualTime / 60)} Min)
</span>
)}
</div>
)
})}
</div>
</div>
)
}
@@ -0,0 +1,114 @@
'use client'
import { Users, GraduationCap, BookOpen, FileCheck } from 'lucide-react'
import { CompanionStats } from '@/lib/companion/types'
interface StatsGridProps {
stats: CompanionStats
loading?: boolean
}
interface StatCardProps {
label: string
value: number
icon: React.ReactNode
color: string
loading?: boolean
}
function StatCard({ label, value, icon, color, loading }: StatCardProps) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-4 hover:shadow-md transition-shadow">
<div className="flex items-start justify-between">
<div>
<p className="text-sm text-slate-500 mb-1">{label}</p>
{loading ? (
<div className="h-8 w-16 bg-slate-200 rounded animate-pulse" />
) : (
<p className="text-2xl font-bold text-slate-900">{value}</p>
)}
</div>
<div className={`p-2 rounded-lg ${color}`}>
{icon}
</div>
</div>
</div>
)
}
export function StatsGrid({ stats, loading }: StatsGridProps) {
const statCards = [
{
label: 'Klassen',
value: stats.classesCount,
icon: <Users className="w-5 h-5 text-blue-600" />,
color: 'bg-blue-100',
},
{
label: 'Schueler',
value: stats.studentsCount,
icon: <GraduationCap className="w-5 h-5 text-green-600" />,
color: 'bg-green-100',
},
{
label: 'Lerneinheiten',
value: stats.learningUnitsCreated,
icon: <BookOpen className="w-5 h-5 text-purple-600" />,
color: 'bg-purple-100',
},
{
label: 'Noten',
value: stats.gradesEntered,
icon: <FileCheck className="w-5 h-5 text-amber-600" />,
color: 'bg-amber-100',
},
]
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{statCards.map((card) => (
<StatCard
key={card.label}
label={card.label}
value={card.value}
icon={card.icon}
color={card.color}
loading={loading}
/>
))}
</div>
)
}
/**
* Compact version of StatsGrid for sidebar or smaller spaces
*/
export function StatsGridCompact({ stats, loading }: StatsGridProps) {
const items = [
{ label: 'Klassen', value: stats.classesCount, icon: <Users className="w-4 h-4" /> },
{ label: 'Schueler', value: stats.studentsCount, icon: <GraduationCap className="w-4 h-4" /> },
{ label: 'Einheiten', value: stats.learningUnitsCreated, icon: <BookOpen className="w-4 h-4" /> },
{ label: 'Noten', value: stats.gradesEntered, icon: <FileCheck className="w-4 h-4" /> },
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-4">
<h3 className="text-sm font-medium text-slate-500 mb-3">Statistiken</h3>
<div className="space-y-3">
{items.map((item) => (
<div key={item.label} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-slate-600">
{item.icon}
<span className="text-sm">{item.label}</span>
</div>
{loading ? (
<div className="h-5 w-8 bg-slate-200 rounded animate-pulse" />
) : (
<span className="font-semibold text-slate-900">{item.value}</span>
)}
</div>
))}
</div>
</div>
)
}
@@ -0,0 +1,170 @@
'use client'
import { ChevronRight, Clock, Lightbulb, ClipboardCheck, BookOpen, Calendar, Users, MessageSquare, FileText } from 'lucide-react'
import { Suggestion, SuggestionPriority } from '@/lib/companion/types'
import { PRIORITY_COLORS } from '@/lib/companion/constants'
interface SuggestionListProps {
suggestions: Suggestion[]
onSuggestionClick?: (suggestion: Suggestion) => void
loading?: boolean
maxItems?: number
}
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
ClipboardCheck,
BookOpen,
Calendar,
Users,
Clock,
MessageSquare,
FileText,
Lightbulb,
}
function getIcon(iconName: string) {
const Icon = iconMap[iconName] || Lightbulb
return Icon
}
interface SuggestionCardProps {
suggestion: Suggestion
onClick?: () => void
}
function SuggestionCard({ suggestion, onClick }: SuggestionCardProps) {
const priorityStyles = PRIORITY_COLORS[suggestion.priority]
const Icon = getIcon(suggestion.icon)
return (
<button
onClick={onClick}
className={`
w-full p-4 rounded-xl border text-left
transition-all duration-200
hover:shadow-md hover:scale-[1.01]
${priorityStyles.bg} ${priorityStyles.border}
`}
>
<div className="flex items-start gap-3">
{/* Priority Dot & Icon */}
<div className="flex-shrink-0 relative">
<div className={`p-2 rounded-lg bg-white shadow-sm`}>
<Icon className={`w-5 h-5 ${priorityStyles.text}`} />
</div>
<span
className={`absolute -top-1 -right-1 w-3 h-3 rounded-full ${priorityStyles.dot}`}
title={`Prioritaet: ${suggestion.priority}`}
/>
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<h4 className={`font-medium ${priorityStyles.text} mb-1`}>
{suggestion.title}
</h4>
<p className="text-sm text-slate-600 line-clamp-2">
{suggestion.description}
</p>
{/* Meta */}
<div className="flex items-center gap-3 mt-2">
<span className="inline-flex items-center gap-1 text-xs text-slate-500">
<Clock className="w-3 h-3" />
~{suggestion.estimatedTime} Min
</span>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${priorityStyles.bg} ${priorityStyles.text}`}>
{suggestion.priority}
</span>
</div>
</div>
{/* Arrow */}
<ChevronRight className="w-5 h-5 text-slate-400 flex-shrink-0" />
</div>
</button>
)
}
export function SuggestionList({
suggestions,
onSuggestionClick,
loading,
maxItems = 5,
}: SuggestionListProps) {
// Sort by priority: urgent > high > medium > low
const priorityOrder: Record<SuggestionPriority, number> = {
urgent: 0,
high: 1,
medium: 2,
low: 3,
}
const sortedSuggestions = [...suggestions]
.sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority])
.slice(0, maxItems)
if (loading) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<div key={i} className="h-24 bg-slate-100 rounded-xl animate-pulse" />
))}
</div>
</div>
)
}
if (suggestions.length === 0) {
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-2 mb-4">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<div className="text-center py-8">
<div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-3">
<ClipboardCheck className="w-6 h-6 text-green-600" />
</div>
<p className="text-slate-600">Alles erledigt!</p>
<p className="text-sm text-slate-500 mt-1">Keine offenen Aufgaben</p>
</div>
</div>
)
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Lightbulb className="w-5 h-5 text-amber-500" />
<h3 className="font-semibold text-slate-900">Vorschlaege</h3>
</div>
<span className="text-sm text-slate-500">
{suggestions.length} Aufgabe{suggestions.length !== 1 ? 'n' : ''}
</span>
</div>
<div className="space-y-3">
{sortedSuggestions.map((suggestion) => (
<SuggestionCard
key={suggestion.id}
suggestion={suggestion}
onClick={() => onSuggestionClick?.(suggestion)}
/>
))}
</div>
{suggestions.length > maxItems && (
<button className="w-full mt-4 py-2 text-sm text-slate-600 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-colors">
Alle {suggestions.length} anzeigen
</button>
)}
</div>
)
}
@@ -0,0 +1,24 @@
// Main components
export { CompanionDashboard } from './CompanionDashboard'
export { ModeToggle } from './ModeToggle'
// Companion Mode components
export { PhaseTimeline, PhaseTimelineDetailed } from './companion-mode/PhaseTimeline'
export { StatsGrid, StatsGridCompact } from './companion-mode/StatsGrid'
export { SuggestionList } from './companion-mode/SuggestionList'
export { EventsCard, EventsInline } from './companion-mode/EventsCard'
// Lesson Mode components
export { LessonContainer } from './lesson-mode/LessonContainer'
export { LessonStartForm } from './lesson-mode/LessonStartForm'
export { LessonActiveView } from './lesson-mode/LessonActiveView'
export { LessonEndedView } from './lesson-mode/LessonEndedView'
export { VisualPieTimer, CompactTimer } from './lesson-mode/VisualPieTimer'
export { QuickActionsBar, QuickActionsCompact } from './lesson-mode/QuickActionsBar'
export { HomeworkSection } from './lesson-mode/HomeworkSection'
export { ReflectionSection } from './lesson-mode/ReflectionSection'
// Modals
export { SettingsModal } from './modals/SettingsModal'
export { FeedbackModal } from './modals/FeedbackModal'
export { OnboardingModal } from './modals/OnboardingModal'
@@ -0,0 +1,153 @@
'use client'
import { useState } from 'react'
import { Plus, Trash2, BookOpen, Calendar } from 'lucide-react'
import { Homework } from '@/lib/companion/types'
interface HomeworkSectionProps {
homeworkList: Homework[]
onAdd: (title: string, dueDate: string) => void
onRemove: (id: string) => void
}
export function HomeworkSection({ homeworkList, onAdd, onRemove }: HomeworkSectionProps) {
const [newTitle, setNewTitle] = useState('')
const [newDueDate, setNewDueDate] = useState('')
const [isAdding, setIsAdding] = useState(false)
// Default due date to next week
const getDefaultDueDate = () => {
const date = new Date()
date.setDate(date.getDate() + 7)
return date.toISOString().split('T')[0]
}
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!newTitle.trim()) return
onAdd(newTitle.trim(), newDueDate || getDefaultDueDate())
setNewTitle('')
setNewDueDate('')
setIsAdding(false)
}
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('de-DE', {
weekday: 'short',
day: 'numeric',
month: 'short',
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-slate-900 flex items-center gap-2">
<BookOpen className="w-5 h-5 text-slate-400" />
Hausaufgaben
</h3>
{!isAdding && (
<button
onClick={() => setIsAdding(true)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
>
<Plus className="w-4 h-4" />
Hinzufuegen
</button>
)}
</div>
{/* Add Form */}
{isAdding && (
<form onSubmit={handleSubmit} className="mb-4 p-4 bg-blue-50 rounded-xl">
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Aufgabe
</label>
<input
type="text"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder="z.B. Aufgabe 1-5 auf S. 42..."
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
Faellig am
</label>
<input
type="date"
value={newDueDate}
onChange={(e) => setNewDueDate(e.target.value)}
min={new Date().toISOString().split('T')[0]}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={!newTitle.trim()}
className="flex-1 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Speichern
</button>
<button
type="button"
onClick={() => {
setIsAdding(false)
setNewTitle('')
setNewDueDate('')
}}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg"
>
Abbrechen
</button>
</div>
</div>
</form>
)}
{/* Homework List */}
{homeworkList.length === 0 ? (
<div className="text-center py-8">
<BookOpen className="w-10 h-10 text-slate-300 mx-auto mb-2" />
<p className="text-slate-500">Keine Hausaufgaben eingetragen</p>
<p className="text-sm text-slate-400 mt-1">
Fuegen Sie Hausaufgaben hinzu, um sie zu dokumentieren
</p>
</div>
) : (
<div className="space-y-3">
{homeworkList.map((hw) => (
<div
key={hw.id}
className="flex items-start gap-3 p-4 bg-slate-50 rounded-xl group"
>
<div className="flex-1">
<p className="font-medium text-slate-900">{hw.title}</p>
<div className="flex items-center gap-2 mt-1 text-sm text-slate-500">
<Calendar className="w-3 h-3" />
<span>Faellig: {formatDate(hw.dueDate)}</span>
</div>
</div>
<button
onClick={() => onRemove(hw.id)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg opacity-0 group-hover:opacity-100 transition-all"
title="Entfernen"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
))}
</div>
)}
</div>
)
}
@@ -0,0 +1,172 @@
'use client'
import { BookOpen, Clock, Users } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { VisualPieTimer } from './VisualPieTimer'
import { QuickActionsBar } from './QuickActionsBar'
import { PhaseTimelineDetailed } from '../companion-mode/PhaseTimeline'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
getTimerColorStatus,
} from '@/lib/companion/constants'
interface LessonActiveViewProps {
session: LessonSession
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onEndLesson: () => void
}
export function LessonActiveView({
session,
onPauseToggle,
onExtendTime,
onSkipPhase,
onEndLesson,
}: LessonActiveViewProps) {
const currentPhase = session.phases[session.currentPhaseIndex]
const phaseId = currentPhase?.phase || 'einstieg'
const phaseColor = PHASE_COLORS[phaseId].hex
const phaseName = PHASE_DISPLAY_NAMES[phaseId]
// Calculate timer values
const phaseDurationSeconds = (currentPhase?.duration || 0) * 60
const elapsedInPhase = currentPhase?.actualTime || 0
const remainingSeconds = phaseDurationSeconds - elapsedInPhase
const progress = Math.min(elapsedInPhase / phaseDurationSeconds, 1)
const isOvertime = remainingSeconds < 0
const colorStatus = getTimerColorStatus(remainingSeconds, isOvertime)
const isLastPhase = session.currentPhaseIndex === session.phases.length - 1
// Calculate total elapsed
const totalElapsedMinutes = Math.floor(session.elapsedTime / 60)
return (
<div className="space-y-6">
{/* Header with Session Info */}
<div
className="bg-gradient-to-r rounded-xl p-6 text-white"
style={{
background: `linear-gradient(135deg, ${phaseColor}, ${phaseColor}dd)`,
}}
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 text-white/80 text-sm mb-1">
<Users className="w-4 h-4" />
<span>{session.className}</span>
<span className="mx-2">|</span>
<BookOpen className="w-4 h-4" />
<span>{session.subject}</span>
</div>
<h2 className="text-2xl font-bold">
{session.topic || phaseName}
</h2>
</div>
<div className="text-right">
<div className="text-white/80 text-sm">Gesamtzeit</div>
<div className="text-xl font-mono font-bold">
{formatTime(session.elapsedTime)}
</div>
</div>
</div>
</div>
{/* Main Timer Section */}
<div className="bg-white border border-slate-200 rounded-xl p-8">
<div className="flex flex-col items-center">
{/* Visual Pie Timer */}
<VisualPieTimer
progress={progress}
remainingSeconds={remainingSeconds}
totalSeconds={phaseDurationSeconds}
colorStatus={colorStatus}
isPaused={session.isPaused}
currentPhaseName={phaseName}
phaseColor={phaseColor}
onTogglePause={onPauseToggle}
size="lg"
/>
{/* Quick Actions */}
<div className="mt-8 w-full max-w-md">
<QuickActionsBar
onExtend={onExtendTime}
onPause={onPauseToggle}
onResume={onPauseToggle}
onSkip={onSkipPhase}
onEnd={onEndLesson}
isPaused={session.isPaused}
isLastPhase={isLastPhase}
/>
</div>
</div>
</div>
{/* Phase Timeline */}
<PhaseTimelineDetailed
phases={session.phases.map((p, i) => ({
id: p.phase,
shortName: p.phase[0].toUpperCase(),
displayName: PHASE_DISPLAY_NAMES[p.phase],
duration: p.duration,
status: p.status === 'active' ? 'active' : p.status === 'completed' ? 'completed' : 'planned',
actualTime: p.actualTime,
color: PHASE_COLORS[p.phase].hex,
}))}
currentPhaseIndex={session.currentPhaseIndex}
onPhaseClick={(index) => {
// Optional: Allow clicking to navigate to a phase
}}
/>
{/* Lesson Stats */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">{totalElapsedMinutes}</div>
<div className="text-sm text-slate-500">Minuten vergangen</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<div
className="w-5 h-5 rounded-full mx-auto mb-2"
style={{ backgroundColor: phaseColor }}
/>
<div className="text-2xl font-bold text-slate-900">
{session.currentPhaseIndex + 1}/{session.phases.length}
</div>
<div className="text-sm text-slate-500">Phase</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl p-4 text-center">
<Clock className="w-5 h-5 text-slate-400 mx-auto mb-2" />
<div className="text-2xl font-bold text-slate-900">
{session.totalPlannedDuration - totalElapsedMinutes}
</div>
<div className="text-sm text-slate-500">Minuten verbleibend</div>
</div>
</div>
{/* Keyboard Shortcuts Hint */}
<div className="text-center text-sm text-slate-400">
<span className="inline-flex items-center gap-4">
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">Leertaste</kbd> Pause
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">E</kbd> +5 Min
</span>
<span>
<kbd className="px-2 py-1 bg-slate-100 rounded text-xs">N</kbd> Weiter
</span>
</span>
</div>
</div>
)
}
@@ -0,0 +1,80 @@
'use client'
import { LessonSession, LessonStatus } from '@/lib/companion/types'
import { LessonStartForm } from './LessonStartForm'
import { LessonActiveView } from './LessonActiveView'
import { LessonEndedView } from './LessonEndedView'
interface LessonContainerProps {
session: LessonSession | null
onStartLesson: (data: { classId: string; subject: string; topic?: string; templateId?: string }) => void
onEndLesson: () => void
onPauseToggle: () => void
onExtendTime: (minutes: number) => void
onSkipPhase: () => void
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
loading?: boolean
}
export function LessonContainer({
session,
onStartLesson,
onEndLesson,
onPauseToggle,
onExtendTime,
onSkipPhase,
onSaveReflection,
onAddHomework,
onRemoveHomework,
loading,
}: LessonContainerProps) {
// Determine which view to show based on session state
const getView = (): 'start' | 'active' | 'ended' => {
if (!session) return 'start'
const status = session.status
if (status === 'completed') return 'ended'
if (status === 'not_started') return 'start'
return 'active'
}
const view = getView()
if (view === 'start') {
return (
<LessonStartForm
onStart={onStartLesson}
loading={loading}
/>
)
}
if (view === 'ended' && session) {
return (
<LessonEndedView
session={session}
onSaveReflection={onSaveReflection}
onAddHomework={onAddHomework}
onRemoveHomework={onRemoveHomework}
onStartNew={() => onEndLesson()} // This will clear the session and show start form
/>
)
}
if (session) {
return (
<LessonActiveView
session={session}
onPauseToggle={onPauseToggle}
onExtendTime={onExtendTime}
onSkipPhase={onSkipPhase}
onEndLesson={onEndLesson}
/>
)
}
return null
}
@@ -0,0 +1,209 @@
'use client'
import { useState } from 'react'
import { CheckCircle, Clock, BarChart3, Plus, RefreshCw } from 'lucide-react'
import { LessonSession } from '@/lib/companion/types'
import { HomeworkSection } from './HomeworkSection'
import { ReflectionSection } from './ReflectionSection'
import {
PHASE_COLORS,
PHASE_DISPLAY_NAMES,
formatTime,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonEndedViewProps {
session: LessonSession
onSaveReflection: (rating: number, notes: string, nextSteps: string) => void
onAddHomework: (title: string, dueDate: string) => void
onRemoveHomework: (id: string) => void
onStartNew: () => void
}
export function LessonEndedView({
session,
onSaveReflection,
onAddHomework,
onRemoveHomework,
onStartNew,
}: LessonEndedViewProps) {
const [activeTab, setActiveTab] = useState<'summary' | 'homework' | 'reflection'>('summary')
// Calculate analytics
const totalPlannedSeconds = session.totalPlannedDuration * 60
const totalActualSeconds = session.elapsedTime
const timeDiff = totalActualSeconds - totalPlannedSeconds
const timeDiffMinutes = Math.round(timeDiff / 60)
const startTime = new Date(session.startTime)
const endTime = session.endTime ? new Date(session.endTime) : new Date()
return (
<div className="space-y-6">
{/* Success Header */}
<div className="bg-gradient-to-r from-green-500 to-green-600 rounded-xl p-6 text-white">
<div className="flex items-center gap-4">
<div className="p-3 bg-white/20 rounded-full">
<CheckCircle className="w-8 h-8" />
</div>
<div>
<h2 className="text-2xl font-bold">Stunde beendet!</h2>
<p className="text-green-100">
{session.className} - {session.subject}
{session.topic && ` - ${session.topic}`}
</p>
</div>
</div>
</div>
{/* Tab Navigation */}
<div className="bg-white border border-slate-200 rounded-xl p-1 flex">
{[
{ id: 'summary', label: 'Zusammenfassung', icon: BarChart3 },
{ id: 'homework', label: 'Hausaufgaben', icon: Plus },
{ id: 'reflection', label: 'Reflexion', icon: RefreshCw },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as typeof activeTab)}
className={`
flex-1 flex items-center justify-center gap-2 py-3 px-4 rounded-lg
font-medium transition-all duration-200
${activeTab === tab.id
? 'bg-slate-900 text-white'
: 'text-slate-600 hover:bg-slate-100'
}
`}
>
<tab.icon className="w-4 h-4" />
{tab.label}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'summary' && (
<div className="space-y-6">
{/* Time Overview */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<Clock className="w-5 h-5 text-slate-400" />
Zeitauswertung
</h3>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatTime(totalActualSeconds)}
</div>
<div className="text-sm text-slate-500">Tatsaechlich</div>
</div>
<div className="text-center p-4 bg-slate-50 rounded-xl">
<div className="text-2xl font-bold text-slate-900">
{formatMinutes(session.totalPlannedDuration)}
</div>
<div className="text-sm text-slate-500">Geplant</div>
</div>
<div className={`text-center p-4 rounded-xl ${timeDiff > 0 ? 'bg-amber-50' : 'bg-green-50'}`}>
<div className={`text-2xl font-bold ${timeDiff > 0 ? 'text-amber-600' : 'text-green-600'}`}>
{timeDiffMinutes > 0 ? '+' : ''}{timeDiffMinutes} Min
</div>
<div className={`text-sm ${timeDiff > 0 ? 'text-amber-500' : 'text-green-500'}`}>
Differenz
</div>
</div>
</div>
{/* Session Times */}
<div className="flex items-center justify-between text-sm text-slate-500 border-t border-slate-100 pt-4">
<span>Start: {startTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
<span>Ende: {endTime.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}</span>
</div>
</div>
{/* Phase Breakdown */}
<div className="bg-white border border-slate-200 rounded-xl p-6">
<h3 className="font-semibold text-slate-900 mb-4 flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-slate-400" />
Phasen-Analyse
</h3>
<div className="space-y-4">
{session.phases.map((phase) => {
const plannedSeconds = phase.duration * 60
const actualSeconds = phase.actualTime
const diff = actualSeconds - plannedSeconds
const diffMinutes = Math.round(diff / 60)
const percentage = Math.min((actualSeconds / plannedSeconds) * 100, 150)
return (
<div key={phase.phase} className="space-y-2">
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase.phase].hex }}
/>
<span className="font-medium text-slate-700">
{PHASE_DISPLAY_NAMES[phase.phase]}
</span>
</div>
<div className="flex items-center gap-3 text-slate-500">
<span>{Math.round(actualSeconds / 60)} / {phase.duration} Min</span>
<span className={`
px-2 py-0.5 rounded text-xs font-medium
${diff > 60 ? 'bg-amber-100 text-amber-700' : diff < -60 ? 'bg-blue-100 text-blue-700' : 'bg-green-100 text-green-700'}
`}>
{diffMinutes > 0 ? '+' : ''}{diffMinutes} Min
</span>
</div>
</div>
{/* Progress Bar */}
<div className="h-3 bg-slate-100 rounded-full overflow-hidden">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(percentage, 100)}%`,
backgroundColor: percentage > 100
? '#f59e0b' // amber for overtime
: PHASE_COLORS[phase.phase].hex,
}}
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)}
{activeTab === 'homework' && (
<HomeworkSection
homeworkList={session.homeworkList}
onAdd={onAddHomework}
onRemove={onRemoveHomework}
/>
)}
{activeTab === 'reflection' && (
<ReflectionSection
reflection={session.reflection}
onSave={onSaveReflection}
/>
)}
{/* Start New Lesson Button */}
<div className="pt-4">
<button
onClick={onStartNew}
className="w-full py-4 px-6 bg-slate-900 text-white rounded-xl font-semibold hover:bg-slate-800 transition-colors flex items-center justify-center gap-2"
>
<RefreshCw className="w-5 h-5" />
Neue Stunde starten
</button>
</div>
</div>
)
}
@@ -0,0 +1,269 @@
'use client'
import { useState } from 'react'
import { Play, Clock, BookOpen, Users, ChevronDown, Info } from 'lucide-react'
import { LessonTemplate, PhaseDurations, Class } from '@/lib/companion/types'
import {
SYSTEM_TEMPLATES,
DEFAULT_PHASE_DURATIONS,
PHASE_DISPLAY_NAMES,
PHASE_ORDER,
calculateTotalDuration,
formatMinutes,
} from '@/lib/companion/constants'
interface LessonStartFormProps {
onStart: (data: {
classId: string
subject: string
topic?: string
templateId?: string
}) => void
loading?: boolean
availableClasses?: Class[]
}
// Mock classes for development
const MOCK_CLASSES: Class[] = [
{ id: 'c1', name: '9a', grade: '9', studentCount: 28 },
{ id: 'c2', name: '9b', grade: '9', studentCount: 26 },
{ id: 'c3', name: '10a', grade: '10', studentCount: 24 },
{ id: 'c4', name: 'Deutsch LK', grade: 'Q1', studentCount: 18 },
{ id: 'c5', name: 'Mathe GK', grade: 'Q2', studentCount: 22 },
]
const SUBJECTS = [
'Deutsch',
'Mathematik',
'Englisch',
'Biologie',
'Physik',
'Chemie',
'Geschichte',
'Geographie',
'Politik',
'Kunst',
'Musik',
'Sport',
'Informatik',
'Sonstiges',
]
export function LessonStartForm({
onStart,
loading,
availableClasses = MOCK_CLASSES,
}: LessonStartFormProps) {
const [selectedClass, setSelectedClass] = useState('')
const [selectedSubject, setSelectedSubject] = useState('')
const [topic, setTopic] = useState('')
const [selectedTemplate, setSelectedTemplate] = useState<LessonTemplate | null>(
SYSTEM_TEMPLATES[0] as LessonTemplate
)
const [showTemplateDetails, setShowTemplateDetails] = useState(false)
const totalDuration = selectedTemplate
? calculateTotalDuration(selectedTemplate.durations)
: calculateTotalDuration(DEFAULT_PHASE_DURATIONS)
const canStart = selectedClass && selectedSubject
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!canStart) return
onStart({
classId: selectedClass,
subject: selectedSubject,
topic: topic || undefined,
templateId: selectedTemplate?.templateId,
})
}
return (
<div className="bg-white border border-slate-200 rounded-xl p-6">
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-blue-100 rounded-xl">
<Play className="w-6 h-6 text-blue-600" />
</div>
<div>
<h2 className="text-xl font-semibold text-slate-900">Neue Stunde starten</h2>
<p className="text-sm text-slate-500">
Waehlen Sie Klasse, Fach und Template
</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Class Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Users className="w-4 h-4 inline mr-2" />
Klasse *
</label>
<select
value={selectedClass}
onChange={(e) => setSelectedClass(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Klasse auswaehlen...</option>
{availableClasses.map((cls) => (
<option key={cls.id} value={cls.id}>
{cls.name} ({cls.studentCount} Schueler)
</option>
))}
</select>
</div>
{/* Subject Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<BookOpen className="w-4 h-4 inline mr-2" />
Fach *
</label>
<select
value={selectedSubject}
onChange={(e) => setSelectedSubject(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
required
>
<option value="">Fach auswaehlen...</option>
{SUBJECTS.map((subject) => (
<option key={subject} value={subject}>
{subject}
</option>
))}
</select>
</div>
{/* Topic (Optional) */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Thema (optional)
</label>
<input
type="text"
value={topic}
onChange={(e) => setTopic(e.target.value)}
placeholder="z.B. Quadratische Funktionen, Gedichtanalyse..."
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
{/* Template Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
<Clock className="w-4 h-4 inline mr-2" />
Template
</label>
<div className="space-y-2">
{SYSTEM_TEMPLATES.map((template) => {
const tpl = template as LessonTemplate
const isSelected = selectedTemplate?.templateId === tpl.templateId
const total = calculateTotalDuration(tpl.durations)
return (
<button
key={tpl.templateId}
type="button"
onClick={() => setSelectedTemplate(tpl)}
className={`
w-full p-4 rounded-xl border text-left transition-all
${isSelected
? 'border-blue-500 bg-blue-50 ring-2 ring-blue-500/20'
: 'border-slate-200 hover:border-slate-300 hover:bg-slate-50'
}
`}
>
<div className="flex items-center justify-between">
<div>
<p className={`font-medium ${isSelected ? 'text-blue-900' : 'text-slate-900'}`}>
{tpl.name}
</p>
<p className="text-sm text-slate-500">{tpl.description}</p>
</div>
<span className={`text-sm font-medium ${isSelected ? 'text-blue-600' : 'text-slate-500'}`}>
{formatMinutes(total)}
</span>
</div>
</button>
)
})}
</div>
{/* Template Details Toggle */}
{selectedTemplate && (
<button
type="button"
onClick={() => setShowTemplateDetails(!showTemplateDetails)}
className="mt-3 flex items-center gap-2 text-sm text-slate-600 hover:text-slate-900"
>
<Info className="w-4 h-4" />
Phasendauern anzeigen
<ChevronDown className={`w-4 h-4 transition-transform ${showTemplateDetails ? 'rotate-180' : ''}`} />
</button>
)}
{/* Template Details */}
{showTemplateDetails && selectedTemplate && (
<div className="mt-3 p-4 bg-slate-50 rounded-xl">
<div className="grid grid-cols-5 gap-2">
{PHASE_ORDER.map((phaseId) => (
<div key={phaseId} className="text-center">
<p className="text-xs text-slate-500">{PHASE_DISPLAY_NAMES[phaseId]}</p>
<p className="font-medium text-slate-900">
{selectedTemplate.durations[phaseId]} Min
</p>
</div>
))}
</div>
</div>
)}
</div>
{/* Summary & Start Button */}
<div className="pt-4 border-t border-slate-200">
<div className="flex items-center justify-between mb-4">
<div className="text-sm text-slate-600">
Gesamtdauer: <span className="font-semibold">{formatMinutes(totalDuration)}</span>
</div>
{selectedClass && (
<div className="text-sm text-slate-600">
Klasse: <span className="font-semibold">
{availableClasses.find((c) => c.id === selectedClass)?.name}
</span>
</div>
)}
</div>
<button
type="submit"
disabled={!canStart || loading}
className={`
w-full py-4 px-6 rounded-xl font-semibold text-lg
flex items-center justify-center gap-3
transition-all duration-200
${canStart && !loading
? 'bg-blue-600 hover:bg-blue-700 text-white shadow-lg shadow-blue-500/25'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{loading ? (
<>
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Stunde wird gestartet...
</>
) : (
<>
<Play className="w-5 h-5" />
Stunde starten
</>
)}
</button>
</div>
</form>
</div>
)
}
@@ -0,0 +1,194 @@
'use client'
import { Plus, Pause, Play, SkipForward, Square, Clock } from 'lucide-react'
interface QuickActionsBarProps {
onExtend: (minutes: number) => void
onPause: () => void
onResume: () => void
onSkip: () => void
onEnd: () => void
isPaused: boolean
isLastPhase: boolean
disabled?: boolean
}
export function QuickActionsBar({
onExtend,
onPause,
onResume,
onSkip,
onEnd,
isPaused,
isLastPhase,
disabled,
}: QuickActionsBarProps) {
return (
<div
className="flex items-center justify-center gap-3 p-4 bg-white border border-slate-200 rounded-xl"
role="toolbar"
aria-label="Steuerung"
>
{/* Extend +5 Min */}
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-50 text-blue-700 hover:bg-blue-100 active:scale-95'
}
`}
title="+5 Minuten (E)"
aria-keyshortcuts="e"
aria-label="5 Minuten verlaengern"
>
<Plus className="w-5 h-5" />
<span>5 Min</span>
</button>
{/* Pause / Resume */}
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
flex items-center gap-2 px-6 py-3 rounded-xl
font-semibold transition-all duration-200
min-w-[52px] min-h-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: isPaused
? 'bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-500/25 active:scale-95'
: 'bg-amber-500 text-white hover:bg-amber-600 shadow-lg shadow-amber-500/25 active:scale-95'
}
`}
title={isPaused ? 'Fortsetzen (Leertaste)' : 'Pausieren (Leertaste)'}
aria-keyshortcuts="Space"
aria-label={isPaused ? 'Stunde fortsetzen' : 'Stunde pausieren'}
>
{isPaused ? (
<>
<Play className="w-5 h-5" />
<span>Fortsetzen</span>
</>
) : (
<>
<Pause className="w-5 h-5" />
<span>Pause</span>
</>
)}
</button>
{/* Skip Phase / End Lesson */}
{isLastPhase ? (
<button
onClick={onEnd}
disabled={disabled}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-red-50 text-red-700 hover:bg-red-100 active:scale-95'
}
`}
title="Stunde beenden"
aria-label="Stunde beenden"
>
<Square className="w-5 h-5" />
<span>Beenden</span>
</button>
) : (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
flex items-center gap-2 px-4 py-3 rounded-xl
font-medium transition-all duration-200
min-w-[52px] justify-center
${disabled || isPaused
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-slate-100 text-slate-700 hover:bg-slate-200 active:scale-95'
}
`}
title="Naechste Phase (N)"
aria-keyshortcuts="n"
aria-label="Zur naechsten Phase springen"
>
<SkipForward className="w-5 h-5" />
<span>Weiter</span>
</button>
)}
</div>
)
}
/**
* Compact version for mobile or sidebar
*/
export function QuickActionsCompact({
onExtend,
onPause,
onResume,
onSkip,
isPaused,
isLastPhase,
disabled,
}: Omit<QuickActionsBarProps, 'onEnd'>) {
return (
<div className="flex items-center gap-2">
<button
onClick={() => onExtend(5)}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-blue-600 hover:bg-blue-50'
}
`}
title="+5 Min"
>
<Clock className="w-5 h-5" />
</button>
<button
onClick={isPaused ? onResume : onPause}
disabled={disabled}
className={`
p-2 rounded-lg transition-all
${disabled
? 'text-slate-300'
: isPaused
? 'text-green-600 hover:bg-green-50'
: 'text-amber-600 hover:bg-amber-50'
}
`}
title={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? <Play className="w-5 h-5" /> : <Pause className="w-5 h-5" />}
</button>
{!isLastPhase && (
<button
onClick={onSkip}
disabled={disabled || isPaused}
className={`
p-2 rounded-lg transition-all
${disabled || isPaused
? 'text-slate-300'
: 'text-slate-600 hover:bg-slate-50'
}
`}
title="Naechste Phase"
>
<SkipForward className="w-5 h-5" />
</button>
)}
</div>
)
}
@@ -0,0 +1,146 @@
'use client'
import { useState, useEffect } from 'react'
import { Star, Save, CheckCircle } from 'lucide-react'
import { LessonReflection } from '@/lib/companion/types'
interface ReflectionSectionProps {
reflection?: LessonReflection
onSave: (rating: number, notes: string, nextSteps: string) => void
}
export function ReflectionSection({ reflection, onSave }: ReflectionSectionProps) {
const [rating, setRating] = useState(reflection?.rating || 0)
const [notes, setNotes] = useState(reflection?.notes || '')
const [nextSteps, setNextSteps] = useState(reflection?.nextSteps || '')
const [hoverRating, setHoverRating] = useState(0)
const [saved, setSaved] = useState(false)
useEffect(() => {
if (reflection) {
setRating(reflection.rating)
setNotes(reflection.notes)
setNextSteps(reflection.nextSteps)
}
}, [reflection])
const handleSave = () => {
if (rating === 0) return
onSave(rating, notes, nextSteps)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}
const ratingLabels = [
'', // 0
'Verbesserungsbedarf',
'Okay',
'Gut',
'Sehr gut',
'Ausgezeichnet',
]
return (
<div className="bg-white border border-slate-200 rounded-xl p-6 space-y-6">
{/* Star Rating */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Wie lief die Stunde?
</label>
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((star) => {
const isFilled = star <= (hoverRating || rating)
return (
<button
key={star}
type="button"
onClick={() => setRating(star)}
onMouseEnter={() => setHoverRating(star)}
onMouseLeave={() => setHoverRating(0)}
className="p-1 transition-transform hover:scale-110"
aria-label={`${star} Stern${star > 1 ? 'e' : ''}`}
>
<Star
className={`w-8 h-8 ${
isFilled
? 'fill-amber-400 text-amber-400'
: 'text-slate-300'
}`}
/>
</button>
)
})}
{(hoverRating || rating) > 0 && (
<span className="ml-3 text-sm text-slate-600">
{ratingLabels[hoverRating || rating]}
</span>
)}
</div>
</div>
{/* Notes */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Notizen zur Stunde
</label>
<textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Was lief gut? Was koennte besser laufen? Besondere Vorkommnisse..."
rows={4}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Next Steps */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Naechste Schritte
</label>
<textarea
value={nextSteps}
onChange={(e) => setNextSteps(e.target.value)}
placeholder="Was muss fuer die naechste Stunde vorbereitet werden? Follow-ups..."
rows={3}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
/>
</div>
{/* Save Button */}
<button
onClick={handleSave}
disabled={rating === 0}
className={`
w-full py-3 px-6 rounded-xl font-semibold
flex items-center justify-center gap-2
transition-all duration-200
${saved
? 'bg-green-600 text-white'
: rating === 0
? 'bg-slate-100 text-slate-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{saved ? (
<>
<CheckCircle className="w-5 h-5" />
Gespeichert!
</>
) : (
<>
<Save className="w-5 h-5" />
Reflexion speichern
</>
)}
</button>
{/* Previous Reflection Info */}
{reflection?.savedAt && (
<p className="text-center text-sm text-slate-400">
Zuletzt gespeichert: {new Date(reflection.savedAt).toLocaleString('de-DE')}
</p>
)}
</div>
)
}
@@ -0,0 +1,220 @@
'use client'
import { Pause, Play } from 'lucide-react'
import { TimerColorStatus } from '@/lib/companion/types'
import {
PIE_TIMER_RADIUS,
PIE_TIMER_CIRCUMFERENCE,
PIE_TIMER_STROKE_WIDTH,
PIE_TIMER_SIZE,
TIMER_COLOR_CLASSES,
TIMER_BG_COLORS,
formatTime,
} from '@/lib/companion/constants'
interface VisualPieTimerProps {
progress: number // 0-1 (how much time has elapsed)
remainingSeconds: number
totalSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
currentPhaseName: string
phaseColor: string
onTogglePause?: () => void
size?: 'sm' | 'md' | 'lg'
}
const sizeConfig = {
sm: { outer: 120, viewBox: 100, radius: 38, stroke: 6, fontSize: 'text-lg' },
md: { outer: 180, viewBox: 100, radius: 40, stroke: 7, fontSize: 'text-2xl' },
lg: { outer: 240, viewBox: 100, radius: 42, stroke: 8, fontSize: 'text-4xl' },
}
export function VisualPieTimer({
progress,
remainingSeconds,
totalSeconds,
colorStatus,
isPaused,
currentPhaseName,
phaseColor,
onTogglePause,
size = 'lg',
}: VisualPieTimerProps) {
const config = sizeConfig[size]
const circumference = 2 * Math.PI * config.radius
// Calculate stroke-dashoffset for progress
// Progress goes from 0 (full) to 1 (empty), so offset decreases as time passes
const strokeDashoffset = circumference * (1 - progress)
// For overtime, show a pulsing full circle
const isOvertime = colorStatus === 'overtime'
const displayTime = formatTime(remainingSeconds)
// Get color classes based on status
const colorClasses = TIMER_COLOR_CLASSES[colorStatus]
const bgColorClass = TIMER_BG_COLORS[colorStatus]
return (
<div className="flex flex-col items-center">
{/* Timer Circle */}
<div
className={`relative ${bgColorClass} rounded-full p-4 transition-colors duration-300`}
style={{ width: config.outer, height: config.outer }}
>
<svg
width="100%"
height="100%"
viewBox={`0 0 ${config.viewBox} ${config.viewBox}`}
className="transform -rotate-90"
>
{/* Background circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke="currentColor"
strokeWidth={config.stroke}
className="text-slate-200"
/>
{/* Progress circle */}
<circle
cx={config.viewBox / 2}
cy={config.viewBox / 2}
r={config.radius}
fill="none"
stroke={isOvertime ? '#dc2626' : phaseColor}
strokeWidth={config.stroke}
strokeLinecap="round"
strokeDasharray={circumference}
strokeDashoffset={isOvertime ? 0 : strokeDashoffset}
className={`transition-all duration-100 ${isOvertime ? 'animate-pulse' : ''}`}
/>
</svg>
{/* Center Content */}
<div className="absolute inset-0 flex flex-col items-center justify-center">
{/* Time Display */}
<span
className={`
font-mono font-bold ${config.fontSize}
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{displayTime}
</span>
{/* Phase Name */}
<span className="text-sm text-slate-500 mt-1">
{currentPhaseName}
</span>
{/* Paused Indicator */}
{isPaused && (
<span className="text-xs text-amber-600 font-medium mt-1 flex items-center gap-1">
<Pause className="w-3 h-3" />
Pausiert
</span>
)}
{/* Overtime Badge */}
{isOvertime && (
<span className="absolute -bottom-2 px-2 py-0.5 bg-red-600 text-white text-xs font-bold rounded-full">
+{Math.abs(Math.floor(remainingSeconds / 60))} Min
</span>
)}
</div>
{/* Pause/Play Button (overlay) */}
{onTogglePause && (
<button
onClick={onTogglePause}
className={`
absolute inset-0 rounded-full
flex items-center justify-center
opacity-0 hover:opacity-100
bg-black/20 backdrop-blur-sm
transition-opacity duration-200
`}
aria-label={isPaused ? 'Fortsetzen' : 'Pausieren'}
>
{isPaused ? (
<Play className="w-12 h-12 text-white" />
) : (
<Pause className="w-12 h-12 text-white" />
)}
</button>
)}
</div>
{/* Status Text */}
<div className="mt-4 text-center">
{isOvertime ? (
<p className="text-red-600 font-semibold animate-pulse">
Ueberzogen - Zeit fuer die naechste Phase!
</p>
) : colorStatus === 'critical' ? (
<p className="text-red-500 font-medium">
Weniger als 2 Minuten verbleibend
</p>
) : colorStatus === 'warning' ? (
<p className="text-amber-500">
Weniger als 5 Minuten verbleibend
</p>
) : null}
</div>
</div>
)
}
/**
* Compact timer for header/toolbar
*/
export function CompactTimer({
remainingSeconds,
colorStatus,
isPaused,
phaseName,
phaseColor,
}: {
remainingSeconds: number
colorStatus: TimerColorStatus
isPaused: boolean
phaseName: string
phaseColor: string
}) {
const isOvertime = colorStatus === 'overtime'
return (
<div className="flex items-center gap-3 px-4 py-2 bg-white border border-slate-200 rounded-xl">
{/* Phase indicator */}
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: phaseColor }}
/>
{/* Phase name */}
<span className="text-sm font-medium text-slate-600">{phaseName}</span>
{/* Time */}
<span
className={`
font-mono font-bold
${isOvertime ? 'text-red-600 animate-pulse' : colorStatus === 'critical' ? 'text-red-500' : colorStatus === 'warning' ? 'text-amber-500' : 'text-slate-900'}
`}
>
{formatTime(remainingSeconds)}
</span>
{/* Paused badge */}
{isPaused && (
<span className="px-2 py-0.5 bg-amber-100 text-amber-700 text-xs font-medium rounded">
Pausiert
</span>
)}
</div>
)
}
@@ -0,0 +1,201 @@
'use client'
import { useState } from 'react'
import { X, MessageSquare, Bug, Lightbulb, Send, CheckCircle } from 'lucide-react'
import { FeedbackType } from '@/lib/companion/types'
interface FeedbackModalProps {
isOpen: boolean
onClose: () => void
onSubmit: (type: FeedbackType, title: string, description: string) => Promise<void>
}
const feedbackTypes: { id: FeedbackType; label: string; icon: typeof Bug; color: string }[] = [
{ id: 'bug', label: 'Bug melden', icon: Bug, color: 'text-red-600 bg-red-50' },
{ id: 'feature', label: 'Feature-Wunsch', icon: Lightbulb, color: 'text-amber-600 bg-amber-50' },
{ id: 'feedback', label: 'Allgemeines Feedback', icon: MessageSquare, color: 'text-blue-600 bg-blue-50' },
]
export function FeedbackModal({ isOpen, onClose, onSubmit }: FeedbackModalProps) {
const [type, setType] = useState<FeedbackType>('feedback')
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [isSuccess, setIsSuccess] = useState(false)
if (!isOpen) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim() || !description.trim()) return
setIsSubmitting(true)
try {
await onSubmit(type, title.trim(), description.trim())
setIsSuccess(true)
setTimeout(() => {
setIsSuccess(false)
setTitle('')
setDescription('')
setType('feedback')
onClose()
}, 2000)
} catch (error) {
console.error('Failed to submit feedback:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-xl">
<MessageSquare className="w-5 h-5 text-blue-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Feedback senden</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Success State */}
{isSuccess ? (
<div className="p-12 text-center">
<div className="w-16 h-16 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-8 h-8 text-green-600" />
</div>
<h3 className="text-xl font-semibold text-slate-900 mb-2">Vielen Dank!</h3>
<p className="text-slate-600">Ihr Feedback wurde erfolgreich gesendet.</p>
</div>
) : (
<form onSubmit={handleSubmit}>
{/* Content */}
<div className="p-6 space-y-6">
{/* Feedback Type */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-3">
Art des Feedbacks
</label>
<div className="grid grid-cols-3 gap-3">
{feedbackTypes.map((ft) => (
<button
key={ft.id}
type="button"
onClick={() => setType(ft.id)}
className={`
p-4 rounded-xl border-2 text-center transition-all
${type === ft.id
? 'border-blue-500 bg-blue-50'
: 'border-slate-200 hover:border-slate-300'
}
`}
>
<div className={`w-10 h-10 rounded-lg ${ft.color} flex items-center justify-center mx-auto mb-2`}>
<ft.icon className="w-5 h-5" />
</div>
<span className={`text-sm font-medium ${type === ft.id ? 'text-blue-700' : 'text-slate-700'}`}>
{ft.label}
</span>
</button>
))}
</div>
</div>
{/* Title */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Titel *
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={
type === 'bug'
? 'z.B. Timer stoppt nach Pause nicht mehr'
: type === 'feature'
? 'z.B. Materialien an Stunde anhaengen'
: 'z.B. Super nuetzliches Tool!'
}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
required
/>
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Beschreibung *
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={
type === 'bug'
? 'Bitte beschreiben Sie den Fehler moeglichst genau. Was haben Sie gemacht? Was ist passiert? Was haetten Sie erwartet?'
: type === 'feature'
? 'Beschreiben Sie die gewuenschte Funktion. Warum waere sie hilfreich?'
: 'Teilen Sie uns Ihre Gedanken mit...'
}
rows={5}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none"
required
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 p-6 border-t border-slate-200 bg-slate-50">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={!title.trim() || !description.trim() || isSubmitting}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${!title.trim() || !description.trim() || isSubmitting
? 'bg-slate-200 text-slate-500 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}
`}
>
{isSubmitting ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Senden...
</>
) : (
<>
<Send className="w-4 h-4" />
Absenden
</>
)}
</button>
</div>
</form>
)}
</div>
</div>
)
}
@@ -0,0 +1,280 @@
'use client'
import { useState } from 'react'
import { ChevronRight, ChevronLeft, Check, GraduationCap, Settings, Timer } from 'lucide-react'
interface OnboardingModalProps {
isOpen: boolean
onClose: () => void
onComplete: (data: { state?: string; schoolType?: string }) => void
}
const STATES = [
'Baden-Wuerttemberg',
'Bayern',
'Berlin',
'Brandenburg',
'Bremen',
'Hamburg',
'Hessen',
'Mecklenburg-Vorpommern',
'Niedersachsen',
'Nordrhein-Westfalen',
'Rheinland-Pfalz',
'Saarland',
'Sachsen',
'Sachsen-Anhalt',
'Schleswig-Holstein',
'Thueringen',
]
const SCHOOL_TYPES = [
'Grundschule',
'Hauptschule',
'Realschule',
'Gymnasium',
'Gesamtschule',
'Berufsschule',
'Foerderschule',
'Andere',
]
interface Step {
id: number
title: string
description: string
icon: typeof GraduationCap
}
const steps: Step[] = [
{
id: 1,
title: 'Willkommen',
description: 'Der Companion hilft Ihnen bei der Unterrichtsplanung und -durchfuehrung.',
icon: GraduationCap,
},
{
id: 2,
title: 'Ihre Schule',
description: 'Waehlen Sie Ihr Bundesland und Ihre Schulform.',
icon: Settings,
},
{
id: 3,
title: 'Bereit!',
description: 'Sie koennen jetzt mit dem Lesson-Modus starten.',
icon: Timer,
},
]
export function OnboardingModal({ isOpen, onClose, onComplete }: OnboardingModalProps) {
const [currentStep, setCurrentStep] = useState(1)
const [selectedState, setSelectedState] = useState('')
const [selectedSchoolType, setSelectedSchoolType] = useState('')
if (!isOpen) return null
const canProceed = () => {
if (currentStep === 2) {
return selectedState !== '' && selectedSchoolType !== ''
}
return true
}
const handleNext = () => {
if (currentStep < 3) {
setCurrentStep(currentStep + 1)
} else {
onComplete({
state: selectedState,
schoolType: selectedSchoolType,
})
}
}
const handleBack = () => {
if (currentStep > 1) {
setCurrentStep(currentStep - 1)
}
}
const currentStepData = steps[currentStep - 1]
const Icon = currentStepData.icon
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" />
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 overflow-hidden">
{/* Progress Bar */}
<div className="h-1 bg-slate-100">
<div
className="h-full bg-blue-600 transition-all duration-300"
style={{ width: `${(currentStep / 3) * 100}%` }}
/>
</div>
{/* Content */}
<div className="p-8">
{/* Step Indicator */}
<div className="flex items-center justify-center gap-2 mb-8">
{steps.map((step) => (
<div
key={step.id}
className={`
w-3 h-3 rounded-full transition-all
${step.id === currentStep
? 'bg-blue-600 scale-125'
: step.id < currentStep
? 'bg-blue-600'
: 'bg-slate-200'
}
`}
/>
))}
</div>
{/* Icon */}
<div className="w-16 h-16 bg-blue-100 rounded-2xl flex items-center justify-center mx-auto mb-6">
<Icon className="w-8 h-8 text-blue-600" />
</div>
{/* Title & Description */}
<h2 className="text-2xl font-bold text-slate-900 text-center mb-2">
{currentStepData.title}
</h2>
<p className="text-slate-600 text-center mb-8">
{currentStepData.description}
</p>
{/* Step Content */}
{currentStep === 1 && (
<div className="space-y-4 text-center">
<div className="grid grid-cols-3 gap-4">
<div className="p-4 bg-blue-50 rounded-xl">
<div className="text-2xl mb-1">5</div>
<div className="text-xs text-slate-600">Phasen</div>
</div>
<div className="p-4 bg-green-50 rounded-xl">
<div className="text-2xl mb-1">45</div>
<div className="text-xs text-slate-600">Minuten</div>
</div>
<div className="p-4 bg-purple-50 rounded-xl">
<div className="text-2xl mb-1"></div>
<div className="text-xs text-slate-600">Flexibel</div>
</div>
</div>
<p className="text-sm text-slate-500 mt-4">
Einstieg Erarbeitung Sicherung Transfer Reflexion
</p>
</div>
)}
{currentStep === 2 && (
<div className="space-y-4">
{/* State Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Bundesland
</label>
<select
value={selectedState}
onChange={(e) => setSelectedState(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{STATES.map((state) => (
<option key={state} value={state}>
{state}
</option>
))}
</select>
</div>
{/* School Type Selection */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-2">
Schulform
</label>
<select
value={selectedSchoolType}
onChange={(e) => setSelectedSchoolType(e.target.value)}
className="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
>
<option value="">Bitte waehlen...</option>
{SCHOOL_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
</div>
)}
{currentStep === 3 && (
<div className="text-center space-y-4">
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto">
<Check className="w-10 h-10 text-green-600" />
</div>
<div className="p-4 bg-slate-50 rounded-xl">
<p className="text-sm text-slate-600">
<strong>Bundesland:</strong> {selectedState || 'Nicht angegeben'}
</p>
<p className="text-sm text-slate-600">
<strong>Schulform:</strong> {selectedSchoolType || 'Nicht angegeben'}
</p>
</div>
<p className="text-sm text-slate-500">
Sie koennen diese Einstellungen jederzeit aendern.
</p>
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={currentStep === 1 ? onClose : handleBack}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
{currentStep === 1 ? (
'Ueberspringen'
) : (
<>
<ChevronLeft className="w-4 h-4" />
Zurueck
</>
)}
</button>
<button
onClick={handleNext}
disabled={!canProceed()}
className={`
flex items-center gap-2 px-6 py-2 rounded-lg font-medium
transition-all duration-200
${canProceed()
? 'bg-blue-600 text-white hover:bg-blue-700'
: 'bg-slate-200 text-slate-500 cursor-not-allowed'
}
`}
>
{currentStep === 3 ? (
<>
<Check className="w-4 h-4" />
Fertig
</>
) : (
<>
Weiter
<ChevronRight className="w-4 h-4" />
</>
)}
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,248 @@
'use client'
import { useState, useEffect } from 'react'
import { X, Settings, Save, RotateCcw } from 'lucide-react'
import { TeacherSettings, PhaseDurations } from '@/lib/companion/types'
import {
DEFAULT_TEACHER_SETTINGS,
DEFAULT_PHASE_DURATIONS,
PHASE_ORDER,
PHASE_DISPLAY_NAMES,
PHASE_COLORS,
calculateTotalDuration,
} from '@/lib/companion/constants'
interface SettingsModalProps {
isOpen: boolean
onClose: () => void
settings: TeacherSettings
onSave: (settings: TeacherSettings) => void
}
export function SettingsModal({
isOpen,
onClose,
settings,
onSave,
}: SettingsModalProps) {
const [localSettings, setLocalSettings] = useState<TeacherSettings>(settings)
const [durations, setDurations] = useState<PhaseDurations>(settings.defaultPhaseDurations)
useEffect(() => {
setLocalSettings(settings)
setDurations(settings.defaultPhaseDurations)
}, [settings])
if (!isOpen) return null
const totalDuration = calculateTotalDuration(durations)
const handleDurationChange = (phase: keyof PhaseDurations, value: number) => {
const newDurations = { ...durations, [phase]: Math.max(1, Math.min(60, value)) }
setDurations(newDurations)
}
const handleReset = () => {
setDurations(DEFAULT_PHASE_DURATIONS)
setLocalSettings(DEFAULT_TEACHER_SETTINGS)
}
const handleSave = () => {
const newSettings: TeacherSettings = {
...localSettings,
defaultPhaseDurations: durations,
}
onSave(newSettings)
onClose()
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between p-6 border-b border-slate-200">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-100 rounded-xl">
<Settings className="w-5 h-5 text-slate-600" />
</div>
<h2 className="text-xl font-semibold text-slate-900">Einstellungen</h2>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
{/* Content */}
<div className="p-6 space-y-6 overflow-y-auto max-h-[60vh]">
{/* Phase Durations */}
<div>
<h3 className="text-sm font-medium text-slate-700 mb-4">
Standard-Phasendauern (Minuten)
</h3>
<div className="space-y-4">
{PHASE_ORDER.map((phase) => (
<div key={phase} className="flex items-center gap-4">
<div className="flex items-center gap-2 w-32">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: PHASE_COLORS[phase].hex }}
/>
<span className="text-sm text-slate-700">
{PHASE_DISPLAY_NAMES[phase]}
</span>
</div>
<input
type="number"
min={1}
max={60}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value) || 1)}
className="w-20 px-3 py-2 border border-slate-200 rounded-lg text-center focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
<input
type="range"
min={1}
max={45}
value={durations[phase]}
onChange={(e) => handleDurationChange(phase, parseInt(e.target.value))}
className="flex-1"
style={{
accentColor: PHASE_COLORS[phase].hex,
}}
/>
</div>
))}
</div>
<div className="mt-4 p-3 bg-slate-50 rounded-xl flex items-center justify-between">
<span className="text-sm text-slate-600">Gesamtdauer:</span>
<span className="font-semibold text-slate-900">{totalDuration} Minuten</span>
</div>
</div>
{/* Other Settings */}
<div className="space-y-4 pt-4 border-t border-slate-200">
<h3 className="text-sm font-medium text-slate-700 mb-4">
Weitere Einstellungen
</h3>
{/* Auto Advance */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Automatischer Phasenwechsel
</span>
<p className="text-xs text-slate-500">
Phasen automatisch wechseln wenn Zeit abgelaufen
</p>
</div>
<input
type="checkbox"
checked={localSettings.autoAdvancePhases}
onChange={(e) =>
setLocalSettings({ ...localSettings, autoAdvancePhases: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Sound Notifications */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Ton-Benachrichtigungen
</span>
<p className="text-xs text-slate-500">
Signalton bei Phasenende und Warnungen
</p>
</div>
<input
type="checkbox"
checked={localSettings.soundNotifications}
onChange={(e) =>
setLocalSettings({ ...localSettings, soundNotifications: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* Keyboard Shortcuts */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Tastaturkuerzel anzeigen
</span>
<p className="text-xs text-slate-500">
Hinweise zu Tastaturkuerzeln einblenden
</p>
</div>
<input
type="checkbox"
checked={localSettings.showKeyboardShortcuts}
onChange={(e) =>
setLocalSettings({ ...localSettings, showKeyboardShortcuts: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
{/* High Contrast */}
<label className="flex items-center justify-between p-3 bg-slate-50 rounded-xl cursor-pointer">
<div>
<span className="text-sm font-medium text-slate-700">
Hoher Kontrast
</span>
<p className="text-xs text-slate-500">
Bessere Sichtbarkeit durch erhoehten Kontrast
</p>
</div>
<input
type="checkbox"
checked={localSettings.highContrastMode}
onChange={(e) =>
setLocalSettings({ ...localSettings, highContrastMode: e.target.checked })
}
className="w-5 h-5 text-blue-600 rounded focus:ring-blue-500"
/>
</label>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between p-6 border-t border-slate-200 bg-slate-50">
<button
onClick={handleReset}
className="flex items-center gap-2 px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
<RotateCcw className="w-4 h-4" />
Zuruecksetzen
</button>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-slate-600 hover:bg-slate-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
onClick={handleSave}
className="flex items-center gap-2 px-6 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors"
>
<Save className="w-4 h-4" />
Speichern
</button>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,231 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import Link from 'next/link'
interface NightModeConfig {
enabled: boolean
shutdown_time: string
startup_time: string
last_action: string | null
last_action_time: string | null
}
interface NightModeStatus {
config: NightModeConfig
current_time: string
next_action: string | null
next_action_time: string | null
time_until_next_action: string | null
services_status: Record<string, string>
}
export function NightModeWidget() {
const [status, setStatus] = useState<NightModeStatus | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const fetchData = useCallback(async () => {
try {
const response = await fetch('/api/admin/night-mode')
if (response.ok) {
setStatus(await response.json())
setError(null)
} else {
setError('Nicht erreichbar')
}
} catch {
setError('Verbindung fehlgeschlagen')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchData()
const interval = setInterval(fetchData, 30000)
return () => clearInterval(interval)
}, [fetchData])
const toggleEnabled = async () => {
if (!status) return
setActionLoading('toggle')
try {
const response = await fetch('/api/admin/night-mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...status.config, enabled: !status.config.enabled }),
})
if (response.ok) {
fetchData()
}
} catch {
// ignore
} finally {
setActionLoading(null)
}
}
const executeAction = async (action: 'start' | 'stop') => {
setActionLoading(action)
try {
const response = await fetch('/api/admin/night-mode/execute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
if (response.ok) {
fetchData()
}
} catch {
// ignore
} finally {
setActionLoading(null)
}
}
const runningCount = Object.values(status?.services_status || {}).filter(
s => s.toLowerCase() === 'running' || s.toLowerCase().includes('up')
).length
const totalCount = Object.keys(status?.services_status || {}).length
if (loading) {
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
<div className="animate-pulse flex items-center gap-3">
<div className="w-10 h-10 bg-slate-200 rounded-full" />
<div className="flex-1">
<div className="h-4 bg-slate-200 rounded w-24 mb-2" />
<div className="h-3 bg-slate-200 rounded w-32" />
</div>
</div>
</div>
)
}
if (error) {
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-slate-100 rounded-full flex items-center justify-center">
<svg className="w-5 h-5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</div>
<div>
<h3 className="font-medium text-slate-900">Nachtabschaltung</h3>
<p className="text-sm text-red-500">{error}</p>
</div>
</div>
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
Details
</Link>
</div>
</div>
)
}
return (
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
{/* Header */}
<div className="px-4 py-3 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
status?.config.enabled ? 'bg-orange-100' : 'bg-slate-100'
}`}>
<svg className={`w-5 h-5 ${status?.config.enabled ? 'text-orange-600' : 'text-slate-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
</div>
<div>
<h3 className="font-semibold text-slate-900">Nachtabschaltung</h3>
<p className="text-sm text-slate-500">
{status?.config.enabled
? `${status.config.shutdown_time} - ${status.config.startup_time}`
: 'Deaktiviert'}
</p>
</div>
</div>
<Link href="/infrastructure/night-mode" className="text-sm text-primary-600 hover:text-primary-700">
Einstellungen
</Link>
</div>
{/* Content */}
<div className="p-4">
{/* Status Row */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
{/* Toggle */}
<button
onClick={toggleEnabled}
disabled={actionLoading !== null}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
status?.config.enabled ? 'bg-orange-600' : 'bg-slate-300'
} ${actionLoading === 'toggle' ? 'opacity-50' : ''}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
status?.config.enabled ? 'translate-x-6' : 'translate-x-1'
}`} />
</button>
{/* Countdown */}
{status?.config.enabled && status.time_until_next_action && (
<div className="flex items-center gap-2">
<span className={`text-sm font-medium ${
status.next_action === 'shutdown' ? 'text-red-600' : 'text-green-600'
}`}>
{status.next_action === 'shutdown' ? '⏸' : '▶'} {status.time_until_next_action}
</span>
</div>
)}
</div>
{/* Service Count */}
<div className="text-sm text-slate-500">
<span className="font-semibold text-green-600">{runningCount}</span>
<span className="text-slate-400">/{totalCount}</span>
<span className="ml-1">aktiv</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => executeAction('stop')}
disabled={actionLoading !== null}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-red-50 text-red-700 rounded-lg text-sm font-medium hover:bg-red-100 disabled:opacity-50 transition-colors"
>
{actionLoading === 'stop' ? (
<span className="animate-spin text-xs"></span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 10h6v4H9z" />
</svg>
)}
Stoppen
</button>
<button
onClick={() => executeAction('start')}
disabled={actionLoading !== null}
className="flex-1 flex items-center justify-center gap-2 px-3 py-2 bg-green-50 text-green-700 rounded-lg text-sm font-medium hover:bg-green-100 disabled:opacity-50 transition-colors"
>
{actionLoading === 'start' ? (
<span className="animate-spin text-xs"></span>
) : (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)}
Starten
</button>
</div>
</div>
</div>
)
}
@@ -0,0 +1,313 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Book, Code, FileText, HelpCircle, Zap, Terminal, Database, Shield, ChevronRight, Clock } from 'lucide-react'
interface NavItem {
title: string
href: string
icon?: React.ReactNode
items?: NavItem[]
}
const navigation: NavItem[] = [
{
title: 'Getting Started',
href: '/developers/getting-started',
icon: <Zap className="w-4 h-4" />,
items: [
{ title: 'Quick Start', href: '/developers/getting-started' },
],
},
{
title: 'SDK Documentation',
href: '/developers/sdk',
icon: <Code className="w-4 h-4" />,
items: [
{ title: 'Overview', href: '/developers/sdk' },
{ title: 'Installation', href: '/developers/sdk/installation' },
{ title: 'Configuration', href: '/developers/sdk/configuration' },
],
},
{
title: 'Consent SDK',
href: '/developers/sdk/consent',
icon: <Shield className="w-4 h-4" />,
items: [
{ title: 'Uebersicht', href: '/developers/sdk/consent' },
{ title: 'Installation', href: '/developers/sdk/consent/installation' },
{ title: 'API Referenz', href: '/developers/sdk/consent/api-reference' },
{ title: 'Frameworks', href: '/developers/sdk/consent/frameworks' },
{ title: 'Mobile SDKs', href: '/developers/sdk/consent/mobile' },
{ title: 'Sicherheit', href: '/developers/sdk/consent/security' },
],
},
{
title: 'API Reference',
href: '/developers/api',
icon: <Terminal className="w-4 h-4" />,
items: [
{ title: 'Overview', href: '/developers/api' },
{ title: 'State API', href: '/developers/api/state' },
{ title: 'RAG Search API', href: '/developers/api/rag' },
{ title: 'Generation API', href: '/developers/api/generate' },
{ title: 'Export API', href: '/developers/api/export' },
],
},
{
title: 'Guides',
href: '/developers/guides',
icon: <Book className="w-4 h-4" />,
items: [
{ title: 'Overview', href: '/developers/guides' },
{ title: 'Phase 1: Assessment', href: '/developers/guides/phase1' },
{ title: 'Phase 2: Dokumentation', href: '/developers/guides/phase2' },
],
},
{
title: 'Changelog',
href: '/developers/changelog',
icon: <Clock className="w-4 h-4" />,
items: [
{ title: 'Versionshistorie', href: '/developers/changelog' },
],
},
]
interface DevPortalLayoutProps {
children: React.ReactNode
title?: string
description?: string
}
export function DevPortalLayout({ children, title, description }: DevPortalLayoutProps) {
const pathname = usePathname()
return (
<div className="min-h-screen bg-white">
{/* Header */}
<header className="sticky top-0 z-50 border-b border-gray-200 bg-white/95 backdrop-blur">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between h-16">
<div className="flex items-center gap-4">
<Link href="/developers" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center">
<FileText className="w-5 h-5 text-white" />
</div>
<span className="font-semibold text-gray-900">Developer Portal</span>
</Link>
<span className="text-gray-300">|</span>
<span className="text-sm text-gray-500">AI Compliance SDK</span>
</div>
<div className="flex items-center gap-4">
<Link
href="/sdk"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
SDK Dashboard
</Link>
<a
href="https://github.com/breakpilot/compliance-sdk"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-gray-600 hover:text-gray-900 transition-colors"
>
GitHub
</a>
</div>
</div>
</div>
</header>
<div className="max-w-7xl mx-auto flex">
{/* Sidebar */}
<aside className="w-64 shrink-0 border-r border-gray-200 h-[calc(100vh-64px)] sticky top-16 overflow-y-auto">
<nav className="p-4 space-y-6">
{navigation.map((section) => (
<div key={section.href}>
<div className="flex items-center gap-2 text-sm font-medium text-gray-900 mb-2">
{section.icon}
<span>{section.title}</span>
</div>
{section.items && (
<ul className="ml-6 space-y-1">
{section.items.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className={`block py-1.5 text-sm transition-colors ${
pathname === item.href
? 'text-blue-600 font-medium'
: 'text-gray-600 hover:text-gray-900'
}`}
>
{item.title}
</Link>
</li>
))}
</ul>
)}
</div>
))}
</nav>
</aside>
{/* Main Content */}
<main className="flex-1 min-w-0">
<div className="max-w-3xl mx-auto px-8 py-12">
{(title || description) && (
<div className="mb-8">
{title && (
<h1 className="text-3xl font-bold text-gray-900 mb-2">{title}</h1>
)}
{description && (
<p className="text-lg text-gray-600">{description}</p>
)}
</div>
)}
<div className="prose prose-gray prose-blue max-w-none">
{children}
</div>
</div>
</main>
</div>
</div>
)
}
// Re-usable components for documentation
export function ApiEndpoint({
method,
path,
description,
}: {
method: 'GET' | 'POST' | 'PUT' | 'DELETE'
path: string
description: string
}) {
const methodColors = {
GET: 'bg-green-100 text-green-800',
POST: 'bg-blue-100 text-blue-800',
PUT: 'bg-yellow-100 text-yellow-800',
DELETE: 'bg-red-100 text-red-800',
}
return (
<div className="border border-gray-200 rounded-lg p-4 my-4 not-prose">
<div className="flex items-center gap-3 mb-2">
<span className={`px-2 py-1 text-xs font-bold rounded ${methodColors[method]}`}>
{method}
</span>
<code className="text-sm font-mono text-gray-800">{path}</code>
</div>
<p className="text-sm text-gray-600">{description}</p>
</div>
)
}
export function CodeBlock({
language,
children,
filename,
}: {
language: string
children: string
filename?: string
}) {
return (
<div className="my-4 not-prose">
{filename && (
<div className="bg-gray-800 text-gray-300 text-xs px-4 py-2 rounded-t-lg border-b border-gray-700">
{filename}
</div>
)}
<pre className={`bg-gray-900 text-gray-100 p-4 overflow-x-auto text-sm ${filename ? 'rounded-b-lg' : 'rounded-lg'}`}>
<code className={`language-${language}`}>{children}</code>
</pre>
</div>
)
}
export function ParameterTable({
parameters,
}: {
parameters: Array<{
name: string
type: string
required?: boolean
description: string
}>
}) {
return (
<div className="my-4 overflow-x-auto not-prose">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Parameter</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Type</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Required</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Description</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{parameters.map((param) => (
<tr key={param.name}>
<td className="px-4 py-3">
<code className="text-sm text-blue-600">{param.name}</code>
</td>
<td className="px-4 py-3">
<code className="text-sm text-gray-600">{param.type}</code>
</td>
<td className="px-4 py-3">
{param.required ? (
<span className="text-red-600 text-sm">Yes</span>
) : (
<span className="text-gray-400 text-sm">No</span>
)}
</td>
<td className="px-4 py-3 text-sm text-gray-600">{param.description}</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export function InfoBox({
type = 'info',
title,
children,
}: {
type?: 'info' | 'warning' | 'success' | 'error'
title?: string
children: React.ReactNode
}) {
const styles = {
info: 'bg-blue-50 border-blue-200 text-blue-800',
warning: 'bg-yellow-50 border-yellow-200 text-yellow-800',
success: 'bg-green-50 border-green-200 text-green-800',
error: 'bg-red-50 border-red-200 text-red-800',
}
const icons = {
info: <HelpCircle className="w-5 h-5" />,
warning: <Shield className="w-5 h-5" />,
success: <Zap className="w-5 h-5" />,
error: <Shield className="w-5 h-5" />,
}
return (
<div className={`my-4 p-4 border rounded-lg ${styles[type]} not-prose`}>
<div className="flex items-start gap-3">
<div className="shrink-0 mt-0.5">{icons[type]}</div>
<div>
{title && <p className="font-medium mb-1">{title}</p>}
<div className="text-sm">{children}</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,165 @@
'use client'
import React from 'react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import {
Shield, Download, FileCode, Layers, Smartphone, Lock,
ChevronDown, ChevronRight, Home, BookOpen,
Code2
} from 'lucide-react'
interface NavItem {
title: string
href: string
icon?: React.ReactNode
children?: NavItem[]
}
const navigation: NavItem[] = [
{
title: 'Uebersicht',
href: '/developers/sdk/consent',
icon: <Home className="w-4 h-4" />,
},
{
title: 'Installation',
href: '/developers/sdk/consent/installation',
icon: <Download className="w-4 h-4" />,
},
{
title: 'API Referenz',
href: '/developers/sdk/consent/api-reference',
icon: <FileCode className="w-4 h-4" />,
},
{
title: 'Frameworks',
href: '/developers/sdk/consent/frameworks',
icon: <Layers className="w-4 h-4" />,
children: [
{ title: 'React', href: '/developers/sdk/consent/frameworks/react' },
{ title: 'Vue', href: '/developers/sdk/consent/frameworks/vue' },
{ title: 'Angular', href: '/developers/sdk/consent/frameworks/angular' },
],
},
{
title: 'Mobile SDKs',
href: '/developers/sdk/consent/mobile',
icon: <Smartphone className="w-4 h-4" />,
children: [
{ title: 'iOS (Swift)', href: '/developers/sdk/consent/mobile/ios' },
{ title: 'Android (Kotlin)', href: '/developers/sdk/consent/mobile/android' },
{ title: 'Flutter', href: '/developers/sdk/consent/mobile/flutter' },
],
},
{
title: 'Sicherheit',
href: '/developers/sdk/consent/security',
icon: <Lock className="w-4 h-4" />,
},
]
function NavLink({ item, depth = 0 }: { item: NavItem; depth?: number }) {
const pathname = usePathname()
const isActive = pathname === item.href
const isParentActive = item.children?.some((child) => pathname === child.href)
const [isOpen, setIsOpen] = React.useState(isActive || isParentActive)
const hasChildren = item.children && item.children.length > 0
return (
<div>
<div className="flex items-center">
<Link
href={item.href}
className={`flex-1 flex items-center gap-2 px-3 py-2 text-sm rounded-lg transition-colors ${
isActive
? 'bg-violet-100 text-violet-900 font-medium'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
style={{ paddingLeft: `${12 + depth * 12}px` }}
>
{item.icon && <span className="shrink-0">{item.icon}</span>}
<span>{item.title}</span>
</Link>
{hasChildren && (
<button
onClick={() => setIsOpen(!isOpen)}
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
>
{isOpen ? (
<ChevronDown className="w-4 h-4 text-gray-400" />
) : (
<ChevronRight className="w-4 h-4 text-gray-400" />
)}
</button>
)}
</div>
{hasChildren && isOpen && (
<div className="mt-1 space-y-1">
{item.children?.map((child) => (
<NavLink key={child.href} item={child} depth={depth + 1} />
))}
</div>
)}
</div>
)
}
export function SDKDocsSidebar() {
return (
<aside className="w-64 h-[calc(100vh-64px)] fixed top-16 left-0 border-r border-gray-200 bg-white overflow-y-auto">
<div className="p-4">
{/* Header */}
<div className="mb-6">
<Link
href="/developers/sdk/consent"
className="flex items-center gap-3 p-3 rounded-xl bg-gradient-to-r from-violet-50 to-purple-50 border border-violet-100"
>
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-violet-600 to-purple-600 flex items-center justify-center">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<div className="font-semibold text-gray-900">Consent SDK</div>
<div className="text-xs text-gray-500">v1.0.0</div>
</div>
</Link>
</div>
{/* Navigation */}
<nav className="space-y-1">
{navigation.map((item) => (
<NavLink key={item.href} item={item} />
))}
</nav>
{/* Resources */}
<div className="mt-8 pt-6 border-t border-gray-200">
<div className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-3 px-3">
Ressourcen
</div>
<div className="space-y-1">
<a
href="https://github.com/breakpilot/consent-sdk"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors"
>
<Code2 className="w-4 h-4" />
<span>GitHub</span>
</a>
<Link
href="/developers"
className="flex items-center gap-2 px-3 py-2 text-sm text-gray-600 hover:bg-gray-100 hover:text-gray-900 rounded-lg transition-colors"
>
<BookOpen className="w-4 h-4" />
<span>Developer Portal</span>
</Link>
</div>
</div>
</div>
</aside>
)
}
export default SDKDocsSidebar
@@ -0,0 +1,366 @@
'use client'
/**
* Dokumente Tab for edu-search page
* Shows filterable list of Abitur documents with pagination
*/
import { useState, useEffect } from 'react'
import { FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search, X, Loader2 } from 'lucide-react'
import {
AbiturDokument,
AbiturDocsResponse,
formatFileSize,
formatDocumentTitle,
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import { PDFPreviewModal } from './PDFPreviewModal'
interface DokumenteTabProps {
onDocumentCountChange?: (count: number) => void
}
export function DokumenteTab({ onDocumentCountChange }: DokumenteTabProps) {
const [documents, setDocuments] = useState<AbiturDokument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Pagination
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const limit = 20
// Filters
const [filterOpen, setFilterOpen] = useState(false)
const [filterFach, setFilterFach] = useState<string>('')
const [filterJahr, setFilterJahr] = useState<string>('')
const [filterBundesland, setFilterBundesland] = useState<string>('')
const [filterNiveau, setFilterNiveau] = useState<string>('')
const [filterTyp, setFilterTyp] = useState<string>('')
// Modal
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
// Fetch documents
useEffect(() => {
const fetchDocuments = async () => {
setLoading(true)
setError(null)
const params = new URLSearchParams()
params.set('page', page.toString())
params.set('limit', limit.toString())
if (filterFach) params.set('fach', filterFach)
if (filterJahr) params.set('jahr', filterJahr)
if (filterBundesland) params.set('bundesland', filterBundesland)
if (filterNiveau) params.set('niveau', filterNiveau)
if (filterTyp) params.set('typ', filterTyp)
try {
const response = await fetch(`/api/education/abitur-docs?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
const data: AbiturDocsResponse = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
onDocumentCountChange?.(data.total || 0)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}
fetchDocuments()
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, onDocumentCountChange])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterBundesland('')
setFilterNiveau('')
setFilterTyp('')
setPage(1)
}
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp
return (
<div className="space-y-4">
{/* Filter Bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-4">
<button
onClick={() => setFilterOpen(!filterOpen)}
className={`px-4 py-2 rounded-lg font-medium flex items-center gap-2 transition-colors ${
filterOpen || hasActiveFilters
? 'bg-purple-100 text-purple-700'
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
}`}
>
<Filter className="w-4 h-4" />
Filter
{hasActiveFilters && (
<span className="bg-purple-600 text-white text-xs px-1.5 py-0.5 rounded-full">
{[filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp].filter(Boolean).length}
</span>
)}
</button>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="text-sm text-slate-500 hover:text-slate-700 flex items-center gap-1"
>
<X className="w-4 h-4" />
Filter zurücksetzen
</button>
)}
</div>
{/* Filter Dropdowns */}
{filterOpen && (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 pt-4 border-t border-slate-200">
{/* Fach */}
<div>
<label className="block text-xs text-slate-500 mb-1">Fach</label>
<select
value={filterFach}
onChange={(e) => { setFilterFach(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Fächer</option>
{FAECHER.map(f => (
<option key={f.id} value={f.id}>{f.label}</option>
))}
</select>
</div>
{/* Jahr */}
<div>
<label className="block text-xs text-slate-500 mb-1">Jahr</label>
<select
value={filterJahr}
onChange={(e) => { setFilterJahr(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Jahre</option>
{JAHRE.map(j => (
<option key={j} value={j}>{j}</option>
))}
</select>
</div>
{/* Bundesland */}
<div>
<label className="block text-xs text-slate-500 mb-1">Bundesland</label>
<select
value={filterBundesland}
onChange={(e) => { setFilterBundesland(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Bundesländer</option>
{BUNDESLAENDER.map(b => (
<option key={b.id} value={b.id}>{b.label}</option>
))}
</select>
</div>
{/* Niveau */}
<div>
<label className="block text-xs text-slate-500 mb-1">Niveau</label>
<select
value={filterNiveau}
onChange={(e) => { setFilterNiveau(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Niveaus</option>
{NIVEAUS.map(n => (
<option key={n.id} value={n.id}>{n.label}</option>
))}
</select>
</div>
{/* Typ */}
<div>
<label className="block text-xs text-slate-500 mb-1">Typ</label>
<select
value={filterTyp}
onChange={(e) => { setFilterTyp(e.target.value); setPage(1) }}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500"
>
<option value="">Alle Typen</option>
{TYPEN.map(t => (
<option key={t.id} value={t.id}>{t.label}</option>
))}
</select>
</div>
</div>
)}
</div>
{/* Document List */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
</div>
) : error ? (
<div className="text-center py-12 text-red-600">
<p>{error}</p>
<button
onClick={() => setPage(1)}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Erneut versuchen
</button>
</div>
) : documents.length === 0 ? (
<div className="text-center py-12 text-slate-500">
<FileText className="w-12 h-12 mx-auto mb-3 opacity-50" />
<p>Keine Dokumente gefunden</p>
{hasActiveFilters && (
<button
onClick={clearFilters}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Filter zurücksetzen
</button>
)}
</div>
) : (
<>
<table className="w-full text-sm">
<thead className="bg-slate-50 border-b border-slate-200">
<tr>
<th className="text-left px-4 py-3 font-medium text-slate-600">Dokument</th>
<th className="text-left px-4 py-3 font-medium text-slate-600">Fach</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Jahr</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Niveau</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Typ</th>
<th className="text-right px-4 py-3 font-medium text-slate-600">Groesse</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Status</th>
<th className="text-center px-4 py-3 font-medium text-slate-600">Aktion</th>
</tr>
</thead>
<tbody>
{documents.map((doc) => {
const fachLabel = FAECHER.find(f => f.id === doc.fach)?.label || doc.fach
return (
<tr
key={doc.id}
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
onClick={() => setSelectedDocument(doc)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-red-500" />
<span className="font-medium text-slate-900 truncate max-w-[200px]" title={doc.dateiname}>
{doc.dateiname}
</span>
</div>
</td>
<td className="px-4 py-3">
<span className="capitalize">{fachLabel}</span>
</td>
<td className="px-4 py-3 text-center">{doc.jahr}</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.niveau === 'eA'
? 'bg-blue-100 text-blue-700'
: 'bg-slate-100 text-slate-700'
}`}>
{doc.niveau}
</span>
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{doc.typ === 'erwartungshorizont' ? 'EWH' : 'Aufgabe'}
</span>
</td>
<td className="px-4 py-3 text-right text-slate-500">
{formatFileSize(doc.file_size)}
</td>
<td className="px-4 py-3 text-center">
<span className={`px-2 py-0.5 rounded-full text-xs ${
doc.status === 'indexed'
? 'bg-green-100 text-green-700'
: doc.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{doc.status === 'indexed' ? 'Indexiert' : doc.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</td>
<td className="px-4 py-3 text-center">
<div className="flex items-center justify-center gap-1" onClick={(e) => e.stopPropagation()}>
<button
onClick={() => setSelectedDocument(doc)}
className="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="Vorschau"
>
<Eye className="w-4 h-4" />
</button>
<a
href={doc.file_path}
download={doc.dateiname}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</a>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<div className="text-sm text-slate-500">
Zeige {(page - 1) * limit + 1}-{Math.min(page * limit, total)} von {total} Dokumenten
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setPage(p => Math.max(1, p - 1))}
disabled={page === 1}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-slate-600">
Seite {page} von {totalPages}
</span>
<button
onClick={() => setPage(p => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="p-2 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
</>
)}
</div>
{/* PDF Preview Modal */}
<PDFPreviewModal
document={selectedDocument}
onClose={() => setSelectedDocument(null)}
/>
</div>
)
}
@@ -0,0 +1,207 @@
'use client'
/**
* PDF Preview Modal for Abitur Documents
* Shows PDF iframe with metadata sidebar
*/
import { X, Download, ExternalLink, FileText, Calendar, BookOpen, Layers, Search } from 'lucide-react'
import { AbiturDokument, formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
interface PDFPreviewModalProps {
document: AbiturDokument | null
onClose: () => void
backendUrl?: string
}
export function PDFPreviewModal({ document, onClose, backendUrl = '' }: PDFPreviewModalProps) {
if (!document) return null
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
const niveauLabel = NIVEAUS.find(n => n.id === document.niveau)?.label || document.niveau
// Build PDF URL - try backend first, fall back to direct path
const pdfUrl = backendUrl
? `${backendUrl}/api/abitur-docs/${document.id}/file`
: document.file_path
const handleDownload = () => {
const link = window.document.createElement('a')
link.href = pdfUrl
link.download = document.dateiname
link.click()
}
const handleSearchInRAG = () => {
// Navigate to edu-search with document as context
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-white rounded-2xl shadow-2xl w-[95vw] h-[90vh] max-w-7xl flex overflow-hidden">
{/* Header */}
<div className="absolute top-0 left-0 right-0 h-14 bg-white border-b border-slate-200 flex items-center justify-between px-4 z-10">
<h2 className="font-semibold text-slate-900 truncate flex items-center gap-2">
<FileText className="w-5 h-5 text-blue-600" />
{formatDocumentTitle(document)}
</h2>
<div className="flex items-center gap-2">
<button
onClick={handleSearchInRAG}
className="px-3 py-1.5 text-sm bg-purple-100 text-purple-700 rounded-lg hover:bg-purple-200 flex items-center gap-1.5"
title="In RAG suchen"
>
<Search className="w-4 h-4" />
RAG-Suche
</button>
<button
onClick={handleDownload}
className="px-3 py-1.5 text-sm bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 flex items-center gap-1.5"
title="Herunterladen"
>
<Download className="w-4 h-4" />
Download
</button>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg transition-colors"
title="Schliessen"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
{/* Content */}
<div className="flex w-full h-full pt-14">
{/* PDF Viewer */}
<div className="flex-1 bg-slate-100 p-4">
<iframe
src={pdfUrl}
className="w-full h-full rounded-lg border border-slate-200 bg-white"
title={document.dateiname}
/>
</div>
{/* Metadata Sidebar */}
<div className="w-80 border-l border-slate-200 bg-slate-50 p-4 overflow-y-auto">
<h3 className="font-semibold text-slate-900 mb-4">Dokument-Details</h3>
<div className="space-y-4">
{/* Fach */}
<div className="flex items-start gap-3">
<BookOpen className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Fach</div>
<div className="font-medium text-slate-900">{fachLabel}</div>
</div>
</div>
{/* Jahr */}
<div className="flex items-start gap-3">
<Calendar className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Jahr</div>
<div className="font-medium text-slate-900">{document.jahr}</div>
</div>
</div>
{/* Niveau */}
<div className="flex items-start gap-3">
<Layers className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Niveau</div>
<div className="font-medium text-slate-900">{niveauLabel}</div>
</div>
</div>
{/* Aufgaben-Nummer */}
<div className="flex items-start gap-3">
<FileText className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Aufgabe</div>
<div className="font-medium text-slate-900">
{document.aufgaben_nummer}
<span className="ml-2 px-2 py-0.5 bg-slate-200 text-slate-700 text-xs rounded-full">
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
</span>
</div>
</div>
</div>
{/* Bundesland */}
<div className="flex items-start gap-3">
<ExternalLink className="w-5 h-5 text-slate-400 mt-0.5" />
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide">Bundesland</div>
<div className="font-medium text-slate-900 capitalize">{document.bundesland}</div>
</div>
</div>
<hr className="border-slate-200" />
{/* File Info */}
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">Datei-Info</div>
<div className="bg-white rounded-lg border border-slate-200 p-3 text-sm space-y-2">
<div className="flex justify-between">
<span className="text-slate-500">Dateiname</span>
<span className="text-slate-900 font-mono text-xs truncate max-w-[150px]" title={document.dateiname}>
{document.dateiname}
</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Groesse</span>
<span className="text-slate-900">{formatFileSize(document.file_size)}</span>
</div>
<div className="flex justify-between">
<span className="text-slate-500">Status</span>
<span className={`px-2 py-0.5 rounded-full text-xs ${
document.status === 'indexed'
? 'bg-green-100 text-green-700'
: document.status === 'error'
? 'bg-red-100 text-red-700'
: 'bg-yellow-100 text-yellow-700'
}`}>
{document.status === 'indexed' ? 'Indexiert' : document.status === 'error' ? 'Fehler' : 'Ausstehend'}
</span>
</div>
</div>
</div>
{/* RAG Info */}
{document.indexed && document.vector_ids.length > 0 && (
<div>
<div className="text-xs text-slate-500 uppercase tracking-wide mb-2">RAG-Index</div>
<div className="bg-purple-50 border border-purple-200 rounded-lg p-3 text-sm">
<div className="flex items-center gap-2 text-purple-700">
<Search className="w-4 h-4" />
<span>{document.vector_ids.length} Vektoren indexiert</span>
</div>
<div className="mt-2 text-xs text-purple-600">
Confidence: {(document.confidence * 100).toFixed(0)}%
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="text-xs text-slate-400 pt-2">
<div>Erstellt: {new Date(document.created_at).toLocaleString('de-DE')}</div>
<div>Aktualisiert: {new Date(document.updated_at).toLocaleString('de-DE')}</div>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,533 @@
'use client'
/**
* DevOps Pipeline Sidebar
*
* Kompakte Sidebar-Komponente fuer Cross-Navigation zwischen DevOps-Modulen.
* Zeigt Pipeline-Flow und ermoeglicht schnelle Navigation.
*
* Features:
* - Desktop: Fixierte Sidebar rechts
* - Mobile: Floating Action Button mit Slide-In Drawer
* - Live Pipeline-Status Badge
* - Backlog-Count Badge
* - Security-Findings-Count Badge
* - Quick-Action: Pipeline triggern
*
* Datenfluss: CI/CD -> Tests -> SBOM -> Security
*/
import Link from 'next/link'
import { useState, useEffect } from 'react'
import type {
DevOpsToolId,
DevOpsPipelineSidebarProps,
DevOpsPipelineSidebarResponsiveProps,
PipelineLiveStatus,
} from '@/types/infrastructure-modules'
import { DEVOPS_PIPELINE_MODULES } from '@/types/infrastructure-modules'
// =============================================================================
// Icons
// =============================================================================
const ToolIcon = ({ id }: { id: DevOpsToolId }) => {
switch (id) {
case 'ci-cd':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
)
case 'tests':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
case 'sbom':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
)
case 'security':
return (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
)
default:
return null
}
}
// Server/Pipeline Icon fuer Header
const ServerIcon = () => (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
)
// Play Icon fuer Quick Action
const PlayIcon = () => (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
)
// =============================================================================
// Live Status Hook (optional - fetches status from API)
// =============================================================================
function usePipelineLiveStatus(): PipelineLiveStatus | null {
const [status, setStatus] = useState<PipelineLiveStatus | null>(null)
useEffect(() => {
// Optional: Fetch live status from API
// For now, return null and display static content
// Uncomment below to enable live status fetching
/*
const fetchStatus = async () => {
try {
const response = await fetch('/api/admin/infrastructure/woodpecker/status')
if (response.ok) {
const data = await response.json()
setStatus(data)
}
} catch (error) {
console.error('Failed to fetch pipeline status:', error)
}
}
fetchStatus()
const interval = setInterval(fetchStatus, 30000) // Poll every 30s
return () => clearInterval(interval)
*/
}, [])
return status
}
// =============================================================================
// Status Badge Component
// =============================================================================
interface StatusBadgeProps {
count: number
type: 'backlog' | 'security' | 'running'
}
function StatusBadge({ count, type }: StatusBadgeProps) {
if (count === 0) return null
const colors = {
backlog: 'bg-amber-500',
security: 'bg-red-500',
running: 'bg-green-500 animate-pulse',
}
return (
<span className={`${colors[type]} text-white text-xs font-medium px-1.5 py-0.5 rounded-full min-w-[1.25rem] text-center`}>
{count}
</span>
)
}
// =============================================================================
// Main Sidebar Component
// =============================================================================
export function DevOpsPipelineSidebar({
currentTool,
compact = false,
className = '',
}: DevOpsPipelineSidebarProps) {
const [isExpanded, setIsExpanded] = useState(!compact)
const liveStatus = usePipelineLiveStatus()
return (
<div className={`bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-slate-200 dark:border-gray-700 overflow-hidden ${className}`}>
{/* Header */}
<div
className="px-4 py-3 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20 border-b border-slate-200 dark:border-gray-700 cursor-pointer"
onClick={() => setIsExpanded(!isExpanded)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-orange-600 dark:text-orange-400">
<ServerIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200 text-sm">
DevOps Pipeline
</span>
{/* Live status indicator */}
{liveStatus?.isRunning && (
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" title="Pipeline laeuft" />
)}
</div>
<svg
className={`w-4 h-4 text-slate-400 transition-transform ${isExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{/* Content */}
{isExpanded && (
<div className="p-3 space-y-3">
{/* Tool Links */}
<div className="space-y-1">
{DEVOPS_PIPELINE_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
className={`flex items-center gap-3 px-3 py-2 rounded-lg transition-colors ${
currentTool === tool.id
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-700'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">{tool.name}</div>
<div className="text-xs text-slate-500 dark:text-slate-500 truncate">
{tool.description}
</div>
</div>
{/* Status badges */}
{tool.id === 'tests' && liveStatus && (
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
)}
{tool.id === 'security' && liveStatus && (
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
)}
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2 h-2 bg-orange-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Pipeline Flow Visualization */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs font-medium text-slate-500 dark:text-slate-400 mb-2 px-1">
Pipeline Flow
</div>
<div className="flex items-center justify-between px-2 py-2 bg-slate-50 dark:bg-gray-900 rounded-lg">
<div className="flex items-center gap-1.5 text-xs">
<span title="Code" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>📝</span>
<span className="text-slate-400"></span>
<span title="Build" className={currentTool === 'ci-cd' ? 'opacity-100' : 'opacity-50'}>🏗</span>
<span className="text-slate-400"></span>
<span title="Test" className={currentTool === 'tests' ? 'opacity-100' : 'opacity-50'}></span>
<span className="text-slate-400"></span>
<span title="SBOM" className={currentTool === 'sbom' ? 'opacity-100' : 'opacity-50'}>📦</span>
<span className="text-slate-400"></span>
<span title="Security" className={currentTool === 'security' ? 'opacity-100' : 'opacity-50'}>🛡</span>
<span className="text-slate-400"></span>
<span title="Deploy" className="opacity-50">🚀</span>
</div>
</div>
</div>
{/* Quick Info zum aktuellen Tool */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<div className="text-xs text-slate-500 dark:text-slate-400 px-1">
{currentTool === 'ci-cd' && (
<span>Verwalten Sie Woodpecker Pipelines und Deployments</span>
)}
{currentTool === 'tests' && (
<span>Ueberwachen Sie 280+ Tests ueber alle Services</span>
)}
{currentTool === 'sbom' && (
<span>Pruefen Sie Abhaengigkeiten und Lizenzen</span>
)}
{currentTool === 'security' && (
<span>Analysieren Sie Vulnerabilities und Security-Scans</span>
)}
</div>
</div>
{/* Quick Action: Pipeline triggern */}
<div className="pt-2 border-t border-slate-200 dark:border-gray-700">
<button
onClick={() => {
// TODO: Implement pipeline trigger
alert('Pipeline wird getriggert...')
}}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-lg transition-colors"
>
<PlayIcon />
<span>Pipeline triggern</span>
</button>
</div>
</div>
)}
</div>
)
}
// =============================================================================
// Responsive Version with Mobile FAB + Drawer
// =============================================================================
/**
* Responsive DevOps Sidebar mit Mobile FAB + Drawer
*
* Desktop (xl+): Fixierte Sidebar rechts
* Mobile/Tablet: Floating Action Button unten rechts, oeffnet Drawer
*/
export function DevOpsPipelineSidebarResponsive({
currentTool,
compact = false,
className = '',
fabPosition = 'bottom-right',
}: DevOpsPipelineSidebarResponsiveProps) {
const [isMobileOpen, setIsMobileOpen] = useState(false)
const liveStatus = usePipelineLiveStatus()
// Close drawer on escape key
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsMobileOpen(false)
}
window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape)
}, [])
// Prevent body scroll when drawer is open
useEffect(() => {
if (isMobileOpen) {
document.body.style.overflow = 'hidden'
} else {
document.body.style.overflow = ''
}
return () => {
document.body.style.overflow = ''
}
}, [isMobileOpen])
const fabPositionClasses = fabPosition === 'bottom-right'
? 'right-4 bottom-20'
: 'left-4 bottom-20'
// Calculate total badge count for FAB
const totalBadgeCount = liveStatus
? liveStatus.backlogCount + liveStatus.securityFindingsCount
: 0
return (
<>
{/* Desktop: Fixed Sidebar */}
<div className={`hidden xl:block fixed right-6 top-24 w-64 z-10 ${className}`}>
<DevOpsPipelineSidebar currentTool={currentTool} compact={compact} />
</div>
{/* Mobile/Tablet: FAB */}
<button
onClick={() => setIsMobileOpen(true)}
className={`xl:hidden fixed ${fabPositionClasses} z-40 w-14 h-14 bg-gradient-to-r from-orange-500 to-amber-500 text-white rounded-full shadow-lg hover:shadow-xl transition-all flex items-center justify-center group`}
aria-label="DevOps Pipeline Navigation oeffnen"
>
<ServerIcon />
{/* Badge indicator */}
{totalBadgeCount > 0 && (
<span className="absolute -top-1 -right-1 w-5 h-5 bg-red-500 text-white text-xs font-bold rounded-full flex items-center justify-center">
{totalBadgeCount > 9 ? '9+' : totalBadgeCount}
</span>
)}
{/* Pulse indicator when pipeline is running */}
{liveStatus?.isRunning && (
<span className="absolute -top-1 -right-1 w-3 h-3 bg-green-400 rounded-full animate-pulse" />
)}
</button>
{/* Mobile/Tablet: Drawer Overlay */}
{isMobileOpen && (
<div className="xl:hidden fixed inset-0 z-50">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm transition-opacity"
onClick={() => setIsMobileOpen(false)}
/>
{/* Drawer */}
<div className="absolute right-0 top-0 bottom-0 w-80 max-w-[85vw] bg-white dark:bg-gray-900 shadow-2xl transform transition-transform animate-slide-in-right">
{/* Drawer Header */}
<div className="flex items-center justify-between px-4 py-4 border-b border-slate-200 dark:border-gray-700 bg-gradient-to-r from-orange-50 to-amber-50 dark:from-orange-900/20 dark:to-amber-900/20">
<div className="flex items-center gap-2">
<span className="text-orange-600 dark:text-orange-400">
<ServerIcon />
</span>
<span className="font-semibold text-slate-700 dark:text-slate-200">
DevOps Pipeline
</span>
{liveStatus?.isRunning && (
<span className="w-2 h-2 bg-green-500 rounded-full animate-pulse" />
)}
</div>
<button
onClick={() => setIsMobileOpen(false)}
className="p-2 text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 rounded-lg hover:bg-slate-100 dark:hover:bg-gray-800 transition-colors"
aria-label="Schliessen"
>
<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>
{/* Drawer Content */}
<div className="p-4 space-y-4 overflow-y-auto max-h-[calc(100vh-80px)]">
{/* Tool Links */}
<div className="space-y-2">
{DEVOPS_PIPELINE_MODULES.map((tool) => (
<Link
key={tool.id}
href={tool.href}
onClick={() => setIsMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all ${
currentTool === tool.id
? 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 font-medium shadow-sm'
: 'text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-gray-800'
}`}
>
<ToolIcon id={tool.id} />
<div className="flex-1">
<div className="text-base font-medium">{tool.name}</div>
<div className="text-sm text-slate-500 dark:text-slate-500">
{tool.description}
</div>
</div>
{/* Status badges */}
{tool.id === 'tests' && liveStatus && (
<StatusBadge count={liveStatus.backlogCount} type="backlog" />
)}
{tool.id === 'security' && liveStatus && (
<StatusBadge count={liveStatus.securityFindingsCount} type="security" />
)}
{currentTool === tool.id && (
<span className="flex-shrink-0 w-2.5 h-2.5 bg-orange-500 rounded-full" />
)}
</Link>
))}
</div>
{/* Pipeline Flow Visualization */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm font-medium text-slate-500 dark:text-slate-400 mb-3">
Pipeline Flow
</div>
<div className="flex items-center justify-center gap-2 p-4 bg-slate-50 dark:bg-gray-800 rounded-xl">
<div className="flex flex-col items-center">
<span className="text-2xl">📝</span>
<span className="text-xs text-slate-500 mt-1">Code</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🏗</span>
<span className="text-xs text-slate-500 mt-1">Build</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl"></span>
<span className="text-xs text-slate-500 mt-1">Test</span>
</div>
<span className="text-slate-400"></span>
<div className="flex flex-col items-center">
<span className="text-2xl">🚀</span>
<span className="text-xs text-slate-500 mt-1">Deploy</span>
</div>
</div>
</div>
{/* Quick Info */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<div className="text-sm text-slate-600 dark:text-slate-400 p-3 bg-slate-50 dark:bg-gray-800 rounded-xl">
{currentTool === 'ci-cd' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Woodpecker Pipelines und Deployments verwalten
</>
)}
{currentTool === 'tests' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> 280+ Tests ueber alle Services ueberwachen
</>
)}
{currentTool === 'sbom' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Abhaengigkeiten und Lizenzen pruefen
</>
)}
{currentTool === 'security' && (
<>
<strong className="text-slate-700 dark:text-slate-300">Aktuell:</strong> Vulnerabilities und Security-Scans analysieren
</>
)}
</div>
</div>
{/* Quick Action: Pipeline triggern */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<button
onClick={() => {
// TODO: Implement pipeline trigger
alert('Pipeline wird getriggert...')
setIsMobileOpen(false)
}}
className="w-full flex items-center justify-center gap-2 px-4 py-3 text-base text-white bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 rounded-xl transition-colors font-medium"
>
<PlayIcon />
<span>Pipeline triggern</span>
</button>
</div>
{/* Link to Infrastructure Overview */}
<div className="pt-4 border-t border-slate-200 dark:border-gray-700">
<Link
href="/infrastructure"
onClick={() => setIsMobileOpen(false)}
className="flex items-center gap-2 px-3 py-2 text-sm text-orange-600 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
<span>Zur Infrastructure-Uebersicht</span>
</Link>
</div>
</div>
</div>
</div>
)}
{/* CSS for slide-in animation */}
<style jsx>{`
@keyframes slide-in-right {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.animate-slide-in-right {
animation: slide-in-right 0.2s ease-out;
}
`}</style>
</>
)
}
export default DevOpsPipelineSidebar
+76
View File
@@ -0,0 +1,76 @@
'use client'
import { usePathname } from 'next/navigation'
import { navigation, metaModules, getModuleByHref } from '@/lib/navigation'
interface HeaderProps {
title?: string
description?: string
}
export function Header({ title, description }: HeaderProps) {
const pathname = usePathname()
// Auto-detect title and description from navigation
let pageTitle = title
let pageDescription = description
if (!pageTitle) {
// Check meta modules first
const metaModule = metaModules.find(m => pathname === m.href || pathname.startsWith(m.href + '/'))
if (metaModule) {
pageTitle = metaModule.name
pageDescription = metaModule.description
} else {
// Check navigation modules
const result = getModuleByHref(pathname)
if (result) {
pageTitle = result.module.name
pageDescription = result.module.description
} else {
// Check category pages
const category = navigation.find(cat => pathname === `/${cat.id}`)
if (category) {
pageTitle = category.name
pageDescription = category.description
}
}
}
}
return (
<header className="h-16 bg-white border-b border-slate-200 flex items-center px-6 sticky top-0 z-10">
<div className="flex-1">
{pageTitle && <h1 className="text-xl font-semibold text-slate-900">{pageTitle}</h1>}
{pageDescription && <p className="text-sm text-slate-500">{pageDescription}</p>}
</div>
{/* Search */}
<div className="flex items-center gap-4">
<div className="relative">
<input
type="text"
placeholder="Suchen... (Ctrl+K)"
className="w-64 pl-10 pr-4 py-2 bg-slate-100 border border-transparent rounded-lg text-sm focus:bg-white focus:border-primary-300 focus:outline-none transition-colors"
/>
<svg
className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
{/* User Area */}
<div className="flex items-center gap-3">
<span className="text-sm text-slate-500">Admin v2</span>
<div className="w-8 h-8 rounded-full bg-primary-600 flex items-center justify-center text-white text-sm font-medium">
A
</div>
</div>
</div>
</header>
)
}
@@ -0,0 +1,107 @@
'use client'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { roles, getRoleById, getStoredRole, storeRole, RoleId } from '@/lib/roles'
interface RoleIndicatorProps {
collapsed?: boolean
onRoleChange?: () => void
}
export function RoleIndicator({ collapsed, onRoleChange }: RoleIndicatorProps) {
const router = useRouter()
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
const [showDropdown, setShowDropdown] = useState(false)
useEffect(() => {
const role = getStoredRole()
setCurrentRole(role)
}, [])
const handleRoleChange = (roleId: RoleId) => {
storeRole(roleId)
setCurrentRole(roleId)
setShowDropdown(false)
onRoleChange?.()
// Refresh the page to update navigation
router.refresh()
}
const role = currentRole ? getRoleById(currentRole) : null
if (!role) {
return null
}
// Role icons
const roleIcons: Record<RoleId, React.ReactNode> = {
developer: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
manager: (
<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>
),
auditor: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
dsb: (
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
}
return (
<div className="relative">
<button
onClick={() => setShowDropdown(!showDropdown)}
className={`w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-slate-300 hover:bg-slate-800 transition-colors ${
collapsed ? 'justify-center' : ''
}`}
title={collapsed ? `Rolle: ${role.name}` : undefined}
>
{currentRole && roleIcons[currentRole]}
{!collapsed && (
<>
<span className="flex-1 text-left">Rolle: {role.name}</span>
<svg
className={`w-4 h-4 transition-transform ${showDropdown ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
)}
</button>
{/* Dropdown */}
{showDropdown && (
<div className={`absolute ${collapsed ? 'left-full ml-2' : 'left-0 right-0'} bottom-full mb-2 bg-slate-800 rounded-lg shadow-lg border border-slate-700 overflow-hidden`}>
{roles.map((r) => (
<button
key={r.id}
onClick={() => handleRoleChange(r.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm transition-colors ${
r.id === currentRole
? 'bg-primary-600 text-white'
: 'text-slate-300 hover:bg-slate-700'
}`}
>
{roleIcons[r.id]}
<span>{r.name}</span>
</button>
))}
</div>
)}
</div>
)
}
+317
View File
@@ -0,0 +1,317 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { useState, useEffect } from 'react'
import { navigation, metaModules, NavCategory, CategoryId } from '@/lib/navigation'
import { RoleId, getStoredRole, isCategoryVisibleForRole } from '@/lib/roles'
import { RoleIndicator } from './RoleIndicator'
// Icons mapping
const categoryIcons: Record<string, React.ReactNode> = {
'shield-check': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
'clipboard-check': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
</svg>
),
shield: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
),
brain: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
server: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />
</svg>
),
graduation: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998 12.078 12.078 0 01.665-6.479L12 14z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 14l9-5-9-5-9 5 9 5zm0 0l6.16-3.422a12.083 12.083 0 01.665 6.479A11.952 11.952 0 0012 20.055a11.952 11.952 0 00-6.824-2.998a12.078 12.078 0 01.665-6.479L12 14zm-4 6v-7.5l4-2.222" />
</svg>
),
mail: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
),
code: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
globe: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9" />
</svg>
),
'code-2': (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
}
const metaIcons: Record<string, React.ReactNode> = {
dashboard: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
</svg>
),
architecture: (
<svg className="w-5 h-5" 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>
),
onboarding: (
<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>
),
backlog: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
</svg>
),
rbac: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
),
}
interface SidebarProps {
onRoleChange?: () => void
}
export function Sidebar({ onRoleChange }: SidebarProps) {
const pathname = usePathname()
const [collapsed, setCollapsed] = useState(false)
const [expandedCategories, setExpandedCategories] = useState<Set<CategoryId>>(new Set())
const [currentRole, setCurrentRole] = useState<RoleId | null>(null)
useEffect(() => {
const role = getStoredRole()
setCurrentRole(role)
// Auto-expand category based on current path
if (role) {
const category = navigation.find(cat =>
cat.modules.some(m => pathname.startsWith(m.href.split('/')[1] ? `/${m.href.split('/')[1]}` : m.href))
)
if (category) {
setExpandedCategories(new Set([category.id]))
}
}
}, [pathname])
const toggleCategory = (categoryId: CategoryId) => {
setExpandedCategories(prev => {
const newSet = new Set(prev)
if (newSet.has(categoryId)) {
newSet.delete(categoryId)
} else {
newSet.add(categoryId)
}
return newSet
})
}
const isModuleActive = (href: string) => {
if (href === '/dashboard') {
return pathname === '/dashboard'
}
return pathname.startsWith(href)
}
const visibleCategories = currentRole
? navigation.filter(cat => isCategoryVisibleForRole(cat.id, currentRole))
: navigation
return (
<aside
className={`${
collapsed ? 'w-16' : 'w-64'
} bg-slate-900 text-white flex flex-col transition-all duration-300 fixed h-full z-20`}
>
{/* Logo/Header */}
<div className="h-16 flex items-center justify-between px-4 border-b border-slate-700">
{!collapsed && (
<Link href="/dashboard" className="font-bold text-lg">
Admin v2
</Link>
)}
<button
onClick={() => setCollapsed(!collapsed)}
className="p-2 rounded-lg hover:bg-slate-800 transition-colors"
title={collapsed ? 'Sidebar erweitern' : 'Sidebar einklappen'}
>
<svg
className={`w-5 h-5 transition-transform ${collapsed ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
</svg>
</button>
</div>
{/* Navigation */}
<nav className="flex-1 py-4 overflow-y-auto">
{/* Meta Modules */}
<div className="px-2 mb-4">
{metaModules.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isModuleActive(module.href)
? 'bg-primary-600 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
title={collapsed ? module.name : undefined}
>
<span className="flex-shrink-0">{metaIcons[module.id]}</span>
{!collapsed && <span className="truncate">{module.name}</span>}
</Link>
))}
</div>
{/* Divider */}
<div className="mx-4 border-t border-slate-700 my-2" />
{/* Categories */}
<div className="px-2 space-y-1">
{visibleCategories.map((category) => {
const categoryHref = category.id === 'compliance-sdk' ? '/sdk' : `/${category.id}`
const isCategoryActive = category.id === 'compliance-sdk'
? category.modules.some(m => pathname.startsWith(m.href))
: pathname.startsWith(categoryHref)
return (
<div key={category.id}>
<div
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors ${
isCategoryActive
? 'bg-slate-800 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
>
<Link
href={categoryHref}
className="flex items-center gap-3 flex-1 min-w-0"
title={collapsed ? category.name : undefined}
>
<span
className="flex-shrink-0"
style={{ color: category.color }}
>
{categoryIcons[category.icon]}
</span>
{!collapsed && (
<span className="flex-1 text-left truncate">{category.name}</span>
)}
</Link>
{!collapsed && (
<button
onClick={(e) => {
e.preventDefault()
toggleCategory(category.id)
}}
className="p-1 rounded hover:bg-slate-700 transition-colors"
title={expandedCategories.has(category.id) ? 'Einklappen' : 'Aufklappen'}
>
<svg
className={`w-4 h-4 transition-transform ${
expandedCategories.has(category.id) ? 'rotate-180' : ''
}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
)}
</div>
{/* Category Modules */}
{!collapsed && expandedCategories.has(category.id) && (
<div className="ml-4 mt-1 space-y-1">
{(() => {
// Group modules by subgroup
const subgroups = new Map<string | undefined, typeof category.modules>()
category.modules.forEach((module) => {
const key = module.subgroup
if (!subgroups.has(key)) {
subgroups.set(key, [])
}
subgroups.get(key)!.push(module)
})
return Array.from(subgroups.entries()).map(([subgroupName, modules]) => (
<div key={subgroupName || 'default'}>
{subgroupName && (
<div className="px-3 py-1.5 text-xs font-medium text-slate-500 uppercase tracking-wider mt-2 first:mt-0">
{subgroupName}
</div>
)}
{modules.map((module) => (
<Link
key={module.id}
href={module.href}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm transition-colors ${
isModuleActive(module.href)
? 'bg-primary-600 text-white'
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
}`}
>
<span
className="w-1.5 h-1.5 rounded-full"
style={{ backgroundColor: category.color }}
/>
<span className="truncate">{module.name}</span>
</Link>
))}
</div>
))
})()}
</div>
)}
</div>
)
})}
</div>
</nav>
{/* Footer with Role Indicator */}
<div className="p-4 border-t border-slate-700">
<RoleIndicator collapsed={collapsed} onRoleChange={onRoleChange} />
<a
href="https://macmini:3000/admin"
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors mt-2 ${
collapsed ? 'justify-center' : ''
}`}
title="Altes Admin (Port 3000)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
{!collapsed && <span>Altes Admin</span>}
</a>
</div>
</aside>
)
}
@@ -0,0 +1,458 @@
'use client'
/**
* BlockReviewPanel Component
*
* Provides a block-by-block review interface for OCR comparison results.
* Shows what each OCR method (A, B, C, D) detected for each grid block.
* Allows approving or correcting each block.
*/
import { useState, useCallback, useMemo } from 'react'
import { cn } from '@/lib/utils'
import type { GridData, GridCell } from './GridOverlay'
import { getCellBlockNumber } from './GridOverlay'
export type BlockStatus = 'pending' | 'approved' | 'corrected' | 'skipped'
export interface MethodResult {
methodId: string
methodName: string
text: string
confidence?: number
}
export interface BlockReviewData {
blockNumber: number
cell: GridCell
methodResults: MethodResult[]
status: BlockStatus
correctedText?: string
approvedMethodId?: string
}
interface BlockReviewPanelProps {
grid: GridData
methodResults: Record<string, { vocabulary: Array<{ english: string; german: string; example?: string }> }>
currentBlockNumber: number
onBlockChange: (blockNumber: number) => void
onApprove: (blockNumber: number, methodId: string, text: string) => void
onCorrect: (blockNumber: number, correctedText: string) => void
onSkip: (blockNumber: number) => void
reviewData: Record<number, BlockReviewData>
className?: string
}
// Method colors for consistent display
const METHOD_COLORS: Record<string, { bg: string; border: string; text: string }> = {
vision_llm: { bg: 'bg-blue-50', border: 'border-blue-300', text: 'text-blue-700' },
tesseract: { bg: 'bg-green-50', border: 'border-green-300', text: 'text-green-700' },
paddleocr: { bg: 'bg-purple-50', border: 'border-purple-300', text: 'text-purple-700' },
claude_vision: { bg: 'bg-amber-50', border: 'border-amber-300', text: 'text-amber-700' },
}
const METHOD_LABELS: Record<string, string> = {
vision_llm: 'B: Vision LLM',
tesseract: 'D: Tesseract',
paddleocr: 'C: PaddleOCR',
claude_vision: 'E: Claude Vision',
}
export function BlockReviewPanel({
grid,
methodResults,
currentBlockNumber,
onBlockChange,
onApprove,
onCorrect,
onSkip,
reviewData,
className,
}: BlockReviewPanelProps) {
const [correctionText, setCorrectionText] = useState('')
const [showCorrection, setShowCorrection] = useState(false)
// Get all non-empty blocks
const nonEmptyBlocks = useMemo(() => {
const blocks: { blockNumber: number; cell: GridCell }[] = []
grid.cells.flat().forEach((cell) => {
if (cell.status !== 'empty') {
blocks.push({
blockNumber: getCellBlockNumber(cell, grid),
cell,
})
}
})
return blocks.sort((a, b) => a.blockNumber - b.blockNumber)
}, [grid])
// Current block data
const currentBlock = useMemo(() => {
return nonEmptyBlocks.find((b) => b.blockNumber === currentBlockNumber)
}, [nonEmptyBlocks, currentBlockNumber])
// Current block index for navigation
const currentIndex = useMemo(() => {
return nonEmptyBlocks.findIndex((b) => b.blockNumber === currentBlockNumber)
}, [nonEmptyBlocks, currentBlockNumber])
// Find matching vocabulary for current cell from each method
const blockMethodResults = useMemo(() => {
if (!currentBlock) return []
const results: MethodResult[] = []
const cellRow = currentBlock.cell.row
const cellCol = currentBlock.cell.col
// For each method, try to find vocabulary that matches this cell position
Object.entries(methodResults).forEach(([methodId, data]) => {
if (!data.vocabulary || data.vocabulary.length === 0) return
// Get the text from the grid cell itself (from grid detection)
const cellText = currentBlock.cell.text
// Try to match vocabulary entries
// This is a simplified matching - in production you'd match by position
const vocabIndex = cellRow * grid.columns + cellCol
let matchedText = ''
if (data.vocabulary[vocabIndex]) {
const v = data.vocabulary[vocabIndex]
matchedText = currentBlock.cell.column_type === 'english'
? v.english
: currentBlock.cell.column_type === 'german'
? v.german
: v.example || ''
}
results.push({
methodId,
methodName: METHOD_LABELS[methodId] || methodId,
text: matchedText || cellText || '(nicht erkannt)',
confidence: currentBlock.cell.confidence,
})
})
// Always include the grid detection result
if (currentBlock.cell.text) {
results.unshift({
methodId: 'grid_detection',
methodName: 'Grid-OCR',
text: currentBlock.cell.text,
confidence: currentBlock.cell.confidence,
})
}
return results
}, [currentBlock, methodResults, grid.columns])
// Navigation handlers
const goToPrevious = useCallback(() => {
if (currentIndex > 0) {
onBlockChange(nonEmptyBlocks[currentIndex - 1].blockNumber)
setShowCorrection(false)
setCorrectionText('')
}
}, [currentIndex, nonEmptyBlocks, onBlockChange])
const goToNext = useCallback(() => {
if (currentIndex < nonEmptyBlocks.length - 1) {
onBlockChange(nonEmptyBlocks[currentIndex + 1].blockNumber)
setShowCorrection(false)
setCorrectionText('')
}
}, [currentIndex, nonEmptyBlocks, onBlockChange])
// Action handlers
const handleApprove = useCallback((methodId: string, text: string) => {
onApprove(currentBlockNumber, methodId, text)
goToNext()
}, [currentBlockNumber, onApprove, goToNext])
const handleCorrect = useCallback(() => {
if (correctionText.trim()) {
onCorrect(currentBlockNumber, correctionText.trim())
goToNext()
}
}, [currentBlockNumber, correctionText, onCorrect, goToNext])
const handleSkip = useCallback(() => {
onSkip(currentBlockNumber)
goToNext()
}, [currentBlockNumber, onSkip, goToNext])
// Calculate progress
const reviewedCount = Object.values(reviewData).filter(
(r) => r.status !== 'pending'
).length
const progress = nonEmptyBlocks.length > 0
? Math.round((reviewedCount / nonEmptyBlocks.length) * 100)
: 0
const currentReview = reviewData[currentBlockNumber]
if (!currentBlock) {
return (
<div className={cn('p-4 text-center text-slate-500', className)}>
Keine Blöcke zum Überprüfen
</div>
)
}
return (
<div className={cn('flex flex-col h-full', className)}>
{/* Header with progress */}
<div className="flex items-center justify-between px-4 py-3 border-b border-slate-200 bg-slate-50">
<div className="flex items-center gap-3">
<span className="font-semibold text-slate-700">
Block {currentBlockNumber}
</span>
<span className="text-sm text-slate-500">
({currentIndex + 1} von {nonEmptyBlocks.length})
</span>
</div>
<div className="flex items-center gap-2">
<div className="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 transition-all duration-300"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-slate-500">{progress}%</span>
</div>
</div>
{/* Block status indicator */}
{currentReview && currentReview.status !== 'pending' && (
<div className={cn(
'px-4 py-2 text-sm font-medium',
currentReview.status === 'approved' && 'bg-green-100 text-green-700',
currentReview.status === 'corrected' && 'bg-blue-100 text-blue-700',
currentReview.status === 'skipped' && 'bg-slate-100 text-slate-600',
)}>
{currentReview.status === 'approved' && `✓ Freigegeben: "${currentReview.correctedText}"`}
{currentReview.status === 'corrected' && `✎ Korrigiert: "${currentReview.correctedText}"`}
{currentReview.status === 'skipped' && '○ Übersprungen'}
</div>
)}
{/* Cell info */}
<div className="px-4 py-2 bg-slate-50 border-b text-sm">
<span className="text-slate-500">Position: </span>
<span className="font-medium">Zeile {currentBlock.cell.row + 1}, Spalte {currentBlock.cell.col + 1}</span>
{currentBlock.cell.column_type && (
<>
<span className="text-slate-500 ml-3">Typ: </span>
<span className="font-medium capitalize">{currentBlock.cell.column_type}</span>
</>
)}
</div>
{/* Method results */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
<h4 className="text-sm font-medium text-slate-600 mb-2">Erkannte Texte:</h4>
{blockMethodResults.map((result) => {
const colors = METHOD_COLORS[result.methodId] || {
bg: 'bg-slate-50',
border: 'border-slate-300',
text: 'text-slate-700'
}
return (
<div
key={result.methodId}
className={cn(
'p-3 rounded-lg border-2 transition-all',
colors.bg,
colors.border,
'hover:shadow-md cursor-pointer'
)}
onClick={() => handleApprove(result.methodId, result.text)}
>
<div className="flex items-center justify-between mb-1">
<span className={cn('text-xs font-semibold', colors.text)}>
{result.methodName}
</span>
{result.confidence !== undefined && result.confidence < 0.7 && (
<span className="text-xs text-orange-500">
{Math.round(result.confidence * 100)}%
</span>
)}
</div>
<div className="text-sm font-medium text-slate-900">
{result.text || <span className="text-slate-400 italic">(leer)</span>}
</div>
<div className="mt-2 text-xs text-slate-500">
Klicken zum Übernehmen
</div>
</div>
)
})}
{/* Manual correction */}
{showCorrection ? (
<div className="p-3 rounded-lg border-2 border-indigo-300 bg-indigo-50">
<label className="text-xs font-semibold text-indigo-700 block mb-2">
Manuelle Korrektur:
</label>
<input
type="text"
value={correctionText}
onChange={(e) => setCorrectionText(e.target.value)}
placeholder="Korrekten Text eingeben..."
className="w-full px-3 py-2 border border-indigo-200 rounded-lg text-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') handleCorrect()
if (e.key === 'Escape') setShowCorrection(false)
}}
/>
<div className="flex gap-2 mt-2">
<button
onClick={handleCorrect}
disabled={!correctionText.trim()}
className="flex-1 px-3 py-1.5 bg-indigo-600 text-white text-sm font-medium rounded-lg hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Übernehmen
</button>
<button
onClick={() => setShowCorrection(false)}
className="px-3 py-1.5 bg-slate-200 text-slate-700 text-sm font-medium rounded-lg hover:bg-slate-300"
>
Abbrechen
</button>
</div>
</div>
) : (
<button
onClick={() => setShowCorrection(true)}
className="w-full p-3 rounded-lg border-2 border-dashed border-slate-300 text-slate-500 text-sm hover:border-indigo-400 hover:text-indigo-600 transition-colors"
>
+ Manuell korrigieren
</button>
)}
</div>
{/* Navigation footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-200 bg-slate-50">
<button
onClick={goToPrevious}
disabled={currentIndex === 0}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
Zurück
</button>
<button
onClick={handleSkip}
className="px-4 py-2 text-sm font-medium text-slate-500 hover:text-slate-700 hover:bg-slate-100 rounded-lg transition-colors"
>
Überspringen
</button>
<button
onClick={goToNext}
disabled={currentIndex === nonEmptyBlocks.length - 1}
className="flex items-center gap-1 px-3 py-2 text-sm font-medium text-slate-600 hover:text-slate-900 disabled:opacity-40 disabled:cursor-not-allowed"
>
Weiter
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
</div>
)
}
/**
* BlockReviewSummary Component
*
* Shows a summary of all reviewed blocks.
*/
interface BlockReviewSummaryProps {
reviewData: Record<number, BlockReviewData>
totalBlocks: number
onBlockClick: (blockNumber: number) => void
className?: string
}
export function BlockReviewSummary({
reviewData,
totalBlocks,
onBlockClick,
className,
}: BlockReviewSummaryProps) {
const stats = useMemo(() => {
const values = Object.values(reviewData)
return {
approved: values.filter((r) => r.status === 'approved').length,
corrected: values.filter((r) => r.status === 'corrected').length,
skipped: values.filter((r) => r.status === 'skipped').length,
pending: totalBlocks - values.length,
}
}, [reviewData, totalBlocks])
return (
<div className={cn('p-4 space-y-4', className)}>
<h3 className="font-semibold text-slate-800">Überprüfungsübersicht</h3>
{/* Stats */}
<div className="grid grid-cols-2 gap-2">
<div className="p-2 bg-green-50 rounded-lg text-center">
<div className="text-xl font-bold text-green-700">{stats.approved}</div>
<div className="text-xs text-green-600">Freigegeben</div>
</div>
<div className="p-2 bg-blue-50 rounded-lg text-center">
<div className="text-xl font-bold text-blue-700">{stats.corrected}</div>
<div className="text-xs text-blue-600">Korrigiert</div>
</div>
<div className="p-2 bg-slate-100 rounded-lg text-center">
<div className="text-xl font-bold text-slate-600">{stats.skipped}</div>
<div className="text-xs text-slate-500">Übersprungen</div>
</div>
<div className="p-2 bg-amber-50 rounded-lg text-center">
<div className="text-xl font-bold text-amber-600">{stats.pending}</div>
<div className="text-xs text-amber-500">Ausstehend</div>
</div>
</div>
{/* Block list */}
<div className="space-y-1 max-h-64 overflow-y-auto">
{Object.entries(reviewData)
.sort(([a], [b]) => Number(a) - Number(b))
.map(([blockNum, data]) => (
<button
key={blockNum}
onClick={() => onBlockClick(Number(blockNum))}
className={cn(
'w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm transition-colors',
data.status === 'approved' && 'bg-green-50 hover:bg-green-100',
data.status === 'corrected' && 'bg-blue-50 hover:bg-blue-100',
data.status === 'skipped' && 'bg-slate-50 hover:bg-slate-100',
)}
>
<span className="font-medium text-slate-700">Block {blockNum}</span>
<span className={cn(
'text-xs',
data.status === 'approved' && 'text-green-600',
data.status === 'corrected' && 'text-blue-600',
data.status === 'skipped' && 'text-slate-500',
)}>
{data.status === 'approved' && '✓'}
{data.status === 'corrected' && '✎'}
{data.status === 'skipped' && '○'}
{' '}
{data.correctedText?.substring(0, 20)}
{data.correctedText && data.correctedText.length > 20 && '...'}
</span>
</button>
))}
</div>
</div>
)
}
@@ -0,0 +1,294 @@
'use client'
/**
* CellCorrectionDialog Component
*
* Modal dialog for manually correcting OCR text in problematic or recognized cells.
* Shows cropped image of the cell for reference and allows text input.
*/
import { useState, useEffect } from 'react'
import type { GridCell } from './GridOverlay'
interface CellCorrectionDialogProps {
cell: GridCell
columnType: 'english' | 'german' | 'example' | 'unknown'
sessionId: string
pageNumber: number
onSave: (text: string) => void
onRetryOCR?: () => void
onClose: () => void
}
export function CellCorrectionDialog({
cell,
columnType,
sessionId,
pageNumber,
onSave,
onRetryOCR,
onClose,
}: CellCorrectionDialogProps) {
const [text, setText] = useState(cell.text || '')
const [loading, setLoading] = useState(false)
const [retrying, setRetrying] = useState(false)
const [cropUrl, setCropUrl] = useState<string | null>(null)
const KLAUSUR_API = '/klausur-api'
// Load cell crop image
useEffect(() => {
const loadCrop = async () => {
try {
const res = await fetch(
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell-crop/${pageNumber}/${cell.row}/${cell.col}`
)
if (res.ok) {
const blob = await res.blob()
setCropUrl(URL.createObjectURL(blob))
}
} catch (e) {
console.error('Failed to load cell crop:', e)
}
}
loadCrop()
return () => {
if (cropUrl) {
URL.revokeObjectURL(cropUrl)
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sessionId, pageNumber, cell.row, cell.col])
const handleSave = async () => {
if (!text.trim()) return
setLoading(true)
try {
const formData = new FormData()
formData.append('text', text)
const res = await fetch(
`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/cell/${cell.row}/${cell.col}`,
{
method: 'PUT',
body: formData,
}
)
if (res.ok) {
onSave(text)
onClose()
} else {
console.error('Failed to save cell:', await res.text())
}
} catch (e) {
console.error('Save failed:', e)
} finally {
setLoading(false)
}
}
const handleRetryOCR = async () => {
if (!onRetryOCR) return
setRetrying(true)
try {
await onRetryOCR()
} finally {
setRetrying(false)
}
}
const getColumnLabel = () => {
switch (columnType) {
case 'english':
return 'Englisch'
case 'german':
return 'Deutsch'
case 'example':
return 'Beispielsatz'
default:
return 'Text'
}
}
const getPlaceholder = () => {
switch (columnType) {
case 'english':
return 'Englisches Wort eingeben...'
case 'german':
return 'Deutsche Ubersetzung eingeben...'
case 'example':
return 'Beispielsatz eingeben...'
default:
return 'Text eingeben...'
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
{/* Dialog */}
<div className="relative w-full max-w-lg bg-white rounded-xl shadow-xl overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b border-slate-200 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-slate-900">
{cell.status === 'problematic' ? 'Nicht erkannter Bereich' : 'Text bearbeiten'}
</h3>
<p className="text-sm text-slate-500">
Zeile {cell.row + 1}, Spalte {cell.col + 1} ({getColumnLabel()})
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-slate-100 transition-colors"
>
<svg className="w-5 h-5 text-slate-500" 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>
{/* Content */}
<div className="p-6 space-y-4">
{/* Cell image preview */}
<div className="border rounded-lg p-3 bg-slate-50">
<p className="text-xs text-slate-500 mb-2 font-medium">Originalbild:</p>
{cropUrl ? (
<img
src={cropUrl}
alt="Zellinhalt"
className="max-h-32 mx-auto rounded border border-slate-200 bg-white"
/>
) : (
<div className="h-20 flex items-center justify-center text-slate-400 text-sm">
Lade Vorschau...
</div>
)}
</div>
{/* Status indicator */}
{cell.status === 'problematic' && (
<div className="flex items-center gap-2 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<svg className="w-5 h-5 text-orange-500 flex-shrink-0" 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>
<span className="text-sm text-orange-700">
Diese Zelle konnte nicht automatisch erkannt werden.
</span>
</div>
)}
{/* Current recognized text */}
{cell.status === 'recognized' && cell.text && (
<div className="p-3 bg-green-50 border border-green-200 rounded-lg">
<p className="text-xs text-green-600 font-medium mb-1">Erkannter Text:</p>
<p className="text-sm text-green-800">{cell.text}</p>
{cell.confidence < 1 && (
<p className="text-xs text-green-600 mt-1">
Konfidenz: {Math.round(cell.confidence * 100)}%
</p>
)}
</div>
)}
{/* Text input */}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">
{cell.status === 'problematic' ? 'Text eingeben' : 'Text korrigieren'}
</label>
{columnType === 'example' ? (
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={getPlaceholder()}
rows={3}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
/>
) : (
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder={getPlaceholder()}
className="w-full px-3 py-2 border border-slate-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 text-slate-900"
autoFocus
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleSave()
}
}}
/>
)}
</div>
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-slate-200 flex items-center justify-between bg-slate-50">
<div>
{onRetryOCR && cell.status === 'problematic' && (
<button
onClick={handleRetryOCR}
disabled={retrying}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 disabled:opacity-50 flex items-center gap-2"
>
{retrying ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Erneut versuchen...
</>
) : (
<>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
OCR erneut versuchen
</>
)}
</button>
)}
</div>
<div className="flex gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50"
>
Abbrechen
</button>
<button
onClick={handleSave}
disabled={loading || !text.trim()}
className="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
>
{loading ? (
<>
<svg className="animate-spin w-4 h-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Speichern...
</>
) : (
'Speichern'
)}
</button>
</div>
</div>
</div>
</div>
)
}
export default CellCorrectionDialog
+549
View File
@@ -0,0 +1,549 @@
'use client'
/**
* GridOverlay Component
*
* SVG overlay for displaying detected OCR grid structure on document images.
* Features:
* - Cell status visualization (recognized/problematic/manual/empty)
* - 1mm grid overlay for A4 pages (210x297mm)
* - Text at original bounding-box positions
* - Editable text (contentEditable) at original positions
* - Click-to-edit for cells
*/
import { useCallback, useState } from 'react'
import { cn } from '@/lib/utils'
export type CellStatus = 'empty' | 'recognized' | 'problematic' | 'manual'
export interface GridCell {
row: number
col: number
x: number // X position as percentage (0-100)
y: number // Y position as percentage (0-100)
width: number // Width as percentage (0-100)
height: number // Height as percentage (0-100)
text: string
confidence: number
status: CellStatus
column_type?: 'english' | 'german' | 'example' | 'unknown'
x_mm?: number
y_mm?: number
width_mm?: number
height_mm?: number
}
export interface GridData {
rows: number
columns: number
cells: GridCell[][]
column_types: string[]
column_boundaries: number[]
row_boundaries: number[]
deskew_angle: number
stats: {
recognized: number
problematic: number
empty: number
manual?: number
total: number
coverage: number
}
page_dimensions?: {
width_mm: number
height_mm: number
format: string
}
source?: string
}
interface GridOverlayProps {
grid: GridData
imageUrl?: string
onCellClick?: (cell: GridCell) => void
onCellTextChange?: (cell: GridCell, newText: string) => void
selectedCell?: GridCell | null
showEmpty?: boolean
showLabels?: boolean
showNumbers?: boolean
showTextLabels?: boolean
showMmGrid?: boolean
showTextAtPosition?: boolean
editableText?: boolean
highlightedBlockNumber?: number | null
className?: string
}
// Status colors
const STATUS_COLORS = {
recognized: {
fill: 'rgba(34, 197, 94, 0.2)',
stroke: '#22c55e',
hoverFill: 'rgba(34, 197, 94, 0.3)',
},
problematic: {
fill: 'rgba(249, 115, 22, 0.3)',
stroke: '#f97316',
hoverFill: 'rgba(249, 115, 22, 0.4)',
},
manual: {
fill: 'rgba(59, 130, 246, 0.2)',
stroke: '#3b82f6',
hoverFill: 'rgba(59, 130, 246, 0.3)',
},
empty: {
fill: 'transparent',
stroke: 'rgba(148, 163, 184, 0.3)',
hoverFill: 'rgba(148, 163, 184, 0.1)',
},
}
// A4 dimensions for mm grid
const A4_WIDTH_MM = 210
const A4_HEIGHT_MM = 297
// Helper to calculate block number (1-indexed, row-by-row)
export function getCellBlockNumber(cell: GridCell, grid: GridData): number {
return cell.row * grid.columns + cell.col + 1
}
/**
* 1mm Grid SVG Lines for A4 format.
* Renders inside a viewBox="0 0 100 100" (percentage-based).
*/
function MmGridLines() {
const lines: React.ReactNode[] = []
// Vertical lines: 210 lines for 210mm
for (let mm = 0; mm <= A4_WIDTH_MM; mm++) {
const x = (mm / A4_WIDTH_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`v-${mm}`}
x1={x}
y1={0}
x2={x}
y2={100}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
// Horizontal lines: 297 lines for 297mm
for (let mm = 0; mm <= A4_HEIGHT_MM; mm++) {
const y = (mm / A4_HEIGHT_MM) * 100
const isCm = mm % 10 === 0
lines.push(
<line
key={`h-${mm}`}
x1={0}
y1={y}
x2={100}
y2={y}
stroke={isCm ? 'rgba(59, 130, 246, 0.25)' : 'rgba(59, 130, 246, 0.1)'}
strokeWidth={isCm ? 0.08 : 0.03}
/>
)
}
return <g style={{ pointerEvents: 'none' }}>{lines}</g>
}
/**
* Positioned text overlay using absolute-positioned HTML divs.
* Each cell's text appears at its bounding-box position with matching font size.
*/
function PositionedTextLayer({
cells,
editable,
onTextChange,
}: {
cells: GridCell[]
editable: boolean
onTextChange?: (cell: GridCell, text: string) => void
}) {
const [hoveredCell, setHoveredCell] = useState<string | null>(null)
return (
<div className="absolute inset-0" style={{ pointerEvents: editable ? 'auto' : 'none' }}>
{cells.map((cell) => {
if (cell.status === 'empty' || !cell.text) return null
const cellKey = `pos-${cell.row}-${cell.col}`
const isHovered = hoveredCell === cellKey
// Estimate font size from cell height: height_pct maps to roughly pt size
// A4 at 100% = 297mm height. Cell height in % * 297mm / 100 = height_mm
// Font size ~= height_mm * 2.2 (roughly matching print)
const heightMm = cell.height_mm ?? (cell.height / 100 * A4_HEIGHT_MM)
const fontSizePt = Math.max(6, Math.min(18, heightMm * 2.2))
return (
<div
key={cellKey}
className={cn(
'absolute overflow-hidden transition-colors duration-100',
editable && 'cursor-text hover:bg-yellow-100/40',
isHovered && !editable && 'bg-blue-100/30',
)}
style={{
left: `${cell.x}%`,
top: `${cell.y}%`,
width: `${cell.width}%`,
height: `${cell.height}%`,
fontSize: `${fontSizePt}pt`,
fontFamily: '"Georgia", "Times New Roman", serif',
lineHeight: 1.1,
color: cell.status === 'manual' ? '#1e40af' : '#1a1a1a',
padding: '0 1px',
display: 'flex',
alignItems: 'center',
}}
onMouseEnter={() => setHoveredCell(cellKey)}
onMouseLeave={() => setHoveredCell(null)}
>
{editable ? (
<span
contentEditable
suppressContentEditableWarning
className="outline-none w-full"
style={{ minHeight: '1em' }}
onBlur={(e) => {
const newText = e.currentTarget.textContent ?? ''
if (newText !== cell.text && onTextChange) {
onTextChange(cell, newText)
}
}}
>
{cell.text}
</span>
) : (
<span className="truncate">{cell.text}</span>
)}
</div>
)
})}
</div>
)
}
export function GridOverlay({
grid,
imageUrl,
onCellClick,
onCellTextChange,
selectedCell,
showEmpty = false,
showLabels = true,
showNumbers = false,
showTextLabels = false,
showMmGrid = false,
showTextAtPosition = false,
editableText = false,
highlightedBlockNumber,
className,
}: GridOverlayProps) {
const handleCellClick = useCallback(
(cell: GridCell) => {
if (onCellClick && cell.status !== 'empty') {
onCellClick(cell)
}
},
[onCellClick]
)
const flatCells = grid.cells.flat()
return (
<div className={cn('relative', className)}>
{/* Background image */}
{imageUrl && (
<img
src={imageUrl}
alt="Document"
className="w-full h-auto"
/>
)}
{/* SVG overlay */}
<svg
className="absolute inset-0 w-full h-full"
style={{ pointerEvents: 'none' }}
viewBox="0 0 100 100"
preserveAspectRatio="none"
>
{/* 1mm Grid */}
{showMmGrid && <MmGridLines />}
{/* Column type labels */}
{showLabels && grid.column_types.length > 0 && (
<g>
{grid.column_types.map((type, idx) => {
const x = grid.column_boundaries[idx]
const width = grid.column_boundaries[idx + 1] - x
const label = type === 'english' ? 'EN' : type === 'german' ? 'DE' : type === 'example' ? 'Ex' : '?'
return (
<text
key={`col-label-${idx}`}
x={x + width / 2}
y={1.5}
textAnchor="middle"
fontSize="1.5"
fill="#64748b"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{label}
</text>
)
})}
</g>
)}
{/* Grid cells (skip if showing text at position to avoid double rendering) */}
{!showTextAtPosition && flatCells.map((cell) => {
const colors = STATUS_COLORS[cell.status]
const isSelected = selectedCell?.row === cell.row && selectedCell?.col === cell.col
const isClickable = cell.status !== 'empty' && onCellClick
const blockNumber = getCellBlockNumber(cell, grid)
const isHighlighted = highlightedBlockNumber === blockNumber
if (!showEmpty && cell.status === 'empty') {
return null
}
return (
<g
key={`cell-${cell.row}-${cell.col}`}
style={{ pointerEvents: isClickable ? 'auto' : 'none' }}
onClick={() => handleCellClick(cell)}
className={isClickable ? 'cursor-pointer' : ''}
>
<rect
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill={isHighlighted ? 'rgba(99, 102, 241, 0.3)' : colors.fill}
stroke={isSelected || isHighlighted ? '#4f46e5' : colors.stroke}
strokeWidth={isSelected || isHighlighted ? 0.3 : 0.15}
rx={0.2}
className={cn(
'transition-all duration-150',
isClickable && 'hover:fill-opacity-40'
)}
/>
{showNumbers && cell.status !== 'empty' && (
<>
<rect
x={cell.x + 0.3}
y={cell.y + 0.3}
width={2.5}
height={1.8}
fill={isHighlighted ? '#4f46e5' : '#374151'}
rx={0.3}
/>
<text
x={cell.x + 1.55}
y={cell.y + 1.5}
textAnchor="middle"
fontSize="1.2"
fill="white"
fontWeight="bold"
style={{ pointerEvents: 'none' }}
>
{blockNumber}
</text>
</>
)}
{!showNumbers && !showTextLabels && cell.status !== 'empty' && (
<circle
cx={cell.x + 0.8}
cy={cell.y + 0.8}
r={0.5}
fill={colors.stroke}
stroke="white"
strokeWidth={0.1}
/>
)}
{showTextLabels && (cell.status === 'recognized' || cell.status === 'manual') && cell.text && (
<text
x={cell.x + cell.width / 2}
y={cell.y + cell.height / 2 + Math.min(cell.height * 0.2, 0.5)}
textAnchor="middle"
fontSize={Math.min(cell.height * 0.5, 1.4)}
fill={cell.status === 'manual' ? '#1e40af' : '#166534'}
fontWeight="500"
style={{ pointerEvents: 'none' }}
>
{cell.text.length > 15 ? cell.text.slice(0, 15) + '\u2026' : cell.text}
</text>
)}
{cell.status === 'recognized' && cell.confidence < 0.7 && (
<text
x={cell.x + cell.width - 0.5}
y={cell.y + 1.2}
fontSize="0.8"
fill="#f97316"
style={{ pointerEvents: 'none' }}
>
!
</text>
)}
{isSelected && (
<rect
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill="none"
stroke="#4f46e5"
strokeWidth={0.4}
strokeDasharray="0.5,0.3"
rx={0.2}
/>
)}
</g>
)
})}
{/* Show cell outlines when in positioned text mode */}
{showTextAtPosition && flatCells.map((cell) => {
if (cell.status === 'empty') return null
return (
<rect
key={`outline-${cell.row}-${cell.col}`}
x={cell.x}
y={cell.y}
width={cell.width}
height={cell.height}
fill="none"
stroke="rgba(99, 102, 241, 0.2)"
strokeWidth={0.08}
rx={0.1}
style={{ pointerEvents: 'none' }}
/>
)
})}
{/* Row boundaries */}
{grid.row_boundaries.map((y, idx) => (
<line
key={`row-line-${idx}`}
x1={0}
y1={y}
x2={100}
y2={y}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth={0.05}
style={{ pointerEvents: 'none' }}
/>
))}
{/* Column boundaries */}
{grid.column_boundaries.map((x, idx) => (
<line
key={`col-line-${idx}`}
x1={x}
y1={0}
x2={x}
y2={100}
stroke="rgba(148, 163, 184, 0.2)"
strokeWidth={0.05}
style={{ pointerEvents: 'none' }}
/>
))}
</svg>
{/* Positioned text HTML overlay (outside SVG for proper text rendering) */}
{showTextAtPosition && (
<PositionedTextLayer
cells={flatCells.filter(c => c.status !== 'empty' && c.text)}
editable={editableText}
onTextChange={onCellTextChange}
/>
)}
</div>
)
}
/**
* GridStats Component
*/
interface GridStatsProps {
stats: GridData['stats']
deskewAngle?: number
source?: string
className?: string
}
export function GridStats({ stats, deskewAngle, source, className }: GridStatsProps) {
const coveragePercent = Math.round(stats.coverage * 100)
return (
<div className={cn('flex flex-wrap gap-3', className)}>
<div className="px-3 py-1.5 bg-green-50 text-green-700 rounded-lg text-sm font-medium">
Erkannt: {stats.recognized}
</div>
{(stats.manual ?? 0) > 0 && (
<div className="px-3 py-1.5 bg-blue-50 text-blue-700 rounded-lg text-sm font-medium">
Manuell: {stats.manual}
</div>
)}
{stats.problematic > 0 && (
<div className="px-3 py-1.5 bg-orange-50 text-orange-700 rounded-lg text-sm font-medium">
Problematisch: {stats.problematic}
</div>
)}
<div className="px-3 py-1.5 bg-slate-50 text-slate-600 rounded-lg text-sm font-medium">
Leer: {stats.empty}
</div>
<div className="px-3 py-1.5 bg-indigo-50 text-indigo-700 rounded-lg text-sm font-medium">
Abdeckung: {coveragePercent}%
</div>
{deskewAngle !== undefined && deskewAngle !== 0 && (
<div className="px-3 py-1.5 bg-purple-50 text-purple-700 rounded-lg text-sm font-medium">
Begradigt: {deskewAngle.toFixed(1)}
</div>
)}
{source && (
<div className="px-3 py-1.5 bg-cyan-50 text-cyan-700 rounded-lg text-sm font-medium">
Quelle: {source === 'tesseract+grid_service' ? 'Tesseract' : 'Vision LLM'}
</div>
)}
</div>
)
}
/**
* Legend Component for GridOverlay
*/
export function GridLegend({ className }: { className?: string }) {
return (
<div className={cn('flex flex-wrap gap-4 text-sm', className)}>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-green-500 bg-green-500/20" />
<span className="text-slate-600">Erkannt</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-orange-500 bg-orange-500/30" />
<span className="text-slate-600">Problematisch</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-blue-500 bg-blue-500/20" />
<span className="text-slate-600">Manuell korrigiert</span>
</div>
<div className="flex items-center gap-2">
<div className="w-4 h-4 rounded border-2 border-slate-300 bg-transparent" />
<span className="text-slate-600">Leer</span>
</div>
</div>
)
}
@@ -0,0 +1,670 @@
'use client'
/**
* GroundTruthPanel — Step-through UI for labeling OCR ground truth.
*
* Shows page image with SVG overlay (color-coded bounding boxes),
* alongside crops of the current entry and editable text fields.
* Keyboard-driven: Enter=confirm, Tab=skip, Arrow keys=navigate.
*/
import { useState, useEffect, useCallback, useRef } from 'react'
// ---------- Types ----------
interface BBox {
x: number
y: number
w: number
h: number
}
interface GTEntry {
row_index: number
english: string
german: string
example: string
confidence: number
bbox: BBox
bbox_en: BBox
bbox_de: BBox
bbox_ex: BBox
status?: 'pending' | 'confirmed' | 'edited' | 'skipped'
}
interface GroundTruthPanelProps {
sessionId: string
selectedPage: number
pageImageUrl: string
}
// ---------- Helpers ----------
const STATUS_COLORS: Record<string, { fill: string; stroke: string }> = {
current: { fill: 'rgba(250,204,21,0.25)', stroke: '#eab308' }, // yellow
confirmed: { fill: 'rgba(34,197,94,0.18)', stroke: '#16a34a' }, // green
edited: { fill: 'rgba(59,130,246,0.18)', stroke: '#2563eb' }, // blue
skipped: { fill: 'rgba(148,163,184,0.15)', stroke: '#94a3b8' }, // gray
pending: { fill: 'rgba(0,0,0,0)', stroke: '#cbd5e1' }, // outline only
}
function getEntryColor(entry: GTEntry, index: number, currentIndex: number) {
if (index === currentIndex) return STATUS_COLORS.current
return STATUS_COLORS[entry.status || 'pending']
}
// ---------- ImageCrop ----------
function ImageCrop({ imageUrl, bbox, naturalWidth, naturalHeight, maxWidth = 380, label }: {
imageUrl: string
bbox: BBox
naturalWidth: number
naturalHeight: number
maxWidth?: number
label?: string
}) {
if (!bbox || bbox.w === 0 || bbox.h === 0) return null
const cropWPx = (bbox.w / 100) * naturalWidth
const cropHPx = (bbox.h / 100) * naturalHeight
if (cropWPx < 1 || cropHPx < 1) return null
const scale = maxWidth / cropWPx
const displayH = cropHPx * scale
return (
<div>
{label && <div className="text-xs font-medium text-slate-500 mb-1">{label}</div>}
<div
className="rounded-lg border border-slate-200 overflow-hidden bg-white"
style={{ width: maxWidth, height: Math.min(displayH, 120), overflow: 'hidden', position: 'relative' }}
>
<img
src={imageUrl}
alt=""
draggable={false}
style={{
position: 'absolute',
width: naturalWidth * scale,
height: naturalHeight * scale,
left: -(bbox.x / 100) * naturalWidth * scale,
top: -(bbox.y / 100) * naturalHeight * scale,
maxWidth: 'none',
}}
/>
</div>
</div>
)
}
// ---------- Main Component ----------
export function GroundTruthPanel({ sessionId, selectedPage, pageImageUrl }: GroundTruthPanelProps) {
const KLAUSUR_API = '/klausur-api'
// State
const [entries, setEntries] = useState<GTEntry[]>([])
const [currentIndex, setCurrentIndex] = useState(0)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [imageNatural, setImageNatural] = useState({ w: 0, h: 0 })
const [showSummary, setShowSummary] = useState(false)
const [savedMessage, setSavedMessage] = useState<string | null>(null)
const [isFullscreen, setIsFullscreen] = useState(false)
const [imageUrl, setImageUrl] = useState(pageImageUrl)
const [deskewAngle, setDeskewAngle] = useState<number | null>(null)
// Editable fields for current entry
const [editEn, setEditEn] = useState('')
const [editDe, setEditDe] = useState('')
const [editEx, setEditEx] = useState('')
const panelRef = useRef<HTMLDivElement>(null)
const enInputRef = useRef<HTMLInputElement>(null)
// Reset image URL when page changes
useEffect(() => {
setImageUrl(pageImageUrl)
setDeskewAngle(null)
}, [pageImageUrl])
// Load natural image dimensions
useEffect(() => {
if (!imageUrl) return
const img = new Image()
img.onload = () => setImageNatural({ w: img.naturalWidth, h: img.naturalHeight })
img.src = imageUrl
}, [imageUrl])
// Sync edit fields when current entry changes
useEffect(() => {
const entry = entries[currentIndex]
if (entry) {
setEditEn(entry.english)
setEditDe(entry.german)
setEditEx(entry.example)
}
}, [currentIndex, entries])
// ---------- Actions ----------
const handleExtract = useCallback(async () => {
setLoading(true)
setError(null)
setShowSummary(false)
setSavedMessage(null)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/extract-with-boxes/${selectedPage}`, {
method: 'POST',
})
if (!res.ok) {
const err = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(err.detail || 'Extract failed')
}
const data = await res.json()
const loaded: GTEntry[] = (data.entries || []).map((e: GTEntry) => ({ ...e, status: 'pending' as const }))
setEntries(loaded)
setCurrentIndex(0)
// Switch to deskewed image if available
if (data.deskewed) {
setImageUrl(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/deskewed-image/${selectedPage}`)
setDeskewAngle(data.deskew_angle)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Extraction failed')
} finally {
setLoading(false)
}
}, [sessionId, selectedPage])
const confirmEntry = useCallback(() => {
if (entries.length === 0) return
const entry = entries[currentIndex]
const isEdited = editEn !== entry.english || editDe !== entry.german || editEx !== entry.example
const updated = [...entries]
updated[currentIndex] = {
...entry,
english: editEn,
german: editDe,
example: editEx,
status: isEdited ? 'edited' : 'confirmed',
}
setEntries(updated)
if (currentIndex < entries.length - 1) {
setCurrentIndex(currentIndex + 1)
} else {
setShowSummary(true)
}
}, [entries, currentIndex, editEn, editDe, editEx])
const skipEntry = useCallback(() => {
if (entries.length === 0) return
const updated = [...entries]
updated[currentIndex] = { ...updated[currentIndex], status: 'skipped' }
setEntries(updated)
if (currentIndex < entries.length - 1) {
setCurrentIndex(currentIndex + 1)
} else {
setShowSummary(true)
}
}, [entries, currentIndex])
const goTo = useCallback((idx: number) => {
if (idx >= 0 && idx < entries.length) {
setCurrentIndex(idx)
setShowSummary(false)
}
}, [entries.length])
const handleSave = useCallback(async () => {
setSaving(true)
setError(null)
try {
const res = await fetch(`${KLAUSUR_API}/api/v1/vocab/sessions/${sessionId}/ground-truth/${selectedPage}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ entries }),
})
if (!res.ok) throw new Error('Save failed')
const data = await res.json()
setSavedMessage(`Gespeichert: ${data.confirmed} bestaetigt, ${data.edited} editiert, ${data.skipped} uebersprungen`)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}, [sessionId, selectedPage, entries])
// ---------- Keyboard shortcuts ----------
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isFullscreen) {
e.preventDefault()
setIsFullscreen(false)
return
}
if (entries.length === 0 || showSummary) return
// Don't capture when typing in inputs
const tag = (e.target as HTMLElement)?.tagName
const isInput = tag === 'INPUT' || tag === 'TEXTAREA'
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
confirmEntry()
} else if (e.key === 'Tab' && !e.shiftKey) {
if (!isInput) {
e.preventDefault()
skipEntry()
}
} else if (e.key === 'ArrowLeft' && !isInput) {
e.preventDefault()
goTo(currentIndex - 1)
} else if (e.key === 'ArrowRight' && !isInput) {
e.preventDefault()
goTo(currentIndex + 1)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [entries.length, showSummary, isFullscreen, confirmEntry, skipEntry, goTo, currentIndex])
// ---------- Computed ----------
const currentEntry = entries[currentIndex]
const confirmedCount = entries.filter(e => e.status === 'confirmed').length
const editedCount = entries.filter(e => e.status === 'edited').length
const skippedCount = entries.filter(e => e.status === 'skipped').length
const processedCount = confirmedCount + editedCount + skippedCount
const progress = entries.length > 0 ? Math.round((processedCount / entries.length) * 100) : 0
// ---------- Render: No entries yet ----------
if (entries.length === 0) {
return (
<div className="bg-white rounded-xl border border-slate-200 p-8 text-center" ref={panelRef}>
<h3 className="text-lg font-semibold text-slate-900 mb-2">Ground Truth Labeling</h3>
<p className="text-sm text-slate-500 mb-6">
Erkennung starten um Vokabeln mit Positionen zu extrahieren.
Danach jede Zeile durchgehen und bestaetigen oder korrigieren.
</p>
<button
onClick={handleExtract}
disabled={loading}
className="px-6 py-3 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50 transition-colors"
>
{loading ? (
<span className="flex items-center gap-2">
<svg className="animate-spin w-5 h-5" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Erkennung laeuft...
</span>
) : 'Erkennung starten'}
</button>
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
)}
</div>
)
}
// ---------- Render: Summary ----------
if (showSummary) {
return (
<div className={`bg-white rounded-xl border border-slate-200 p-6 ${
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none' : ''
}`} ref={panelRef}>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-slate-900">Zusammenfassung</h3>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)}
</button>
</div>
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-700">{confirmedCount}</div>
<div className="text-sm text-green-600">Bestaetigt</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-blue-700">{editedCount}</div>
<div className="text-sm text-blue-600">Editiert</div>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-slate-700">{skippedCount}</div>
<div className="text-sm text-slate-500">Uebersprungen</div>
</div>
</div>
<div className="flex gap-3">
<button
onClick={handleSave}
disabled={saving}
className="flex-1 px-4 py-2.5 bg-teal-600 text-white rounded-lg font-medium hover:bg-teal-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Ground Truth speichern'}
</button>
<button
onClick={() => { setShowSummary(false); setCurrentIndex(0) }}
className="px-4 py-2.5 bg-slate-100 text-slate-700 rounded-lg font-medium hover:bg-slate-200"
>
Nochmal durchgehen
</button>
</div>
{savedMessage && (
<div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg text-green-700 text-sm">
{savedMessage}
</div>
)}
{error && (
<div className="mt-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
)}
{/* Entry list for quick review */}
<div className="mt-6 max-h-96 overflow-y-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white">
<tr className="border-b border-slate-200">
<th className="text-left py-2 px-2 text-slate-500">#</th>
<th className="text-left py-2 px-2 text-slate-500">English</th>
<th className="text-left py-2 px-2 text-slate-500">Deutsch</th>
<th className="text-left py-2 px-2 text-slate-500">Status</th>
</tr>
</thead>
<tbody>
{entries.map((e, i) => (
<tr
key={i}
onClick={() => goTo(i)}
className="border-b border-slate-100 hover:bg-slate-50 cursor-pointer"
>
<td className="py-1.5 px-2 text-slate-400">{i + 1}</td>
<td className="py-1.5 px-2">{e.english}</td>
<td className="py-1.5 px-2">{e.german}</td>
<td className="py-1.5 px-2">
<span className={`inline-block px-2 py-0.5 rounded-full text-xs font-medium ${
e.status === 'confirmed' ? 'bg-green-100 text-green-700' :
e.status === 'edited' ? 'bg-blue-100 text-blue-700' :
e.status === 'skipped' ? 'bg-slate-100 text-slate-500' :
'bg-yellow-100 text-yellow-700'
}`}>
{e.status === 'confirmed' ? 'OK' :
e.status === 'edited' ? 'Editiert' :
e.status === 'skipped' ? 'Skip' : 'Offen'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ---------- Render: Main Review UI ----------
return (
<div className={`bg-white rounded-xl border border-slate-200 overflow-hidden ${
isFullscreen ? 'fixed inset-0 z-50 overflow-auto m-0 rounded-none bg-white' : ''
}`} ref={panelRef}>
{/* Header with progress + fullscreen toggle */}
<div className="flex items-center gap-2 px-4 pt-2">
<div className="flex-1 h-1.5 bg-slate-100 rounded-full">
<div
className="h-full bg-teal-500 transition-all duration-300 rounded-full"
style={{ width: `${progress}%` }}
/>
</div>
<span className="text-xs text-slate-400 whitespace-nowrap">{currentIndex + 1}/{entries.length}</span>
{deskewAngle !== null && (
<span className="text-xs text-teal-600 whitespace-nowrap" title="Bild wurde begradigt">
{deskewAngle.toFixed(1)}&deg;
</span>
)}
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-1.5 rounded-lg hover:bg-slate-100 text-slate-500 hover:text-slate-700 transition-colors"
title={isFullscreen ? 'Vollbild verlassen (Esc)' : 'Vollbild'}
>
{isFullscreen ? (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9 3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5 5.25 5.25" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 3.75v4.5m0-4.5h4.5m-4.5 0L9 9M3.75 20.25v-4.5m0 4.5h4.5m-4.5 0L9 15M20.25 3.75h-4.5m4.5 0v4.5m0-4.5L15 9m5.25 11.25h-4.5m4.5 0v-4.5m0 4.5L15 15" />
</svg>
)}
</button>
</div>
<div className={`flex flex-col ${isFullscreen ? 'lg:flex-row h-[calc(100vh-3rem)]' : 'lg:flex-row'}`}>
{/* Left: Page image with SVG overlay (2/3) */}
<div className={`${isFullscreen ? 'lg:w-2/3 p-4 overflow-y-auto h-full' : 'lg:w-2/3 p-4'}`}>
<div className="relative bg-slate-50 rounded-lg overflow-hidden">
{imageUrl && (
<img
src={imageUrl}
alt={`Seite ${selectedPage + 1}`}
className="w-full"
draggable={false}
/>
)}
{/* SVG Overlay */}
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 w-full h-full"
style={{ pointerEvents: 'none' }}
>
{entries.map((entry, i) => {
const colors = getEntryColor(entry, i, currentIndex)
return (
<rect
key={i}
x={entry.bbox.x}
y={entry.bbox.y}
width={entry.bbox.w}
height={entry.bbox.h}
fill={colors.fill}
stroke={colors.stroke}
strokeWidth={i === currentIndex ? 0.3 : 0.15}
style={{ cursor: 'pointer', pointerEvents: 'all' }}
onClick={() => goTo(i)}
/>
)
})}
</svg>
</div>
{/* Legend */}
<div className="flex items-center gap-4 mt-3 text-xs text-slate-500">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.current.fill, border: `1px solid ${STATUS_COLORS.current.stroke}` }} /> Aktuell
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.confirmed.fill, border: `1px solid ${STATUS_COLORS.confirmed.stroke}` }} /> Bestaetigt
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.edited.fill, border: `1px solid ${STATUS_COLORS.edited.stroke}` }} /> Editiert
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-sm" style={{ background: STATUS_COLORS.skipped.fill, border: `1px solid ${STATUS_COLORS.skipped.stroke}` }} /> Uebersprungen
</span>
</div>
</div>
{/* Right: Crops + Edit fields (1/3) */}
<div className={`lg:w-1/3 border-l border-slate-200 p-4 space-y-4 ${isFullscreen ? 'overflow-y-auto h-full' : ''}`}>
{currentEntry && (
<>
{/* Row crop */}
{imageNatural.w > 0 && (
<ImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
label="Gesamte Zeile"
/>
)}
{/* Column crops */}
{imageNatural.w > 0 && (
<div className="grid grid-cols-3 gap-2">
{currentEntry.bbox_en.w > 0 && (
<ImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox_en}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
maxWidth={120}
label="EN"
/>
)}
{currentEntry.bbox_de.w > 0 && (
<ImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox_de}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
maxWidth={120}
label="DE"
/>
)}
{currentEntry.bbox_ex.w > 0 && (
<ImageCrop
imageUrl={imageUrl}
bbox={currentEntry.bbox_ex}
naturalWidth={imageNatural.w}
naturalHeight={imageNatural.h}
maxWidth={120}
label="EX"
/>
)}
</div>
)}
{/* Confidence badge */}
<div className="flex items-center gap-2">
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${
currentEntry.confidence >= 70 ? 'bg-green-100 text-green-700' :
currentEntry.confidence >= 40 ? 'bg-yellow-100 text-yellow-700' :
'bg-red-100 text-red-700'
}`}>
Konfidenz: {currentEntry.confidence}%
</span>
</div>
{/* Edit fields */}
<div className="space-y-3">
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">English</label>
<input
ref={enInputRef}
type="text"
value={editEn}
onChange={e => setEditEn(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Deutsch</label>
<input
type="text"
value={editDe}
onChange={e => setEditDe(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
<div>
<label className="block text-xs font-medium text-slate-500 mb-1">Beispiel</label>
<input
type="text"
value={editEx}
onChange={e => setEditEx(e.target.value)}
className="w-full px-3 py-2 border border-slate-300 rounded-lg text-sm focus:ring-2 focus:ring-teal-500 focus:border-teal-500"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex gap-2">
<button
onClick={confirmEntry}
className="flex-1 px-4 py-2.5 bg-green-600 text-white rounded-lg font-medium hover:bg-green-700 text-sm"
title="Enter"
>
OK (Enter)
</button>
<button
onClick={skipEntry}
className="flex-1 px-4 py-2.5 bg-slate-200 text-slate-700 rounded-lg font-medium hover:bg-slate-300 text-sm"
title="Tab"
>
Skip (Tab)
</button>
</div>
{/* Navigation */}
<div className="flex items-center justify-between">
<button
onClick={() => goTo(currentIndex - 1)}
disabled={currentIndex === 0}
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
>
&larr; Zurueck
</button>
<span className="text-sm text-slate-500 font-medium">
{currentIndex + 1} / {entries.length}
</span>
<button
onClick={() => goTo(currentIndex + 1)}
disabled={currentIndex === entries.length - 1}
className="px-3 py-1.5 bg-slate-100 rounded-lg text-sm text-slate-600 hover:bg-slate-200 disabled:opacity-30"
>
Weiter &rarr;
</button>
</div>
{/* Progress stats */}
<div className="text-xs text-slate-400 text-center">
{confirmedCount} bestaetigt &middot; {editedCount} editiert &middot; {skippedCount} uebersprungen &middot; {progress}%
</div>
{/* Keyboard hints */}
<div className="text-xs text-slate-400 text-center border-t border-slate-100 pt-2">
Enter = Bestaetigen &middot; Tab = Ueberspringen &middot; &larr;&rarr; = Navigieren{isFullscreen ? ' \u00B7 Esc = Vollbild verlassen' : ''}
</div>
</>
)}
</div>
</div>
{error && (
<div className="mx-4 mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">{error}</div>
)}
</div>
)
}
@@ -0,0 +1,390 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { BlockReviewPanel, BlockReviewSummary, type BlockReviewData } from '../BlockReviewPanel'
import type { GridData, GridCell } from '../GridOverlay'
// Mock grid data
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
const cells: GridCell[][] = []
for (let r = 0; r < rows; r++) {
const row: GridCell[] = []
for (let c = 0; c < cols; c++) {
row.push({
row: r,
col: c,
x: (c / cols) * 100,
y: (r / rows) * 100,
width: 100 / cols,
height: 100 / rows,
text: r === 0 || c === 0 ? '' : `cell-${r}-${c}`,
confidence: 0.85,
status: r === 0 || c === 0 ? 'empty' : 'recognized',
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
})
}
cells.push(row)
}
return {
rows,
columns: cols,
cells,
column_types: ['unknown', 'english', 'german'],
column_boundaries: [0, 33.33, 66.66, 100],
row_boundaries: [0, 33.33, 66.66, 100],
deskew_angle: 0,
stats: {
recognized: 4,
problematic: 0,
empty: 5,
total: 9,
coverage: 0.44,
},
}
}
const createMockMethodResults = () => ({
vision_llm: {
vocabulary: [
{ english: 'word1', german: 'Wort1' },
{ english: 'word2', german: 'Wort2' },
],
},
tesseract: {
vocabulary: [
{ english: 'word1', german: 'Wort1' },
{ english: 'word2', german: 'Wort2' },
],
},
})
describe('BlockReviewPanel', () => {
const mockOnBlockChange = vi.fn()
const mockOnApprove = vi.fn()
const mockOnCorrect = vi.fn()
const mockOnSkip = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should render the current block number', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Block 5')).toBeInTheDocument()
})
it('should display progress percentage', () => {
const reviewData: Record<number, BlockReviewData> = {
5: {
blockNumber: 5,
cell: createMockGrid().cells[1][1],
methodResults: [],
status: 'approved',
correctedText: 'approved text',
},
}
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={reviewData}
/>
)
expect(screen.getByText('25%')).toBeInTheDocument()
})
it('should show cell position information', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Position:')).toBeInTheDocument()
})
it('should display method results', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Erkannte Texte:')).toBeInTheDocument()
})
it('should call onSkip when skip button is clicked', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
fireEvent.click(screen.getByText('Überspringen'))
expect(mockOnSkip).toHaveBeenCalledWith(5)
})
it('should show manual correction button', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('+ Manuell korrigieren')).toBeInTheDocument()
})
it('should show correction input when correction button is clicked', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
expect(screen.getByPlaceholderText('Korrekten Text eingeben...')).toBeInTheDocument()
})
it('should call onCorrect when correction is submitted', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
fireEvent.click(screen.getByText('+ Manuell korrigieren'))
const input = screen.getByPlaceholderText('Korrekten Text eingeben...')
fireEvent.change(input, { target: { value: 'corrected text' } })
fireEvent.click(screen.getByText('Übernehmen'))
expect(mockOnCorrect).toHaveBeenCalledWith(5, 'corrected text')
})
it('should show approved status when block is approved', () => {
const reviewData: Record<number, BlockReviewData> = {
5: {
blockNumber: 5,
cell: createMockGrid().cells[1][1],
methodResults: [],
status: 'approved',
correctedText: 'approved text',
},
}
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={reviewData}
/>
)
expect(screen.getByText(/Freigegeben:/)).toBeInTheDocument()
})
it('should disable previous button on first block', () => {
render(
<BlockReviewPanel
grid={createMockGrid()}
methodResults={createMockMethodResults()}
currentBlockNumber={5}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
const prevButton = screen.getByText('Zurück').closest('button')
expect(prevButton).toBeDisabled()
})
it('should show empty message when no blocks available', () => {
const emptyGrid: GridData = {
...createMockGrid(),
cells: [[{
row: 0,
col: 0,
x: 0,
y: 0,
width: 100,
height: 100,
text: '',
confidence: 0,
status: 'empty'
}]],
}
render(
<BlockReviewPanel
grid={emptyGrid}
methodResults={{}}
currentBlockNumber={1}
onBlockChange={mockOnBlockChange}
onApprove={mockOnApprove}
onCorrect={mockOnCorrect}
onSkip={mockOnSkip}
reviewData={{}}
/>
)
expect(screen.getByText('Keine Blöcke zum Überprüfen')).toBeInTheDocument()
})
})
describe('BlockReviewSummary', () => {
const mockOnBlockClick = vi.fn()
beforeEach(() => {
vi.clearAllMocks()
})
it('should display summary statistics', () => {
const reviewData: Record<number, BlockReviewData> = {
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text1' },
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 'text2' },
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'skipped' },
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={5}
onBlockClick={mockOnBlockClick}
/>
)
expect(screen.getByText('Überprüfungsübersicht')).toBeInTheDocument()
expect(screen.getByText('1')).toBeInTheDocument() // approved count
expect(screen.getByText('Freigegeben')).toBeInTheDocument()
expect(screen.getByText('Korrigiert')).toBeInTheDocument()
expect(screen.getByText('Übersprungen')).toBeInTheDocument()
expect(screen.getByText('Ausstehend')).toBeInTheDocument()
})
it('should call onBlockClick when a block is clicked', () => {
const reviewData: Record<number, BlockReviewData> = {
5: { blockNumber: 5, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 'text' },
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={10}
onBlockClick={mockOnBlockClick}
/>
)
fireEvent.click(screen.getByText('Block 5'))
expect(mockOnBlockClick).toHaveBeenCalledWith(5)
})
it('should show correct counts for each status', () => {
const reviewData: Record<number, BlockReviewData> = {
1: { blockNumber: 1, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't1' },
2: { blockNumber: 2, cell: {} as GridCell, methodResults: [], status: 'approved', correctedText: 't2' },
3: { blockNumber: 3, cell: {} as GridCell, methodResults: [], status: 'corrected', correctedText: 't3' },
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={5}
onBlockClick={mockOnBlockClick}
/>
)
// 2 approved, 1 corrected, 0 skipped, 2 pending
const approvedCount = screen.getAllByText('2')[0]
expect(approvedCount).toBeInTheDocument()
})
it('should truncate long corrected text', () => {
const reviewData: Record<number, BlockReviewData> = {
1: {
blockNumber: 1,
cell: {} as GridCell,
methodResults: [],
status: 'approved',
correctedText: 'This is a very long text that should be truncated'
},
}
render(
<BlockReviewSummary
reviewData={reviewData}
totalBlocks={1}
onBlockClick={mockOnBlockClick}
/>
)
expect(screen.getByText(/This is a very long t\.\.\./)).toBeInTheDocument()
})
})
@@ -0,0 +1,267 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from '../GridOverlay'
import type { GridData, GridCell } from '../GridOverlay'
// Helper to create mock grid data
const createMockGrid = (rows: number = 3, cols: number = 3): GridData => {
const cells: GridCell[][] = []
for (let r = 0; r < rows; r++) {
const row: GridCell[] = []
for (let c = 0; c < cols; c++) {
row.push({
row: r,
col: c,
x: (c / cols) * 100,
y: (r / rows) * 100,
width: 100 / cols,
height: 100 / rows,
text: `cell-${r}-${c}`,
confidence: 0.85,
status: r === 0 ? 'empty' : c === 0 ? 'problematic' : 'recognized',
column_type: c === 1 ? 'english' : c === 2 ? 'german' : 'unknown',
})
}
cells.push(row)
}
return {
rows,
columns: cols,
cells,
column_types: ['unknown', 'english', 'german'],
column_boundaries: [0, 33.33, 66.66, 100],
row_boundaries: [0, 33.33, 66.66, 100],
deskew_angle: 0,
stats: {
recognized: 4,
problematic: 2,
empty: 3,
manual: 0,
total: 9,
coverage: 0.67,
},
}
}
describe('getCellBlockNumber', () => {
it('should return correct block number for first cell', () => {
const grid = createMockGrid(3, 4)
const cell: GridCell = { row: 0, col: 0, x: 0, y: 0, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(1)
})
it('should return correct block number for cell in second row', () => {
const grid = createMockGrid(3, 4)
const cell: GridCell = { row: 1, col: 0, x: 0, y: 33, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(5)
})
it('should return correct block number for last cell', () => {
const grid = createMockGrid(3, 4)
const cell: GridCell = { row: 2, col: 3, x: 75, y: 66, width: 25, height: 33, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(12)
})
it('should calculate correctly for different grid sizes', () => {
const grid = createMockGrid(5, 5)
const cell: GridCell = { row: 2, col: 3, x: 60, y: 40, width: 20, height: 20, text: '', confidence: 1, status: 'empty' }
expect(getCellBlockNumber(cell, grid)).toBe(14) // row 2 * 5 cols + col 3 + 1 = 14
})
})
describe('GridOverlay', () => {
it('should render without crashing', () => {
render(<GridOverlay grid={createMockGrid()} />)
expect(document.querySelector('svg')).toBeInTheDocument()
})
it('should render image when imageUrl is provided', () => {
render(<GridOverlay grid={createMockGrid()} imageUrl="https://example.com/image.jpg" />)
const img = screen.getByAltText('Document')
expect(img).toBeInTheDocument()
expect(img).toHaveAttribute('src', 'https://example.com/image.jpg')
})
it('should call onCellClick when a non-empty cell is clicked', () => {
const mockOnClick = vi.fn()
const grid = createMockGrid()
render(<GridOverlay grid={grid} onCellClick={mockOnClick} />)
// Find a recognized cell (non-empty) and click it
const recognizedCells = document.querySelectorAll('rect')
// Click on a cell that should be clickable (recognized status)
recognizedCells.forEach(rect => {
if (rect.getAttribute('fill')?.includes('34, 197, 94')) {
fireEvent.click(rect)
}
})
// onCellClick should have been called for recognized cells
expect(mockOnClick).toHaveBeenCalled()
})
it('should not call onCellClick for empty cells', () => {
const mockOnClick = vi.fn()
const emptyGrid: GridData = {
...createMockGrid(),
cells: [[{ row: 0, col: 0, x: 0, y: 0, width: 100, height: 100, text: '', confidence: 0, status: 'empty' }]],
}
render(<GridOverlay grid={emptyGrid} onCellClick={mockOnClick} showEmpty />)
const cell = document.querySelector('rect')
if (cell) {
fireEvent.click(cell)
}
expect(mockOnClick).not.toHaveBeenCalled()
})
it('should show column labels when showLabels is true', () => {
render(<GridOverlay grid={createMockGrid()} showLabels />)
// Check for column type labels
const svgTexts = document.querySelectorAll('text')
const labels = Array.from(svgTexts).map(t => t.textContent)
expect(labels).toContain('EN')
expect(labels).toContain('DE')
})
it('should hide empty cells when showEmpty is false', () => {
const grid = createMockGrid()
const emptyCellCount = grid.cells.flat().filter(c => c.status === 'empty').length
const { container } = render(<GridOverlay grid={grid} showEmpty={false} />)
const allRects = container.querySelectorAll('g > rect')
// Should have fewer rects when empty cells are hidden
const totalCells = grid.rows * grid.columns
expect(allRects.length).toBeLessThan(totalCells * 2) // each cell can have multiple rects
})
it('should show block numbers when showNumbers is true', () => {
render(<GridOverlay grid={createMockGrid()} showNumbers />)
const svgTexts = document.querySelectorAll('text')
const numbers = Array.from(svgTexts).map(t => t.textContent).filter(t => /^\d+$/.test(t || ''))
expect(numbers.length).toBeGreaterThan(0)
})
it('should highlight a specific block when highlightedBlockNumber is set', () => {
const grid = createMockGrid()
render(<GridOverlay grid={grid} showNumbers highlightedBlockNumber={5} />)
// Check that there's a highlighted element (with indigo color)
const rects = document.querySelectorAll('rect')
const highlightedRect = Array.from(rects).find(
rect => rect.getAttribute('stroke') === '#4f46e5'
)
expect(highlightedRect).toBeTruthy()
})
it('should apply custom className', () => {
const { container } = render(<GridOverlay grid={createMockGrid()} className="custom-class" />)
expect(container.firstChild).toHaveClass('custom-class')
})
it('should render row and column boundary lines', () => {
render(<GridOverlay grid={createMockGrid()} />)
const lines = document.querySelectorAll('line')
expect(lines.length).toBeGreaterThan(0)
})
})
describe('GridStats', () => {
const mockStats = {
recognized: 10,
problematic: 3,
empty: 5,
manual: 2,
total: 20,
coverage: 0.75,
}
it('should display recognized count', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Erkannt: 10')).toBeInTheDocument()
})
it('should display problematic count when greater than 0', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Problematisch: 3')).toBeInTheDocument()
})
it('should not display problematic when count is 0', () => {
render(<GridStats stats={{ ...mockStats, problematic: 0 }} />)
expect(screen.queryByText(/Problematisch:/)).not.toBeInTheDocument()
})
it('should display manual count when greater than 0', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Manuell: 2')).toBeInTheDocument()
})
it('should not display manual when count is 0', () => {
render(<GridStats stats={{ ...mockStats, manual: 0 }} />)
expect(screen.queryByText(/Manuell:/)).not.toBeInTheDocument()
})
it('should display empty count', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Leer: 5')).toBeInTheDocument()
})
it('should display coverage percentage', () => {
render(<GridStats stats={mockStats} />)
expect(screen.getByText('Abdeckung: 75%')).toBeInTheDocument()
})
it('should display deskew angle when provided and non-zero', () => {
render(<GridStats stats={mockStats} deskewAngle={2.5} />)
expect(screen.getByText('Begradigt: 2.5')).toBeInTheDocument()
})
it('should not display deskew angle when 0', () => {
render(<GridStats stats={mockStats} deskewAngle={0} />)
expect(screen.queryByText(/Begradigt:/)).not.toBeInTheDocument()
})
it('should apply custom className', () => {
const { container } = render(<GridStats stats={mockStats} className="custom-stats" />)
expect(container.firstChild).toHaveClass('custom-stats')
})
})
describe('GridLegend', () => {
it('should display all status labels', () => {
render(<GridLegend />)
expect(screen.getByText('Erkannt')).toBeInTheDocument()
expect(screen.getByText('Problematisch')).toBeInTheDocument()
expect(screen.getByText('Manuell korrigiert')).toBeInTheDocument()
expect(screen.getByText('Leer')).toBeInTheDocument()
})
it('should render color indicators for each status', () => {
const { container } = render(<GridLegend />)
const colorIndicators = container.querySelectorAll('.w-4.h-4.rounded')
expect(colorIndicators.length).toBe(4)
})
it('should apply custom className', () => {
const { container } = render(<GridLegend className="custom-legend" />)
expect(container.firstChild).toHaveClass('custom-legend')
})
})
+15
View File
@@ -0,0 +1,15 @@
/**
* OCR Components
*
* Components for OCR grid detection and visualization.
*/
export { GridOverlay, GridStats, GridLegend, getCellBlockNumber } from './GridOverlay'
export type { GridCell, GridData, CellStatus } from './GridOverlay'
export { CellCorrectionDialog } from './CellCorrectionDialog'
export { BlockReviewPanel, BlockReviewSummary } from './BlockReviewPanel'
export type { BlockStatus, MethodResult, BlockReviewData } from './BlockReviewPanel'
export { GroundTruthPanel } from './GroundTruthPanel'
@@ -0,0 +1,100 @@
'use client'
import type { ArchitectureContext as ArchitectureContextType } from './types'
interface ArchitectureContextProps {
context: ArchitectureContextType
currentStep?: string
highlightedComponents?: string[]
}
const LAYER_CONFIG = {
frontend: { label: 'Frontend', color: 'blue', icon: '🖥️' },
api: { label: 'API', color: 'purple', icon: '🔌' },
service: { label: 'Service', color: 'green', icon: '⚙️' },
database: { label: 'Datenbank', color: 'orange', icon: '🗄️' },
}
export function ArchitectureContext({ context, currentStep, highlightedComponents = [] }: ArchitectureContextProps) {
const layerConfig = LAYER_CONFIG[context.layer]
return (
<div className="bg-slate-900 rounded-lg p-6 mb-6">
<h4 className="text-white font-medium mb-4 flex items-center">
<span className="mr-2">🏗</span>
Architektur-Kontext{currentStep && `: ${currentStep}`}
</h4>
{/* Data Flow Visualization */}
<div className="mb-6">
<div className="flex items-center justify-center flex-wrap gap-2">
{context.dataFlow.map((component, index) => {
const isHighlighted = highlightedComponents.includes(component.toLowerCase())
const isCurrentLayer = component.toLowerCase().includes(context.layer)
return (
<div key={index} className="flex items-center">
<div className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
isHighlighted || isCurrentLayer
? 'bg-blue-500 text-white ring-2 ring-blue-300'
: 'bg-slate-700 text-slate-300'
}`}>
{component}
{isCurrentLayer && (
<span className="ml-2 text-xs"> Sie sind hier</span>
)}
</div>
{index < context.dataFlow.length - 1 && (
<span className="mx-2 text-slate-500"></span>
)}
</div>
)
})}
</div>
</div>
{/* Layer Info */}
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-slate-800 rounded-lg p-4">
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Aktuelle Schicht</h5>
<div className="flex items-center">
<span className="text-xl mr-2">{layerConfig.icon}</span>
<span className={`text-${layerConfig.color}-400 font-medium`}>
{layerConfig.label}
</span>
</div>
</div>
<div className="bg-slate-800 rounded-lg p-4">
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Beteiligte Services</h5>
<div className="flex flex-wrap gap-1">
{context.services.map((service) => (
<span
key={service}
className="px-2 py-1 bg-slate-700 text-slate-300 text-xs rounded"
>
{service}
</span>
))}
</div>
</div>
</div>
{/* Dependencies */}
{context.dependencies.length > 0 && (
<div className="pt-4 border-t border-slate-700">
<h5 className="text-slate-400 text-xs uppercase tracking-wide mb-2">Abhaengigkeiten</h5>
<div className="flex flex-wrap gap-2">
{context.dependencies.map((dep) => (
<span
key={dep}
className="px-2 py-1 bg-amber-900/50 text-amber-300 text-xs rounded border border-amber-700"
>
{dep}
</span>
))}
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,28 @@
'use client'
import type { EducationContent } from './types'
interface EducationCardProps {
content: EducationContent | undefined
}
export function EducationCard({ content }: EducationCardProps) {
if (!content) return null
return (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-6 mb-6">
<h3 className="text-lg font-semibold text-blue-800 mb-4 flex items-center">
<span className="mr-2">💡</span>
Warum ist das wichtig?
</h3>
<h4 className="text-md font-medium text-blue-700 mb-3">{content.title}</h4>
<div className="space-y-2 text-blue-900">
{content.content.map((line, index) => (
<p key={index} className={line.startsWith('•') ? 'ml-4' : ''}>
{line}
</p>
))}
</div>
</div>
)
}
@@ -0,0 +1,57 @@
'use client'
import type { TestResult } from './types'
interface TestResultCardProps {
result: TestResult
}
export function TestResultCard({ result }: TestResultCardProps) {
const statusColors = {
passed: 'bg-green-100 border-green-300 text-green-800',
failed: 'bg-red-100 border-red-300 text-red-800',
pending: 'bg-yellow-100 border-yellow-300 text-yellow-800',
skipped: 'bg-gray-100 border-gray-300 text-gray-600',
}
const statusIcons = {
passed: '✓',
failed: '✗',
pending: '○',
skipped: '',
}
return (
<div className={`border rounded-lg p-4 mb-3 ${statusColors[result.status]}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<h4 className="font-medium flex items-center">
<span className="mr-2">{statusIcons[result.status]}</span>
{result.name}
</h4>
<p className="text-sm opacity-80 mt-1">{result.description}</p>
</div>
<span className="text-xs opacity-60">{result.duration_ms.toFixed(0)}ms</span>
</div>
<div className="mt-3 grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-medium">Erwartet:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.expected}
</code>
</div>
<div>
<span className="font-medium">Erhalten:</span>
<code className="block mt-1 bg-white bg-opacity-50 px-2 py-1 rounded text-xs">
{result.actual}
</code>
</div>
</div>
{result.error_message && (
<div className="mt-2 text-xs text-red-700 bg-red-50 p-2 rounded">
Fehler: {result.error_message}
</div>
)}
</div>
)
}
@@ -0,0 +1,57 @@
'use client'
import { TestResultCard } from './TestResultCard'
import type { TestCategoryResult } from './types'
interface TestRunnerProps {
category: string
categoryResult?: TestCategoryResult
isLoading: boolean
onRunTests: () => void
runButtonLabel?: string
rerunButtonLabel?: string
}
export function TestRunner({
categoryResult,
isLoading,
onRunTests,
runButtonLabel = '▶️ Tests ausfuehren',
rerunButtonLabel = '🔄 Erneut ausfuehren',
}: TestRunnerProps) {
if (!categoryResult) {
return (
<div className="text-center py-6">
<button
onClick={onRunTests}
disabled={isLoading}
className={`px-6 py-3 rounded-lg font-medium transition-colors ${
isLoading
? 'bg-gray-400 cursor-not-allowed'
: 'bg-green-600 text-white hover:bg-green-700'
}`}
>
{isLoading ? '⏳ Tests laufen...' : runButtonLabel}
</button>
</div>
)
}
return (
<div>
<div className="flex items-center justify-between mb-4">
<h3 className="font-semibold text-gray-700">Testergebnisse</h3>
<button
onClick={onRunTests}
disabled={isLoading}
className="text-sm text-blue-600 hover:text-blue-800"
>
{rerunButtonLabel}
</button>
</div>
{categoryResult.tests.map((test, index) => (
<TestResultCard key={index} result={test} />
))}
</div>
)
}
@@ -0,0 +1,65 @@
'use client'
import type { FullTestResults } from './types'
interface TestSummaryProps {
results: FullTestResults
}
export function TestSummary({ results }: TestSummaryProps) {
const passRate = results.total_tests > 0
? ((results.total_passed / results.total_tests) * 100).toFixed(1)
: '0'
return (
<div className="bg-white rounded-lg shadow-lg p-6">
<h3 className="text-xl font-bold mb-4">Test-Ergebnisse</h3>
{/* Summary Stats */}
<div className="grid grid-cols-4 gap-4 mb-6">
<div className="bg-gray-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-gray-700">{results.total_tests}</div>
<div className="text-sm text-gray-500">Gesamt</div>
</div>
<div className="bg-green-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-green-600">{results.total_passed}</div>
<div className="text-sm text-green-600">Bestanden</div>
</div>
<div className="bg-red-50 rounded-lg p-4 text-center">
<div className="text-3xl font-bold text-red-600">{results.total_failed}</div>
<div className="text-sm text-red-600">Fehlgeschlagen</div>
</div>
<div className={`rounded-lg p-4 text-center ${
parseFloat(passRate) >= 80 ? 'bg-green-50' : parseFloat(passRate) >= 50 ? 'bg-yellow-50' : 'bg-red-50'
}`}>
<div className={`text-3xl font-bold ${
parseFloat(passRate) >= 80 ? 'text-green-600' : parseFloat(passRate) >= 50 ? 'text-yellow-600' : 'text-red-600'
}`}>{passRate}%</div>
<div className="text-sm text-gray-500">Erfolgsrate</div>
</div>
</div>
{/* Category Results */}
<div className="space-y-4">
{results.categories.map((category) => (
<div key={category.category} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<h4 className="font-medium">{category.display_name}</h4>
<span className={`text-sm px-2 py-1 rounded ${
category.failed === 0 ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{category.passed}/{category.total} bestanden
</span>
</div>
<p className="text-sm text-gray-600">{category.description}</p>
</div>
))}
</div>
{/* Duration */}
<div className="mt-6 text-sm text-gray-500 text-right">
Gesamtdauer: {(results.duration_ms / 1000).toFixed(2)}s
</div>
</div>
)
}
@@ -0,0 +1,31 @@
'use client'
interface WizardBannerProps {
module: string
title: string
description?: string
}
export function WizardBanner({ module, title, description }: WizardBannerProps) {
return (
<div className="bg-gradient-to-r from-blue-50 to-purple-50 border border-blue-200 rounded-lg p-4 mb-6">
<div className="flex items-center justify-between">
<div className="flex items-center">
<span className="text-2xl mr-3">🎓</span>
<div>
<h3 className="font-medium text-blue-800">Lern-Wizard: {title}</h3>
<p className="text-sm text-blue-600">
{description || 'Interaktives Onboarding mit Tests und Architektur-Erklaerungen'}
</p>
</div>
</div>
<a
href={`/admin/${module}/wizard`}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"
>
Wizard starten
</a>
</div>
</div>
)
}
@@ -0,0 +1,53 @@
'use client'
interface WizardNavigationProps {
currentStep: number
totalSteps: number
onPrev: () => void
onNext: () => void
showNext?: boolean
isLoading?: boolean
nextLabel?: string
prevLabel?: string
}
export function WizardNavigation({
currentStep,
totalSteps,
onPrev,
onNext,
showNext = true,
isLoading = false,
nextLabel = 'Weiter →',
prevLabel = '← Zurueck',
}: WizardNavigationProps) {
return (
<div className="flex justify-between mt-8 pt-6 border-t">
<button
onClick={onPrev}
disabled={currentStep === 0 || isLoading}
className={`px-6 py-2 rounded-lg transition-colors ${
currentStep === 0 || isLoading
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
>
{prevLabel}
</button>
{showNext && currentStep < totalSteps - 1 && (
<button
onClick={onNext}
disabled={isLoading}
className={`px-6 py-2 rounded-lg transition-colors ${
isLoading
? 'bg-blue-400 cursor-not-allowed'
: 'bg-blue-600 text-white hover:bg-blue-700'
}`}
>
{isLoading ? 'Bitte warten...' : nextLabel}
</button>
)}
</div>
)
}
@@ -0,0 +1,72 @@
'use client'
import { createContext, useContext, useState, ReactNode, useCallback } from 'react'
import type { WizardStep, WizardContextValue, TestCategoryResult, FullTestResults } from './types'
const WizardContext = createContext<WizardContextValue | null>(null)
export function useWizard(): WizardContextValue {
const context = useContext(WizardContext)
if (!context) {
throw new Error('useWizard must be used within a WizardProvider')
}
return context
}
interface WizardProviderProps {
children: ReactNode
initialSteps: WizardStep[]
module: string
}
export function WizardProvider({ children, initialSteps, module }: WizardProviderProps) {
const [currentStep, setCurrentStep] = useState(0)
const [steps, setSteps] = useState<WizardStep[]>(initialSteps)
const [categoryResults, setCategoryResults] = useState<Record<string, TestCategoryResult>>({})
const [fullResults, setFullResults] = useState<FullTestResults | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const goToNext = useCallback(() => {
if (currentStep < steps.length - 1) {
setSteps((prev) =>
prev.map((step, idx) =>
idx === currentStep && step.status === 'pending'
? { ...step, status: 'completed' }
: step
)
)
setCurrentStep((prev) => prev + 1)
}
}, [currentStep, steps.length])
const goToPrev = useCallback(() => {
if (currentStep > 0) {
setCurrentStep((prev) => prev - 1)
}
}, [currentStep])
const value: WizardContextValue = {
currentStep,
steps,
setCurrentStep,
setSteps,
goToNext,
goToPrev,
module,
categoryResults,
setCategoryResults,
fullResults,
setFullResults,
isLoading,
setIsLoading,
error,
setError,
}
return (
<WizardContext.Provider value={value}>
{children}
</WizardContext.Provider>
)
}
@@ -0,0 +1,43 @@
'use client'
import type { WizardStep } from './types'
interface WizardStepperProps {
steps: WizardStep[]
currentStep: number
onStepClick: (index: number) => void
}
export function WizardStepper({ steps, currentStep, onStepClick }: WizardStepperProps) {
return (
<div className="flex items-center justify-between mb-8 overflow-x-auto pb-4">
{steps.map((step, index) => (
<div key={step.id} className="flex items-center">
<button
onClick={() => onStepClick(index)}
className={`flex flex-col items-center min-w-[80px] p-2 rounded-lg transition-colors ${
index === currentStep
? 'bg-blue-100 text-blue-700'
: step.status === 'completed'
? 'bg-green-100 text-green-700 cursor-pointer hover:bg-green-200'
: step.status === 'failed'
? 'bg-red-100 text-red-700 cursor-pointer hover:bg-red-200'
: 'text-gray-400'
}`}
disabled={index > currentStep && steps[index - 1]?.status === 'pending'}
>
<span className="text-2xl mb-1">{step.icon}</span>
<span className="text-xs font-medium text-center">{step.name}</span>
{step.status === 'completed' && <span className="text-xs text-green-600"></span>}
{step.status === 'failed' && <span className="text-xs text-red-600"></span>}
</button>
{index < steps.length - 1 && (
<div className={`h-0.5 w-8 mx-1 ${
index < currentStep ? 'bg-green-400' : 'bg-gray-200'
}`} />
)}
</div>
))}
</div>
)
}
+34
View File
@@ -0,0 +1,34 @@
// Wizard Framework Components
// ============================
// Wiederverwendbare Komponenten fuer Admin-Modul-Wizards
// Context & Provider
export { WizardProvider, useWizard } from './WizardProvider'
// UI Components
export { WizardStepper } from './WizardStepper'
export { WizardNavigation } from './WizardNavigation'
export { WizardBanner } from './WizardBanner'
// Education Components
export { EducationCard } from './EducationCard'
export { ArchitectureContext } from './ArchitectureContext'
// Test Components
export { TestRunner } from './TestRunner'
export { TestResultCard } from './TestResultCard'
export { TestSummary } from './TestSummary'
// Types
export type {
WizardStep,
StepStatus,
TestResult,
TestCategoryResult,
FullTestResults,
EducationContent,
StepEducation,
ModuleEducation,
ArchitectureContext as ArchitectureContextType,
WizardContextValue,
} from './types'
+104
View File
@@ -0,0 +1,104 @@
// ==============================================
// Wizard Framework Types
// ==============================================
export type StepStatus = 'pending' | 'active' | 'completed' | 'failed'
export interface WizardStep {
id: string
name: string
icon: string
status: StepStatus
category?: string
}
export interface TestResult {
name: string
description: string
expected: string
actual: string
status: 'passed' | 'failed' | 'pending' | 'skipped'
duration_ms: number
error_message?: string
}
export interface ArchitectureContext {
layer: 'frontend' | 'api' | 'service' | 'database'
services: string[]
dependencies: string[]
dataFlow: string[]
}
export interface TestCategoryResult {
category: string
display_name: string
description: string
why_important: string
architecture_context?: ArchitectureContext
tests: TestResult[]
passed: number
failed: number
total: number
duration_ms: number
}
export interface FullTestResults {
timestamp: string
categories: TestCategoryResult[]
total_passed: number
total_failed: number
total_tests: number
duration_ms: number
}
export interface EducationContent {
title: string
content: string[]
}
export interface StepEducation {
stepId: string
title: string
whyImportant: string
whatIsTested: string[]
architectureHighlight?: {
layer: string
components: string[]
dataFlow: string
}
learnMoreLinks?: {
label: string
url: string
}[]
commonMistakes?: string[]
bestPractices?: string[]
}
export interface ModuleEducation {
module: string
overview: {
title: string
description: string
businessValue: string
complianceContext: string
}
steps: StepEducation[]
}
export interface WizardContextValue {
currentStep: number
steps: WizardStep[]
setCurrentStep: (step: number) => void
setSteps: React.Dispatch<React.SetStateAction<WizardStep[]>>
goToNext: () => void
goToPrev: () => void
module: string
categoryResults: Record<string, TestCategoryResult>
setCategoryResults: React.Dispatch<React.SetStateAction<Record<string, TestCategoryResult>>>
fullResults: FullTestResults | null
setFullResults: React.Dispatch<React.SetStateAction<FullTestResults | null>>
isLoading: boolean
setIsLoading: React.Dispatch<React.SetStateAction<boolean>>
error: string | null
setError: React.Dispatch<React.SetStateAction<string | null>>
}