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 { getS3Url } from './s3Config';
|
||||||
import type {
|
import type {
|
||||||
UploadPresignedURLResponse,
|
UploadPresignedURLResponse,
|
||||||
|
DownloadPresignedURLResponse,
|
||||||
CreatePostResponse,
|
CreatePostResponse,
|
||||||
ListPostsRequest,
|
ListPostsRequest,
|
||||||
ListPostsResponse,
|
ListPostsResponse,
|
||||||
@@ -94,6 +95,21 @@ export async function uploadBlogFile(
|
|||||||
return { fileKey, url };
|
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
|
* Backwards-compatible image upload helper
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -11,9 +11,10 @@ import {
|
|||||||
KEY_ESCAPE_COMMAND,
|
KEY_ESCAPE_COMMAND,
|
||||||
SELECTION_CHANGE_COMMAND,
|
SELECTION_CHANGE_COMMAND,
|
||||||
} from 'lexical';
|
} 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 {
|
function formatFileSize(bytes?: number): string | undefined {
|
||||||
if (bytes === undefined) {
|
if (bytes === undefined) {
|
||||||
@@ -49,16 +50,25 @@ export default function ArchiveComponent({
|
|||||||
const [editor] = useLexicalComposerContext();
|
const [editor] = useLexicalComposerContext();
|
||||||
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [downloadInfo, setDownloadInfo] = useState<DownloadPresignedURLResponse | null>(null);
|
||||||
const resolvedUrl = useMemo(() => {
|
const [isFetchingDownload, setIsFetchingDownload] = useState(false);
|
||||||
if (fileKey) {
|
const [downloadError, setDownloadError] = useState<string | undefined>(undefined);
|
||||||
return getS3Url(fileKey);
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [fileKey]);
|
|
||||||
|
|
||||||
const readableSize = useMemo(() => formatFileSize(fileSize), [fileSize]);
|
const readableSize = useMemo(() => formatFileSize(fileSize), [fileSize]);
|
||||||
const isUploading = uploadProgress !== undefined && uploadProgress < 100 && !uploadError;
|
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(() => {
|
const handleEscape = useCallback(() => {
|
||||||
if (!isSelected) {
|
if (!isSelected) {
|
||||||
@@ -111,6 +121,63 @@ export default function ArchiveComponent({
|
|||||||
);
|
);
|
||||||
}, [editor, handleClick, handleEscape]);
|
}, [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
|
const borderColor = uploadError
|
||||||
? 'rgba(244, 67, 54, 0.6)'
|
? 'rgba(244, 67, 54, 0.6)'
|
||||||
: isSelected
|
: isSelected
|
||||||
@@ -136,19 +203,14 @@ export default function ArchiveComponent({
|
|||||||
<div className="editor-archive-details">
|
<div className="editor-archive-details">
|
||||||
<div className="editor-archive-title">{fileName}</div>
|
<div className="editor-archive-title">{fileName}</div>
|
||||||
<div className="editor-archive-meta">Archive{readableSize ? ` • ${readableSize}` : ''}</div>
|
<div className="editor-archive-meta">Archive{readableSize ? ` • ${readableSize}` : ''}</div>
|
||||||
{resolvedUrl ? (
|
<button
|
||||||
<a
|
type="button"
|
||||||
className="editor-archive-link"
|
className={`editor-archive-link${downloadButtonDisabled ? ' disabled' : ''}`}
|
||||||
href={resolvedUrl}
|
onClick={handleDownload}
|
||||||
download={fileName}
|
disabled={downloadButtonDisabled}
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
>
|
>
|
||||||
Download
|
{downloadButtonLabel}
|
||||||
</a>
|
</button>
|
||||||
) : (
|
|
||||||
<span className="editor-archive-link disabled">Preparing link...</span>
|
|
||||||
)}
|
|
||||||
{isUploading && (
|
{isUploading && (
|
||||||
<div className="editor-archive-progress">
|
<div className="editor-archive-progress">
|
||||||
<div className="editor-archive-progress-bar">
|
<div className="editor-archive-progress-bar">
|
||||||
@@ -165,6 +227,9 @@ export default function ArchiveComponent({
|
|||||||
{uploadError && (
|
{uploadError && (
|
||||||
<div className="editor-archive-error">❌ {uploadError}</div>
|
<div className="editor-archive-error">❌ {uploadError}</div>
|
||||||
)}
|
)}
|
||||||
|
{downloadError && (
|
||||||
|
<div className="editor-archive-error">❌ {downloadError}</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ export interface UploadPresignedURLResponse {
|
|||||||
expireAt: number;
|
expireAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DownloadPresignedURLResponse {
|
||||||
|
url: string;
|
||||||
|
expireAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreatePostResponse {
|
export interface CreatePostResponse {
|
||||||
postId: string;
|
postId: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user