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:
2026-06-03 15:45:11 +02:00
parent 95724c8e3a
commit 3b227fe9b2
5 changed files with 157 additions and 18 deletions

View 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>
);
}

View File

@@ -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: () => (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Loading document viewer
</div>
),
});
interface FilePreviewDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -185,24 +196,34 @@ export function FilePreviewDialog({
</div>
)}
{!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.
<iframe
title={fileName ?? 'Office document preview'}
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
previewUrl,
)}`}
className="h-full w-full"
sandbox="allow-scripts allow-same-origin allow-popups"
/>
)}
{!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) ? (
<DocxViewer url={previewUrl} fileName={fileName} />
) : (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">
<FileWarning className="size-8 text-muted-foreground" aria-hidden />
<p className="text-sm font-medium">In-browser preview isn&apos;t available</p>
<p className="max-w-xs text-xs text-muted-foreground">
This Office format ({mimeType ?? 'unknown'}) can&apos;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' && (
<div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center">