import { useState, useEffect, useCallback } from 'react' import { motion, AnimatePresence } from 'framer-motion' import { Search, ChevronLeft, ChevronRight, CheckCircle2, Clock, FileText, Loader2, Save, RefreshCw, } from 'lucide-react' import axios from 'axios' const API_BASE = import.meta.env.VITE_API_URL || '/api' const INPUT_CLASS = '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' 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', } function StatusBadge({ status }) { const Icon = status === 'reviewed' ? CheckCircle2 : Clock return ( {status} ) } // ───────────────────────────────────────────────────────────── // Full-screen Job Detail // ───────────────────────────────────────────────────────────── 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 [editAuthor, setEditAuthor] = useState('') const [editBook, setEditBook] = useState('') const [editChapter, setEditChapter] = useState('') const [editPage, setEditPage] = useState('') const [reviewerName, setReviewerName] = useState('') const [submitting, setSubmitting] = useState(false) const [saveResult, setSaveResult] = useState(null) useEffect(() => { let cancelled = false setLoading(true) setError(null) setSaveResult(null) axios.get(`${API_BASE}/jobs/${jobId}`) .then(res => { if (!cancelled) { const d = res.data setJob(d) setEditedText(d.reviewed_text ?? d.ocr_text ?? '') setEditAuthor(d.author || '') setEditBook(d.book || '') setEditChapter(d.chapter || '') setEditPage(d.page || '') setReviewerName(d.reviewer_name || '') } }) .catch(err => { if (!cancelled) setError(err.response?.data?.detail || err.message) }) .finally(() => { if (!cancelled) setLoading(false) }) return () => { cancelled = true } }, [jobId]) const handleSave = async () => { if (!reviewerName.trim()) { setSaveResult({ success: false, error: 'Reviewer name is required.' }) return } setSubmitting(true) setSaveResult(null) try { const res = await axios.put(`${API_BASE}/jobs/${jobId}/review`, { reviewed_text: editedText, reviewer_name: reviewerName.trim(), author: editAuthor, book: editBook, chapter: editChapter, page: editPage, }) setJob(res.data) setSaveResult({ success: true }) onReviewed(res.data) } catch (err) { setSaveResult({ success: false, error: err.response?.data?.detail || err.message }) } finally { setSubmitting(false) } } const isReviewed = job?.status === 'reviewed' return ( {/* Top bar */} Back to results {job && ( <> {job.id} > )} {loading && ( )} {error && ( {error} )} {job && !loading && ( <> {/* Image + Text */} { e.target.style.display = 'none' }} /> {isReviewed ? 'Reviewed Text' : 'OCR Text'} (editable) setEditedText(e.target.value)} className="flex-1 w-full bg-transparent text-sm text-gray-200 font-mono resize-none focus:outline-none min-h-0" placeholder="Text content..." /> {/* Original OCR text collapsed for reviewed jobs */} {isReviewed && job.ocr_text && ( Original OCR Text {job.ocr_text} )} {/* Metadata + reviewer row */} Author setEditAuthor(e.target.value)} placeholder="Author" className={INPUT_CLASS} /> Book setEditBook(e.target.value)} placeholder="Book title" className={INPUT_CLASS} /> Chapter setEditChapter(e.target.value)} placeholder="Chapter" className={INPUT_CLASS} /> Page setEditPage(e.target.value)} placeholder="Page" className={INPUT_CLASS} /> Reviewer setReviewerName(e.target.value)} placeholder="Your name" className={INPUT_CLASS} /> {submitting ? ( <> Saving...> ) : isReviewed ? ( <> Save Changes> ) : ( <> Mark Reviewed> )} {saveResult && ( {saveResult.success ? (isReviewed ? 'Changes saved!' : 'Job marked as reviewed!') : saveResult.error} )} {/* Read-only info row */} {job.submitted_at && ( Submitted: {new Date(job.submitted_at).toLocaleString()} )} {isReviewed && job.reviewed_at && ( Last reviewed: {new Date(job.reviewed_at).toLocaleString()} )} {job.mode && Mode: {job.mode}} > )} ) } // ───────────────────────────────────────────────────────────── // Search / List view // ───────────────────────────────────────────────────────────── 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]) useEffect(() => { fetchJobs(0) }, []) // eslint-disable-line react-hooks/exhaustive-deps const handleReviewed = (updatedJob) => { setJobs(prev => prev.map(j => j.id === updatedJob.id ? { ...j, ...updatedJob } : j)) } const totalPages = Math.ceil(total / LIMIT) // When a job is selected show full-screen detail if (selectedJobId) { return ( setSelectedJobId(null)} onReviewed={handleReviewed} /> ) } return ( {/* Search form */} { e.preventDefault(); fetchJobs(0) }} className="flex gap-2"> setSearch(e.target.value)} placeholder="Search all fields..." className={`${INPUT_CLASS} flex-1`} /> Search setFilterStatus(e.target.value)} className={INPUT_CLASS}> All statuses Unreviewed Reviewed setFilterAuthor(e.target.value)} placeholder="Author..." className={INPUT_CLASS} /> setFilterBook(e.target.value)} placeholder="Book..." className={INPUT_CLASS} /> {total} job{total !== 1 ? 's' : ''} found fetchJobs(page)} className="flex items-center gap-1 text-xs text-gray-400 hover:text-gray-200 transition-colors"> Refresh {loading && } {error && ( {error} )} {!loading && !error && jobs.length === 0 && ( No jobs found Commit your first OCR job from the New Job tab )} {/* Results grid */} {jobs.map(job => ( setSelectedJobId(job.id)} className="text-left glass p-4 rounded-xl border border-white/5 hover:border-white/20 hover:bg-white/5 transition-all" initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} whileHover={{ scale: 1.02 }} whileTap={{ scale: 0.98 }} layout > {job.book && {job.book}} {job.chapter && Ch. {job.chapter}} {job.page && p. {job.page}} {job.author && {job.author}} {new Date(job.submitted_at).toLocaleDateString()} ))} {totalPages > 1 && ( fetchJobs(page - 1)} disabled={page === 0} className="glass glass-hover p-2 rounded-lg disabled:opacity-30"> Page {page + 1} of {totalPages} fetchJobs(page + 1)} disabled={page >= totalPages - 1} className="glass glass-hover p-2 rounded-lg disabled:opacity-30"> )} ) }
{error}
{isReviewed ? 'Reviewed Text' : 'OCR Text'} (editable)
{job.ocr_text}
No jobs found
Commit your first OCR job from the New Job tab
{job.book}
{job.author}
{new Date(job.submitted_at).toLocaleDateString()}