Add job tracking with PostgreSQL, image storage, and review workflow

- 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>
This commit is contained in:
Aaron Roberts
2026-06-09 16:48:12 +01:00
parent 68147eb97c
commit fd747e6c23
9 changed files with 1208 additions and 212 deletions

View File

@@ -17,6 +17,15 @@ CORS_ORIGINS=http://localhost:3000
# Upload Configuration
MAX_UPLOAD_SIZE_MB=100
# PostgreSQL Configuration
POSTGRES_USER=ocr_user
POSTGRES_PASSWORD=ocr_password
POSTGRES_DB=ocr_db
DATABASE_URL=postgresql://ocr_user:ocr_password@postgres:5432/ocr_db
# OCR Image Storage (host path mounted into container)
OCR_IMAGES_DIR=/data/ocr_images
# Processing Configuration
BASE_SIZE=1024
IMAGE_SIZE=640

71
backend/database.py Normal file
View File

@@ -0,0 +1,71 @@
import os
import psycopg2
import psycopg2.extras
from contextlib import contextmanager
from decouple import config as env_config
DATABASE_URL = env_config(
"DATABASE_URL",
default="postgresql://ocr_user:ocr_password@postgres:5432/ocr_db"
)
def _get_conn():
return psycopg2.connect(DATABASE_URL, cursor_factory=psycopg2.extras.RealDictCursor)
def init_db():
"""Create tables if they don't exist. Called once at startup."""
conn = None
try:
conn = _get_conn()
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS ocr_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
author TEXT,
book TEXT,
chapter TEXT,
page TEXT,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
image_path TEXT NOT NULL,
original_filename TEXT,
ocr_text TEXT,
status TEXT NOT NULL DEFAULT 'unreviewed',
reviewed_text TEXT,
reviewer_name TEXT,
reviewed_at TIMESTAMPTZ,
mode TEXT
)
""")
# Index for fast full-text-style searches on common fields
cur.execute("""
CREATE INDEX IF NOT EXISTS ocr_jobs_status_idx ON ocr_jobs(status)
""")
cur.execute("""
CREATE INDEX IF NOT EXISTS ocr_jobs_submitted_at_idx ON ocr_jobs(submitted_at DESC)
""")
conn.commit()
print("Database initialized.")
except Exception as exc:
print(f"Database init failed: {exc}")
if conn:
conn.rollback()
raise
finally:
if conn:
conn.close()
@contextmanager
def get_db():
"""Yield a connection and auto-commit/rollback."""
conn = _get_conn()
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()

View File

@@ -1,14 +1,17 @@
import os
import re
import uuid
import tempfile
import shutil
import base64
from typing import List, Dict, Any, Optional
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
from fastapi import FastAPI, File, UploadFile, Form, HTTPException, Query
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from fastapi.responses import JSONResponse, StreamingResponse, FileResponse
from pydantic import BaseModel
import torch
from transformers import AutoModel, AutoTokenizer
from PIL import Image
@@ -24,6 +27,9 @@ from pdf_utils import (
clean_markdown_content
)
from format_converter import DocumentConverter
from database import init_db, get_db
OCR_IMAGES_DIR = env_config("OCR_IMAGES_DIR", default="/data/ocr_images")
# -----------------------------
# Lifespan context for model loading
@@ -36,6 +42,15 @@ async def lifespan(app: FastAPI):
"""Load model on startup, cleanup on shutdown"""
global model, tokenizer
# Image storage directory
os.makedirs(OCR_IMAGES_DIR, exist_ok=True)
# Database
try:
init_db()
except Exception as exc:
print(f"Warning: database initialization failed: {exc}")
# Environment setup
os.environ.pop("TRANSFORMERS_CACHE", None)
MODEL_NAME = env_config("MODEL_NAME", default="deepseek-ai/DeepSeek-OCR")
@@ -581,6 +596,238 @@ async def process_pdf(
print(traceback.format_exc())
raise HTTPException(status_code=500, detail="An internal error occurred during PDF processing.")
# -----------------------------
# Job management routes
# -----------------------------
class ReviewRequest(BaseModel):
reviewed_text: str
reviewer_name: str
def _job_row_to_dict(row) -> Dict[str, Any]:
"""Convert a DB row (RealDictRow) to a plain dict with serialisable values."""
d = dict(row)
for key, val in d.items():
if isinstance(val, datetime):
d[key] = val.isoformat()
elif val is not None and hasattr(val, '__str__') and type(val).__name__ == 'UUID':
d[key] = str(val)
return d
@app.post("/api/jobs")
async def commit_job(
image: UploadFile = File(...),
author: str = Form(""),
book: str = Form(""),
chapter: str = Form(""),
page: str = Form(""),
ocr_text: str = Form(""),
mode: str = Form("plain_ocr"),
):
"""Commit an OCR job: save the image and insert a DB record."""
job_id = str(uuid.uuid4())
# Determine file extension from original filename or content type
original_filename = image.filename or "image"
ext = os.path.splitext(original_filename)[1].lower()
if not ext:
ct = (image.content_type or "").lower()
ext_map = {
"image/png": ".png", "image/jpeg": ".jpg", "image/jpg": ".jpg",
"image/webp": ".webp", "image/gif": ".gif", "image/bmp": ".bmp",
}
ext = ext_map.get(ct, ".png")
image_path = os.path.join(OCR_IMAGES_DIR, f"{job_id}{ext}")
try:
content = await image.read()
with open(image_path, "wb") as f:
f.write(content)
except Exception as exc:
raise HTTPException(status_code=500, detail="Failed to save image file.")
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO ocr_jobs
(id, author, book, chapter, page, image_path, original_filename,
ocr_text, mode, status)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'unreviewed')
RETURNING *
""",
(job_id, author or None, book or None, chapter or None,
page or None, image_path, original_filename,
ocr_text or None, mode),
)
row = cur.fetchone()
except Exception as exc:
# Clean up saved image if DB insert fails
try:
os.remove(image_path)
except Exception:
pass
print(f"Job commit DB error: {exc}")
raise HTTPException(status_code=500, detail="Failed to save job to database.")
return JSONResponse(_job_row_to_dict(row), status_code=201)
@app.get("/api/jobs")
async def list_jobs(
search: Optional[str] = Query(None, description="General text search across all fields"),
author: Optional[str] = Query(None),
book: Optional[str] = Query(None),
chapter: Optional[str] = Query(None),
status: Optional[str] = Query(None, description="unreviewed | reviewed"),
limit: int = Query(20, ge=1, le=200),
offset: int = Query(0, ge=0),
):
"""Search and list jobs. All filters are optional and combinable."""
conditions = []
params: List[Any] = []
if search:
conditions.append(
"(author ILIKE %s OR book ILIKE %s OR chapter ILIKE %s "
"OR page ILIKE %s OR ocr_text ILIKE %s OR reviewer_name ILIKE %s)"
)
like = f"%{search}%"
params.extend([like, like, like, like, like, like])
if author:
conditions.append("author ILIKE %s")
params.append(f"%{author}%")
if book:
conditions.append("book ILIKE %s")
params.append(f"%{book}%")
if chapter:
conditions.append("chapter ILIKE %s")
params.append(f"%{chapter}%")
if status:
conditions.append("status = %s")
params.append(status)
where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
f"SELECT COUNT(*) AS total FROM ocr_jobs {where}",
params,
)
total = cur.fetchone()["total"]
cur.execute(
f"""
SELECT id, author, book, chapter, page, submitted_at, status,
reviewer_name, reviewed_at, mode, original_filename
FROM ocr_jobs {where}
ORDER BY submitted_at DESC
LIMIT %s OFFSET %s
""",
params + [limit, offset],
)
rows = [_job_row_to_dict(r) for r in cur.fetchall()]
except Exception as exc:
print(f"list_jobs DB error: {exc}")
raise HTTPException(status_code=500, detail="Database error.")
return JSONResponse({"total": total, "limit": limit, "offset": offset, "jobs": rows})
@app.get("/api/jobs/{job_id}")
async def get_job(job_id: str):
"""Retrieve full job record including OCR text."""
try:
uuid.UUID(job_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid job ID.")
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT * FROM ocr_jobs WHERE id = %s", (job_id,))
row = cur.fetchone()
except Exception as exc:
print(f"get_job DB error: {exc}")
raise HTTPException(status_code=500, detail="Database error.")
if not row:
raise HTTPException(status_code=404, detail="Job not found.")
return JSONResponse(_job_row_to_dict(row))
@app.get("/api/jobs/{job_id}/image")
async def get_job_image(job_id: str):
"""Serve the stored image for a job."""
try:
uuid.UUID(job_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid job ID.")
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute("SELECT image_path FROM ocr_jobs WHERE id = %s", (job_id,))
row = cur.fetchone()
except Exception as exc:
print(f"get_job_image DB error: {exc}")
raise HTTPException(status_code=500, detail="Database error.")
if not row:
raise HTTPException(status_code=404, detail="Job not found.")
path = row["image_path"]
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="Image file not found on disk.")
return FileResponse(path)
@app.put("/api/jobs/{job_id}/review")
async def review_job(job_id: str, body: ReviewRequest):
"""Mark a job as reviewed with the corrected text and reviewer name."""
try:
uuid.UUID(job_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid job ID.")
try:
with get_db() as conn:
with conn.cursor() as cur:
cur.execute(
"""
UPDATE ocr_jobs
SET status = 'reviewed',
reviewed_text = %s,
reviewer_name = %s,
reviewed_at = NOW()
WHERE id = %s
RETURNING *
""",
(body.reviewed_text, body.reviewer_name, job_id),
)
row = cur.fetchone()
except Exception as exc:
print(f"review_job DB error: {exc}")
raise HTTPException(status_code=500, detail="Database error.")
if not row:
raise HTTPException(status_code=404, detail="Job not found.")
return JSONResponse(_job_row_to_dict(row))
if __name__ == "__main__":
host = env_config("API_HOST", default="0.0.0.0")
port = env_config("API_PORT", default=8000, cast=int)

View File

@@ -15,3 +15,4 @@ PyMuPDF>=1.23.0
img2pdf>=0.5.0
python-docx>=1.1.0
markdown>=3.5.0
psycopg2-binary>=2.9.0

View File

@@ -1,4 +1,19 @@
services:
postgres:
image: postgres:16-alpine
container_name: deepseek-ocr-postgres
environment:
POSTGRES_USER: ${POSTGRES_USER:-ocr_user}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ocr_password}
POSTGRES_DB: ${POSTGRES_DB:-ocr_db}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-ocr_user} -d ${POSTGRES_DB:-ocr_db}"]
interval: 5s
timeout: 5s
retries: 10
backend:
build: ./backend
container_name: deepseek-ocr-backend
@@ -10,8 +25,14 @@ services:
API_HOST: ${API_HOST:-0.0.0.0}
API_PORT: ${API_PORT:-8000}
MAX_UPLOAD_SIZE_MB: ${MAX_UPLOAD_SIZE_MB:-100}
DATABASE_URL: ${DATABASE_URL:-postgresql://ocr_user:ocr_password@postgres:5432/ocr_db}
OCR_IMAGES_DIR: ${OCR_IMAGES_DIR:-/data/ocr_images}
volumes:
- ./models:/models
- ./ocr_images:/data/ocr_images
depends_on:
postgres:
condition: service_healthy
deploy:
resources:
reservations:
@@ -22,8 +43,6 @@ services:
shm_size: "4g"
ports:
- "${API_PORT:-8000}:${API_PORT:-8000}"
networks:
- ocr-network
frontend:
build: ./frontend
@@ -32,9 +51,10 @@ services:
- "${FRONTEND_PORT:-3000}:80"
depends_on:
- backend
networks:
- ocr-network
volumes:
postgres_data:
networks:
ocr-network:
driver: bridge
default:
name: rw-research

View File

@@ -1,16 +1,21 @@
import { useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Sparkles, Zap, Loader2, Settings, Image as ImageIcon, FileText } from 'lucide-react'
import { Sparkles, Zap, Loader2, Settings, Image as ImageIcon, FileText, Layers } from 'lucide-react'
import ImageUpload from './components/ImageUpload'
import ModeSelector from './components/ModeSelector'
import ResultPanel from './components/ResultPanel'
import AdvancedSettings from './components/AdvancedSettings'
import PDFProcessor from './components/PDFProcessor'
import MetadataForm from './components/MetadataForm'
import JobsPanel from './components/JobsPanel'
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL || '/api'
function App() {
const [view, setView] = useState('new_job') // 'new_job' | 'jobs'
// OCR state
const [mode, setMode] = useState('plain_ocr')
const [fileType, setFileType] = useState('image') // 'image' or 'pdf'
const [image, setImage] = useState(null)
@@ -20,7 +25,7 @@ function App() {
const [error, setError] = useState(null)
const [showAdvanced, setShowAdvanced] = useState(false)
const [includeCaption, setIncludeCaption] = useState(false)
// Form state
const [prompt, setPrompt] = useState('')
const [findTerm, setFindTerm] = useState('')
@@ -31,12 +36,16 @@ function App() {
test_compress: false
})
// Job metadata
const [metadata, setMetadata] = useState({ author: '', book: '', chapter: '', page: '' })
// Job commit state
const [commitLoading, setCommitLoading] = useState(false)
const [commitResult, setCommitResult] = useState(null)
const handleFileTypeChange = useCallback((newType) => {
// Clear current file when switching types
setImage(null)
if (imagePreview) {
URL.revokeObjectURL(imagePreview)
}
if (imagePreview) URL.revokeObjectURL(imagePreview)
setImagePreview(null)
setError(null)
setResult(null)
@@ -45,24 +54,17 @@ function App() {
const handleImageSelect = useCallback((file) => {
if (file === null) {
// Clear everything when removing image
setImage(null)
if (imagePreview && fileType === 'image') {
URL.revokeObjectURL(imagePreview)
}
if (imagePreview && fileType === 'image') URL.revokeObjectURL(imagePreview)
setImagePreview(null)
setError(null)
setResult(null)
} else {
setImage(file)
// Only create preview URL for images, not PDFs
if (fileType === 'image') {
setImagePreview(URL.createObjectURL(file))
} else {
setImagePreview(file) // Just store the file for PDFs
}
setImagePreview(fileType === 'image' ? URL.createObjectURL(file) : file)
setError(null)
setResult(null)
setCommitResult(null)
}
}, [imagePreview, fileType])
@@ -71,16 +73,15 @@ function App() {
setError('Please upload an image first')
return
}
setLoading(true)
setError(null)
setCommitResult(null)
try {
const formData = new FormData()
formData.append('image', image)
formData.append('mode', mode)
formData.append('prompt', prompt)
// Enable grounding only for find mode
formData.append('grounding', mode === 'find_ref')
formData.append('include_caption', includeCaption)
formData.append('find_term', findTerm)
@@ -91,11 +92,8 @@ function App() {
formData.append('test_compress', advancedSettings.test_compress)
const response = await axios.post(`${API_BASE}/ocr`, formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
headers: { 'Content-Type': 'multipart/form-data' },
})
setResult(response.data)
} catch (err) {
setError(err.response?.data?.detail || err.message || 'An error occurred')
@@ -104,23 +102,38 @@ function App() {
}
}
const handleCopy = useCallback(() => {
if (result?.text) {
navigator.clipboard.writeText(result.text)
const handleCommitJob = useCallback(async () => {
if (!image || !result?.text) return
setCommitLoading(true)
setCommitResult(null)
try {
const formData = new FormData()
formData.append('image', image)
formData.append('author', metadata.author)
formData.append('book', metadata.book)
formData.append('chapter', metadata.chapter)
formData.append('page', metadata.page)
formData.append('ocr_text', result.text)
formData.append('mode', mode)
const response = await axios.post(`${API_BASE}/jobs`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
})
setCommitResult({ success: true, job: response.data })
} catch (err) {
setCommitResult({ success: false, error: err.response?.data?.detail || err.message })
} finally {
setCommitLoading(false)
}
}, [image, result, metadata, mode])
const handleCopy = useCallback(() => {
if (result?.text) navigator.clipboard.writeText(result.text)
}, [result])
const handleDownload = useCallback(() => {
if (!result?.text) return
const extensions = {
plain_ocr: 'txt',
describe: 'txt',
find_ref: 'txt',
freeform: 'txt',
}
const ext = extensions[mode] || 'txt'
const ext = { plain_ocr: 'txt', describe: 'txt', find_ref: 'txt', freeform: 'txt' }[mode] || 'txt'
const blob = new Blob([result.text], { type: 'text/plain' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
@@ -138,27 +151,13 @@ function App() {
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNjAiIGhlaWdodD0iNjAiIHZpZXdCb3g9IjAgMCA2MCA2MCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBmaWxsPSJub25lIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiPjxwYXRoIGQ9Ik0zNiAxOGMzLjMxIDAgNiAyLjY5IDYgNnMtMi42OSA2LTYgNi02LTIuNjktNi02IDIuNjktNiA2LTZ6TTI0IDZjMy4zMSAwIDYgMi42OSA2IDZzLTIuNjkgNi02IDYtNi0yLjY5LTYtNiAyLjY5LTYgNi02ek00OCAzNmMzLjMxIDAgNiAyLjY5IDYgNnMtMi42OSA2LTYgNi02LTIuNjktNi02IDIuNjktNiA2LTZ6IiBzdHJva2U9InJnYmEoMTQ3LCA1MSwgMjM0LCAwLjEpIiBzdHJva2Utd2lkdGg9IjIiLz48L2c+PC9zdmc+')] opacity-30" />
<motion.div
className="absolute top-20 left-20 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl"
animate={{
scale: [1, 1.2, 1],
opacity: [0.3, 0.5, 0.3],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut"
}}
animate={{ scale: [1, 1.2, 1], opacity: [0.3, 0.5, 0.3] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
/>
<motion.div
className="absolute bottom-20 right-20 w-96 h-96 bg-cyan-500/10 rounded-full blur-3xl"
animate={{
scale: [1.2, 1, 1.2],
opacity: [0.5, 0.3, 0.5],
}}
transition={{
duration: 8,
repeat: Infinity,
ease: "easeInOut"
}}
animate={{ scale: [1.2, 1, 1.2], opacity: [0.5, 0.3, 0.5] }}
transition={{ duration: 8, repeat: Infinity, ease: "easeInOut" }}
/>
</div>
@@ -166,7 +165,7 @@ function App() {
<header className="sticky top-0 z-50 glass border-b border-white/10">
<div className="max-w-7xl mx-auto px-6 py-4">
<div className="flex items-center justify-between">
<motion.div
<motion.div
className="flex items-center gap-3"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
@@ -182,173 +181,229 @@ function App() {
<p className="text-xs text-gray-400">Next-Gen Vision AI</p>
</div>
</motion.div>
{/* Navigation */}
<nav className="flex gap-2">
<motion.button
onClick={() => setView('new_job')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${
view === 'new_job'
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
: 'glass text-gray-400 hover:bg-white/5'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Zap className="w-4 h-4" />
New Job
</motion.button>
<motion.button
onClick={() => setView('jobs')}
className={`flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all ${
view === 'jobs'
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
: 'glass text-gray-400 hover:bg-white/5'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Layers className="w-4 h-4" />
Browse Jobs
</motion.button>
</nav>
</div>
</div>
</header>
{/* Main Content */}
<main className="max-w-7xl mx-auto px-6 py-8">
<div className="grid lg:grid-cols-2 gap-6">
{/* Left Panel - Upload & Controls */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="space-y-6"
>
{/* File Type Toggle */}
<div className="glass p-4 rounded-2xl">
<div className="grid grid-cols-2 gap-2">
<motion.button
onClick={() => handleFileTypeChange('image')}
className={`p-3 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2 ${
fileType === 'image'
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
: 'glass text-gray-400 hover:bg-white/5'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<ImageIcon className="w-4 h-4" />
Image OCR
</motion.button>
<motion.button
onClick={() => handleFileTypeChange('pdf')}
className={`p-3 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2 ${
fileType === 'pdf'
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
: 'glass text-gray-400 hover:bg-white/5'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<FileText className="w-4 h-4" />
PDF Processing
</motion.button>
</div>
</div>
{/* Mode Selector with integrated inputs */}
<ModeSelector
mode={mode}
onModeChange={setMode}
prompt={prompt}
onPromptChange={setPrompt}
findTerm={findTerm}
onFindTermChange={setFindTerm}
/>
{/* Image/PDF Upload */}
<ImageUpload
onImageSelect={handleImageSelect}
preview={imagePreview}
fileType={fileType}
/>
{/* Advanced Settings Toggle */}
<motion.button
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full glass px-4 py-3 rounded-2xl flex items-center justify-between hover:bg-white/5 transition-colors"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
<AnimatePresence mode="wait">
{view === 'jobs' ? (
<motion.div
key="jobs"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-purple-400" />
<span className="text-sm font-medium text-gray-300">Advanced Settings</span>
</div>
<motion.div
animate={{ rotate: showAdvanced ? 180 : 0 }}
transition={{ duration: 0.3 }}
>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</motion.div>
</motion.button>
{/* Advanced Settings Panel */}
<AnimatePresence>
{showAdvanced && (
<AdvancedSettings
settings={advancedSettings}
onSettingsChange={setAdvancedSettings}
includeCaption={includeCaption}
onIncludeCaptionChange={setIncludeCaption}
/>
)}
</AnimatePresence>
{/* Action Button / PDF Processor */}
{fileType === 'pdf' ? (
<PDFProcessor
pdfFile={image}
mode={mode}
prompt={prompt}
advancedSettings={advancedSettings}
includeCaption={includeCaption}
/>
) : (
<>
<motion.button
onClick={handleSubmit}
disabled={!image || loading}
className={`w-full relative overflow-hidden rounded-2xl p-[2px] ${
!image || loading ? 'opacity-50 cursor-not-allowed' : ''
}`}
whileHover={!loading && image ? { scale: 1.02 } : {}}
whileTap={!loading && image ? { scale: 0.98 } : {}}
<JobsPanel />
</motion.div>
) : (
<motion.div
key="new_job"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
>
<div className="grid lg:grid-cols-2 gap-6">
{/* Left Panel - Upload & Controls */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
className="space-y-6"
>
<div className="absolute inset-0 bg-gradient-to-r from-purple-600 via-pink-600 to-cyan-600 animate-gradient" />
<div className="relative bg-dark-100 px-8 py-4 rounded-2xl flex items-center justify-center gap-3">
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="font-semibold">Processing Magic...</span>
</>
) : (
<>
<Zap className="w-5 h-5" />
<span className="font-semibold">Analyze Image</span>
</>
)}
{/* File Type Toggle */}
<div className="glass p-4 rounded-2xl">
<div className="grid grid-cols-2 gap-2">
<motion.button
onClick={() => handleFileTypeChange('image')}
className={`p-3 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2 ${
fileType === 'image'
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
: 'glass text-gray-400 hover:bg-white/5'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<ImageIcon className="w-4 h-4" />
Image OCR
</motion.button>
<motion.button
onClick={() => handleFileTypeChange('pdf')}
className={`p-3 rounded-xl text-sm font-medium transition-all flex items-center justify-center gap-2 ${
fileType === 'pdf'
? 'bg-gradient-to-r from-purple-600 to-cyan-600 text-white'
: 'glass text-gray-400 hover:bg-white/5'
}`}
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<FileText className="w-4 h-4" />
PDF Processing
</motion.button>
</div>
</div>
</motion.button>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="glass p-4 rounded-2xl border-red-500/50 bg-red-500/10"
{/* Job Metadata */}
<MetadataForm metadata={metadata} onChange={setMetadata} />
{/* Mode Selector with integrated inputs */}
<ModeSelector
mode={mode}
onModeChange={setMode}
prompt={prompt}
onPromptChange={setPrompt}
findTerm={findTerm}
onFindTermChange={setFindTerm}
/>
{/* Image/PDF Upload */}
<ImageUpload
onImageSelect={handleImageSelect}
preview={imagePreview}
fileType={fileType}
/>
{/* Advanced Settings Toggle */}
<motion.button
onClick={() => setShowAdvanced(!showAdvanced)}
className="w-full glass px-4 py-3 rounded-2xl flex items-center justify-between hover:bg-white/5 transition-colors"
whileHover={{ scale: 1.01 }}
whileTap={{ scale: 0.99 }}
>
<p className="text-sm text-red-400">{error}</p>
</motion.div>
)}
</>
)}
</motion.div>
<div className="flex items-center gap-2">
<Settings className="w-4 h-4 text-purple-400" />
<span className="text-sm font-medium text-gray-300">Advanced Settings</span>
</div>
<motion.div
animate={{ rotate: showAdvanced ? 180 : 0 }}
transition={{ duration: 0.3 }}
>
<svg className="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</motion.div>
</motion.button>
{/* Right Panel - Results */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<ResultPanel
result={result}
loading={loading}
imagePreview={imagePreview}
onCopy={handleCopy}
onDownload={handleDownload}
/>
</motion.div>
</div>
{/* Advanced Settings Panel */}
<AnimatePresence>
{showAdvanced && (
<AdvancedSettings
settings={advancedSettings}
onSettingsChange={setAdvancedSettings}
includeCaption={includeCaption}
onIncludeCaptionChange={setIncludeCaption}
/>
)}
</AnimatePresence>
{/* Action Button / PDF Processor */}
{fileType === 'pdf' ? (
<PDFProcessor
pdfFile={image}
mode={mode}
prompt={prompt}
advancedSettings={advancedSettings}
includeCaption={includeCaption}
/>
) : (
<>
<motion.button
onClick={handleSubmit}
disabled={!image || loading}
className={`w-full relative overflow-hidden rounded-2xl p-[2px] ${
!image || loading ? 'opacity-50 cursor-not-allowed' : ''
}`}
whileHover={!loading && image ? { scale: 1.02 } : {}}
whileTap={!loading && image ? { scale: 0.98 } : {}}
>
<div className="absolute inset-0 bg-gradient-to-r from-purple-600 via-pink-600 to-cyan-600 animate-gradient" />
<div className="relative bg-dark-100 px-8 py-4 rounded-2xl flex items-center justify-center gap-3">
{loading ? (
<>
<Loader2 className="w-5 h-5 animate-spin" />
<span className="font-semibold">Processing Magic...</span>
</>
) : (
<>
<Zap className="w-5 h-5" />
<span className="font-semibold">Analyze Image</span>
</>
)}
</div>
</motion.button>
{error && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="glass p-4 rounded-2xl border-red-500/50 bg-red-500/10"
>
<p className="text-sm text-red-400">{error}</p>
</motion.div>
)}
</>
)}
</motion.div>
{/* Right Panel - Results */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<ResultPanel
result={result}
loading={loading}
imagePreview={imagePreview}
onCopy={handleCopy}
onDownload={handleDownload}
onCommitJob={mode === 'plain_ocr' && result ? handleCommitJob : null}
commitLoading={commitLoading}
commitResult={commitResult}
/>
</motion.div>
</div>
</motion.div>
)}
</AnimatePresence>
</main>
{/* Footer */}
<footer className="mt-20 border-t border-white/10 glass">
<div className="max-w-7xl mx-auto px-6 py-8 text-center space-y-2">
<p className="text-sm text-gray-400">
Powered by <span className="gradient-text font-semibold">DeepSeek-OCR</span>
Powered by <span className="gradient-text font-semibold">DeepSeek-OCR</span> &bull;
Built with <span className="text-pink-400"></span> using React + FastAPI
</p>
<p className="text-xs text-gray-500">

View File

@@ -0,0 +1,492 @@
import { useState, useEffect, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import {
Search, ChevronLeft, ChevronRight, CheckCircle2, Clock,
User, BookOpen, FileText, Calendar, Hash, Loader2, X, Save,
RefreshCw, Image as ImageIcon,
} from 'lucide-react'
import axios from 'axios'
const API_BASE = import.meta.env.VITE_API_URL || '/api'
const STATUS_COLORS = {
unreviewed: 'text-amber-400 bg-amber-400/10 border-amber-400/30',
reviewed: 'text-green-400 bg-green-400/10 border-green-400/30',
}
const STATUS_ICONS = {
unreviewed: Clock,
reviewed: CheckCircle2,
}
function StatusBadge({ status }) {
const Icon = STATUS_ICONS[status] || Clock
return (
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border ${STATUS_COLORS[status] || 'text-gray-400'}`}>
<Icon className="w-3 h-3" />
{status}
</span>
)
}
function MetaRow({ icon: Icon, label, value }) {
if (!value) return null
return (
<div className="flex items-start gap-2 text-sm">
<Icon className="w-4 h-4 text-purple-400 mt-0.5 flex-shrink-0" />
<span className="text-gray-400 flex-shrink-0">{label}:</span>
<span className="text-gray-200">{value}</span>
</div>
)
}
function JobDetail({ jobId, onClose, onReviewed }) {
const [job, setJob] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [editedText, setEditedText] = useState('')
const [reviewerName, setReviewerName] = useState('')
const [submitting, setSubmitting] = useState(false)
const [reviewResult, setReviewResult] = useState(null)
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
setReviewResult(null)
axios.get(`${API_BASE}/jobs/${jobId}`)
.then(res => {
if (!cancelled) {
setJob(res.data)
setEditedText(res.data.reviewed_text ?? res.data.ocr_text ?? '')
}
})
.catch(err => {
if (!cancelled) setError(err.response?.data?.detail || err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [jobId])
const handleMarkReviewed = async () => {
if (!reviewerName.trim()) {
setReviewResult({ success: false, error: 'Reviewer name is required.' })
return
}
setSubmitting(true)
setReviewResult(null)
try {
const res = await axios.put(`${API_BASE}/jobs/${jobId}/review`, {
reviewed_text: editedText,
reviewer_name: reviewerName.trim(),
})
setJob(res.data)
setReviewResult({ success: true })
onReviewed(res.data)
} catch (err) {
setReviewResult({ success: false, error: err.response?.data?.detail || err.message })
} finally {
setSubmitting(false)
}
}
const inputClass =
'w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-200 ' +
'placeholder-gray-600 focus:outline-none focus:border-purple-500/50 transition-colors'
return (
<div className="glass rounded-2xl flex flex-col h-full overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b border-white/10 flex-shrink-0">
<h3 className="font-semibold text-gray-200">Job Detail</h3>
<button onClick={onClose} className="glass glass-hover p-1.5 rounded-lg">
<X className="w-4 h-4" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-5 space-y-5">
{loading && (
<div className="flex justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-purple-400" />
</div>
)}
{error && (
<div className="glass p-4 rounded-xl border-red-500/30 bg-red-500/10">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{job && !loading && (
<>
{/* Status + IDs */}
<div className="flex items-center gap-3 flex-wrap">
<StatusBadge status={job.status} />
<span className="text-xs text-gray-500 font-mono">{job.id}</span>
</div>
{/* Metadata */}
<div className="glass p-4 rounded-xl space-y-2">
<MetaRow icon={User} label="Author" value={job.author} />
<MetaRow icon={BookOpen} label="Book" value={job.book} />
<MetaRow icon={Hash} label="Chapter" value={job.chapter} />
<MetaRow icon={FileText} label="Page" value={job.page} />
<MetaRow icon={Calendar} label="Submitted" value={job.submitted_at ? new Date(job.submitted_at).toLocaleString() : null} />
{job.mode && <MetaRow icon={FileText} label="Mode" value={job.mode} />}
</div>
{/* Image */}
<div>
<p className="text-xs text-gray-400 mb-2 flex items-center gap-1">
<ImageIcon className="w-3.5 h-3.5" /> Source Image
</p>
<img
src={`${API_BASE}/jobs/${job.id}/image`}
alt="Job source"
className="w-full rounded-xl border border-white/10 bg-black/30"
onError={e => { e.target.style.display = 'none' }}
/>
</div>
{/* OCR / Reviewed text */}
<div>
<p className="text-xs text-gray-400 mb-2">
{job.status === 'reviewed' ? 'Reviewed Text' : 'OCR Text (editable)'}
</p>
{job.status === 'reviewed' ? (
<div className="bg-white/5 border border-white/10 rounded-xl p-4 max-h-60 overflow-y-auto">
<pre className="text-sm text-gray-200 whitespace-pre-wrap font-mono">
{job.reviewed_text}
</pre>
</div>
) : (
<textarea
value={editedText}
onChange={e => setEditedText(e.target.value)}
rows={8}
className={`${inputClass} resize-y font-mono`}
placeholder="OCR text will appear here for editing..."
/>
)}
</div>
{/* Original OCR text (collapsed) for reviewed jobs */}
{job.status === 'reviewed' && job.ocr_text && (
<details className="glass rounded-xl overflow-hidden">
<summary className="px-4 py-3 cursor-pointer text-sm text-gray-400 hover:bg-white/5 transition-colors">
Original OCR Text
</summary>
<div className="px-4 py-3 border-t border-white/10">
<pre className="text-sm text-gray-500 whitespace-pre-wrap font-mono">
{job.ocr_text}
</pre>
</div>
</details>
)}
{/* Review info for reviewed jobs */}
{job.status === 'reviewed' && (
<div className="glass p-4 rounded-xl space-y-2">
<MetaRow icon={User} label="Reviewer" value={job.reviewer_name} />
<MetaRow icon={Calendar} label="Reviewed" value={job.reviewed_at ? new Date(job.reviewed_at).toLocaleString() : null} />
</div>
)}
{/* Mark Reviewed form */}
{job.status === 'unreviewed' && (
<div className="glass p-4 rounded-xl space-y-3 border border-purple-500/20">
<p className="text-sm font-medium text-gray-300">Mark as Reviewed</p>
<div>
<label className="text-xs text-gray-400 mb-1 block">Reviewer Name</label>
<input
type="text"
value={reviewerName}
onChange={e => setReviewerName(e.target.value)}
placeholder="Your name"
className={inputClass}
/>
</div>
{reviewResult && (
<div className={`p-3 rounded-lg text-sm ${reviewResult.success ? 'bg-green-500/10 text-green-400' : 'bg-red-500/10 text-red-400'}`}>
{reviewResult.success ? 'Job marked as reviewed!' : reviewResult.error}
</div>
)}
<motion.button
onClick={handleMarkReviewed}
disabled={submitting || !reviewerName.trim()}
className={`w-full flex items-center justify-center gap-2 px-4 py-3 rounded-xl font-medium text-sm transition-all ${
submitting || !reviewerName.trim()
? 'opacity-50 cursor-not-allowed bg-white/5'
: 'bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-500 hover:to-emerald-500'
}`}
whileHover={!submitting && reviewerName.trim() ? { scale: 1.02 } : {}}
whileTap={!submitting && reviewerName.trim() ? { scale: 0.98 } : {}}
>
{submitting ? (
<><Loader2 className="w-4 h-4 animate-spin" /> Saving...</>
) : (
<><Save className="w-4 h-4" /> Mark Reviewed</>
)}
</motion.button>
</div>
)}
</>
)}
</div>
</div>
)
}
export default function JobsPanel() {
const [search, setSearch] = useState('')
const [filterStatus, setFilterStatus] = useState('')
const [filterAuthor, setFilterAuthor] = useState('')
const [filterBook, setFilterBook] = useState('')
const [jobs, setJobs] = useState([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [selectedJobId, setSelectedJobId] = useState(null)
const LIMIT = 20
const fetchJobs = useCallback(async (pageNum = 0) => {
setLoading(true)
setError(null)
try {
const params = new URLSearchParams()
if (search.trim()) params.set('search', search.trim())
if (filterStatus) params.set('status', filterStatus)
if (filterAuthor.trim()) params.set('author', filterAuthor.trim())
if (filterBook.trim()) params.set('book', filterBook.trim())
params.set('limit', LIMIT)
params.set('offset', pageNum * LIMIT)
const res = await axios.get(`${API_BASE}/jobs?${params}`)
setJobs(res.data.jobs)
setTotal(res.data.total)
setPage(pageNum)
} catch (err) {
setError(err.response?.data?.detail || err.message)
} finally {
setLoading(false)
}
}, [search, filterStatus, filterAuthor, filterBook])
// Initial load
useEffect(() => {
fetchJobs(0)
}, []) // eslint-disable-line react-hooks/exhaustive-deps
const handleSearch = (e) => {
e.preventDefault()
fetchJobs(0)
}
const handleReviewed = (updatedJob) => {
setJobs(prev => prev.map(j => j.id === updatedJob.id ? { ...j, ...updatedJob } : j))
}
const totalPages = Math.ceil(total / LIMIT)
const inputClass =
'bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-200 ' +
'placeholder-gray-600 focus:outline-none focus:border-purple-500/50 transition-colors'
return (
<div className="grid lg:grid-cols-2 gap-6 h-full">
{/* Left: Search + List */}
<div className="space-y-4">
{/* Search form */}
<div className="glass p-4 rounded-2xl space-y-3">
<form onSubmit={handleSearch} className="flex gap-2">
<input
type="text"
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search all fields..."
className={`${inputClass} flex-1`}
/>
<motion.button
type="submit"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-gradient-to-r from-purple-600 to-cyan-600 text-sm font-medium"
whileHover={{ scale: 1.02 }}
whileTap={{ scale: 0.98 }}
>
<Search className="w-4 h-4" />
Search
</motion.button>
</form>
{/* Filters */}
<div className="grid grid-cols-3 gap-2">
<select
value={filterStatus}
onChange={e => setFilterStatus(e.target.value)}
className={`${inputClass} col-span-1`}
>
<option value="">All statuses</option>
<option value="unreviewed">Unreviewed</option>
<option value="reviewed">Reviewed</option>
</select>
<input
type="text"
value={filterAuthor}
onChange={e => setFilterAuthor(e.target.value)}
placeholder="Author..."
className={`${inputClass} col-span-1`}
/>
<input
type="text"
value={filterBook}
onChange={e => setFilterBook(e.target.value)}
placeholder="Book..."
className={`${inputClass} col-span-1`}
/>
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-gray-500">
{total} job{total !== 1 ? 's' : ''} found
</span>
<button
onClick={() => fetchJobs(page)}
className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200 transition-colors"
>
<RefreshCw className="w-3 h-3" />
Refresh
</button>
</div>
</div>
{/* Results */}
<div className="space-y-2">
{loading && (
<div className="flex justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-purple-400" />
</div>
)}
{error && (
<div className="glass p-4 rounded-xl border-red-500/30 bg-red-500/10">
<p className="text-sm text-red-400">{error}</p>
</div>
)}
{!loading && !error && jobs.length === 0 && (
<div className="glass p-8 rounded-2xl text-center">
<FileText className="w-10 h-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-400">No jobs found</p>
<p className="text-xs text-gray-500 mt-1">Commit your first OCR job from the New Job tab</p>
</div>
)}
<AnimatePresence>
{jobs.map(job => (
<motion.button
key={job.id}
onClick={() => setSelectedJobId(job.id === selectedJobId ? null : job.id)}
className={`w-full text-left glass p-4 rounded-xl transition-all border ${
selectedJobId === job.id
? 'border-purple-500/50 bg-purple-500/5'
: 'border-white/5 hover:border-white/20 hover:bg-white/5'
}`}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
layout
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
{job.book && (
<span className="text-sm font-medium text-gray-200 truncate">{job.book}</span>
)}
{job.chapter && (
<span className="text-xs text-gray-500">Ch. {job.chapter}</span>
)}
{job.page && (
<span className="text-xs text-gray-500">p. {job.page}</span>
)}
</div>
{job.author && (
<p className="text-xs text-gray-400">{job.author}</p>
)}
<p className="text-xs text-gray-600 mt-1 font-mono">
{new Date(job.submitted_at).toLocaleString()}
</p>
</div>
<div className="flex-shrink-0">
<StatusBadge status={job.status} />
</div>
</div>
</motion.button>
))}
</AnimatePresence>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-3">
<button
onClick={() => fetchJobs(page - 1)}
disabled={page === 0}
className="glass glass-hover p-2 rounded-lg disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm text-gray-400">
Page {page + 1} of {totalPages}
</span>
<button
onClick={() => fetchJobs(page + 1)}
disabled={page >= totalPages - 1}
className="glass glass-hover p-2 rounded-lg disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</div>
{/* Right: Detail panel */}
<div>
<AnimatePresence mode="wait">
{selectedJobId ? (
<motion.div
key={selectedJobId}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
className="h-full"
>
<JobDetail
jobId={selectedJobId}
onClose={() => setSelectedJobId(null)}
onReviewed={handleReviewed}
/>
</motion.div>
) : (
<motion.div
key="empty"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="glass p-8 rounded-2xl flex flex-col items-center justify-center text-center h-full min-h-64"
>
<Search className="w-10 h-10 mb-3 text-gray-600" />
<p className="text-gray-400">Select a job to view details</p>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}

View File

@@ -0,0 +1,63 @@
import { BookOpen } from 'lucide-react'
export default function MetadataForm({ metadata, onChange }) {
const { author, book, chapter, page } = metadata
const field = (key) => (e) => onChange({ ...metadata, [key]: e.target.value })
const inputClass =
'w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm text-gray-200 ' +
'placeholder-gray-600 focus:outline-none focus:border-purple-500/50 transition-colors'
return (
<div className="glass p-4 rounded-2xl space-y-3">
<div className="flex items-center gap-2">
<BookOpen className="w-4 h-4 text-purple-400" />
<h3 className="text-sm font-medium text-gray-300">Job Metadata</h3>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-gray-400 mb-1 block">Author</label>
<input
type="text"
value={author}
onChange={field('author')}
placeholder="Author name"
className={inputClass}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Book</label>
<input
type="text"
value={book}
onChange={field('book')}
placeholder="Book title"
className={inputClass}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Chapter</label>
<input
type="text"
value={chapter}
onChange={field('chapter')}
placeholder="Chapter"
className={inputClass}
/>
</div>
<div>
<label className="text-xs text-gray-400 mb-1 block">Page</label>
<input
type="text"
value={page}
onChange={field('page')}
placeholder="Page number"
className={inputClass}
/>
</div>
</div>
</div>
)
}

View File

@@ -1,10 +1,10 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Copy, Download, Sparkles, Loader2, CheckCircle2, ChevronDown } from 'lucide-react'
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 }) {
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)
@@ -313,6 +313,44 @@ export default function ResultPanel({ result, loading, imagePreview, onCopy, onD
</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 &mdash; 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 }}