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 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(null) const [numPages, setNumPages] = useState(0) const [pageNumber, setPageNumber] = useState(1) const [width, setWidth] = useState() const [kind, setKind] = useState('loading') const [pdfData, setPdfData] = useState(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 (

{label} {caption && {caption}}

Open original
{kind === 'loading' && (
Loading document…
)} {(kind === 'failed' || kind === 'other') && (

{kind === 'other' ? `This ${docNoun} can’t be previewed here.` : 'This document can’t be previewed here.'}

Open the original {kind === 'other' ? docNoun : 'document'} →
)} {kind === 'pdf' && pdfFile && ( { setNumPages(numPages) setPageNumber(1) }} onLoadError={() => setKind('failed')} loading={
Loading document…
} error={
Couldn’t load this document.
} className="flex justify-center" >
)}
{/* Page navigation — only meaningful for multi-page documents. */} {kind === 'pdf' && numPages > 1 && (
Page {pageNumber} of {numPages}
)}
) }