Intermediate
Upload & Review UI
In this step, you will create a drag-and-drop upload interface with extraction result display, inline editing, and correction capabilities.
What We Are Building
A single-page web interface that lets users drag and drop documents, see real-time processing progress, review extraction results, and correct any errors before exporting the data.
Step 1: The Upload Interface
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document Intelligence</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif;
background: #f5f5f5; color: #333; }
.container { max-width: 900px; margin: 0 auto; padding: 20px; }
h1 { text-align: center; margin: 20px 0; color: #6366f1; }
/* Drop zone */
.drop-zone { border: 3px dashed #ccc; border-radius: 12px; padding: 60px 20px;
text-align: center; cursor: pointer; transition: all 0.3s;
background: white; margin-bottom: 20px; }
.drop-zone.drag-over { border-color: #6366f1; background: #eef2ff; }
.drop-zone p { font-size: 18px; color: #666; }
.drop-zone .icon { font-size: 48px; margin-bottom: 10px; }
/* Job list */
.job-list { list-style: none; }
.job-item { background: white; border-radius: 8px; padding: 16px;
margin-bottom: 12px; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.job-header { display: flex; justify-content: space-between; align-items: center; }
.job-name { font-weight: 600; }
.job-status { padding: 4px 12px; border-radius: 20px; font-size: 13px; }
.status-pending { background: #fef3c7; color: #92400e; }
.status-processing { background: #dbeafe; color: #1e40af; }
.status-completed { background: #d1fae5; color: #065f46; }
.status-failed { background: #fee2e2; color: #991b1b; }
.progress-bar { height: 6px; background: #e5e7eb; border-radius: 3px; margin-top: 8px; }
.progress-fill { height: 100%; background: #6366f1; border-radius: 3px; transition: width 0.5s; }
/* Results */
.results-panel { background: white; border-radius: 8px; padding: 20px;
margin-top: 12px; border: 1px solid #e5e7eb; }
.field-row { display: flex; gap: 12px; margin-bottom: 8px; align-items: center; }
.field-label { font-weight: 600; min-width: 140px; color: #6b7280; }
.field-value { flex: 1; }
.field-value input { width: 100%; padding: 6px 10px; border: 1px solid #d1d5db;
border-radius: 6px; font-size: 14px; }
.btn { padding: 8px 20px; border: none; border-radius: 6px; cursor: pointer;
font-size: 14px; font-weight: 500; }
.btn-primary { background: #6366f1; color: white; }
.btn-export { background: #10b981; color: white; }
</style>
</head>
<body>
<div class="container">
<h1>📄 Document Intelligence</h1>
<div class="drop-zone" id="dropZone">
<div class="icon">📤</div>
<p>Drag & drop documents here<br>or click to browse</p>
<input type="file" id="fileInput" multiple accept=".pdf,.png,.jpg,.jpeg,.tiff,.bmp"
style="display:none">
</div>
<ul class="job-list" id="jobList"></ul>
</div>
<script>
const dropZone = document.getElementById("dropZone");
const fileInput = document.getElementById("fileInput");
const jobList = document.getElementById("jobList");
const jobs = {};
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", e => {
e.preventDefault(); dropZone.classList.add("drag-over");
});
dropZone.addEventListener("dragleave", () => dropZone.classList.remove("drag-over"));
dropZone.addEventListener("drop", e => {
e.preventDefault(); dropZone.classList.remove("drag-over");
handleFiles(e.dataTransfer.files);
});
fileInput.addEventListener("change", () => handleFiles(fileInput.files));
async function handleFiles(files) {
for (const file of files) {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: form });
const data = await res.json();
// Start processing
const procRes = await fetch(`/api/process?filename=${encodeURIComponent(file.name)}`,
{ method: "POST" });
const procData = await procRes.json();
addJobToUI(procData.job_id, file.name);
pollJob(procData.job_id);
}
}
function addJobToUI(jobId, filename) {
const li = document.createElement("li");
li.className = "job-item";
li.id = `job-${jobId}`;
li.innerHTML = `
<div class="job-header">
<span class="job-name">${filename}</span>
<span class="job-status status-pending">Pending</span>
</div>
<div class="progress-bar"><div class="progress-fill" style="width:0%"></div></div>
`;
jobList.prepend(li);
}
async function pollJob(jobId) {
const interval = setInterval(async () => {
const res = await fetch(`/api/jobs/${jobId}`);
const data = await res.json();
updateJobUI(jobId, data);
if (data.status === "completed" || data.status === "failed") {
clearInterval(interval);
if (data.status === "completed") loadResults(jobId);
}
}, 1000);
}
function updateJobUI(jobId, data) {
const el = document.getElementById(`job-${jobId}`);
if (!el) return;
const statusEl = el.querySelector(".job-status");
statusEl.textContent = data.status;
statusEl.className = `job-status status-${data.status}`;
el.querySelector(".progress-fill").style.width = `${data.progress * 100}%`;
}
async function loadResults(jobId) {
const res = await fetch(`/api/results/${jobId}`);
const data = await res.json();
const el = document.getElementById(`job-${jobId}`);
const panel = document.createElement("div");
panel.className = "results-panel";
let html = "<h3>Extracted Data</h3>";
const fields = data.structured_data || {};
for (const [key, value] of Object.entries(fields)) {
if (typeof value !== "object") {
html += `<div class="field-row">
<span class="field-label">${key}</span>
<span class="field-value"><input value="${value || ""}"></span>
</div>`;
}
}
html += `<div style="margin-top:12px">
<button class="btn btn-export" onclick="exportJSON('${jobId}')">Export JSON</button>
</div>`;
panel.innerHTML = html;
el.appendChild(panel);
}
function exportJSON(jobId) {
window.open(`/api/results/${jobId}/download`, "_blank");
}
</script>
</body>
</html>
Step 2: Results API Endpoints
# Add to app/main.py
@app.get("/api/results/{job_id}")
async def get_results(job_id: str):
"""Get extraction results for a completed job."""
results_path = Path(settings.results_dir) / f"{job_id}.json"
if not results_path.exists():
raise HTTPException(status_code=404, detail="Results not found")
with open(results_path) as f:
return json.load(f)
@app.get("/api/results/{job_id}/download")
async def download_results(job_id: str):
"""Download results as a JSON file."""
results_path = Path(settings.results_dir) / f"{job_id}.json"
if not results_path.exists():
raise HTTPException(status_code=404, detail="Results not found")
return FileResponse(
str(results_path),
media_type="application/json",
filename=f"extraction-{job_id}.json"
)
UX improvement: The inline editing fields let users correct extraction errors before exporting. Track correction rates to identify which fields need better extraction prompts.
Key Takeaways
- Drag-and-drop upload with file type validation provides a smooth user experience.
- Real-time polling updates progress bars and status badges as documents process.
- Inline editable fields let users correct extraction errors before exporting.
- JSON export provides clean structured data for downstream systems.
Lilly Tech Systems