feat(deps): pdfjs-dist + react-pdf for consistent in-app PDF preview
Replaces the `<iframe src={presignedUrl}>` preview path which
delegated rendering to the browser's built-in PDF viewer. The iframe
worked on desktop but failed on mobile (older Android Chrome
refuses inline PDFs; iOS Safari opens a new tab).
`<PdfViewer>` renders via pdfjs-dist + react-pdf so the experience
is identical across all browsers + form factors. Adds page nav,
zoom controls, and per-page accessibility labels.
Lazy-loaded via next/dynamic with ssr:false — pdfjs is ~150kb gzip,
no route ships it unless a PDF is actually previewed.
pdfjs worker + CMaps + fonts loaded from unpkg CDN pinned to the
matched pdfjs-dist version (first-load cost paid once per user, no
bundle-size impact on routes that never preview a PDF).
Verified: tsc clean, vitest 1315/1315, next build green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -90,6 +90,7 @@
|
|||||||
"p-limit": "^7.3.0",
|
"p-limit": "^7.3.0",
|
||||||
"papaparse": "^5.5.3",
|
"papaparse": "^5.5.3",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
|
"pdfjs-dist": "^5.7.284",
|
||||||
"pdfkit": "^0.18.0",
|
"pdfkit": "^0.18.0",
|
||||||
"pino": "^10.3.1",
|
"pino": "^10.3.1",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
@@ -102,6 +103,7 @@
|
|||||||
"react-hook-form": "^7.75.0",
|
"react-hook-form": "^7.75.0",
|
||||||
"react-image-crop": "^11.0.10",
|
"react-image-crop": "^11.0.10",
|
||||||
"react-number-format": "^5.4.5",
|
"react-number-format": "^5.4.5",
|
||||||
|
"react-pdf": "^10.4.1",
|
||||||
"react-resizable-panels": "^3.0.6",
|
"react-resizable-panels": "^3.0.6",
|
||||||
"recharts": "^3.8.1",
|
"recharts": "^3.8.1",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|||||||
205
pnpm-lock.yaml
generated
205
pnpm-lock.yaml
generated
@@ -196,6 +196,9 @@ importers:
|
|||||||
pdf-lib:
|
pdf-lib:
|
||||||
specifier: ^1.17.1
|
specifier: ^1.17.1
|
||||||
version: 1.17.1
|
version: 1.17.1
|
||||||
|
pdfjs-dist:
|
||||||
|
specifier: ^5.7.284
|
||||||
|
version: 5.7.284
|
||||||
pdfkit:
|
pdfkit:
|
||||||
specifier: ^0.18.0
|
specifier: ^0.18.0
|
||||||
version: 0.18.0
|
version: 0.18.0
|
||||||
@@ -232,6 +235,9 @@ importers:
|
|||||||
react-number-format:
|
react-number-format:
|
||||||
specifier: ^5.4.5
|
specifier: ^5.4.5
|
||||||
version: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 5.4.5(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
|
react-pdf:
|
||||||
|
specifier: ^10.4.1
|
||||||
|
version: 10.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
react-resizable-panels:
|
react-resizable-panels:
|
||||||
specifier: ^3.0.6
|
specifier: ^3.0.6
|
||||||
version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -267,7 +273,7 @@ importers:
|
|||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
unpdf:
|
unpdf:
|
||||||
specifier: ^1.6.2
|
specifier: ^1.6.2
|
||||||
version: 1.6.2
|
version: 1.6.2(@napi-rs/canvas@0.1.100)
|
||||||
vaul:
|
vaul:
|
||||||
specifier: ^1.1.2
|
specifier: ^1.1.2
|
||||||
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.1.2(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -1205,6 +1211,81 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||||
|
resolution: {integrity: sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||||
|
resolution: {integrity: sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||||
|
resolution: {integrity: sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||||
|
resolution: {integrity: sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||||
|
resolution: {integrity: sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||||
|
resolution: {integrity: sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||||
|
resolution: {integrity: sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||||
|
resolution: {integrity: sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||||
|
resolution: {integrity: sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||||
|
resolution: {integrity: sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||||
|
resolution: {integrity: sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@napi-rs/canvas@0.1.100':
|
||||||
|
resolution: {integrity: sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==}
|
||||||
|
engines: {node: '>= 10'}
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
|
||||||
|
|
||||||
@@ -5420,10 +5501,16 @@ packages:
|
|||||||
mailparser@3.9.8:
|
mailparser@3.9.8:
|
||||||
resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==}
|
resolution: {integrity: sha512-7jSlFGXiianVnhnb6wdutJFloD34488nrHY7r6FNqwXAhZ7YiJDYrKKTxZJ0oSrXcAPHm8YoYnh97xyGtrBQ3w==}
|
||||||
|
|
||||||
|
make-cancellable-promise@2.0.0:
|
||||||
|
resolution: {integrity: sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==}
|
||||||
|
|
||||||
make-dir@4.0.0:
|
make-dir@4.0.0:
|
||||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
|
make-event-props@2.0.0:
|
||||||
|
resolution: {integrity: sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==}
|
||||||
|
|
||||||
marked@15.0.12:
|
marked@15.0.12:
|
||||||
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
|
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
|
||||||
engines: {node: '>= 18'}
|
engines: {node: '>= 18'}
|
||||||
@@ -5445,6 +5532,14 @@ packages:
|
|||||||
memory-pager@1.5.0:
|
memory-pager@1.5.0:
|
||||||
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
resolution: {integrity: sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==}
|
||||||
|
|
||||||
|
merge-refs@2.0.0:
|
||||||
|
resolution: {integrity: sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
merge-stream@2.0.0:
|
merge-stream@2.0.0:
|
||||||
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
|
||||||
|
|
||||||
@@ -5844,6 +5939,14 @@ packages:
|
|||||||
pdf-lib@1.17.1:
|
pdf-lib@1.17.1:
|
||||||
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||||
|
|
||||||
|
pdfjs-dist@5.4.296:
|
||||||
|
resolution: {integrity: sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==}
|
||||||
|
engines: {node: '>=20.16.0 || >=22.3.0'}
|
||||||
|
|
||||||
|
pdfjs-dist@5.7.284:
|
||||||
|
resolution: {integrity: sha512-h4EdYQczmGhbOlqc3PPZwxevn7ApdWPbovAuWXOB/DjIyigSnwfy2oze7c6mRcSr9XgLp3eN3EeL4DyySTPMFw==}
|
||||||
|
engines: {node: '>=22.13.0 || >=24'}
|
||||||
|
|
||||||
pdfkit@0.18.0:
|
pdfkit@0.18.0:
|
||||||
resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==}
|
resolution: {integrity: sha512-NvUwSDZ0eYEzqAiWwVQkRkjYUkZ48kcsHuCO31ykqPPIVkwoSDjDGiwIgHHNtsiwls3z3P/zy4q00hl2chg2Ug==}
|
||||||
|
|
||||||
@@ -6058,6 +6161,16 @@ packages:
|
|||||||
react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
|
||||||
|
react-pdf@10.4.1:
|
||||||
|
resolution: {integrity: sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==}
|
||||||
|
peerDependencies:
|
||||||
|
'@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@types/react':
|
||||||
|
optional: true
|
||||||
|
|
||||||
react-redux@9.2.0:
|
react-redux@9.2.0:
|
||||||
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@@ -6990,6 +7103,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
warning@4.0.3:
|
||||||
|
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
|
||||||
|
|
||||||
wasm-feature-detect@1.8.0:
|
wasm-feature-detect@1.8.0:
|
||||||
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
|
resolution: {integrity: sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==}
|
||||||
|
|
||||||
@@ -7952,6 +8068,54 @@ snapshots:
|
|||||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-android-arm64@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-arm64@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-darwin-x64@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-linux-x64-musl@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc@0.1.100':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@napi-rs/canvas@0.1.100':
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas-android-arm64': 0.1.100
|
||||||
|
'@napi-rs/canvas-darwin-arm64': 0.1.100
|
||||||
|
'@napi-rs/canvas-darwin-x64': 0.1.100
|
||||||
|
'@napi-rs/canvas-linux-arm-gnueabihf': 0.1.100
|
||||||
|
'@napi-rs/canvas-linux-arm64-gnu': 0.1.100
|
||||||
|
'@napi-rs/canvas-linux-arm64-musl': 0.1.100
|
||||||
|
'@napi-rs/canvas-linux-riscv64-gnu': 0.1.100
|
||||||
|
'@napi-rs/canvas-linux-x64-gnu': 0.1.100
|
||||||
|
'@napi-rs/canvas-linux-x64-musl': 0.1.100
|
||||||
|
'@napi-rs/canvas-win32-arm64-msvc': 0.1.100
|
||||||
|
'@napi-rs/canvas-win32-x64-msvc': 0.1.100
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@napi-rs/wasm-runtime@0.2.12':
|
'@napi-rs/wasm-runtime@0.2.12':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/core': 1.10.0
|
'@emnapi/core': 1.10.0
|
||||||
@@ -12199,10 +12363,14 @@ snapshots:
|
|||||||
punycode.js: 2.3.1
|
punycode.js: 2.3.1
|
||||||
tlds: 1.261.0
|
tlds: 1.261.0
|
||||||
|
|
||||||
|
make-cancellable-promise@2.0.0: {}
|
||||||
|
|
||||||
make-dir@4.0.0:
|
make-dir@4.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
semver: 7.8.0
|
semver: 7.8.0
|
||||||
|
|
||||||
|
make-event-props@2.0.0: {}
|
||||||
|
|
||||||
marked@15.0.12: {}
|
marked@15.0.12: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@@ -12216,6 +12384,10 @@ snapshots:
|
|||||||
memory-pager@1.5.0:
|
memory-pager@1.5.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
merge-refs@2.0.0(@types/react@19.2.14):
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|
||||||
merge2@1.4.1: {}
|
merge2@1.4.1: {}
|
||||||
@@ -12597,6 +12769,14 @@ snapshots:
|
|||||||
pako: 1.0.11
|
pako: 1.0.11
|
||||||
tslib: 1.14.1
|
tslib: 1.14.1
|
||||||
|
|
||||||
|
pdfjs-dist@5.4.296:
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 0.1.100
|
||||||
|
|
||||||
|
pdfjs-dist@5.7.284:
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 0.1.100
|
||||||
|
|
||||||
pdfkit@0.18.0:
|
pdfkit@0.18.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@noble/ciphers': 1.3.0
|
'@noble/ciphers': 1.3.0
|
||||||
@@ -12828,6 +13008,21 @@ snapshots:
|
|||||||
react: 19.2.6
|
react: 19.2.6
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
|
||||||
|
react-pdf@10.4.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
|
||||||
|
dependencies:
|
||||||
|
clsx: 2.1.1
|
||||||
|
dequal: 2.0.3
|
||||||
|
make-cancellable-promise: 2.0.0
|
||||||
|
make-event-props: 2.0.0
|
||||||
|
merge-refs: 2.0.0(@types/react@19.2.14)
|
||||||
|
pdfjs-dist: 5.4.296
|
||||||
|
react: 19.2.6
|
||||||
|
react-dom: 19.2.6(react@19.2.6)
|
||||||
|
tiny-invariant: 1.3.3
|
||||||
|
warning: 4.0.3
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/react': 19.2.14
|
||||||
|
|
||||||
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
|
react-redux@9.2.0(@types/react@19.2.14)(react@19.2.6)(redux@5.0.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/use-sync-external-store': 0.0.6
|
'@types/use-sync-external-store': 0.0.6
|
||||||
@@ -13722,7 +13917,9 @@ snapshots:
|
|||||||
|
|
||||||
unload@2.4.1: {}
|
unload@2.4.1: {}
|
||||||
|
|
||||||
unpdf@1.6.2: {}
|
unpdf@1.6.2(@napi-rs/canvas@0.1.100):
|
||||||
|
optionalDependencies:
|
||||||
|
'@napi-rs/canvas': 0.1.100
|
||||||
|
|
||||||
unrs-resolver@1.11.1:
|
unrs-resolver@1.11.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -13884,6 +14081,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
xml-name-validator: 5.0.0
|
xml-name-validator: 5.0.0
|
||||||
|
|
||||||
|
warning@4.0.3:
|
||||||
|
dependencies:
|
||||||
|
loose-envify: 1.4.0
|
||||||
|
|
||||||
wasm-feature-detect@1.8.0: {}
|
wasm-feature-detect@1.8.0: {}
|
||||||
|
|
||||||
watchpack@2.5.1:
|
watchpack@2.5.1:
|
||||||
|
|||||||
@@ -1,11 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
|
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
|
||||||
|
// pdfjs-dist is ~150kb gzip — lazy-load so routes that never preview
|
||||||
|
// PDFs don't ship it. ssr:false because the worker setup needs window.
|
||||||
|
const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m.PdfViewer })), {
|
||||||
|
ssr: false,
|
||||||
|
loading: () => (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Loading PDF viewer…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
interface FilePreviewDialogProps {
|
interface FilePreviewDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -94,7 +106,7 @@ export function FilePreviewDialog({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && previewUrl && isPdf && (
|
{!loading && !error && previewUrl && isPdf && (
|
||||||
<iframe src={previewUrl} title={fileName ?? 'PDF Preview'} className="h-full w-full" />
|
<PdfViewer url={previewUrl} fileName={fileName} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
148
src/components/files/pdf-viewer.tsx
Normal file
148
src/components/files/pdf-viewer.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Document, Page, pdfjs } from 'react-pdf';
|
||||||
|
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({ 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(
|
||||||
|
() => ({
|
||||||
|
// Inline the worker fetch URL above; CMap/StandardFontDataUrl
|
||||||
|
// pull from the same CDN for unicode + non-system fonts.
|
||||||
|
cMapUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/cmaps/`,
|
||||||
|
standardFontDataUrl: `https://unpkg.com/pdfjs-dist@${pdfjs.version}/standard_fonts/`,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Reset on url change so navigation between documents lands on
|
||||||
|
// page 1 at default zoom.
|
||||||
|
setPageNumber(1);
|
||||||
|
setScale(1);
|
||||||
|
setError(null);
|
||||||
|
}, [url]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user