feat: Add document download link component, refactor download URL resolution with expiration checks
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DownloadIcon } from '../../components/icons';
|
||||||
|
import { resolveDocumentDownloadHref } from '../documentActions';
|
||||||
|
import type { Document } from '../../types/documents';
|
||||||
|
|
||||||
|
interface DocumentDownloadLinkProps extends React.AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||||
|
document?: Document | null;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DocumentDownloadLink: React.FC<DocumentDownloadLinkProps> = ({
|
||||||
|
document,
|
||||||
|
children,
|
||||||
|
className = 'icon-button',
|
||||||
|
title = 'Download document',
|
||||||
|
'aria-label': ariaLabel = 'Download document',
|
||||||
|
...rest
|
||||||
|
}) => {
|
||||||
|
const downloadUrl = resolveDocumentDownloadHref(document);
|
||||||
|
|
||||||
|
if (!downloadUrl) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={downloadUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
title={title}
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
{...rest}
|
||||||
|
>
|
||||||
|
{children || <DownloadIcon />}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DocumentDownloadLink;
|
||||||
@@ -8,15 +8,18 @@ import type { Document } from '../types/documents';
|
|||||||
|
|
||||||
const asyncFalse = async () => false;
|
const asyncFalse = async () => false;
|
||||||
|
|
||||||
const resolveDocumentDownloadHref = (document?: Document | null): string | null => {
|
export const resolveDocumentDownloadHref = (document?: Document | null): string | null => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const downloadUrl = (document.current_version as { download?: { url: string } | null } | null)?.download?.url;
|
const download = document.current_version?.download;
|
||||||
if (!downloadUrl) {
|
if (!download?.url) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return downloadUrl;
|
if (download.expires_at && download.expires_at <= Date.now()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return download.url;
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasDocumentTextContentAsset = (document?: Document | null, getDocumentAsset?: GetDocumentAsset | null): boolean => {
|
const hasDocumentTextContentAsset = (document?: Document | null, getDocumentAsset?: GetDocumentAsset | null): boolean => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
@import './base/controls.css';
|
@import './base/controls.css';
|
||||||
@import './layout/panel-header-controls.css';
|
@import './layout/panel-header-controls.css';
|
||||||
@import './components/resize-handle.css';
|
@import './components/resize-handle.css';
|
||||||
@import './viewer/preview-zoom.css';
|
@import '../viewer/preview-zoom.css';
|
||||||
@import './base/iconography.css';
|
@import './base/iconography.css';
|
||||||
@import '../documents/styles/controls.css';
|
@import '../documents/styles/controls.css';
|
||||||
@import './layout/structure.css';
|
@import './layout/structure.css';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef } from 'react';
|
|||||||
import type { JSX } from 'react';
|
import type { JSX } from 'react';
|
||||||
import DocumentInfoPanel from './components/DocumentInfoPanel';
|
import DocumentInfoPanel from './components/DocumentInfoPanel';
|
||||||
import UnifiedDocumentViewer from './UnifiedDocumentViewer';
|
import UnifiedDocumentViewer from './UnifiedDocumentViewer';
|
||||||
|
import { resolveDocumentDownloadHref } from '../documents/documentActions';
|
||||||
|
|
||||||
import type { Document } from '../types/documents';
|
import type { Document } from '../types/documents';
|
||||||
|
|
||||||
@@ -56,13 +57,13 @@ const DocumentViewerLayout = ({
|
|||||||
|
|
||||||
const renderViewportPane = useCallback(() => (
|
const renderViewportPane = useCallback(() => (
|
||||||
<div className="document-viewer__viewport" ref={viewportRef}>
|
<div className="document-viewer__viewport" ref={viewportRef}>
|
||||||
{!document?.current_version?.download?.url ? (
|
{!resolveDocumentDownloadHref(document) ? (
|
||||||
<div className="document-viewer__message">{previewLoadingMessage}</div>
|
<div className="document-viewer__message">{previewLoadingMessage}</div>
|
||||||
) : (
|
) : (
|
||||||
previewContent
|
previewContent
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
), [previewContent, document?.current_version?.download?.url, previewLoadingMessage, viewportRef]);
|
), [previewContent, document, previewLoadingMessage, viewportRef]);
|
||||||
|
|
||||||
const viewportPane = renderViewportPane();
|
const viewportPane = renderViewportPane();
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import React, {
|
import React, {
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
DownloadIcon,
|
|
||||||
CloseIcon,
|
CloseIcon,
|
||||||
IconZoomInArea,
|
IconZoomInArea,
|
||||||
WindowMaximizeIcon,
|
WindowMaximizeIcon,
|
||||||
@@ -15,10 +15,10 @@ import {
|
|||||||
buildCorrespondentOptions,
|
buildCorrespondentOptions,
|
||||||
sortCorrespondents,
|
sortCorrespondents,
|
||||||
} from './components/DocumentSummarySection';
|
} from './components/DocumentSummarySection';
|
||||||
|
import DocumentDownloadLink from '../documents/components/DocumentDownloadLink';
|
||||||
import { usePreviewContext } from './PreviewContext';
|
import { usePreviewContext } from './PreviewContext';
|
||||||
import type { DocumentSummarySectionProps } from './components/DocumentSummarySection';
|
import type { DocumentSummarySectionProps } from './components/DocumentSummarySection';
|
||||||
import { extractDocumentMetadataPayload } from './logic/documentSummary';
|
import { extractDocumentMetadataPayload } from './logic/documentSummary';
|
||||||
import { createDocumentActionState } from '../documents/documentActions';
|
|
||||||
import { resolveDocumentAssetUrl } from '../lib/assets/AssetManager';
|
import { resolveDocumentAssetUrl } from '../lib/assets/AssetManager';
|
||||||
import PanelHeader from '../components/PanelHeader';
|
import PanelHeader from '../components/PanelHeader';
|
||||||
import BreadcrumbTrail from '../components/BreadcrumbTrail';
|
import BreadcrumbTrail from '../components/BreadcrumbTrail';
|
||||||
@@ -48,33 +48,14 @@ interface DocumentViewerPanelProps extends DocumentSummarySectionProps {
|
|||||||
|
|
||||||
const createDocumentViewerHeaderActions = ({
|
const createDocumentViewerHeaderActions = ({
|
||||||
document,
|
document,
|
||||||
actionState,
|
|
||||||
onZoom,
|
|
||||||
canZoom = false,
|
|
||||||
}) => {
|
}) => {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadHref = actionState?.downloadHref;
|
|
||||||
if (!downloadHref && !(canZoom && onZoom)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{downloadHref ? (
|
<DocumentDownloadLink document={document} />
|
||||||
<a
|
|
||||||
className="icon-button"
|
|
||||||
href={downloadHref}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
aria-label="Download document"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</a>
|
|
||||||
) : null}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -93,7 +74,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
ensureAssetUrl,
|
ensureAssetUrl,
|
||||||
getDocumentAsset,
|
getDocumentAsset,
|
||||||
ensurePreviewData,
|
ensurePreviewData,
|
||||||
notifyApiError,
|
|
||||||
sidebarToggle = null,
|
sidebarToggle = null,
|
||||||
onClose,
|
onClose,
|
||||||
resolveFolderPath,
|
resolveFolderPath,
|
||||||
@@ -125,6 +105,18 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
return Boolean(getDocumentAsset(document, 'text-content'));
|
return Boolean(getDocumentAsset(document, 'text-content'));
|
||||||
}, [document, getDocumentAsset]);
|
}, [document, getDocumentAsset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!document?.id || !ensurePreviewData) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const download = document.current_version?.download;
|
||||||
|
if (download?.expires_at && download.expires_at <= Date.now()) {
|
||||||
|
ensurePreviewData(document.id).catch((error) => {
|
||||||
|
console.warn('Failed to refresh expired document', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [document, ensurePreviewData]);
|
||||||
|
|
||||||
const navigateToFolder = useCallback(
|
const navigateToFolder = useCallback(
|
||||||
(folderId: FolderId | null) => {
|
(folderId: FolderId | null) => {
|
||||||
const target = folderId == null
|
const target = folderId == null
|
||||||
@@ -242,27 +234,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
? 'document-viewer document-viewer--stacked'
|
? 'document-viewer document-viewer--stacked'
|
||||||
: 'document-viewer';
|
: 'document-viewer';
|
||||||
|
|
||||||
const actionState = useMemo(
|
|
||||||
() =>
|
|
||||||
document
|
|
||||||
? createDocumentActionState({
|
|
||||||
document,
|
|
||||||
ensurePreviewData,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
notifyApiError,
|
|
||||||
ocrErrorMessage: 'Unable to open text content.',
|
|
||||||
})
|
|
||||||
: null,
|
|
||||||
[
|
|
||||||
document,
|
|
||||||
ensurePreviewData,
|
|
||||||
ensureAssetUrl,
|
|
||||||
getDocumentAsset,
|
|
||||||
notifyApiError,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
const breadcrumbs = useMemo(() => {
|
const breadcrumbs = useMemo(() => {
|
||||||
if (!document || !resolveFolderPath) {
|
if (!document || !resolveFolderPath) {
|
||||||
return [];
|
return [];
|
||||||
@@ -294,9 +265,6 @@ const DocumentViewerPanel: React.FC<DocumentViewerPanelProps> = ({
|
|||||||
|
|
||||||
const headerActions = createDocumentViewerHeaderActions({
|
const headerActions = createDocumentViewerHeaderActions({
|
||||||
document,
|
document,
|
||||||
actionState,
|
|
||||||
onZoom: handleZoomOpen,
|
|
||||||
canZoom: Boolean(document),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const maximizeButton = isSidebarVariant && onMaximize
|
const maximizeButton = isSidebarVariant && onMaximize
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import PdfViewer from './PdfViewer';
|
import PdfViewer from './PdfViewer';
|
||||||
import MediaViewer from './MediaViewer';
|
import MediaViewer from './MediaViewer';
|
||||||
|
import { resolveDocumentDownloadHref } from '../documents/documentActions';
|
||||||
import type { Document } from '../types/documents';
|
import type { Document } from '../types/documents';
|
||||||
|
|
||||||
interface UnifiedDocumentViewerProps {
|
interface UnifiedDocumentViewerProps {
|
||||||
@@ -17,7 +18,7 @@ const UnifiedDocumentViewer: React.FC<UnifiedDocumentViewerProps> = ({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadUrl = document.current_version?.download?.url;
|
const downloadUrl = resolveDocumentDownloadHref(document);
|
||||||
if (!downloadUrl) {
|
if (!downloadUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import UnifiedDocumentViewer from '../UnifiedDocumentViewer';
|
import UnifiedDocumentViewer from '../UnifiedDocumentViewer';
|
||||||
|
import DocumentDownloadLink from '../../documents/components/DocumentDownloadLink';
|
||||||
import PanelHeader from '../../components/PanelHeader';
|
import PanelHeader from '../../components/PanelHeader';
|
||||||
import { IconX, DownloadIcon, FileInfoIcon } from '../../components/icons';
|
import { IconX, FileInfoIcon } from '../../components/icons';
|
||||||
|
|
||||||
import type { Document } from '../../types/documents';
|
import type { Document } from '../../types/documents';
|
||||||
|
|
||||||
@@ -29,7 +30,6 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
|||||||
lastDocumentRef.current = inputDocument;
|
lastDocumentRef.current = inputDocument;
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadUrl = lastDocumentRef.current?.current_version?.download?.url;
|
|
||||||
const documentTitle = lastDocumentRef.current?.title || undefined;
|
const documentTitle = lastDocumentRef.current?.title || undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -132,18 +132,7 @@ const PreviewZoomOverlay: React.FC<PreviewZoomOverlayProps> = ({
|
|||||||
}
|
}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{downloadUrl && (
|
<DocumentDownloadLink document={lastDocumentRef.current} />
|
||||||
<a
|
|
||||||
href={downloadUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="icon-button"
|
|
||||||
aria-label="Download document"
|
|
||||||
title="Download document"
|
|
||||||
>
|
|
||||||
<DownloadIcon />
|
|
||||||
</a>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user