Initial commit
This commit is contained in:
83
frontend/src/components/AdvancedSettings.jsx
Normal file
83
frontend/src/components/AdvancedSettings.jsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { Sliders } from 'lucide-react'
|
||||
|
||||
export default function AdvancedSettings({ settings, onSettingsChange, includeCaption, onIncludeCaptionChange }) {
|
||||
const handleChange = (key, value) => {
|
||||
onSettingsChange({
|
||||
...settings,
|
||||
[key]: value
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="glass p-6 rounded-2xl space-y-4"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="font-semibold text-gray-200">Advanced Settings</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-gray-400">Base Size</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.base_size}
|
||||
onChange={(e) => handleChange('base_size', parseInt(e.target.value))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-gray-400">Image Size</label>
|
||||
<input
|
||||
type="number"
|
||||
value={settings.image_size}
|
||||
onChange={(e) => handleChange('image_size', parseInt(e.target.value))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-gray-400">Crop Mode</label>
|
||||
<select
|
||||
value={settings.crop_mode ? 'true' : 'false'}
|
||||
onChange={(e) => handleChange('crop_mode', e.target.value === 'true')}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="true">Enabled</option>
|
||||
<option value="false">Disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-gray-400">Test Compress</label>
|
||||
<select
|
||||
value={settings.test_compress ? 'true' : 'false'}
|
||||
onChange={(e) => handleChange('test_compress', e.target.value === 'true')}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500"
|
||||
>
|
||||
<option value="false">Disabled</option>
|
||||
<option value="true">Enabled</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeCaption}
|
||||
onChange={(e) => onIncludeCaptionChange(e.target.checked)}
|
||||
className="accent-purple-500"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Include image caption</span>
|
||||
</label>
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
99
frontend/src/components/ImageUpload.jsx
Normal file
99
frontend/src/components/ImageUpload.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useCallback } from 'react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { Upload, Image as ImageIcon, X } from 'lucide-react'
|
||||
|
||||
export default function ImageUpload({ onImageSelect, preview }) {
|
||||
const onDrop = useCallback((acceptedFiles) => {
|
||||
if (acceptedFiles?.[0]) {
|
||||
onImageSelect(acceptedFiles[0])
|
||||
}
|
||||
}, [onImageSelect])
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
onDrop,
|
||||
accept: {
|
||||
'image/*': ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp']
|
||||
},
|
||||
multiple: false
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="glass p-6 rounded-2xl space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold text-gray-200">Upload Image</h3>
|
||||
<ImageIcon className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
|
||||
{!preview ? (
|
||||
<motion.div
|
||||
{...getRootProps()}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-2xl p-12 text-center cursor-pointer
|
||||
transition-all duration-300
|
||||
${isDragActive
|
||||
? 'border-purple-500 bg-purple-500/10'
|
||||
: 'border-white/20 bg-white/5 hover:border-white/40 hover:bg-white/10'
|
||||
}
|
||||
`}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
>
|
||||
<input {...getInputProps()} />
|
||||
|
||||
<div className="space-y-4">
|
||||
<motion.div
|
||||
animate={{
|
||||
y: isDragActive ? -10 : 0,
|
||||
scale: isDragActive ? 1.1 : 1
|
||||
}}
|
||||
className="flex justify-center"
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-purple-500 to-cyan-500 rounded-2xl blur-xl opacity-50" />
|
||||
<div className="relative bg-gradient-to-br from-purple-600 to-cyan-500 p-4 rounded-2xl">
|
||||
<Upload className="w-8 h-8" />
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div>
|
||||
<p className="text-lg font-medium text-gray-200">
|
||||
{isDragActive ? 'Drop it like it\'s hot! 🔥' : 'Drag & drop your image'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
or click to browse • PNG, JPG, WEBP up to 10MB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className="relative group"
|
||||
>
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
className="w-full rounded-2xl border border-white/10"
|
||||
/>
|
||||
<motion.button
|
||||
onClick={() => onImageSelect(null)}
|
||||
className="absolute top-3 right-3 bg-red-500/80 backdrop-blur-sm p-2 rounded-full opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
whileHover={{ scale: 1.1 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</motion.button>
|
||||
|
||||
{/* Grounding overlay canvas */}
|
||||
<canvas
|
||||
id="preview-canvas"
|
||||
className="absolute top-0 left-0 w-full h-full pointer-events-none"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/ModeSelector.jsx
Normal file
105
frontend/src/components/ModeSelector.jsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { motion } from 'framer-motion'
|
||||
import { FileText, Eye, Search, Wand2 } from 'lucide-react'
|
||||
|
||||
const modes = [
|
||||
{ id: 'plain_ocr', name: 'Plain OCR', icon: FileText, color: 'from-blue-500 to-cyan-500', desc: 'Extract raw text', needsInput: false },
|
||||
{ id: 'describe', name: 'Describe', icon: Eye, color: 'from-violet-500 to-purple-500', desc: 'Image description', needsInput: false },
|
||||
{ id: 'find_ref', name: 'Find', icon: Search, color: 'from-yellow-500 to-orange-500', desc: 'Locate specific terms', needsInput: 'findTerm' },
|
||||
{ id: 'freeform', name: 'Freeform', icon: Wand2, color: 'from-fuchsia-500 to-pink-500', desc: 'Custom prompt', needsInput: 'prompt' },
|
||||
]
|
||||
|
||||
export default function ModeSelector({
|
||||
mode,
|
||||
onModeChange,
|
||||
prompt,
|
||||
onPromptChange,
|
||||
findTerm,
|
||||
onFindTermChange
|
||||
}) {
|
||||
const selectedMode = modes.find(m => m.id === mode)
|
||||
const needsInput = selectedMode?.needsInput
|
||||
|
||||
return (
|
||||
<div className="glass p-4 rounded-2xl space-y-3">
|
||||
<h3 className="text-sm font-semibold text-gray-200">Mode</h3>
|
||||
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{modes.map((m) => {
|
||||
const Icon = m.icon
|
||||
const isSelected = mode === m.id
|
||||
|
||||
return (
|
||||
<motion.button
|
||||
key={m.id}
|
||||
onClick={() => onModeChange(m.id)}
|
||||
className={`
|
||||
relative p-2 rounded-xl text-center transition-all
|
||||
${isSelected
|
||||
? 'glass border-white/20 shadow-lg'
|
||||
: 'bg-white/5 border border-white/10 hover:border-white/20'
|
||||
}
|
||||
`}
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
>
|
||||
{isSelected && (
|
||||
<motion.div
|
||||
layoutId="selected-mode"
|
||||
className={`absolute inset-0 bg-gradient-to-br ${m.color} opacity-10 rounded-xl`}
|
||||
transition={{ type: "spring", bounce: 0.2, duration: 0.6 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative space-y-1">
|
||||
<div className={`
|
||||
w-8 h-8 mx-auto rounded-lg flex items-center justify-center
|
||||
${isSelected
|
||||
? `bg-gradient-to-br ${m.color}`
|
||||
: 'bg-white/10'
|
||||
}
|
||||
`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
</div>
|
||||
<p className={`text-xs font-medium ${isSelected ? 'text-white' : 'text-gray-300'}`}>
|
||||
{m.name}
|
||||
</p>
|
||||
</div>
|
||||
</motion.button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{needsInput === 'findTerm' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={findTerm}
|
||||
onChange={(e) => onFindTermChange(e.target.value)}
|
||||
placeholder="Enter term to find (e.g., Total, Invoice #)"
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500 transition-colors"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{needsInput === 'prompt' && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<textarea
|
||||
value={prompt}
|
||||
onChange={(e) => onPromptChange(e.target.value)}
|
||||
placeholder="Enter your custom prompt..."
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm focus:outline-none focus:border-purple-500 transition-colors resize-none"
|
||||
rows={2}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
302
frontend/src/components/ResultPanel.jsx
Normal file
302
frontend/src/components/ResultPanel.jsx
Normal file
@@ -0,0 +1,302 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import { motion, AnimatePresence } from 'framer-motion'
|
||||
import { Copy, Download, Sparkles, Loader2, CheckCircle2, ChevronDown } from 'lucide-react'
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
|
||||
export default function ResultPanel({ result, loading, imagePreview, onCopy, onDownload }) {
|
||||
const canvasRef = useRef(null)
|
||||
const imgRef = useRef(null)
|
||||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||||
const [imageLoaded, setImageLoaded] = useState(false)
|
||||
|
||||
// Check if text looks like markdown
|
||||
const isMarkdown = result?.text && (
|
||||
result.text.includes('##') ||
|
||||
result.text.includes('**') ||
|
||||
result.text.includes('```') ||
|
||||
result.text.includes('- ') ||
|
||||
result.text.includes('|')
|
||||
)
|
||||
|
||||
// Draw boxes function
|
||||
const drawBoxes = useCallback(() => {
|
||||
if (!result?.boxes?.length || !canvasRef.current || !imgRef.current) {
|
||||
console.log('❌ Cannot draw - missing:', {
|
||||
hasBoxes: !!result?.boxes?.length,
|
||||
hasCanvas: !!canvasRef.current,
|
||||
hasImgRef: !!imgRef.current
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
console.log('🎨 Drawing boxes:', result.boxes)
|
||||
|
||||
const img = imgRef.current
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas.getContext('2d')
|
||||
|
||||
console.log('📐 Image dimensions:', {
|
||||
displayWidth: img.offsetWidth,
|
||||
displayHeight: img.offsetHeight,
|
||||
naturalWidth: img.naturalWidth,
|
||||
naturalHeight: img.naturalHeight,
|
||||
imageDims: result.image_dims
|
||||
})
|
||||
|
||||
// Set canvas size to match displayed image
|
||||
canvas.width = img.offsetWidth
|
||||
canvas.height = img.offsetHeight
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
|
||||
// Calculate scale factors
|
||||
const scaleX = img.offsetWidth / (result.image_dims?.w || img.naturalWidth)
|
||||
const scaleY = img.offsetHeight / (result.image_dims?.h || img.naturalHeight)
|
||||
|
||||
console.log('📏 Scale factors:', { scaleX, scaleY })
|
||||
|
||||
// Draw boxes
|
||||
result.boxes.forEach((box, idx) => {
|
||||
const [x1, y1, x2, y2] = box.box
|
||||
const colors = [
|
||||
'#00ff00', '#00ffff', '#ff00ff', '#ffff00', '#ff0066'
|
||||
]
|
||||
const color = colors[idx % colors.length]
|
||||
|
||||
// Scale coordinates
|
||||
const sx = x1 * scaleX
|
||||
const sy = y1 * scaleY
|
||||
const sw = (x2 - x1) * scaleX
|
||||
const sh = (y2 - y1) * scaleY
|
||||
|
||||
console.log(`📦 Box ${idx} (${box.label}):`, {
|
||||
original: [x1, y1, x2, y2],
|
||||
scaled: [sx, sy, sx + sw, sy + sh],
|
||||
dimensions: { width: sw, height: sh }
|
||||
})
|
||||
|
||||
// Draw semi-transparent fill
|
||||
ctx.fillStyle = color + '33'
|
||||
ctx.fillRect(sx, sy, sw, sh)
|
||||
|
||||
// Draw thick neon border
|
||||
ctx.strokeStyle = color
|
||||
ctx.lineWidth = 4
|
||||
ctx.shadowColor = color
|
||||
ctx.shadowBlur = 10
|
||||
ctx.strokeRect(sx, sy, sw, sh)
|
||||
ctx.shadowBlur = 0
|
||||
|
||||
// Label background
|
||||
if (box.label) {
|
||||
ctx.font = 'bold 14px Inter'
|
||||
const metrics = ctx.measureText(box.label)
|
||||
const padding = 8
|
||||
const labelHeight = 24
|
||||
|
||||
ctx.fillStyle = color
|
||||
ctx.fillRect(sx, sy - labelHeight, metrics.width + padding * 2, labelHeight)
|
||||
|
||||
// Label text
|
||||
ctx.fillStyle = '#000'
|
||||
ctx.fillText(box.label, sx + padding, sy - 7)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('✅ Finished drawing', result.boxes.length, 'boxes')
|
||||
}, [result])
|
||||
|
||||
// Trigger drawing when image loads
|
||||
useEffect(() => {
|
||||
if (imageLoaded && result?.boxes?.length) {
|
||||
console.log('🚀 Image loaded, drawing boxes now')
|
||||
drawBoxes()
|
||||
}
|
||||
}, [imageLoaded, result, drawBoxes])
|
||||
|
||||
// Reset imageLoaded when result changes
|
||||
useEffect(() => {
|
||||
setImageLoaded(false)
|
||||
}, [result])
|
||||
|
||||
// Redraw on window resize
|
||||
useEffect(() => {
|
||||
if (!imageLoaded || !result?.boxes?.length) return
|
||||
|
||||
const handleResize = () => {
|
||||
console.log('📐 Window resized, redrawing')
|
||||
drawBoxes()
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [imageLoaded, result, drawBoxes])
|
||||
|
||||
return (
|
||||
<div className="glass p-6 rounded-2xl space-y-4 h-full">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5 text-purple-400" />
|
||||
<h3 className="font-semibold text-gray-200">Results</h3>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="flex gap-2">
|
||||
<motion.button
|
||||
onClick={onCopy}
|
||||
className="glass glass-hover p-2 rounded-lg"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title="Copy to clipboard"
|
||||
>
|
||||
<Copy className="w-4 h-4" />
|
||||
</motion.button>
|
||||
<motion.button
|
||||
onClick={onDownload}
|
||||
className="glass glass-hover p-2 rounded-lg"
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
title="Download"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
</motion.button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{loading ? (
|
||||
<motion.div
|
||||
key="loading"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center justify-center py-20 space-y-4"
|
||||
>
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{ duration: 2, repeat: Infinity, ease: "linear" }}
|
||||
className="w-16 h-16 border-4 border-purple-500/20 border-t-purple-500 rounded-full"
|
||||
/>
|
||||
<Loader2 className="w-8 h-8 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-purple-400" />
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 animate-pulse">
|
||||
Processing your image with AI magic...
|
||||
</p>
|
||||
</motion.div>
|
||||
) : result ? (
|
||||
<motion.div
|
||||
key="result"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="space-y-4"
|
||||
>
|
||||
{/* Preview with boxes */}
|
||||
{imagePreview && result.boxes && result.boxes.length > 0 && (
|
||||
<div className="relative rounded-xl overflow-hidden border border-white/10 bg-black">
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={imagePreview}
|
||||
alt="Result"
|
||||
className="w-full block"
|
||||
onLoad={() => {
|
||||
console.log('🖼️ Image loaded, triggering draw')
|
||||
setImageLoaded(true)
|
||||
}}
|
||||
/>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute top-0 left-0 w-full h-full pointer-events-none"
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text result */}
|
||||
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-96 overflow-y-auto">
|
||||
{isMarkdown ? (
|
||||
<div className="prose prose-invert prose-sm max-w-none">
|
||||
<ReactMarkdown>{result.text}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-sm text-gray-200 whitespace-pre-wrap font-mono">
|
||||
{result.text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Settings Dropdown */}
|
||||
<details className="glass rounded-xl overflow-hidden">
|
||||
<summary className="px-4 py-3 cursor-pointer flex items-center justify-between hover:bg-white/5 transition-colors">
|
||||
<span className="text-sm font-medium text-gray-300">Advanced Settings & Metadata</span>
|
||||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||||
</summary>
|
||||
<div className="px-4 py-3 border-t border-white/10 space-y-3">
|
||||
{result.metadata && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-2">Processing Metadata</p>
|
||||
<pre className="text-xs text-gray-500 whitespace-pre-wrap">
|
||||
{JSON.stringify(result.metadata, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{result.boxes?.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-2">Detected Regions ({result.boxes.length})</p>
|
||||
<div className="space-y-1">
|
||||
{result.boxes.map((box, idx) => (
|
||||
<div key={idx} className="text-xs text-gray-500">
|
||||
{box.label}: [{box.box.map(n => Math.round(n)).join(', ')}]
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{/* Success indicator */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
className="flex items-center justify-center gap-2 text-green-400"
|
||||
>
|
||||
<CheckCircle2 className="w-5 h-5" />
|
||||
<span className="text-sm font-medium">Processing complete!</span>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="empty"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center justify-center py-20 space-y-4"
|
||||
>
|
||||
<div className="relative">
|
||||
<motion.div
|
||||
animate={{
|
||||
scale: [1, 1.2, 1],
|
||||
opacity: [0.5, 0.8, 0.5]
|
||||
}}
|
||||
transition={{ duration: 3, repeat: Infinity }}
|
||||
className="w-20 h-20 bg-purple-500/20 rounded-full blur-xl"
|
||||
/>
|
||||
<Sparkles className="w-10 h-10 absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-purple-400" />
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium text-gray-300">
|
||||
Ready to process
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
Upload an image and hit analyze to see the magic!
|
||||
</p>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user