From d1826ef7be2bae81faa075260d2f1d491f847e84 Mon Sep 17 00:00:00 2001 From: cialloo Date: Mon, 27 Oct 2025 21:41:52 +0800 Subject: [PATCH] feat: add download functionality to ArchiveComponent with presigned URL support --- src/blog/api.ts | 16 ++++ src/blog/nodes/ArchiveComponent.tsx | 109 ++++++++++++++++++++++------ src/blog/types.ts | 5 ++ 3 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src/blog/api.ts b/src/blog/api.ts index ab61706..f2971af 100644 --- a/src/blog/api.ts +++ b/src/blog/api.ts @@ -6,6 +6,7 @@ import { apiRequest, apiPost } from '../utils/api'; import { getS3Url } from './s3Config'; import type { UploadPresignedURLResponse, + DownloadPresignedURLResponse, CreatePostResponse, ListPostsRequest, ListPostsResponse, @@ -94,6 +95,21 @@ export async function uploadBlogFile( return { fileKey, url }; } +/** + * Get presigned URL for downloading a file + */ +export async function getDownloadPresignedURL( + fileKey: string, +): Promise { + const response = await apiPost(`${API_BASE}/file/download`, { fileKey }); + + if (!response.ok) { + throw new Error(`Failed to get download URL: ${response.statusText}`); + } + + return response.json(); +} + /** * Backwards-compatible image upload helper */ diff --git a/src/blog/nodes/ArchiveComponent.tsx b/src/blog/nodes/ArchiveComponent.tsx index c8d9316..b1da61f 100644 --- a/src/blog/nodes/ArchiveComponent.tsx +++ b/src/blog/nodes/ArchiveComponent.tsx @@ -11,9 +11,10 @@ import { KEY_ESCAPE_COMMAND, SELECTION_CHANGE_COMMAND, } from 'lexical'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getS3Url } from '../s3Config'; +import { getDownloadPresignedURL } from '../api'; +import type { DownloadPresignedURLResponse } from '../types'; function formatFileSize(bytes?: number): string | undefined { if (bytes === undefined) { @@ -49,16 +50,25 @@ export default function ArchiveComponent({ const [editor] = useLexicalComposerContext(); const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); const containerRef = useRef(null); - - const resolvedUrl = useMemo(() => { - if (fileKey) { - return getS3Url(fileKey); - } - return undefined; - }, [fileKey]); + const [downloadInfo, setDownloadInfo] = useState(null); + const [isFetchingDownload, setIsFetchingDownload] = useState(false); + const [downloadError, setDownloadError] = useState(undefined); const readableSize = useMemo(() => formatFileSize(fileSize), [fileSize]); const isUploading = uploadProgress !== undefined && uploadProgress < 100 && !uploadError; + const downloadButtonDisabled = !fileKey || isUploading || Boolean(uploadError) || isFetchingDownload; + const downloadButtonLabel = useMemo(() => { + if (!fileKey) { + return 'Preparing link...'; + } + if (uploadError) { + return 'Unavailable'; + } + if (isFetchingDownload) { + return 'Preparing...'; + } + return 'Download'; + }, [fileKey, isFetchingDownload, uploadError]); const handleEscape = useCallback(() => { if (!isSelected) { @@ -111,6 +121,63 @@ export default function ArchiveComponent({ ); }, [editor, handleClick, handleEscape]); + useEffect(() => { + setDownloadInfo(null); + setDownloadError(undefined); + setIsFetchingDownload(false); + }, [fileKey]); + + const triggerDownload = useCallback( + (url: string) => { + const link = document.createElement('a'); + link.href = url; + link.download = fileName; + link.target = '_blank'; + link.rel = 'noopener noreferrer'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, + [fileName], + ); + + const handleDownload = useCallback(async () => { + if (!fileKey || isFetchingDownload) { + return; + } + + try { + setIsFetchingDownload(true); + setDownloadError(undefined); + + const isInfoValid = (info: DownloadPresignedURLResponse | null): boolean => { + if (!info) { + return false; + } + const expireAtMs = info.expireAt > 1_000_000_000_000 ? info.expireAt : info.expireAt * 1000; + return Date.now() < expireAtMs - 5_000; + }; + + let info = downloadInfo; + if (!isInfoValid(info)) { + info = await getDownloadPresignedURL(fileKey); + setDownloadInfo(info); + } + + if (!info) { + throw new Error('Download link unavailable'); + } + + triggerDownload(info.url); + } catch (error) { + setDownloadError( + error instanceof Error ? error.message : 'Unable to prepare download link', + ); + } finally { + setIsFetchingDownload(false); + } + }, [downloadInfo, fileKey, isFetchingDownload, triggerDownload]); + const borderColor = uploadError ? 'rgba(244, 67, 54, 0.6)' : isSelected @@ -136,19 +203,14 @@ export default function ArchiveComponent({
{fileName}
Archive{readableSize ? ` • ${readableSize}` : ''}
- {resolvedUrl ? ( - - Download - - ) : ( - Preparing link... - )} + {isUploading && (
@@ -165,6 +227,9 @@ export default function ArchiveComponent({ {uploadError && (
❌ {uploadError}
)} + {downloadError && ( +
❌ {downloadError}
+ )}
); diff --git a/src/blog/types.ts b/src/blog/types.ts index 7e02f58..912921d 100644 --- a/src/blog/types.ts +++ b/src/blog/types.ts @@ -8,6 +8,11 @@ export interface UploadPresignedURLResponse { expireAt: number; } +export interface DownloadPresignedURLResponse { + url: string; + expireAt: number; +} + export interface CreatePostResponse { postId: string; }