diff --git a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts
index 7f2e01ae..5f9b9830 100644
--- a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts
+++ b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts
@@ -127,7 +127,7 @@ export class DocumentsController {
reply.status(200).send();
} catch (error) {
logger.error({ error: `${error}` }, "Failed to restore drive item");
- throw new CrudException("Failed to restore drive item", 500);
+ throw new CrudException(`Failed to restore drive item: ${error}`, 500);
}
};
diff --git a/tdrive/frontend/public/locales/en.json b/tdrive/frontend/public/locales/en.json
index d1a7d955..668458ac 100644
--- a/tdrive/frontend/public/locales/en.json
+++ b/tdrive/frontend/public/locales/en.json
@@ -173,6 +173,7 @@
"general.add": "Add",
"general.back": "Back",
"general.cancel": "Cancel",
+ "general.close": "Close",
"general.connexion_status.connected": "You are online",
"general.connexion_status.connecting": "Reconnecting…",
"general.connexion_status.disconnected": "You are offline",
@@ -184,6 +185,10 @@
"general.resume": "Resume",
"general.send": "Send message",
"general.update": "Update",
+ "general.uploading": "Uploading",
+ "general.uploaded": "Uploaded",
+ "general.files": "file(s)",
+ "general.upload_failed": "Upload failed",
"general.user.anonymous": "Anonymous",
"general.user.deactivated": "User is no longer in this company",
"general.user.deleted": "Deleted Account",
diff --git a/tdrive/frontend/public/locales/fr.json b/tdrive/frontend/public/locales/fr.json
index ab5d8958..77506d61 100644
--- a/tdrive/frontend/public/locales/fr.json
+++ b/tdrive/frontend/public/locales/fr.json
@@ -166,6 +166,7 @@
"general.add": "Ajouter",
"general.back": "Retour",
"general.cancel": "Annuler",
+ "general.close": "Fermer",
"general.connexion_status.connected": "Vous êtes connecté",
"general.connexion_status.connecting": "Reconnexion en cours…",
"general.connexion_status.disconnected": "Vous êtes hors ligne",
@@ -177,6 +178,10 @@
"general.resume": "Reprendre",
"general.send": "Envoyer un message",
"general.update": "Mettre à jour",
+ "general.uploading": "Téléchargement en cours",
+ "general.uploaded": "Téléversé",
+ "general.files": "fichiers",
+ "general.upload_failed": "Échec de l'envoi",
"general.user.anonymous": "Compte anonyme",
"general.user.deactivated": "Cet utilisateur n'est plus dans cette l'entreprise",
"general.user.deleted": "Compte supprimé",
diff --git a/tdrive/frontend/public/locales/ru.json b/tdrive/frontend/public/locales/ru.json
index 14ae7857..b0b46beb 100644
--- a/tdrive/frontend/public/locales/ru.json
+++ b/tdrive/frontend/public/locales/ru.json
@@ -184,6 +184,11 @@
"general.resume": "Продолжить",
"general.send": "Отправить сообщение",
"general.update": "Обновить",
+ "general.uploading": "Загрузка",
+ "general.uploaded": "Загружено",
+ "general.close": "Закрыть",
+ "general.files": "файлы",
+ "general.upload_failed": "Загрузка не удалась",
"general.user.anonymous": "Анонимный",
"general.user.deactivated": "Пользователь больше не состоит в этой компании",
"general.user.deleted": "Удаленная учетная запись",
diff --git a/tdrive/frontend/public/locales/vi.json b/tdrive/frontend/public/locales/vi.json
index d56444b3..3ed8e1db 100644
--- a/tdrive/frontend/public/locales/vi.json
+++ b/tdrive/frontend/public/locales/vi.json
@@ -155,6 +155,7 @@
"general.add": "Thêm",
"general.back": "Quay lại",
"general.cancel": "Hủy",
+ "general.close": "Đóng",
"general.connexion_status.connected": "Bạn đang trực tuyến",
"general.connexion_status.connecting": "Đang kết nối...",
"general.connexion_status.disconnected": "Bạn đang ngoại tuyến",
@@ -166,6 +167,10 @@
"general.resume": "Tiếp tục",
"general.send": "Gửi",
"general.update": "Cập nhật",
+ "general.uploading": "Đang tải lên",
+ "general.uploaded": "Đã tải lên",
+ "general.files": "tệp",
+ "general.upload_failed": "Tải lên thất bại",
"general.user.anonymous": "Vô danh",
"general.user.deactivated": "Người dùng không còn thuộc công ty này nữa",
"general.user.deleted": "Tài khoản đã bị xóa",
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-down.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-down.svg
new file mode 100644
index 00000000..6d833db8
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-down.svg
@@ -0,0 +1,3 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-up.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-up.svg
new file mode 100644
index 00000000..29717e7c
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-up.svg
@@ -0,0 +1,3 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/check-green.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/check-green.svg
new file mode 100644
index 00000000..fd3a232d
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/check-green.svg
@@ -0,0 +1,3 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/folder.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/folder.svg
new file mode 100644
index 00000000..89279d31
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/folder.svg
@@ -0,0 +1,3 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-cancel.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-cancel.svg
new file mode 100644
index 00000000..62f810e6
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-cancel.svg
@@ -0,0 +1,3 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-pause.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-pause.svg
new file mode 100644
index 00000000..22d9c8dc
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-pause.svg
@@ -0,0 +1,3 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-resume.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-resume.svg
new file mode 100644
index 00000000..f88c327d
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-resume.svg
@@ -0,0 +1,4 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-show-folder.svg b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-show-folder.svg
new file mode 100644
index 00000000..81c03f8e
--- /dev/null
+++ b/tdrive/frontend/src/app/atoms/icons-colored/assets/icon-show-folder.svg
@@ -0,0 +1,11 @@
+
diff --git a/tdrive/frontend/src/app/atoms/icons-colored/index.tsx b/tdrive/frontend/src/app/atoms/icons-colored/index.tsx
index bdd6178f..67deb4be 100644
--- a/tdrive/frontend/src/app/atoms/icons-colored/index.tsx
+++ b/tdrive/frontend/src/app/atoms/icons-colored/index.tsx
@@ -10,6 +10,14 @@ import { ReactComponent as FileTypeUnknownSvg } from './assets/file-type-unknown
import { ReactComponent as FileTypeMediaSvg } from './assets/file-type-media.svg';
import { ReactComponent as FileTypeSlidesSvg } from './assets/file-type-slides.svg';
import { ReactComponent as FileTypeLinkSvg } from './assets/file-type-link.svg';
+import { ReactComponent as FolderSvg } from './assets/folder.svg';
+import { ReactComponent as ArrowDownSvg } from './assets/arrow-down.svg';
+import { ReactComponent as ArrowUpSvg } from './assets/arrow-up.svg';
+import { ReactComponent as CheckGreenSvg } from './assets/check-green.svg';
+import { ReactComponent as ShowFolderSvg } from './assets/icon-show-folder.svg';
+import { ReactComponent as ResumeSvg } from './assets/icon-resume.svg';
+import { ReactComponent as PauseSvg } from './assets/icon-pause.svg';
+import { ReactComponent as CancelSvg } from './assets/icon-cancel.svg';
import { ReactComponent as RemoveSvg } from './assets/remove.svg';
import { ReactComponent as SentSvg } from './assets/sent.svg';
@@ -37,6 +45,22 @@ export const FileTypeSlidesIcon = (props: ComponentProps<'svg'>) => (
export const FileTypeLinkIcon = (props: ComponentProps<'svg'>) => ;
+export const FolderIcon = (props: ComponentProps<'svg'>) => ;
+
+export const ArrowDownIcon = (props: ComponentProps<'svg'>) => ;
+
+export const ArrowUpIcon = (props: ComponentProps<'svg'>) => ;
+
+export const CheckGreenIcon = (props: ComponentProps<'svg'>) => ;
+
+export const ShowFolderIcon = (props: ComponentProps<'svg'>) => ;
+
+export const ResumeIcon = (props: ComponentProps<'svg'>) => ;
+
+export const PauseIcon = (props: ComponentProps<'svg'>) => ;
+
+export const CancelIcon = (props: ComponentProps<'svg'>) => ;
+
export const RemoveIcon = (props: ComponentProps<'svg'>) => ;
export const SentIcon = (props: ComponentProps<'svg'>) => ;
diff --git a/tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-file-row.tsx b/tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-file-row.tsx
deleted file mode 100644
index 6002c519..00000000
--- a/tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-file-row.tsx
+++ /dev/null
@@ -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 (
-
-
-
- {pendingFile?.originalFile?.name ? (
-
-
- {capitalize(pendingFile?.originalFile.name)}
-
- {isPendingFileStatusPause(pendingFile.status) && (
-
- ({Languages.t('general.paused')})
-
- )}
-
- ) : (
-
- )}
- {pendingFile?.label ? (
-
-
- {pendingFile?.label}
-
-
- ) : (
-
- )}
-
-
-
- {pendingFileState.id ? (
- !isPendingFileStatusSuccess(pendingFileState.status) &&
- !isPendingFileStatusError(pendingFileState.status) ? (
-
-
- ) : (
-
- )
- }
- onClick={() => pauseOrResumeUpload(pendingFileState.id)}
- style={{
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- }}
- className="testid:button-toggle-tooltip-status"
- />
-
- ) : (
-
- )
- ) : (
-
- )}
-
-
-
- {!isPendingFileStatusSuccess(pendingFileState.status) &&
- !isPendingFileStatusError(pendingFileState.status) ? (
-
- }
- onClick={() => cancelUpload(pendingFileState.id)}
- style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}
- className="testid:button-toggle-tooltip-cancel"
- />
-
- ) : (
-
- )}
-
-
-
-
-
- );
-};
diff --git a/tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-files-list.tsx b/tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-files-list.tsx
deleted file mode 100644
index 3d3d3dd8..00000000
--- a/tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-files-list.tsx
+++ /dev/null
@@ -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(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 ? (
-
- setHiddenPendingFiles(!hiddenPendingFiles)}
- >
-
-
-
- {currentTask.total > 0 && `${currentTask.uploaded}/${currentTask.total} `}
- {Languages.t('components.drive_dropzone.uploading')}
-
-
-
- {hiddenPendingFiles ? : }
-
-
-
- {!hiddenPendingFiles && (
-
-
-
-
- {handleTimeChange()}
-
-
-
- <>
- {pendingFilesState.length > 0 &&
- pendingFilesState.map((pendingFileState, index) => (
-
- ))}
- >
-
-
- )}
-
- ) : (
- <>>
- );
-};
diff --git a/tdrive/frontend/src/app/components/file-uploads/pending-file-components/styles.scss b/tdrive/frontend/src/app/components/file-uploads/pending-file-components/styles.scss
deleted file mode 100644
index 01b654a6..00000000
--- a/tdrive/frontend/src/app/components/file-uploads/pending-file-components/styles.scss
+++ /dev/null
@@ -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;
- }
-}
diff --git a/tdrive/frontend/src/app/components/file-uploads/pending-root-components/file-type-icon-map.tsx b/tdrive/frontend/src/app/components/file-uploads/pending-root-components/file-type-icon-map.tsx
new file mode 100644
index 00000000..8df50d0e
--- /dev/null
+++ b/tdrive/frontend/src/app/components/file-uploads/pending-root-components/file-type-icon-map.tsx
@@ -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': ,
+ 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': (
+
+ ),
+ 'application/msword': ,
+ 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ,
+ 'application/vnd.ms-excel': ,
+ 'application/vnd.ms-powerpoint': ,
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation': (
+
+ ),
+ 'application/zip': ,
+ 'application/x-rar-compressed': ,
+ 'application/x-tar': ,
+ 'application/x-7z-compressed': ,
+ 'application/x-bzip': ,
+ 'application/x-bzip2': ,
+ 'application/x-gzip': ,
+ 'video/mp4': ,
+ 'video/mpeg': ,
+ 'video/ogg': ,
+ 'video/webm': ,
+ 'video/quicktime': ,
+};
diff --git a/tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-list.tsx b/tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-list.tsx
new file mode 100644
index 00000000..2b05d71f
--- /dev/null
+++ b/tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-list.tsx
@@ -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 = ({
+ uploadingCount,
+ completedCount,
+ totalRoots,
+ uploadingPercentage,
+ toggleModal,
+ modalExpanded,
+}) => (
+
+
+ {uploadingCount > 0
+ ? `${Languages.t('general.uploading')} ${uploadingCount}`
+ : `${Languages.t('general.uploaded')} ${completedCount}`}{' '}
+ {Languages.t('general.files')}
+
+
+
+);
+
+interface ModalFooterProps {
+ pauseOrResumeUpload: () => void;
+ cancelUpload: () => void;
+ isPaused: () => boolean;
+ uploadingCount: number;
+}
+
+const ModalFooter: React.FC = ({
+ pauseOrResumeUpload,
+ cancelUpload,
+ isPaused,
+ uploadingCount,
+}) => (
+
+
+ {uploadingCount > 0 && (
+
+ )}
+
+
+
+);
+
+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 && (
+
+
+
+ {modalExpanded && (
+
+
+
+ {keys.map(key => (
+
+ ))}
+
+
+
+
+ )}
+
+ )}
+ >
+ );
+};
+
+export default PendingRootList;
diff --git a/tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-row.tsx b/tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-row.tsx
new file mode 100644
index 00000000..1efbdb21
--- /dev/null
+++ b/tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-row.tsx
@@ -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] ||
+ ) : (
+
+ ),
+ [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 (
+
+
+
+
+
+ {itemTypeIcon(firstPendingFile?.type)}
+
+
+
+ {truncateRootName(rootKey)}
+ {root.status !== 'failed' && root.uploadedSize > 0 && (
+
+ ({formatFileSize(root.uploadedSize)} / {formatFileSize(root.size)})
+
+ )}
+ {root.status === 'failed' && (
+ {Languages.t('general.upload_failed')}
+ )}
+
+
+
+ {isUploadCompleted ? (
+
+ ) : (
+ !['cancelled', 'failed'].includes(root.status) &&
+ firstPendingFile?.status !== 'error' && (
+ <>
+
+
+ >
+ )
+ )}
+
+
+
+
+
+ {!showFolder && (
+
+ )}
+
+
+ );
+};
+
+export default PendingRootRow;
diff --git a/tdrive/frontend/src/app/components/file-uploads/uploads-viewer.tsx b/tdrive/frontend/src/app/components/file-uploads/uploads-viewer.tsx
index 552e4b45..9b2e8914 100644
--- a/tdrive/frontend/src/app/components/file-uploads/uploads-viewer.tsx
+++ b/tdrive/frontend/src/app/components/file-uploads/uploads-viewer.tsx
@@ -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 (
- 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 ;
};
-export default ChatUploadsViewer;
+export default UploadsViewer;
diff --git a/tdrive/frontend/src/app/components/search-popup/parts/drive-item-result.tsx b/tdrive/frontend/src/app/components/search-popup/parts/drive-item-result.tsx
index ca30c09f..28a8e06f 100644
--- a/tdrive/frontend/src/app/components/search-popup/parts/drive-item-result.tsx
+++ b/tdrive/frontend/src/app/components/search-popup/parts/drive-item-result.tsx
@@ -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 (
{history.push(RouterServices.generateRouteFromState({companyId: company, itemId: file.id})); openDoc(file)}}
+ onClick={() => {
+ history.push(
+ RouterServices.generateRouteFromState({ companyId: company, itemId: file.id }),
+ );
+ openDoc(file);
+ }}
>
@@ -96,14 +101,26 @@ export const FileResultMedia = (props: {
if (file.is_directory) {
return (
-
+
);
}
return (
-
+
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 (``) 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 },
});
}
});
diff --git a/tdrive/frontend/src/app/features/drive/api-client/api-client.ts b/tdrive/frontend/src/app/features/drive/api-client/api-client.ts
index 247bf292..fe4e562e 100644
--- a/tdrive/frontend/src/app/features/drive/api-client/api-client.ts
+++ b/tdrive/frontend/src/app/features/drive/api-client/api-client.ts
@@ -1,5 +1,13 @@
import Api from '../../global/framework/api-service';
-import { BrowseFilter, BrowsePaginate, BrowseQuery, BrowseSort, DriveItem, DriveItemDetails, DriveItemVersion } from '../types';
+import {
+ BrowseFilter,
+ BrowsePaginate,
+ BrowseQuery,
+ BrowseSort,
+ DriveItem,
+ DriveItemDetails,
+ DriveItemVersion,
+} from '../types';
import Workspace from '@deprecated/workspaces/workspaces';
import Logger from 'features/global/framework/logger-service';
import { JWTDataType } from 'app/features/auth/jwt-storage-service';
diff --git a/tdrive/frontend/src/app/features/drive/hooks/use-drive-upload.tsx b/tdrive/frontend/src/app/features/drive/hooks/use-drive-upload.tsx
index e29bfcf0..6f2e300e 100644
--- a/tdrive/frontend/src/app/features/drive/hooks/use-drive-upload.tsx
+++ b/tdrive/frontend/src/app/features/drive/hooks/use-drive-upload.tsx
@@ -10,18 +10,19 @@ import Logger from '@features/global/framework/logger-service';
* @returns
*/
export const useDriveUpload = () => {
- const { create, refresh } = useDriveActions();
+ const { create, refresh, restore } = useDriveActions();
- const logger = Logger.getLogger('useDriveUpload')
+ const logger = Logger.getLogger('useDriveUpload');
const uploadVersion = async (file: File, context: { companyId: string; id: string }) => {
return new Promise(r => {
- FileUploadService.upload([file], {
+ FileUploadService.upload([{ root: file.name, file }], {
context: {
companyId: context.companyId,
id: context.id,
},
- callback: async (file, context) => {
+ callback: async (filePayload, context) => {
+ const file = filePayload.file;
if (file) {
const version = {
drive_item_id: context.id,
@@ -47,58 +48,9 @@ export const useDriveUpload = () => {
tree: FileTreeObject,
context: { companyId: string; parentId: string },
) => {
- // Create all directories
- logger.debug("Start creating directories ...");
- const filesPerParentId = await FileUploadService.createDirectories(tree.tree, context);
+ logger.debug('Start creating directories and file upload ...');
+ await FileUploadService.createDirectories(tree, context);
await refresh(context.parentId, true);
- logger.debug("All directories created");
-
- // Upload files into directories
- logger.debug("Start file uploading")
- //create counter to calculate number of uploaded files, and refresh browsing window only when all the files were uploaded
- let expectedUploadsCount = 0;
- const parentFolder = context.parentId;
- let uploadedFilesCount = 0;
- for (const parentId of Object.keys(filesPerParentId)) {
- logger.debug(`Upload files for directory ${parentId}`);
- expectedUploadsCount += filesPerParentId[parentId].length;
- await FileUploadService.upload(filesPerParentId[parentId], {
- context: {
- companyId: context.companyId,
- parentId: parentId,
- },
- callback: (file, context) => {
- logger.debug('created file: ', file);
- uploadedFilesCount++;
- if (file) {
- create(
- {
- company_id: context.companyId,
- workspace_id: 'drive', //We don't set workspace ID for now
- parent_id: context.parentId,
- name: file.metadata?.name,
- size: file.upload_data?.size,
- },
- {
- provider: 'internal',
- application_id: '',
- file_metadata: {
- name: file.metadata?.name,
- size: file.upload_data?.size,
- mime: file.metadata?.mime,
- thumbnails: file?.thumbnails,
- source: 'internal',
- external_id: file.id,
- },
- },
- );
- }
- if (uploadedFilesCount == expectedUploadsCount) {
- refresh(parentFolder, true);
- }
- },
- });
- }
};
const uploadFromUrl = (
@@ -112,14 +64,17 @@ export const useDriveUpload = () => {
request.onload = function () {
try {
if (request.status != 200)
- throw new Error(`Unexpected response status code: ${request.status} from ${JSON.stringify(url)}`);
+ throw new Error(
+ `Unexpected response status code: ${request.status} from ${JSON.stringify(url)}`,
+ );
const file = new File([request.response], name);
- FileUploadService.upload([file], {
+ FileUploadService.upload([{ root: file.name, file }], {
context: {
companyId: context.companyId,
parentId: context.parentId,
},
- callback: (file, context) => {
+ callback: (filePayload, context) => {
+ const file = filePayload.file;
if (file) {
create(
{
diff --git a/tdrive/frontend/src/app/features/files/hooks/use-exp-upload.ts b/tdrive/frontend/src/app/features/files/hooks/use-exp-upload.ts
new file mode 100644
index 00000000..4151580d
--- /dev/null
+++ b/tdrive/frontend/src/app/features/files/hooks/use-exp-upload.ts
@@ -0,0 +1,65 @@
+import RouterServices from '@features/router/services/router-service';
+import Resumable from '@features/files/utils/resumable';
+
+/* eslint-disable @typescript-eslint/no-explicit-any */
+type TreeItem = { [key: string]: { root: string; file: File } | TreeItem };
+
+export type FileTreeObject = {
+ tree: TreeItem;
+ documentsCount: number;
+ totalSize: number;
+};
+
+export const useUploadExp = () => {
+ const { companyId } = RouterServices.getStateFromRoute();
+
+ // Initialize a single Resumable instance
+ const resumable = new Resumable({
+ target: `/upload/${companyId}`, // Example API endpoint, adjust as needed
+ chunkSize: 1 * 1024 * 1024, // 1 MB chunk size
+ simultaneousUploads: 3,
+ testChunks: false,
+ });
+
+ // Add files from the tree to the Resumable instance
+ const addFilesToResumable = (tree: TreeItem) => {
+ const traverseTree = (item: TreeItem) => {
+ Object.values(item).forEach(value => {
+ if ('file' in value && value.file instanceof File) {
+ resumable.addFile(value.file); // Add file to Resumable
+ } else {
+ traverseTree(value as TreeItem); // Recursively traverse nested items
+ }
+ });
+ };
+
+ traverseTree(tree);
+ };
+
+ // Start upload and log each file being uploaded
+ const startUpload = () => {
+ resumable.on('fileAdded', (file: any) => {
+ console.log(`Uploading file: ${file.fileName}`);
+ });
+
+ resumable.on('fileSuccess', (file: any, message: any) => {
+ console.log(`File uploaded successfully: ${file.fileName}`, message);
+ });
+
+ resumable.on('fileError', (file: any, error: any) => {
+ console.error(`Error uploading file: ${file.fileName}`, error);
+ });
+
+ resumable.upload(); // Start the upload
+ };
+
+ // Main uploadTree function
+ const uploadTree = (tree: FileTreeObject) => {
+ addFilesToResumable(tree.tree); // Add files from the tree
+ startUpload(); // Start uploading
+ };
+
+ return {
+ uploadTree,
+ };
+};
diff --git a/tdrive/frontend/src/app/features/files/hooks/use-upload-zones.ts b/tdrive/frontend/src/app/features/files/hooks/use-upload-zones.ts
index 343e85a5..658b1427 100644
--- a/tdrive/frontend/src/app/features/files/hooks/use-upload-zones.ts
+++ b/tdrive/frontend/src/app/features/files/hooks/use-upload-zones.ts
@@ -27,7 +27,15 @@ export const useUploadZones = (zoneId: string) => {
list: File[],
options?: { uploader?: (file: File, context: any) => Promise; context?: any },
) => {
- const newFiles = await FileUploadService.upload(list, options);
+ const newFiles = await FileUploadService.upload(
+ list.map((file, index) => {
+ return {
+ root: file.name,
+ file,
+ };
+ }),
+ options,
+ );
setFiles([
...files,
...newFiles.map(f => {
diff --git a/tdrive/frontend/src/app/features/files/hooks/use-upload.ts b/tdrive/frontend/src/app/features/files/hooks/use-upload.ts
index a03d50a1..3102c5a3 100644
--- a/tdrive/frontend/src/app/features/files/hooks/use-upload.ts
+++ b/tdrive/frontend/src/app/features/files/hooks/use-upload.ts
@@ -2,18 +2,25 @@ import FileUploadService from '@features/files/services/file-upload-service';
import RouterServices from '@features/router/services/router-service';
import { useRecoilState, useRecoilValue } from 'recoil';
import { PendingFilesListState } from '../state/atoms/pending-files-list';
+import { RootPendingFilesListState } from '../state/atoms/root-pending-files-list';
import { CurrentTaskSelector } from '../state/selectors/current-task';
export const useUpload = () => {
const { companyId } = RouterServices.getStateFromRoute();
- const [pendingFilesListState, setPendingFilesListState] = useRecoilState(PendingFilesListState);
- FileUploadService.setRecoilHandler(setPendingFilesListState);
+ const [pendingFilesListState, _setPendingFilesListState] = useRecoilState(PendingFilesListState);
+ const [_rootPendingFilesListState, setRootPendingFilesListState] =
+ useRecoilState(RootPendingFilesListState);
+ FileUploadService.setRecoilHandler(setRootPendingFilesListState);
const currentTask = useRecoilValue(CurrentTaskSelector);
- const pauseOrResumeUpload = (id: string) => FileUploadService.pauseOrResume(id);
+ const pauseOrResumeUpload = () => FileUploadService.pauseOrResume();
- const cancelUpload = (id: string) => FileUploadService.cancel(id);
+ const pauseOrResumeRootUpload = (id: string) => FileUploadService.pauseOrResumeRoot(id);
+
+ const cancelUpload = () => FileUploadService.cancelUpload();
+
+ const cancelRootUpload = (id: string) => FileUploadService.cancelRootUpload(id);
const getOnePendingFile = (id: string) => FileUploadService.getPendingFile(id);
@@ -23,13 +30,18 @@ export const useUpload = () => {
const retryUpload = (id: string) => FileUploadService.retry(id);
+ const clearRoots = () => FileUploadService.clearRoots();
+
return {
pendingFilesListState,
pauseOrResumeUpload,
+ pauseOrResumeRootUpload,
cancelUpload,
+ cancelRootUpload,
getOnePendingFile,
currentTask,
deleteOneFile,
retryUpload,
+ clearRoots,
};
};
diff --git a/tdrive/frontend/src/app/features/files/services/file-upload-service.ts b/tdrive/frontend/src/app/features/files/services/file-upload-service.ts
index df93111a..260a8d90 100644
--- a/tdrive/frontend/src/app/features/files/services/file-upload-service.ts
+++ b/tdrive/frontend/src/app/features/files/services/file-upload-service.ts
@@ -10,19 +10,46 @@ import RouterServices from '@features/router/services/router-service';
import _ from 'lodash';
import FileUploadAPIClient from '../api/file-upload-api-client';
import { isPendingFileStatusPending } from '../utils/pending-files';
-import { FileTreeObject } from "components/uploads/file-tree-utils";
-import { DriveApiClient } from "features/drive/api-client/api-client";
+import { FileTreeObject } from 'components/uploads/file-tree-utils';
+import { DriveApiClient } from 'features/drive/api-client/api-client';
import { ToasterService } from 'app/features/global/services/toaster-service';
import Languages from 'app/features/global/services/languages-service';
+import { DriveItem, DriveItemVersion } from 'app/features/drive/types';
export enum Events {
ON_CHANGE = 'notify',
}
+export enum UploadStateEnum {
+ Progress = 'progress',
+ Completed = 'completed',
+ Paused = 'paused',
+ Cancelled = 'cancelled',
+ Failed = 'failed',
+}
+
+type RootState = { [key: string]: boolean };
+
const logger = Logger.getLogger('Services/FileUploadService');
class FileUploadService {
private pendingFiles: PendingFileType[] = [];
+ private groupedPendingFiles: { [key: string]: PendingFileType[] } = {};
+ private rootSizes: { [key: string]: number } = {};
+ private groupIds: { [key: string]: string } = {};
+ private rootStates: {
+ paused: RootState;
+ cancelled: RootState;
+ completed: RootState;
+ failed: RootState;
+ } = {
+ paused: {},
+ cancelled: {},
+ completed: {},
+ failed: {},
+ };
public currentTaskId = '';
+ public parentId = '';
+ public uploadStatus = UploadStateEnum.Progress;
private recoilHandler: Function = () => undefined;
private logger: Logger.Logger = Logger.getLogger('FileUploadService');
@@ -30,28 +57,114 @@ class FileUploadService {
this.recoilHandler = handler;
}
+ /**
+ * Helper method to pause execution when `isPaused` is true.
+ * @private
+ */
+ async _waitWhilePaused(id?: string) {
+ while (this.uploadStatus === UploadStateEnum.Paused || (id && this.rootStates.paused[id])) {
+ if (this.uploadStatus === UploadStateEnum.Cancelled || (id && this.rootStates.cancelled[id]))
+ return;
+ await new Promise(resolve => setTimeout(resolve, 100)); // Check every 100ms
+ }
+ }
+
+ /**
+ * Helper method to cancel execution when `isCancelled` is true.
+ * @private
+ */
+ private async checkCancellation(id?: string) {
+ if (this.uploadStatus === UploadStateEnum.Cancelled || (id && this.rootStates.cancelled[id])) {
+ logger.warn('Operation cancelled.');
+ throw new Error('Upload process cancelled.');
+ }
+ }
+
notify() {
- const updatedState = this.pendingFiles.map((f: PendingFileType) => {
- return {
- id: f.id,
- status: f.status,
- progress: f.progress,
- file: f.backendFile,
+ const updatedState = Object.keys(this.groupedPendingFiles).reduce((acc: any, key: string) => {
+ // Calculate the uploaded size
+ const uploadedSize = this.groupedPendingFiles[key]
+ .map((f: PendingFileType) =>
+ f.status === 'success' && f.originalFile?.size ? f.originalFile.size : 0,
+ )
+ .reduce((acc: number, size: number) => acc + size, 0);
+
+ // Check for failed files
+ const failedFiles = this.groupedPendingFiles[key].filter(f => f.status === 'error');
+ if (failedFiles.length > 0) {
+ this.rootStates.failed[key] = true;
+ }
+
+ // Determine the upload status based on failed, cancelled, paused, completed, or uploading states
+ const status = this.rootStates.failed[key]
+ ? 'failed'
+ : this.rootStates.cancelled[key]
+ ? 'cancelled'
+ : this.rootStates.paused[key]
+ ? 'paused'
+ : uploadedSize === this.rootSizes[key]
+ ? 'completed'
+ : 'uploading';
+
+ if (status === 'completed') {
+ this.rootStates.completed[key] = true;
+ }
+
+ // Add to the accumulator object
+ acc[key] = {
+ id: this.groupIds[key],
+ items: [],
+ size: this.rootSizes[key],
+ uploadedSize,
+ status,
};
- });
+
+ return acc;
+ }, {});
+
this.recoilHandler(_.cloneDeep(updatedState));
}
- public async createDirectories(root: FileTreeObject['tree'], context: { companyId: string; parentId: string }) {
- // Create all directories
- const filesPerParentId: { [key: string]: File[] } = {};
- filesPerParentId[context.parentId] = []
+ public async createDirectories(
+ tree: FileTreeObject,
+ context: { companyId: string; parentId: string },
+ ) {
+ // reset the upload status
+ this.uploadStatus = UploadStateEnum.Progress;
- const traverserTreeLevel = async (tree: FileTreeObject['tree'], parentId: string) => {
+ const root = tree.tree;
+ this.rootSizes = this.rootSizes = {
+ ...this.rootSizes,
+ ...(tree.sizePerRoot || {}),
+ };
+ // Create all directories
+ const filesPerParentId: { [key: string]: { root: string; file: File }[] } = {};
+ filesPerParentId[context.parentId] = [];
+ const idsToBeRestored: string[] = [];
+
+ const traverserTreeLevel = async (
+ tree: FileTreeObject['tree'],
+ parentId: string,
+ tmp = false,
+ ) => {
+ // cancel upload
+ if (this.uploadStatus === UploadStateEnum.Cancelled) return;
+
+ // check if upload is paused
+ await this._waitWhilePaused();
+
+ // start descending the tree
for (const directory of Object.keys(tree)) {
- if (tree[directory] instanceof File) {
- logger.trace(`${directory} is a file, save it for future upload`);
- filesPerParentId[parentId].push(tree[directory] as File);
+ const root = tree[directory].root as string;
+ await this.checkCancellation(root);
+ await this._waitWhilePaused(root);
+ if (tree[directory].file instanceof File) {
+ const file = tree[directory].file as File;
+ console.log(`Adding file: ${file.name} under parentId: ${parentId}`);
+ filesPerParentId[parentId].push({
+ root: tree[directory].root as string,
+ file,
+ });
} else {
logger.debug(`Create directory ${directory}`);
@@ -60,6 +173,7 @@ class FileUploadService {
parent_id: parentId,
name: directory,
is_directory: true,
+ is_in_trash: tmp,
};
if (!this.pendingFiles.some(f => isPendingFileStatusPending(f.status))) {
@@ -77,39 +191,104 @@ class FileUploadService {
backendFile: null,
resumable: null,
label: directory,
- type: "file",
+ type: 'file',
pausable: false,
};
- this.pendingFiles.push(pendingFile);
- this.notify();
-
try {
- const driveItem = await DriveApiClient.create(context.companyId, { item: item, version: {}});
+ const driveItem = await DriveApiClient.create(context.companyId, {
+ item: item,
+ version: {},
+ });
+ this.groupIds[directory] = driveItem.id;
this.logger.debug(`Directory ${directory} created`);
pendingFile.status = 'success';
this.notify();
if (driveItem?.id) {
- filesPerParentId[driveItem.id] = []
+ filesPerParentId[driveItem.id] = [];
+ if (tmp && !idsToBeRestored.includes(driveItem.id))
+ idsToBeRestored.push(driveItem.id);
await traverserTreeLevel(tree[directory] as FileTreeObject['tree'], driveItem.id);
}
} catch (e) {
this.logger.error(e);
- throw new Error('Could not create directory');
}
}
}
+ // uploading the files goes here
+ const files = _.cloneDeep(filesPerParentId[parentId]);
+ // reset the filesPerParentId
+ filesPerParentId[parentId] = [];
+ await this.upload(files, {
+ context: {
+ companyId: context.companyId,
+ parentId: parentId,
+ },
+ callback: async (filePayload, context) => {
+ const isFileRoot = filePayload.root.includes('.');
+ const root = filePayload.root;
+ const file = filePayload.file;
+ if (file) {
+ const item = {
+ company_id: context.companyId,
+ workspace_id: 'drive', //We don't set workspace ID for now
+ parent_id: context.parentId,
+ name: file.metadata?.name,
+ size: file.upload_data?.size,
+ } as Partial;
+ const version = {
+ provider: 'internal',
+ application_id: '',
+ file_metadata: {
+ name: file.metadata?.name,
+ size: file.upload_data?.size,
+ mime: file.metadata?.mime,
+ thumbnails: file?.thumbnails,
+ source: 'internal',
+ external_id: file.id,
+ },
+ } as Partial;
+
+ // create the document
+ const documentId = await DriveApiClient.create(context.companyId, { item, version });
+ // assign the group id with the document id
+ if (isFileRoot) {
+ this.groupIds[root] = documentId.id;
+ // set the id for the root
+ this.notify();
+ }
+ }
+ },
+ });
+ };
+
+ // split the tree per root
+ const rootKeys = Object.keys(root);
+ const rootTrees = rootKeys.map(key => {
+ return { [key]: root[key] };
+ });
+
+ // tree promises
+ const treePromises = rootTrees.map(tree => {
+ return traverserTreeLevel(tree, context.parentId, true);
+ });
+
+ try {
+ await Promise.all(treePromises);
+ } catch (error) {
+ logger.error('Error while processing tree', error);
}
- await traverserTreeLevel(root, context.parentId);
- return filesPerParentId;
+ // await traverserTreeLevel(root, context.parentId, true);
+
+ return { filesPerParentId, idsToBeRestored };
}
public async upload(
- fileList: File[],
+ fileList: { root: string; file: File }[],
options?: {
context?: any;
- callback?: (file: FileType | null, context: any) => void;
+ callback?: (file: { root: string; file: FileType | null }, context: any) => void;
},
): Promise {
const { companyId } = RouterServices.getStateFromRoute();
@@ -125,7 +304,11 @@ class FileUploadService {
}
for (const file of fileList) {
- if (!file) continue;
+ // cancel upload
+ await this.checkCancellation(file.root);
+ // wait here if the upload is paused
+ await this._waitWhilePaused(file.root);
+ if (!file.file) continue;
const pendingFile: PendingFileType = {
id: uuid(),
@@ -134,21 +317,24 @@ class FileUploadService {
lastProgress: new Date().getTime(),
speed: 0,
uploadTaskId: this.currentTaskId,
- originalFile: file,
+ originalFile: file.file,
backendFile: null,
resumable: null,
- type: "file",
+ type: 'file',
label: null,
- pausable: true
+ pausable: true,
};
this.pendingFiles.push(pendingFile);
-
+ if (!this.groupedPendingFiles[file.root]) {
+ this.groupedPendingFiles[file.root] = [];
+ }
+ this.groupedPendingFiles[file.root].push(pendingFile);
this.notify();
// First we create the file object
const resource = (
- await FileUploadAPIClient.upload(file, { companyId, ...(options?.context || {}) })
+ await FileUploadAPIClient.upload(file.file, { companyId, ...(options?.context || {}) })
)?.resource;
if (!resource) {
@@ -173,7 +359,7 @@ class FileUploadService {
},
});
- pendingFile.resumable.addFile(file);
+ pendingFile.resumable.addFile(file.file);
pendingFile.resumable.on('fileAdded', () => pendingFile.resumable.upload());
@@ -197,8 +383,10 @@ class FileUploadService {
try {
pendingFile.backendFile = JSON.parse(message).resource;
pendingFile.status = 'success';
- console.log('fileSuccess', options?.callback);
- options?.callback?.(pendingFile.backendFile, options?.context || {});
+ options?.callback?.(
+ { root: file.root, file: pendingFile.backendFile },
+ options?.context || {},
+ );
this.notify();
} catch (e) {
logger.error(`Error on fileSuccess Event`, e);
@@ -208,10 +396,10 @@ class FileUploadService {
pendingFile.resumable.on('fileError', () => {
pendingFile.status = 'error';
pendingFile.resumable.cancel();
- const intendedFilename = (pendingFile.originalFile || {}).name || (pendingFile.backendFile || { metadata: {}}).metadata.name;
- ToasterService.error(Languages.t('services.file_upload_service.toaster.upload_file_error', [intendedFilename],
- 'Error uploading file ' + intendedFilename));
- options?.callback?.(null, options?.context || {});
+ const intendedFilename =
+ (pendingFile.originalFile || {}).name ||
+ (pendingFile.backendFile || { metadata: {} }).metadata.name;
+ options?.callback?.({ root: file.root, file: null }, options?.context || {});
this.notify();
});
}
@@ -237,26 +425,89 @@ class FileUploadService {
return this.pendingFiles.filter(f => f.backendFile?.id && f.backendFile.id === id)[0];
}
- public cancel(id: string, timeout = 1000) {
- const fileToCancel = this.pendingFiles.filter(f => f.id === id)[0];
+ public cancelUpload() {
+ this.uploadStatus = UploadStateEnum.Cancelled;
- fileToCancel.status = 'cancel';
+ // pause or resume the resumable tasks
+ const fileToCancel = this.pendingFiles;
- if (fileToCancel.resumable) {
- fileToCancel.resumable.cancel();
- this.notify();
-
- if (fileToCancel.backendFile)
- this.deleteOneFile({
- companyId: fileToCancel.backendFile.company_id,
- fileId: fileToCancel.backendFile.id,
- });
+ if (!fileToCancel) {
+ console.error(`No files found for id`);
+ return;
}
- setTimeout(() => {
- this.pendingFiles = this.pendingFiles.filter(f => f.id !== id);
+ for (const file of fileToCancel) {
+ if (file.status === 'success') continue;
+
+ try {
+ if (file.resumable) {
+ file.resumable.cancel();
+ if (file.backendFile)
+ this.deleteOneFile({
+ companyId: file.backendFile.company_id,
+ fileId: file.backendFile.id,
+ });
+ } else {
+ console.warn('Resumable object is not available for file', file);
+ }
+ } catch (error) {
+ console.error('Error while pausing or resuming file', file, error);
+ }
+ }
+
+ // clean everything
+ this.pendingFiles = [];
+ this.groupedPendingFiles = {};
+ this.rootSizes = {};
+ this.groupIds = {};
+
+ this.notify();
+ }
+
+ public cancelRootUpload(id: string) {
+ this.rootStates.cancelled[id] = true;
+ // if it's 1 root, cancel the upload
+ if (Object.keys(this.groupedPendingFiles).length === 1) {
+ this.cancelUpload();
+ return;
+ } else {
+ // pause or resume the resumable tasks
+ const filesToProcess = this.groupedPendingFiles[id];
+
+ if (!filesToProcess || filesToProcess.length === 0) {
+ console.error(`No files found for id: ${id}`);
+ return;
+ }
+
+ for (const file of filesToProcess) {
+ if (file.status === 'success') continue;
+
+ try {
+ if (file.resumable) {
+ file.resumable.cancel();
+ if (file.backendFile)
+ this.deleteOneFile({
+ companyId: file.backendFile.company_id,
+ fileId: file.backendFile.id,
+ });
+ } else {
+ console.warn('Resumable object is not available for file', file);
+ }
+ } catch (error) {
+ console.error('Error while pausing or resuming file', file, error);
+ }
+ }
+
+ // clean everything
+ this.pendingFiles = this.pendingFiles.filter(f => f.uploadTaskId !== id);
+ // remove the root key
+ this.groupedPendingFiles[id] = [];
+ // remove the root size
+ delete this.rootSizes[id];
+ // remove the root id
+ delete this.groupIds[id];
this.notify();
- }, timeout);
+ }
}
public retry(id: string) {
@@ -270,15 +521,101 @@ class FileUploadService {
}
}
- public pauseOrResume(id: string) {
- const fileToCancel = this.pendingFiles.filter(f => f.id === id)[0];
+ private pauseOrResumeFile(file: PendingFileType) {
+ try {
+ if (file.resumable) {
+ if (file.status !== 'pause') {
+ file.status = 'pause';
+ file.resumable.pause();
+ } else {
+ file.status = 'pending';
+ file.resumable.upload();
+ }
+ } else {
+ console.warn('Resumable object is not available for file', file);
+ }
+ } catch (error) {
+ console.error('Error while pausing or resuming file', file, error);
+ }
+ }
- fileToCancel.status !== 'pause'
- ? (fileToCancel.status = 'pause')
- : (fileToCancel.status = 'pending');
- fileToCancel.status === 'pause'
- ? fileToCancel.resumable.pause()
- : fileToCancel.resumable.upload();
+ public pauseOrResume() {
+ // pause or resume the curent upload task
+ switch (this.uploadStatus) {
+ case UploadStateEnum.Progress:
+ this.uploadStatus = UploadStateEnum.Paused;
+ break;
+ case UploadStateEnum.Paused:
+ this.uploadStatus = UploadStateEnum.Progress;
+ break;
+ case UploadStateEnum.Cancelled:
+ throw new Error('Cannot toggle upload status: Upload is cancelled.');
+ default:
+ throw new Error(`Unexpected upload status: ${this.uploadStatus}`);
+ }
+
+ // pause or resume the resumable tasks
+ const filesToProcess = this.pendingFiles;
+
+ if (!filesToProcess || filesToProcess.length === 0) {
+ console.error(`No files found for id`);
+ return;
+ }
+
+ for (const file of filesToProcess) {
+ if (file.status === 'success') continue;
+ this.pauseOrResumeFile(file);
+ }
+
+ // update the status of the roots
+ const roots = Object.keys(this.groupedPendingFiles);
+ for (const root of roots) {
+ // check if the root has completed skip it
+ if (this.rootStates.completed[root]) continue;
+ if (this.uploadStatus === UploadStateEnum.Paused) {
+ this.rootStates.paused[root] = true;
+ } else {
+ this.rootStates.paused[root] = false;
+ }
+ }
+
+ this.notify();
+ }
+
+ public pauseOrResumeRoot(id: string) {
+ const completedRoots = Object.keys(this.rootStates.completed);
+ const roots = Object.keys(this.rootSizes);
+ const isOnlyRootInProgress = roots.length - completedRoots.length === 1;
+
+ // Check if this is the only root in progress
+ if (!this.rootStates.completed[id] && isOnlyRootInProgress) {
+ this.uploadStatus =
+ this.uploadStatus === UploadStateEnum.Progress
+ ? UploadStateEnum.Paused
+ : UploadStateEnum.Progress;
+ }
+
+ // set the pause status for the root
+ if (Object.keys(this.rootStates.paused).includes(id)) {
+ this.rootStates.paused[id] = !this.rootStates.paused[id];
+ } else {
+ this.rootStates.paused[id] = true;
+ }
+
+ // pause or resume the resumable tasks
+ const filesToProcess = this.groupedPendingFiles[id];
+
+ if (!filesToProcess || filesToProcess.length === 0) {
+ console.error(`No files found for id: ${id}`);
+ return;
+ }
+
+ for (const file of filesToProcess) {
+ if (file.status === 'success') continue;
+
+ // pause or resume the file
+ this.pauseOrResumeFile(file);
+ }
this.notify();
}
@@ -307,6 +644,7 @@ class FileUploadService {
testChunks: testChunks || false,
simultaneousUploads: simultaneousUploads || 5,
maxChunkRetries: maxChunkRetries || 2,
+ xhrTimeout: 60000,
query,
});
}
@@ -335,13 +673,18 @@ class FileUploadService {
});
}
-
public getDownloadRoute({ companyId, fileId }: { companyId: string; fileId: string }): string {
return FileUploadAPIClient.getDownloadRoute({
companyId: companyId,
fileId: fileId,
});
}
+
+ public clearRoots() {
+ this.groupedPendingFiles = {};
+ this.groupIds = {};
+ this.notify();
+ }
}
export default new FileUploadService();
diff --git a/tdrive/frontend/src/app/features/files/state/atoms/root-pending-files-list.ts b/tdrive/frontend/src/app/features/files/state/atoms/root-pending-files-list.ts
new file mode 100644
index 00000000..e0a2c2c0
--- /dev/null
+++ b/tdrive/frontend/src/app/features/files/state/atoms/root-pending-files-list.ts
@@ -0,0 +1,18 @@
+import { atom } from 'recoil';
+import { PendingFileRecoilType } from '@features/files/types/file';
+
+export const RootPendingFilesListState = atom<
+ | {
+ [key: string]: {
+ id: string;
+ size: number;
+ uploadedSize: number;
+ status: string;
+ items: PendingFileRecoilType[];
+ };
+ }
+ | undefined
+>({
+ key: 'RootPendingFilesListState',
+ default: {},
+});
diff --git a/tdrive/frontend/src/app/features/files/state/selectors/current-task.ts b/tdrive/frontend/src/app/features/files/state/selectors/current-task.ts
index 0ff96e3b..df4b41f6 100644
--- a/tdrive/frontend/src/app/features/files/state/selectors/current-task.ts
+++ b/tdrive/frontend/src/app/features/files/state/selectors/current-task.ts
@@ -1,11 +1,13 @@
import FileUploadService from '@features/files/services/file-upload-service';
import { selector } from 'recoil';
import { PendingFilesListState } from '../atoms/pending-files-list';
+import { RootPendingFilesListState } from '../atoms/root-pending-files-list';
export const CurrentTaskSelector = selector({
key: 'CurrentTaskFilesSelector',
get: ({ get }) => {
const list = get(PendingFilesListState);
+ const rootList = get(RootPendingFilesListState) || {};
const currentTaskFiles = list
? list.filter(
@@ -16,8 +18,11 @@ export const CurrentTaskSelector = selector({
: [];
return {
+ parentId: FileUploadService.parentId,
+ roots: rootList,
files: currentTaskFiles,
total: currentTaskFiles.length,
+ status: FileUploadService.uploadStatus,
uploaded: currentTaskFiles.filter(f => f.status === 'success').length,
completed: currentTaskFiles.every(f => f.status === 'success'),
};
diff --git a/tdrive/frontend/src/app/features/files/types/file.ts b/tdrive/frontend/src/app/features/files/types/file.ts
index db4ddca0..a2dfeaea 100644
--- a/tdrive/frontend/src/app/features/files/types/file.ts
+++ b/tdrive/frontend/src/app/features/files/types/file.ts
@@ -57,6 +57,7 @@ export type FileType = {
upload_data: FileUploadDataObjectType;
user_id: string;
user?: UserType;
+ parent_id?: string;
};
export type PendingFileRecoilType = {
@@ -64,13 +65,16 @@ export type PendingFileRecoilType = {
status: 'pending' | 'error' | 'success' | 'pause' | 'cancel';
progress: number; //Between 0 and 1
file: FileType | null;
+ type: string;
+ parentId: string | null;
+ size: number;
};
/**
* It could be not only a file, but also a task with creating folders
*/
export type PendingFileType = {
- type: "file" | "folder"
+ type: 'file' | 'folder';
resumable: typeof Resumable | null; //Contain the resumable instance in charge of this file
uploadTaskId: string;
id: string;
@@ -83,3 +87,15 @@ export type PendingFileType = {
label: string | null;
pausable: boolean;
};
+
+export type UploadRootType = {
+ id: string;
+ size: number;
+ uploadedSize: number;
+ status: string;
+ items: PendingFileRecoilType[];
+};
+
+export type UploadRootListType = {
+ [key: string]: UploadRootType;
+};
diff --git a/tdrive/frontend/src/app/features/files/utils/resumable.js b/tdrive/frontend/src/app/features/files/utils/resumable.js
index ac456e53..cb3c4633 100755
--- a/tdrive/frontend/src/app/features/files/utils/resumable.js
+++ b/tdrive/frontend/src/app/features/files/utils/resumable.js
@@ -97,7 +97,7 @@ window.define =
},
minFileSize: 1,
minFileSizeErrorCallback: function (file, errorCount) {
- alert(
+ console.log(
file.fileName ||
file.name +
' is too small, please upload files larger than ' +
diff --git a/tdrive/frontend/src/app/views/client/body/drive/browser.tsx b/tdrive/frontend/src/app/views/client/body/drive/browser.tsx
index 403a4062..0d658233 100644
--- a/tdrive/frontend/src/app/views/client/body/drive/browser.tsx
+++ b/tdrive/frontend/src/app/views/client/body/drive/browser.tsx
@@ -21,14 +21,14 @@ import {
useOnBuildDateContextMenu,
useOnBuildSortContextMenu,
} from './context-menu';
-import {DocumentRow, DocumentRowOverlay} from './documents/document-row';
+import { DocumentRow, DocumentRowOverlay } from './documents/document-row';
import { FolderRow } from './documents/folder-row';
import { FolderRowSkeleton } from './documents/folder-row-skeleton';
import HeaderPath from './header-path';
import { ConfirmDeleteModal } from './modals/confirm-delete';
import { ConfirmTrashModal } from './modals/confirm-trash';
import { CreateModalAtom } from './modals/create';
-import { UploadModelAtom } from './modals/upload'
+import { UploadModelAtom } from './modals/upload';
import { PropertiesModal } from './modals/properties';
import { AccessModal } from './modals/update-access';
import { PublicLinkModal } from './modals/public-link';
@@ -49,10 +49,11 @@ import { useCurrentUser } from 'app/features/users/hooks/use-current-user';
import { ConfirmModal } from './modals/confirm-move';
import { useHistory } from 'react-router-dom';
import { SortIcon } from 'app/atoms/icons-agnostic';
+import { useUploadExp } from 'app/features/files/hooks/use-exp-upload';
export const DriveCurrentFolderAtom = atomFamily<
- string,
- { context?: string; initialFolderId: string }
+ string,
+ { context?: string; initialFolderId: string }
>({
key: 'DriveCurrentFolderAtom',
default: options => options.initialFolderId || 'root',
@@ -109,6 +110,7 @@ export default memo(
paginateItem,
} = useDriveItem(parentId);
const { uploadTree } = useDriveUpload();
+ const { uploadTree: _uploadTree } = useUploadExp();
const loading = loadingParent || loadingParentChange;
@@ -309,6 +311,8 @@ export default memo(
}
}, [paginateItem, loading, parentId, itemsPerPage]);
+ const [isPreparingUpload, setIsPreparingUpload] = useState(false);
+
return (
<>
{viewId == 'shared-with-me' ? (
@@ -329,12 +333,16 @@ export default memo(
ref={uploadZoneRef}
driveCollectionKey={uploadZone}
onAddFiles={async (_, event) => {
+ setIsPreparingUpload(true);
const tree = await getFilesTree(event);
+ setIsPreparingUpload(false);
setCreationModalState({ parent_id: '', open: false });
- uploadTree(tree, {
+ await uploadTree(tree, {
companyId,
parentId,
});
+ await new Promise (resolve => setTimeout(resolve, 1000));
+ refresh(parentId);
}}
onDragOver={handleDragOver}
onDrop={handleDrop}
@@ -358,7 +366,11 @@ export default memo(
(loading && (!items?.length || loadingParentChange) ? 'opacity-50 ' : '')
}
>
-
+
{sharedWithMe ? (
@@ -376,7 +388,7 @@ export default memo(
{ x: evt.clientX, y: evt.clientY },
'center',
undefined,
- "browser-share-with-me-menu-file-type"
+ 'browser-share-with-me-menu-file-type',
);
}}
testClassId="button-open-menu-file-type"
@@ -399,7 +411,7 @@ export default memo(
{ x: evt.clientX, y: evt.clientY },
'center',
undefined,
- "browser-share-with-me-menu-people"
+ 'browser-share-with-me-menu-people',
);
}}
testClassId="button-open-menu-people"
@@ -419,7 +431,7 @@ export default memo(
{ x: evt.clientX, y: evt.clientY },
'center',
undefined,
- "browser-share-with-me-menu-last-modified"
+ 'browser-share-with-me-menu-last-modified',
);
}}
testClassId="button-open-menu-last-modified"
@@ -450,9 +462,17 @@ export default memo(
)}
-