open-navigator / web_app /src /components /DocumentViewer.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
8.94 kB
import { useEffect, useMemo, useRef, useState } from 'react'
import { Document, Page, pdfjs } from 'react-pdf'
import 'react-pdf/dist/Page/AnnotationLayer.css'
import 'react-pdf/dist/Page/TextLayer.css'
import {
DocumentTextIcon,
ChevronLeftIcon,
ChevronRightIcon,
ArrowTopRightOnSquareIcon,
} from '@heroicons/react/24/outline'
import { apiBaseUrl } from '../lib/api'
/**
* Inline document viewer for meeting agenda/minutes — the document analogue of
* MeetingPlayer's embedded recording. PDFs render one page at a time with
* prev/next paging, sized to the container.
*
* Government document hosts rarely send CORS headers, so the browser can't fetch
* the file directly; we load it through the same-origin API proxy
* (/document/proxy?url=…), which only serves URLs already in the warehouse.
*
* Not every "minutes"/"agenda" link is a PDF — some portals serve HTML pages or
* Word/Office files. We fetch the bytes ONCE, sniff the real type (content-type +
* %PDF magic), and only hand actual PDFs to react-pdf. Non-PDF documents are NOT
* forced through the PDF renderer (which would just fail) — they show a typed
* "open the original" card instead. Any fetch/render failure also falls back to
* that card rather than a broken frame.
*/
// Map a content-type to a human noun for the non-PDF fallback card.
function describeDocType(contentType: string): string {
const ct = contentType.toLowerCase()
if (ct.includes('html')) return 'web page'
if (ct.includes('msword') || ct.includes('wordprocessing')) return 'Word document'
if (ct.includes('spreadsheet') || ct.includes('ms-excel')) return 'spreadsheet'
if (ct.includes('presentation') || ct.includes('powerpoint')) return 'slideshow'
if (ct.includes('rtf')) return 'rich-text document'
if (ct.includes('plain')) return 'text file'
return 'document'
}
// The first bytes of a PDF are always the ASCII magic "%PDF".
function looksLikePdf(bytes: Uint8Array): boolean {
return bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46
}
// Bundled pdf.js worker; Vite resolves this URL at build time (no CDN dependency).
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString()
// Stable identity so <Document> doesn't reload on every render (react-pdf warns
// when the `options` prop changes reference).
const PDF_OPTIONS = {
cMapUrl: 'https://unpkg.com/pdfjs-dist@4.8.69/cmaps/',
cMapPacked: true,
}
interface DocumentViewerProps {
/** The real external document URL (proxied for same-origin fetch). */
url: string
/** Heading label, e.g. "Agenda" or "Minutes". */
label: string
/** Optional caption under the heading (e.g. the document date). */
caption?: string
}
type DocKind = 'loading' | 'pdf' | 'other' | 'failed'
export default function DocumentViewer({ url, label, caption }: DocumentViewerProps) {
const containerRef = useRef<HTMLDivElement>(null)
const [numPages, setNumPages] = useState(0)
const [pageNumber, setPageNumber] = useState(1)
const [width, setWidth] = useState<number>()
const [kind, setKind] = useState<DocKind>('loading')
const [pdfData, setPdfData] = useState<Uint8Array | null>(null)
const [docNoun, setDocNoun] = useState('document')
// Render the page at the container's width so it scales on resize/mobile.
useEffect(() => {
const el = containerRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const update = () => setWidth(el.clientWidth)
update()
const ro = new ResizeObserver(update)
ro.observe(el)
return () => ro.disconnect()
}, [])
// Same-origin proxy URL; the proxy validates `url` against the warehouse.
const fileUrl = useMemo(
() => `${apiBaseUrl}/document/proxy?url=${encodeURIComponent(url)}`,
[url],
)
// Fetch the bytes once and decide how to render. PDFs go to react-pdf; anything
// else (HTML page, Word/Office file, fetch failure) shows the typed fallback
// card — we never feed a non-PDF to the PDF renderer.
useEffect(() => {
let cancelled = false
setKind('loading')
setPdfData(null)
fetch(fileUrl)
.then(async (resp) => {
if (!resp.ok) throw new Error(`proxy returned ${resp.status}`)
const contentType = resp.headers.get('content-type') ?? ''
const bytes = new Uint8Array(await resp.arrayBuffer())
if (cancelled) return
if (contentType.toLowerCase().includes('pdf') || looksLikePdf(bytes)) {
setPdfData(bytes)
setKind('pdf')
} else {
setDocNoun(describeDocType(contentType))
setKind('other')
}
})
.catch(() => {
if (!cancelled) setKind('failed')
})
return () => {
cancelled = true
}
}, [fileUrl])
// Stable identity for react-pdf's `file` prop. pdf.js transfers (detaches) the
// buffer to its worker on load, so this must NOT change reference per render or
// it would reload from a detached buffer.
const pdfFile = useMemo(() => (pdfData ? { data: pdfData } : null), [pdfData])
const goPrev = () => setPageNumber((p) => Math.max(1, p - 1))
const goNext = () => setPageNumber((p) => Math.min(numPages || 1, p + 1))
return (
<div className="bg-white rounded-lg shadow-sm p-6 mb-6">
<div className="mb-4 flex items-center justify-between gap-3">
<h2 className="flex items-center gap-2 text-lg font-bold text-gray-900">
<DocumentTextIcon className="h-5 w-5 text-[#1d6b5f]" />
{label}
{caption && <span className="text-sm font-normal text-gray-400">{caption}</span>}
</h2>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center gap-1.5 text-sm font-medium text-[#1d6b5f] hover:text-[#155448]"
>
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
Open original
</a>
</div>
<div ref={containerRef} className="overflow-hidden rounded-lg bg-gray-100">
{kind === 'loading' && (
<div className="px-4 py-12 text-center text-sm text-gray-500">Loading document…</div>
)}
{(kind === 'failed' || kind === 'other') && (
<div className="flex flex-col items-center gap-2 px-4 py-12 text-center text-sm text-gray-500">
<DocumentTextIcon className="h-8 w-8 text-gray-300" />
<p>
{kind === 'other'
? `This ${docNoun} can’t be previewed here.`
: 'This document can’t be previewed here.'}
</p>
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 font-medium text-[#1d6b5f] hover:underline"
>
<ArrowTopRightOnSquareIcon className="h-4 w-4" />
Open the original {kind === 'other' ? docNoun : 'document'} →
</a>
</div>
)}
{kind === 'pdf' && pdfFile && (
<Document
file={pdfFile}
options={PDF_OPTIONS}
onLoadSuccess={({ numPages }) => {
setNumPages(numPages)
setPageNumber(1)
}}
onLoadError={() => setKind('failed')}
loading={<div className="px-4 py-12 text-center text-sm text-gray-500">Loading document…</div>}
error={<div className="px-4 py-12 text-center text-sm text-gray-500">Couldn’t load this document.</div>}
className="flex justify-center"
>
<Page
pageNumber={pageNumber}
width={width}
renderAnnotationLayer
renderTextLayer
/>
</Document>
)}
</div>
{/* Page navigation — only meaningful for multi-page documents. */}
{kind === 'pdf' && numPages > 1 && (
<div className="mt-3 flex items-center justify-center gap-4 text-sm text-gray-600">
<button
onClick={goPrev}
disabled={pageNumber <= 1}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
>
<ChevronLeftIcon className="h-4 w-4" />
Prev
</button>
<span className="tabular-nums">
Page {pageNumber} of {numPages}
</span>
<button
onClick={goNext}
disabled={pageNumber >= numPages}
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-medium transition-colors hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-40"
>
Next
<ChevronRightIcon className="h-4 w-4" />
</button>
</div>
)}
</div>
)
}