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>
222 lines
7.8 KiB
TypeScript
222 lines
7.8 KiB
TypeScript
'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>
|
|
)
|
|
}
|