feat: add download functionality to ArchiveComponent with presigned URL support
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 29s

This commit is contained in:
2025-10-27 21:41:52 +08:00
parent b4b36ae082
commit d1826ef7be
3 changed files with 108 additions and 22 deletions

View File

@@ -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<DownloadPresignedURLResponse> {
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
*/

View File

@@ -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<HTMLDivElement | null>(null);
const resolvedUrl = useMemo(() => {
if (fileKey) {
return getS3Url(fileKey);
}
return undefined;
}, [fileKey]);
const [downloadInfo, setDownloadInfo] = useState<DownloadPresignedURLResponse | null>(null);
const [isFetchingDownload, setIsFetchingDownload] = useState(false);
const [downloadError, setDownloadError] = useState<string | undefined>(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({
<div className="editor-archive-details">
<div className="editor-archive-title">{fileName}</div>
<div className="editor-archive-meta">Archive{readableSize ? `${readableSize}` : ''}</div>
{resolvedUrl ? (
<a
className="editor-archive-link"
href={resolvedUrl}
download={fileName}
target="_blank"
rel="noopener noreferrer"
>
Download
</a>
) : (
<span className="editor-archive-link disabled">Preparing link...</span>
)}
<button
type="button"
className={`editor-archive-link${downloadButtonDisabled ? ' disabled' : ''}`}
onClick={handleDownload}
disabled={downloadButtonDisabled}
>
{downloadButtonLabel}
</button>
{isUploading && (
<div className="editor-archive-progress">
<div className="editor-archive-progress-bar">
@@ -165,6 +227,9 @@ export default function ArchiveComponent({
{uploadError && (
<div className="editor-archive-error"> {uploadError}</div>
)}
{downloadError && (
<div className="editor-archive-error"> {downloadError}</div>
)}
</div>
</div>
);

View File

@@ -8,6 +8,11 @@ export interface UploadPresignedURLResponse {
expireAt: number;
}
export interface DownloadPresignedURLResponse {
url: string;
expireAt: number;
}
export interface CreatePostResponse {
postId: string;
}