Files
pn-new-crm/src/components/files/pdf-viewer.tsx
Matt 221ae5784e chore(autonomous-session): consolidate uncommitted work from prior session
Bundles the prior autonomous-session output that was sitting unstaged:

- Em-dash sweep across src/ + tests/ (en-dash/em-dash to hyphen, ~2280 instances)
- country-flag-icons rollout (CountryFlag component, replaces emoji glyphs that
  never rendered on Windows; lazy-loads the 3x2 SVG index as a single chunk
  after the per-subpath dynamic-import approach silently failed in webpack)
- Admin IA Phase 1+2: 7-domain regroup, 41 to 38 pages, /admin/berths index,
  redirects (ocr to ai, reports to dashboard, invitations to users),
  docs/admin-ia-proposal.md
- Per-template email tester (registry + endpoint + UI on Email admin page)
- Cancel-document mode picker (delete-from-Documenso vs keep-for-audit)
- Dashboard PDF report: 25 widgets, SVG charts, date-range picker, 11 resolvers
- Customize-widgets per-region sortables at xl+ (charts/rails/feed); single
  flat sortable below xl when the layout stacks; per-viewport saved orders
- Audit doc updates capturing each shipped item
- Lint fixes: react-compiler immutability in DonutChart (reduce instead of
  let-reassign), set-state-in-effect disables in CountryFlag and
  UploadForSigning preview-bytes effect, unused 'confirm' destructures in
  interest contract + reservation tabs, unescaped apostrophe in test-template
  card copy
2026-05-23 00:52:59 +02:00

171 lines
6.1 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 { useMemo, useState } from 'react';
import { Document, Page, pdfjs } from 'react-pdf';
import { usePinch } from '@use-gesture/react';
import { ChevronLeft, ChevronRight, Loader2, Minus, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import 'react-pdf/dist/Page/AnnotationLayer.css';
import 'react-pdf/dist/Page/TextLayer.css';
/**
* In-app PDF viewer.
*
* Replaces the `<iframe>` preview which delegated rendering to the
* browser's built-in PDF viewer. The iframe path works on desktop
* Chrome/Firefox/Safari but is unreliable on mobile (older Android
* Chrome refuses to render PDFs inline; iOS Safari opens a new tab).
* react-pdf renders via pdfjs-dist which works identically everywhere.
*
* The pdfjs worker is loaded from a CDN matched to the installed
* pdfjs-dist version. Bundling the worker locally would inflate the
* main-route bundle by ~150kb; the CDN avoids that cost on every page
* that uses pdfjs at the cost of a single first-load fetch per user.
*/
// Match the pdfjs.version that react-pdf has bundled at runtime. If
// pdfjs-dist is bumped, the URL auto-tracks. .mjs (module worker) is
// the current pdfjs distribution; .js fallback handled by pdf.js
// internally for older browsers.
pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`;
interface PdfViewerProps {
url: string;
/** Optional aria-friendly filename for the document. */
fileName?: string;
}
export function PdfViewer(props: PdfViewerProps) {
// Key-based remount: re-mount the inner body whenever the url
// changes so all local state (page, zoom, error) re-initializes from
// scratch. Replaces the prior useEffect(reset, [url]) the Compiler
// flagged as set-state-in-effect.
return <PdfViewerBody key={props.url} {...props} />;
}
function PdfViewerBody({ url, fileName }: PdfViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(1);
const [scale, setScale] = useState(1);
const [error, setError] = useState<string | null>(null);
// Keep options stable across renders so react-pdf doesn't refetch
// every render - useMemo wins because react-pdf compares by identity.
const options = useMemo(
() => ({
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
}),
[],
);
// Pinch-zoom on touch devices. usePinch's `offset` already maps
// gesture distance to a smoothly-changing scalar; we clamp it to
// the same [0.5, 3] range as the +/- buttons.
const pinchBindings = usePinch(
({ offset: [s] }) => {
setScale(Math.max(0.5, Math.min(3, s)));
},
{
scaleBounds: { min: 0.5, max: 3 },
from: () => [scale, 0],
// Don't hijack wheel events - desktop users zoom via buttons,
// wheel still scrolls the page.
eventOptions: { passive: false },
},
);
return (
<div className="flex h-full flex-col">
<div className="flex items-center justify-between gap-2 border-b bg-muted/40 px-3 py-2 text-sm">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
disabled={pageNumber <= 1}
onClick={() => setPageNumber((p) => Math.max(1, p - 1))}
aria-label="Previous page"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[80px] text-center tabular-nums">
{numPages ? `${pageNumber} / ${numPages}` : '-'}
</span>
<Button
type="button"
variant="ghost"
size="icon"
disabled={numPages === null || pageNumber >= numPages}
onClick={() => setPageNumber((p) => (numPages ? Math.min(numPages, p + 1) : p))}
aria-label="Next page"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setScale((s) => Math.max(0.5, s - 0.25))}
aria-label="Zoom out"
>
<Minus className="h-4 w-4" />
</Button>
<span className="min-w-[48px] text-center tabular-nums">{Math.round(scale * 100)}%</span>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => setScale((s) => Math.min(3, s + 0.25))}
aria-label="Zoom in"
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
<div
className="flex-1 overflow-auto bg-muted/30 p-4 touch-pan-y"
// Pinch-zoom on touch devices (tablets, phones). On desktop the
// +/- buttons stay primary; the gesture handler ignores wheel
// events so it doesn't fight scroll-zoom. Range matches the
// button zoom (50%300%).
{...pinchBindings()}
>
{error ? (
<div className="flex h-full items-center justify-center text-sm text-destructive">
{error}
</div>
) : (
<div className="flex justify-center">
<Document
file={url}
options={options}
onLoadSuccess={({ numPages: n }) => setNumPages(n)}
onLoadError={(err) => setError(err.message || 'Failed to load PDF')}
loading={
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
Loading PDF
</div>
}
>
<Page
pageNumber={pageNumber}
scale={scale}
renderAnnotationLayer
renderTextLayer
aria-label={fileName ? `${fileName}, page ${pageNumber}` : `Page ${pageNumber}`}
/>
</Document>
</div>
)}
</div>
</div>
);
}