Add autocomplete suggestions for Author, Chapter, and Reviewer fields
Adds a GET /api/jobs/suggestions endpoint that returns distinct values for author, chapter, and reviewer_name from the database, and wires them into HTML datalist elements on the New Job, result view, and Browse Jobs pages. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -754,6 +754,31 @@ async def list_jobs(
|
|||||||
return JSONResponse({"total": total, "limit": limit, "offset": offset, "jobs": rows})
|
return JSONResponse({"total": total, "limit": limit, "offset": offset, "jobs": rows})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/jobs/suggestions")
|
||||||
|
async def job_suggestions():
|
||||||
|
"""Return distinct values for author, chapter, and reviewer_name to power autocomplete."""
|
||||||
|
try:
|
||||||
|
with get_db() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT
|
||||||
|
array_remove(array_agg(DISTINCT author ORDER BY author), NULL) AS authors,
|
||||||
|
array_remove(array_agg(DISTINCT chapter ORDER BY chapter), NULL) AS chapters,
|
||||||
|
array_remove(array_agg(DISTINCT reviewer_name ORDER BY reviewer_name), NULL) AS reviewers
|
||||||
|
FROM ocr_jobs
|
||||||
|
""")
|
||||||
|
row = cur.fetchone()
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"suggestions DB error: {exc}")
|
||||||
|
raise HTTPException(status_code=500, detail="Database error.")
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"authors": row["authors"] or [],
|
||||||
|
"chapters": row["chapters"] or [],
|
||||||
|
"reviewers": row["reviewers"] or [],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/jobs/{job_id}")
|
@app.get("/api/jobs/{job_id}")
|
||||||
async def get_job(job_id: str):
|
async def get_job(job_id: str):
|
||||||
"""Retrieve full job record including OCR text."""
|
"""Retrieve full job record including OCR text."""
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useCallback } from 'react'
|
import { useState, useCallback } from 'react'
|
||||||
|
import { useSuggestions } from './hooks/useSuggestions'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
Sparkles, Zap, Loader2, Settings, Image as ImageIcon, FileText,
|
Sparkles, Zap, Loader2, Settings, Image as ImageIcon, FileText,
|
||||||
@@ -39,6 +40,8 @@ function App() {
|
|||||||
base_size: 1024, image_size: 640, crop_mode: true, test_compress: false,
|
base_size: 1024, image_size: 640, crop_mode: true, test_compress: false,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const suggestions = useSuggestions()
|
||||||
|
|
||||||
const [metadata, setMetadata] = useState({ author: '', book: '', chapter: '', page: '' })
|
const [metadata, setMetadata] = useState({ author: '', book: '', chapter: '', page: '' })
|
||||||
const [editedOcrText, setEditedOcrText] = useState('')
|
const [editedOcrText, setEditedOcrText] = useState('')
|
||||||
const [commitLoading, setCommitLoading] = useState(false)
|
const [commitLoading, setCommitLoading] = useState(false)
|
||||||
@@ -268,17 +271,24 @@ function App() {
|
|||||||
|
|
||||||
{/* Metadata row */}
|
{/* Metadata row */}
|
||||||
<div className="glass p-4 rounded-2xl flex-shrink-0">
|
<div className="glass p-4 rounded-2xl flex-shrink-0">
|
||||||
|
<datalist id="rv-authors">
|
||||||
|
{suggestions.authors.map(a => <option key={a} value={a} />)}
|
||||||
|
</datalist>
|
||||||
|
<datalist id="rv-chapters">
|
||||||
|
{suggestions.chapters.map(c => <option key={c} value={c} />)}
|
||||||
|
</datalist>
|
||||||
<div className="grid grid-cols-4 gap-4">
|
<div className="grid grid-cols-4 gap-4">
|
||||||
{[
|
{[
|
||||||
{ key: 'author', label: 'Author', placeholder: 'Author name' },
|
{ key: 'author', label: 'Author', placeholder: 'Author name', list: 'rv-authors' },
|
||||||
{ key: 'book', label: 'Book', placeholder: 'Book title' },
|
{ key: 'book', label: 'Book', placeholder: 'Book title', list: undefined },
|
||||||
{ key: 'chapter', label: 'Chapter', placeholder: 'Chapter' },
|
{ key: 'chapter', label: 'Chapter', placeholder: 'Chapter', list: 'rv-chapters' },
|
||||||
{ key: 'page', label: 'Page', placeholder: 'Page number' },
|
{ key: 'page', label: 'Page', placeholder: 'Page number', list: undefined },
|
||||||
].map(({ key, label, placeholder }) => (
|
].map(({ key, label, placeholder, list }) => (
|
||||||
<div key={key}>
|
<div key={key}>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">{label}</label>
|
<label className="text-xs text-gray-400 mb-1 block">{label}</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
list={list}
|
||||||
value={metadata[key]}
|
value={metadata[key]}
|
||||||
onChange={metaField(key)}
|
onChange={metaField(key)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
@@ -379,7 +389,7 @@ function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MetadataForm metadata={metadata} onChange={setMetadata} />
|
<MetadataForm metadata={metadata} onChange={setMetadata} suggestions={suggestions} />
|
||||||
|
|
||||||
<ModeSelector
|
<ModeSelector
|
||||||
mode={mode} onModeChange={setMode}
|
mode={mode} onModeChange={setMode}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
|
import { useSuggestions } from '../hooks/useSuggestions'
|
||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import {
|
import {
|
||||||
Search, ChevronLeft, ChevronRight, CheckCircle2, Clock,
|
Search, ChevronLeft, ChevronRight, CheckCircle2, Clock,
|
||||||
@@ -30,7 +31,7 @@ function StatusBadge({ status }) {
|
|||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// Full-screen Job Detail
|
// Full-screen Job Detail
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
function JobDetail({ jobId, onClose, onReviewed }) {
|
function JobDetail({ jobId, onClose, onReviewed, suggestions = {} }) {
|
||||||
const [job, setJob] = useState(null)
|
const [job, setJob] = useState(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
@@ -177,10 +178,19 @@ function JobDetail({ jobId, onClose, onReviewed }) {
|
|||||||
|
|
||||||
{/* Metadata + reviewer row */}
|
{/* Metadata + reviewer row */}
|
||||||
<div className="glass p-4 rounded-2xl flex-shrink-0">
|
<div className="glass p-4 rounded-2xl flex-shrink-0">
|
||||||
|
<datalist id="jd-authors">
|
||||||
|
{(suggestions.authors || []).map(a => <option key={a} value={a} />)}
|
||||||
|
</datalist>
|
||||||
|
<datalist id="jd-chapters">
|
||||||
|
{(suggestions.chapters || []).map(c => <option key={c} value={c} />)}
|
||||||
|
</datalist>
|
||||||
|
<datalist id="jd-reviewers">
|
||||||
|
{(suggestions.reviewers || []).map(r => <option key={r} value={r} />)}
|
||||||
|
</datalist>
|
||||||
<div className="grid grid-cols-6 gap-4">
|
<div className="grid grid-cols-6 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Author</label>
|
<label className="text-xs text-gray-400 mb-1 block">Author</label>
|
||||||
<input type="text" value={editAuthor} onChange={e => setEditAuthor(e.target.value)} placeholder="Author" className={INPUT_CLASS} />
|
<input type="text" list="jd-authors" value={editAuthor} onChange={e => setEditAuthor(e.target.value)} placeholder="Author" className={INPUT_CLASS} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Book</label>
|
<label className="text-xs text-gray-400 mb-1 block">Book</label>
|
||||||
@@ -188,7 +198,7 @@ function JobDetail({ jobId, onClose, onReviewed }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Chapter</label>
|
<label className="text-xs text-gray-400 mb-1 block">Chapter</label>
|
||||||
<input type="text" value={editChapter} onChange={e => setEditChapter(e.target.value)} placeholder="Chapter" className={INPUT_CLASS} />
|
<input type="text" list="jd-chapters" value={editChapter} onChange={e => setEditChapter(e.target.value)} placeholder="Chapter" className={INPUT_CLASS} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Page</label>
|
<label className="text-xs text-gray-400 mb-1 block">Page</label>
|
||||||
@@ -196,7 +206,7 @@ function JobDetail({ jobId, onClose, onReviewed }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Reviewer</label>
|
<label className="text-xs text-gray-400 mb-1 block">Reviewer</label>
|
||||||
<input type="text" value={reviewerName} onChange={e => setReviewerName(e.target.value)} placeholder="Your name" className={INPUT_CLASS} />
|
<input type="text" list="jd-reviewers" value={reviewerName} onChange={e => setReviewerName(e.target.value)} placeholder="Your name" className={INPUT_CLASS} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col justify-end">
|
<div className="flex flex-col justify-end">
|
||||||
<motion.button
|
<motion.button
|
||||||
@@ -255,6 +265,7 @@ function JobDetail({ jobId, onClose, onReviewed }) {
|
|||||||
// Search / List view
|
// Search / List view
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
export default function JobsPanel() {
|
export default function JobsPanel() {
|
||||||
|
const suggestions = useSuggestions()
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [filterStatus, setFilterStatus] = useState('')
|
const [filterStatus, setFilterStatus] = useState('')
|
||||||
const [filterAuthor, setFilterAuthor] = useState('')
|
const [filterAuthor, setFilterAuthor] = useState('')
|
||||||
@@ -308,6 +319,7 @@ export default function JobsPanel() {
|
|||||||
jobId={selectedJobId}
|
jobId={selectedJobId}
|
||||||
onClose={() => setSelectedJobId(null)}
|
onClose={() => setSelectedJobId(null)}
|
||||||
onReviewed={handleReviewed}
|
onReviewed={handleReviewed}
|
||||||
|
suggestions={suggestions}
|
||||||
/>
|
/>
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
)
|
)
|
||||||
@@ -340,13 +352,16 @@ export default function JobsPanel() {
|
|||||||
</motion.button>
|
</motion.button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<datalist id="jp-authors">
|
||||||
|
{suggestions.authors.map(a => <option key={a} value={a} />)}
|
||||||
|
</datalist>
|
||||||
<div className="grid grid-cols-3 gap-2">
|
<div className="grid grid-cols-3 gap-2">
|
||||||
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className={INPUT_CLASS}>
|
<select value={filterStatus} onChange={e => setFilterStatus(e.target.value)} className={INPUT_CLASS}>
|
||||||
<option value="">All statuses</option>
|
<option value="">All statuses</option>
|
||||||
<option value="unreviewed">Unreviewed</option>
|
<option value="unreviewed">Unreviewed</option>
|
||||||
<option value="reviewed">Reviewed</option>
|
<option value="reviewed">Reviewed</option>
|
||||||
</select>
|
</select>
|
||||||
<input type="text" value={filterAuthor} onChange={e => setFilterAuthor(e.target.value)} placeholder="Author..." className={INPUT_CLASS} />
|
<input type="text" list="jp-authors" value={filterAuthor} onChange={e => setFilterAuthor(e.target.value)} placeholder="Author..." className={INPUT_CLASS} />
|
||||||
<input type="text" value={filterBook} onChange={e => setFilterBook(e.target.value)} placeholder="Book..." className={INPUT_CLASS} />
|
<input type="text" value={filterBook} onChange={e => setFilterBook(e.target.value)} placeholder="Book..." className={INPUT_CLASS} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { BookOpen } from 'lucide-react'
|
import { BookOpen } from 'lucide-react'
|
||||||
|
|
||||||
export default function MetadataForm({ metadata, onChange }) {
|
export default function MetadataForm({ metadata, onChange, suggestions = {} }) {
|
||||||
const { author, book, chapter, page } = metadata
|
const { author, book, chapter, page } = metadata
|
||||||
|
const { authors = [], chapters = [] } = suggestions
|
||||||
|
|
||||||
const field = (key) => (e) => onChange({ ...metadata, [key]: e.target.value })
|
const field = (key) => (e) => onChange({ ...metadata, [key]: e.target.value })
|
||||||
|
|
||||||
@@ -16,11 +17,19 @@ export default function MetadataForm({ metadata, onChange }) {
|
|||||||
<h3 className="text-sm font-medium text-gray-300">Job Metadata</h3>
|
<h3 className="text-sm font-medium text-gray-300">Job Metadata</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<datalist id="mf-authors">
|
||||||
|
{authors.map(a => <option key={a} value={a} />)}
|
||||||
|
</datalist>
|
||||||
|
<datalist id="mf-chapters">
|
||||||
|
{chapters.map(c => <option key={c} value={c} />)}
|
||||||
|
</datalist>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-gray-400 mb-1 block">Author</label>
|
<label className="text-xs text-gray-400 mb-1 block">Author</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
list="mf-authors"
|
||||||
value={author}
|
value={author}
|
||||||
onChange={field('author')}
|
onChange={field('author')}
|
||||||
placeholder="Author name"
|
placeholder="Author name"
|
||||||
@@ -41,6 +50,7 @@ export default function MetadataForm({ metadata, onChange }) {
|
|||||||
<label className="text-xs text-gray-400 mb-1 block">Chapter</label>
|
<label className="text-xs text-gray-400 mb-1 block">Chapter</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
list="mf-chapters"
|
||||||
value={chapter}
|
value={chapter}
|
||||||
onChange={field('chapter')}
|
onChange={field('chapter')}
|
||||||
placeholder="Chapter"
|
placeholder="Chapter"
|
||||||
|
|||||||
16
frontend/src/hooks/useSuggestions.js
Normal file
16
frontend/src/hooks/useSuggestions.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
|
||||||
|
|
||||||
|
export function useSuggestions() {
|
||||||
|
const [suggestions, setSuggestions] = useState({ authors: [], chapters: [], reviewers: [] })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetch(`${API_BASE}/jobs/suggestions`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(data => { if (data) setSuggestions(data) })
|
||||||
|
.catch(() => {})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return suggestions
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user