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>
900 lines
38 KiB
TypeScript
900 lines
38 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useRouter } from 'next/navigation'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import { Sidebar } from '@/components/Sidebar'
|
|
import { ThemeToggle } from '@/components/ThemeToggle'
|
|
import { LanguageDropdown } from '@/components/LanguageDropdown'
|
|
import { QRCodeUpload, UploadedFile } from '@/components/QRCodeUpload'
|
|
|
|
// LocalStorage Key for upload session
|
|
const SESSION_ID_KEY = 'bp_cleanup_session'
|
|
|
|
/**
|
|
* Worksheet Cleanup Page - Apple Weather Dashboard Style
|
|
*
|
|
* Design principles:
|
|
* - Dark gradient background
|
|
* - Ultra-translucent glass cards (~8% opacity)
|
|
* - White text, monochrome palette
|
|
* - Step-by-step cleanup wizard
|
|
*/
|
|
|
|
// =============================================================================
|
|
// GLASS CARD - Ultra Transparent
|
|
// =============================================================================
|
|
|
|
interface GlassCardProps {
|
|
children: React.ReactNode
|
|
className?: string
|
|
onClick?: () => void
|
|
size?: 'sm' | 'md' | 'lg'
|
|
delay?: number
|
|
}
|
|
|
|
function GlassCard({ children, className = '', onClick, size = 'md', delay = 0, isDark = true }: GlassCardProps & { isDark?: boolean }) {
|
|
const [isVisible, setIsVisible] = useState(false)
|
|
const [isHovered, setIsHovered] = useState(false)
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => setIsVisible(true), delay)
|
|
return () => clearTimeout(timer)
|
|
}, [delay])
|
|
|
|
const sizeClasses = {
|
|
sm: 'p-4',
|
|
md: 'p-5',
|
|
lg: 'p-6',
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`
|
|
rounded-3xl
|
|
${sizeClasses[size]}
|
|
${onClick ? 'cursor-pointer' : ''}
|
|
${className}
|
|
`}
|
|
style={{
|
|
background: isDark
|
|
? (isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)')
|
|
: (isHovered ? 'rgba(255, 255, 255, 0.9)' : 'rgba(255, 255, 255, 0.7)'),
|
|
backdropFilter: 'blur(24px) saturate(180%)',
|
|
WebkitBackdropFilter: 'blur(24px) saturate(180%)',
|
|
border: isDark ? '1px solid rgba(255, 255, 255, 0.1)' : '1px solid rgba(0, 0, 0, 0.1)',
|
|
boxShadow: isDark
|
|
? '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)'
|
|
: '0 4px 24px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.5)',
|
|
opacity: isVisible ? 1 : 0,
|
|
transform: isVisible
|
|
? `translateY(0) scale(${isHovered ? 1.01 : 1})`
|
|
: 'translateY(20px)',
|
|
transition: 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
|
}}
|
|
onMouseEnter={() => setIsHovered(true)}
|
|
onMouseLeave={() => setIsHovered(false)}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// PROGRESS RING
|
|
// =============================================================================
|
|
|
|
interface ProgressRingProps {
|
|
progress: number
|
|
size?: number
|
|
strokeWidth?: number
|
|
label: string
|
|
value: string
|
|
color?: string
|
|
}
|
|
|
|
function ProgressRing({
|
|
progress,
|
|
size = 80,
|
|
strokeWidth = 6,
|
|
label,
|
|
value,
|
|
color = '#a78bfa'
|
|
}: ProgressRingProps) {
|
|
const radius = (size - strokeWidth) / 2
|
|
const circumference = radius * 2 * Math.PI
|
|
const offset = circumference - (progress / 100) * circumference
|
|
|
|
return (
|
|
<div className="flex flex-col items-center">
|
|
<div className="relative" style={{ width: size, height: size }}>
|
|
<svg className="transform -rotate-90" width={size} height={size}>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke="rgba(255, 255, 255, 0.1)"
|
|
strokeWidth={strokeWidth}
|
|
/>
|
|
<circle
|
|
cx={size / 2}
|
|
cy={size / 2}
|
|
r={radius}
|
|
fill="none"
|
|
stroke={color}
|
|
strokeWidth={strokeWidth}
|
|
strokeDasharray={circumference}
|
|
strokeDashoffset={offset}
|
|
strokeLinecap="round"
|
|
style={{ transition: 'stroke-dashoffset 0.5s ease' }}
|
|
/>
|
|
</svg>
|
|
<div className="absolute inset-0 flex items-center justify-center">
|
|
<span className="text-lg font-bold text-white">{value}</span>
|
|
</div>
|
|
</div>
|
|
<span className="mt-2 text-xs text-white/50">{label}</span>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// =============================================================================
|
|
// TYPES
|
|
// =============================================================================
|
|
|
|
interface PreviewResult {
|
|
has_handwriting: boolean
|
|
confidence: number
|
|
handwriting_ratio: number
|
|
image_width: number
|
|
image_height: number
|
|
estimated_times_ms: {
|
|
detection: number
|
|
inpainting: number
|
|
reconstruction: number
|
|
total: number
|
|
}
|
|
}
|
|
|
|
interface PipelineResult {
|
|
success: boolean
|
|
handwriting_detected: boolean
|
|
handwriting_removed: boolean
|
|
layout_reconstructed: boolean
|
|
cleaned_image_base64?: string
|
|
fabric_json?: any
|
|
metadata: any
|
|
}
|
|
|
|
// =============================================================================
|
|
// MAIN PAGE
|
|
// =============================================================================
|
|
|
|
export default function WorksheetCleanupPage() {
|
|
const { isDark } = useTheme()
|
|
const router = useRouter()
|
|
|
|
// File state
|
|
const [file, setFile] = useState<File | null>(null)
|
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null)
|
|
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
|
|
const [maskUrl, setMaskUrl] = useState<string | null>(null)
|
|
|
|
// Loading states
|
|
const [isPreviewing, setIsPreviewing] = useState(false)
|
|
const [isProcessing, setIsProcessing] = useState(false)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Results
|
|
const [previewResult, setPreviewResult] = useState<PreviewResult | null>(null)
|
|
const [pipelineResult, setPipelineResult] = useState<PipelineResult | null>(null)
|
|
|
|
// Options
|
|
const [removeHandwriting, setRemoveHandwriting] = useState(true)
|
|
const [reconstructLayout, setReconstructLayout] = useState(true)
|
|
const [inpaintingMethod, setInpaintingMethod] = useState<string>('auto')
|
|
|
|
// Step tracking
|
|
const [currentStep, setCurrentStep] = useState<'upload' | 'preview' | 'processing' | 'result'>('upload')
|
|
|
|
// QR Code Upload
|
|
const [showQRModal, setShowQRModal] = useState(false)
|
|
const [uploadSessionId, setUploadSessionId] = useState('')
|
|
const [mobileUploadedFiles, setMobileUploadedFiles] = useState<UploadedFile[]>([])
|
|
|
|
// Format file size
|
|
const formatFileSize = (bytes: number): string => {
|
|
if (bytes === 0) return '0 B'
|
|
const k = 1024
|
|
const sizes = ['B', 'KB', 'MB', 'GB']
|
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]
|
|
}
|
|
|
|
// Initialize upload session ID
|
|
useEffect(() => {
|
|
let storedSessionId = localStorage.getItem(SESSION_ID_KEY)
|
|
if (!storedSessionId) {
|
|
storedSessionId = `cleanup-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
|
localStorage.setItem(SESSION_ID_KEY, storedSessionId)
|
|
}
|
|
setUploadSessionId(storedSessionId)
|
|
}, [])
|
|
|
|
const getApiUrl = useCallback(() => {
|
|
if (typeof window === 'undefined') return 'http://localhost:8086'
|
|
const { hostname, protocol } = window.location
|
|
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
|
}, [])
|
|
|
|
// Handle file selection
|
|
const handleFileSelect = useCallback((selectedFile: File) => {
|
|
setFile(selectedFile)
|
|
setError(null)
|
|
setPreviewResult(null)
|
|
setPipelineResult(null)
|
|
setCleanedUrl(null)
|
|
setMaskUrl(null)
|
|
|
|
const url = URL.createObjectURL(selectedFile)
|
|
setPreviewUrl(url)
|
|
setCurrentStep('upload')
|
|
}, [])
|
|
|
|
// Handle mobile file selection - convert to File and trigger handleFileSelect
|
|
const handleMobileFileSelect = useCallback(async (uploadedFile: UploadedFile) => {
|
|
try {
|
|
const base64Data = uploadedFile.dataUrl.split(',')[1]
|
|
const byteCharacters = atob(base64Data)
|
|
const byteNumbers = new Array(byteCharacters.length)
|
|
for (let i = 0; i < byteCharacters.length; i++) {
|
|
byteNumbers[i] = byteCharacters.charCodeAt(i)
|
|
}
|
|
const byteArray = new Uint8Array(byteNumbers)
|
|
const blob = new Blob([byteArray], { type: uploadedFile.type })
|
|
const file = new File([blob], uploadedFile.name, { type: uploadedFile.type })
|
|
handleFileSelect(file)
|
|
setShowQRModal(false)
|
|
} catch (error) {
|
|
console.error('Failed to convert mobile file:', error)
|
|
setError('Fehler beim Laden der Datei vom Handy')
|
|
}
|
|
}, [handleFileSelect])
|
|
|
|
// Handle drop
|
|
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault()
|
|
const droppedFile = e.dataTransfer.files[0]
|
|
if (droppedFile && droppedFile.type.startsWith('image/')) {
|
|
handleFileSelect(droppedFile)
|
|
}
|
|
}, [handleFileSelect])
|
|
|
|
// Preview cleanup
|
|
const handlePreview = useCallback(async () => {
|
|
if (!file) return
|
|
|
|
setIsPreviewing(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('image', file)
|
|
|
|
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/preview-cleanup`, {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
|
|
const result = await response.json()
|
|
setPreviewResult(result)
|
|
setCurrentStep('preview')
|
|
} catch (err) {
|
|
console.error('Preview failed:', err)
|
|
setError(err instanceof Error ? err.message : 'Vorschau fehlgeschlagen')
|
|
} finally {
|
|
setIsPreviewing(false)
|
|
}
|
|
}, [file, getApiUrl])
|
|
|
|
// Run full cleanup pipeline
|
|
const handleCleanup = useCallback(async () => {
|
|
if (!file) return
|
|
|
|
setIsProcessing(true)
|
|
setCurrentStep('processing')
|
|
setError(null)
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('image', file)
|
|
formData.append('remove_handwriting', String(removeHandwriting))
|
|
formData.append('reconstruct', String(reconstructLayout))
|
|
formData.append('inpainting_method', inpaintingMethod)
|
|
|
|
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/cleanup-pipeline`, {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
|
throw new Error(errorData.detail || `HTTP ${response.status}`)
|
|
}
|
|
|
|
const result: PipelineResult = await response.json()
|
|
setPipelineResult(result)
|
|
|
|
// Create cleaned image URL
|
|
if (result.cleaned_image_base64) {
|
|
const cleanedBlob = await fetch(`data:image/png;base64,${result.cleaned_image_base64}`).then(r => r.blob())
|
|
setCleanedUrl(URL.createObjectURL(cleanedBlob))
|
|
}
|
|
|
|
setCurrentStep('result')
|
|
} catch (err) {
|
|
console.error('Cleanup failed:', err)
|
|
setError(err instanceof Error ? err.message : 'Bereinigung fehlgeschlagen')
|
|
setCurrentStep('preview')
|
|
} finally {
|
|
setIsProcessing(false)
|
|
}
|
|
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
|
|
|
|
// Get detection mask
|
|
const handleGetMask = useCallback(async () => {
|
|
if (!file) return
|
|
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('image', file)
|
|
|
|
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/detect-handwriting/mask`, {
|
|
method: 'POST',
|
|
body: formData
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`)
|
|
}
|
|
|
|
const blob = await response.blob()
|
|
setMaskUrl(URL.createObjectURL(blob))
|
|
} catch (err) {
|
|
console.error('Mask fetch failed:', err)
|
|
}
|
|
}, [file, getApiUrl])
|
|
|
|
// Open in worksheet editor
|
|
const handleOpenInEditor = useCallback(() => {
|
|
if (pipelineResult?.fabric_json) {
|
|
// Store the fabric JSON in sessionStorage
|
|
sessionStorage.setItem('worksheetCleanupResult', JSON.stringify(pipelineResult.fabric_json))
|
|
router.push('/worksheet-editor')
|
|
}
|
|
}, [pipelineResult, router])
|
|
|
|
// Reset to start
|
|
const handleReset = useCallback(() => {
|
|
setFile(null)
|
|
setPreviewUrl(null)
|
|
setCleanedUrl(null)
|
|
setMaskUrl(null)
|
|
setPreviewResult(null)
|
|
setPipelineResult(null)
|
|
setError(null)
|
|
setCurrentStep('upload')
|
|
}, [])
|
|
|
|
return (
|
|
<div className={`min-h-screen flex relative overflow-hidden ${
|
|
isDark
|
|
? 'bg-gradient-to-br from-indigo-900 via-purple-900 to-pink-800'
|
|
: 'bg-gradient-to-br from-slate-100 via-blue-50 to-indigo-100'
|
|
}`}>
|
|
{/* Animated Background Blobs */}
|
|
<div className={`absolute -top-40 -right-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${isDark ? 'bg-purple-500 opacity-30' : 'bg-purple-300 opacity-40'}`} />
|
|
<div className={`absolute top-1/2 -left-40 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-2000 ${isDark ? 'bg-pink-500 opacity-30' : 'bg-pink-300 opacity-40'}`} />
|
|
<div className={`absolute -bottom-40 right-1/3 w-96 h-96 rounded-full mix-blend-multiply filter blur-3xl animate-blob animation-delay-4000 ${isDark ? 'bg-blue-500 opacity-30' : 'bg-blue-300 opacity-40'}`} />
|
|
|
|
{/* Sidebar */}
|
|
<div className="relative z-10 p-4">
|
|
<Sidebar />
|
|
</div>
|
|
|
|
{/* Main Content */}
|
|
<div className="flex-1 flex flex-col relative z-10 p-6 overflow-y-auto">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-8">
|
|
<div>
|
|
<h1 className={`text-3xl font-bold mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>Arbeitsblatt bereinigen</h1>
|
|
<p className={isDark ? 'text-white/50' : 'text-slate-500'}>Handschrift entfernen und Layout rekonstruieren</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<ThemeToggle />
|
|
<LanguageDropdown />
|
|
</div>
|
|
</div>
|
|
|
|
{/* Step Indicator */}
|
|
<div className="flex items-center justify-center gap-4 mb-8">
|
|
{['upload', 'preview', 'processing', 'result'].map((step, idx) => (
|
|
<div key={step} className="flex items-center">
|
|
<div className={`
|
|
w-10 h-10 rounded-full flex items-center justify-center font-medium transition-all
|
|
${currentStep === step
|
|
? 'bg-purple-500 text-white shadow-lg shadow-purple-500/50'
|
|
: ['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
|
? 'bg-green-500 text-white'
|
|
: isDark ? 'bg-white/10 text-white/40' : 'bg-slate-200 text-slate-400'
|
|
}
|
|
`}>
|
|
{['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx ? (
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : (
|
|
idx + 1
|
|
)}
|
|
</div>
|
|
{idx < 3 && (
|
|
<div className={`w-16 h-0.5 mx-2 ${
|
|
['upload', 'preview', 'processing', 'result'].indexOf(currentStep) > idx
|
|
? 'bg-green-500'
|
|
: isDark ? 'bg-white/20' : 'bg-slate-300'
|
|
}`} />
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Error Display */}
|
|
{error && (
|
|
<GlassCard className="mb-6" size="sm" isDark={isDark}>
|
|
<div className="flex items-center gap-3 text-red-400">
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>{error}</span>
|
|
</div>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{/* Content based on step */}
|
|
<div className="flex-1">
|
|
{/* Step 1: Upload */}
|
|
{currentStep === 'upload' && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Upload Options - File and QR Code side by side */}
|
|
<GlassCard className="col-span-1" delay={100}>
|
|
<div
|
|
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
|
onDrop={handleDrop}
|
|
onDragOver={(e) => e.preventDefault()}
|
|
onClick={() => document.getElementById('file-input')?.click()}
|
|
>
|
|
<input
|
|
id="file-input"
|
|
type="file"
|
|
accept="image/*"
|
|
onChange={(e) => e.target.files?.[0] && handleFileSelect(e.target.files[0])}
|
|
className="hidden"
|
|
/>
|
|
{previewUrl ? (
|
|
<div className="space-y-4">
|
|
<img
|
|
src={previewUrl}
|
|
alt="Preview"
|
|
className="max-h-40 mx-auto rounded-xl shadow-2xl"
|
|
/>
|
|
<p className="text-white font-medium text-sm">{file?.name}</p>
|
|
<p className="text-white/50 text-xs">Klicke zum Ändern</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<svg className="w-16 h-16 mx-auto mb-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
|
</svg>
|
|
<p className="text-xl font-semibold text-white mb-2">Datei auswählen</p>
|
|
<p className="text-white/50 text-sm mb-2">Ziehe ein Bild hierher oder klicke</p>
|
|
<p className="text-white/30 text-xs">PNG, JPG, JPEG</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* QR Code Upload */}
|
|
<GlassCard className="col-span-1" delay={150}>
|
|
<div
|
|
className="border-2 border-dashed border-white/20 rounded-2xl p-8 text-center cursor-pointer transition-all hover:border-purple-400/50 hover:bg-white/5 min-h-[280px] flex flex-col items-center justify-center"
|
|
onClick={() => setShowQRModal(true)}
|
|
>
|
|
<div className="w-16 h-16 mx-auto mb-4 rounded-xl bg-purple-500/20 flex items-center justify-center">
|
|
<span className="text-3xl">📱</span>
|
|
</div>
|
|
<p className="text-xl font-semibold text-white mb-2">Mit Handy scannen</p>
|
|
<p className="text-white/50 text-sm mb-2">QR-Code scannen um Foto hochzuladen</p>
|
|
<p className="text-white/30 text-xs">Im lokalen Netzwerk</p>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Options */}
|
|
{file && (
|
|
<>
|
|
<GlassCard delay={200}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Optionen</h3>
|
|
<div className="space-y-4">
|
|
<label className="flex items-center gap-4 cursor-pointer group">
|
|
<input
|
|
type="checkbox"
|
|
checked={removeHandwriting}
|
|
onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
|
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
|
/>
|
|
<div>
|
|
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
|
Handschrift entfernen
|
|
</span>
|
|
<p className="text-white/40 text-sm">Erkennt und entfernt handgeschriebene Inhalte</p>
|
|
</div>
|
|
</label>
|
|
|
|
<label className="flex items-center gap-4 cursor-pointer group">
|
|
<input
|
|
type="checkbox"
|
|
checked={reconstructLayout}
|
|
onChange={(e) => setReconstructLayout(e.target.checked)}
|
|
className="w-5 h-5 rounded bg-white/10 border-white/20 text-purple-500 focus:ring-purple-500"
|
|
/>
|
|
<div>
|
|
<span className="text-white font-medium group-hover:text-purple-300 transition-colors">
|
|
Layout rekonstruieren
|
|
</span>
|
|
<p className="text-white/40 text-sm">Erstellt bearbeitbare Textblöcke</p>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
<GlassCard delay={300}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Methode</h3>
|
|
<select
|
|
value={inpaintingMethod}
|
|
onChange={(e) => setInpaintingMethod(e.target.value)}
|
|
className="w-full p-3 rounded-xl bg-white/10 border border-white/20 text-white focus:ring-2 focus:ring-purple-500 focus:border-transparent"
|
|
>
|
|
<option value="auto">Automatisch (empfohlen)</option>
|
|
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
|
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
|
</select>
|
|
<p className="text-white/40 text-sm mt-3">
|
|
Die automatische Methode wählt die beste Option basierend auf dem Bildinhalt.
|
|
</p>
|
|
</GlassCard>
|
|
|
|
{/* Action Button */}
|
|
<div className="col-span-1 lg:col-span-2 flex justify-center">
|
|
<button
|
|
onClick={handlePreview}
|
|
disabled={isPreviewing}
|
|
className="px-8 py-4 rounded-2xl font-semibold text-lg bg-gradient-to-r from-purple-500 to-pink-500 text-white hover:shadow-lg hover:shadow-purple-500/30 transition-all disabled:opacity-50 flex items-center gap-3"
|
|
>
|
|
{isPreviewing ? (
|
|
<>
|
|
<div className="w-5 h-5 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
|
Analysiere...
|
|
</>
|
|
) : (
|
|
<>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
|
|
</svg>
|
|
Vorschau
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 2: Preview */}
|
|
{currentStep === 'preview' && previewResult && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
{/* Stats */}
|
|
<GlassCard delay={100}>
|
|
<h3 className="text-lg font-semibold text-white mb-6">Analyse</h3>
|
|
<div className="flex justify-around">
|
|
<ProgressRing
|
|
progress={previewResult.confidence * 100}
|
|
label="Konfidenz"
|
|
value={`${Math.round(previewResult.confidence * 100)}%`}
|
|
color={previewResult.has_handwriting ? '#f97316' : '#22c55e'}
|
|
/>
|
|
<ProgressRing
|
|
progress={previewResult.handwriting_ratio * 100 * 10}
|
|
label="Handschrift"
|
|
value={`${(previewResult.handwriting_ratio * 100).toFixed(1)}%`}
|
|
color="#a78bfa"
|
|
/>
|
|
</div>
|
|
<div className={`mt-6 p-4 rounded-xl text-center ${
|
|
previewResult.has_handwriting
|
|
? 'bg-orange-500/20 text-orange-300'
|
|
: 'bg-green-500/20 text-green-300'
|
|
}`}>
|
|
{previewResult.has_handwriting
|
|
? 'Handschrift erkannt'
|
|
: 'Keine Handschrift gefunden'}
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Time Estimates */}
|
|
<GlassCard delay={200}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Geschätzte Zeit</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between text-white/70">
|
|
<span>Erkennung</span>
|
|
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s</span>
|
|
</div>
|
|
{removeHandwriting && previewResult.has_handwriting && (
|
|
<div className="flex justify-between text-white/70">
|
|
<span>Bereinigung</span>
|
|
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s</span>
|
|
</div>
|
|
)}
|
|
{reconstructLayout && (
|
|
<div className="flex justify-between text-white/70">
|
|
<span>Layout</span>
|
|
<span className="text-white">~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between pt-3 border-t border-white/10 font-medium">
|
|
<span className="text-white">Gesamt</span>
|
|
<span className="text-purple-300">~{Math.round(previewResult.estimated_times_ms.total / 1000)}s</span>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Image Info */}
|
|
<GlassCard delay={300}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Bild-Info</h3>
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between text-white/70">
|
|
<span>Breite</span>
|
|
<span className="text-white">{previewResult.image_width}px</span>
|
|
</div>
|
|
<div className="flex justify-between text-white/70">
|
|
<span>Höhe</span>
|
|
<span className="text-white">{previewResult.image_height}px</span>
|
|
</div>
|
|
<div className="flex justify-between text-white/70">
|
|
<span>Pixel</span>
|
|
<span className="text-white">{(previewResult.image_width * previewResult.image_height / 1000000).toFixed(1)}MP</span>
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={handleGetMask}
|
|
className="w-full mt-4 px-4 py-2 rounded-xl bg-white/10 text-white/70 hover:bg-white/20 hover:text-white transition-all text-sm"
|
|
>
|
|
Maske anzeigen
|
|
</button>
|
|
</GlassCard>
|
|
|
|
{/* Preview Images */}
|
|
<GlassCard className="col-span-1 lg:col-span-2" delay={400}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
|
{previewUrl && (
|
|
<img
|
|
src={previewUrl}
|
|
alt="Original"
|
|
className="w-full max-h-96 object-contain rounded-xl"
|
|
/>
|
|
)}
|
|
</GlassCard>
|
|
|
|
{maskUrl && (
|
|
<GlassCard delay={500}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Maske</h3>
|
|
<img
|
|
src={maskUrl}
|
|
alt="Mask"
|
|
className="w-full max-h-96 object-contain rounded-xl"
|
|
/>
|
|
</GlassCard>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="col-span-1 lg:col-span-3 flex justify-center gap-4">
|
|
<button
|
|
onClick={() => setCurrentStep('upload')}
|
|
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
</svg>
|
|
Zurück
|
|
</button>
|
|
<button
|
|
onClick={handleCleanup}
|
|
disabled={isProcessing}
|
|
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30 transition-all disabled:opacity-50 flex items-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
|
</svg>
|
|
Bereinigen starten
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 3: Processing */}
|
|
{currentStep === 'processing' && (
|
|
<div className="flex flex-col items-center justify-center py-20">
|
|
<GlassCard className="text-center max-w-md" delay={0}>
|
|
<div className="w-20 h-20 mx-auto mb-6 relative">
|
|
<div className="absolute inset-0 rounded-full border-4 border-white/10"></div>
|
|
<div className="absolute inset-0 rounded-full border-4 border-purple-500 border-t-transparent animate-spin"></div>
|
|
</div>
|
|
<h3 className="text-xl font-semibold text-white mb-2">Verarbeite Bild...</h3>
|
|
<p className="text-white/50">
|
|
{removeHandwriting ? 'Handschrift wird erkannt und entfernt' : 'Bild wird analysiert'}
|
|
</p>
|
|
</GlassCard>
|
|
</div>
|
|
)}
|
|
|
|
{/* Step 4: Result */}
|
|
{currentStep === 'result' && pipelineResult && (
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Status */}
|
|
<GlassCard className="col-span-1 lg:col-span-2" delay={100}>
|
|
<div className={`flex items-center gap-4 ${
|
|
pipelineResult.success ? 'text-green-300' : 'text-red-300'
|
|
}`}>
|
|
<div className={`w-12 h-12 rounded-full flex items-center justify-center ${
|
|
pipelineResult.success ? 'bg-green-500/20' : 'bg-red-500/20'
|
|
}`}>
|
|
{pipelineResult.success ? (
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
) : (
|
|
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
)}
|
|
</div>
|
|
<div>
|
|
<h3 className="text-xl font-semibold">
|
|
{pipelineResult.success ? 'Erfolgreich bereinigt!' : 'Verarbeitung fehlgeschlagen'}
|
|
</h3>
|
|
<p className="text-white/50">
|
|
{pipelineResult.handwriting_removed
|
|
? `Handschrift wurde entfernt. ${pipelineResult.metadata?.layout?.element_count || 0} Elemente erkannt.`
|
|
: pipelineResult.handwriting_detected
|
|
? 'Handschrift erkannt, aber nicht entfernt'
|
|
: 'Keine Handschrift im Bild gefunden'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</GlassCard>
|
|
|
|
{/* Original */}
|
|
<GlassCard delay={200}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Original</h3>
|
|
{previewUrl && (
|
|
<img
|
|
src={previewUrl}
|
|
alt="Original"
|
|
className="w-full rounded-xl"
|
|
/>
|
|
)}
|
|
</GlassCard>
|
|
|
|
{/* Cleaned */}
|
|
<GlassCard delay={300}>
|
|
<h3 className="text-lg font-semibold text-white mb-4">Bereinigt</h3>
|
|
{cleanedUrl ? (
|
|
<img
|
|
src={cleanedUrl}
|
|
alt="Cleaned"
|
|
className="w-full rounded-xl"
|
|
/>
|
|
) : (
|
|
<div className="aspect-video rounded-xl bg-white/5 flex items-center justify-center text-white/40">
|
|
Kein Bild
|
|
</div>
|
|
)}
|
|
</GlassCard>
|
|
|
|
{/* Actions */}
|
|
<div className="col-span-1 lg:col-span-2 flex justify-center gap-4">
|
|
<button
|
|
onClick={handleReset}
|
|
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
|
</svg>
|
|
Neues Bild
|
|
</button>
|
|
{cleanedUrl && (
|
|
<a
|
|
href={cleanedUrl}
|
|
download="bereinigt.png"
|
|
className="px-6 py-3 rounded-xl bg-white/10 text-white hover:bg-white/20 transition-all flex items-center gap-2"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
Download
|
|
</a>
|
|
)}
|
|
{pipelineResult.layout_reconstructed && pipelineResult.fabric_json && (
|
|
<button
|
|
onClick={handleOpenInEditor}
|
|
className="px-8 py-3 rounded-xl font-semibold bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30 transition-all flex items-center gap-2"
|
|
>
|
|
<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>
|
|
Im Editor öffnen
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* QR Code Modal */}
|
|
{showQRModal && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
|
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={() => setShowQRModal(false)} />
|
|
<div className="relative w-full max-w-md rounded-3xl bg-slate-900">
|
|
<QRCodeUpload
|
|
sessionId={uploadSessionId}
|
|
onClose={() => setShowQRModal(false)}
|
|
onFilesChanged={(files) => {
|
|
setMobileUploadedFiles(files)
|
|
}}
|
|
/>
|
|
{/* Select button for mobile files */}
|
|
{mobileUploadedFiles.length > 0 && (
|
|
<div className="p-4 border-t border-white/10">
|
|
<p className="text-white/60 text-sm mb-3">Datei auswählen:</p>
|
|
<div className="space-y-2 max-h-40 overflow-y-auto">
|
|
{mobileUploadedFiles.map((file) => (
|
|
<button
|
|
key={file.id}
|
|
onClick={() => handleMobileFileSelect(file)}
|
|
className="w-full flex items-center gap-3 p-3 rounded-xl text-left transition-all bg-white/5 hover:bg-white/10 border border-white/10"
|
|
>
|
|
<span className="text-xl">{file.type.startsWith('image/') ? '🖼️' : '📄'}</span>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-white font-medium truncate">{file.name}</p>
|
|
<p className="text-white/50 text-xs">{formatFileSize(file.size)}</p>
|
|
</div>
|
|
<span className="text-purple-400 text-sm">Verwenden →</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|