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
171 lines
6.1 KiB
TypeScript
171 lines
6.1 KiB
TypeScript
'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>
|
||
);
|
||
}
|