Files
www.cialloo.com/src/blog/nodes/ArchiveComponent.tsx
cialloo d1826ef7be
All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 29s
feat: add download functionality to ArchiveComponent with presigned URL support
2025-10-27 21:41:52 +08:00

237 lines
6.7 KiB
TypeScript

import type { NodeKey } from 'lexical';
import type { JSX } from 'react';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useLexicalNodeSelection } from '@lexical/react/useLexicalNodeSelection';
import { mergeRegister } from '@lexical/utils';
import {
$setSelection,
CLICK_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_ESCAPE_COMMAND,
SELECTION_CHANGE_COMMAND,
} from 'lexical';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getDownloadPresignedURL } from '../api';
import type { DownloadPresignedURLResponse } from '../types';
function formatFileSize(bytes?: number): string | undefined {
if (bytes === undefined) {
return undefined;
}
if (bytes === 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const order = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const size = bytes / Math.pow(1024, order);
return `${size.toFixed(size >= 10 || order === 0 ? 0 : 1)} ${units[order]}`;
}
interface ArchiveComponentProps {
nodeKey: NodeKey;
fileName: string;
fileSize?: number;
fileKey?: string;
uploadProgress?: number;
uploadError?: string;
uploadId?: string;
}
export default function ArchiveComponent({
nodeKey,
fileName,
fileSize,
fileKey,
uploadProgress,
uploadError,
}: ArchiveComponentProps): JSX.Element {
const [editor] = useLexicalComposerContext();
const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey);
const containerRef = useRef<HTMLDivElement | null>(null);
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) {
return false;
}
$setSelection(null);
editor.update(() => {
setSelected(true);
editor.getRootElement()?.focus();
});
return true;
}, [editor, isSelected, setSelected]);
const handleClick = useCallback(
(event: MouseEvent) => {
if (event.target === null) {
return false;
}
if (containerRef.current && containerRef.current.contains(event.target as Node)) {
if (event.shiftKey) {
setSelected(!isSelected);
} else {
clearSelection();
setSelected(true);
}
return true;
}
return false;
},
[clearSelection, isSelected, setSelected],
);
useEffect(() => {
return mergeRegister(
editor.registerCommand(
SELECTION_CHANGE_COMMAND,
() => false,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
CLICK_COMMAND,
handleClick,
COMMAND_PRIORITY_LOW,
),
editor.registerCommand(
KEY_ESCAPE_COMMAND,
handleEscape,
COMMAND_PRIORITY_LOW,
),
);
}, [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
? 'var(--accent-color, #4CAF50)'
: 'var(--border-color, #ccc)';
return (
<div
ref={containerRef}
className="editor-archive-card"
style={{
borderColor,
}}
tabIndex={0}
onFocus={() => {
clearSelection();
setSelected(true);
}}
>
<div className="editor-archive-icon" aria-hidden>
ZIP
</div>
<div className="editor-archive-details">
<div className="editor-archive-title">{fileName}</div>
<div className="editor-archive-meta">Archive{readableSize ? `${readableSize}` : ''}</div>
<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">
<div
className="editor-archive-progress-value"
style={{ width: `${uploadProgress || 0}%` }}
/>
</div>
<div className="editor-archive-progress-label">
Uploading... {Math.round(uploadProgress || 0)}%
</div>
</div>
)}
{uploadError && (
<div className="editor-archive-error"> {uploadError}</div>
)}
{downloadError && (
<div className="editor-archive-error"> {downloadError}</div>
)}
</div>
</div>
);
}