Feature: attachment preview - reliable MIME + inline PDF on desktop and mobile

- MIME: Stalwart's download endpoint often returns application/octet-stream, so
  blob: previews silently downloaded (UUID filename) instead of rendering.
  Resolve the most specific MIME (attachment type -> filename ext -> blob type)
  and re-wrap the blob; also fixes inline preview for images and video.
- Desktop PDF: render via <iframe> (reliable for blob: PDFs) instead of <object>.
- Mobile PDF: no usable inline viewer (Android shows a blank frame / silent
  download; iOS Safari renders only the first page of a PDF in an <iframe>), so
  render with pdf.js (canvas, dynamic-imported so it stays off the desktop
  bundle; iOS-safe canvas cap). Double-tap zoom (fit -> 2x -> 3x -> fit) and
  2-finger pinch zoom (to 4x), both centred on the gesture and pannable via
  native scrolling.
- Route to pdf.js when navigator.pdfViewerEnabled is false (Android) and on iOS
  (incl. iPadOS, which reports true yet shows only the first page in a frame).
- Modal header gains an open-in-new-tab icon (next to download/close); the
  Android/browser Back button closes the preview instead of navigating the page.
- On a pdf.js render failure, offer an open-in-new-tab action as fallback.
This commit is contained in:
dealerweb
2026-05-31 15:58:53 +02:00
committed by Linus Rath
parent ae66f8d89d
commit 0352312f25
21 changed files with 652 additions and 13 deletions
+133 -13
View File
@@ -1,10 +1,47 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useTranslations } from "next-intl";
import { X, Download, Loader2 } from "lucide-react";
import { X, Download, Loader2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { getFilePreviewKind } from "@/lib/file-preview";
import dynamic from "next/dynamic";
// pdf.js-based inline viewer for mobile (no native inline PDF viewer). Loaded
// only on the mobile PDF path so pdfjs-dist + its worker never reach the
// desktop bundle.
const PdfMobileViewer = dynamic(
() => import("@/components/files/pdf-mobile-viewer").then((m) => m.PdfMobileViewer),
{ ssr: false },
);
// Map a few well-known extensions back to canonical MIME types. Used when the
// server returns application/octet-stream (or empty) for an attachment whose
// actual type is obvious from the filename. The blob.type drives how browsers
// render blob: URLs, so guessing wrong here means the inline preview silently
// downgrades to a download.
const EXT_TO_MIME: Record<string, string> = {
pdf: "application/pdf",
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
gif: "image/gif",
webp: "image/webp",
avif: "image/avif",
bmp: "image/bmp",
mp3: "audio/mpeg",
wav: "audio/wav",
ogg: "audio/ogg",
m4a: "audio/mp4",
mp4: "video/mp4",
webm: "video/webm",
ogv: "video/ogg",
};
function inferMimeFromName(name: string): string | undefined {
const ext = name.toLowerCase().split(".").pop();
return ext ? EXT_TO_MIME[ext] : undefined;
}
interface FilePreviewModalProps {
name: string;
@@ -111,6 +148,49 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [resolvedFileType, setResolvedFileType] = useState(() => getFilePreviewKind(name));
const [pdfInlineSupported, setPdfInlineSupported] = useState(true);
// Decide whether to render the PDF in a plain <iframe> (desktop) or with the
// pdf.js canvas viewer (mobile). navigator.pdfViewerEnabled is the standard
// signal and correctly reports false on Android Chrome (no inline viewer).
// iOS Safari is the exception: it reports true (it can show PDFs on top-frame
// navigation) yet renders only the FIRST page inside an <iframe> - a
// long-standing WebKit limitation - so it must use pdf.js too. Detect iOS
// (incl. iPadOS, which spoofs a "Macintosh" UA but exposes touch points).
useEffect(() => {
const nav = navigator as Navigator & { pdfViewerEnabled?: boolean };
const isIOS =
/iPad|iPhone|iPod/.test(nav.userAgent) ||
(nav.maxTouchPoints > 1 && /Macintosh/.test(nav.userAgent));
if (isIOS) {
setPdfInlineSupported(false);
} else if (typeof nav.pdfViewerEnabled === "boolean") {
setPdfInlineSupported(nav.pdfViewerEnabled);
}
}, []);
// Keep the latest onClose for the back-button handler without re-subscribing.
const onCloseRef = useRef(onClose);
onCloseRef.current = onClose;
// Make the Android/browser Back button close the preview instead of
// navigating the page underneath: push a throwaway history entry when the
// modal opens and close it on popstate. On a normal close (X / backdrop /
// Escape) the modal unmounts and we pop that entry ourselves, so the user's
// next Back isn't swallowed by it.
useEffect(() => {
window.history.pushState({ __filePreview: true }, "");
let poppedByBack = false;
const onPop = () => {
poppedByBack = true;
onCloseRef.current();
};
window.addEventListener("popstate", onPop);
return () => {
window.removeEventListener("popstate", onPop);
if (!poppedByBack) window.history.back();
};
}, []);
const fileType = resolvedFileType;
@@ -137,7 +217,25 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
const text = await blob.text();
if (!cancelled) setContent(text);
} else {
revokeUrl = URL.createObjectURL(blob);
// Stalwart's download endpoint can return generic
// application/octet-stream for attachments even when the email's
// MIME structure declared application/pdf (etc.). The blob inherits
// that, so a blob: URL plugged into <iframe> looks like a binary
// stream and Chrome/Edge silently download it (with the blob UUID
// as filename) instead of rendering inline. Re-wrap with the most
// specific MIME we can resolve - prefer explicit attachment type,
// then filename-derived MIME, then whatever the blob came with.
const inferredFromName = inferMimeFromName(name);
const isUseless = (ty?: string) =>
!ty || ty === "application/octet-stream" || ty === "binary/octet-stream";
const effectiveType =
(contentType && !isUseless(contentType) ? contentType : undefined)
?? inferredFromName
?? blob.type;
const typedBlob = blob.type !== effectiveType
? new Blob([blob], { type: effectiveType })
: blob;
revokeUrl = URL.createObjectURL(typedBlob);
if (!cancelled) setObjectUrl(revokeUrl);
}
} catch {
@@ -173,6 +271,18 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
<div className="flex items-center justify-between px-4 py-3 bg-background/90 backdrop-blur border-b border-border" onClick={(e) => e.stopPropagation()}>
<h3 className="text-sm font-medium truncate">{name}</h3>
<div className="flex items-center gap-2">
{objectUrl && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
title={t("open_in_new_tab")}
aria-label={t("open_in_new_tab")}
onClick={() => window.open(objectUrl, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => void onDownload()}>
<Download className="w-4 h-4" />
</Button>
@@ -231,19 +341,29 @@ export function FilePreviewModal({ name, onClose, onDownload, getFileContent }:
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && (
<object
data={objectUrl}
type="application/pdf"
{!loading && !error && fileType === "pdf" && objectUrl && pdfInlineSupported && (
// <iframe> renders PDFs reliably across desktop Chromium, Firefox,
// and Safari from a blob: URL. <object> was prone to falling back to
// a silent download when the blob's Content-Type wasn't recognised.
<iframe
src={objectUrl}
className="w-full max-w-5xl h-full rounded-lg bg-white"
aria-label={name}
title={name}
onClick={(e) => e.stopPropagation()}
/>
)}
{!loading && !error && fileType === "pdf" && objectUrl && !pdfInlineSupported && (
// Mobile browsers can't show a PDF inline in an <iframe> (Android: a
// blank frame / silent download; iOS: only the first page), so render
// it with pdf.js (canvas) instead. The header's open-in-new-tab /
// download actions are the fallback if pdf.js can't render the doc.
<div
className="w-full max-w-3xl h-full overflow-auto rounded-lg bg-neutral-200 dark:bg-neutral-800 p-2"
onClick={(e) => e.stopPropagation()}
>
<Button onClick={() => void onDownload()}>
<Download className="w-4 h-4 mr-2" />
{t("download")}
</Button>
</object>
<PdfMobileViewer url={objectUrl} />
</div>
)}
{!loading && !error && fileType === "audio" && objectUrl && (
+223
View File
@@ -0,0 +1,223 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { Loader2, ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
// Real inline PDF preview for mobile browsers. Android Chrome / iOS WebKit have
// no native inline PDF viewer, so the desktop <iframe src=blob:> approach
// renders a blank frame. pdf.js rasterises each page to a <canvas> in pure JS,
// so it works everywhere regardless of native PDF support.
//
// This component is dynamic-imported (ssr:false) only on the mobile PDF path,
// and it dynamic-imports pdfjs-dist itself, so the (~hundreds of KB) library +
// worker never reach the desktop bundle.
// iOS WebKit caps total canvas area (~16 MP) and is memory-sensitive; keep each
// page's backing store well under that so large pages don't render blank. The
// backing store is rendered at ~2x the fit width (dpr), which also gives the
// double-tap zoom some headroom before CSS upscaling softens the text.
const MAX_CANVAS_AREA = 4_000_000; // ~4 MP per page
// Double-tap zoom steps: fit -> 2x -> 3x -> fit. Implemented as the page
// container's CSS width (so panning is native scrolling, not a transform).
const ZOOM_STEPS = [1, 2, 3];
const MAX_ZOOM = 4; // pinch can go a bit beyond the double-tap steps
const DOUBLE_TAP_MS = 300;
const DOUBLE_TAP_SLOP = 30; // px
export function PdfMobileViewer({ url }: { url: string }) {
const rootRef = useRef<HTMLDivElement>(null);
const pagesRef = useRef<HTMLDivElement>(null);
const zoom = useRef({ step: 0, scale: 1 });
const [status, setStatus] = useState<"loading" | "ready" | "error">("loading");
const t = useTranslations("files");
// Render the PDF pages to canvases.
useEffect(() => {
let cancelled = false;
let loadingTask: import("pdfjs-dist").PDFDocumentLoadingTask | null = null;
// Reset zoom for a freshly loaded document.
zoom.current = { step: 0, scale: 1 };
if (pagesRef.current) pagesRef.current.style.width = "100%";
(async () => {
try {
const pdfjs = await import("pdfjs-dist");
pdfjs.GlobalWorkerOptions.workerSrc = new URL(
"pdfjs-dist/build/pdf.worker.min.mjs",
import.meta.url,
).toString();
loadingTask = pdfjs.getDocument({ url });
const doc = await loadingTask.promise;
if (cancelled) return;
const pages = pagesRef.current;
if (!pages) return;
pages.replaceChildren();
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const cssWidth = Math.min(pages.clientWidth || 320, 900);
for (let n = 1; n <= doc.numPages; n++) {
if (cancelled) return;
const page = await doc.getPage(n);
const baseVp = page.getViewport({ scale: 1 });
let scale = (cssWidth / baseVp.width) * dpr;
const area = baseVp.width * scale * (baseVp.height * scale);
if (area > MAX_CANVAS_AREA) scale *= Math.sqrt(MAX_CANVAS_AREA / area);
const viewport = page.getViewport({ scale });
const canvas = document.createElement("canvas");
canvas.width = Math.floor(viewport.width);
canvas.height = Math.floor(viewport.height);
canvas.style.width = "100%";
canvas.style.height = "auto";
canvas.style.display = "block";
canvas.style.margin = "0 auto 8px";
canvas.style.background = "#fff";
pages.appendChild(canvas);
await page.render({ canvas, viewport }).promise;
}
if (!cancelled) setStatus("ready");
} catch {
if (!cancelled) setStatus("error");
}
})();
return () => {
cancelled = true;
void loadingTask?.destroy().catch(() => {});
};
}, [url]);
// Gesture zoom. 1-finger double-tap cycles the steps (fit -> 2x -> 3x ->
// fit); 2-finger pinch zooms continuously up to MAX_ZOOM. Both drive a
// per-page CSS-width zoom that pans via native scrolling and re-centre on the
// gesture point. touch-action (pan-x pan-y) keeps 1-finger panning native
// while disabling the browser's own pinch/double-tap page zoom, which we
// replace here.
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const distance = (a: Touch, b: Touch) =>
Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);
// Apply an absolute zoom scale, keeping (cx, cy) [relative to root] under
// the same content point.
const applyZoom = (target: number, cx: number, cy: number) => {
const pages = pagesRef.current;
if (!pages) return;
const next = Math.max(1, Math.min(MAX_ZOOM, target));
const ratio = next / zoom.current.scale;
if (ratio === 1) return;
pages.style.width = `${next * 100}%`;
void root.offsetWidth; // force reflow so the new scroll range is live
root.scrollLeft = (root.scrollLeft + cx) * ratio - cx;
root.scrollTop = (root.scrollTop + cy) * ratio - cy;
zoom.current.scale = next;
};
let lastTap = 0;
let lastX = 0;
let lastY = 0;
let pinching = false;
let pinchStartDist = 0;
let pinchStartScale = 1;
let didPinch = false;
const onStart = (e: TouchEvent) => {
if (e.touches.length === 2) {
pinching = true;
didPinch = true;
pinchStartDist = distance(e.touches[0], e.touches[1]) || 1;
pinchStartScale = zoom.current.scale;
}
};
const onMove = (e: TouchEvent) => {
if (!pinching || e.touches.length !== 2) return;
e.preventDefault(); // take over the 2-finger gesture from native pan
const rect = root.getBoundingClientRect();
const cx = (e.touches[0].clientX + e.touches[1].clientX) / 2 - rect.left;
const cy = (e.touches[0].clientY + e.touches[1].clientY) / 2 - rect.top;
applyZoom(pinchStartScale * (distance(e.touches[0], e.touches[1]) / pinchStartDist), cx, cy);
};
const onEnd = (e: TouchEvent) => {
if (pinching && e.touches.length < 2) {
pinching = false;
// Snap the step index to the current scale so double-tap stays sensible.
const s = zoom.current.scale;
zoom.current.step = s <= 1.01 ? 0 : s < 3 ? 1 : 2;
}
if (e.touches.length > 0) return; // fingers still down
if (didPinch) {
didPinch = false; // a pinch is not a tap
return;
}
if (e.changedTouches.length !== 1) return;
const touch = e.changedTouches[0];
const now = e.timeStamp;
const isDouble =
now - lastTap < DOUBLE_TAP_MS &&
Math.abs(touch.clientX - lastX) < DOUBLE_TAP_SLOP &&
Math.abs(touch.clientY - lastY) < DOUBLE_TAP_SLOP;
if (!isDouble) {
lastTap = now;
lastX = touch.clientX;
lastY = touch.clientY;
return;
}
lastTap = 0; // consume so a third tap starts fresh
e.preventDefault();
const rect = root.getBoundingClientRect();
zoom.current.step = (zoom.current.step + 1) % ZOOM_STEPS.length;
applyZoom(ZOOM_STEPS[zoom.current.step], touch.clientX - rect.left, touch.clientY - rect.top);
};
root.addEventListener("touchstart", onStart, { passive: true });
root.addEventListener("touchmove", onMove, { passive: false });
root.addEventListener("touchend", onEnd, { passive: false });
return () => {
root.removeEventListener("touchstart", onStart);
root.removeEventListener("touchmove", onMove);
root.removeEventListener("touchend", onEnd);
};
}, []);
return (
<div
ref={rootRef}
className="w-full h-full overflow-auto"
// Allow panning, but disable the browser's pinch/double-tap page zoom so
// our own double-tap zoom drives the PDF instead of the whole modal.
style={{ touchAction: "pan-x pan-y" }}
>
{status === "loading" && (
<div className="flex justify-center py-10">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
)}
{status === "error" && (
// pdf.js couldn't render this document (corrupt/encrypted, worker load
// failure, ...). Offer the OS/native viewer instead of a stuck frame.
<div className="flex justify-center py-10">
<Button
variant="outline"
size="sm"
onClick={() => window.open(url, "_blank", "noopener,noreferrer")}
>
<ExternalLink className="w-4 h-4 mr-2" />
{t("open_in_new_tab")}
</Button>
</div>
)}
<div ref={pagesRef} className="w-full" />
</div>
);
}
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Klikněte na libovolnou zprávu vlevo pro začátek, nebo využijte průvodce níže."
},
"files": {
"open_in_new_tab": "Otevřít na nové kartě",
"title": "Soubory",
"search_placeholder": "Hledat soubory...",
"empty_state_title": "Žádné soubory",
+1
View File
@@ -2837,6 +2837,7 @@
"hint": "Klik på en e-mail til venstre for at komme i gang, eller tag rundvisningen nedenfor."
},
"files": {
"open_in_new_tab": "Åbn i ny fane",
"title": "Filer",
"search_placeholder": "Søg efter filer...",
"empty_state_title": "Ingen filer endnu",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Klicken Sie links auf eine E-Mail, um loszulegen, oder starten Sie die Tour."
},
"files": {
"open_in_new_tab": "In neuem Tab öffnen",
"title": "Dateien",
"search_placeholder": "Dateien suchen...",
"empty_state_title": "Noch keine Dateien",
+1
View File
@@ -2837,6 +2837,7 @@
"hint": "Click any email on the left to get started, or take the tour below."
},
"files": {
"open_in_new_tab": "Open in new tab",
"title": "Files",
"search_placeholder": "Search files...",
"empty_state_title": "No files yet",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Haz clic en un correo a la izquierda para empezar, o inicia el tour."
},
"files": {
"open_in_new_tab": "Abrir en una pestaña nueva",
"title": "Archivos",
"search_placeholder": "Buscar archivos...",
"empty_state_title": "Aún no hay archivos",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Cliquez sur un e-mail à gauche pour commencer, ou lancez la visite."
},
"files": {
"open_in_new_tab": "Ouvrir dans un nouvel onglet",
"title": "Fichiers",
"search_placeholder": "Rechercher des fichiers...",
"empty_state_title": "Aucun fichier pour le moment",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Clicca su un'email a sinistra per iniziare, oppure fai il tour."
},
"files": {
"open_in_new_tab": "Apri in una nuova scheda",
"title": "File",
"search_placeholder": "Cerca file...",
"empty_state_title": "Nessun file ancora",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "左のメールをクリックして始めるか、ツアーを開始してください。"
},
"files": {
"open_in_new_tab": "新しいタブで開く",
"title": "ファイル",
"search_placeholder": "ファイルを検索...",
"empty_state_title": "ファイルがありません",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "왼쪽에서 메일을 클릭해서 시작하거나, 아래의 둘러보기를 클릭해 보세요."
},
"files": {
"open_in_new_tab": "새 탭에서 열기",
"title": "파일",
"search_placeholder": "파일 검색...",
"empty_state_title": "아직 파일이 없어요",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Noklikšķiniet uz jebkuru vēstuli kreisajā pusē, lai sāktu."
},
"files": {
"open_in_new_tab": "Atvērt jaunā cilnē",
"title": "Faili",
"search_placeholder": "Meklēt failus...",
"empty_state_title": "Failu vēl nav",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Klik links op een e-mail om te beginnen, of start de tour."
},
"files": {
"open_in_new_tab": "Openen in nieuw tabblad",
"title": "Bestanden",
"search_placeholder": "Bestanden zoeken...",
"empty_state_title": "Nog geen bestanden",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Kliknij dowolną wiadomość po lewej, aby zacząć, albo skorzystaj z przewodnika poniżej."
},
"files": {
"open_in_new_tab": "Otwórz w nowej karcie",
"title": "Pliki",
"search_placeholder": "Szukaj plików...",
"empty_state_title": "Brak plików",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Clique num e-mail à esquerda para começar, ou inicie o tour."
},
"files": {
"open_in_new_tab": "Abrir num novo separador",
"title": "Arquivos",
"search_placeholder": "Pesquisar arquivos...",
"empty_state_title": "Ainda não há arquivos",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Нажмите на любое письмо слева, чтобы начать, или пройдите тур ниже."
},
"files": {
"open_in_new_tab": "Открыть в новой вкладке",
"title": "Файлы",
"search_placeholder": "Поиск файлов...",
"empty_state_title": "Файлов пока нет",
+1
View File
@@ -2837,6 +2837,7 @@
"hint": "Başlamak için soldaki herhangi bir e-postaya tıklayın veya aşağıdaki turu yapın."
},
"files": {
"open_in_new_tab": "Yeni sekmede aç",
"title": "Dosyalar",
"search_placeholder": "Dosyalarda ara...",
"empty_state_title": "Henüz dosya yok",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "Натисніть будь-яку електронну пошту ліворуч, щоб розпочати, або перегляньте огляд нижче."
},
"files": {
"open_in_new_tab": "Відкрити в новій вкладці",
"title": "Файли",
"search_placeholder": "Пошук файлів...",
"empty_state_title": "Файлів ще немає",
+1
View File
@@ -2814,6 +2814,7 @@
"hint": "点击左侧的任意邮件即可开始,或参加下方的导览。"
},
"files": {
"open_in_new_tab": "在新标签页中打开",
"title": "文件",
"search_placeholder": "搜索文件...",
"empty_state_title": "还没有文件",
+278
View File
@@ -33,6 +33,7 @@
"next": "^16.2.6",
"next-intl": "^4.9.1",
"otpauth": "^9.5.0",
"pdfjs-dist": "^6.0.227",
"pkijs": "^3.4.0",
"postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6",
@@ -1890,6 +1891,271 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.0.tgz",
"integrity": "sha512-Jqxcy1XOIqj+lH9sl1GT+il6GR3uQv13vI2mrwubP3uT8Olak2ClDrK2RnxlQKjwv8BRr4b3ug0YR7c6hBX8wg==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.0",
"@napi-rs/canvas-darwin-arm64": "1.0.0",
"@napi-rs/canvas-darwin-x64": "1.0.0",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.0",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.0",
"@napi-rs/canvas-linux-arm64-musl": "1.0.0",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-gnu": "1.0.0",
"@napi-rs/canvas-linux-x64-musl": "1.0.0",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.0",
"@napi-rs/canvas-win32-x64-msvc": "1.0.0"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.0.tgz",
"integrity": "sha512-3hNKJObUK7JsCF9aJlVCs1J0/KE/gGfZNeK8MO1ge6bB3aicr5walGme9t9No1f/oyk9GgvdAT/rjSdsx3gbIw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-ZIja19/BiGz2puhki+WUYSRriwFeFJ8Mi9eK3hZdSS85w4Y60cuEAJVhMCfKwswQkKkUtrnzdKMBuO7TupvexA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.0.tgz",
"integrity": "sha512-hImggWc82jqZVpEsFR9S7PE9OQYjq/H/D7vwCGB6X1jRH+UVBP1+1niJTPBOat1B154T6GKK7/kcFtoWgjgFzQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-hlJRy6d+kWLKVOG/+1rEvNQVURZ0DxxRPJsLmEWwhwiXZUJc0BF5o9esALHSEP4CoJK4wChRtj3hnyBgVx2oWA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-5Hru4T3RXkosRQafcjelv7AUzw9mXqmGYsxnzeDDOWveFCJyEPMSJltvGCM+jfH98seOCbfwm9KyFg6Jm5FhAA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-LTUl9jS8WsLSUGaxQZKQkxfluOJRpgvBuxxdM4pYcjib+di8AU4OzQc6+L6SzGMLcKc9H0RAjojRatBhTMqYdg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.0.tgz",
"integrity": "sha512-Iz931SAZf+WVDzpjk52Q3ffW3zw0YflFwEZMgs036Wfu1kX/LrwT9wGjsuSqyduqefUkl91/vTdAjn8hQu5ezA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-pFEQ5eFK4JusgN1K6KkO9DKP/Hi1WMJOkF8Ch03/khTc4bFbCKkCCsJG4YcOMOW9bI4XbT2/eMAWxhO0xaWgPA==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-jnvr8NrLHiZ3NCiOKWqDbkI4Ah+QDrqtZ+sddPZBltEb1mQ2coSvCSJYfict+oAwcm0c970oTmVySpjKP/lnaA==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-y2j9/Gfd5joqiqxdP/L1smqjQ+uAx3C4N0EC7bDHrnZEEH8ToM/OC5p3uHvtj4Lq591aHj+ArL01UDLNwT5HgQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-qwdhh9N6Gge/hC4pL9S1tQp0iKwhSl/dYjg7+RGp9k26iRGRi5MqqUyKGOXIWli0zOcuy5Y2wIH/jk2ry6i/jA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
@@ -8003,6 +8269,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/pdfjs-dist": {
"version": "6.0.227",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.0.227.tgz",
"integrity": "sha512-/P6M4SXw+70waMVLUM7rdRtvo+dEzqE1t6W/zQNvBETo2MaRa5rrvCcAYdfWGiUzadTgM0lJmRApUrW0d9zgKg==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -56,6 +56,7 @@
"next": "^16.2.6",
"next-intl": "^4.9.1",
"otpauth": "^9.5.0",
"pdfjs-dist": "^6.0.227",
"pkijs": "^3.4.0",
"postal-mime": "^2.7.4",
"pvtsutils": "^1.3.6",