Files
papercrate/frontend/src/preview/DocumentViewerLayout.tsx
T

234 lines
6.4 KiB
TypeScript

import { useCallback, useMemo, useRef } from 'react';
import type { JSX } from 'react';
import DocumentInfoPanel from '../documents/DocumentInfoPanel';
import { DownloadIcon } from '../ui/icons';
import PdfViewer from './PdfViewer';
import { AUDIO_EXTENSIONS, VIDEO_EXTENSIONS } from '../constants/preview';
import type { Document } from '../types/documents';
interface DocumentLink {
url?: string;
mimeType?: string;
filename?: string;
}
interface ContentTabConfig {
id?: string;
label?: string;
enabled?: boolean;
forceDisplay?: boolean;
loadContent?: (options?: { signal?: AbortSignal }) => unknown | Promise<unknown>;
loadingMessage?: string;
emptyMessage?: string;
unavailableMessage?: string;
errorMessage?: string;
[key: string]: unknown;
}
type LayoutMode = 'split' | 'stacked' | (string & {});
interface DocumentViewerLayoutProps {
document?: Document | null;
documentLink?: DocumentLink | null;
summaryProps?: Record<string, unknown>;
metadataPayload?: unknown;
contentTabConfig?: ContentTabConfig | null;
resetKey?: string | null;
classNamePrefix?: string;
defaultTabId?: string;
infoPanelProps?: Record<string, unknown>;
previewLoadingMessage?: string;
layoutMode?: LayoutMode;
}
const getFileExtension = (filename?: string | null) => {
if (!filename) {
return '';
}
const match = filename.toLowerCase().match(/\.([a-z0-9]+)$/);
return match ? match[1] : '';
};
const DocumentViewerLayout = ({
document,
documentLink,
summaryProps = {},
metadataPayload,
contentTabConfig,
resetKey,
classNamePrefix = 'document-viewer',
defaultTabId = 'details',
infoPanelProps = {},
previewLoadingMessage = 'Preparing preview…',
layoutMode = 'split',
}: DocumentViewerLayoutProps): JSX.Element => {
const isStacked = layoutMode === 'stacked';
const viewportRef = useRef<HTMLDivElement>(null);
const previewContent = useMemo(() => {
if (!document || !documentLink?.url) {
return null;
}
const normalizedMimeType = (documentLink.mimeType
|| document.mime_type
|| '')
.toLowerCase();
const normalizedFilename = documentLink.filename
|| document.filename
|| document.original_name
|| '';
const fileExtension = getFileExtension(normalizedFilename);
const isImage = normalizedMimeType.startsWith('image/');
const isPdf = normalizedMimeType === 'application/pdf'
|| normalizedMimeType === 'application/x-pdf';
const isAudio = normalizedMimeType.startsWith('audio/')
|| AUDIO_EXTENSIONS.has(fileExtension);
const isVideo = normalizedMimeType.startsWith('video/')
|| VIDEO_EXTENSIONS.has(fileExtension);
const mediaLabel = document.title
|| normalizedFilename
|| 'Document preview';
if (isImage) {
return (
<img
src={documentLink.url}
alt={`Preview of ${document.title}`}
className="document-viewer__object document-viewer__object--image"
draggable={false}
/>
);
}
if (isPdf) {
const documentTitle = document.title
|| document.filename
|| document.original_name;
return (
<PdfViewer
src={documentLink.url}
title={documentTitle ? `Preview of ${documentTitle}` : undefined}
viewportRef={viewportRef}
/>
);
}
if (isAudio) {
return (
<audio
className="document-viewer__object document-viewer__object--audio"
controls
preload="metadata"
src={documentLink.url}
aria-label={`Audio preview of ${mediaLabel}`}
>
</audio>
);
}
if (isVideo) {
return (
<video
className="document-viewer__object document-viewer__object--video"
controls
preload="metadata"
src={documentLink.url}
aria-label={`Video preview of ${mediaLabel}`}
>
</video>
);
}
const displayMimeType = document.mime_type || documentLink.mimeType || 'this file type';
const displayFilename = documentLink.filename
|| document.filename
|| document.original_name
|| 'download';
return (
<div className="document-viewer__unsupported">
<div className="document-viewer__unsupported-message">
Preview is not available for {displayMimeType} files.
</div>
<div className="document-viewer__unsupported-filename">{displayFilename}</div>
<a
className="button-link document-viewer__unsupported-download"
href={documentLink.url}
download={displayFilename}
target="_blank"
rel="noopener noreferrer"
>
<DownloadIcon />
<span>Download</span>
</a>
</div>
);
}, [document, documentLink, viewportRef]);
const renderViewportPane = useCallback(() => (
<div className="document-viewer__viewport" ref={viewportRef}>
{!documentLink?.url ? (
<div className="document-viewer__message">{previewLoadingMessage}</div>
) : (
previewContent
)}
</div>
), [previewContent, documentLink?.url, previewLoadingMessage, viewportRef]);
const viewportPane = renderViewportPane();
const stackedLeadingTabs = useMemo(() => (
isStacked
? [
{
id: 'preview',
label: 'Preview',
render: () => renderViewportPane(),
},
]
: []
), [isStacked, renderViewportPane]);
const resolvedDefaultTabId = isStacked ? 'preview' : defaultTabId;
const summaryPlacement = 'tabs';
const tabsPlacement = 'bottom';
const summaryLayout = 'compact';
const detailsPane = (
<div className="document-viewer__details-pane">
<div className="document-viewer__details">
<DocumentInfoPanel
document={document}
summaryProps={summaryProps}
metadataPayload={metadataPayload}
contentConfig={contentTabConfig}
defaultTabId={resolvedDefaultTabId}
classNamePrefix={classNamePrefix}
hideTabNavWhenSingle={false}
resetKey={resetKey || document?.id}
summaryPlacement={summaryPlacement}
summaryLayout={summaryLayout}
leadingTabs={stackedLeadingTabs}
tabsPlacement={tabsPlacement}
{...infoPanelProps}
/>
</div>
</div>
);
if (isStacked) {
return detailsPane;
}
return (
<>
{detailsPane}
{viewportPane}
</>
);
};
export default DocumentViewerLayout;