[split-required] Split 500-850 LOC files (batch 2)
backend-lehrer (10 files): - game/database.py (785 → 5), correction_api.py (683 → 4) - classroom_engine/antizipation.py (676 → 5) - llm_gateway schools/edu_search already done in prior batch klausur-service (12 files): - orientation_crop_api.py (694 → 5), pdf_export.py (677 → 4) - zeugnis_crawler.py (676 → 5), grid_editor_api.py (671 → 5) - eh_templates.py (658 → 5), mail/api.py (651 → 5) - qdrant_service.py (638 → 5), training_api.py (625 → 4) website (6 pages): - middleware (696 → 8), mail (733 → 6), consent (628 → 8) - compliance/risks (622 → 5), export (502 → 5), brandbook (629 → 7) studio-v2 (3 components): - B2BMigrationWizard (848 → 3), CleanupPanel (765 → 2) - dashboard-experimental (739 → 2) admin-lehrer (4 files): - uebersetzungen (769 → 4), manager (670 → 2) - ChunkBrowserQA (675 → 6), dsfa/page (674 → 5) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { usePerformance } from '@/lib/spatial-ui/PerformanceContext'
|
||||
|
||||
// =============================================================================
|
||||
// GLASS CARD - Ultra Transparent
|
||||
// =============================================================================
|
||||
|
||||
interface GlassCardProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
delay?: number
|
||||
}
|
||||
|
||||
export function GlassCard({ children, className = '', onClick, size = 'md', delay = 0 }: GlassCardProps) {
|
||||
const { settings } = usePerformance()
|
||||
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' }
|
||||
const blur = settings.enableBlur ? 24 * settings.blurIntensity : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`rounded-3xl ${sizeClasses[size]} ${onClick ? 'cursor-pointer' : ''} ${className}`}
|
||||
style={{
|
||||
background: isHovered ? 'rgba(255, 255, 255, 0.12)' : 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
|
||||
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANALOG CLOCK
|
||||
// =============================================================================
|
||||
|
||||
export function AnalogClock() {
|
||||
const [time, setTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const hours = time.getHours() % 12
|
||||
const minutes = time.getMinutes()
|
||||
const seconds = time.getSeconds()
|
||||
const hourDeg = (hours * 30) + (minutes * 0.5)
|
||||
const minuteDeg = minutes * 6
|
||||
const secondDeg = seconds * 6
|
||||
|
||||
return (
|
||||
<div className="relative w-32 h-32">
|
||||
<div className="absolute inset-0 rounded-full" style={{ background: 'rgba(255, 255, 255, 0.05)', border: '2px solid rgba(255, 255, 255, 0.15)' }}>
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div key={i} className="absolute w-1 h-3 bg-white/40 rounded-full" style={{ left: '50%', top: '8px', transform: `translateX(-50%) rotate(${i * 30}deg)`, transformOrigin: '50% 56px' }} />
|
||||
))}
|
||||
<div className="absolute w-1.5 h-10 bg-white rounded-full" style={{ left: '50%', bottom: '50%', transform: `translateX(-50%) rotate(${hourDeg}deg)`, transformOrigin: 'bottom center' }} />
|
||||
<div className="absolute w-1 h-14 bg-white/80 rounded-full" style={{ left: '50%', bottom: '50%', transform: `translateX(-50%) rotate(${minuteDeg}deg)`, transformOrigin: 'bottom center' }} />
|
||||
<div className="absolute w-0.5 h-14 bg-orange-400 rounded-full" style={{ left: '50%', bottom: '50%', transform: `translateX(-50%) rotate(${secondDeg}deg)`, transformOrigin: 'bottom center', transition: 'transform 0.1s ease-out' }} />
|
||||
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPASS
|
||||
// =============================================================================
|
||||
|
||||
export function Compass({ direction = 225 }: { direction?: number }) {
|
||||
return (
|
||||
<div className="relative w-24 h-24">
|
||||
<div className="absolute inset-0 rounded-full" style={{ background: 'rgba(255, 255, 255, 0.05)', border: '2px solid rgba(255, 255, 255, 0.15)' }}>
|
||||
<span className="absolute top-2 left-1/2 -translate-x-1/2 text-xs font-bold text-red-400">N</span>
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 text-xs font-medium text-white/50">S</span>
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">W</span>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">O</span>
|
||||
<div className="absolute inset-4" style={{ transform: `rotate(${direction}deg)`, transition: 'transform 0.5s ease-out' }}>
|
||||
<div className="absolute w-1.5 h-8 bg-gradient-to-t from-red-500 to-red-400 rounded-full" style={{ left: '50%', bottom: '50%', transform: 'translateX(-50%)', transformOrigin: 'bottom center' }} />
|
||||
<div className="absolute w-1.5 h-8 bg-gradient-to-b from-white/80 to-white/40 rounded-full" style={{ left: '50%', top: '50%', transform: 'translateX(-50%)', transformOrigin: 'top center' }} />
|
||||
</div>
|
||||
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BAR CHART
|
||||
// =============================================================================
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number; highlight?: boolean }[]
|
||||
maxValue?: number
|
||||
}
|
||||
|
||||
export function BarChart({ data, maxValue }: BarChartProps) {
|
||||
const max = maxValue || Math.max(...data.map((d) => d.value))
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-2 h-32">
|
||||
{data.map((item, index) => {
|
||||
const height = (item.value / max) * 100
|
||||
return (
|
||||
<div key={index} className="flex flex-col items-center gap-2 flex-1">
|
||||
<span className="text-xs text-white/60 font-medium">{item.value}</span>
|
||||
<div className="w-full rounded-lg transition-all duration-500" style={{
|
||||
height: `${height}%`, minHeight: 8,
|
||||
background: item.highlight ? 'linear-gradient(to top, rgba(96, 165, 250, 0.6), rgba(167, 139, 250, 0.6))' : 'rgba(255, 255, 255, 0.2)',
|
||||
boxShadow: item.highlight ? '0 0 20px rgba(139, 92, 246, 0.3)' : 'none',
|
||||
}} />
|
||||
<span className="text-xs text-white/40">{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TEMPERATURE DISPLAY
|
||||
// =============================================================================
|
||||
|
||||
export function TemperatureDisplay({ temp, condition }: { temp: number; condition: string }) {
|
||||
const conditionIcons: Record<string, string> = { sunny: '☀️', cloudy: '☁️', rainy: '🌧️', snowy: '🌨️', partly_cloudy: '⛅' }
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-2">{conditionIcons[condition] || '☀️'}</div>
|
||||
<div className="flex items-start justify-center">
|
||||
<span className="text-6xl font-extralight text-white">{temp}</span>
|
||||
<span className="text-2xl text-white/60 mt-2">°C</span>
|
||||
</div>
|
||||
<p className="text-white/50 text-sm mt-1 capitalize">{condition.replace('_', ' ')}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// PROGRESS RING
|
||||
// =============================================================================
|
||||
|
||||
interface ProgressRingProps {
|
||||
progress: number; size?: number; strokeWidth?: number; label: string; value: string; color?: string
|
||||
}
|
||||
|
||||
export 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 width={size} height={size} className="transform -rotate-90">
|
||||
<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} strokeLinecap="round" strokeDasharray={circumference} strokeDashoffset={offset} style={{ transition: 'stroke-dashoffset 1s ease-out' }} />
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-light text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/40 text-xs mt-2 font-medium uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT DISPLAY
|
||||
// =============================================================================
|
||||
|
||||
export function StatDisplay({ value, unit, label, icon }: { value: string; unit?: string; label: string; icon?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
{icon && <div className="text-2xl mb-2 opacity-80">{icon}</div>}
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="text-4xl font-light text-white">{value}</span>
|
||||
{unit && <span className="text-lg text-white/50 font-light">{unit}</span>}
|
||||
</div>
|
||||
<p className="text-white/40 text-xs mt-1 font-medium uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIST ITEM
|
||||
// =============================================================================
|
||||
|
||||
export function ListItem({ icon, title, subtitle, value, delay = 0 }: { icon: string; title: string; subtitle?: string; value?: string; delay?: number }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all"
|
||||
style={{ background: isHovered ? 'rgba(255, 255, 255, 0.06)' : 'transparent', opacity: isVisible ? 1 : 0, transform: isVisible ? 'translateX(0)' : 'translateX(-10px)', transition: 'all 0.3s ease-out' }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center text-xl" style={{ background: 'rgba(255,255,255,0.08)' }}>{icon}</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{title}</p>
|
||||
{subtitle && <p className="text-white/40 text-sm">{subtitle}</p>}
|
||||
</div>
|
||||
{value && <span className="text-white/50 font-medium">{value}</span>}
|
||||
<svg className="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ transform: isHovered ? 'translateX(2px)' : 'translateX(0)', transition: 'transform 0.2s' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACTION BUTTON
|
||||
// =============================================================================
|
||||
|
||||
export function ActionButton({ icon, label, primary = false, onClick, delay = 0 }: { icon: string; label: string; primary?: boolean; onClick?: () => void; delay?: number }) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isPressed, setIsPressed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center justify-center gap-3 p-4 rounded-2xl font-medium transition-all"
|
||||
style={{
|
||||
background: primary ? 'linear-gradient(135deg, rgba(96, 165, 250, 0.3), rgba(167, 139, 250, 0.3))' : 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)', color: 'white',
|
||||
opacity: isVisible ? 1 : 0, transform: isVisible ? `translateY(0) scale(${isPressed ? 0.97 : 1})` : 'translateY(10px)',
|
||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseDown={() => setIsPressed(true)} onMouseUp={() => setIsPressed(false)} onMouseLeave={() => setIsPressed(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QUALITY INDICATOR
|
||||
// =============================================================================
|
||||
|
||||
export function QualityIndicator() {
|
||||
const { metrics, forceQuality } = usePerformance()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-6 z-50" style={{
|
||||
background: 'rgba(0, 0, 0, 0.3)', backdropFilter: 'blur(20px)', WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)', borderRadius: 16,
|
||||
padding: isExpanded ? 16 : 12, minWidth: isExpanded ? 200 : 'auto', transition: 'all 0.3s ease-out',
|
||||
}}>
|
||||
<button onClick={() => setIsExpanded(!isExpanded)} className="flex items-center gap-3 text-white/70 text-sm">
|
||||
<span className={`w-2 h-2 rounded-full ${metrics.qualityLevel === 'high' ? 'bg-green-400' : metrics.qualityLevel === 'medium' ? 'bg-yellow-400' : 'bg-red-400'}`} />
|
||||
<span className="font-mono">{metrics.fps} FPS</span>
|
||||
<span className="text-white/30">|</span>
|
||||
<span className="uppercase text-xs tracking-wide">{metrics.qualityLevel}</span>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10 space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{(['high', 'medium', 'low'] as const).map((level) => (
|
||||
<button key={level} onClick={() => forceQuality(level)} className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all ${metrics.qualityLevel === level ? 'bg-white/15 text-white' : 'bg-white/5 text-white/40 hover:bg-white/10'}`}>
|
||||
{level[0].toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,503 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
// Spatial UI System
|
||||
import { PerformanceProvider, usePerformance } from '@/lib/spatial-ui/PerformanceContext'
|
||||
import { FocusProvider } from '@/lib/spatial-ui/FocusContext'
|
||||
import { FloatingMessage } from '@/components/spatial-ui/FloatingMessage'
|
||||
|
||||
/**
|
||||
* Apple Weather Style Dashboard - Refined Version
|
||||
*
|
||||
* Design principles:
|
||||
* - Photo/gradient background that sets the mood
|
||||
* - Ultra-translucent cards (~8% opacity)
|
||||
* - Cards blend INTO the background
|
||||
* - White text, monochrome palette
|
||||
* - Subtle blur, minimal shadows
|
||||
* - Useful info: time, weather, compass
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// 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 }: GlassCardProps) {
|
||||
const { settings } = usePerformance()
|
||||
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',
|
||||
}
|
||||
|
||||
const blur = settings.enableBlur ? 24 * settings.blurIntensity : 0
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
rounded-3xl
|
||||
${sizeClasses[size]}
|
||||
${onClick ? 'cursor-pointer' : ''}
|
||||
${className}
|
||||
`}
|
||||
style={{
|
||||
background: isHovered
|
||||
? 'rgba(255, 255, 255, 0.12)'
|
||||
: 'rgba(255, 255, 255, 0.08)',
|
||||
backdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
|
||||
WebkitBackdropFilter: blur > 0 ? `blur(${blur}px) saturate(180%)` : 'none',
|
||||
border: '1px solid rgba(255, 255, 255, 0.1)',
|
||||
boxShadow: '0 4px 24px rgba(0, 0, 0, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ANALOG CLOCK - Apple Style
|
||||
// =============================================================================
|
||||
|
||||
function AnalogClock() {
|
||||
const [time, setTime] = useState(new Date())
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTime(new Date()), 1000)
|
||||
return () => clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
const hours = time.getHours() % 12
|
||||
const minutes = time.getMinutes()
|
||||
const seconds = time.getSeconds()
|
||||
|
||||
const hourDeg = (hours * 30) + (minutes * 0.5)
|
||||
const minuteDeg = minutes * 6
|
||||
const secondDeg = seconds * 6
|
||||
|
||||
return (
|
||||
<div className="relative w-32 h-32">
|
||||
{/* Clock face */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.15)',
|
||||
}}
|
||||
>
|
||||
{/* Hour markers */}
|
||||
{[...Array(12)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="absolute w-1 h-3 bg-white/40 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '8px',
|
||||
transform: `translateX(-50%) rotate(${i * 30}deg)`,
|
||||
transformOrigin: '50% 56px',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Hour hand */}
|
||||
<div
|
||||
className="absolute w-1.5 h-10 bg-white rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: `translateX(-50%) rotate(${hourDeg}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Minute hand */}
|
||||
<div
|
||||
className="absolute w-1 h-14 bg-white/80 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: `translateX(-50%) rotate(${minuteDeg}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Second hand */}
|
||||
<div
|
||||
className="absolute w-0.5 h-14 bg-orange-400 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: `translateX(-50%) rotate(${secondDeg}deg)`,
|
||||
transformOrigin: 'bottom center',
|
||||
transition: 'transform 0.1s ease-out',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Center dot */}
|
||||
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// COMPASS - Apple Weather Style
|
||||
// =============================================================================
|
||||
|
||||
function Compass({ direction = 225 }: { direction?: number }) {
|
||||
return (
|
||||
<div className="relative w-24 h-24">
|
||||
{/* Compass face */}
|
||||
<div
|
||||
className="absolute inset-0 rounded-full"
|
||||
style={{
|
||||
background: 'rgba(255, 255, 255, 0.05)',
|
||||
border: '2px solid rgba(255, 255, 255, 0.15)',
|
||||
}}
|
||||
>
|
||||
{/* Cardinal directions */}
|
||||
<span className="absolute top-2 left-1/2 -translate-x-1/2 text-xs font-bold text-red-400">N</span>
|
||||
<span className="absolute bottom-2 left-1/2 -translate-x-1/2 text-xs font-medium text-white/50">S</span>
|
||||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">W</span>
|
||||
<span className="absolute right-2 top-1/2 -translate-y-1/2 text-xs font-medium text-white/50">O</span>
|
||||
|
||||
{/* Needle */}
|
||||
<div
|
||||
className="absolute inset-4"
|
||||
style={{
|
||||
transform: `rotate(${direction}deg)`,
|
||||
transition: 'transform 0.5s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* North (red) */}
|
||||
<div
|
||||
className="absolute w-1.5 h-8 bg-gradient-to-t from-red-500 to-red-400 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
bottom: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
{/* South (white) */}
|
||||
<div
|
||||
className="absolute w-1.5 h-8 bg-gradient-to-b from-white/80 to-white/40 rounded-full"
|
||||
style={{
|
||||
left: '50%',
|
||||
top: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
transformOrigin: 'top center',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Center */}
|
||||
<div className="absolute w-3 h-3 bg-white rounded-full left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// BAR CHART - Apple Weather Hourly Style
|
||||
// =============================================================================
|
||||
|
||||
interface BarChartProps {
|
||||
data: { label: string; value: number; highlight?: boolean }[]
|
||||
maxValue?: number
|
||||
}
|
||||
|
||||
function BarChart({ data, maxValue }: BarChartProps) {
|
||||
const max = maxValue || Math.max(...data.map((d) => d.value))
|
||||
|
||||
return (
|
||||
<div className="flex items-end justify-between gap-2 h-32">
|
||||
{data.map((item, index) => {
|
||||
const height = (item.value / max) * 100
|
||||
return (
|
||||
<div key={index} className="flex flex-col items-center gap-2 flex-1">
|
||||
<span className="text-xs text-white/60 font-medium">{item.value}</span>
|
||||
<div
|
||||
className="w-full rounded-lg transition-all duration-500"
|
||||
style={{
|
||||
height: `${height}%`,
|
||||
minHeight: 8,
|
||||
background: item.highlight
|
||||
? 'linear-gradient(to top, rgba(96, 165, 250, 0.6), rgba(167, 139, 250, 0.6))'
|
||||
: 'rgba(255, 255, 255, 0.2)',
|
||||
boxShadow: item.highlight ? '0 0 20px rgba(139, 92, 246, 0.3)' : 'none',
|
||||
}}
|
||||
/>
|
||||
<span className="text-xs text-white/40">{item.label}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TEMPERATURE DISPLAY
|
||||
// =============================================================================
|
||||
|
||||
function TemperatureDisplay({ temp, condition }: { temp: number; condition: string }) {
|
||||
const conditionIcons: Record<string, string> = {
|
||||
sunny: '☀️',
|
||||
cloudy: '☁️',
|
||||
rainy: '🌧️',
|
||||
snowy: '🌨️',
|
||||
partly_cloudy: '⛅',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-6xl mb-2">{conditionIcons[condition] || '☀️'}</div>
|
||||
<div className="flex items-start justify-center">
|
||||
<span className="text-6xl font-extralight text-white">{temp}</span>
|
||||
<span className="text-2xl text-white/60 mt-2">°C</span>
|
||||
</div>
|
||||
<p className="text-white/50 text-sm mt-1 capitalize">
|
||||
{condition.replace('_', ' ')}
|
||||
</p>
|
||||
</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 width={size} height={size} className="transform -rotate-90">
|
||||
<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}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={offset}
|
||||
style={{ transition: 'stroke-dashoffset 1s ease-out' }}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-lg font-light text-white">{value}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-white/40 text-xs mt-2 font-medium uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// STAT DISPLAY
|
||||
// =============================================================================
|
||||
|
||||
function StatDisplay({ value, unit, label, icon }: { value: string; unit?: string; label: string; icon?: string }) {
|
||||
return (
|
||||
<div className="text-center">
|
||||
{icon && <div className="text-2xl mb-2 opacity-80">{icon}</div>}
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="text-4xl font-light text-white">{value}</span>
|
||||
{unit && <span className="text-lg text-white/50 font-light">{unit}</span>}
|
||||
</div>
|
||||
<p className="text-white/40 text-xs mt-1 font-medium uppercase tracking-wide">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// LIST ITEM
|
||||
// =============================================================================
|
||||
|
||||
function ListItem({ icon, title, subtitle, value, delay = 0 }: {
|
||||
icon: string; title: string; subtitle?: string; value?: string; delay?: number
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isHovered, setIsHovered] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all"
|
||||
style={{
|
||||
background: isHovered ? 'rgba(255, 255, 255, 0.06)' : 'transparent',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? 'translateX(0)' : 'translateX(-10px)',
|
||||
transition: 'all 0.3s ease-out',
|
||||
}}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-white/8 flex items-center justify-center text-xl"
|
||||
style={{ background: 'rgba(255,255,255,0.08)' }}>
|
||||
{icon}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-white font-medium">{title}</p>
|
||||
{subtitle && <p className="text-white/40 text-sm">{subtitle}</p>}
|
||||
</div>
|
||||
{value && <span className="text-white/50 font-medium">{value}</span>}
|
||||
<svg className="w-4 h-4 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24"
|
||||
style={{ transform: isHovered ? 'translateX(2px)' : 'translateX(0)', transition: 'transform 0.2s' }}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACTION BUTTON
|
||||
// =============================================================================
|
||||
|
||||
function ActionButton({ icon, label, primary = false, onClick, delay = 0 }: {
|
||||
icon: string; label: string; primary?: boolean; onClick?: () => void; delay?: number
|
||||
}) {
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [isPressed, setIsPressed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), delay)
|
||||
return () => clearTimeout(timer)
|
||||
}, [delay])
|
||||
|
||||
return (
|
||||
<button
|
||||
className="w-full flex items-center justify-center gap-3 p-4 rounded-2xl font-medium transition-all"
|
||||
style={{
|
||||
background: primary
|
||||
? 'linear-gradient(135deg, rgba(96, 165, 250, 0.3), rgba(167, 139, 250, 0.3))'
|
||||
: 'rgba(255, 255, 255, 0.06)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
color: 'white',
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transform: isVisible ? `translateY(0) scale(${isPressed ? 0.97 : 1})` : 'translateY(10px)',
|
||||
transition: 'all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
}}
|
||||
onMouseDown={() => setIsPressed(true)}
|
||||
onMouseUp={() => setIsPressed(false)}
|
||||
onMouseLeave={() => setIsPressed(false)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-xl">{icon}</span>
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// QUALITY INDICATOR
|
||||
// =============================================================================
|
||||
|
||||
function QualityIndicator() {
|
||||
const { metrics, settings, forceQuality } = usePerformance()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-6 left-6 z-50"
|
||||
style={{
|
||||
background: 'rgba(0, 0, 0, 0.3)',
|
||||
backdropFilter: 'blur(20px)',
|
||||
WebkitBackdropFilter: 'blur(20px)',
|
||||
border: '1px solid rgba(255, 255, 255, 0.08)',
|
||||
borderRadius: 16,
|
||||
padding: isExpanded ? 16 : 12,
|
||||
minWidth: isExpanded ? 200 : 'auto',
|
||||
transition: 'all 0.3s ease-out',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="flex items-center gap-3 text-white/70 text-sm"
|
||||
>
|
||||
<span className={`w-2 h-2 rounded-full ${
|
||||
metrics.qualityLevel === 'high' ? 'bg-green-400' :
|
||||
metrics.qualityLevel === 'medium' ? 'bg-yellow-400' : 'bg-red-400'
|
||||
}`} />
|
||||
<span className="font-mono">{metrics.fps} FPS</span>
|
||||
<span className="text-white/30">|</span>
|
||||
<span className="uppercase text-xs tracking-wide">{metrics.qualityLevel}</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4 pt-4 border-t border-white/10 space-y-2">
|
||||
<div className="flex gap-1">
|
||||
{(['high', 'medium', 'low'] as const).map((level) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => forceQuality(level)}
|
||||
className={`flex-1 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
metrics.qualityLevel === level
|
||||
? 'bg-white/15 text-white'
|
||||
: 'bg-white/5 text-white/40 hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
{level[0].toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import {
|
||||
GlassCard, AnalogClock, Compass, BarChart, TemperatureDisplay,
|
||||
ProgressRing, StatDisplay, ListItem, ActionButton, QualityIndicator,
|
||||
} from './_components/DashboardWidgets'
|
||||
|
||||
// =============================================================================
|
||||
// MAIN DASHBOARD
|
||||
@@ -529,15 +41,10 @@ function DashboardContent() {
|
||||
|
||||
const greeting = time.getHours() < 12 ? 'Guten Morgen' : time.getHours() < 18 ? 'Guten Tag' : 'Guten Abend'
|
||||
|
||||
// Weekly correction data
|
||||
const weeklyData = [
|
||||
{ label: 'Mo', value: 4, highlight: false },
|
||||
{ label: 'Di', value: 7, highlight: false },
|
||||
{ label: 'Mi', value: 3, highlight: false },
|
||||
{ label: 'Do', value: 8, highlight: false },
|
||||
{ label: 'Fr', value: 6, highlight: true },
|
||||
{ label: 'Sa', value: 2, highlight: false },
|
||||
{ label: 'So', value: 0, highlight: false },
|
||||
{ label: 'Mo', value: 4 }, { label: 'Di', value: 7 }, { label: 'Mi', value: 3 },
|
||||
{ label: 'Do', value: 8 }, { label: 'Fr', value: 6, highlight: true },
|
||||
{ label: 'Sa', value: 2 }, { label: 'So', value: 0 },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -545,40 +52,25 @@ function DashboardContent() {
|
||||
{/* Background */}
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-slate-900 via-indigo-950 to-slate-900"
|
||||
style={{
|
||||
transform: `translate(${parallax.x * 0.5}px, ${parallax.y * 0.5}px) scale(1.05)`,
|
||||
transition: 'transform 0.3s ease-out',
|
||||
}}
|
||||
style={{ transform: `translate(${parallax.x * 0.5}px, ${parallax.y * 0.5}px) scale(1.05)`, transition: 'transform 0.3s ease-out' }}
|
||||
>
|
||||
{/* Stars */}
|
||||
<div className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(2px 2px at 20px 30px, white, transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, white, transparent),
|
||||
radial-gradient(2px 2px at 160px 120px, rgba(255,255,255,0.9), transparent),
|
||||
radial-gradient(1px 1px at 230px 80px, white, transparent),
|
||||
radial-gradient(2px 2px at 300px 150px, rgba(255,255,255,0.7), transparent)`,
|
||||
backgroundSize: '400px 200px',
|
||||
}}
|
||||
/>
|
||||
{/* Ambient glows */}
|
||||
<div className="absolute w-[500px] h-[500px] rounded-full opacity-20"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, transparent 70%)',
|
||||
left: '10%', top: '20%',
|
||||
transform: `translate(${parallax.x}px, ${parallax.y}px)`,
|
||||
transition: 'transform 0.5s ease-out',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute w-[400px] h-[400px] rounded-full opacity-15"
|
||||
style={{
|
||||
background: 'radial-gradient(circle, rgba(167, 139, 250, 0.5) 0%, transparent 70%)',
|
||||
right: '5%', bottom: '10%',
|
||||
transform: `translate(${-parallax.x * 0.8}px, ${-parallax.y * 0.8}px)`,
|
||||
transition: 'transform 0.5s ease-out',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 opacity-30" style={{
|
||||
backgroundImage: `radial-gradient(2px 2px at 20px 30px, white, transparent),
|
||||
radial-gradient(2px 2px at 40px 70px, rgba(255,255,255,0.8), transparent),
|
||||
radial-gradient(1px 1px at 90px 40px, white, transparent),
|
||||
radial-gradient(2px 2px at 160px 120px, rgba(255,255,255,0.9), transparent),
|
||||
radial-gradient(1px 1px at 230px 80px, white, transparent),
|
||||
radial-gradient(2px 2px at 300px 150px, rgba(255,255,255,0.7), transparent)`,
|
||||
backgroundSize: '400px 200px',
|
||||
}} />
|
||||
<div className="absolute w-[500px] h-[500px] rounded-full opacity-20" style={{
|
||||
background: 'radial-gradient(circle, rgba(99, 102, 241, 0.5) 0%, transparent 70%)',
|
||||
left: '10%', top: '20%', transform: `translate(${parallax.x}px, ${parallax.y}px)`, transition: 'transform 0.5s ease-out',
|
||||
}} />
|
||||
<div className="absolute w-[400px] h-[400px] rounded-full opacity-15" style={{
|
||||
background: 'radial-gradient(circle, rgba(167, 139, 250, 0.5) 0%, transparent 70%)',
|
||||
right: '5%', bottom: '10%', transform: `translate(${-parallax.x * 0.8}px, ${-parallax.y * 0.8}px)`, transition: 'transform 0.5s ease-out',
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
@@ -606,8 +98,6 @@ function DashboardContent() {
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="grid grid-cols-12 gap-4 max-w-7xl mx-auto">
|
||||
|
||||
{/* Clock & Weather Row */}
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={50}>
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -618,13 +108,9 @@ function DashboardContent() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={100}>
|
||||
<TemperatureDisplay temp={8} condition="partly_cloudy" />
|
||||
</GlassCard>
|
||||
<GlassCard size="lg" delay={100}><TemperatureDisplay temp={8} condition="partly_cloudy" /></GlassCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={150}>
|
||||
<div className="flex flex-col items-center">
|
||||
@@ -634,24 +120,16 @@ function DashboardContent() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={200}>
|
||||
<StatDisplay icon="📋" value="12" label="Offene Korrekturen" />
|
||||
<div className="mt-4 pt-4 border-t border-white/10 flex justify-around">
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-light text-white">28</p>
|
||||
<p className="text-white/40 text-xs">Diese Woche</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-xl font-light text-white">156</p>
|
||||
<p className="text-white/40 text-xs">Gesamt</p>
|
||||
</div>
|
||||
<div className="text-center"><p className="text-xl font-light text-white">28</p><p className="text-white/40 text-xs">Diese Woche</p></div>
|
||||
<div className="text-center"><p className="text-xl font-light text-white">156</p><p className="text-white/40 text-xs">Gesamt</p></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Bar Chart */}
|
||||
<div className="col-span-6">
|
||||
<GlassCard size="lg" delay={250}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
@@ -662,7 +140,6 @@ function DashboardContent() {
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Progress Rings */}
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={300}>
|
||||
<div className="flex justify-around">
|
||||
@@ -671,8 +148,6 @@ function DashboardContent() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Time Saved */}
|
||||
<div className="col-span-3">
|
||||
<GlassCard size="lg" delay={350}>
|
||||
<StatDisplay icon="⏱" value="4.2" unit="h" label="Zeit gespart" />
|
||||
@@ -680,7 +155,6 @@ function DashboardContent() {
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Klausuren List */}
|
||||
<div className="col-span-8">
|
||||
<GlassCard size="lg" delay={400}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
@@ -695,7 +169,6 @@ function DashboardContent() {
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="col-span-4">
|
||||
<GlassCard size="lg" delay={450}>
|
||||
<h2 className="text-white text-sm font-medium uppercase tracking-wide opacity-60 mb-4">Schnellaktionen</h2>
|
||||
@@ -706,19 +179,10 @@ function DashboardContent() {
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Floating Messages */}
|
||||
<FloatingMessage
|
||||
autoDismissMs={12000}
|
||||
maxQueue={3}
|
||||
position="top-right"
|
||||
offset={{ x: 24, y: 24 }}
|
||||
/>
|
||||
|
||||
{/* Quality Indicator */}
|
||||
<FloatingMessage autoDismissMs={12000} maxQueue={3} position="top-right" offset={{ x: 24, y: 24 }} />
|
||||
<QualityIndicator />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useAlertsB2B, B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
|
||||
import { InfoBox, TipBox, StepBox } from './InfoBox'
|
||||
import { useAlertsB2B, Package } from '@/lib/AlertsB2BContext'
|
||||
import { WizardStep1, WizardStep2, WizardStep3 } from './B2BWizardSteps'
|
||||
import { WizardStep4, WizardStep5 } from './B2BWizardDetails'
|
||||
import type { MigrationMethod } from './B2BWizardSteps'
|
||||
|
||||
interface B2BMigrationWizardProps {
|
||||
onComplete: () => void
|
||||
@@ -11,8 +13,6 @@ interface B2BMigrationWizardProps {
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
type MigrationMethod = 'email' | 'rss' | 'reconstruct' | null
|
||||
|
||||
export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigrationWizardProps) {
|
||||
const { isDark } = useTheme()
|
||||
const {
|
||||
@@ -41,7 +41,6 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
|
||||
|
||||
const handleNext = () => {
|
||||
if (step < totalSteps) {
|
||||
// Special handling for step transitions
|
||||
if (step === 1 && companyName.trim()) {
|
||||
updateTenant({ companyName: companyName.trim() })
|
||||
}
|
||||
@@ -64,7 +63,6 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
|
||||
}
|
||||
|
||||
const completeWizard = () => {
|
||||
// Save sources based on migration method
|
||||
if (migrationMethod === 'email' && inboundEmail) {
|
||||
addSource({
|
||||
tenantId: tenant.id,
|
||||
@@ -85,7 +83,6 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
|
||||
})
|
||||
}
|
||||
|
||||
// Update settings
|
||||
updateSettings({
|
||||
migrationCompleted: true,
|
||||
wizardCompleted: true,
|
||||
@@ -124,7 +121,7 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
|
||||
? '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 - Dashboard Style */}
|
||||
{/* Animated Background Blobs */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className={`absolute -top-40 -right-40 w-80 h-80 rounded-full mix-blend-multiply filter blur-3xl animate-blob ${
|
||||
isDark ? 'bg-purple-500 opacity-70' : 'bg-purple-300 opacity-50'
|
||||
@@ -157,7 +154,7 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
|
||||
`}</style>
|
||||
|
||||
<div className="relative z-10 flex-1 flex flex-col items-center justify-center p-8">
|
||||
{/* Exit Button - Fixed Top Right */}
|
||||
{/* Exit Button */}
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
@@ -227,579 +224,42 @@ export function B2BMigrationWizard({ onComplete, onSkip, onCancel }: B2BMigratio
|
||||
? 'bg-white/10 border-white/20'
|
||||
: 'bg-white/80 border-black/10 shadow-xl'
|
||||
}`}>
|
||||
{/* Step 1: Firmenname */}
|
||||
{step === 1 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen im B2B-Bereich
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie heisst Ihr Unternehmen?
|
||||
</p>
|
||||
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Hectronic GmbH"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
className={`w-full px-4 py-3 rounded-xl border text-lg ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<InfoBox variant="info" title="Warum fragen wir das?" icon="💡">
|
||||
<p>Ihr Firmenname wird verwendet, um:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Ihre eindeutige E-Mail-Adresse zu generieren</li>
|
||||
<li>Berichte und Digests zu personalisieren</li>
|
||||
<li>Ihr Dashboard anzupassen</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
<WizardStep1 companyName={companyName} setCompanyName={setCompanyName} />
|
||||
)}
|
||||
|
||||
{/* Step 2: Template waehlen */}
|
||||
{step === 2 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Branchenvorlage waehlen
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie eine Vorlage fuer Ihre Branche oder starten Sie leer
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{availableTemplates.map((template) => (
|
||||
<button
|
||||
key={template.templateId}
|
||||
onClick={() => setSelectedTemplateId(template.templateId)}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedTemplateId === template.templateId
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
|
||||
🏭
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{template.templateName}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{template.templateDescription}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{template.guidedConfig.packageSelector.options.map(pkg => (
|
||||
<span
|
||||
key={pkg}
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
template.guidedConfig.packageSelector.default.includes(pkg)
|
||||
? isDark ? 'bg-blue-500/30 text-blue-300' : 'bg-blue-100 text-blue-700'
|
||||
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplateId === template.templateId && (
|
||||
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom option */}
|
||||
<button
|
||||
onClick={() => setSelectedTemplateId('custom')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedTemplateId === 'custom'
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-100'
|
||||
}`}>
|
||||
⚙️
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Eigene Konfiguration
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Starten Sie ohne Vorlage und konfigurieren Sie alles selbst
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<WizardStep2
|
||||
availableTemplates={availableTemplates}
|
||||
selectedTemplateId={selectedTemplateId}
|
||||
setSelectedTemplateId={setSelectedTemplateId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Migration Method */}
|
||||
{step === 3 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Nutzen Sie bereits Google Alerts?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie, wie Sie Ihre bestehenden Alerts uebernehmen moechten
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Email Forwarding (Recommended) */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('email')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'email'
|
||||
? 'border-green-500 bg-green-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center text-2xl">
|
||||
📧
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-500">
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Leiten Sie Ihre bestehenden Google Alert E-Mails an uns weiter.
|
||||
Keine Aenderung an Ihren Alerts noetig.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* RSS Import */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('rss')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'rss'
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
|
||||
📡
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
RSS-Feed Import
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-700'}`}>
|
||||
Eingeschraenkt
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
RSS-Feeds, falls in Ihrem Google-Konto verfuegbar.
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
|
||||
⚠️ Google hat RSS fuer viele Konten deaktiviert
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Reconstruction */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('reconstruct')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'reconstruct'
|
||||
? 'border-amber-500 bg-amber-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl">
|
||||
🔄
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Rekonstruktion
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Beschreiben Sie, was Sie beobachten moechten. Wir erstellen die
|
||||
optimale Konfiguration fuer Sie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TipBox title="Kein Neustart noetig" icon="💡" className="mt-6">
|
||||
<p>
|
||||
Ihre bestehenden Google Alerts bleiben bestehen. Wir sind eine zusaetzliche
|
||||
Intelligenzschicht, die filtert, priorisiert und zusammenfasst.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
<WizardStep3 migrationMethod={migrationMethod} setMigrationMethod={setMigrationMethod} />
|
||||
)}
|
||||
|
||||
{/* Step 4: Migration Details */}
|
||||
{step === 4 && (
|
||||
<div>
|
||||
{/* Email Forwarding */}
|
||||
{migrationMethod === 'email' && (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung einrichten
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Google Alerts sendet E-Mails - leiten Sie diese einfach an uns weiter
|
||||
</p>
|
||||
|
||||
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
|
||||
<p>
|
||||
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
|
||||
Sie richten einen Gmail-Filter ein, der diese E-Mails automatisch weiterleitet -
|
||||
wir uebernehmen die Verarbeitung und Auswertung.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Inbound Email */}
|
||||
<div className={`p-4 rounded-xl border-2 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'}`}>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Ihre eindeutige Weiterleitungsadresse:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inboundEmail}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border font-mono text-sm ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(inboundEmail)}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-3">
|
||||
<StepBox step={1} title="Gmail-Einstellungen oeffnen" isActive>
|
||||
Oeffnen Sie <a href="https://mail.google.com/mail/u/0/#settings/filters" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:underline">Gmail → Einstellungen → Filter und blockierte Adressen</a>
|
||||
</StepBox>
|
||||
<StepBox step={2} title="Neuen Filter erstellen">
|
||||
Klicken Sie auf "Neuen Filter erstellen" und geben Sie bei "Von" ein: <code className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>googlealerts-noreply@google.com</code>
|
||||
</StepBox>
|
||||
<StepBox step={3} title="Weiterleitung aktivieren">
|
||||
Waehlen Sie "Weiterleiten an" und fuegen Sie die obige Adresse ein. Aktivieren Sie auch "Filter auf passende Konversationen anwenden".
|
||||
</StepBox>
|
||||
</div>
|
||||
|
||||
<TipBox title="Keine Aenderung an Ihren Google Alerts noetig" icon="✨" className="mt-4">
|
||||
<p>
|
||||
Ihre bestehenden Google Alerts bleiben unveraendert. Der Gmail-Filter leitet
|
||||
eingehende Alert-E-Mails automatisch an uns weiter. Sie koennen die E-Mails
|
||||
auch weiterhin in Ihrem Posteingang sehen.
|
||||
</p>
|
||||
</TipBox>
|
||||
|
||||
{/* Test Button */}
|
||||
<div className={`p-4 rounded-xl border ${
|
||||
testEmailSent
|
||||
? isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'
|
||||
: isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{testEmailSent ? '✓ Test-E-Mail empfangen' : 'Verbindung testen'}
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{testEmailSent
|
||||
? 'Die Weiterleitung funktioniert!'
|
||||
: 'Senden Sie eine Test-E-Mail, um die Einrichtung zu pruefen'}
|
||||
</p>
|
||||
</div>
|
||||
{!testEmailSent && (
|
||||
<button
|
||||
onClick={() => setTestEmailSent(true)}
|
||||
className="px-4 py-2 rounded-lg bg-white/20 hover:bg-white/30 transition-all"
|
||||
>
|
||||
Test senden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* RSS Import */}
|
||||
{migrationMethod === 'rss' && (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
RSS-Feeds importieren
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Fuegen Sie die RSS-URLs Ihrer Google Alerts hinzu
|
||||
</p>
|
||||
|
||||
{/* Warning Box */}
|
||||
<InfoBox variant="warning" title="Wichtiger Hinweis zu RSS" icon="⚠️" className="mb-6">
|
||||
<p>
|
||||
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie in Google Alerts
|
||||
kein RSS-Symbol sehen oder die Option "RSS-Feed" nicht verfuegbar ist,
|
||||
nutzen Sie bitte stattdessen die <strong>E-Mail-Weiterleitung</strong>.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setMigrationMethod('email')}
|
||||
className="mt-3 text-sm font-medium text-purple-400 hover:text-purple-300 underline"
|
||||
>
|
||||
→ Zur E-Mail-Weiterleitung wechseln
|
||||
</button>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
{rssUrls.map((url, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://www.google.de/alerts/feeds/..."
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
const newUrls = [...rssUrls]
|
||||
newUrls[idx] = e.target.value
|
||||
setRssUrls(newUrls)
|
||||
}}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
{rssUrls.length > 1 && (
|
||||
<button
|
||||
onClick={() => setRssUrls(rssUrls.filter((_, i) => i !== idx))}
|
||||
className={`p-3 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setRssUrls([...rssUrls, ''])}
|
||||
className={`w-full py-3 rounded-lg border-2 border-dashed transition-all ${
|
||||
isDark
|
||||
? 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
|
||||
: 'border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
+ Weiteren Feed hinzufuegen
|
||||
</button>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Falls RSS verfuegbar ist:
|
||||
</p>
|
||||
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>1. Oeffnen Sie google.de/alerts</li>
|
||||
<li>2. Suchen Sie nach einem orangefarbenen RSS-Symbol</li>
|
||||
<li>3. Klicken Sie darauf und kopieren Sie die URL</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Reconstruction */}
|
||||
{migrationMethod === 'reconstruct' && (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Was moechten Sie beobachten?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Beschreiben Sie Ihre Beobachtungsziele - wir erstellen die optimale Konfiguration
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
placeholder="z.B. Wir beobachten europaweite kommunale Ausschreibungen für Parkscheinautomaten, EV-Ladesäulen mit Bezahlterminals und Tankautomaten. Wir bekommen aktuell zu viele irrelevante Treffer wie News, Stellenanzeigen und Zubehör..."
|
||||
value={alertDescription}
|
||||
onChange={(e) => setAlertDescription(e.target.value)}
|
||||
rows={6}
|
||||
className={`w-full px-4 py-3 rounded-xl border resize-none ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<InfoBox variant="tip" title="Je mehr Details, desto besser" icon="✨">
|
||||
<p>Beschreiben Sie:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Welche Produkte/Services Sie anbieten</li>
|
||||
<li>Welche Kaeufer/Maerkte relevant sind</li>
|
||||
<li>Was Sie aktuell stoert (zu viel News, Jobs, etc.)</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
|
||||
{alertDescription.length > 50 && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10 border border-green-500/30' : 'bg-green-50 border border-green-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
||||
KI-Analyse bereit
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? 'text-green-300/70' : 'text-green-600'}`}>
|
||||
Wir werden Ihre Beschreibung analysieren und optimale Filter erstellen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<WizardStep4
|
||||
migrationMethod={migrationMethod}
|
||||
setMigrationMethod={setMigrationMethod}
|
||||
inboundEmail={inboundEmail}
|
||||
testEmailSent={testEmailSent}
|
||||
setTestEmailSent={setTestEmailSent}
|
||||
rssUrls={rssUrls}
|
||||
setRssUrls={setRssUrls}
|
||||
alertDescription={alertDescription}
|
||||
setAlertDescription={setAlertDescription}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 5: Notification Settings */}
|
||||
{step === 5 && (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Benachrichtigungen konfigurieren
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie moechten Sie ueber relevante Ausschreibungen informiert werden?
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Regions */}
|
||||
{selectedTemplate && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Regionen
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.guidedConfig.regionSelector.options.map(region => (
|
||||
<button
|
||||
key={region}
|
||||
onClick={() => {
|
||||
if (selectedRegions.includes(region)) {
|
||||
setSelectedRegions(selectedRegions.filter(r => r !== region))
|
||||
} else {
|
||||
setSelectedRegions([...selectedRegions, region])
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedRegions.includes(region)
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{region}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{selectedTemplate && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Produktbereiche
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.guidedConfig.packageSelector.options.map(pkg => (
|
||||
<button
|
||||
key={pkg}
|
||||
onClick={() => {
|
||||
if (selectedPackages.includes(pkg)) {
|
||||
setSelectedPackages(selectedPackages.filter(p => p !== pkg))
|
||||
} else {
|
||||
setSelectedPackages([...selectedPackages, pkg])
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedPackages.includes(pkg)
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Ihre Konfiguration
|
||||
</h4>
|
||||
<ul className={`space-y-2 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>• Firma: <strong>{companyName}</strong></li>
|
||||
<li>• Template: <strong>{selectedTemplate?.templateName || 'Eigene Konfiguration'}</strong></li>
|
||||
<li>• Migration: <strong>{
|
||||
migrationMethod === 'email' ? 'E-Mail Weiterleitung' :
|
||||
migrationMethod === 'rss' ? 'RSS Import' : 'Rekonstruktion'
|
||||
}</strong></li>
|
||||
<li>• Regionen: <strong>{selectedRegions.join(', ')}</strong></li>
|
||||
<li>• Produkte: <strong>{selectedPackages.map(p => getPackageLabel(p as Package)).join(', ')}</strong></li>
|
||||
<li>• Digest: <strong>Taeglich um 08:00, max. 10 Treffer</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<TipBox title="Bereit fuer den Start" icon="🚀">
|
||||
<p>
|
||||
Nach Abschluss werden wir Ihre Alerts analysieren und nur die wirklich
|
||||
relevanten Ausschreibungen herausfiltern. Erwarten Sie ca. 80-90% weniger
|
||||
irrelevante Treffer.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
</div>
|
||||
<WizardStep5
|
||||
selectedTemplate={selectedTemplate}
|
||||
selectedRegions={selectedRegions}
|
||||
setSelectedRegions={setSelectedRegions}
|
||||
selectedPackages={selectedPackages}
|
||||
setSelectedPackages={setSelectedPackages}
|
||||
companyName={companyName}
|
||||
migrationMethod={migrationMethod}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
460
studio-v2/components/B2BWizardDetails.tsx
Normal file
460
studio-v2/components/B2BWizardDetails.tsx
Normal file
@@ -0,0 +1,460 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
|
||||
import { InfoBox, TipBox, StepBox } from './InfoBox'
|
||||
import type { MigrationMethod } from './B2BWizardSteps'
|
||||
|
||||
// =============================================================================
|
||||
// Step 4: Migration Details (Email / RSS / Reconstruct)
|
||||
// =============================================================================
|
||||
|
||||
interface Step4Props {
|
||||
migrationMethod: MigrationMethod
|
||||
setMigrationMethod: (v: MigrationMethod) => void
|
||||
inboundEmail: string
|
||||
testEmailSent: boolean
|
||||
setTestEmailSent: (v: boolean) => void
|
||||
rssUrls: string[]
|
||||
setRssUrls: (v: string[]) => void
|
||||
alertDescription: string
|
||||
setAlertDescription: (v: string) => void
|
||||
}
|
||||
|
||||
export function WizardStep4({
|
||||
migrationMethod,
|
||||
setMigrationMethod,
|
||||
inboundEmail,
|
||||
testEmailSent,
|
||||
setTestEmailSent,
|
||||
rssUrls,
|
||||
setRssUrls,
|
||||
alertDescription,
|
||||
setAlertDescription,
|
||||
}: Step4Props) {
|
||||
const { isDark } = useTheme()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Email Forwarding */}
|
||||
{migrationMethod === 'email' && (
|
||||
<EmailForwardingDetails
|
||||
inboundEmail={inboundEmail}
|
||||
testEmailSent={testEmailSent}
|
||||
setTestEmailSent={setTestEmailSent}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* RSS Import */}
|
||||
{migrationMethod === 'rss' && (
|
||||
<RSSImportDetails
|
||||
rssUrls={rssUrls}
|
||||
setRssUrls={setRssUrls}
|
||||
setMigrationMethod={setMigrationMethod}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Reconstruction */}
|
||||
{migrationMethod === 'reconstruct' && (
|
||||
<ReconstructionDetails
|
||||
alertDescription={alertDescription}
|
||||
setAlertDescription={setAlertDescription}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Email Forwarding Details
|
||||
// =============================================================================
|
||||
|
||||
function EmailForwardingDetails({
|
||||
inboundEmail,
|
||||
testEmailSent,
|
||||
setTestEmailSent,
|
||||
}: {
|
||||
inboundEmail: string
|
||||
testEmailSent: boolean
|
||||
setTestEmailSent: (v: boolean) => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung einrichten
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Google Alerts sendet E-Mails - leiten Sie diese einfach an uns weiter
|
||||
</p>
|
||||
|
||||
<InfoBox variant="info" title="So funktioniert es" icon="💡" className="mb-6">
|
||||
<p>
|
||||
Google Alerts versendet Benachrichtigungen per E-Mail an Ihr Konto.
|
||||
Sie richten einen Gmail-Filter ein, der diese E-Mails automatisch weiterleitet -
|
||||
wir uebernehmen die Verarbeitung und Auswertung.
|
||||
</p>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Inbound Email */}
|
||||
<div className={`p-4 rounded-xl border-2 ${isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'}`}>
|
||||
<label className={`block text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Ihre eindeutige Weiterleitungsadresse:
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={inboundEmail}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border font-mono text-sm ${
|
||||
isDark
|
||||
? 'bg-white/5 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => navigator.clipboard.writeText(inboundEmail)}
|
||||
className="px-4 py-2 rounded-lg bg-purple-500 text-white hover:bg-purple-600 transition-all"
|
||||
>
|
||||
Kopieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Steps */}
|
||||
<div className="space-y-3">
|
||||
<StepBox step={1} title="Gmail-Einstellungen oeffnen" isActive>
|
||||
Oeffnen Sie <a href="https://mail.google.com/mail/u/0/#settings/filters" target="_blank" rel="noopener noreferrer" className="text-purple-400 hover:underline">Gmail → Einstellungen → Filter und blockierte Adressen</a>
|
||||
</StepBox>
|
||||
<StepBox step={2} title="Neuen Filter erstellen">
|
||||
Klicken Sie auf "Neuen Filter erstellen" und geben Sie bei "Von" ein: <code className={`px-2 py-1 rounded ${isDark ? 'bg-white/10' : 'bg-slate-100'}`}>googlealerts-noreply@google.com</code>
|
||||
</StepBox>
|
||||
<StepBox step={3} title="Weiterleitung aktivieren">
|
||||
Waehlen Sie "Weiterleiten an" und fuegen Sie die obige Adresse ein. Aktivieren Sie auch "Filter auf passende Konversationen anwenden".
|
||||
</StepBox>
|
||||
</div>
|
||||
|
||||
<TipBox title="Keine Aenderung an Ihren Google Alerts noetig" icon="✨" className="mt-4">
|
||||
<p>
|
||||
Ihre bestehenden Google Alerts bleiben unveraendert. Der Gmail-Filter leitet
|
||||
eingehende Alert-E-Mails automatisch an uns weiter. Sie koennen die E-Mails
|
||||
auch weiterhin in Ihrem Posteingang sehen.
|
||||
</p>
|
||||
</TipBox>
|
||||
|
||||
{/* Test Button */}
|
||||
<div className={`p-4 rounded-xl border ${
|
||||
testEmailSent
|
||||
? isDark ? 'bg-green-500/10 border-green-500/30' : 'bg-green-50 border-green-200'
|
||||
: isDark ? 'bg-white/5 border-white/10' : 'bg-white border-slate-200'
|
||||
}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{testEmailSent ? '✓ Test-E-Mail empfangen' : 'Verbindung testen'}
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{testEmailSent
|
||||
? 'Die Weiterleitung funktioniert!'
|
||||
: 'Senden Sie eine Test-E-Mail, um die Einrichtung zu pruefen'}
|
||||
</p>
|
||||
</div>
|
||||
{!testEmailSent && (
|
||||
<button
|
||||
onClick={() => setTestEmailSent(true)}
|
||||
className="px-4 py-2 rounded-lg bg-white/20 hover:bg-white/30 transition-all"
|
||||
>
|
||||
Test senden
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// RSS Import Details
|
||||
// =============================================================================
|
||||
|
||||
function RSSImportDetails({
|
||||
rssUrls,
|
||||
setRssUrls,
|
||||
setMigrationMethod,
|
||||
}: {
|
||||
rssUrls: string[]
|
||||
setRssUrls: (v: string[]) => void
|
||||
setMigrationMethod: (v: MigrationMethod) => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
RSS-Feeds importieren
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Fuegen Sie die RSS-URLs Ihrer Google Alerts hinzu
|
||||
</p>
|
||||
|
||||
{/* Warning Box */}
|
||||
<InfoBox variant="warning" title="Wichtiger Hinweis zu RSS" icon="⚠️" className="mb-6">
|
||||
<p>
|
||||
Google hat die RSS-Option fuer viele Konten entfernt. Falls Sie in Google Alerts
|
||||
kein RSS-Symbol sehen oder die Option "RSS-Feed" nicht verfuegbar ist,
|
||||
nutzen Sie bitte stattdessen die <strong>E-Mail-Weiterleitung</strong>.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setMigrationMethod('email')}
|
||||
className="mt-3 text-sm font-medium text-purple-400 hover:text-purple-300 underline"
|
||||
>
|
||||
→ Zur E-Mail-Weiterleitung wechseln
|
||||
</button>
|
||||
</InfoBox>
|
||||
|
||||
<div className="space-y-4">
|
||||
{rssUrls.map((url, idx) => (
|
||||
<div key={idx} className="flex gap-2">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://www.google.de/alerts/feeds/..."
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
const newUrls = [...rssUrls]
|
||||
newUrls[idx] = e.target.value
|
||||
setRssUrls(newUrls)
|
||||
}}
|
||||
className={`flex-1 px-4 py-3 rounded-lg border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
{rssUrls.length > 1 && (
|
||||
<button
|
||||
onClick={() => setRssUrls(rssUrls.filter((_, i) => i !== idx))}
|
||||
className={`p-3 rounded-lg ${isDark ? 'hover:bg-white/10' : 'hover:bg-slate-100'}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
onClick={() => setRssUrls([...rssUrls, ''])}
|
||||
className={`w-full py-3 rounded-lg border-2 border-dashed transition-all ${
|
||||
isDark
|
||||
? 'border-white/20 text-white/60 hover:border-white/40 hover:text-white'
|
||||
: 'border-slate-200 text-slate-500 hover:border-slate-300 hover:text-slate-700'
|
||||
}`}
|
||||
>
|
||||
+ Weiteren Feed hinzufuegen
|
||||
</button>
|
||||
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<p className={`text-sm font-medium mb-2 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Falls RSS verfuegbar ist:
|
||||
</p>
|
||||
<ol className={`text-sm space-y-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>1. Oeffnen Sie google.de/alerts</li>
|
||||
<li>2. Suchen Sie nach einem orangefarbenen RSS-Symbol</li>
|
||||
<li>3. Klicken Sie darauf und kopieren Sie die URL</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Reconstruction Details
|
||||
// =============================================================================
|
||||
|
||||
function ReconstructionDetails({
|
||||
alertDescription,
|
||||
setAlertDescription,
|
||||
}: {
|
||||
alertDescription: string
|
||||
setAlertDescription: (v: string) => void
|
||||
}) {
|
||||
const { isDark } = useTheme()
|
||||
return (
|
||||
<>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Was moechten Sie beobachten?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Beschreiben Sie Ihre Beobachtungsziele - wir erstellen die optimale Konfiguration
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
placeholder="z.B. Wir beobachten europaweite kommunale Ausschreibungen für Parkscheinautomaten, EV-Ladesäulen mit Bezahlterminals und Tankautomaten. Wir bekommen aktuell zu viele irrelevante Treffer wie News, Stellenanzeigen und Zubehör..."
|
||||
value={alertDescription}
|
||||
onChange={(e) => setAlertDescription(e.target.value)}
|
||||
rows={6}
|
||||
className={`w-full px-4 py-3 rounded-xl border resize-none ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<InfoBox variant="tip" title="Je mehr Details, desto besser" icon="✨">
|
||||
<p>Beschreiben Sie:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Welche Produkte/Services Sie anbieten</li>
|
||||
<li>Welche Kaeufer/Maerkte relevant sind</li>
|
||||
<li>Was Sie aktuell stoert (zu viel News, Jobs, etc.)</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
|
||||
{alertDescription.length > 50 && (
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-green-500/10 border border-green-500/30' : 'bg-green-50 border border-green-200'}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">🤖</span>
|
||||
<div>
|
||||
<p className={`font-medium ${isDark ? 'text-green-300' : 'text-green-700'}`}>
|
||||
KI-Analyse bereit
|
||||
</p>
|
||||
<p className={`text-sm ${isDark ? 'text-green-300/70' : 'text-green-600'}`}>
|
||||
Wir werden Ihre Beschreibung analysieren und optimale Filter erstellen.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step 5: Notification Settings & Summary
|
||||
// =============================================================================
|
||||
|
||||
interface Step5Props {
|
||||
selectedTemplate: B2BTemplate | undefined
|
||||
selectedRegions: string[]
|
||||
setSelectedRegions: (v: string[]) => void
|
||||
selectedPackages: string[]
|
||||
setSelectedPackages: (v: string[]) => void
|
||||
companyName: string
|
||||
migrationMethod: MigrationMethod
|
||||
}
|
||||
|
||||
export function WizardStep5({
|
||||
selectedTemplate,
|
||||
selectedRegions,
|
||||
setSelectedRegions,
|
||||
selectedPackages,
|
||||
setSelectedPackages,
|
||||
companyName,
|
||||
migrationMethod,
|
||||
}: Step5Props) {
|
||||
const { isDark } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Benachrichtigungen konfigurieren
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie moechten Sie ueber relevante Ausschreibungen informiert werden?
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Regions */}
|
||||
{selectedTemplate && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Regionen
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.guidedConfig.regionSelector.options.map(region => (
|
||||
<button
|
||||
key={region}
|
||||
onClick={() => {
|
||||
if (selectedRegions.includes(region)) {
|
||||
setSelectedRegions(selectedRegions.filter(r => r !== region))
|
||||
} else {
|
||||
setSelectedRegions([...selectedRegions, region])
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedRegions.includes(region)
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{region}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Packages */}
|
||||
{selectedTemplate && (
|
||||
<div>
|
||||
<label className={`block text-sm font-medium mb-3 ${isDark ? 'text-white/80' : 'text-slate-700'}`}>
|
||||
Produktbereiche
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTemplate.guidedConfig.packageSelector.options.map(pkg => (
|
||||
<button
|
||||
key={pkg}
|
||||
onClick={() => {
|
||||
if (selectedPackages.includes(pkg)) {
|
||||
setSelectedPackages(selectedPackages.filter(p => p !== pkg))
|
||||
} else {
|
||||
setSelectedPackages([...selectedPackages, pkg])
|
||||
}
|
||||
}}
|
||||
className={`px-3 py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
selectedPackages.includes(pkg)
|
||||
? 'bg-blue-500 text-white'
|
||||
: isDark
|
||||
? 'bg-white/10 text-white/60 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<div className={`p-4 rounded-xl ${isDark ? 'bg-white/5' : 'bg-slate-50'}`}>
|
||||
<h4 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Ihre Konfiguration
|
||||
</h4>
|
||||
<ul className={`space-y-2 text-sm ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
<li>• Firma: <strong>{companyName}</strong></li>
|
||||
<li>• Template: <strong>{selectedTemplate?.templateName || 'Eigene Konfiguration'}</strong></li>
|
||||
<li>• Migration: <strong>{
|
||||
migrationMethod === 'email' ? 'E-Mail Weiterleitung' :
|
||||
migrationMethod === 'rss' ? 'RSS Import' : 'Rekonstruktion'
|
||||
}</strong></li>
|
||||
<li>• Regionen: <strong>{selectedRegions.join(', ')}</strong></li>
|
||||
<li>• Produkte: <strong>{selectedPackages.map(p => getPackageLabel(p as Package)).join(', ')}</strong></li>
|
||||
<li>• Digest: <strong>Taeglich um 08:00, max. 10 Treffer</strong></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<TipBox title="Bereit fuer den Start" icon="🚀">
|
||||
<p>
|
||||
Nach Abschluss werden wir Ihre Alerts analysieren und nur die wirklich
|
||||
relevanten Ausschreibungen herausfiltern. Erwarten Sie ca. 80-90% weniger
|
||||
irrelevante Treffer.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
281
studio-v2/components/B2BWizardSteps.tsx
Normal file
281
studio-v2/components/B2BWizardSteps.tsx
Normal file
@@ -0,0 +1,281 @@
|
||||
'use client'
|
||||
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { B2BTemplate, Package, getPackageIcon, getPackageLabel } from '@/lib/AlertsB2BContext'
|
||||
import { InfoBox, TipBox, StepBox } from './InfoBox'
|
||||
|
||||
// =============================================================================
|
||||
// Step 1: Company Name
|
||||
// =============================================================================
|
||||
|
||||
interface Step1Props {
|
||||
companyName: string
|
||||
setCompanyName: (v: string) => void
|
||||
}
|
||||
|
||||
export function WizardStep1({ companyName, setCompanyName }: Step1Props) {
|
||||
const { isDark } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Willkommen im B2B-Bereich
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Wie heisst Ihr Unternehmen?
|
||||
</p>
|
||||
|
||||
<div className="max-w-md mx-auto space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="z.B. Hectronic GmbH"
|
||||
value={companyName}
|
||||
onChange={(e) => setCompanyName(e.target.value)}
|
||||
className={`w-full px-4 py-3 rounded-xl border text-lg ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white placeholder-white/40'
|
||||
: 'bg-white border-slate-200 text-slate-900 placeholder-slate-400'
|
||||
}`}
|
||||
/>
|
||||
|
||||
<InfoBox variant="info" title="Warum fragen wir das?" icon="💡">
|
||||
<p>Ihr Firmenname wird verwendet, um:</p>
|
||||
<ul className="list-disc list-inside mt-2 space-y-1">
|
||||
<li>Ihre eindeutige E-Mail-Adresse zu generieren</li>
|
||||
<li>Berichte und Digests zu personalisieren</li>
|
||||
<li>Ihr Dashboard anzupassen</li>
|
||||
</ul>
|
||||
</InfoBox>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step 2: Template Selection
|
||||
// =============================================================================
|
||||
|
||||
interface Step2Props {
|
||||
availableTemplates: B2BTemplate[]
|
||||
selectedTemplateId: string | null
|
||||
setSelectedTemplateId: (v: string | null) => void
|
||||
}
|
||||
|
||||
export function WizardStep2({ availableTemplates, selectedTemplateId, setSelectedTemplateId }: Step2Props) {
|
||||
const { isDark } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Branchenvorlage waehlen
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie eine Vorlage fuer Ihre Branche oder starten Sie leer
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{availableTemplates.map((template) => (
|
||||
<button
|
||||
key={template.templateId}
|
||||
onClick={() => setSelectedTemplateId(template.templateId)}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedTemplateId === template.templateId
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
|
||||
🏭
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{template.templateName}
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
{template.templateDescription}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{template.guidedConfig.packageSelector.options.map(pkg => (
|
||||
<span
|
||||
key={pkg}
|
||||
className={`px-2 py-1 rounded-full text-xs font-medium ${
|
||||
template.guidedConfig.packageSelector.default.includes(pkg)
|
||||
? isDark ? 'bg-blue-500/30 text-blue-300' : 'bg-blue-100 text-blue-700'
|
||||
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
{getPackageIcon(pkg)} {getPackageLabel(pkg)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedTemplateId === template.templateId && (
|
||||
<div className="w-6 h-6 rounded-full bg-blue-500 flex items-center justify-center">
|
||||
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Custom option */}
|
||||
<button
|
||||
onClick={() => setSelectedTemplateId('custom')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
selectedTemplateId === 'custom'
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center text-2xl ${
|
||||
isDark ? 'bg-white/20' : 'bg-slate-100'
|
||||
}`}>
|
||||
⚙️
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Eigene Konfiguration
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Starten Sie ohne Vorlage und konfigurieren Sie alles selbst
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step 3: Migration Method
|
||||
// =============================================================================
|
||||
|
||||
export type MigrationMethod = 'email' | 'rss' | 'reconstruct' | null
|
||||
|
||||
interface Step3Props {
|
||||
migrationMethod: MigrationMethod
|
||||
setMigrationMethod: (v: MigrationMethod) => void
|
||||
}
|
||||
|
||||
export function WizardStep3({ migrationMethod, setMigrationMethod }: Step3Props) {
|
||||
const { isDark } = useTheme()
|
||||
return (
|
||||
<div>
|
||||
<h2 className={`text-2xl font-bold mb-2 text-center ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Nutzen Sie bereits Google Alerts?
|
||||
</h2>
|
||||
<p className={`mb-6 text-center ${isDark ? 'text-white/60' : 'text-slate-600'}`}>
|
||||
Waehlen Sie, wie Sie Ihre bestehenden Alerts uebernehmen moechten
|
||||
</p>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Email Forwarding (Recommended) */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('email')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'email'
|
||||
? 'border-green-500 bg-green-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-green-400 to-emerald-500 flex items-center justify-center text-2xl">
|
||||
📧
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
E-Mail Weiterleitung
|
||||
</h3>
|
||||
<span className="px-2 py-0.5 rounded-full text-xs font-medium bg-green-500/20 text-green-500">
|
||||
Empfohlen
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Leiten Sie Ihre bestehenden Google Alert E-Mails an uns weiter.
|
||||
Keine Aenderung an Ihren Alerts noetig.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* RSS Import */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('rss')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'rss'
|
||||
? 'border-blue-500 bg-blue-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-blue-400 to-indigo-500 flex items-center justify-center text-2xl">
|
||||
📡
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
RSS-Feed Import
|
||||
</h3>
|
||||
<span className={`px-2 py-0.5 rounded-full text-xs font-medium ${isDark ? 'bg-amber-500/20 text-amber-400' : 'bg-amber-100 text-amber-700'}`}>
|
||||
Eingeschraenkt
|
||||
</span>
|
||||
</div>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
RSS-Feeds, falls in Ihrem Google-Konto verfuegbar.
|
||||
</p>
|
||||
<p className={`text-xs mt-1 ${isDark ? 'text-amber-400/70' : 'text-amber-600'}`}>
|
||||
⚠️ Google hat RSS fuer viele Konten deaktiviert
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Reconstruction */}
|
||||
<button
|
||||
onClick={() => setMigrationMethod('reconstruct')}
|
||||
className={`w-full text-left p-5 rounded-xl border-2 transition-all ${
|
||||
migrationMethod === 'reconstruct'
|
||||
? 'border-amber-500 bg-amber-500/20 shadow-lg'
|
||||
: isDark
|
||||
? 'border-white/10 bg-white/5 hover:bg-white/10'
|
||||
: 'border-slate-200 bg-white hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center text-2xl">
|
||||
🔄
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className={`font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Rekonstruktion
|
||||
</h3>
|
||||
<p className={`text-sm mt-1 ${isDark ? 'text-white/60' : 'text-slate-500'}`}>
|
||||
Beschreiben Sie, was Sie beobachten moechten. Wir erstellen die
|
||||
optimale Konfiguration fuer Sie.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TipBox title="Kein Neustart noetig" icon="💡" className="mt-6">
|
||||
<p>
|
||||
Ihre bestehenden Google Alerts bleiben bestehen. Wir sind eine zusaetzliche
|
||||
Intelligenzschicht, die filtert, priorisiert und zusammenfasst.
|
||||
</p>
|
||||
</TipBox>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTheme } from '@/lib/ThemeContext'
|
||||
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
||||
import { UploadStep, PreviewStep, ResultStep } from './CleanupSteps'
|
||||
|
||||
interface CleanupPanelProps {
|
||||
isOpen: boolean
|
||||
@@ -15,7 +16,7 @@ interface CleanupCapabilities {
|
||||
paddleocr_available: boolean
|
||||
}
|
||||
|
||||
interface PreviewResult {
|
||||
export interface PreviewResult {
|
||||
has_handwriting: boolean
|
||||
confidence: number
|
||||
handwriting_ratio: number
|
||||
@@ -32,7 +33,7 @@ interface PreviewResult {
|
||||
}
|
||||
}
|
||||
|
||||
interface PipelineResult {
|
||||
export interface PipelineResult {
|
||||
success: boolean
|
||||
handwriting_detected: boolean
|
||||
handwriting_removed: boolean
|
||||
@@ -51,7 +52,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
const [cleanedUrl, setCleanedUrl] = useState<string | null>(null)
|
||||
const [maskUrl, setMaskUrl] = useState<string | null>(null)
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isPreviewing, setIsPreviewing] = useState(false)
|
||||
const [isProcessing, setIsProcessing] = useState(false)
|
||||
|
||||
@@ -74,7 +74,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
return hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
||||
}, [])
|
||||
|
||||
// Load capabilities on mount
|
||||
const loadCapabilities = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetch(`${getApiUrl()}/api/v1/worksheet/capabilities`)
|
||||
@@ -87,7 +86,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
}
|
||||
}, [getApiUrl])
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = useCallback((selectedFile: File) => {
|
||||
setFile(selectedFile)
|
||||
setError(null)
|
||||
@@ -95,14 +93,11 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
setPipelineResult(null)
|
||||
setCleanedUrl(null)
|
||||
setMaskUrl(null)
|
||||
|
||||
// Create preview URL
|
||||
const url = URL.createObjectURL(selectedFile)
|
||||
setPreviewUrl(url)
|
||||
setCurrentStep('upload')
|
||||
}, [])
|
||||
|
||||
// Handle drop
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault()
|
||||
const droppedFile = e.dataTransfer.files[0]
|
||||
@@ -111,31 +106,18 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
}
|
||||
}, [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 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')
|
||||
|
||||
// Also load capabilities
|
||||
await loadCapabilities()
|
||||
} catch (err) {
|
||||
console.error('Preview failed:', err)
|
||||
@@ -145,39 +127,27 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
}
|
||||
}, [file, getApiUrl, loadCapabilities])
|
||||
|
||||
// Run full cleanup pipeline
|
||||
const handleCleanup = useCallback(async () => {
|
||||
if (!file) return
|
||||
|
||||
setIsProcessing(true)
|
||||
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
|
||||
})
|
||||
|
||||
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)
|
||||
@@ -187,18 +157,14 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
}
|
||||
}, [file, removeHandwriting, reconstructLayout, inpaintingMethod, getApiUrl])
|
||||
|
||||
// Import to canvas
|
||||
const handleImportToCanvas = useCallback(async () => {
|
||||
if (!pipelineResult?.fabric_json || !canvas) return
|
||||
|
||||
try {
|
||||
// Clear canvas and load new content
|
||||
canvas.clear()
|
||||
canvas.loadFromJSON(pipelineResult.fabric_json, () => {
|
||||
canvas.renderAll()
|
||||
saveToHistory('Imported: Cleaned worksheet')
|
||||
})
|
||||
|
||||
onClose()
|
||||
} catch (err) {
|
||||
console.error('Import failed:', err)
|
||||
@@ -206,23 +172,13 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
}
|
||||
}, [pipelineResult, canvas, saveToHistory, onClose])
|
||||
|
||||
// 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 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) {
|
||||
@@ -232,7 +188,6 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
// Styles
|
||||
const overlayStyle = 'fixed inset-0 bg-black/50 backdrop-blur-sm z-50'
|
||||
const modalStyle = isDark
|
||||
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
||||
@@ -242,6 +197,9 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
? 'bg-white/5 border-white/10 hover:bg-white/10'
|
||||
: 'bg-white/50 border-slate-200 hover:bg-slate-50'
|
||||
|
||||
const stepNames = ['upload', 'preview', 'result'] as const
|
||||
const stepIndex = stepNames.indexOf(currentStep)
|
||||
|
||||
return (
|
||||
<div className={overlayStyle} onClick={onClose}>
|
||||
<div
|
||||
@@ -251,9 +209,7 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-orange-500/20' : 'bg-orange-100'
|
||||
}`}>
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${isDark ? 'bg-orange-500/20' : 'bg-orange-100'}`}>
|
||||
<svg className={`w-7 h-7 ${isDark ? 'text-orange-300' : 'text-orange-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} 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>
|
||||
@@ -262,17 +218,10 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
<h2 className={`text-xl font-semibold ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Arbeitsblatt bereinigen
|
||||
</h2>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Handschrift entfernen und Layout rekonstruieren
|
||||
</p>
|
||||
<p className={`text-sm ${labelStyle}`}>Handschrift entfernen und Layout rekonstruieren</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`p-2 rounded-xl transition-colors ${
|
||||
isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onClose} className={`p-2 rounded-xl transition-colors ${isDark ? 'hover:bg-white/10 text-white/70' : 'hover:bg-slate-100 text-slate-500'}`}>
|
||||
<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>
|
||||
@@ -281,23 +230,19 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
|
||||
{/* Step Indicator */}
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
{['upload', 'preview', 'result'].map((step, idx) => (
|
||||
{stepNames.map((step, idx) => (
|
||||
<div key={step} className="flex items-center">
|
||||
<div className={`w-8 h-8 rounded-full flex items-center justify-center font-medium ${
|
||||
currentStep === step
|
||||
? isDark ? 'bg-purple-500 text-white' : 'bg-purple-600 text-white'
|
||||
: idx < ['upload', 'preview', 'result'].indexOf(currentStep)
|
||||
: idx < stepIndex
|
||||
? isDark ? 'bg-green-500 text-white' : 'bg-green-600 text-white'
|
||||
: isDark ? 'bg-white/10 text-white/50' : 'bg-slate-200 text-slate-400'
|
||||
}`}>
|
||||
{idx < ['upload', 'preview', 'result'].indexOf(currentStep) ? '✓' : idx + 1}
|
||||
{idx < stepIndex ? '✓' : idx + 1}
|
||||
</div>
|
||||
{idx < 2 && (
|
||||
<div className={`w-12 h-0.5 ${
|
||||
idx < ['upload', 'preview', 'result'].indexOf(currentStep)
|
||||
? isDark ? 'bg-green-500' : 'bg-green-600'
|
||||
: isDark ? 'bg-white/20' : 'bg-slate-200'
|
||||
}`} />
|
||||
<div className={`w-12 h-0.5 ${idx < stepIndex ? isDark ? 'bg-green-500' : 'bg-green-600' : isDark ? 'bg-white/20' : 'bg-slate-200'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -305,364 +250,38 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<div className={`p-4 rounded-xl mb-4 ${
|
||||
isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'
|
||||
}`}>
|
||||
<div className={`p-4 rounded-xl mb-4 ${isDark ? 'bg-red-500/20 text-red-300' : 'bg-red-50 text-red-700'}`}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Upload */}
|
||||
{currentStep === 'upload' && (
|
||||
<div className="space-y-6">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${
|
||||
isDark
|
||||
? 'border-white/20 hover:border-purple-400/50 hover:bg-white/5'
|
||||
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/30'
|
||||
}`}
|
||||
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-64 mx-auto rounded-xl shadow-lg"
|
||||
/>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{file?.name}
|
||||
</p>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Klicke zum Ändern oder ziehe eine andere Datei hierher
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className={`w-16 h-16 mx-auto mb-4 ${isDark ? 'text-white/30' : 'text-slate-300'}`} 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-lg font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Bild hochladen
|
||||
</p>
|
||||
<p className={labelStyle}>
|
||||
Ziehe ein Bild hierher oder klicke zum Auswählen
|
||||
</p>
|
||||
<p className={`text-xs mt-2 ${labelStyle}`}>
|
||||
Unterstützt: PNG, JPG, JPEG
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
{file && (
|
||||
<div className="space-y-4">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Optionen
|
||||
</h3>
|
||||
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHandwriting}
|
||||
onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
||||
className="w-5 h-5 rounded"
|
||||
/>
|
||||
<div>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Handschrift entfernen
|
||||
</span>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Erkennt und entfernt handgeschriebene Inhalte
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reconstructLayout}
|
||||
onChange={(e) => setReconstructLayout(e.target.checked)}
|
||||
className="w-5 h-5 rounded"
|
||||
/>
|
||||
<div>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Layout rekonstruieren
|
||||
</span>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Erstellt bearbeitbare Fabric.js Objekte
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{removeHandwriting && (
|
||||
<div className="space-y-2">
|
||||
<label className={`block text-sm font-medium ${labelStyle}`}>
|
||||
Inpainting-Methode
|
||||
</label>
|
||||
<select
|
||||
value={inpaintingMethod}
|
||||
onChange={(e) => setInpaintingMethod(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<option value="auto">Automatisch (empfohlen)</option>
|
||||
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
||||
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
||||
{capabilities?.lama_available && (
|
||||
<option value="lama">LaMa (beste Qualität)</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<UploadStep
|
||||
isDark={isDark} labelStyle={labelStyle} cardStyle={cardStyle}
|
||||
file={file} previewUrl={previewUrl}
|
||||
handleDrop={handleDrop} handleFileSelect={handleFileSelect}
|
||||
removeHandwriting={removeHandwriting} setRemoveHandwriting={setRemoveHandwriting}
|
||||
reconstructLayout={reconstructLayout} setReconstructLayout={setReconstructLayout}
|
||||
inpaintingMethod={inpaintingMethod} setInpaintingMethod={setInpaintingMethod}
|
||||
lamaAvailable={capabilities?.lama_available ?? false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 2: Preview */}
|
||||
{currentStep === 'preview' && previewResult && (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Detection Result */}
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Erkennungsergebnis
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Handschrift gefunden:</span>
|
||||
<span className={previewResult.has_handwriting
|
||||
? isDark ? 'text-orange-300' : 'text-orange-600'
|
||||
: isDark ? 'text-green-300' : 'text-green-600'
|
||||
}>
|
||||
{previewResult.has_handwriting ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Konfidenz:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{(previewResult.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Handschrift-Anteil:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{(previewResult.handwriting_ratio * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Bildgröße:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{previewResult.image_width} × {previewResult.image_height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Estimates */}
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Geschätzte Zeit
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Erkennung:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
{removeHandwriting && previewResult.has_handwriting && (
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Bereinigung:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{reconstructLayout && (
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Layout:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex justify-between pt-2 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Gesamt:</span>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
~{Math.round(previewResult.estimated_times_ms.total / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Images */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Original
|
||||
</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Maske
|
||||
</h3>
|
||||
<button
|
||||
onClick={handleGetMask}
|
||||
className={`text-sm px-3 py-1 rounded-lg ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white/70 hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Maske laden
|
||||
</button>
|
||||
</div>
|
||||
{maskUrl ? (
|
||||
<img
|
||||
src={maskUrl}
|
||||
alt="Mask"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className={`aspect-video rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-100'
|
||||
}`}>
|
||||
<span className={labelStyle}>Klicke "Maske laden" zum Anzeigen</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<PreviewStep
|
||||
isDark={isDark} labelStyle={labelStyle} cardStyle={cardStyle}
|
||||
previewResult={previewResult} previewUrl={previewUrl} maskUrl={maskUrl}
|
||||
removeHandwriting={removeHandwriting} reconstructLayout={reconstructLayout}
|
||||
handleGetMask={handleGetMask}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Step 3: Result */}
|
||||
{currentStep === 'result' && pipelineResult && (
|
||||
<div className="space-y-6">
|
||||
{/* Status */}
|
||||
<div className={`p-4 rounded-xl ${
|
||||
pipelineResult.success
|
||||
? isDark ? 'bg-green-500/20' : 'bg-green-50'
|
||||
: isDark ? 'bg-red-500/20' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{pipelineResult.success ? (
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-red-300' : 'text-red-600'}`} 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>
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`font-medium ${
|
||||
pipelineResult.success
|
||||
? isDark ? 'text-green-300' : 'text-green-700'
|
||||
: isDark ? 'text-red-300' : 'text-red-700'
|
||||
}`}>
|
||||
{pipelineResult.success ? 'Erfolgreich bereinigt' : 'Bereinigung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
{pipelineResult.handwriting_detected
|
||||
? pipelineResult.handwriting_removed
|
||||
? 'Handschrift wurde erkannt und entfernt'
|
||||
: 'Handschrift erkannt, aber nicht entfernt'
|
||||
: 'Keine Handschrift gefunden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result Images */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Original
|
||||
</h3>
|
||||
{previewUrl && (
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Original"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Bereinigt
|
||||
</h3>
|
||||
{cleanedUrl ? (
|
||||
<img
|
||||
src={cleanedUrl}
|
||||
alt="Cleaned"
|
||||
className="w-full rounded-xl shadow-lg"
|
||||
/>
|
||||
) : (
|
||||
<div className={`aspect-video rounded-xl flex items-center justify-center ${
|
||||
isDark ? 'bg-white/5' : 'bg-slate-100'
|
||||
}`}>
|
||||
<span className={labelStyle}>Kein Bild</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout Info */}
|
||||
{pipelineResult.layout_reconstructed && pipelineResult.metadata?.layout && (
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Layout-Rekonstruktion
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<span className={labelStyle}>Elemente:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.element_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={labelStyle}>Tabellen:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.table_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={labelStyle}>Größe:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.page_width} × {pipelineResult.metadata.layout.page_height}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ResultStep
|
||||
isDark={isDark} labelStyle={labelStyle} cardStyle={cardStyle}
|
||||
pipelineResult={pipelineResult} previewUrl={previewUrl} cleanedUrl={cleanedUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -673,9 +292,7 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
<button
|
||||
onClick={() => setCurrentStep(currentStep === 'result' ? 'preview' : 'upload')}
|
||||
className={`px-4 py-2 rounded-xl font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
← Zurück
|
||||
@@ -684,73 +301,34 @@ export function CleanupPanel({ isOpen, onClose }: CleanupPanelProps) {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
|
||||
isDark
|
||||
? 'bg-white/10 text-white hover:bg-white/20'
|
||||
: 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
<button onClick={onClose} className={`px-5 py-2.5 rounded-xl font-medium transition-colors ${
|
||||
isDark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-slate-100 text-slate-700 hover:bg-slate-200'
|
||||
}`}>
|
||||
Abbrechen
|
||||
</button>
|
||||
|
||||
{currentStep === 'upload' && file && (
|
||||
<button
|
||||
onClick={handlePreview}
|
||||
disabled={isPreviewing}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40'
|
||||
: 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isPreviewing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />
|
||||
Analysiere...
|
||||
</>
|
||||
) : (
|
||||
'Vorschau'
|
||||
)}
|
||||
<button onClick={handlePreview} disabled={isPreviewing} className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark ? 'bg-purple-500/30 text-purple-300 hover:bg-purple-500/40' : 'bg-purple-600 text-white hover:bg-purple-700'
|
||||
} disabled:opacity-50`}>
|
||||
{isPreviewing ? (<><div className="w-4 h-4 border-2 border-current border-t-transparent rounded-full animate-spin" />Analysiere...</>) : 'Vorschau'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === 'preview' && (
|
||||
<button
|
||||
onClick={handleCleanup}
|
||||
disabled={isProcessing}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30'
|
||||
: 'bg-gradient-to-r from-orange-600 to-red-600 text-white hover:shadow-lg hover:shadow-orange-600/30'
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
Verarbeite...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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
|
||||
</>
|
||||
<button onClick={handleCleanup} disabled={isProcessing} className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark ? 'bg-gradient-to-r from-orange-500 to-red-500 text-white hover:shadow-lg hover:shadow-orange-500/30' : 'bg-gradient-to-r from-orange-600 to-red-600 text-white hover:shadow-lg hover:shadow-orange-600/30'
|
||||
} disabled:opacity-50`}>
|
||||
{isProcessing ? (<><div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />Verarbeite...</>) : (
|
||||
<><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</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{currentStep === 'result' && pipelineResult?.success && (
|
||||
<button
|
||||
onClick={handleImportToCanvas}
|
||||
className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark
|
||||
? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30'
|
||||
: 'bg-gradient-to-r from-green-600 to-emerald-600 text-white hover:shadow-lg hover:shadow-green-600/30'
|
||||
}`}
|
||||
>
|
||||
<button onClick={handleImportToCanvas} className={`px-5 py-2.5 rounded-xl font-medium transition-all flex items-center gap-2 ${
|
||||
isDark ? 'bg-gradient-to-r from-green-500 to-emerald-500 text-white hover:shadow-lg hover:shadow-green-500/30' : 'bg-gradient-to-r from-green-600 to-emerald-600 text-white hover:shadow-lg hover:shadow-green-600/30'
|
||||
}`}>
|
||||
<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-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
|
||||
390
studio-v2/components/worksheet-editor/CleanupSteps.tsx
Normal file
390
studio-v2/components/worksheet-editor/CleanupSteps.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
'use client'
|
||||
|
||||
import type { PreviewResult, PipelineResult } from './CleanupPanel'
|
||||
|
||||
// =============================================================================
|
||||
// Step 1: Upload
|
||||
// =============================================================================
|
||||
|
||||
interface UploadStepProps {
|
||||
isDark: boolean
|
||||
labelStyle: string
|
||||
cardStyle: string
|
||||
file: File | null
|
||||
previewUrl: string | null
|
||||
handleDrop: (e: React.DragEvent) => void
|
||||
handleFileSelect: (f: File) => void
|
||||
removeHandwriting: boolean
|
||||
setRemoveHandwriting: (v: boolean) => void
|
||||
reconstructLayout: boolean
|
||||
setReconstructLayout: (v: boolean) => void
|
||||
inpaintingMethod: string
|
||||
setInpaintingMethod: (v: string) => void
|
||||
lamaAvailable: boolean
|
||||
}
|
||||
|
||||
export function UploadStep({
|
||||
isDark, labelStyle, cardStyle, file, previewUrl,
|
||||
handleDrop, handleFileSelect,
|
||||
removeHandwriting, setRemoveHandwriting,
|
||||
reconstructLayout, setReconstructLayout,
|
||||
inpaintingMethod, setInpaintingMethod,
|
||||
lamaAvailable,
|
||||
}: UploadStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Dropzone */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all ${
|
||||
isDark
|
||||
? 'border-white/20 hover:border-purple-400/50 hover:bg-white/5'
|
||||
: 'border-slate-300 hover:border-purple-400 hover:bg-purple-50/30'
|
||||
}`}
|
||||
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-64 mx-auto rounded-xl shadow-lg"
|
||||
/>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{file?.name}
|
||||
</p>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Klicke zum Ändern oder ziehe eine andere Datei hierher
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<svg className={`w-16 h-16 mx-auto mb-4 ${isDark ? 'text-white/30' : 'text-slate-300'}`} 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-lg font-medium mb-2 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Bild hochladen
|
||||
</p>
|
||||
<p className={labelStyle}>
|
||||
Ziehe ein Bild hierher oder klicke zum Auswählen
|
||||
</p>
|
||||
<p className={`text-xs mt-2 ${labelStyle}`}>
|
||||
Unterstützt: PNG, JPG, JPEG
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
{file && (
|
||||
<div className="space-y-4">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Optionen
|
||||
</h3>
|
||||
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeHandwriting}
|
||||
onChange={(e) => setRemoveHandwriting(e.target.checked)}
|
||||
className="w-5 h-5 rounded"
|
||||
/>
|
||||
<div>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Handschrift entfernen
|
||||
</span>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Erkennt und entfernt handgeschriebene Inhalte
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label className={`flex items-center gap-3 p-4 rounded-xl border cursor-pointer transition-all ${cardStyle}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reconstructLayout}
|
||||
onChange={(e) => setReconstructLayout(e.target.checked)}
|
||||
className="w-5 h-5 rounded"
|
||||
/>
|
||||
<div>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Layout rekonstruieren
|
||||
</span>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
Erstellt bearbeitbare Fabric.js Objekte
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{removeHandwriting && (
|
||||
<div className="space-y-2">
|
||||
<label className={`block text-sm font-medium ${labelStyle}`}>
|
||||
Inpainting-Methode
|
||||
</label>
|
||||
<select
|
||||
value={inpaintingMethod}
|
||||
onChange={(e) => setInpaintingMethod(e.target.value)}
|
||||
className={`w-full p-3 rounded-xl border ${
|
||||
isDark
|
||||
? 'bg-white/10 border-white/20 text-white'
|
||||
: 'bg-white border-slate-200 text-slate-900'
|
||||
}`}
|
||||
>
|
||||
<option value="auto">Automatisch (empfohlen)</option>
|
||||
<option value="opencv_telea">OpenCV Telea (schnell)</option>
|
||||
<option value="opencv_ns">OpenCV NS (glatter)</option>
|
||||
{lamaAvailable && (
|
||||
<option value="lama">LaMa (beste Qualität)</option>
|
||||
)}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step 2: Preview
|
||||
// =============================================================================
|
||||
|
||||
interface PreviewStepProps {
|
||||
isDark: boolean
|
||||
labelStyle: string
|
||||
cardStyle: string
|
||||
previewResult: PreviewResult
|
||||
previewUrl: string | null
|
||||
maskUrl: string | null
|
||||
removeHandwriting: boolean
|
||||
reconstructLayout: boolean
|
||||
handleGetMask: () => void
|
||||
}
|
||||
|
||||
export function PreviewStep({
|
||||
isDark, labelStyle, cardStyle,
|
||||
previewResult, previewUrl, maskUrl,
|
||||
removeHandwriting, reconstructLayout,
|
||||
handleGetMask,
|
||||
}: PreviewStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
{/* Detection Result */}
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Erkennungsergebnis
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Handschrift gefunden:</span>
|
||||
<span className={previewResult.has_handwriting
|
||||
? isDark ? 'text-orange-300' : 'text-orange-600'
|
||||
: isDark ? 'text-green-300' : 'text-green-600'
|
||||
}>
|
||||
{previewResult.has_handwriting ? 'Ja' : 'Nein'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Konfidenz:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{(previewResult.confidence * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Handschrift-Anteil:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{(previewResult.handwriting_ratio * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Bildgröße:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
{previewResult.image_width} × {previewResult.image_height}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Time Estimates */}
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Geschätzte Zeit
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Erkennung:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.detection / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
{removeHandwriting && previewResult.has_handwriting && (
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Bereinigung:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.inpainting / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{reconstructLayout && (
|
||||
<div className="flex justify-between">
|
||||
<span className={labelStyle}>Layout:</span>
|
||||
<span className={isDark ? 'text-white' : 'text-slate-900'}>
|
||||
~{Math.round(previewResult.estimated_times_ms.reconstruction / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex justify-between pt-2 border-t ${isDark ? 'border-white/10' : 'border-slate-200'}`}>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Gesamt:</span>
|
||||
<span className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
~{Math.round(previewResult.estimated_times_ms.total / 1000)}s
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Images */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Original
|
||||
</h3>
|
||||
{previewUrl && <img src={previewUrl} alt="Original" className="w-full rounded-xl shadow-lg" />}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>Maske</h3>
|
||||
<button
|
||||
onClick={handleGetMask}
|
||||
className={`text-sm px-3 py-1 rounded-lg ${
|
||||
isDark ? 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
}`}
|
||||
>
|
||||
Maske laden
|
||||
</button>
|
||||
</div>
|
||||
{maskUrl ? (
|
||||
<img src={maskUrl} alt="Mask" className="w-full rounded-xl shadow-lg" />
|
||||
) : (
|
||||
<div className={`aspect-video rounded-xl flex items-center justify-center ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<span className={labelStyle}>Klicke "Maske laden" zum Anzeigen</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Step 3: Result
|
||||
// =============================================================================
|
||||
|
||||
interface ResultStepProps {
|
||||
isDark: boolean
|
||||
labelStyle: string
|
||||
cardStyle: string
|
||||
pipelineResult: PipelineResult
|
||||
previewUrl: string | null
|
||||
cleanedUrl: string | null
|
||||
}
|
||||
|
||||
export function ResultStep({
|
||||
isDark, labelStyle, cardStyle,
|
||||
pipelineResult, previewUrl, cleanedUrl,
|
||||
}: ResultStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Status */}
|
||||
<div className={`p-4 rounded-xl ${
|
||||
pipelineResult.success
|
||||
? isDark ? 'bg-green-500/20' : 'bg-green-50'
|
||||
: isDark ? 'bg-red-500/20' : 'bg-red-50'
|
||||
}`}>
|
||||
<div className="flex items-center gap-3">
|
||||
{pipelineResult.success ? (
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-green-300' : 'text-green-600'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className={`w-6 h-6 ${isDark ? 'text-red-300' : 'text-red-600'}`} 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>
|
||||
)}
|
||||
<div>
|
||||
<h3 className={`font-medium ${
|
||||
pipelineResult.success
|
||||
? isDark ? 'text-green-300' : 'text-green-700'
|
||||
: isDark ? 'text-red-300' : 'text-red-700'
|
||||
}`}>
|
||||
{pipelineResult.success ? 'Erfolgreich bereinigt' : 'Bereinigung fehlgeschlagen'}
|
||||
</h3>
|
||||
<p className={`text-sm ${labelStyle}`}>
|
||||
{pipelineResult.handwriting_detected
|
||||
? pipelineResult.handwriting_removed
|
||||
? 'Handschrift wurde erkannt und entfernt'
|
||||
: 'Handschrift erkannt, aber nicht entfernt'
|
||||
: 'Keine Handschrift gefunden'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Result Images */}
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>Original</h3>
|
||||
{previewUrl && <img src={previewUrl} alt="Original" className="w-full rounded-xl shadow-lg" />}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>Bereinigt</h3>
|
||||
{cleanedUrl ? (
|
||||
<img src={cleanedUrl} alt="Cleaned" className="w-full rounded-xl shadow-lg" />
|
||||
) : (
|
||||
<div className={`aspect-video rounded-xl flex items-center justify-center ${isDark ? 'bg-white/5' : 'bg-slate-100'}`}>
|
||||
<span className={labelStyle}>Kein Bild</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Layout Info */}
|
||||
{pipelineResult.layout_reconstructed && pipelineResult.metadata?.layout && (
|
||||
<div className={`p-4 rounded-xl border ${cardStyle}`}>
|
||||
<h3 className={`font-medium mb-3 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
Layout-Rekonstruktion
|
||||
</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<span className={labelStyle}>Elemente:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.element_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={labelStyle}>Tabellen:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.table_count}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className={labelStyle}>Größe:</span>
|
||||
<p className={`font-medium ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
||||
{pipelineResult.metadata.layout.page_width} × {pipelineResult.metadata.layout.page_height}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user