feat(files): in-app .docx preview + allow office/text mimes
- .docx now renders client-side via docx-preview (fetches bytes from our own storage; works with private MinIO/disk). Drops Microsoft's hosted Office viewer which can't reach a private object store. - add office (.docx/.doc/.xlsx/.xls) + text/csv to PREVIEWABLE_MIMES so /api/v1/files/[id]/preview returns a URL instead of rejecting them (was surfacing as a misleading "Failed to load preview") - legacy .doc + spreadsheets fall through to a download CTA (can't render client-side); text/csv use the existing TextPreview Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -80,6 +80,7 @@
|
|||||||
"country-flag-icons": "^1.6.17",
|
"country-flag-icons": "^1.6.17",
|
||||||
"cron-parser": "^5.5.0",
|
"cron-parser": "^5.5.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"docx-preview": "^0.3.7",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"echarts-for-react": "^3.0.6",
|
"echarts-for-react": "^3.0.6",
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -157,6 +157,9 @@ importers:
|
|||||||
date-fns:
|
date-fns:
|
||||||
specifier: ^4.1.0
|
specifier: ^4.1.0
|
||||||
version: 4.1.0
|
version: 4.1.0
|
||||||
|
docx-preview:
|
||||||
|
specifier: ^0.3.7
|
||||||
|
version: 0.3.7
|
||||||
drizzle-orm:
|
drizzle-orm:
|
||||||
specifier: ^0.45.2
|
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)
|
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==}
|
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
docx-preview@0.3.7:
|
||||||
|
resolution: {integrity: sha512-Lav69CTA/IYZPJTsKH7oYeoZjyg96N0wEJMNslGJnZJ+dMUZK85Lt5ASC79yUlD48ecWjuv+rkcmFt6EVPV0Xg==}
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||||
|
|
||||||
@@ -11146,6 +11152,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
esutils: 2.0.3
|
esutils: 2.0.3
|
||||||
|
|
||||||
|
docx-preview@0.3.7:
|
||||||
|
dependencies:
|
||||||
|
jszip: 3.10.1
|
||||||
|
|
||||||
dom-serializer@2.0.0:
|
dom-serializer@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
domelementtype: 2.3.0
|
domelementtype: 2.3.0
|
||||||
|
|||||||
87
src/components/files/docx-viewer.tsx
Normal file
87
src/components/files/docx-viewer.tsx
Normal file
@@ -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 <DocxViewerBody key={url} url={url} fileName={fileName} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function DocxViewerBody({ url, fileName }: { url: string; fileName?: string }) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="relative h-full overflow-auto bg-muted/30 p-4">
|
||||||
|
{loading && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
||||||
|
Rendering document…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="flex h-full items-center justify-center px-6 text-center text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
aria-label={fileName ?? 'Document preview'}
|
||||||
|
className="mx-auto max-w-3xl [&_.docx-wrapper]:bg-transparent [&_.docx-wrapper]:p-0 [&_.docx-wrapper>section.docx]:mx-auto [&_.docx-wrapper>section.docx]:mb-4 [&_.docx-wrapper>section.docx]:bg-white [&_.docx-wrapper>section.docx]:shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { apiFetch } from '@/lib/api/client';
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { isWordDocx } from '@/lib/constants/file-validation';
|
||||||
|
|
||||||
// yet-another-react-lightbox is ~50kb, lazy-load it.
|
// yet-another-react-lightbox is ~50kb, lazy-load it.
|
||||||
const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false });
|
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: () => (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||||
|
Loading document viewer…
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
interface FilePreviewDialogProps {
|
interface FilePreviewDialogProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
@@ -185,24 +196,34 @@ export function FilePreviewDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && !error && previewUrl && kind === 'office' && (
|
{!loading &&
|
||||||
// Office documents render via Microsoft's hosted Office viewer
|
!error &&
|
||||||
// - public URL only; presigned download URLs include a token
|
previewUrl &&
|
||||||
// in the query string so they work here even though the file
|
kind === 'office' &&
|
||||||
// isn't world-public. The viewer streams the document and
|
// Word .docx renders in-browser via docx-preview (fetches the
|
||||||
// renders a high-fidelity preview without us shipping a
|
// bytes from our own storage — works with private MinIO/disk).
|
||||||
// headless LibreOffice. Falls back to "download to view" if
|
// We do NOT use Microsoft's hosted Office viewer: it requires a
|
||||||
// the embed loads but renders nothing (e.g. CORS rejected) -
|
// publicly-reachable URL, which our private object store isn't.
|
||||||
// detection is hard so we just keep the download CTA below.
|
// Legacy .doc + spreadsheet formats can't be rendered client-
|
||||||
<iframe
|
// side, so they fall through to a download CTA.
|
||||||
title={fileName ?? 'Office document preview'}
|
(isWordDocx(mimeType, fileName) ? (
|
||||||
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
|
<DocxViewer url={previewUrl} fileName={fileName} />
|
||||||
previewUrl,
|
) : (
|
||||||
)}`}
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
className="h-full w-full"
|
<FileWarning className="size-8 text-muted-foreground" aria-hidden />
|
||||||
sandbox="allow-scripts allow-same-origin allow-popups"
|
<p className="text-sm font-medium">In-browser preview isn't available</p>
|
||||||
/>
|
<p className="max-w-xs text-xs text-muted-foreground">
|
||||||
)}
|
This Office format ({mimeType ?? 'unknown'}) can't be rendered in the
|
||||||
|
browser. Download it to view locally.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a href={previewUrl} download={fileName ?? 'download'}>
|
||||||
|
<Download className="mr-1.5 size-4" aria-hidden />
|
||||||
|
Download
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
{!loading && !error && previewUrl && kind === 'unknown' && (
|
{!loading && !error && previewUrl && kind === 'unknown' && (
|
||||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
|||||||
@@ -34,8 +34,28 @@ export const PREVIEWABLE_MIMES = new Set<string>([
|
|||||||
'image/gif',
|
'image/gif',
|
||||||
'image/webp',
|
'image/webp',
|
||||||
'application/pdf',
|
'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
|
* 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
|
* upload handler to reject files whose first few bytes don't match the
|
||||||
|
|||||||
Reference in New Issue
Block a user