feat: Add document download link component, refactor download URL resolution with expiration checks

This commit is contained in:
2025-12-04 23:38:17 +01:00
parent 22e39420bb
commit 86b9ac2ab6
8 changed files with 72 additions and 70 deletions
@@ -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;
+7 -4
View File
@@ -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 => {
+1 -1
View File
@@ -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';
+3 -2
View File
@@ -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();
+15 -47
View File
@@ -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>
)}
</> </>
} }
/> />