fix: Restore all files lost during destructive rebase
A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
310
studio-v2/components/korrektur/AnnotationLayer.tsx
Normal file
310
studio-v2/components/korrektur/AnnotationLayer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
159
studio-v2/components/korrektur/AnnotationToolbar.tsx
Normal file
159
studio-v2/components/korrektur/AnnotationToolbar.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
258
studio-v2/components/korrektur/CriteriaPanel.tsx
Normal file
258
studio-v2/components/korrektur/CriteriaPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
221
studio-v2/components/korrektur/DocumentViewer.tsx
Normal file
221
studio-v2/components/korrektur/DocumentViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
293
studio-v2/components/korrektur/EHSuggestionPanel.tsx
Normal file
293
studio-v2/components/korrektur/EHSuggestionPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
194
studio-v2/components/korrektur/GutachtenEditor.tsx
Normal file
194
studio-v2/components/korrektur/GutachtenEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
6
studio-v2/components/korrektur/index.ts
Normal file
6
studio-v2/components/korrektur/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user