Make the container actually deployable, publish to GHCR
The Docker setup built but never came up. Three separate blockers, each found by running the full compose stack against a real Postgres: - The runner stage hand-copied prisma/@prisma/dotenv, which misses the CLI's transitive deps (valibot) and crash-looped the entrypoint. A dedicated migrator stage now installs the CLI at the exact lockfile version with its real dependency tree. - Docker sets HOSTNAME to the container id and Next's standalone server binds to it verbatim, so the app was unreachable on localhost inside the container and the healthcheck could never pass. Pinned to 0.0.0.0. - openssl was missing from the build stages but present at runtime, so Prisma detected a different platform and tried to download a matching query engine at boot, as an unprivileged user, into a root-owned directory. Aligned openssl across every stage that touches Prisma. Verified from a clean volume: migrations apply, both containers report healthy, /api/health returns 200, and the app serves from the image. Also: - Publish multi-arch images to GHCR on push to main and on v* tags, with an SBOM and a signed provenance attestation. - Default compose to the published image; add a build override for working on the app itself. Host port and image are configurable. - Drop two unused dependencies: canvas (19MB of native node-canvas; the pipeline uses @napi-rs/canvas) and multer (routes use formData()). - Add a root .gitignore. There wasn't one, so a real .env was one `git add .` away from being committed. Untrack .DS_Store. - Rewrite the README around what the tool does and how to run it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4130ce2e09
commit
7e7e4d9b1c
36 files changed
+2260
-1270
No files matched your search
@@ -19,13 +19,35 @@ type DocData = {
|
||||
|
||||
type OriginalPage = { page: number; path: string };
|
||||
type PixelPage = { page: number; path: string };
|
||||
type OcrWord = {
|
||||
text: string;
|
||||
confidence: number;
|
||||
bbox: { x0: number; y0: number; x1: number; y1: number };
|
||||
};
|
||||
type OcrPage = {
|
||||
page: number;
|
||||
path: string;
|
||||
text?: string;
|
||||
confidence?: number;
|
||||
wordCount?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function assetUrl(id: string, type: "original" | "pixel", page: number) {
|
||||
return `/api/documents/${id}/asset?type=${type}&page=${page}`;
|
||||
}
|
||||
|
||||
function confidenceStyle(c: number): { bg: string; border: string } {
|
||||
if (c >= 90) return { bg: "rgba(34,197,94,0.35)", border: "#22c55e" };
|
||||
if (c >= 70) return { bg: "rgba(245,158,11,0.35)", border: "#f59e0b" };
|
||||
return { bg: "rgba(239,68,68,0.35)", border: "#ef4444" };
|
||||
}
|
||||
|
||||
function confidenceTextColor(c: number): string {
|
||||
if (c >= 90) return "text-emerald-400";
|
||||
if (c >= 70) return "text-amber-400";
|
||||
return "text-red-400";
|
||||
}
|
||||
|
||||
export default function ViewerPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -50,6 +72,12 @@ export default function ViewerPage({
|
||||
const [ocrPages, setOcrPages] = useState<OcrPage[]>([]);
|
||||
const [ocrCurrentPage, setOcrCurrentPage] = useState(1);
|
||||
const [ocrProgress, setOcrProgress] = useState({ page: 0, total: 0 });
|
||||
const [ocrError, setOcrError] = useState<string | null>(null);
|
||||
const [ocrView, setOcrView] = useState<"text" | "overlay">("text");
|
||||
const [ocrWords, setOcrWords] = useState<Record<number, OcrWord[]>>({});
|
||||
const [overlayDims, setOverlayDims] = useState<{ w: number; h: number } | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<"original" | "pixel" | "ocr">(
|
||||
"original"
|
||||
@@ -63,25 +91,24 @@ export default function ViewerPage({
|
||||
const found = data.documents.find((d: DocData) => d.id === id);
|
||||
if (found) {
|
||||
setDoc(found);
|
||||
// Auto-trigger original render
|
||||
renderOriginalPages(found.id);
|
||||
// Auto-trigger pixel if not done yet
|
||||
if (!found.pixelResultDir || found.pixelPageCount === 0) {
|
||||
if (found.pixelPageCount === 0) {
|
||||
runPixelAnalysis(found.id);
|
||||
} else {
|
||||
// Load cached pixel pages
|
||||
const pages = Array.from(
|
||||
{ length: found.pixelPageCount },
|
||||
(_, i) => ({
|
||||
setPixelPages(
|
||||
Array.from({ length: found.pixelPageCount }, (_, i) => ({
|
||||
page: i + 1,
|
||||
path: `${found.pixelResultDir}/page_${i + 1}.png`,
|
||||
})
|
||||
path: assetUrl(found.id, "pixel", i + 1),
|
||||
}))
|
||||
);
|
||||
setPixelPages(pages);
|
||||
}
|
||||
// If OCR was already run, load the pages
|
||||
if (found.ocrRan && found.ocrResultDir && found.ocrPageCount > 0) {
|
||||
loadOcrPages(found.ocrResultDir, found.ocrPageCount);
|
||||
if (found.ocrRan && found.ocrPageCount > 0) {
|
||||
// Set page placeholders; per-page text/words hydrate lazily on view.
|
||||
setOcrPages(
|
||||
Array.from({ length: found.ocrPageCount }, (_, i) => ({
|
||||
page: i + 1,
|
||||
}))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -120,13 +147,7 @@ export default function ViewerPage({
|
||||
setPixelPages(data.pages);
|
||||
setDoc((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
pixelResultDir:
|
||||
data.pages[0]?.path?.replace(/\/page_\d+\.png$/, "") ?? null,
|
||||
pixelPageCount: data.pageCount,
|
||||
pageCount: data.pageCount,
|
||||
}
|
||||
? { ...prev, pixelPageCount: data.pageCount, pageCount: data.pageCount }
|
||||
: prev
|
||||
);
|
||||
}
|
||||
@@ -136,26 +157,55 @@ export default function ViewerPage({
|
||||
setPixelLoading(false);
|
||||
};
|
||||
|
||||
const loadOcrPages = async (dir: string, count: number) => {
|
||||
const pages: OcrPage[] = [];
|
||||
for (let i = 1; i <= count; i++) {
|
||||
const pagePath = `${dir}/page_${i}.txt`;
|
||||
// Lazily hydrate persisted OCR words + reconstructed text for a page.
|
||||
const ensureOcrPageLoaded = useCallback(
|
||||
async (page: number) => {
|
||||
if (ocrWords[page]) return;
|
||||
try {
|
||||
const res = await fetch(pagePath);
|
||||
const text = res.ok ? await res.text() : "(failed to load)";
|
||||
pages.push({ page: i, path: pagePath, text });
|
||||
const res = await fetch(`/api/documents/${id}/ocr/words?page=${page}`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
setOcrWords((prev) => ({ ...prev, [page]: data.words ?? [] }));
|
||||
setOcrPages((prev) =>
|
||||
prev.map((p) =>
|
||||
p.page === page
|
||||
? {
|
||||
...p,
|
||||
text: data.text,
|
||||
confidence: data.confidence,
|
||||
wordCount: (data.words ?? []).length,
|
||||
}
|
||||
: p
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
pages.push({ page: i, path: pagePath, text: "(failed to load)" });
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
[id, ocrWords]
|
||||
);
|
||||
|
||||
// Hydrate the currently-viewed OCR page after a reload / re-run completes.
|
||||
useEffect(() => {
|
||||
if (!doc?.ocrRan || ocrLoading) return;
|
||||
const entry = ocrPages.find((p) => p.page === ocrCurrentPage);
|
||||
if (entry && entry.text === undefined && !entry.error) {
|
||||
ensureOcrPageLoaded(ocrCurrentPage);
|
||||
}
|
||||
setOcrPages(pages);
|
||||
};
|
||||
}, [doc?.ocrRan, ocrLoading, ocrCurrentPage, ocrPages, ensureOcrPageLoaded]);
|
||||
|
||||
// Reset the measured overlay image size whenever the page/view changes.
|
||||
useEffect(() => {
|
||||
setOverlayDims(null);
|
||||
}, [ocrCurrentPage, ocrView]);
|
||||
|
||||
const runOcr = async () => {
|
||||
if (!doc || doc.ocrRan) return;
|
||||
if (!doc) return;
|
||||
setOcrLoading(true);
|
||||
setOcrError(null);
|
||||
setOcrProgress({ page: 0, total: 0 });
|
||||
setOcrPages([]);
|
||||
setOcrWords({});
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
|
||||
@@ -163,21 +213,18 @@ export default function ViewerPage({
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 409) {
|
||||
let message = "OCR request failed";
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data.pages) {
|
||||
await loadOcrPages(
|
||||
data.pages[0]?.path?.replace(/\/page_\d+\.txt$/, ""),
|
||||
data.pageCount
|
||||
);
|
||||
setDoc((prev) => (prev ? { ...prev, ocrRan: true } : prev));
|
||||
}
|
||||
message = data.error || message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setOcrError(message);
|
||||
setOcrLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Stream reading (NDJSON)
|
||||
const reader = res.body?.getReader();
|
||||
if (!reader) {
|
||||
setOcrLoading(false);
|
||||
@@ -204,51 +251,62 @@ export default function ViewerPage({
|
||||
} else if (event.type === "page_done") {
|
||||
setOcrProgress({ page: event.page, total: event.totalPages });
|
||||
setOcrPages((prev) => [
|
||||
...prev,
|
||||
...prev.filter((p) => p.page !== event.page),
|
||||
{
|
||||
page: event.page,
|
||||
path: event.path,
|
||||
text: event.text,
|
||||
confidence: event.confidence,
|
||||
wordCount: event.wordCount,
|
||||
},
|
||||
]);
|
||||
} else if (event.type === "page_error") {
|
||||
setOcrProgress({ page: event.page, total: event.totalPages });
|
||||
setOcrPages((prev) => [
|
||||
...prev.filter((p) => p.page !== event.page),
|
||||
{ page: event.page, error: event.error },
|
||||
]);
|
||||
} else if (event.type === "complete") {
|
||||
setDoc((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
ocrRan: true,
|
||||
ocrResultDir:
|
||||
event.pages[0]?.path?.replace(
|
||||
/\/page_\d+\.txt$/,
|
||||
""
|
||||
) ?? null,
|
||||
ocrPageCount: event.pageCount,
|
||||
}
|
||||
? { ...prev, ocrRan: true, ocrPageCount: event.succeeded }
|
||||
: prev
|
||||
);
|
||||
} else if (event.type === "error") {
|
||||
setOcrError(event.error || "OCR processing failed");
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
// Re-sort by page number after streaming; drop cached words so the
|
||||
// overlay re-fetches fresh boxes from persistence.
|
||||
setOcrPages((prev) => [...prev].sort((a, b) => a.page - b.page));
|
||||
setOcrWords({});
|
||||
} catch (e) {
|
||||
console.error("OCR error:", e);
|
||||
setOcrError("OCR processing failed");
|
||||
}
|
||||
setOcrLoading(false);
|
||||
};
|
||||
|
||||
const downloadAll = (type: "pixel" | "ocr") => {
|
||||
const pages = type === "pixel" ? pixelPages : ocrPages;
|
||||
pages.forEach((p) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = p.path;
|
||||
a.download = p.path.split("/").pop() || `page_${p.page}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
const downloadOcrText = () => {
|
||||
[...ocrPages]
|
||||
.sort((a, b) => a.page - b.page)
|
||||
.forEach((p) => {
|
||||
const content = p.error
|
||||
? `(page ${p.page} failed: ${p.error})`
|
||||
: p.text ?? "";
|
||||
const blob = new Blob([content], { type: "text/plain" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `page_${p.page}.txt`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
});
|
||||
};
|
||||
|
||||
const downloadAsPdf = async (type: "original" | "pixel") => {
|
||||
@@ -259,7 +317,6 @@ export default function ViewerPage({
|
||||
console.error("PDF export failed", await res.text());
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
@@ -274,6 +331,17 @@ export default function ViewerPage({
|
||||
}
|
||||
};
|
||||
|
||||
const downloadImages = (pages: { page: number; path: string }[]) => {
|
||||
pages.forEach((p) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = p.path;
|
||||
a.download = `page_${p.page}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<main className="flex min-h-[60vh] items-center justify-center">
|
||||
@@ -296,6 +364,10 @@ export default function ViewerPage({
|
||||
);
|
||||
}
|
||||
|
||||
const currentOcr = ocrPages.find((p) => p.page === ocrCurrentPage);
|
||||
const currentWords = ocrWords[ocrCurrentPage] ?? [];
|
||||
const overlayBg = originalPages[ocrCurrentPage - 1]?.path;
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-[1600px] px-4 py-6">
|
||||
{/* Header */}
|
||||
@@ -322,7 +394,7 @@ export default function ViewerPage({
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">{doc.originalName}</h1>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{doc.pageCount > 0 ? `${doc.pageCount} pages \u00b7 ` : ""}
|
||||
{doc.pageCount > 0 ? `${doc.pageCount} pages · ` : ""}
|
||||
Uploaded {new Date(doc.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -348,7 +420,7 @@ export default function ViewerPage({
|
||||
|
||||
{/* 3-Panel Grid */}
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
{/* Panel 1: Original (server-rendered) */}
|
||||
{/* Panel 1: Original */}
|
||||
<Panel
|
||||
title="Original PDF"
|
||||
badge={
|
||||
@@ -362,22 +434,11 @@ export default function ViewerPage({
|
||||
visible={activeTab === "original"}
|
||||
onDownload={
|
||||
originalPages.length > 0
|
||||
? () => {
|
||||
originalPages.forEach((p) => {
|
||||
const a = document.createElement("a");
|
||||
a.href = p.path;
|
||||
a.download = p.path.split("/").pop() || `page_${p.page}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
});
|
||||
}
|
||||
? () => downloadImages(originalPages)
|
||||
: undefined
|
||||
}
|
||||
onDownloadPdf={
|
||||
originalPages.length > 0
|
||||
? () => downloadAsPdf("original")
|
||||
: undefined
|
||||
originalPages.length > 0 ? () => downloadAsPdf("original") : undefined
|
||||
}
|
||||
>
|
||||
{originalLoading ? (
|
||||
@@ -389,33 +450,16 @@ export default function ViewerPage({
|
||||
</div>
|
||||
) : originalPages.length > 0 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page navigator */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setOriginalCurrentPage((p) => Math.max(1, p - 1))
|
||||
}
|
||||
disabled={originalCurrentPage <= 1}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-zinc-400">
|
||||
Page {originalCurrentPage} / {originalPages.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setOriginalCurrentPage((p) =>
|
||||
Math.min(originalPages.length, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={originalCurrentPage >= originalPages.length}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{/* Image display */}
|
||||
<PageNav
|
||||
current={originalCurrentPage}
|
||||
total={originalPages.length}
|
||||
onPrev={() => setOriginalCurrentPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() =>
|
||||
setOriginalCurrentPage((p) =>
|
||||
Math.min(originalPages.length, p + 1)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -424,28 +468,16 @@ export default function ViewerPage({
|
||||
className="w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* Page thumbnails */}
|
||||
{originalPages.length > 1 && (
|
||||
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
|
||||
{originalPages.map((p) => (
|
||||
<button
|
||||
key={p.page}
|
||||
onClick={() => setOriginalCurrentPage(p.page)}
|
||||
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
|
||||
originalCurrentPage === p.page
|
||||
? "border-blue-500 bg-blue-500/20 text-blue-300"
|
||||
: "border-white/10 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{p.page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Thumbs
|
||||
count={originalPages.length}
|
||||
current={originalCurrentPage}
|
||||
onSelect={setOriginalCurrentPage}
|
||||
activeClass="border-blue-500 bg-blue-500/20 text-blue-300"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<iframe
|
||||
src={doc.filePath}
|
||||
src={`/api/documents/${doc.id}/asset?type=source`}
|
||||
className="h-full w-full rounded-lg"
|
||||
title="Original PDF"
|
||||
/>
|
||||
@@ -465,7 +497,7 @@ export default function ViewerPage({
|
||||
badgeColor="bg-violet-500/10 text-violet-400"
|
||||
visible={activeTab === "pixel"}
|
||||
onDownload={
|
||||
pixelPages.length > 0 ? () => downloadAll("pixel") : undefined
|
||||
pixelPages.length > 0 ? () => downloadImages(pixelPages) : undefined
|
||||
}
|
||||
onDownloadPdf={
|
||||
pixelPages.length > 0 ? () => downloadAsPdf("pixel") : undefined
|
||||
@@ -475,40 +507,21 @@ export default function ViewerPage({
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
|
||||
<p className="text-sm text-zinc-500">
|
||||
Analyzing all pages...
|
||||
</p>
|
||||
<p className="text-sm text-zinc-500">Analyzing all pages...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : pixelPages.length > 0 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page navigator */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setPixelCurrentPage((p) => Math.max(1, p - 1))
|
||||
}
|
||||
disabled={pixelCurrentPage <= 1}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-zinc-400">
|
||||
Page {pixelCurrentPage} / {pixelPages.length}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setPixelCurrentPage((p) =>
|
||||
Math.min(pixelPages.length, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={pixelCurrentPage >= pixelPages.length}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
{/* Image display */}
|
||||
<PageNav
|
||||
current={pixelCurrentPage}
|
||||
total={pixelPages.length}
|
||||
onPrev={() => setPixelCurrentPage((p) => Math.max(1, p - 1))}
|
||||
onNext={() =>
|
||||
setPixelCurrentPage((p) =>
|
||||
Math.min(pixelPages.length, p + 1)
|
||||
)
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
@@ -517,24 +530,12 @@ export default function ViewerPage({
|
||||
className="w-full rounded-lg object-contain"
|
||||
/>
|
||||
</div>
|
||||
{/* Page thumbnails */}
|
||||
{pixelPages.length > 1 && (
|
||||
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
|
||||
{pixelPages.map((p) => (
|
||||
<button
|
||||
key={p.page}
|
||||
onClick={() => setPixelCurrentPage(p.page)}
|
||||
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
|
||||
pixelCurrentPage === p.page
|
||||
? "border-violet-500 bg-violet-500/20 text-violet-300"
|
||||
: "border-white/10 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{p.page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Thumbs
|
||||
count={pixelPages.length}
|
||||
current={pixelCurrentPage}
|
||||
onSelect={setPixelCurrentPage}
|
||||
activeClass="border-violet-500 bg-violet-500/20 text-violet-300"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-zinc-500">
|
||||
@@ -547,10 +548,10 @@ export default function ViewerPage({
|
||||
<Panel
|
||||
title="Tesseract OCR"
|
||||
badge={
|
||||
doc.ocrRan
|
||||
? `${ocrPages.length} pages`
|
||||
: ocrLoading
|
||||
? `Processing ${ocrProgress.page}/${ocrProgress.total}`
|
||||
ocrLoading
|
||||
? `Processing ${ocrProgress.page}/${ocrProgress.total}`
|
||||
: doc.ocrRan
|
||||
? `${ocrPages.length} pages`
|
||||
: "Manual"
|
||||
}
|
||||
badgeColor={
|
||||
@@ -560,16 +561,13 @@ export default function ViewerPage({
|
||||
}
|
||||
visible={activeTab === "ocr"}
|
||||
onDownload={
|
||||
ocrPages.length > 0 ? () => downloadAll("ocr") : undefined
|
||||
ocrPages.length > 0 && !ocrLoading ? downloadOcrText : undefined
|
||||
}
|
||||
>
|
||||
{ocrLoading ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 p-6">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-emerald-500 border-t-transparent" />
|
||||
<p className="text-sm text-zinc-400">
|
||||
Running Tesseract OCR...
|
||||
</p>
|
||||
{/* Progress bar */}
|
||||
<p className="text-sm text-zinc-400">Running Tesseract OCR...</p>
|
||||
{ocrProgress.total > 0 && (
|
||||
<div className="w-full max-w-xs">
|
||||
<div className="mb-1 flex justify-between text-[10px] text-zinc-500">
|
||||
@@ -577,10 +575,7 @@ export default function ViewerPage({
|
||||
Page {ocrProgress.page} of {ocrProgress.total}
|
||||
</span>
|
||||
<span>
|
||||
{Math.round(
|
||||
(ocrProgress.page / ocrProgress.total) * 100
|
||||
)}
|
||||
%
|
||||
{Math.round((ocrProgress.page / ocrProgress.total) * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-white/5">
|
||||
@@ -593,87 +588,174 @@ export default function ViewerPage({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Live results feed */}
|
||||
{ocrPages.length > 0 && (
|
||||
<div className="mt-2 w-full max-w-xs rounded-lg border border-white/5 bg-white/[0.02] p-3">
|
||||
<p className="mb-1 text-[10px] font-semibold text-zinc-500">
|
||||
Pages completed:
|
||||
</p>
|
||||
{ocrPages.map((p) => (
|
||||
<div
|
||||
key={p.page}
|
||||
className="flex items-center justify-between text-[10px] text-zinc-400"
|
||||
>
|
||||
<span>Page {p.page}</span>
|
||||
{p.confidence !== undefined && (
|
||||
<span className="text-emerald-400">
|
||||
{p.confidence.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{[...ocrPages]
|
||||
.sort((a, b) => a.page - b.page)
|
||||
.map((p) => (
|
||||
<div
|
||||
key={p.page}
|
||||
className="flex items-center justify-between text-[10px] text-zinc-400"
|
||||
>
|
||||
<span>Page {p.page}</span>
|
||||
{p.error ? (
|
||||
<span className="text-red-400">failed</span>
|
||||
) : p.confidence !== undefined ? (
|
||||
<span className={confidenceTextColor(p.confidence)}>
|
||||
{p.confidence.toFixed(0)}%
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : ocrPages.length > 0 ? (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Page navigator */}
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
|
||||
<button
|
||||
onClick={() =>
|
||||
setOcrCurrentPage((p) => Math.max(1, p - 1))
|
||||
}
|
||||
disabled={ocrCurrentPage <= 1}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-zinc-400">
|
||||
Page {ocrCurrentPage} / {ocrPages.length}
|
||||
{ocrPages[ocrCurrentPage - 1]?.confidence !== undefined && (
|
||||
<span className="ml-2 text-emerald-400">
|
||||
({ocrPages[ocrCurrentPage - 1].confidence!.toFixed(0)}%
|
||||
conf)
|
||||
{/* Navigator + view toggle + re-run */}
|
||||
<div className="flex items-center justify-between gap-2 border-b border-white/5 px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setOcrCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={ocrCurrentPage <= 1}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setOcrCurrentPage((p) => Math.min(ocrPages.length, p + 1))
|
||||
}
|
||||
disabled={ocrCurrentPage >= ocrPages.length}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
<span className="ml-1 text-xs text-zinc-400">
|
||||
{ocrCurrentPage}/{ocrPages.length}
|
||||
</span>
|
||||
{currentOcr?.confidence !== undefined && (
|
||||
<span
|
||||
className={`ml-1 rounded-full bg-white/5 px-2 py-0.5 text-[10px] font-semibold ${confidenceTextColor(
|
||||
currentOcr.confidence
|
||||
)}`}
|
||||
title="Page-level OCR confidence"
|
||||
>
|
||||
{currentOcr.confidence.toFixed(0)}%
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
onClick={() =>
|
||||
setOcrCurrentPage((p) =>
|
||||
Math.min(ocrPages.length, p + 1)
|
||||
)
|
||||
}
|
||||
disabled={ocrCurrentPage >= ocrPages.length}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex rounded-lg bg-white/5 p-0.5">
|
||||
{(["text", "overlay"] as const).map((v) => (
|
||||
<button
|
||||
key={v}
|
||||
onClick={() => setOcrView(v)}
|
||||
className={`rounded-md px-2 py-0.5 text-[10px] font-semibold capitalize transition ${
|
||||
ocrView === v
|
||||
? "bg-emerald-600 text-white"
|
||||
: "text-zinc-400 hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{v}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={runOcr}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-[10px] font-semibold text-zinc-400 transition hover:border-white/20 hover:text-white"
|
||||
title="Re-run OCR"
|
||||
>
|
||||
Re-run
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* OCR text display */}
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-zinc-300">
|
||||
{ocrPages[ocrCurrentPage - 1]?.text ||
|
||||
"(no text extracted)"}
|
||||
</pre>
|
||||
</div>
|
||||
{/* Page thumbnails */}
|
||||
{ocrPages.length > 1 && (
|
||||
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
|
||||
{ocrPages.map((p) => (
|
||||
<button
|
||||
key={p.page}
|
||||
onClick={() => setOcrCurrentPage(p.page)}
|
||||
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
|
||||
ocrCurrentPage === p.page
|
||||
? "border-emerald-500 bg-emerald-500/20 text-emerald-300"
|
||||
: "border-white/10 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{p.page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
{ocrError && (
|
||||
<div className="border-b border-red-500/20 bg-red-500/5 px-3 py-1.5 text-[11px] text-red-300">
|
||||
{ocrError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ocrView === "text" ? (
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{currentOcr?.error ? (
|
||||
<p className="text-sm text-red-300">
|
||||
Page {ocrCurrentPage} failed: {currentOcr.error}
|
||||
</p>
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-zinc-300">
|
||||
{currentOcr?.text ?? "(loading…)"}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{currentOcr?.error ? (
|
||||
<p className="p-4 text-sm text-red-300">
|
||||
Page {ocrCurrentPage} failed: {currentOcr.error}
|
||||
</p>
|
||||
) : overlayBg ? (
|
||||
<div className="relative inline-block w-full">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={overlayBg}
|
||||
alt={`OCR overlay page ${ocrCurrentPage}`}
|
||||
className="w-full rounded-lg object-contain"
|
||||
onLoad={(e) => {
|
||||
const el = e.currentTarget;
|
||||
setOverlayDims({
|
||||
w: el.naturalWidth,
|
||||
h: el.naturalHeight,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{overlayDims &&
|
||||
currentWords.map((w, i) => {
|
||||
const style = confidenceStyle(w.confidence);
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
title={`${w.text} — ${w.confidence.toFixed(0)}%`}
|
||||
className="absolute cursor-help"
|
||||
style={{
|
||||
left: `${(w.bbox.x0 / overlayDims.w) * 100}%`,
|
||||
top: `${(w.bbox.y0 / overlayDims.h) * 100}%`,
|
||||
width: `${
|
||||
((w.bbox.x1 - w.bbox.x0) / overlayDims.w) * 100
|
||||
}%`,
|
||||
height: `${
|
||||
((w.bbox.y1 - w.bbox.y0) / overlayDims.h) * 100
|
||||
}%`,
|
||||
backgroundColor: style.bg,
|
||||
border: `1px solid ${style.border}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="p-4 text-sm text-zinc-500">
|
||||
Render the original page to see the overlay.
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-zinc-500">
|
||||
<LegendDot color="#22c55e" label="≥90" />
|
||||
<LegendDot color="#f59e0b" label="70–89" />
|
||||
<LegendDot color="#ef4444" label="<70" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Thumbs
|
||||
count={ocrPages.length}
|
||||
current={ocrCurrentPage}
|
||||
onSelect={setOcrCurrentPage}
|
||||
activeClass="border-emerald-500 bg-emerald-500/20 text-emerald-300"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
@@ -697,12 +779,14 @@ export default function ViewerPage({
|
||||
OCR Not Yet Run
|
||||
</p>
|
||||
<p className="mb-4 text-xs text-zinc-500">
|
||||
Runs Tesseract on every page. Only one run allowed per doc.
|
||||
Runs Tesseract on the sanitized image of every page. Re-runnable.
|
||||
</p>
|
||||
{ocrError && (
|
||||
<p className="mb-3 text-xs text-red-300">{ocrError}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={runOcr}
|
||||
disabled={doc.ocrRan}
|
||||
className="rounded-full bg-emerald-600 px-6 py-2.5 text-sm font-semibold transition hover:bg-emerald-500 disabled:opacity-50"
|
||||
className="rounded-full bg-emerald-600 px-6 py-2.5 text-sm font-semibold transition hover:bg-emerald-500"
|
||||
>
|
||||
Run Tesseract OCR
|
||||
</button>
|
||||
@@ -715,6 +799,83 @@ export default function ViewerPage({
|
||||
);
|
||||
}
|
||||
|
||||
function PageNav({
|
||||
current,
|
||||
total,
|
||||
onPrev,
|
||||
onNext,
|
||||
}: {
|
||||
current: number;
|
||||
total: number;
|
||||
onPrev: () => void;
|
||||
onNext: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-white/5 px-4 py-2">
|
||||
<button
|
||||
onClick={onPrev}
|
||||
disabled={current <= 1}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
<span className="text-xs text-zinc-400">
|
||||
Page {current} / {total}
|
||||
</span>
|
||||
<button
|
||||
onClick={onNext}
|
||||
disabled={current >= total}
|
||||
className="rounded-md border border-white/10 px-2 py-1 text-xs font-medium text-zinc-400 transition hover:bg-white/5 disabled:opacity-30"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Thumbs({
|
||||
count,
|
||||
current,
|
||||
onSelect,
|
||||
activeClass,
|
||||
}: {
|
||||
count: number;
|
||||
current: number;
|
||||
onSelect: (page: number) => void;
|
||||
activeClass: string;
|
||||
}) {
|
||||
if (count <= 1) return null;
|
||||
return (
|
||||
<div className="flex gap-1 overflow-x-auto border-t border-white/5 p-2">
|
||||
{Array.from({ length: count }, (_, i) => i + 1).map((page) => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => onSelect(page)}
|
||||
className={`flex-shrink-0 rounded-md border px-2 py-1 text-[10px] font-semibold transition ${
|
||||
current === page
|
||||
? activeClass
|
||||
: "border-white/10 text-zinc-500 hover:text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LegendDot({ color, label }: { color: string; label: string }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-sm"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({
|
||||
title,
|
||||
badge,
|
||||
|
||||
Reference in new issue
Block a user