Files
breakpilot-lehrer/admin-lehrer/app/(admin)/education/abitur-archiv/components/FullscreenViewer.tsx
Benjamin Boenisch 5a31f52310 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>
2026-02-11 23:47:26 +01:00

457 lines
18 KiB
TypeScript

'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>
)
}