- .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>
88 lines
3.2 KiB
TypeScript
88 lines
3.2 KiB
TypeScript
'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>
|
|
);
|
|
}
|