Files
XRayPDF/client/src/app/dashboard/[id]/page.tsx
T
Aryan SinghandClaude Opus 5 7e7e4d9b1c 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>
2026-09-01 23:54:57 -05:00

949 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState, use, useCallback } from "react";
import Link from "next/link";
type DocData = {
id: string;
originalName: string;
filePath: string;
pixelResultDir: string | null;
pixelPageCount: number;
ocrResultDir: string | null;
ocrPageCount: number;
ocrRan: boolean;
fileSize: number;
pageCount: number;
createdAt: string;
};
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;
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,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const [doc, setDoc] = useState<DocData | null>(null);
const [loading, setLoading] = useState(true);
// Original server-rendered state
const [originalLoading, setOriginalLoading] = useState(false);
const [originalPages, setOriginalPages] = useState<OriginalPage[]>([]);
const [originalCurrentPage, setOriginalCurrentPage] = useState(1);
// Pixel state
const [pixelLoading, setPixelLoading] = useState(false);
const [pixelPages, setPixelPages] = useState<PixelPage[]>([]);
const [pixelCurrentPage, setPixelCurrentPage] = useState(1);
// OCR state
const [ocrLoading, setOcrLoading] = useState(false);
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"
);
const fetchDoc = useCallback(async () => {
setLoading(true);
const res = await fetch("/api/documents");
if (res.ok) {
const data = await res.json();
const found = data.documents.find((d: DocData) => d.id === id);
if (found) {
setDoc(found);
renderOriginalPages(found.id);
if (found.pixelPageCount === 0) {
runPixelAnalysis(found.id);
} else {
setPixelPages(
Array.from({ length: found.pixelPageCount }, (_, i) => ({
page: i + 1,
path: assetUrl(found.id, "pixel", i + 1),
}))
);
}
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,
}))
);
}
}
}
setLoading(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]);
useEffect(() => {
fetchDoc();
}, [fetchDoc]);
const renderOriginalPages = async (docId: string) => {
setOriginalLoading(true);
try {
const res = await fetch(`/api/documents/${docId}/render`, {
method: "POST",
});
if (res.ok) {
const data = await res.json();
setOriginalPages(data.pages);
}
} catch (e) {
console.error("Render error:", e);
}
setOriginalLoading(false);
};
const runPixelAnalysis = async (docId: string) => {
setPixelLoading(true);
try {
const res = await fetch(`/api/documents/${docId}/pixel`, {
method: "POST",
});
if (res.ok) {
const data = await res.json();
setPixelPages(data.pages);
setDoc((prev) =>
prev
? { ...prev, pixelPageCount: data.pageCount, pageCount: data.pageCount }
: prev
);
}
} catch (e) {
console.error("Pixel analysis error:", e);
}
setPixelLoading(false);
};
// 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(`/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 {
/* 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);
}
}, [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) return;
setOcrLoading(true);
setOcrError(null);
setOcrProgress({ page: 0, total: 0 });
setOcrPages([]);
setOcrWords({});
try {
const res = await fetch(`/api/documents/${doc.id}/ocr`, {
method: "POST",
});
if (!res.ok) {
let message = "OCR request failed";
try {
const data = await res.json();
message = data.error || message;
} catch {
/* ignore */
}
setOcrError(message);
setOcrLoading(false);
return;
}
const reader = res.body?.getReader();
if (!reader) {
setOcrLoading(false);
return;
}
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) {
if (!line.trim()) continue;
try {
const event = JSON.parse(line);
if (event.type === "progress") {
setOcrProgress({ page: event.page, total: event.totalPages });
} else if (event.type === "page_done") {
setOcrProgress({ page: event.page, total: event.totalPages });
setOcrPages((prev) => [
...prev.filter((p) => p.page !== event.page),
{
page: event.page,
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, 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 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") => {
if (!doc) return;
try {
const res = await fetch(`/api/documents/${doc.id}/export?type=${type}`);
if (!res.ok) {
console.error("PDF export failed", await res.text());
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${doc.originalName.replace(/\.pdf$/i, "")}_${type}.pdf`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (err) {
console.error("PDF download failed", err);
}
};
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">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-violet-500 border-t-transparent" />
</main>
);
}
if (!doc) {
return (
<main className="flex min-h-[60vh] flex-col items-center justify-center gap-4">
<p className="text-zinc-400">Document not found.</p>
<Link
href="/dashboard"
className="rounded-lg bg-violet-600 px-4 py-2 text-sm font-semibold transition hover:bg-violet-500"
>
Back to Dashboard
</Link>
</main>
);
}
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 */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Link
href="/dashboard"
className="rounded-lg border border-white/10 p-2 transition hover:border-white/20 hover:bg-white/5"
>
<svg
className="h-4 w-4 text-zinc-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M15.75 19.5L8.25 12l7.5-7.5"
/>
</svg>
</Link>
<div>
<h1 className="text-lg font-bold">{doc.originalName}</h1>
<p className="text-xs text-zinc-500">
{doc.pageCount > 0 ? `${doc.pageCount} pages · ` : ""}
Uploaded {new Date(doc.createdAt).toLocaleString()}
</p>
</div>
</div>
{/* Mobile tab switcher */}
<div className="flex gap-1 rounded-xl bg-white/5 p-1 sm:hidden">
{(["original", "pixel", "ocr"] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 rounded-lg px-3 py-1.5 text-xs font-semibold capitalize transition ${
activeTab === tab
? "bg-violet-600 text-white"
: "text-zinc-400 hover:text-white"
}`}
>
{tab === "ocr" ? "OCR" : tab}
</button>
))}
</div>
</div>
{/* 3-Panel Grid */}
<div className="grid gap-4 sm:grid-cols-3">
{/* Panel 1: Original */}
<Panel
title="Original PDF"
badge={
originalLoading
? "Rendering..."
: originalPages.length > 0
? `${originalPages.length} pages`
: "Source"
}
badgeColor="bg-blue-500/10 text-blue-400"
visible={activeTab === "original"}
onDownload={
originalPages.length > 0
? () => downloadImages(originalPages)
: undefined
}
onDownloadPdf={
originalPages.length > 0 ? () => downloadAsPdf("original") : undefined
}
>
{originalLoading ? (
<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-blue-500 border-t-transparent" />
<p className="text-sm text-zinc-500">Rendering pages...</p>
</div>
</div>
) : originalPages.length > 0 ? (
<div className="flex h-full flex-col">
<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
src={originalPages[originalCurrentPage - 1]?.path}
alt={`Original page ${originalCurrentPage}`}
className="w-full rounded-lg object-contain"
/>
</div>
<Thumbs
count={originalPages.length}
current={originalCurrentPage}
onSelect={setOriginalCurrentPage}
activeClass="border-blue-500 bg-blue-500/20 text-blue-300"
/>
</div>
) : (
<iframe
src={`/api/documents/${doc.id}/asset?type=source`}
className="h-full w-full rounded-lg"
title="Original PDF"
/>
)}
</Panel>
{/* Panel 2: Pixel Analysis */}
<Panel
title="Pixel Analysis"
badge={
pixelLoading
? "Processing..."
: pixelPages.length > 0
? `${pixelPages.length} pages`
: "Pending"
}
badgeColor="bg-violet-500/10 text-violet-400"
visible={activeTab === "pixel"}
onDownload={
pixelPages.length > 0 ? () => downloadImages(pixelPages) : undefined
}
onDownloadPdf={
pixelPages.length > 0 ? () => downloadAsPdf("pixel") : undefined
}
>
{pixelLoading ? (
<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>
</div>
</div>
) : pixelPages.length > 0 ? (
<div className="flex h-full flex-col">
<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
src={pixelPages[pixelCurrentPage - 1]?.path}
alt={`Pixel analysis page ${pixelCurrentPage}`}
className="w-full rounded-lg object-contain"
/>
</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">
Pixel analysis will run automatically...
</div>
)}
</Panel>
{/* Panel 3: Tesseract OCR */}
<Panel
title="Tesseract OCR"
badge={
ocrLoading
? `Processing ${ocrProgress.page}/${ocrProgress.total}`
: doc.ocrRan
? `${ocrPages.length} pages`
: "Manual"
}
badgeColor={
doc.ocrRan
? "bg-emerald-500/10 text-emerald-400"
: "bg-amber-500/10 text-amber-400"
}
visible={activeTab === "ocr"}
onDownload={
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>
{ocrProgress.total > 0 && (
<div className="w-full max-w-xs">
<div className="mb-1 flex justify-between text-[10px] text-zinc-500">
<span>
Page {ocrProgress.page} of {ocrProgress.total}
</span>
<span>
{Math.round((ocrProgress.page / ocrProgress.total) * 100)}%
</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-white/5">
<div
className="h-full rounded-full bg-emerald-500 transition-all duration-500"
style={{
width: `${(ocrProgress.page / ocrProgress.total) * 100}%`,
}}
/>
</div>
</div>
)}
{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]
.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">
{/* 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>
)}
</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>
{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">
<div className="rounded-full bg-amber-500/10 p-4">
<svg
className="h-8 w-8 text-amber-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
/>
</svg>
</div>
<div className="text-center">
<p className="mb-1 text-sm font-medium text-zinc-300">
OCR Not Yet Run
</p>
<p className="mb-4 text-xs text-zinc-500">
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}
className="rounded-full bg-emerald-600 px-6 py-2.5 text-sm font-semibold transition hover:bg-emerald-500"
>
Run Tesseract OCR
</button>
</div>
</div>
)}
</Panel>
</div>
</main>
);
}
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,
badgeColor,
children,
visible,
onDownload,
onDownloadPdf,
}: {
title: string;
badge: string;
badgeColor: string;
children: React.ReactNode;
visible: boolean;
onDownload?: () => void;
onDownloadPdf?: () => void;
}) {
return (
<div
className={`flex flex-col overflow-hidden rounded-2xl border border-white/5 bg-white/[0.02] ${
visible ? "" : "hidden sm:flex"
}`}
>
<div className="flex items-center justify-between border-b border-white/5 px-4 py-3">
<h3 className="text-sm font-semibold">{title}</h3>
<div className="flex items-center gap-2">
{onDownload && (
<button
onClick={onDownload}
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="Download all pages"
>
<svg
className="mr-1 inline-block h-3 w-3"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3"
/>
</svg>
All
</button>
)}
{onDownloadPdf && (
<button
onClick={onDownloadPdf}
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="Convert to PDF and download"
>
PDF
</button>
)}
<span
className={`rounded-full px-2.5 py-0.5 text-[10px] font-semibold ${badgeColor}`}
>
{badge}
</span>
</div>
</div>
<div className="flex-1" style={{ minHeight: "65vh" }}>
{children}
</div>
</div>
);
}