"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(null); const [loading, setLoading] = useState(true); // Original server-rendered state const [originalLoading, setOriginalLoading] = useState(false); const [originalPages, setOriginalPages] = useState([]); const [originalCurrentPage, setOriginalCurrentPage] = useState(1); // Pixel state const [pixelLoading, setPixelLoading] = useState(false); const [pixelPages, setPixelPages] = useState([]); const [pixelCurrentPage, setPixelCurrentPage] = useState(1); // OCR state const [ocrLoading, setOcrLoading] = useState(false); const [ocrPages, setOcrPages] = useState([]); const [ocrCurrentPage, setOcrCurrentPage] = useState(1); const [ocrProgress, setOcrProgress] = useState({ page: 0, total: 0 }); const [ocrError, setOcrError] = useState(null); const [ocrView, setOcrView] = useState<"text" | "overlay">("text"); const [ocrWords, setOcrWords] = useState>({}); 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 (
); } if (!doc) { return (

Document not found.

Back to Dashboard
); } const currentOcr = ocrPages.find((p) => p.page === ocrCurrentPage); const currentWords = ocrWords[ocrCurrentPage] ?? []; const overlayBg = originalPages[ocrCurrentPage - 1]?.path; return (
{/* Header */}

{doc.originalName}

{doc.pageCount > 0 ? `${doc.pageCount} pages · ` : ""} Uploaded {new Date(doc.createdAt).toLocaleString()}

{/* Mobile tab switcher */}
{(["original", "pixel", "ocr"] as const).map((tab) => ( ))}
{/* 3-Panel Grid */}
{/* Panel 1: Original */} 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 ? (

Rendering pages...

) : originalPages.length > 0 ? (
setOriginalCurrentPage((p) => Math.max(1, p - 1))} onNext={() => setOriginalCurrentPage((p) => Math.min(originalPages.length, p + 1) ) } />
{/* eslint-disable-next-line @next/next/no-img-element */} {`Original
) : (