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
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 29s
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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"
|
||||
<button
|
||||
type="button"
|
||||
className={`editor-archive-link${downloadButtonDisabled ? ' disabled' : ''}`}
|
||||
onClick={handleDownload}
|
||||
disabled={downloadButtonDisabled}
|
||||
>
|
||||
Download
|
||||
</a>
|
||||
) : (
|
||||
<span className="editor-archive-link disabled">Preparing link...</span>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,11 @@ export interface UploadPresignedURLResponse {
|
||||
expireAt: number;
|
||||
}
|
||||
|
||||
export interface DownloadPresignedURLResponse {
|
||||
url: string;
|
||||
expireAt: number;
|
||||
}
|
||||
|
||||
export interface CreatePostResponse {
|
||||
postId: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user