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,223 @@
'use client'
/**
* AehnlicheDokumente - RAG-based similar documents panel
* Shows documents with similar content based on vector similarity
*/
import { useState, useEffect } from 'react'
import { Loader2, FileText, AlertCircle, RefreshCw, ExternalLink } from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import type { SimilarDocument } from '@/lib/education/abitur-archiv-types'
import { FAECHER } from '@/lib/education/abitur-docs-types'
interface AehnlicheDokumenteProps {
documentId: string
onSelectDocument: (doc: AbiturDokument) => void
limit?: number
}
export function AehnlicheDokumente({
documentId,
onSelectDocument,
limit = 5
}: AehnlicheDokumenteProps) {
const [similarDocs, setSimilarDocs] = useState<SimilarDocument[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const fetchSimilarDocuments = async () => {
if (!documentId) return
setLoading(true)
setError(null)
try {
const res = await fetch(`/api/education/abitur-archiv/similar?id=${documentId}&limit=${limit}`)
if (!res.ok) {
// Use mock data if endpoint not available
setSimilarDocs(getMockSimilarDocuments(documentId))
return
}
const data = await res.json()
setSimilarDocs(data.similar || [])
} catch (err) {
console.log('Similar docs fetch failed, using mock data')
setSimilarDocs(getMockSimilarDocuments(documentId))
} finally {
setLoading(false)
}
}
fetchSimilarDocuments()
}, [documentId, limit])
const handleRefresh = () => {
setLoading(true)
// Re-trigger the effect
setSimilarDocs([])
setTimeout(() => {
setSimilarDocs(getMockSimilarDocuments(documentId))
setLoading(false)
}, 500)
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mb-3" />
<p className="text-sm text-slate-500">Suche aehnliche Dokumente...</p>
</div>
)
}
if (error) {
return (
<div className="text-center py-8">
<AlertCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-sm text-red-600 mb-3">{error}</p>
<button
onClick={handleRefresh}
className="px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg flex items-center gap-2 mx-auto"
>
<RefreshCw className="w-4 h-4" />
Erneut versuchen
</button>
</div>
)
}
if (similarDocs.length === 0) {
return (
<div className="text-center py-8">
<FileText className="w-10 h-10 text-slate-300 mx-auto mb-3" />
<p className="text-sm text-slate-500">Keine aehnlichen Dokumente gefunden</p>
<p className="text-xs text-slate-400 mt-1">
Versuchen Sie eine andere Suche oder laden Sie mehr Dokumente hoch.
</p>
</div>
)
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium text-slate-700">Aehnliche Dokumente</h4>
<button
onClick={handleRefresh}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded"
title="Aktualisieren"
>
<RefreshCw className="w-4 h-4" />
</button>
</div>
<div className="space-y-2">
{similarDocs.map((doc) => (
<SimilarDocumentCard
key={doc.id}
document={doc}
onSelect={() => {
// Convert SimilarDocument to AbiturDokument for selection
// In production, this would fetch the full document
onSelectDocument(doc as unknown as AbiturDokument)
}}
/>
))}
</div>
<p className="text-xs text-slate-400 text-center pt-2">
Basierend auf semantischer Aehnlichkeit (RAG)
</p>
</div>
)
}
function SimilarDocumentCard({
document,
onSelect
}: {
document: SimilarDocument
onSelect: () => void
}) {
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
const similarityPercent = Math.round(document.similarity_score * 100)
return (
<button
onClick={onSelect}
className="w-full flex items-start gap-3 p-3 bg-white border border-slate-200 rounded-lg
hover:bg-blue-50 hover:border-blue-200 transition-colors text-left group"
>
{/* Icon */}
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0
group-hover:bg-blue-100 transition-colors">
<FileText className="w-5 h-5 text-slate-400 group-hover:text-blue-500" />
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 truncate group-hover:text-blue-700">
{fachLabel} {document.jahr}
</div>
<div className="text-sm text-slate-500 flex items-center gap-2">
<span>{document.niveau}</span>
<span>|</span>
<span>Aufgabe {document.aufgaben_nummer}</span>
{document.typ === 'erwartungshorizont' && (
<>
<span>|</span>
<span className="text-orange-600">EWH</span>
</>
)}
</div>
</div>
{/* Similarity Score */}
<div className="flex-shrink-0">
<div className={`px-2 py-1 rounded-full text-xs font-medium ${
similarityPercent >= 80
? 'bg-green-100 text-green-700'
: similarityPercent >= 60
? 'bg-yellow-100 text-yellow-700'
: 'bg-slate-100 text-slate-600'
}`}>
{similarityPercent}%
</div>
</div>
</button>
)
}
// Mock data generator for development
function getMockSimilarDocuments(documentId: string): SimilarDocument[] {
// Generate consistent mock data based on document ID
const idHash = documentId.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const faecher = ['deutsch', 'englisch']
const jahre = [2021, 2022, 2023, 2024, 2025]
const niveaus: Array<'eA' | 'gA'> = ['eA', 'gA']
const nummern = ['I', 'II', 'III']
const typen: Array<'aufgabe' | 'erwartungshorizont'> = ['aufgabe', 'erwartungshorizont']
const docs: SimilarDocument[] = []
for (let i = 0; i < 5; i++) {
const idx = (idHash + i) % (faecher.length * jahre.length * niveaus.length)
docs.push({
id: `similar-${documentId}-${i}`,
dateiname: `${jahre[idx % jahre.length]}_${faecher[idx % faecher.length]}_${niveaus[idx % niveaus.length]}_${nummern[idx % nummern.length]}.pdf`,
similarity_score: 0.95 - (i * 0.1) + (Math.random() * 0.05),
fach: faecher[idx % faecher.length],
jahr: jahre[(idx + i) % jahre.length],
niveau: niveaus[idx % niveaus.length],
typ: typen[(idx + i) % typen.length],
aufgaben_nummer: nummern[(idx + i) % nummern.length]
})
}
return docs.sort((a, b) => b.similarity_score - a.similarity_score)
}

