Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| 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> | |
| ) | |
| } | |