diff --git a/package.json b/package.json index 8a3b444b..5a2e4cc0 100644 --- a/package.json +++ b/package.json @@ -80,6 +80,7 @@ "country-flag-icons": "^1.6.17", "cron-parser": "^5.5.0", "date-fns": "^4.1.0", + "docx-preview": "^0.3.7", "drizzle-orm": "^0.45.2", "echarts": "^6.0.0", "echarts-for-react": "^3.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dff8ddc0..a2a9e759 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + docx-preview: + specifier: ^0.3.7 + version: 0.3.7 drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(gel@2.2.0)(kysely@0.28.17)(postgres@3.4.9) @@ -4226,6 +4229,9 @@ packages: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} + docx-preview@0.3.7: + resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==} + dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -11146,6 +11152,10 @@ snapshots: dependencies: esutils: 2.0.3 + docx-preview@0.3.7: + dependencies: + jszip: 3.10.1 + dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 diff --git a/src/components/files/docx-viewer.tsx b/src/components/files/docx-viewer.tsx new file mode 100644 index 00000000..05c11ad0 --- /dev/null +++ b/src/components/files/docx-viewer.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Loader2 } from 'lucide-react'; + +/** + * In-app .docx viewer. + * + * Renders Word OOXML (.docx) client-side via `docx-preview` (lazy-loaded + * so the ~library cost only lands on routes that actually preview a docx). + * We fetch the bytes from our own storage URL and render them in-browser — + * deliberately NOT delegating to Microsoft's hosted Office viewer, which + * requires a publicly-reachable URL and so can't render documents stored + * in our private object store. + * + * Legacy .doc / .xls / .xlsx are not handled here (docx-preview is OOXML- + * Word only); the preview dialog routes those to a download CTA instead. + */ +export function DocxViewer({ url, fileName }: { url: string; fileName?: string }) { + // Key-based remount on url change keeps render state (loading/error + + // the imperatively-populated container) re-initialised from scratch, + // mirroring PdfViewer. + return ; +} + +function DocxViewerBody({ url, fileName }: { url: string; fileName?: string }) { + const containerRef = useRef(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + async function render() { + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`Failed to load document (${res.status})`); + const blob = await res.blob(); + if (cancelled) return; + const { renderAsync } = await import('docx-preview'); + const container = containerRef.current; + if (!container) return; + container.innerHTML = ''; + await renderAsync(blob, container, undefined, { + className: 'docx', + inWrapper: true, + // Let the document flow to the container width rather than + // forcing fixed A4 page metrics that overflow the dialog. + ignoreWidth: true, + ignoreHeight: true, + breakPages: true, + }); + if (!cancelled) setError(null); + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to render document'); + } + } finally { + if (!cancelled) setLoading(false); + } + } + void render(); + return () => { + cancelled = true; + }; + }, [url]); + + return ( +
+ {loading && ( +
+ + Rendering document… +
+ )} + {error && !loading && ( +
+ {error} +
+ )} +
+
+ ); +} diff --git a/src/components/files/file-preview-dialog.tsx b/src/components/files/file-preview-dialog.tsx index b9f64816..cb784169 100644 --- a/src/components/files/file-preview-dialog.tsx +++ b/src/components/files/file-preview-dialog.tsx @@ -14,6 +14,7 @@ import { } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; +import { isWordDocx } from '@/lib/constants/file-validation'; // yet-another-react-lightbox is ~50kb, lazy-load it. const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false }); @@ -30,6 +31,16 @@ const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m ), }); +// docx-preview is lazy-loaded the same way — only .docx previews pull it in. +const DocxViewer = dynamic(() => import('./docx-viewer').then((m) => ({ default: m.DocxViewer })), { + ssr: false, + loading: () => ( +
+ Loading document viewer… +
+ ), +}); + interface FilePreviewDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -185,24 +196,34 @@ export function FilePreviewDialog({
)} - {!loading && !error && previewUrl && kind === 'office' && ( - // Office documents render via Microsoft's hosted Office viewer - // - public URL only; presigned download URLs include a token - // in the query string so they work here even though the file - // isn't world-public. The viewer streams the document and - // renders a high-fidelity preview without us shipping a - // headless LibreOffice. Falls back to "download to view" if - // the embed loads but renders nothing (e.g. CORS rejected) - - // detection is hard so we just keep the download CTA below. -