View File

@@ -0,0 +1,203 @@
'use client'
/**
* DokumentCard - Card component for Abitur document grid view
* Features: Preview, Download, Add to Klausur actions
*/
import { useState } from 'react'
import { FileText, Eye, Download, Plus, Calendar, Layers, BookOpen, ExternalLink } from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import { formatFileSize, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
interface DokumentCardProps {
document: AbiturDokument
onPreview: (doc: AbiturDokument) => void
onDownload: (doc: AbiturDokument) => void
onAddToKlausur?: (doc: AbiturDokument) => void
}
export function DokumentCard({
document,
onPreview,
onDownload,
onAddToKlausur
}: DokumentCardProps) {
const [isHovered, setIsHovered] = useState(false)
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
const niveauLabel = document.niveau === 'eA' ? 'Erhoehtes Niveau' : 'Grundlegendes Niveau'
const handleDownload = (e: React.MouseEvent) => {
e.stopPropagation()
onDownload(document)
}
const handleAddToKlausur = (e: React.MouseEvent) => {
e.stopPropagation()
onAddToKlausur?.(document)
}
return (
<div
className="bg-white rounded-xl border border-slate-200 overflow-hidden hover:shadow-lg
transition-all duration-200 cursor-pointer group"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={() => onPreview(document)}
>
{/* Header with Type Badge */}
<div className="relative h-32 bg-gradient-to-br from-slate-100 to-slate-50 flex items-center justify-center">
<FileText className="w-16 h-16 text-slate-300 group-hover:text-blue-400 transition-colors" />
{/* Type Badge */}
<div className="absolute top-3 left-3">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${
document.typ === 'erwartungshorizont'
? 'bg-orange-100 text-orange-700'
: 'bg-purple-100 text-purple-700'
}`}>
{document.typ === 'erwartungshorizont' ? 'Erwartungshorizont' : 'Aufgabe'}
</span>
</div>
{/* Year Badge */}
<div className="absolute top-3 right-3">
<span className="px-2 py-1 bg-white/80 backdrop-blur-sm rounded-lg text-xs font-semibold text-slate-700">
{document.jahr}
</span>
</div>
{/* Status Badge */}
<div className="absolute bottom-3 right-3">
<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>
{/* Hover Overlay with Preview */}
{isHovered && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center">
<button
className="px-4 py-2 bg-white text-slate-800 rounded-lg font-medium
flex items-center gap-2 shadow-lg hover:bg-blue-50 transition-colors"
onClick={(e) => {
e.stopPropagation()
onPreview(document)
}}
>
<Eye className="w-4 h-4" />
Vorschau
</button>
</div>
)}
</div>
{/* Content */}
<div className="p-4">
{/* Title */}
<h3 className="font-semibold text-slate-800 mb-2 line-clamp-2 min-h-[2.5rem]">
{fachLabel} {document.niveau} - Aufgabe {document.aufgaben_nummer}
</h3>
{/* Metadata */}
<div className="space-y-1.5 mb-4">
<div className="flex items-center gap-2 text-sm text-slate-500">
<BookOpen className="w-4 h-4" />
<span>{fachLabel}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-500">
<Layers className="w-4 h-4" />
<span>{niveauLabel}</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-500">
<ExternalLink className="w-4 h-4" />
<span className="capitalize">{document.bundesland}</span>
</div>
<div className="flex items-center gap-2 text-xs text-slate-400">
<span>{formatFileSize(document.file_size)}</span>
<span>|</span>
<span>{document.dateiname}</span>
</div>
</div>
{/* Action Buttons */}
<div className="flex gap-2">
<button
onClick={() => onPreview(document)}
className="flex-1 px-3 py-2 bg-blue-50 text-blue-700 rounded-lg hover:bg-blue-100
transition-colors text-sm font-medium flex items-center justify-center gap-1.5"
>
<Eye className="w-4 h-4" />
Vorschau
</button>
<button
onClick={handleDownload}
className="px-3 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
title="Herunterladen"
>
<Download className="w-4 h-4" />
</button>
{onAddToKlausur && (
<button
onClick={handleAddToKlausur}
className="px-3 py-2 text-green-600 hover:bg-green-50 rounded-lg transition-colors"
title="Zur Klausur hinzufuegen"
>
<Plus className="w-4 h-4" />
</button>
)}
</div>
</div>
</div>
)
}
/**
* Compact card variant for list view or similar documents
*/
export function DokumentCardCompact({
document,
onPreview,
similarity_score
}: {
document: AbiturDokument
onPreview: (doc: AbiturDokument) => void
similarity_score?: number
}) {
const fachLabel = FAECHER.find(f => f.id === document.fach)?.label || document.fach
return (
<button
onClick={() => onPreview(document)}
className="w-full flex items-center gap-3 p-3 bg-white border border-slate-200 rounded-lg
hover:bg-slate-50 hover:border-slate-300 transition-colors text-left"
>
<div className="w-10 h-10 bg-slate-100 rounded-lg flex items-center justify-center flex-shrink-0">
<FileText className="w-5 h-5 text-slate-400" />
</div>
<div className="flex-1 min-w-0">
<div className="font-medium text-slate-800 truncate">
{fachLabel} {document.jahr} - {document.niveau}
</div>
<div className="text-sm text-slate-500 truncate">
Aufgabe {document.aufgaben_nummer}
{document.typ === 'erwartungshorizont' && ' (EWH)'}
</div>
</div>
{similarity_score !== undefined && (
<div className="flex-shrink-0">
<span className="px-2 py-1 bg-green-100 text-green-700 text-xs rounded-full">
{Math.round(similarity_score * 100)}%
</span>
</div>
)}
</button>
)
}

View File

@@ -0,0 +1,456 @@
'use client'
/**
* FullscreenViewer - Enhanced PDF viewer with fullscreen, zoom, and page navigation
* Features: Keyboard shortcuts, zoom controls, similar documents panel
*/
import { useState, useEffect, useCallback } from 'react'
import {
X, Download, ZoomIn, ZoomOut, Maximize2, Minimize2,
ChevronLeft, ChevronRight, RotateCw, FileText, Search,
BookOpen, Calendar, Layers, ExternalLink, Plus
} from 'lucide-react'
import type { AbiturDokument } from '@/lib/education/abitur-docs-types'
import { formatFileSize, formatDocumentTitle, FAECHER, NIVEAUS } from '@/lib/education/abitur-docs-types'
import { ZOOM_LEVELS, MIN_ZOOM, MAX_ZOOM, ZOOM_STEP } from '@/lib/education/abitur-archiv-types'
import { AehnlicheDokumente } from './AehnlicheDokumente'
interface FullscreenViewerProps {
document: AbiturDokument | null
onClose: () => void
onAddToKlausur?: (doc: AbiturDokument) => void
backendUrl?: string
}
export function FullscreenViewer({
document,
onClose,
onAddToKlausur,
backendUrl = ''
}: FullscreenViewerProps) {
const [isFullscreen, setIsFullscreen] = useState(false)
const [zoom, setZoom] = useState(100)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [showSidebar, setShowSidebar] = useState(true)
const [activeTab, setActiveTab] = useState<'details' | 'similar'>('details')
// Reset state when document changes
useEffect(() => {
setZoom(100)
setCurrentPage(1)
setIsFullscreen(false)
}, [document?.id])
// Keyboard shortcuts
useEffect(() => {
if (!document) return
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
switch (e.key) {
case 'Escape':
if (isFullscreen) {
setIsFullscreen(false)
} else {
onClose()
}
break
case 'f':
case 'F11':
e.preventDefault()
setIsFullscreen(prev => !prev)
break
case '+':
case '=':
e.preventDefault()
setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))
break
case '-':
e.preventDefault()
setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))
break
case '0':
e.preventDefault()
setZoom(100)
break
case 'ArrowLeft':
e.preventDefault()
setCurrentPage(p => Math.max(1, p - 1))
break
case 'ArrowRight':
e.preventDefault()
setCurrentPage(p => Math.min(totalPages, p + 1))
break
case 's':
if (e.ctrlKey || e.metaKey) {
e.preventDefault()
handleDownload()
}
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [document, isFullscreen, totalPages, onClose])
// Handle native fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!window.document.fullscreenElement)
}
window.document.addEventListener('fullscreenchange', handleFullscreenChange)
return () => window.document.removeEventListener('fullscreenchange', handleFullscreenChange)
}, [])
const handleDownload = useCallback(() => {
if (!document) return
const link = window.document.createElement('a')
link.href = pdfUrl
link.download = document.dateiname
link.click()
}, [document])
const handleSearchInRAG = () => {
if (!document) return
window.location.href = `/education/edu-search?doc=${document.id}&search=1`
}
const handleAddToKlausur = () => {
if (!document || !onAddToKlausur) return
onAddToKlausur(document)
}
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
const pdfUrl = backendUrl
? `${backendUrl}/api/abitur-docs/${document.id}/file`
: document.file_path
return (
<div className={`fixed inset-0 z-50 flex ${isFullscreen ? 'bg-black' : 'bg-black/60 backdrop-blur-sm'}`}>
{/* Modal Container */}
<div className={`relative bg-white flex flex-col ${
isFullscreen ? 'w-full h-full' : 'w-[95vw] h-[95vh] max-w-7xl m-auto rounded-2xl overflow-hidden shadow-2xl'
}`}>
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 bg-white border-b border-slate-200">
<div className="flex items-center gap-3">
<FileText className="w-5 h-5 text-blue-600" />
<div>
<h2 className="font-semibold text-slate-900">
{formatDocumentTitle(document)}
</h2>
<p className="text-sm text-slate-500">
{document.dateiname}
</p>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2">
{/* Zoom Controls */}
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
<button
onClick={() => setZoom(z => Math.max(MIN_ZOOM, z - ZOOM_STEP))}
className="p-1.5 hover:bg-slate-200 rounded"
title="Verkleinern (-)"
>
<ZoomOut className="w-4 h-4 text-slate-600" />
</button>
<span className="text-sm font-medium text-slate-700 w-12 text-center">
{zoom}%
</span>
<button
onClick={() => setZoom(z => Math.min(MAX_ZOOM, z + ZOOM_STEP))}
className="p-1.5 hover:bg-slate-200 rounded"
title="Vergroessern (+)"
>
<ZoomIn className="w-4 h-4 text-slate-600" />
</button>
<button
onClick={() => setZoom(100)}
className="p-1.5 hover:bg-slate-200 rounded ml-1"
title="Zuruecksetzen (0)"
>
<RotateCw className="w-4 h-4 text-slate-600" />
</button>
</div>
{/* Page Navigation */}
{totalPages > 1 && (
<div className="flex items-center gap-1 px-2 py-1 bg-slate-100 rounded-lg">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
>
<ChevronLeft className="w-4 h-4 text-slate-600" />
</button>
<span className="text-sm font-medium text-slate-700 min-w-[60px] text-center">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-1.5 hover:bg-slate-200 rounded disabled:opacity-50"
>
<ChevronRight className="w-4 h-4 text-slate-600" />
</button>
</div>
)}
<div className="w-px h-6 bg-slate-200" />
{/* Action Buttons */}
<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" />
<span className="hidden sm:inline">RAG-Suche</span>
</button>
{onAddToKlausur && (
<button
onClick={handleAddToKlausur}
className="px-3 py-1.5 text-sm bg-green-100 text-green-700 rounded-lg hover:bg-green-200 flex items-center gap-1.5"
title="Als Vorlage verwenden"
>
<Plus className="w-4 h-4" />
<span className="hidden sm:inline">Zur Klausur</span>
</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 (Ctrl+S)"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
</button>
<div className="w-px h-6 bg-slate-200" />
<button
onClick={() => setShowSidebar(!showSidebar)}
className={`p-2 rounded-lg transition-colors ${
showSidebar ? 'bg-slate-200 text-slate-700' : 'text-slate-500 hover:bg-slate-100'
}`}
title="Seitenleiste"
>
<Layers className="w-4 h-4" />
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
className="p-2 hover:bg-slate-100 rounded-lg"
title={isFullscreen ? 'Vollbild beenden (F)' : 'Vollbild (F)'}
>
{isFullscreen ? (
<Minimize2 className="w-5 h-5 text-slate-600" />
) : (
<Maximize2 className="w-5 h-5 text-slate-600" />
)}
</button>
<button
onClick={onClose}
className="p-2 hover:bg-slate-100 rounded-lg"
title="Schliessen (Esc)"
>
<X className="w-5 h-5 text-slate-500" />
</button>
</div>
</div>
{/* Content */}
<div className="flex flex-1 overflow-hidden">
{/* PDF Viewer */}
<div className="flex-1 bg-slate-100 p-4 overflow-auto">
<div
className="bg-white rounded-lg border border-slate-200 mx-auto shadow-sm transition-transform duration-200"
style={{
transform: `scale(${zoom / 100})`,
transformOrigin: 'top center',
width: '100%',
maxWidth: zoom > 100 ? 'none' : '100%'
}}
>
<iframe
src={pdfUrl}
className="w-full h-[calc(90vh-120px)] rounded-lg"
title={document.dateiname}
/>
</div>
</div>
{/* Sidebar */}
{showSidebar && (
<div className="w-80 border-l border-slate-200 bg-slate-50 flex flex-col">
{/* Sidebar Tabs */}
<div className="flex border-b border-slate-200">
<button
onClick={() => setActiveTab('details')}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === 'details'
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
: 'text-slate-600 hover:text-slate-800'
}`}
>
Details
</button>
<button
onClick={() => setActiveTab('similar')}
className={`flex-1 px-4 py-3 text-sm font-medium transition-colors ${
activeTab === 'similar'
? 'text-blue-600 border-b-2 border-blue-600 bg-white'
: 'text-slate-600 hover:text-slate-800'
}`}
>
Aehnliche
</button>
</div>
{/* Sidebar Content */}
<div className="flex-1 overflow-y-auto p-4">
{activeTab === 'details' ? (
<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>
{/* Aufgabe */}
<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>
) : (
<AehnlicheDokumente
documentId={document.id}
onSelectDocument={(doc) => {
// This would be handled by parent - for now just show preview
console.log('Selected similar document:', doc.id)
}}
/>
)}
</div>
</div>
)}
</div>
{/* Keyboard Shortcut Hint */}
<div className="absolute bottom-4 left-4 text-xs text-slate-400 bg-white/80 backdrop-blur-sm px-3 py-1.5 rounded-lg shadow-sm">
Tastenkuerzel: F (Vollbild) | +/- (Zoom) | 0 (Reset) | Esc (Schliessen)
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,243 @@
'use client'
/**
* ThemenSuche - Autocomplete search for Abitur themes
* Features debounced API calls, suggestion display, and keyboard navigation
*/
import { useState, useEffect, useRef, useCallback } from 'react'
import { Search, X, Loader2 } from 'lucide-react'
import type { ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
import { POPULAR_THEMES } from '@/lib/education/abitur-archiv-types'
interface ThemenSucheProps {
onSearch: (query: string) => void
onClear: () => void
placeholder?: string
}
export function ThemenSuche({
onSearch,
onClear,
placeholder = 'Thema suchen (z.B. Gedichtanalyse, Eroerterung, Drama...)'
}: ThemenSucheProps) {
const [query, setQuery] = useState('')
const [suggestions, setSuggestions] = useState<ThemaSuggestion[]>([])
const [loading, setLoading] = useState(false)
const [showDropdown, setShowDropdown] = useState(false)
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
// Debounced API call for suggestions
useEffect(() => {
const timer = setTimeout(async () => {
if (query.length >= 2) {
setLoading(true)
try {
const res = await fetch(`/api/education/abitur-archiv/suggest?q=${encodeURIComponent(query)}`)
const data = await res.json()
setSuggestions(data.suggestions || [])
setShowDropdown(true)
} catch (error) {
console.error('Suggest error:', error)
// Fallback to popular themes
setSuggestions(POPULAR_THEMES.filter(t =>
t.label.toLowerCase().includes(query.toLowerCase())
))
} finally {
setLoading(false)
}
} else if (query.length === 0) {
setSuggestions(POPULAR_THEMES)
} else {
setSuggestions([])
}
}, 300)
return () => clearTimeout(timer)
}, [query])
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (
dropdownRef.current &&
!dropdownRef.current.contains(e.target as Node) &&
inputRef.current &&
!inputRef.current.contains(e.target as Node)
) {
setShowDropdown(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (!showDropdown || suggestions.length === 0) return
switch (e.key) {
case 'ArrowDown':
e.preventDefault()
setSelectedIndex(prev => Math.min(prev + 1, suggestions.length - 1))
break
case 'ArrowUp':
e.preventDefault()
setSelectedIndex(prev => Math.max(prev - 1, -1))
break
case 'Enter':
e.preventDefault()
if (selectedIndex >= 0) {
handleSelectSuggestion(suggestions[selectedIndex])
} else if (query.trim()) {
handleSearch()
}
break
case 'Escape':
setShowDropdown(false)
setSelectedIndex(-1)
break
}
}, [showDropdown, suggestions, selectedIndex, query])
const handleSelectSuggestion = (suggestion: ThemaSuggestion) => {
setQuery(suggestion.label)
setShowDropdown(false)
setSelectedIndex(-1)
onSearch(suggestion.label)
}
const handleSearch = () => {
if (query.trim()) {
onSearch(query.trim())
setShowDropdown(false)
}
}
const handleClear = () => {
setQuery('')
setSuggestions(POPULAR_THEMES)
setShowDropdown(false)
setSelectedIndex(-1)
onClear()
inputRef.current?.focus()
}
const handleFocus = () => {
if (query.length === 0) {
setSuggestions(POPULAR_THEMES)
}
setShowDropdown(true)
}
return (
<div className="relative">
{/* Search Input */}
<div className="relative flex items-center">
<div className="absolute left-4 text-slate-400">
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Search className="w-5 h-5" />
)}
</div>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setSelectedIndex(-1)
}}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
placeholder={placeholder}
className="w-full pl-12 pr-24 py-3 text-lg border border-slate-300 rounded-xl
focus:ring-2 focus:ring-blue-500 focus:border-transparent
bg-white shadow-sm"
/>
<div className="absolute right-2 flex items-center gap-2">
{query && (
<button
onClick={handleClear}
className="p-1.5 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-lg"
title="Suche loeschen"
>
<X className="w-4 h-4" />
</button>
)}
<button
onClick={handleSearch}
disabled={!query.trim()}
className="px-4 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700
disabled:opacity-50 disabled:cursor-not-allowed text-sm font-medium"
>
Suchen
</button>
</div>
</div>
{/* Suggestions Dropdown */}
{showDropdown && suggestions.length > 0 && (
<div
ref={dropdownRef}
className="absolute top-full left-0 right-0 mt-2 bg-white rounded-xl border border-slate-200
shadow-lg z-50 max-h-80 overflow-y-auto"
>
<div className="p-2">
{query.length === 0 && (
<div className="px-3 py-2 text-xs font-medium text-slate-500 uppercase tracking-wide">
Beliebte Themen
</div>
)}
{suggestions.map((suggestion, index) => (
<button
key={`${suggestion.aufgabentyp}-${suggestion.label}`}
onClick={() => handleSelectSuggestion(suggestion)}
className={`w-full px-3 py-2.5 text-left rounded-lg flex items-center justify-between
transition-colors ${
index === selectedIndex
? 'bg-blue-50 text-blue-700'
: 'hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-3">
<Search className="w-4 h-4 text-slate-400" />
<div>
<div className="font-medium text-slate-800">{suggestion.label}</div>
{suggestion.kategorie && (
<div className="text-xs text-slate-500">{suggestion.kategorie}</div>
)}
</div>
</div>
<span className="text-sm text-slate-400">
{suggestion.count} Dokumente
</span>
</button>
))}
</div>
</div>
)}
{/* Quick Theme Tags */}
{!showDropdown && query.length === 0 && (
<div className="mt-3 flex flex-wrap gap-2">
<span className="text-sm text-slate-500">Vorschlaege:</span>
{POPULAR_THEMES.slice(0, 5).map((theme) => (
<button
key={theme.aufgabentyp}
onClick={() => handleSelectSuggestion(theme)}
className="px-3 py-1 text-sm bg-slate-100 text-slate-700 rounded-full
hover:bg-slate-200 transition-colors"
>
{theme.label}
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,516 @@
'use client'
/**
* Abitur-Archiv - Hauptseite
* Zentralabitur-Materialien 2021-2025 mit erweiterter Themensuche
*/
import { useState, useEffect, useCallback } from 'react'
import {
FileText, Filter, ChevronLeft, ChevronRight, Eye, Download, Search,
X, Loader2, Grid, List, LayoutGrid, BarChart3, Archive
} from 'lucide-react'
import type { AbiturDokument, AbiturDocsResponse } from '@/lib/education/abitur-docs-types'
import {
formatFileSize,
FAECHER,
JAHRE,
BUNDESLAENDER,
NIVEAUS,
TYPEN,
} from '@/lib/education/abitur-docs-types'
import type { ViewMode, ThemaSuggestion } from '@/lib/education/abitur-archiv-types'
import { ThemenSuche } from './components/ThemenSuche'
import { DokumentCard } from './components/DokumentCard'
import { FullscreenViewer } from './components/FullscreenViewer'
export default function AbiturArchivPage() {
// Documents state
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
// View mode
const [viewMode, setViewMode] = useState<ViewMode>('grid')
// 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>('')
// Theme search
const [searchQuery, setSearchQuery] = useState<string>('')
const [themes, setThemes] = useState<ThemaSuggestion[]>([])
// Modal
const [selectedDocument, setSelectedDocument] = useState<AbiturDokument | null>(null)
// Stats
const [stats, setStats] = useState({ total: 0, indexed: 0, faecher: 0 })
// Fetch documents
const fetchDocuments = useCallback(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)
if (searchQuery) params.set('thema', searchQuery)
try {
const response = await fetch(`/api/education/abitur-archiv?${params.toString()}`)
if (!response.ok) throw new Error('Fehler beim Laden der Dokumente')
const data = await response.json()
setDocuments(data.documents || [])
setTotalPages(data.total_pages || 1)
setTotal(data.total || 0)
setThemes(data.themes || [])
// Update stats
const indexed = (data.documents || []).filter((d: AbiturDokument) => d.status === 'indexed').length
const uniqueFaecher = new Set((data.documents || []).map((d: AbiturDokument) => d.fach)).size
setStats({ total: data.total || 0, indexed, faecher: uniqueFaecher })
} catch (err) {
setError(err instanceof Error ? err.message : 'Unbekannter Fehler')
} finally {
setLoading(false)
}
}, [page, filterFach, filterJahr, filterBundesland, filterNiveau, filterTyp, searchQuery])
useEffect(() => {
fetchDocuments()
}, [fetchDocuments])
const clearFilters = () => {
setFilterFach('')
setFilterJahr('')
setFilterBundesland('')
setFilterNiveau('')
setFilterTyp('')
setSearchQuery('')
setPage(1)
}
const handleSearch = (query: string) => {
setSearchQuery(query)
setPage(1)
}
const handleClearSearch = () => {
setSearchQuery('')
setPage(1)
}
const handleDownload = (doc: AbiturDokument) => {
const link = window.document.createElement('a')
link.href = doc.file_path
link.download = doc.dateiname
link.click()
}
const handleAddToKlausur = (doc: AbiturDokument) => {
// Navigate to klausur-korrektur with document reference
const params = new URLSearchParams()
params.set('archiv_doc_id', doc.id)
params.set('aufgabentyp', doc.typ === 'erwartungshorizont' ? 'vorlage' : 'aufgabe')
window.location.href = `/education/klausur-korrektur?${params.toString()}`
}
const hasActiveFilters = filterFach || filterJahr || filterBundesland || filterNiveau || filterTyp || searchQuery
return (
<div className="min-h-screen bg-slate-50">
{/* Header */}
<div className="bg-white border-b border-slate-200">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center">
<Archive className="w-6 h-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-900">Abitur-Archiv</h1>
<p className="text-sm text-slate-500">Zentralabitur-Materialien 2021-2025</p>
</div>
</div>
{/* Stats */}
<div className="hidden md:flex items-center gap-6">
<div className="text-center">
<div className="text-2xl font-bold text-slate-800">{stats.total}</div>
<div className="text-xs text-slate-500">Dokumente</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-green-600">{stats.indexed}</div>
<div className="text-xs text-slate-500">Indexiert</div>
</div>
<div className="text-center">
<div className="text-2xl font-bold text-blue-600">{stats.faecher}</div>
<div className="text-xs text-slate-500">Faecher</div>
</div>
</div>
</div>
</div>
</div>
{/* Main Content */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-6">
{/* Theme Search */}
<div className="bg-white rounded-xl border border-slate-200 p-6">
<ThemenSuche
onSearch={handleSearch}
onClear={handleClearSearch}
/>
</div>
{/* Filter Bar */}
<div className="bg-white rounded-xl border border-slate-200 p-4">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<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, searchQuery].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 zuruecksetzen
</button>
)}
</div>
<div className="flex items-center gap-3">
{/* Results count */}
<span className="text-sm text-slate-500">
{total} Treffer
</span>
{/* View Mode Toggle */}
<div className="flex bg-slate-100 rounded-lg p-1">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'grid' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Raster-Ansicht"
>
<LayoutGrid className="w-4 h-4" />
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-md transition-colors ${
viewMode === 'list' ? 'bg-white shadow-sm text-blue-600' : 'text-slate-500 hover:text-slate-700'
}`}
title="Listen-Ansicht"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
</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 Faecher</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 Bundeslaender</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>
{/* Active Search Query Display */}
{searchQuery && (
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border border-blue-200 rounded-lg">
<Search className="w-4 h-4 text-blue-600" />
<span className="text-sm text-blue-700">
Suche: <strong>{searchQuery}</strong>
</span>
<button
onClick={handleClearSearch}
className="ml-auto text-blue-600 hover:text-blue-800"
>
<X className="w-4 h-4" />
</button>
</div>
)}
{/* Document Display */}
<div className="bg-white rounded-xl border border-slate-200 overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-16">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin" />
</div>
) : error ? (
<div className="text-center py-16 text-red-600">
<p>{error}</p>
<button
onClick={() => fetchDocuments()}
className="mt-2 text-sm text-blue-600 hover:underline"
>
Erneut versuchen
</button>
</div>
) : documents.length === 0 ? (
<div className="text-center py-16 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 zuruecksetzen
</button>
)}
</div>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="p-4">
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{documents.map((doc) => (
<DokumentCard
key={doc.id}
document={doc}
onPreview={setSelectedDocument}
onDownload={handleDownload}
onAddToKlausur={handleAddToKlausur}
/>
))}
</div>
</div>
) : (
/* List View */
<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>
<button
onClick={() => handleDownload(doc)}
className="p-1.5 text-slate-600 hover:bg-slate-100 rounded"
title="Download"
>
<Download className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
)}
{/* Pagination */}
{documents.length > 0 && (
<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>
</div>
{/* Fullscreen Viewer Modal */}
<FullscreenViewer
document={selectedDocument}
onClose={() => setSelectedDocument(null)}
onAddToKlausur={handleAddToKlausur}
/>
</div>
)
}