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

View File

@@ -0,0 +1,310 @@
'use client'
import { useState, useRef, useCallback } from 'react'
import type { Annotation, AnnotationType, AnnotationPosition } from '@/app/korrektur/types'
import { ANNOTATION_COLORS } from '@/app/korrektur/types'
interface AnnotationLayerProps {
annotations: Annotation[]
selectedAnnotation: string | null
currentTool: AnnotationType | null
onAnnotationCreate: (position: AnnotationPosition, type: AnnotationType) => void
onAnnotationSelect: (id: string | null) => void
onAnnotationDelete: (id: string) => void
isEditable?: boolean
className?: string
}
export function AnnotationLayer({
annotations,
selectedAnnotation,
currentTool,
onAnnotationCreate,
onAnnotationSelect,
onAnnotationDelete,
isEditable = true,
className = '',
}: AnnotationLayerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [isDrawing, setIsDrawing] = useState(false)
const [drawStart, setDrawStart] = useState<{ x: number; y: number } | null>(null)
const [drawEnd, setDrawEnd] = useState<{ x: number; y: number } | null>(null)
const getRelativePosition = useCallback(
(e: React.MouseEvent | MouseEvent): { x: number; y: number } => {
if (!containerRef.current) return { x: 0, y: 0 }
const rect = containerRef.current.getBoundingClientRect()
return {
x: ((e.clientX - rect.left) / rect.width) * 100,
y: ((e.clientY - rect.top) / rect.height) * 100,
}
},
[]
)
const handleMouseDown = (e: React.MouseEvent) => {
if (!isEditable || !currentTool) return
e.preventDefault()
e.stopPropagation()
const pos = getRelativePosition(e)
setIsDrawing(true)
setDrawStart(pos)
setDrawEnd(pos)
onAnnotationSelect(null)
}
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDrawing || !drawStart) return
const pos = getRelativePosition(e)
setDrawEnd(pos)
},
[isDrawing, drawStart, getRelativePosition]
)
const handleMouseUp = useCallback(
(e: React.MouseEvent) => {
if (!isDrawing || !drawStart || !drawEnd || !currentTool) {
setIsDrawing(false)
return
}
// Calculate rectangle bounds
const minX = Math.min(drawStart.x, drawEnd.x)
const minY = Math.min(drawStart.y, drawEnd.y)
const maxX = Math.max(drawStart.x, drawEnd.x)
const maxY = Math.max(drawStart.y, drawEnd.y)
// Minimum size check (at least 2% width/height)
const width = maxX - minX
const height = maxY - minY
if (width >= 1 && height >= 1) {
const position: AnnotationPosition = {
x: minX,
y: minY,
width: width,
height: height,
}
onAnnotationCreate(position, currentTool)
}
setIsDrawing(false)
setDrawStart(null)
setDrawEnd(null)
},
[isDrawing, drawStart, drawEnd, currentTool, onAnnotationCreate]
)
const handleAnnotationClick = (e: React.MouseEvent, id: string) => {
e.stopPropagation()
onAnnotationSelect(selectedAnnotation === id ? null : id)
}
const handleBackgroundClick = () => {
onAnnotationSelect(null)
}
// Drawing preview rectangle
const drawingRect =
isDrawing && drawStart && drawEnd
? {
x: Math.min(drawStart.x, drawEnd.x),
y: Math.min(drawStart.y, drawEnd.y),
width: Math.abs(drawEnd.x - drawStart.x),
height: Math.abs(drawEnd.y - drawStart.y),
}
: null
return (
<div
ref={containerRef}
className={`absolute inset-0 ${className}`}
style={{
pointerEvents: isEditable && currentTool ? 'auto' : 'none',
cursor: currentTool ? 'crosshair' : 'default',
}}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onClick={handleBackgroundClick}
>
<svg
className="absolute inset-0 w-full h-full"
style={{ pointerEvents: 'none' }}
>
{/* Existing Annotations */}
{annotations.map((annotation) => {
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
const isSelected = selectedAnnotation === annotation.id
return (
<g
key={annotation.id}
style={{ pointerEvents: 'auto', cursor: 'pointer' }}
onClick={(e) => handleAnnotationClick(e, annotation.id)}
>
{/* Highlight Rectangle */}
<rect
x={`${annotation.position.x}%`}
y={`${annotation.position.y}%`}
width={`${annotation.position.width}%`}
height={`${annotation.position.height}%`}
fill={`${color}20`}
stroke={color}
strokeWidth={isSelected ? 3 : 2}
strokeDasharray={annotation.type === 'comment' ? '4 2' : 'none'}
rx="4"
ry="4"
className="transition-all"
/>
{/* Type Indicator */}
<circle
cx={`${annotation.position.x}%`}
cy={`${annotation.position.y}%`}
r="8"
fill={color}
className="drop-shadow-sm"
/>
{/* Severity Indicator */}
{annotation.severity === 'critical' && (
<circle
cx={`${annotation.position.x + annotation.position.width}%`}
cy={`${annotation.position.y}%`}
r="6"
fill="#ef4444"
className="animate-pulse"
/>
)}
</g>
)
})}
{/* Drawing Preview */}
{drawingRect && currentTool && (
<rect
x={`${drawingRect.x}%`}
y={`${drawingRect.y}%`}
width={`${drawingRect.width}%`}
height={`${drawingRect.height}%`}
fill={`${ANNOTATION_COLORS[currentTool]}30`}
stroke={ANNOTATION_COLORS[currentTool]}
strokeWidth={2}
strokeDasharray="4 2"
rx="4"
ry="4"
/>
)}
</svg>
{/* Selected Annotation Popup */}
{selectedAnnotation && (
<AnnotationPopup
annotation={annotations.find((a) => a.id === selectedAnnotation)!}
onDelete={() => onAnnotationDelete(selectedAnnotation)}
onClose={() => onAnnotationSelect(null)}
/>
)}
</div>
)
}
// =============================================================================
// ANNOTATION POPUP
// =============================================================================
interface AnnotationPopupProps {
annotation: Annotation
onDelete: () => void
onClose: () => void
}
function AnnotationPopup({ annotation, onDelete, onClose }: AnnotationPopupProps) {
const color = ANNOTATION_COLORS[annotation.type] || '#6b7280'
const typeLabels: Record<AnnotationType, string> = {
rechtschreibung: 'Rechtschreibung',
grammatik: 'Grammatik',
inhalt: 'Inhalt',
struktur: 'Struktur',
stil: 'Stil',
comment: 'Kommentar',
highlight: 'Markierung',
}
return (
<div
className="absolute z-10 w-64 rounded-xl bg-slate-800/95 backdrop-blur-sm border border-white/10 shadow-xl overflow-hidden"
style={{
left: `${Math.min(annotation.position.x + annotation.position.width, 70)}%`,
top: `${annotation.position.y}%`,
transform: 'translateY(-50%)',
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div
className="px-3 py-2 flex items-center justify-between"
style={{ backgroundColor: `${color}30` }}
>
<span className="text-white font-medium text-sm" style={{ color }}>
{typeLabels[annotation.type]}
</span>
<button
onClick={onClose}
className="p-1 rounded hover:bg-white/10 text-white/60"
>
<svg className="w-4 h-4" 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-3 space-y-2">
{annotation.text && (
<p className="text-white/80 text-sm">{annotation.text}</p>
)}
{annotation.suggestion && (
<div className="p-2 rounded-lg bg-green-500/10 border border-green-500/20">
<p className="text-green-400 text-xs font-medium mb-1">Vorschlag:</p>
<p className="text-white/70 text-sm">{annotation.suggestion}</p>
</div>
)}
{/* Severity Badge */}
<div className="flex items-center gap-2">
<span
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
annotation.severity === 'critical'
? 'bg-red-500/20 text-red-400'
: annotation.severity === 'major'
? 'bg-orange-500/20 text-orange-400'
: 'bg-gray-500/20 text-gray-400'
}`}
>
{annotation.severity === 'critical'
? 'Kritisch'
: annotation.severity === 'major'
? 'Wichtig'
: 'Hinweis'}
</span>
</div>
</div>
{/* Actions */}
<div className="px-3 py-2 border-t border-white/10 flex gap-2">
<button
onClick={onDelete}
className="flex-1 px-3 py-1.5 rounded-lg bg-red-500/20 text-red-400 text-sm hover:bg-red-500/30 transition-colors"
>
Loeschen
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
'use client'
import type { AnnotationType } from '@/app/korrektur/types'
import { ANNOTATION_COLORS } from '@/app/korrektur/types'
interface AnnotationToolbarProps {
selectedTool: AnnotationType | null
onToolSelect: (tool: AnnotationType | null) => void
className?: string
}
const tools: Array<{
type: AnnotationType
label: string
shortcut: string
icon: React.ReactNode
}> = [
{
type: 'rechtschreibung',
label: 'Rechtschreibung',
shortcut: 'R',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
</svg>
),
},
{
type: 'grammatik',
label: 'Grammatik',
shortcut: 'G',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5h12M9 3v2m1.048 9.5A18.022 18.022 0 016.412 9m6.088 9h7M11 21l5-10 5 10M12.751 5C11.783 10.77 8.07 15.61 3 18.129" />
</svg>
),
},
{
type: 'inhalt',
label: 'Inhalt',
shortcut: 'I',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
),
},
{
type: 'struktur',
label: 'Struktur',
shortcut: 'S',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
),
},
{
type: 'stil',
label: 'Stil',
shortcut: 'T',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
</svg>
),
},
{
type: 'comment',
label: 'Kommentar',
shortcut: 'K',
icon: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
</svg>
),
},
]
export function AnnotationToolbar({
selectedTool,
onToolSelect,
className = '',
}: AnnotationToolbarProps) {
return (
<div className={`flex items-center gap-1 p-2 bg-white/5 rounded-xl ${className}`}>
{tools.map((tool) => {
const isSelected = selectedTool === tool.type
const color = ANNOTATION_COLORS[tool.type]
return (
<button
key={tool.type}
onClick={() => onToolSelect(isSelected ? null : tool.type)}
className={`relative p-2 rounded-lg transition-all ${
isSelected
? 'bg-white/20 shadow-lg'
: 'hover:bg-white/10'
}`}
style={{
color: isSelected ? color : 'rgba(255, 255, 255, 0.6)',
}}
title={`${tool.label} (${tool.shortcut})`}
>
{tool.icon}
{/* Shortcut Badge */}
<span
className={`absolute -bottom-1 -right-1 w-4 h-4 rounded text-[10px] font-bold flex items-center justify-center ${
isSelected ? 'bg-white/20' : 'bg-white/10'
}`}
style={{ color: isSelected ? color : 'rgba(255, 255, 255, 0.4)' }}
>
{tool.shortcut}
</span>
</button>
)
})}
{/* Divider */}
<div className="w-px h-8 bg-white/10 mx-2" />
{/* Clear Tool Button */}
<button
onClick={() => onToolSelect(null)}
className={`p-2 rounded-lg transition-all ${
selectedTool === null
? 'bg-white/20 text-white'
: 'hover:bg-white/10 text-white/60'
}`}
title="Auswahl (Esc)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 15l-2 5L9 9l11 4-5 2zm0 0l5 5M7.188 2.239l.777 2.897M5.136 7.965l-2.898-.777M13.95 4.05l-2.122 2.122m-5.657 5.656l-2.12 2.122" />
</svg>
</button>
</div>
)
}
// =============================================================================
// ANNOTATION LEGEND
// =============================================================================
export function AnnotationLegend({ className = '' }: { className?: string }) {
return (
<div className={`flex flex-wrap gap-3 text-xs ${className}`}>
{tools.map((tool) => (
<div key={tool.type} className="flex items-center gap-1.5">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: ANNOTATION_COLORS[tool.type] }}
/>
<span className="text-white/60">{tool.label}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,258 @@
'use client'
import { useMemo } from 'react'
import type { CriteriaScores, Annotation } from '@/app/korrektur/types'
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, calculateGrade, getGradeLabel } from '@/app/korrektur/types'
interface CriteriaPanelProps {
scores: CriteriaScores
annotations: Annotation[]
onScoreChange: (criterion: string, value: number) => void
onLoadEHSuggestions?: (criterion: string) => void
isLoading?: boolean
className?: string
}
export function CriteriaPanel({
scores,
annotations,
onScoreChange,
onLoadEHSuggestions,
isLoading = false,
className = '',
}: CriteriaPanelProps) {
// Count annotations per criterion
const annotationCounts = useMemo(() => {
const counts: Record<string, number> = {}
for (const annotation of annotations) {
const type = annotation.linked_criterion || annotation.type
counts[type] = (counts[type] || 0) + 1
}
return counts
}, [annotations])
// Calculate total grade
const { totalWeightedScore, totalWeight, gradePoints, gradeLabel } = useMemo(() => {
let weightedScore = 0
let weight = 0
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
const score = scores[criterion]
if (score !== undefined) {
weightedScore += score * config.weight
weight += config.weight
}
}
const percentage = weight > 0 ? weightedScore / weight : 0
const grade = calculateGrade(percentage)
const label = getGradeLabel(grade)
return {
totalWeightedScore: weightedScore,
totalWeight: weight,
gradePoints: grade,
gradeLabel: label,
}
}, [scores])
return (
<div className={`space-y-4 ${className}`}>
{/* Criteria List */}
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
const score = scores[criterion] || 0
const annotationCount = annotationCounts[criterion] || 0
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<CriterionCard
key={criterion}
id={criterion}
name={config.name}
weight={config.weight}
score={score}
annotationCount={annotationCount}
color={color}
onScoreChange={(value) => onScoreChange(criterion, value)}
onLoadSuggestions={
onLoadEHSuggestions
? () => onLoadEHSuggestions(criterion)
: undefined
}
/>
)
})}
{/* Total Score */}
<div className="p-4 rounded-2xl bg-gradient-to-r from-purple-500/20 to-pink-500/20 border border-purple-500/30">
<div className="flex items-center justify-between mb-2">
<span className="text-white/60 text-sm">Gesamtnote</span>
<span className="text-2xl font-bold text-white">
{gradePoints} Punkte
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-white/40 text-xs">
{Math.round(totalWeightedScore / totalWeight)}% gewichtet
</span>
<span className="text-lg font-semibold text-purple-300">
({gradeLabel})
</span>
</div>
</div>
</div>
)
}
// =============================================================================
// CRITERION CARD
// =============================================================================
interface CriterionCardProps {
id: string
name: string
weight: number
score: number
annotationCount: number
color: string
onScoreChange: (value: number) => void
onLoadSuggestions?: () => void
}
function CriterionCard({
id,
name,
weight,
score,
annotationCount,
color,
onScoreChange,
onLoadSuggestions,
}: CriterionCardProps) {
const gradePoints = calculateGrade(score)
const gradeLabel = getGradeLabel(gradePoints)
return (
<div className="p-4 rounded-2xl bg-white/5 border border-white/10">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-white font-medium">{name}</span>
<span className="text-white/40 text-xs">({weight}%)</span>
</div>
<span className="text-white/60 text-sm">
{gradePoints} P ({gradeLabel})
</span>
</div>
{/* Slider */}
<div className="relative mb-3">
<input
type="range"
min="0"
max="100"
value={score}
onChange={(e) => onScoreChange(Number(e.target.value))}
className="w-full h-2 bg-white/10 rounded-full appearance-none cursor-pointer"
style={{
background: `linear-gradient(to right, ${color} ${score}%, rgba(255,255,255,0.1) ${score}%)`,
}}
/>
<div className="flex justify-between mt-1 text-xs text-white/30">
<span>0</span>
<span>25</span>
<span>50</span>
<span>75</span>
<span>100</span>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{annotationCount > 0 && (
<span
className="px-2 py-0.5 rounded-full text-xs font-medium"
style={{ backgroundColor: `${color}20`, color }}
>
{annotationCount} Anmerkung{annotationCount !== 1 ? 'en' : ''}
</span>
)}
</div>
{onLoadSuggestions && (id === 'inhalt' || id === 'struktur') && (
<button
onClick={onLoadSuggestions}
className="text-xs text-purple-400 hover:text-purple-300 transition-colors flex items-center gap-1"
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
EH-Vorschlaege
</button>
)}
</div>
</div>
)
}
// =============================================================================
// COMPACT CRITERIA SUMMARY
// =============================================================================
interface CriteriaSummaryProps {
scores: CriteriaScores
className?: string
}
export function CriteriaSummary({ scores, className = '' }: CriteriaSummaryProps) {
const { gradePoints, gradeLabel } = useMemo(() => {
let weightedScore = 0
let weight = 0
for (const [criterion, config] of Object.entries(DEFAULT_CRITERIA)) {
const score = scores[criterion]
if (score !== undefined) {
weightedScore += score * config.weight
weight += config.weight
}
}
const percentage = weight > 0 ? weightedScore / weight : 0
const grade = calculateGrade(percentage)
const label = getGradeLabel(grade)
return { gradePoints: grade, gradeLabel: label }
}, [scores])
return (
<div className={`flex items-center gap-3 ${className}`}>
{Object.entries(DEFAULT_CRITERIA).map(([criterion, config]) => {
const score = scores[criterion] || 0
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<div
key={criterion}
className="flex items-center gap-1"
title={`${config.name}: ${score}%`}
>
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-white/60 text-xs">{score}</span>
</div>
)
})}
<div className="w-px h-4 bg-white/20" />
<span className="text-white font-medium text-sm">
{gradePoints} ({gradeLabel})
</span>
</div>
)
}

View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useRef, useCallback, useEffect } from 'react'
interface DocumentViewerProps {
fileUrl: string
fileType: 'pdf' | 'image'
currentPage: number
totalPages: number
onPageChange: (page: number) => void
children?: React.ReactNode // For annotation overlay
className?: string
}
export function DocumentViewer({
fileUrl,
fileType,
currentPage,
totalPages,
onPageChange,
children,
className = '',
}: DocumentViewerProps) {
const [zoom, setZoom] = useState(1)
const [isDragging, setIsDragging] = useState(false)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [startPos, setStartPos] = useState({ x: 0, y: 0 })
const containerRef = useRef<HTMLDivElement>(null)
const handleZoomIn = () => setZoom((prev) => Math.min(prev + 0.25, 3))
const handleZoomOut = () => setZoom((prev) => Math.max(prev - 0.25, 0.5))
const handleFit = () => {
setZoom(1)
setPosition({ x: 0, y: 0 })
}
const handleMouseDown = (e: React.MouseEvent) => {
if (e.button !== 1 && !e.ctrlKey) return // Middle click or Ctrl+click for pan
setIsDragging(true)
setStartPos({ x: e.clientX - position.x, y: e.clientY - position.y })
}
const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDragging) return
setPosition({
x: e.clientX - startPos.x,
y: e.clientY - startPos.y,
})
},
[isDragging, startPos]
)
const handleMouseUp = useCallback(() => {
setIsDragging(false)
}, [])
useEffect(() => {
if (isDragging) {
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
}
return () => {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
}, [isDragging, handleMouseMove, handleMouseUp])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === '+' || e.key === '=') {
e.preventDefault()
handleZoomIn()
} else if (e.key === '-') {
e.preventDefault()
handleZoomOut()
} else if (e.key === '0') {
e.preventDefault()
handleFit()
} else if (e.key === 'ArrowLeft' && currentPage > 1) {
e.preventDefault()
onPageChange(currentPage - 1)
} else if (e.key === 'ArrowRight' && currentPage < totalPages) {
e.preventDefault()
onPageChange(currentPage + 1)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [currentPage, totalPages, onPageChange])
return (
<div className={`flex flex-col h-full ${className}`}>
{/* Toolbar */}
<div className="flex items-center justify-between px-4 py-2 bg-white/5 border-b border-white/10">
<div className="flex items-center gap-2">
<button
onClick={handleZoomOut}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="Verkleinern (-)"
>
<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 0zM13 10H7" />
</svg>
</button>
<span className="text-white/60 text-sm min-w-[60px] text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="Vergroessern (+)"
>
<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 0zM10 7v3m0 0v3m0-3h3m-3 0H7" />
</svg>
</button>
<button
onClick={handleFit}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors"
title="Einpassen (0)"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
</button>
</div>
{/* Page Navigation */}
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage <= 1}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors disabled:opacity-30"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-white/60 text-sm">
{currentPage} / {totalPages}
</span>
<button
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage >= totalPages}
className="p-2 rounded-lg hover:bg-white/10 text-white/60 hover:text-white transition-colors disabled:opacity-30"
>
<svg className="w-5 h-5" 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>
{/* Document Area */}
<div
ref={containerRef}
className="flex-1 overflow-hidden bg-slate-800/50 relative"
onMouseDown={handleMouseDown}
style={{ cursor: isDragging ? 'grabbing' : 'default' }}
>
<div
className="absolute inset-0 flex items-center justify-center"
style={{
transform: `translate(${position.x}px, ${position.y}px) scale(${zoom})`,
transformOrigin: 'center center',
transition: isDragging ? 'none' : 'transform 0.2s ease-out',
}}
>
{/* Document Image/PDF */}
<div className="relative">
{fileType === 'image' ? (
<img
src={fileUrl}
alt="Schuelerarbeit"
className="max-w-full max-h-full object-contain shadow-2xl"
draggable={false}
/>
) : (
<iframe
src={`${fileUrl}#page=${currentPage}`}
className="w-[800px] h-[1000px] bg-white shadow-2xl"
title="PDF Dokument"
/>
)}
{/* Annotation Overlay */}
{children && (
<div className="absolute inset-0 pointer-events-none">
{children}
</div>
)}
</div>
</div>
</div>
{/* Page Thumbnails (for multi-page) */}
{totalPages > 1 && (
<div className="flex gap-2 p-3 bg-white/5 border-t border-white/10 overflow-x-auto">
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
<button
key={page}
onClick={() => onPageChange(page)}
className={`flex-shrink-0 w-12 h-16 rounded-lg border-2 flex items-center justify-center text-sm font-medium transition-all ${
page === currentPage
? 'border-purple-500 bg-purple-500/20 text-white'
: 'border-white/10 bg-white/5 text-white/60 hover:border-white/30'
}`}
>
{page}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,293 @@
'use client'
import { useState } from 'react'
import type { EHSuggestion } from '@/app/korrektur/types'
import { DEFAULT_CRITERIA, ANNOTATION_COLORS, NIBIS_ATTRIBUTION } from '@/app/korrektur/types'
interface EHSuggestionPanelProps {
suggestions: EHSuggestion[]
isLoading: boolean
onLoadSuggestions: (criterion?: string) => void
onInsertSuggestion?: (text: string) => void
className?: string
}
export function EHSuggestionPanel({
suggestions,
isLoading,
onLoadSuggestions,
onInsertSuggestion,
className = '',
}: EHSuggestionPanelProps) {
const [selectedCriterion, setSelectedCriterion] = useState<string | undefined>(undefined)
const [expandedSuggestion, setExpandedSuggestion] = useState<string | null>(null)
const handleLoadSuggestions = () => {
onLoadSuggestions(selectedCriterion)
}
// Group suggestions by criterion
const groupedSuggestions = suggestions.reduce((acc, suggestion) => {
if (!acc[suggestion.criterion]) {
acc[suggestion.criterion] = []
}
acc[suggestion.criterion].push(suggestion)
return acc
}, {} as Record<string, EHSuggestion[]>)
return (
<div className={`space-y-4 ${className}`}>
{/* Header with Attribution (CTRL-SRC-002) */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-white font-semibold">EH-Vorschlaege</h3>
<p className="text-white/40 text-xs">Aus 500+ NiBiS Dokumenten</p>
</div>
</div>
{/* Attribution Notice */}
<div className="p-2 rounded-lg bg-white/5 border border-white/10">
<div className="flex items-center gap-2 text-xs text-white/50">
<svg className="w-3 h-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>
Quelle: {NIBIS_ATTRIBUTION.publisher} {' '}
<a
href={NIBIS_ATTRIBUTION.license_url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:underline"
>
{NIBIS_ATTRIBUTION.license}
</a>
</span>
</div>
</div>
{/* Criterion Filter */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => setSelectedCriterion(undefined)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCriterion === undefined
? 'bg-purple-500 text-white'
: 'bg-white/10 text-white/60 hover:bg-white/20'
}`}
>
Alle
</button>
{Object.entries(DEFAULT_CRITERIA).map(([id, config]) => {
const color = ANNOTATION_COLORS[id as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<button
key={id}
onClick={() => setSelectedCriterion(id)}
className={`px-3 py-1.5 rounded-lg text-xs font-medium transition-colors ${
selectedCriterion === id
? 'text-white'
: 'text-white/60 hover:bg-white/20'
}`}
style={{
backgroundColor: selectedCriterion === id ? color : 'rgba(255,255,255,0.1)',
}}
>
{config.name}
</button>
)
})}
</div>
{/* Load Button */}
<button
onClick={handleLoadSuggestions}
disabled={isLoading}
className="w-full px-4 py-3 rounded-xl bg-gradient-to-r from-blue-500 to-cyan-500 text-white font-medium hover:shadow-lg hover:shadow-blue-500/30 transition-all disabled:opacity-50 flex items-center justify-center gap-2"
>
{isLoading ? (
<>
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
RAG-Suche laeuft...
</>
) : (
<>
<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>
EH-Vorschlaege laden
</>
)}
</button>
{/* Suggestions List */}
{suggestions.length > 0 && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{Object.entries(groupedSuggestions).map(([criterion, criterionSuggestions]) => {
const config = DEFAULT_CRITERIA[criterion]
const color = ANNOTATION_COLORS[criterion as keyof typeof ANNOTATION_COLORS] || '#6b7280'
return (
<div key={criterion} className="space-y-2">
{/* Criterion Header */}
<div className="flex items-center gap-2 sticky top-0 bg-slate-900/95 backdrop-blur-sm py-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-white/60 text-xs font-medium">
{config?.name || criterion}
</span>
<span className="text-white/30 text-xs">
({criterionSuggestions.length})
</span>
</div>
{/* Suggestions */}
{criterionSuggestions.map((suggestion, index) => (
<SuggestionCard
key={`${criterion}-${index}`}
suggestion={suggestion}
color={color}
isExpanded={expandedSuggestion === `${criterion}-${index}`}
onToggle={() =>
setExpandedSuggestion(
expandedSuggestion === `${criterion}-${index}`
? null
: `${criterion}-${index}`
)
}
onInsert={onInsertSuggestion}
/>
))}
</div>
)
})}
</div>
)}
{/* Empty State */}
{!isLoading && suggestions.length === 0 && (
<div className="p-6 rounded-xl bg-white/5 border border-white/10 text-center">
<div className="w-12 h-12 rounded-full bg-white/10 flex items-center justify-center mx-auto mb-3">
<svg className="w-6 h-6 text-white/30" 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>
</div>
<p className="text-white/60 text-sm">
Klicken Sie auf "EH-Vorschlaege laden" um<br />
relevante Bewertungskriterien zu finden.
</p>
</div>
)}
</div>
)
}
// =============================================================================
// SUGGESTION CARD
// =============================================================================
interface SuggestionCardProps {
suggestion: EHSuggestion
color: string
isExpanded: boolean
onToggle: () => void
onInsert?: (text: string) => void
}
function SuggestionCard({
suggestion,
color,
isExpanded,
onToggle,
onInsert,
}: SuggestionCardProps) {
const relevancePercent = Math.round(suggestion.relevance_score * 100)
return (
<div
className="rounded-xl bg-white/5 border border-white/10 overflow-hidden transition-all"
style={{ borderLeftColor: color, borderLeftWidth: '3px' }}
>
{/* Header */}
<button
onClick={onToggle}
className="w-full px-3 py-2 flex items-center justify-between hover:bg-white/5 transition-colors"
>
<div className="flex items-center gap-2 flex-1 min-w-0">
<div
className="flex-shrink-0 w-8 h-8 rounded-lg flex items-center justify-center text-xs font-bold"
style={{
backgroundColor: `${color}20`,
color: color,
}}
>
{relevancePercent}%
</div>
<p className="text-white/70 text-sm truncate text-left">
{suggestion.excerpt.slice(0, 60)}...
</p>
</div>
<svg
className={`w-4 h-4 text-white/40 transition-transform flex-shrink-0 ml-2 ${
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>
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="px-3 pb-3 space-y-2">
<p className="text-white/60 text-sm whitespace-pre-wrap">
{suggestion.excerpt}
</p>
{/* Source Attribution */}
<div className="flex items-center gap-2 text-xs text-white/40 pt-1 border-t border-white/10">
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<span>
{suggestion.source_document || 'NiBiS Kerncurriculum'} ({NIBIS_ATTRIBUTION.license})
</span>
</div>
{/* Actions */}
{onInsert && (
<div className="flex gap-2">
<button
onClick={() => onInsert(suggestion.excerpt)}
className="flex-1 px-3 py-2 rounded-lg bg-white/10 text-white/70 text-xs hover:bg-white/20 hover:text-white transition-colors flex items-center justify-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Einfuegen
</button>
<button
onClick={() => {
// Insert with citation (CTRL-SRC-002)
const citation = `\n\n[Quelle: ${suggestion.source_document || 'NiBiS Kerncurriculum'}, ${NIBIS_ATTRIBUTION.publisher}, ${NIBIS_ATTRIBUTION.license}]`
onInsert(suggestion.excerpt + citation)
}}
className="px-3 py-2 rounded-lg bg-blue-500/20 text-blue-300 text-xs hover:bg-blue-500/30 transition-colors flex items-center justify-center gap-1"
title="Mit Quellenangabe einfuegen"
>
<svg className="w-4 h-4" 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.101" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.172 13.828a4 4 0 015.656 0l4-4a4 4 0 00-5.656-5.656l-1.102 1.101" />
</svg>
+ Zitat
</button>
</div>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,194 @@
'use client'
import { useState, useEffect, useRef } from 'react'
interface GutachtenEditorProps {
value: string
onChange: (value: string) => void
onGenerate?: () => void
isGenerating?: boolean
placeholder?: string
className?: string
}
export function GutachtenEditor({
value,
onChange,
onGenerate,
isGenerating = false,
placeholder = 'Gutachten hier eingeben oder generieren lassen...',
className = '',
}: GutachtenEditorProps) {
const [isFocused, setIsFocused] = useState(false)
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Auto-resize textarea
useEffect(() => {
const textarea = textareaRef.current
if (textarea) {
textarea.style.height = 'auto'
textarea.style.height = `${Math.max(200, textarea.scrollHeight)}px`
}
}, [value])
// Word count
const wordCount = value.trim() ? value.trim().split(/\s+/).length : 0
const charCount = value.length
return (
<div className={`space-y-3 ${className}`}>
{/* Header */}
<div className="flex items-center justify-between">
<h3 className="text-white font-semibold">Gutachten</h3>
<div className="flex items-center gap-2">
<span className="text-white/40 text-xs">
{wordCount} Woerter / {charCount} Zeichen
</span>
{onGenerate && (
<button
onClick={onGenerate}
disabled={isGenerating}
className="px-3 py-1.5 rounded-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white text-sm font-medium hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
>
{isGenerating ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Generiere...
</>
) : (
<>
<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>
KI Generieren
</>
)}
</button>
)}
</div>
</div>
{/* Editor */}
<div
className={`relative rounded-2xl transition-all ${
isFocused
? 'ring-2 ring-purple-500 ring-offset-2 ring-offset-slate-900'
: ''
}`}
>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
placeholder={placeholder}
className="w-full min-h-[200px] p-4 rounded-2xl bg-white/5 border border-white/10 text-white placeholder-white/30 resize-none focus:outline-none"
/>
{/* Loading Overlay */}
{isGenerating && (
<div className="absolute inset-0 bg-slate-900/80 backdrop-blur-sm rounded-2xl flex items-center justify-center">
<div className="text-center">
<div className="w-12 h-12 border-4 border-purple-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-white/60 text-sm">Gutachten wird generiert...</p>
<p className="text-white/40 text-xs mt-1">Nutzt 500+ NiBiS Dokumente</p>
</div>
</div>
)}
</div>
{/* Quick Insert Buttons */}
<div className="flex flex-wrap gap-2">
<QuickInsertButton
label="Einleitung"
onClick={() => onChange(value + '\n\nEinleitung:\n')}
/>
<QuickInsertButton
label="Staerken"
onClick={() => onChange(value + '\n\nStaerken der Arbeit:\n- ')}
/>
<QuickInsertButton
label="Schwaechen"
onClick={() => onChange(value + '\n\nVerbesserungsmoeglichkeiten:\n- ')}
/>
<QuickInsertButton
label="Fazit"
onClick={() => onChange(value + '\n\nGesamteindruck:\n')}
/>
</div>
</div>
)
}
// =============================================================================
// QUICK INSERT BUTTON
// =============================================================================
interface QuickInsertButtonProps {
label: string
onClick: () => void
}
function QuickInsertButton({ label, onClick }: QuickInsertButtonProps) {
return (
<button
onClick={onClick}
className="px-3 py-1 rounded-lg bg-white/5 border border-white/10 text-white/60 text-xs hover:bg-white/10 hover:text-white transition-colors"
>
+ {label}
</button>
)
}
// =============================================================================
// GUTACHTEN PREVIEW (Read-only)
// =============================================================================
interface GutachtenPreviewProps {
value: string
className?: string
}
export function GutachtenPreview({ value, className = '' }: GutachtenPreviewProps) {
if (!value) {
return (
<div className={`p-4 rounded-2xl bg-white/5 border border-white/10 text-white/40 text-center ${className}`}>
Kein Gutachten vorhanden
</div>
)
}
// Split into paragraphs for better rendering
const paragraphs = value.split('\n\n').filter(Boolean)
return (
<div className={`p-4 rounded-2xl bg-white/5 border border-white/10 space-y-4 ${className}`}>
{paragraphs.map((paragraph, index) => {
// Check if it's a heading (ends with :)
const lines = paragraph.split('\n')
const firstLine = lines[0]
const isHeading = firstLine.endsWith(':')
if (isHeading) {
return (
<div key={index}>
<h4 className="text-white font-semibold mb-2">{firstLine}</h4>
{lines.slice(1).map((line, lineIndex) => (
<p key={lineIndex} className="text-white/70 text-sm">
{line}
</p>
))}
</div>
)
}
return (
<p key={index} className="text-white/70 text-sm whitespace-pre-wrap">
{paragraph}
</p>
)
})}
</div>
)
}

View File

@@ -0,0 +1,6 @@
export { DocumentViewer } from './DocumentViewer'
export { AnnotationLayer } from './AnnotationLayer'
export { AnnotationToolbar, AnnotationLegend } from './AnnotationToolbar'
export { CriteriaPanel, CriteriaSummary } from './CriteriaPanel'
export { GutachtenEditor, GutachtenPreview } from './GutachtenEditor'
export { EHSuggestionPanel } from './EHSuggestionPanel'