From 86b9ac2ab6f5ced8437ad0c68066d1436a2e0d3d Mon Sep 17 00:00:00 2001 From: Nils Schneider Date: Thu, 4 Dec 2025 23:38:17 +0100 Subject: [PATCH] feat: Add document download link component, refactor download URL resolution with expiration checks --- .../components/DocumentDownloadLink.tsx | 40 ++++++++++++ frontend/src/documents/documentActions.ts | 11 ++-- frontend/src/styles/index.css | 4 +- frontend/src/viewer/DocumentViewerLayout.tsx | 5 +- frontend/src/viewer/DocumentViewerPanel.tsx | 62 +++++-------------- frontend/src/viewer/UnifiedDocumentViewer.tsx | 3 +- .../viewer/components/PreviewZoomOverlay.tsx | 17 +---- .../preview => viewer}/preview-zoom.css | 0 8 files changed, 72 insertions(+), 70 deletions(-) create mode 100644 frontend/src/documents/components/DocumentDownloadLink.tsx rename frontend/src/{styles/preview => viewer}/preview-zoom.css (100%) diff --git a/frontend/src/documents/components/DocumentDownloadLink.tsx b/frontend/src/documents/components/DocumentDownloadLink.tsx new file mode 100644 index 0000000..048402e --- /dev/null +++ b/frontend/src/documents/components/DocumentDownloadLink.tsx @@ -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 { + document?: Document | null; + children?: React.ReactNode; +} + +const DocumentDownloadLink: React.FC = ({ + document, + children, + className = 'icon-button', + title = 'Download document', + 'aria-label': ariaLabel = 'Download document', + ...rest +}) => { + const downloadUrl = resolveDocumentDownloadHref(document); + + if (!downloadUrl) { + return null; + } + + return ( + + {children || } + + ); +}; + +export default DocumentDownloadLink; diff --git a/frontend/src/documents/documentActions.ts b/frontend/src/documents/documentActions.ts index 863cb6f..d500b06 100644 --- a/frontend/src/documents/documentActions.ts +++ b/frontend/src/documents/documentActions.ts @@ -8,15 +8,18 @@ import type { Document } from '../types/documents'; const asyncFalse = async () => false; -const resolveDocumentDownloadHref = (document?: Document | null): string | null => { +export const resolveDocumentDownloadHref = (document?: Document | null): string | null => { if (!document) { return null; } - const downloadUrl = (document.current_version as { download?: { url: string } | null } | null)?.download?.url; - if (!downloadUrl) { + const download = document.current_version?.download; + if (!download?.url) { 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 => { diff --git a/frontend/src/styles/index.css b/frontend/src/styles/index.css index a955bd6..9f3fefa 100644 --- a/frontend/src/styles/index.css +++ b/frontend/src/styles/index.css @@ -2,7 +2,7 @@ @import './base/controls.css'; @import './layout/panel-header-controls.css'; @import './components/resize-handle.css'; -@import './viewer/preview-zoom.css'; +@import '../viewer/preview-zoom.css'; @import './base/iconography.css'; @import '../documents/styles/controls.css'; @import './layout/structure.css'; @@ -21,4 +21,4 @@ @import '../login/login.css'; @import '../documents/styles/shared-snippets.css'; @import './uploads/overlay.css'; -@import './base/text-button.css'; \ No newline at end of file +@import './base/text-button.css'; diff --git a/frontend/src/viewer/DocumentViewerLayout.tsx b/frontend/src/viewer/DocumentViewerLayout.tsx index 458a63f..807650f 100644 --- a/frontend/src/viewer/DocumentViewerLayout.tsx +++ b/frontend/src/viewer/DocumentViewerLayout.tsx @@ -2,6 +2,7 @@ import { useCallback, useMemo, useRef } from 'react'; import type { JSX } from 'react'; import DocumentInfoPanel from './components/DocumentInfoPanel'; import UnifiedDocumentViewer from './UnifiedDocumentViewer'; +import { resolveDocumentDownloadHref } from '../documents/documentActions'; import type { Document } from '../types/documents'; @@ -56,13 +57,13 @@ const DocumentViewerLayout = ({ const renderViewportPane = useCallback(() => (
- {!document?.current_version?.download?.url ? ( + {!resolveDocumentDownloadHref(document) ? (
{previewLoadingMessage}
) : ( previewContent )}
- ), [previewContent, document?.current_version?.download?.url, previewLoadingMessage, viewportRef]); + ), [previewContent, document, previewLoadingMessage, viewportRef]); const viewportPane = renderViewportPane(); diff --git a/frontend/src/viewer/DocumentViewerPanel.tsx b/frontend/src/viewer/DocumentViewerPanel.tsx index 2ac846a..e4c0ac9 100644 --- a/frontend/src/viewer/DocumentViewerPanel.tsx +++ b/frontend/src/viewer/DocumentViewerPanel.tsx @@ -1,12 +1,12 @@ import React, { useCallback, + useEffect, useMemo, useRef, } from 'react'; import type { ReactNode } from 'react'; import { useNavigate } from 'react-router-dom'; import { - DownloadIcon, CloseIcon, IconZoomInArea, WindowMaximizeIcon, @@ -15,10 +15,10 @@ import { buildCorrespondentOptions, sortCorrespondents, } from './components/DocumentSummarySection'; +import DocumentDownloadLink from '../documents/components/DocumentDownloadLink'; import { usePreviewContext } from './PreviewContext'; import type { DocumentSummarySectionProps } from './components/DocumentSummarySection'; import { extractDocumentMetadataPayload } from './logic/documentSummary'; -import { createDocumentActionState } from '../documents/documentActions'; import { resolveDocumentAssetUrl } from '../lib/assets/AssetManager'; import PanelHeader from '../components/PanelHeader'; import BreadcrumbTrail from '../components/BreadcrumbTrail'; @@ -48,33 +48,14 @@ interface DocumentViewerPanelProps extends DocumentSummarySectionProps { const createDocumentViewerHeaderActions = ({ document, - actionState, - onZoom, - canZoom = false, }) => { if (!document) { return null; } - const downloadHref = actionState?.downloadHref; - if (!downloadHref && !(canZoom && onZoom)) { - return null; - } - return ( <> - {downloadHref ? ( - - - - ) : null} + ); }; @@ -93,7 +74,6 @@ const DocumentViewerPanel: React.FC = ({ ensureAssetUrl, getDocumentAsset, ensurePreviewData, - notifyApiError, sidebarToggle = null, onClose, resolveFolderPath, @@ -125,6 +105,18 @@ const DocumentViewerPanel: React.FC = ({ return Boolean(getDocumentAsset(document, 'text-content')); }, [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( (folderId: FolderId | null) => { const target = folderId == null @@ -242,27 +234,6 @@ const DocumentViewerPanel: React.FC = ({ ? 'document-viewer document-viewer--stacked' : '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(() => { if (!document || !resolveFolderPath) { return []; @@ -294,9 +265,6 @@ const DocumentViewerPanel: React.FC = ({ const headerActions = createDocumentViewerHeaderActions({ document, - actionState, - onZoom: handleZoomOpen, - canZoom: Boolean(document), }); const maximizeButton = isSidebarVariant && onMaximize diff --git a/frontend/src/viewer/UnifiedDocumentViewer.tsx b/frontend/src/viewer/UnifiedDocumentViewer.tsx index 6c67df7..f630e4a 100644 --- a/frontend/src/viewer/UnifiedDocumentViewer.tsx +++ b/frontend/src/viewer/UnifiedDocumentViewer.tsx @@ -1,6 +1,7 @@ import React, { useMemo } from 'react'; import PdfViewer from './PdfViewer'; import MediaViewer from './MediaViewer'; +import { resolveDocumentDownloadHref } from '../documents/documentActions'; import type { Document } from '../types/documents'; interface UnifiedDocumentViewerProps { @@ -17,7 +18,7 @@ const UnifiedDocumentViewer: React.FC = ({ return null; } - const downloadUrl = document.current_version?.download?.url; + const downloadUrl = resolveDocumentDownloadHref(document); if (!downloadUrl) { return null; } diff --git a/frontend/src/viewer/components/PreviewZoomOverlay.tsx b/frontend/src/viewer/components/PreviewZoomOverlay.tsx index 9f29876..334bfba 100644 --- a/frontend/src/viewer/components/PreviewZoomOverlay.tsx +++ b/frontend/src/viewer/components/PreviewZoomOverlay.tsx @@ -1,8 +1,9 @@ import React, { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import UnifiedDocumentViewer from '../UnifiedDocumentViewer'; +import DocumentDownloadLink from '../../documents/components/DocumentDownloadLink'; import PanelHeader from '../../components/PanelHeader'; -import { IconX, DownloadIcon, FileInfoIcon } from '../../components/icons'; +import { IconX, FileInfoIcon } from '../../components/icons'; import type { Document } from '../../types/documents'; @@ -29,7 +30,6 @@ const PreviewZoomOverlay: React.FC = ({ lastDocumentRef.current = inputDocument; } - const downloadUrl = lastDocumentRef.current?.current_version?.download?.url; const documentTitle = lastDocumentRef.current?.title || undefined; useEffect(() => { @@ -132,18 +132,7 @@ const PreviewZoomOverlay: React.FC = ({ } actions={ <> - {downloadUrl && ( - - - - )} + } /> diff --git a/frontend/src/styles/preview/preview-zoom.css b/frontend/src/viewer/preview-zoom.css similarity index 100% rename from frontend/src/styles/preview/preview-zoom.css rename to frontend/src/viewer/preview-zoom.css