All checks were successful
CI - Build and Push / Build and Push Docker Image (push) Successful in 29s
237 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|