✨ Big folder upload (#780)
This commit is contained in:
-221
@@ -1,221 +0,0 @@
|
||||
import React from 'react';
|
||||
import { PauseCircle, PlayCircle, Trash2 } from 'react-feather';
|
||||
import { Row, Col, Typography, Divider, Progress, Button, Tooltip } from 'antd';
|
||||
import { capitalize } from 'lodash';
|
||||
|
||||
import {
|
||||
isPendingFileStatusCancel,
|
||||
isPendingFileStatusError,
|
||||
isPendingFileStatusPause,
|
||||
isPendingFileStatusPending,
|
||||
isPendingFileStatusSuccess,
|
||||
} from '../../../features/files/utils/pending-files';
|
||||
import Languages from '@features/global/services/languages-service';
|
||||
import { useUpload } from '@features/files/hooks/use-upload';
|
||||
import { PendingFileRecoilType, PendingFileType } from '@features/files/types/file';
|
||||
|
||||
type PropsType = {
|
||||
pendingFileState: PendingFileRecoilType;
|
||||
pendingFile: PendingFileType;
|
||||
};
|
||||
|
||||
const { Text } = Typography;
|
||||
export default ({ pendingFileState, pendingFile }: PropsType) => {
|
||||
const { pauseOrResumeUpload, cancelUpload } = useUpload();
|
||||
|
||||
const getProgressStrokeColor = (status: PendingFileRecoilType['status']) => {
|
||||
if (isPendingFileStatusCancel(status)) return 'var(--error)';
|
||||
if (isPendingFileStatusError(status)) return 'var(--error)';
|
||||
if (isPendingFileStatusPause(status)) return 'var(--warning)';
|
||||
if (isPendingFileStatusPending(status)) return 'var(--progress-bar-color)';
|
||||
|
||||
return 'var(--success)';
|
||||
};
|
||||
|
||||
const setStatus = () => {
|
||||
switch (pendingFileState.status) {
|
||||
case 'error':
|
||||
case 'pause':
|
||||
case 'cancel':
|
||||
return 'exception';
|
||||
case 'pending':
|
||||
return 'active';
|
||||
case 'success':
|
||||
return 'success';
|
||||
default:
|
||||
return 'normal';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor:
|
||||
isPendingFileStatusCancel(pendingFileState.status) ||
|
||||
isPendingFileStatusError(pendingFileState.status)
|
||||
? 'var(--error-background)'
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
<Row
|
||||
className="testid:pending-files-row"
|
||||
justify="space-between"
|
||||
align="middle"
|
||||
wrap={false}
|
||||
style={{
|
||||
height: 39,
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
<Col className="small-left-margin" flex="auto" style={{ lineHeight: '16px' }}>
|
||||
{pendingFile?.originalFile?.name ? (
|
||||
<Row justify="start" align="middle" wrap={false}>
|
||||
<Text
|
||||
ellipsis
|
||||
style={{
|
||||
maxWidth: isPendingFileStatusPause(pendingFile.status) ? 130 : 160,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
className="testid:file-name"
|
||||
>
|
||||
{capitalize(pendingFile?.originalFile.name)}
|
||||
</Text>
|
||||
{isPendingFileStatusPause(pendingFile.status) && (
|
||||
<Text type="secondary" className='ant-typography-single-line' style={{ verticalAlign: 'middle', marginLeft: 4 }}>
|
||||
({Languages.t('general.paused')})
|
||||
</Text>
|
||||
)}
|
||||
</Row>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
height: 8,
|
||||
maxWidth: 160,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--grey-background)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{pendingFile?.label ? (
|
||||
<Row justify="start" align="middle" wrap={false}>
|
||||
<Text
|
||||
className="testid:file-label"
|
||||
ellipsis
|
||||
style={{
|
||||
maxWidth: isPendingFileStatusPause(pendingFile.status) ? 130 : 160,
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
>
|
||||
{pendingFile?.label}
|
||||
</Text>
|
||||
</Row>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
height: 8,
|
||||
maxWidth: 160,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--grey-background)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{pendingFileState.id ? (
|
||||
!isPendingFileStatusSuccess(pendingFileState.status) &&
|
||||
!isPendingFileStatusError(pendingFileState.status) ? (
|
||||
<Tooltip
|
||||
placement="top"
|
||||
title={
|
||||
isPendingFileStatusPause(pendingFileState.status)
|
||||
? Languages.t('general.resume')
|
||||
: Languages.t('general.pause')
|
||||
}
|
||||
className="pending-file-row-tooltip-file-status"
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
shape="circle"
|
||||
disabled={isPendingFileStatusError(pendingFileState.status)}
|
||||
icon={
|
||||
isPendingFileStatusPause(pendingFileState.status) ? (
|
||||
<PlayCircle size={16} color="var(--black)" />
|
||||
) : (
|
||||
<PauseCircle size={16} color="var(--black)" />
|
||||
)
|
||||
}
|
||||
onClick={() => pauseOrResumeUpload(pendingFileState.id)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
className="testid:button-toggle-tooltip-status"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div style={{ width: 32 }} />
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
height: 8,
|
||||
maxWidth: 32,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'var(--grey-background)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
<Col
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
lineHeight: '16px',
|
||||
}}
|
||||
>
|
||||
{!isPendingFileStatusSuccess(pendingFileState.status) &&
|
||||
!isPendingFileStatusError(pendingFileState.status) ? (
|
||||
<Tooltip title={Languages.t('general.cancel')} placement="top" className="testid:pending-file-row-tooltip-cancel">
|
||||
<Button
|
||||
type="link"
|
||||
shape="circle"
|
||||
icon={<Trash2 size={16} color={'var(--black)'} />}
|
||||
onClick={() => cancelUpload(pendingFileState.id)}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
className="testid:button-toggle-tooltip-cancel"
|
||||
/>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div style={{ width: 32 }} />
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<div className="file-progress-bar-container testid:progress-bar">
|
||||
<Progress
|
||||
type="line"
|
||||
className="file-progress-bar"
|
||||
percent={pendingFileState.progress * 100}
|
||||
showInfo={false}
|
||||
trailColor="var(--white)"
|
||||
status={setStatus()}
|
||||
strokeColor={getProgressStrokeColor(pendingFileState.status)}
|
||||
/>
|
||||
</div>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { Minus, Plus } from 'react-feather';
|
||||
import { Layout, Row, Col, Typography } from 'antd';
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import moment from 'moment';
|
||||
|
||||
import PendingFileRow from './pending-file-row';
|
||||
import Languages from '@features/global/services/languages-service';
|
||||
import { PendingFileRecoilType } from '@features/files/types/file';
|
||||
import { useUpload } from '@features/files/hooks/use-upload';
|
||||
|
||||
import './styles.scss';
|
||||
|
||||
type PropsType = {
|
||||
pendingFilesState: PendingFileRecoilType[];
|
||||
visible: boolean;
|
||||
};
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Header, Content } = Layout;
|
||||
export default ({ pendingFilesState, visible }: PropsType) => {
|
||||
const { getOnePendingFile, currentTask } = useUpload();
|
||||
const [hiddenPendingFiles, setHiddenPendingFiles] = useState<boolean>(false);
|
||||
|
||||
const handleTimeChange = useCallback(() => {
|
||||
const pendingFiles = pendingFilesState.map(state => getOnePendingFile(state.id));
|
||||
const uploadingFiles = pendingFiles.filter(f => f?.resumable && f.resumable.isUploading());
|
||||
|
||||
const remainingSizeTotal = uploadingFiles
|
||||
.map(f => (1 - f.progress) * (f?.originalFile?.size || 0))
|
||||
.reduce((accumulator: number, nextValue: number) => accumulator + nextValue, 0);
|
||||
|
||||
const speed =
|
||||
uploadingFiles
|
||||
.map(f => f.speed)
|
||||
.reduce((accumulator: number, nextValue: number) => accumulator + nextValue, 0) /
|
||||
uploadingFiles.map(f => f.speed).length;
|
||||
|
||||
const timeRemainingInMs = remainingSizeTotal / speed;
|
||||
|
||||
const momentTimeRemaining = moment(new Date().getTime() + timeRemainingInMs).fromNow();
|
||||
|
||||
if (momentTimeRemaining !== 'Invalid date') {
|
||||
return Languages.t('components.pending_file_list.estimation.end') + ` ${momentTimeRemaining}...`;
|
||||
} else {
|
||||
return Languages.t('components.pending_file_list.estimation.approximations');
|
||||
}
|
||||
}, [getOnePendingFile, pendingFilesState]);
|
||||
|
||||
return pendingFilesState.length > 0 ? (
|
||||
<Layout className={'pending-files-list-layout ' + (visible ? 'visible' : '') + ' testid:pending-file-list'}>
|
||||
<Header
|
||||
className={classNames('pending-files-list-header')}
|
||||
onClick={() => setHiddenPendingFiles(!hiddenPendingFiles)}
|
||||
>
|
||||
<Row justify="space-between" align="middle">
|
||||
<Col>
|
||||
<Text style={{ color: 'var(--white)' }}>
|
||||
{currentTask.total > 0 && `${currentTask.uploaded}/${currentTask.total} `}
|
||||
{Languages.t('components.drive_dropzone.uploading')}
|
||||
</Text>
|
||||
</Col>
|
||||
<Col style={{ display: 'flex', alignItems: 'center' }}>
|
||||
{hiddenPendingFiles ? <Plus size={18} /> : <Minus size={18} />}
|
||||
</Col>
|
||||
</Row>
|
||||
</Header>
|
||||
{!hiddenPendingFiles && (
|
||||
<Content className="pending-files-list-content">
|
||||
<PerfectScrollbar
|
||||
options={{ suppressScrollX: true, suppressScrollY: false }}
|
||||
component="div"
|
||||
style={{ width: '100%', height: 114 }}
|
||||
>
|
||||
<Row justify="start" align="middle" style={{ background: '#DFE7FE' }}>
|
||||
<Col className="small-left-margin">
|
||||
<Text style={{ color: '#6C6C6D' }}>{handleTimeChange()}</Text>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<>
|
||||
{pendingFilesState.length > 0 &&
|
||||
pendingFilesState.map((pendingFileState, index) => (
|
||||
<PendingFileRow
|
||||
key={`${pendingFileState.file?.id}-${index}`}
|
||||
pendingFileState={pendingFileState}
|
||||
pendingFile={getOnePendingFile(pendingFileState.id)}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
</PerfectScrollbar>
|
||||
</Content>
|
||||
)}
|
||||
</Layout>
|
||||
) : (
|
||||
<></>
|
||||
);
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
.pending-files-list-layout {
|
||||
border: 1px solid var(--black-alpha-70);
|
||||
box-shadow: var(--box-shadow-base);
|
||||
width: 256px;
|
||||
|
||||
border-radius: var(--border-radius-base);
|
||||
position: absolute;
|
||||
z-index: 100;
|
||||
bottom: 8px;
|
||||
right: 24px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s;
|
||||
transition-delay: 1s;
|
||||
|
||||
&.visible {
|
||||
opacity: 1;
|
||||
transition-delay: 0s;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.pending-files-list-header {
|
||||
color: var(--white);
|
||||
padding: 0 8px;
|
||||
background-color: var(--secondary);
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
border-radius: 8px 8px 0 0;
|
||||
cursor: pointer;
|
||||
|
||||
&.hidden {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.pending-files-list-content {
|
||||
min-height: 32px;
|
||||
max-height: 176px;
|
||||
overflow-y: none;
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
FileTypeArchiveIcon,
|
||||
FileTypeDocumentIcon,
|
||||
FileTypeSpreadsheetIcon,
|
||||
FileTypeMediaIcon,
|
||||
FileTypeSlidesIcon,
|
||||
FileTypePdfIcon,
|
||||
} from 'app/atoms/icons-colored';
|
||||
|
||||
// Map mime types to their respective JSX icon elements
|
||||
export const fileTypeIconsMap = {
|
||||
'application/pdf': <FileTypePdfIcon />,
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': (
|
||||
<FileTypeDocumentIcon />
|
||||
),
|
||||
'application/msword': <FileTypeDocumentIcon />,
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': <FileTypeSpreadsheetIcon />,
|
||||
'application/vnd.ms-excel': <FileTypeSpreadsheetIcon />,
|
||||
'application/vnd.ms-powerpoint': <FileTypeSlidesIcon />,
|
||||
'application/vnd.openxmlformats-officedocument.presentationml.presentation': (
|
||||
<FileTypeSlidesIcon />
|
||||
),
|
||||
'application/zip': <FileTypeArchiveIcon />,
|
||||
'application/x-rar-compressed': <FileTypeArchiveIcon />,
|
||||
'application/x-tar': <FileTypeArchiveIcon />,
|
||||
'application/x-7z-compressed': <FileTypeArchiveIcon />,
|
||||
'application/x-bzip': <FileTypeArchiveIcon />,
|
||||
'application/x-bzip2': <FileTypeArchiveIcon />,
|
||||
'application/x-gzip': <FileTypeArchiveIcon />,
|
||||
'video/mp4': <FileTypeMediaIcon />,
|
||||
'video/mpeg': <FileTypeMediaIcon />,
|
||||
'video/ogg': <FileTypeMediaIcon />,
|
||||
'video/webm': <FileTypeMediaIcon />,
|
||||
'video/quicktime': <FileTypeMediaIcon />,
|
||||
};
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { useUpload } from '@features/files/hooks/use-upload';
|
||||
import PerfectScrollbar from 'react-perfect-scrollbar';
|
||||
import { ArrowDownIcon, ArrowUpIcon } from 'app/atoms/icons-colored';
|
||||
import { UploadRootListType } from 'app/features/files/types/file';
|
||||
import Languages from '@features/global/services/languages-service';
|
||||
import PendingRootRow from './pending-root-row';
|
||||
import { UploadStateEnum } from 'app/features/files/services/file-upload-service';
|
||||
|
||||
const getFilteredRoots = (keys: string[], roots: UploadRootListType) => {
|
||||
const inProgress = keys.filter(key => roots[key].status === 'uploading');
|
||||
const completed = keys.filter(key => roots[key].status === 'completed');
|
||||
const paused = keys.filter(key => roots[key].status === 'paused');
|
||||
return { inProgress, completed, paused };
|
||||
};
|
||||
|
||||
interface ModalHeaderProps {
|
||||
uploadingCount: number;
|
||||
completedCount: number;
|
||||
totalRoots: number;
|
||||
uploadingPercentage: number;
|
||||
toggleModal: () => void;
|
||||
modalExpanded: boolean;
|
||||
}
|
||||
|
||||
const ModalHeader: React.FC<ModalHeaderProps> = ({
|
||||
uploadingCount,
|
||||
completedCount,
|
||||
totalRoots,
|
||||
uploadingPercentage,
|
||||
toggleModal,
|
||||
modalExpanded,
|
||||
}) => (
|
||||
<div className="w-full flex bg-[#45454A] text-white p-4 items-center justify-between">
|
||||
<p className="testid:upload-modal-head-status">
|
||||
{uploadingCount > 0
|
||||
? `${Languages.t('general.uploading')} ${uploadingCount}`
|
||||
: `${Languages.t('general.uploaded')} ${completedCount}`}{' '}
|
||||
{Languages.t('general.files')}
|
||||
</p>
|
||||
<button
|
||||
className="ml-auto flex items-center testid:upload-modal-toggle-arrow"
|
||||
onClick={toggleModal}
|
||||
>
|
||||
{modalExpanded ? <ArrowDownIcon /> : <ArrowUpIcon />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface ModalFooterProps {
|
||||
pauseOrResumeUpload: () => void;
|
||||
cancelUpload: () => void;
|
||||
isPaused: () => boolean;
|
||||
uploadingCount: number;
|
||||
}
|
||||
|
||||
const ModalFooter: React.FC<ModalFooterProps> = ({
|
||||
pauseOrResumeUpload,
|
||||
cancelUpload,
|
||||
isPaused,
|
||||
uploadingCount,
|
||||
}) => (
|
||||
<div className="w-full flex bg-[#F0F2F3] text-black p-4 items-center justify-between">
|
||||
<div className="flex space-x-4 ml-auto">
|
||||
{uploadingCount > 0 && (
|
||||
<button
|
||||
className="text-blue-500 px-4 py-2 rounded hover:bg-blue-600 hover:text-white testid:upload-modal-pause-resume"
|
||||
onClick={pauseOrResumeUpload}
|
||||
>
|
||||
{isPaused() ? Languages.t('general.resume') : Languages.t('general.pause')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="text-blue-500 px-4 py-2 rounded hover:bg-blue-600 hover:text-white testid:upload-modal-cancel-close"
|
||||
onClick={cancelUpload}
|
||||
>
|
||||
{uploadingCount ? Languages.t('general.cancel') : Languages.t('general.close')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PendingRootList = ({
|
||||
roots,
|
||||
status,
|
||||
parentId,
|
||||
}: {
|
||||
roots: UploadRootListType;
|
||||
status: UploadStateEnum;
|
||||
parentId: string;
|
||||
}): JSX.Element => {
|
||||
const [modalExpanded, setModalExpanded] = useState(true);
|
||||
const { pauseOrResumeUpload, cancelUpload } = useUpload();
|
||||
const keys = useMemo(() => Object.keys(roots || {}), [roots]);
|
||||
|
||||
const {
|
||||
inProgress: rootsInProgress,
|
||||
completed: rootsCompleted,
|
||||
paused: rootsPaused,
|
||||
} = useMemo(() => getFilteredRoots(keys, roots), [keys, roots]);
|
||||
|
||||
const isPaused = useCallback(() => status === UploadStateEnum.Paused, [status]);
|
||||
|
||||
const totalRoots = keys.length;
|
||||
const uploadingCount = rootsInProgress.length;
|
||||
const completedCount = rootsCompleted.length;
|
||||
const pausedCount = rootsPaused.length;
|
||||
const uploadingPercentage = Math.floor((uploadingCount / totalRoots) * 100) || 100;
|
||||
|
||||
const toggleModal = useCallback(() => setModalExpanded(prev => !prev), []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{totalRoots > 0 && (
|
||||
<div className="fixed bottom-4 right-4 w-1/3 shadow-lg rounded-sm overflow-hidden testid:upload-modal">
|
||||
<ModalHeader
|
||||
uploadingCount={uploadingCount + pausedCount}
|
||||
completedCount={completedCount}
|
||||
totalRoots={totalRoots}
|
||||
uploadingPercentage={uploadingPercentage}
|
||||
toggleModal={toggleModal}
|
||||
modalExpanded={modalExpanded}
|
||||
/>
|
||||
|
||||
{modalExpanded && (
|
||||
<div className="modal-body">
|
||||
<div className="bg-white px-4 py-2">
|
||||
<PerfectScrollbar
|
||||
options={{ suppressScrollX: true, suppressScrollY: false }}
|
||||
component="div"
|
||||
style={{ width: '100%', maxHeight: 300 }}
|
||||
>
|
||||
{keys.map(key => (
|
||||
<PendingRootRow key={key} rootKey={key} root={roots[key]} parentId={parentId} />
|
||||
))}
|
||||
</PerfectScrollbar>
|
||||
</div>
|
||||
<ModalFooter
|
||||
pauseOrResumeUpload={pauseOrResumeUpload}
|
||||
cancelUpload={cancelUpload}
|
||||
isPaused={isPaused}
|
||||
uploadingCount={uploadingCount + pausedCount}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingRootList;
|
||||
+208
@@ -0,0 +1,208 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useUpload } from '@features/files/hooks/use-upload';
|
||||
import RouterService from '@features/router/services/router-service';
|
||||
import { UploadRootType } from 'app/features/files/types/file';
|
||||
import {
|
||||
FileTypeUnknownIcon,
|
||||
FolderIcon,
|
||||
CheckGreenIcon,
|
||||
PauseIcon,
|
||||
CancelIcon,
|
||||
ResumeIcon,
|
||||
ShowFolderIcon,
|
||||
} from 'app/atoms/icons-colored';
|
||||
import { fileTypeIconsMap } from './file-type-icon-map';
|
||||
import { useDriveActions } from 'app/features/drive/hooks/use-drive-actions';
|
||||
import { useDriveItem } from 'app/features/drive/hooks/use-drive-item';
|
||||
import Languages from 'app/features/global/services/languages-service';
|
||||
|
||||
const PendingRootRow = ({
|
||||
rootKey,
|
||||
root,
|
||||
parentId,
|
||||
}: {
|
||||
rootKey: string;
|
||||
root: UploadRootType;
|
||||
parentId: string;
|
||||
}): JSX.Element => {
|
||||
const { pauseOrResumeRootUpload, cancelRootUpload, clearRoots } = useUpload();
|
||||
const [showFolder, setShowFolder] = useState(false);
|
||||
const [restoredFolder, setRestoredFolder] = useState(false);
|
||||
const { restore } = useDriveActions();
|
||||
const { refresh } = useDriveItem(parentId || '');
|
||||
|
||||
const firstPendingFile = root.items[0];
|
||||
const uploadedFilesSize = root.uploadedSize;
|
||||
const uploadProgress = Math.floor((uploadedFilesSize / root.size) * 100);
|
||||
const isUploadCompleted = root.status === 'completed';
|
||||
const isFileRoot = rootKey.includes('.');
|
||||
|
||||
// Callback function to open the folder after the upload is completed
|
||||
const handleShowFolder = useCallback(() => {
|
||||
if (!showFolder || isFileRoot) {
|
||||
const redirectionURL = RouterService.generateRouteFromState({
|
||||
itemId: root.id,
|
||||
});
|
||||
window.open(redirectionURL, '_blank');
|
||||
} else {
|
||||
RouterService.push(RouterService.generateRouteFromState({ dirId: root.id || '' }));
|
||||
}
|
||||
}, [showFolder, root, isFileRoot, clearRoots]);
|
||||
|
||||
// Function to determine the icon for the root
|
||||
// If the root is a file, it will show the file icon based on the content type
|
||||
// If the root is a folder, it will show the folder icon
|
||||
const itemTypeIcon = useCallback(
|
||||
(type: string) =>
|
||||
isFileRoot ? (
|
||||
fileTypeIconsMap[type as keyof typeof fileTypeIconsMap] || <FileTypeUnknownIcon />
|
||||
) : (
|
||||
<FolderIcon />
|
||||
),
|
||||
[isFileRoot],
|
||||
);
|
||||
|
||||
// A timeout to show the folder icon after the upload is completed
|
||||
// This is to give a visual feedback to the user and will be shown shortly
|
||||
// after the green check icon appears
|
||||
useEffect(() => {
|
||||
if (isUploadCompleted) {
|
||||
const timeout = setTimeout(async () => {
|
||||
setShowFolder(true);
|
||||
}, 1500);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [isUploadCompleted]);
|
||||
|
||||
useEffect(() => {
|
||||
const postProcess = async () => {
|
||||
if (isUploadCompleted && !restoredFolder) {
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await restore(root.id, parentId);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
await refresh(parentId);
|
||||
}
|
||||
};
|
||||
if (isUploadCompleted && !restoredFolder) {
|
||||
setRestoredFolder(true);
|
||||
postProcess();
|
||||
}
|
||||
}, [isUploadCompleted]);
|
||||
|
||||
// Helper to convert size to the closest unit
|
||||
const formatFileSize = (sizeInBytes: number): string => {
|
||||
if (sizeInBytes) {
|
||||
if (sizeInBytes < 1024) return `${sizeInBytes} Bytes`;
|
||||
if (sizeInBytes < 1024 ** 2) return `${(sizeInBytes / 1024).toFixed(2)} KB`;
|
||||
if (sizeInBytes < 1024 ** 3) return `${(sizeInBytes / 1024 ** 2).toFixed(2)} MB`;
|
||||
return `${(sizeInBytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
} else {
|
||||
return '0 Bytes';
|
||||
}
|
||||
};
|
||||
|
||||
// Helper to truncate the root name / key if it is too long
|
||||
const truncateRootName = (rootName: string): string => {
|
||||
if (rootName.length > 30) {
|
||||
return `${rootName.substring(0, 20)}...`;
|
||||
}
|
||||
return rootName;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="root-row">
|
||||
<div className="root-details mt-2">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 flex items-center justify-center bg-[#f3f3f7] rounded-md">
|
||||
<div className="w-full h-full flex items-center justify-center testid:upload-modal-row-type">
|
||||
{itemTypeIcon(firstPendingFile?.type)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="ml-4">
|
||||
<span className="font-bold">{truncateRootName(rootKey)} </span>
|
||||
{root.status !== 'failed' && root.uploadedSize > 0 && (
|
||||
<span className="ml-4 text-sm">
|
||||
({formatFileSize(root.uploadedSize)} / {formatFileSize(root.size)})
|
||||
</span>
|
||||
)}
|
||||
{root.status === 'failed' && (
|
||||
<span className="ml-4 text-red-500">{Languages.t('general.upload_failed')}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="progress-check flex items-center justify-center ml-auto">
|
||||
{isUploadCompleted ? (
|
||||
<button
|
||||
onClick={handleShowFolder}
|
||||
className="hover:bg-gray-100 p-2 rounded-md transition-all duration-200 testid:upload-modal-row-show-folder"
|
||||
>
|
||||
{!isFileRoot && (
|
||||
<>
|
||||
<CheckGreenIcon
|
||||
className={`transition-opacity ${
|
||||
showFolder ? 'opacity-0 w-0 h-0' : 'opacity-1 hover:scale-110'
|
||||
}`}
|
||||
/>
|
||||
<ShowFolderIcon
|
||||
className={`transition-opacity duration-300 ${
|
||||
showFolder ? 'opacity-1 hover:scale-110' : 'opacity-0 w-0 h-0'
|
||||
}`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{isFileRoot && (
|
||||
<CheckGreenIcon className="opacity-1 hover:scale-110 transition-transform duration-200" />
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
!['cancelled', 'failed'].includes(root.status) &&
|
||||
firstPendingFile?.status !== 'error' && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => pauseOrResumeRootUpload(rootKey)}
|
||||
className="hover:bg-blue-100 p-2 rounded-md transition-all duration-200 testid:upload-modal-row-pause-resume"
|
||||
>
|
||||
{root.status === 'paused' ? (
|
||||
<ResumeIcon className="hover:scale-110 transition-transform duration-200" />
|
||||
) : (
|
||||
<PauseIcon className="hover:scale-110 transition-transform duration-200" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="ml-2 hover:bg-red-100 p-2 rounded-md transition-all duration-200 testid:upload-modal-row-cancel"
|
||||
onClick={() => cancelRootUpload(rootKey)}
|
||||
>
|
||||
<CancelIcon className="hover:scale-110 transition-transform duration-200" />
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="root-progress h-[3px] mt-4">
|
||||
{!showFolder && (
|
||||
<div className="w-full h-[3px] bg-[#F0F2F3]">
|
||||
<div
|
||||
className={`testid:upload-modal-row-progress h-full ${
|
||||
root.status === 'failed'
|
||||
? 'bg-[#FF0000]' // Red color for failed uploads
|
||||
: root.status === 'cancelled'
|
||||
? 'bg-[#FFA500]' // Orange for cancelled uploads
|
||||
: 'bg-[#00A029]' // Green for successful uploads
|
||||
}`}
|
||||
style={{
|
||||
width: `${
|
||||
root.status === 'failed' || root.status === 'cancelled' ? 100 : uploadProgress
|
||||
}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PendingRootRow;
|
||||
@@ -1,16 +1,19 @@
|
||||
import React from 'react';
|
||||
import { useUpload } from '@features/files/hooks/use-upload';
|
||||
import PendingFilesList from './pending-file-components/pending-files-list';
|
||||
import PendingRootList from './pending-root-components/pending-root-list';
|
||||
|
||||
const ChatUploadsViewer = (): JSX.Element => {
|
||||
const UploadsViewer = (): JSX.Element => {
|
||||
const { currentTask } = useUpload();
|
||||
|
||||
return (
|
||||
<PendingFilesList
|
||||
visible={!!currentTask && currentTask.files.length > 0 && !currentTask.completed}
|
||||
pendingFilesState={currentTask.files}
|
||||
/>
|
||||
);
|
||||
// Destructure and provide default values for safety
|
||||
const { roots = {}, status, parentId } = currentTask || {};
|
||||
const rootKeys = Object.keys(roots);
|
||||
|
||||
// Early return for clarity
|
||||
if (rootKeys.length === 0) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <PendingRootList roots={roots} status={status} parentId={parentId} />;
|
||||
};
|
||||
|
||||
export default ChatUploadsViewer;
|
||||
export default UploadsViewer;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FolderIcon } from '@heroicons/react/solid';
|
||||
import { FolderIcon } from 'app/atoms/icons-colored';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { onDriveItemDownloadClick } from '../common';
|
||||
@@ -20,7 +20,7 @@ import RouterServices from '@features/router/services/router-service';
|
||||
import useRouterCompany from 'app/features/router/hooks/use-router-company';
|
||||
import { DocumentIcon } from '@views/client/body/drive/documents/document-icon';
|
||||
|
||||
export default (props: { driveItem: DriveItem & { user?: UserType }}) => {
|
||||
export default (props: { driveItem: DriveItem & { user?: UserType } }) => {
|
||||
const history = useHistory();
|
||||
const input = useRecoilValue(SearchInputState);
|
||||
const file = props.driveItem;
|
||||
@@ -31,7 +31,7 @@ export default (props: { driveItem: DriveItem & { user?: UserType }}) => {
|
||||
const { open } = useDrivePreview();
|
||||
const company = useRouterCompany();
|
||||
|
||||
function openDoc(file: DriveItem){
|
||||
function openDoc(file: DriveItem) {
|
||||
open(file);
|
||||
if (file.is_directory) setOpen(false);
|
||||
}
|
||||
@@ -39,7 +39,12 @@ export default (props: { driveItem: DriveItem & { user?: UserType }}) => {
|
||||
return (
|
||||
<div
|
||||
className="flex items-center p-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md cursor-pointer testid:drive-item-result"
|
||||
onClick={() => {history.push(RouterServices.generateRouteFromState({companyId: company, itemId: file.id})); openDoc(file)}}
|
||||
onClick={() => {
|
||||
history.push(
|
||||
RouterServices.generateRouteFromState({ companyId: company, itemId: file.id }),
|
||||
);
|
||||
openDoc(file);
|
||||
}}
|
||||
>
|
||||
<FileResultMedia file={file} className="w-16 h-16 mr-3" />
|
||||
<div className="grow mr-3 overflow-hidden">
|
||||
@@ -96,14 +101,26 @@ export const FileResultMedia = (props: {
|
||||
|
||||
if (file.is_directory) {
|
||||
return (
|
||||
<div className={'relative flex bg-blue-100 rounded-md ' + (props.className || '') + ' testid:folder-result-media'}>
|
||||
<div
|
||||
className={
|
||||
'relative flex bg-blue-100 rounded-md ' +
|
||||
(props.className || '') +
|
||||
' testid:folder-result-media'
|
||||
}
|
||||
>
|
||||
<FolderIcon className="w-10 h-10 m-auto text-blue-500 testid:folder-icon" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={'relative flex bg-zinc-200 rounded-md ' + (props.className || '') + ' testid:file-result-media'}>
|
||||
<div
|
||||
className={
|
||||
'relative flex bg-zinc-200 rounded-md ' +
|
||||
(props.className || '') +
|
||||
' testid:file-result-media'
|
||||
}
|
||||
>
|
||||
<Media
|
||||
size={props.size || 'md'}
|
||||
url={url}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { number } from 'prop-types';
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type TreeItem = { [key: string]: File | TreeItem };
|
||||
type TreeItem = { [key: string]: { root: string; file: File } | TreeItem };
|
||||
|
||||
export type FileTreeObject = {
|
||||
tree: TreeItem;
|
||||
documentsCount: number;
|
||||
totalSize: number;
|
||||
sizePerRoot: { [key: string]: number };
|
||||
};
|
||||
|
||||
export const getFilesTree = (
|
||||
@@ -119,6 +122,7 @@ export const getFilesTree = (
|
||||
});
|
||||
}
|
||||
|
||||
let timeBegin = Date.now();
|
||||
[].slice.call(items).forEach(function (entry: any) {
|
||||
entry = entry.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
@@ -134,6 +138,7 @@ export const getFilesTree = (
|
||||
resolve(true);
|
||||
}, resolve.bind(null, true));
|
||||
} else if (entry.isDirectory) {
|
||||
const timeToRead = Date.now();
|
||||
readDirectory(entry, null, resolve);
|
||||
}
|
||||
}),
|
||||
@@ -145,6 +150,7 @@ export const getFilesTree = (
|
||||
return false;
|
||||
}
|
||||
|
||||
timeBegin = Date.now();
|
||||
Promise.all(rootPromises).then(cb.bind(null, fd, files));
|
||||
}
|
||||
|
||||
@@ -152,6 +158,7 @@ export const getFilesTree = (
|
||||
const documents_number = paths ? paths.length : 0;
|
||||
let total_size = 0;
|
||||
const tree: any = {};
|
||||
const size_per_root: { [key: string]: number } = {};
|
||||
(paths || []).forEach(function (path, file_index) {
|
||||
let dirs = tree;
|
||||
const real_file = files[file_index];
|
||||
@@ -163,7 +170,17 @@ export const getFilesTree = (
|
||||
return;
|
||||
}
|
||||
if (dir_index === path.split('/').length - 1) {
|
||||
dirs[dir] = real_file;
|
||||
const root = path.split('/')[0];
|
||||
dirs[dir] = {
|
||||
file: real_file,
|
||||
root,
|
||||
};
|
||||
// Calculate the total size of each root
|
||||
if (!size_per_root[root]) {
|
||||
size_per_root[root] = real_file.size;
|
||||
} else {
|
||||
size_per_root[root] += real_file.size;
|
||||
}
|
||||
} else {
|
||||
if (!dirs[dir]) {
|
||||
dirs[dir] = {};
|
||||
@@ -172,35 +189,76 @@ export const getFilesTree = (
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
fcb && fcb(tree, documents_number, total_size);
|
||||
resolve({ tree, documentsCount: documents_number, totalSize: total_size });
|
||||
resolve({
|
||||
tree,
|
||||
documentsCount: documents_number,
|
||||
totalSize: total_size,
|
||||
sizePerRoot: size_per_root,
|
||||
});
|
||||
};
|
||||
|
||||
// Handle file input based on the event type, starting with `dataTransfer` for drag-and-drop events
|
||||
if (event.dataTransfer) {
|
||||
const dt = event.dataTransfer;
|
||||
|
||||
// When dragging files into the browser, `dataTransfer.items` contains a list of the dragged items.
|
||||
// `webkitGetAsEntry` allows access to a directory-like API, letting us explore folders and subfolders.
|
||||
// This means we can recursively scan for files in folders without relying on manual user input.
|
||||
if (dt.items && dt.items.length && 'webkitGetAsEntry' in dt.items[0]) {
|
||||
// Use `entriesApi` to iterate through items, handling directories and files.
|
||||
// This is ideal for cases where users drag entire folder structures into the app.
|
||||
entriesApi(dt.items, (files, paths) => cb(event, files || [], paths));
|
||||
} else if ('getFilesAndDirectories' in dt) {
|
||||
}
|
||||
// If `getFilesAndDirectories` is available on `dataTransfer`, it indicates a newer API is supported.
|
||||
// This API directly provides both files and directories, making it easier to process structured uploads.
|
||||
else if ('getFilesAndDirectories' in dt) {
|
||||
// Use `newDirectoryApi` to process files and directories in a standardized way.
|
||||
newDirectoryApi(dt, (files, paths) => cb(event, files || [], paths));
|
||||
} else if (dt.files) {
|
||||
}
|
||||
// If neither of the advanced APIs (`webkitGetAsEntry` or `getFilesAndDirectories`) is available,
|
||||
// fall back to using the basic `dataTransfer.files` property.
|
||||
// This works only for files, meaning directories won’t be detected or handled.
|
||||
else if (dt.files) {
|
||||
// Use `arrayApi` to process the flat list of files.
|
||||
arrayApi(dt, (files, paths) => cb(event, files || [], paths));
|
||||
} else cb(event, [], []);
|
||||
} else if (event.target) {
|
||||
}
|
||||
// If no files or directories can be detected (e.g., if the user drops something invalid),
|
||||
// return an empty response to ensure the application doesn’t break.
|
||||
else cb(event, [], []);
|
||||
}
|
||||
// If the event comes from a file input field rather than drag-and-drop (`event.target` exists):
|
||||
else if (event.target) {
|
||||
const t = event.target as any;
|
||||
|
||||
// When a file input element (`<input type="file">`) is used, it stores the selected files in `target.files`.
|
||||
// This is the standard way for users to upload files through a file picker dialog.
|
||||
if (t.files && t.files.length) {
|
||||
// Process the selected files as a flat array using `arrayApi`.
|
||||
arrayApi(t, (files, paths) => cb(event, files || [], paths));
|
||||
} else if ('getFilesAndDirectories' in t) {
|
||||
}
|
||||
// If the input element supports `getFilesAndDirectories`, handle structured uploads.
|
||||
// This could occur in custom or enhanced file inputs that allow folder selection.
|
||||
else if ('getFilesAndDirectories' in t) {
|
||||
newDirectoryApi(t, (files, paths) => cb(event, files || [], paths));
|
||||
} else {
|
||||
}
|
||||
// If no valid files or directories can be detected, return an empty response.
|
||||
else {
|
||||
cb(event, [], []);
|
||||
}
|
||||
} else {
|
||||
}
|
||||
// Fallback for cases where neither `dataTransfer` nor `target` is available:
|
||||
// This typically occurs in unusual scenarios, such as handling a manually triggered upload.
|
||||
else {
|
||||
// If a callback (`fcb`) is provided, call it with the first file found (if any).
|
||||
// This is a last-resort assumption that `event.target.files` has at least one valid file.
|
||||
fcb && fcb([(event.target as any).files[0]], 1, (event.target as any).files[0].size);
|
||||
|
||||
// Resolve the promise with a default response, treating the single file as the entire tree.
|
||||
resolve({
|
||||
tree: (event.target as any).files[0],
|
||||
documentsCount: 1,
|
||||
totalSize: (event.target as any).files[0].size,
|
||||
sizePerRoot: { [(event.target as any).files[0].name]: (event.target as any).files[0].size },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user