- Add PostgreSQL service to docker-compose with health check and postgres_data volume
- Mount ./ocr_images as bind volume for persistent image storage
- Add backend/database.py with schema init and get_db() context manager
- Add 5 new API endpoints: POST /api/jobs, GET /api/jobs (search), GET /api/jobs/{id},
GET /api/jobs/{id}/image, PUT /api/jobs/{id}/review
- Jobs are saved with author/book/chapter/page metadata, auto UUID, and submitted_at timestamp
- Jobs start as 'unreviewed'; review captures edited text, reviewer name, and reviewed_at
- Add MetadataForm.jsx (author/book/chapter/page inputs) to the New Job panel
- Add JobsPanel.jsx with search/filter, paginated list, and detail pane with review form
- Add "Commit Job" button to ResultPanel (plain_ocr mode only) with success/error feedback
- Add "New Job" / "Browse Jobs" navigation to the app header
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
397 lines
15 KiB
JavaScript
397 lines
15 KiB
JavaScript
import { useEffect, useRef, useState, useCallback } from 'react'
|
||
import { motion, AnimatePresence } from 'framer-motion'
|
||
import { Copy, Download, Sparkles, Loader2, CheckCircle2, ChevronDown, Database } from 'lucide-react'
|
||
import ReactMarkdown from 'react-markdown'
|
||
import DOMPurify from 'dompurify'
|
||
|
||
export default function ResultPanel({ result, loading, imagePreview, onCopy, onDownload, onCommitJob, commitLoading, commitResult }) {
|
||
const canvasRef = useRef(null)
|
||
const imgRef = useRef(null)
|
||
const [showAdvanced, setShowAdvanced] = useState(false)
|
||
const [imageLoaded, setImageLoaded] = useState(false)
|
||
|
||
// Check if text looks like HTML (model outputs HTML, not markdown)
|
||
const isHTML = result?.text && (
|
||
result.text.includes('<table') ||
|
||
result.text.includes('<tr>') ||
|
||
result.text.includes('<td>') ||
|
||
result.text.includes('<div') ||
|
||
result.text.includes('<p>') ||
|
||
result.text.includes('<h1') ||
|
||
result.text.includes('<h2')
|
||
)
|
||
|
||
// Also check if it looks like markdown (for backwards compatibility)
|
||
const isMarkdown = result?.text && !isHTML && (
|
||
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">
|
||
{isHTML ? (
|
||
<div
|
||
className="prose prose-invert prose-sm max-w-none"
|
||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(result.text) }}
|
||
style={{
|
||
color: '#e5e7eb',
|
||
}}
|
||
/>
|
||
) : 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>
|
||
|
||
{/* Raw Response Viewer */}
|
||
{result.raw_text && (
|
||
<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">🔍 Raw Model Response</span>
|
||
<ChevronDown className="w-4 h-4 text-gray-400" />
|
||
</summary>
|
||
<div className="px-4 py-3 border-t border-white/10 space-y-2">
|
||
<p className="text-xs text-gray-400 mb-2">Unprocessed output from the model (useful for debugging)</p>
|
||
<div className="bg-black/30 rounded-lg p-3 max-h-64 overflow-y-auto">
|
||
<pre className="text-xs text-green-400 font-mono whitespace-pre-wrap break-words select-all">
|
||
{result.raw_text}
|
||
</pre>
|
||
</div>
|
||
<div className="flex gap-2 mt-2">
|
||
<button
|
||
onClick={() => navigator.clipboard.writeText(result.raw_text)}
|
||
className="text-xs px-3 py-1 bg-white/5 hover:bg-white/10 rounded-lg transition-colors"
|
||
>
|
||
Copy Raw
|
||
</button>
|
||
<span className="text-xs text-gray-500 py-1">
|
||
{result.raw_text.length} characters
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
)}
|
||
|
||
{/* 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">⚙️ Metadata & Debug Info</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">Parsed Bounding Boxes ({result.boxes.length})</p>
|
||
<div className="bg-black/30 rounded-lg p-2 space-y-1 max-h-32 overflow-y-auto">
|
||
{result.boxes.map((box, idx) => (
|
||
<div key={idx} className="text-xs font-mono">
|
||
<span className="text-cyan-400">Box {idx + 1}:</span>{' '}
|
||
<span className="text-purple-400">{box.label}</span>{' '}
|
||
<span className="text-gray-500">
|
||
[{box.box.map(n => Math.round(n)).join(', ')}]
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
<p className="text-xs text-gray-500 mt-2">
|
||
Coordinates are scaled from model output (0-999) to image pixels
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</details>
|
||
|
||
{/* Commit Job button (plain_ocr only) */}
|
||
{onCommitJob && (
|
||
<div className="space-y-2">
|
||
<motion.button
|
||
onClick={onCommitJob}
|
||
disabled={commitLoading || commitResult?.success}
|
||
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-medium text-sm transition-all ${
|
||
commitLoading || commitResult?.success
|
||
? 'opacity-50 cursor-not-allowed bg-white/5'
|
||
: 'bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500'
|
||
}`}
|
||
whileHover={!commitLoading && !commitResult?.success ? { scale: 1.02 } : {}}
|
||
whileTap={!commitLoading && !commitResult?.success ? { scale: 0.98 } : {}}
|
||
>
|
||
{commitLoading ? (
|
||
<><Loader2 className="w-4 h-4 animate-spin" /> Committing Job...</>
|
||
) : commitResult?.success ? (
|
||
<><CheckCircle2 className="w-4 h-4" /> Job Committed</>
|
||
) : (
|
||
<><Database className="w-4 h-4" /> Commit Job</>
|
||
)}
|
||
</motion.button>
|
||
|
||
{commitResult?.success && (
|
||
<div className="glass p-3 rounded-xl bg-green-500/10 border border-green-500/20">
|
||
<p className="text-xs text-green-400">
|
||
Job saved — ID: <span className="font-mono">{commitResult.job?.id}</span>
|
||
</p>
|
||
</div>
|
||
)}
|
||
{commitResult && !commitResult.success && (
|
||
<div className="glass p-3 rounded-xl bg-red-500/10 border border-red-500/20">
|
||
<p className="text-xs text-red-400">{commitResult.error}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* 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>
|
||
)
|
||
}
|