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.
-
- )}
+ {!loading &&
+ !error &&
+ previewUrl &&
+ kind === 'office' &&
+ // Word .docx renders in-browser via docx-preview (fetches the
+ // bytes from our own storage — works with private MinIO/disk).
+ // We do NOT use Microsoft's hosted Office viewer: it requires a
+ // publicly-reachable URL, which our private object store isn't.
+ // Legacy .doc + spreadsheet formats can't be rendered client-
+ // side, so they fall through to a download CTA.
+ (isWordDocx(mimeType, fileName) ? (
+
+ ) : (
+
+
+
In-browser preview isn't available
+
+ This Office format ({mimeType ?? 'unknown'}) can't be rendered in the
+ browser. Download it to view locally.
+
diff --git a/src/lib/constants/file-validation.ts b/src/lib/constants/file-validation.ts
index d3623341..64a952e1 100644
--- a/src/lib/constants/file-validation.ts
+++ b/src/lib/constants/file-validation.ts
@@ -34,8 +34,28 @@ export const PREVIEWABLE_MIMES = new Set([
'image/gif',
'image/webp',
'application/pdf',
+ // Plain text + CSV render via the in-app TextPreview.
+ 'text/plain',
+ 'text/csv',
+ // Office formats: .docx renders client-side via docx-preview; the
+ // legacy/binary + spreadsheet formats fall through to a download CTA in
+ // the preview dialog. They're allow-listed here so the preview endpoint
+ // returns a URL instead of rejecting the file outright (which surfaced
+ // as a misleading "Failed to load preview"). We deliberately do NOT use
+ // Microsoft's hosted Office viewer — it can't reach our private storage.
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // .docx
+ 'application/msword', // .doc
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // .xlsx
+ 'application/vnd.ms-excel', // .xls
]);
+/** True when the file is an OOXML Word document we can render in-browser. */
+export function isWordDocx(mimeType: string | undefined, fileName: string | undefined): boolean {
+ if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document')
+ return true;
+ return (fileName ?? '').toLowerCase().endsWith('.docx');
+}
+
/**
* Magic-byte signatures keyed by claimed MIME type. Used by the file
* upload handler to reject files whose first few bytes don't match the