From 6002974306eca267c00da8cd792ecb2822c33847 Mon Sep 17 00:00:00 2001 From: Montassar Ghanmy Date: Tue, 4 Feb 2025 15:56:07 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Big=20folder=20upload=20(#780)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../documents/web/controllers/documents.ts | 2 +- tdrive/frontend/public/locales/en.json | 5 + tdrive/frontend/public/locales/fr.json | 5 + tdrive/frontend/public/locales/ru.json | 5 + tdrive/frontend/public/locales/vi.json | 5 + .../atoms/icons-colored/assets/arrow-down.svg | 3 + .../atoms/icons-colored/assets/arrow-up.svg | 3 + .../icons-colored/assets/check-green.svg | 3 + .../app/atoms/icons-colored/assets/folder.svg | 3 + .../icons-colored/assets/icon-cancel.svg | 3 + .../atoms/icons-colored/assets/icon-pause.svg | 3 + .../icons-colored/assets/icon-resume.svg | 4 + .../icons-colored/assets/icon-show-folder.svg | 11 + .../src/app/atoms/icons-colored/index.tsx | 24 + .../pending-file-row.tsx | 221 -------- .../pending-files-list.tsx | 99 ---- .../pending-file-components/styles.scss | 41 -- .../file-type-icon-map.tsx | 35 ++ .../pending-root-list.tsx | 152 ++++++ .../pending-root-row.tsx | 208 ++++++++ .../file-uploads/uploads-viewer.tsx | 23 +- .../search-popup/parts/drive-item-result.tsx | 29 +- .../app/components/uploads/file-tree-utils.ts | 82 ++- .../features/drive/api-client/api-client.ts | 10 +- .../features/drive/hooks/use-drive-upload.tsx | 71 +-- .../features/files/hooks/use-exp-upload.ts | 65 +++ .../features/files/hooks/use-upload-zones.ts | 10 +- .../app/features/files/hooks/use-upload.ts | 20 +- .../files/services/file-upload-service.ts | 473 +++++++++++++++--- .../state/atoms/root-pending-files-list.ts | 18 + .../files/state/selectors/current-task.ts | 5 + .../src/app/features/files/types/file.ts | 18 +- .../src/app/features/files/utils/resumable.js | 2 +- .../app/views/client/body/drive/browser.tsx | 52 +- .../body/drive/documents/document-icon.tsx | 2 +- .../body/drive/documents/folder-row.tsx | 2 +- .../body/drive/modals/create/create-link.tsx | 11 +- .../body/drive/modals/selector/index.tsx | 3 +- .../app/views/client/common/disk-usage.tsx | 2 +- 39 files changed, 1190 insertions(+), 543 deletions(-) create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-down.svg create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/arrow-up.svg create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/check-green.svg create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/folder.svg create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/icon-cancel.svg create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/icon-pause.svg create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/icon-resume.svg create mode 100644 tdrive/frontend/src/app/atoms/icons-colored/assets/icon-show-folder.svg delete mode 100644 tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-file-row.tsx delete mode 100644 tdrive/frontend/src/app/components/file-uploads/pending-file-components/pending-files-list.tsx delete mode 100644 tdrive/frontend/src/app/components/file-uploads/pending-file-components/styles.scss create mode 100644 tdrive/frontend/src/app/components/file-uploads/pending-root-components/file-type-icon-map.tsx create mode 100644 tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-list.tsx create mode 100644 tdrive/frontend/src/app/components/file-uploads/pending-root-components/pending-root-row.tsx create mode 100644 tdrive/frontend/src/app/features/files/hooks/use-exp-upload.ts create mode 100644 tdrive/frontend/src/app/features/files/state/atoms/root-pending-files-list.ts 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) ? ( - - +
+); + +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( </BaseSmall> )} - <Menu menu={() => onBuildSortContextMenu()} sortData={sortLabel} testClassId="browser-menu-sorting"> + <Menu + menu={() => onBuildSortContextMenu()} + sortData={sortLabel} + testClassId="browser-menu-sorting" + > {' '} - <Button theme="outline" className="ml-4 flex flex-row items-center border-0 md:border !text-gray-500 md:!text-blue-500 px-0 md:px-4" testClassId="button-sorting"> + <Button + theme="outline" + className="ml-4 flex flex-row items-center border-0 md:border !text-gray-500 md:!text-blue-500 px-0 md:px-4" + testClassId="button-sorting" + > <SortIcon className={`h-4 w-4 mr-2 -ml-1 ${ sortLabel.order === 'asc' ? 'transform rotate-180' : '' @@ -467,7 +487,11 @@ export default memo( {viewId !== 'shared_with_me' && ( <Menu menu={() => onBuildContextMenu(details)} testClassId="browser-menu-more"> {' '} - <Button theme="secondary" className="ml-4 flex flex-row items-center bg-transparent md:bg-blue-500 md:bg-opacity-25 !text-gray-500 md:!text-blue-500 px-0 md:px-4" testClassId="button-more"> + <Button + theme="secondary" + className="ml-4 flex flex-row items-center bg-transparent md:bg-blue-500 md:bg-opacity-25 !text-gray-500 md:!text-blue-500 px-0 md:px-4" + testClassId="button-more" + > <span> {selectedCount > 1 ? `${selectedCount} items` @@ -493,8 +517,10 @@ export default memo( <br /> <Button onClick={() => uploadItemModal()} - theme="primary" + theme={isPreparingUpload ? 'outline' : 'primary'} className="mt-4" + loading={isPreparingUpload} + disabled={isPreparingUpload} testClassId="button-add-doc" > {Languages.t('scenes.app.drive.add_doc')} diff --git a/tdrive/frontend/src/app/views/client/body/drive/documents/document-icon.tsx b/tdrive/frontend/src/app/views/client/body/drive/documents/document-icon.tsx index 094a0ccf..bd945000 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/documents/document-icon.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/documents/document-icon.tsx @@ -9,7 +9,7 @@ import { FileTypeSpreadsheetIcon, FileTypeUnknownIcon, } from '@atoms/icons-colored'; -import { FolderIcon } from '@heroicons/react/solid'; +import { FolderIcon } from 'app/atoms/icons-colored'; import fileUploadApiClient from '@features/files/api/file-upload-api-client'; import type { DriveItem, FileMetadata } from 'app/features/drive/types'; import { ComponentProps } from 'react'; diff --git a/tdrive/frontend/src/app/views/client/body/drive/documents/folder-row.tsx b/tdrive/frontend/src/app/views/client/body/drive/documents/folder-row.tsx index ce25e0b7..7a35d119 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/documents/folder-row.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/documents/folder-row.tsx @@ -1,5 +1,5 @@ import { DotsHorizontalIcon } from '@heroicons/react/outline'; -import { FolderIcon } from '@heroicons/react/solid'; +import { FolderIcon } from 'app/atoms/icons-colored'; import { Button } from '@atoms/button/button'; import { Base, BaseSmall } from '@atoms/text'; import Menu from '@components/menus/menu'; diff --git a/tdrive/frontend/src/app/views/client/body/drive/modals/create/create-link.tsx b/tdrive/frontend/src/app/views/client/body/drive/modals/create/create-link.tsx index db0b03e0..f2168527 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/modals/create/create-link.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/modals/create/create-link.tsx @@ -5,7 +5,7 @@ import { useState } from 'react'; import { useRecoilState } from 'recoil'; import { CreateModalAtom } from '.'; import FileUploadService from 'features/files/services/file-upload-service'; -import Languages from "features/global/services/languages-service"; +import Languages from 'features/global/services/languages-service'; export const CreateLink = () => { const [name, setName] = useState<string>(''); @@ -21,11 +21,12 @@ export const CreateLink = () => { type: 'text/uri-list', }); - await FileUploadService.upload([file], { + await FileUploadService.upload([{ root: file.name, file }], { context: { parentId: state.parent_id, }, - callback: (file, context) => { + callback: (filePayload, context) => { + const file = filePayload?.file; if (file) create( { name, parent_id: context.parentId, size: file.upload_data?.size }, @@ -50,7 +51,7 @@ export const CreateLink = () => { <> <Input disabled={loading} - placeholder={ Languages.t('components.create_link_modal.hint')} + placeholder={Languages.t('components.create_link_modal.hint')} className="w-full mt-4" onChange={e => setName(e.target.value)} testClassId="create-link-name-input" @@ -74,7 +75,7 @@ export const CreateLink = () => { }} testClassId="create-link-button" > - { Languages.t('components.create_link_modal.button')} + {Languages.t('components.create_link_modal.button')} </Button> </> ); diff --git a/tdrive/frontend/src/app/views/client/body/drive/modals/selector/index.tsx b/tdrive/frontend/src/app/views/client/body/drive/modals/selector/index.tsx index d006ae2f..86aaafb8 100644 --- a/tdrive/frontend/src/app/views/client/body/drive/modals/selector/index.tsx +++ b/tdrive/frontend/src/app/views/client/body/drive/modals/selector/index.tsx @@ -1,4 +1,5 @@ -import { DocumentIcon, FolderIcon } from '@heroicons/react/solid'; +import { DocumentIcon } from '@heroicons/react/solid'; +import { FolderIcon } from 'app/atoms/icons-colored'; import { Button } from '@atoms/button/button'; import { Checkbox } from '@atoms/input/input-checkbox'; import { Modal, ModalContent } from '@atoms/modal'; diff --git a/tdrive/frontend/src/app/views/client/common/disk-usage.tsx b/tdrive/frontend/src/app/views/client/common/disk-usage.tsx index 97218851..5eb4c819 100644 --- a/tdrive/frontend/src/app/views/client/common/disk-usage.tsx +++ b/tdrive/frontend/src/app/views/client/common/disk-usage.tsx @@ -10,7 +10,7 @@ import { useDriveItem } from "features/drive/hooks/use-drive-item"; const DiskUsage = () => { const { viewId } = RouterServices.getStateFromRoute(); - console.log("VIEW-iD::" + viewId); + // console.log("VIEW-iD::" + viewId); const [used, setUsed] = useState(0); const [usedBytes, setUsedBytes] = useState(0);