diff --git a/packages/client-core/i18n/en/editor.json b/packages/client-core/i18n/en/editor.json index 2d70f9ca2f..4275bf81c4 100755 --- a/packages/client-core/i18n/en/editor.json +++ b/packages/client-core/i18n/en/editor.json @@ -1216,6 +1216,7 @@ "uploadAssets": "Upload Assets", "uploadFiles": "Upload Files", "uploadFolder": "Upload Folder", + "uploadingFiles": "Uploading Files ({{completed}}/{{total}})", "search-placeholder": "Search", "generatingThumbnails": "Generating Thumbnails ({{count}} remaining)", "file": "File", diff --git a/packages/client-core/src/util/upload.tsx b/packages/client-core/src/util/upload.tsx index 84c3b8c0dc..cc6d7c170b 100644 --- a/packages/client-core/src/util/upload.tsx +++ b/packages/client-core/src/util/upload.tsx @@ -26,7 +26,7 @@ Ethereal Engine. All Rights Reserved. import i18n from 'i18next' import config from '@etherealengine/common/src/config' -import { getMutableState } from '@etherealengine/hyperflux' +import { defineState, getMutableState, none, useMutableState } from '@etherealengine/hyperflux' import '@etherealengine/common/src/utils/jsonUtils' @@ -37,6 +37,71 @@ import { RethrownError } from './errors' export type CancelableUploadPromiseReturnType = { cancel: () => void; promise: Promise } export type CancelableUploadPromiseArrayReturnType = { cancel: () => void; promises: Array> } +const getFileKeys = (files: Array | File) => { + const keys = [] as string[] + if (Array.isArray(files)) { + files.forEach((file) => { + keys.push(file.name) + }) + } else { + keys.push(files.name) + } + + return keys +} + +export const useUploadingFiles = () => { + const fileUploadState = useMutableState(FileUploadState).value + const values = Object.values(fileUploadState) + const total = values.length + const completed = values.reduce((prev, curr) => (curr === 1 ? prev + 1 : prev), 0) + const sum = values.reduce((prev, curr) => prev + curr, 0) + const progress = sum ? (sum / total) * 100 : 0 + return { completed, total, progress } +} + +export const FileUploadState = defineState({ + name: 'FileUploadState', + initial: {} as Record, + + startFileUpload: (files: Array) => { + const keys = getFileKeys(files) + const toMerge = keys.reduce( + (prev, curr) => ({ + ...prev, + [curr]: 0 + }), + {} + ) + getMutableState(FileUploadState).merge(toMerge) + }, + + updateFileUpload: (files: Array, progress: number) => { + const keys = getFileKeys(files) + progress = Math.min(progress, 0.9) + const toMerge = keys.reduce( + (prev, curr) => ({ + ...prev, + [curr]: progress + }), + {} + ) + getMutableState(FileUploadState).merge(toMerge) + }, + + endFileUpload: (files: Array) => { + const keys = getFileKeys(files) + const toMerge = keys.reduce( + (prev, curr) => ({ + ...prev, + [curr]: none + }), + {} + ) + getMutableState(FileUploadState).merge(toMerge) + } +}) + export const uploadToFeathersService = ( service: keyof ServiceTypes, files: Array, @@ -48,6 +113,8 @@ export const uploadToFeathersService = ( request.timeout = 10 * 60 * 1000 // 10 minutes - need to support big files on slow connections let aborted = false + FileUploadState.startFileUpload(files) + return { cancel: () => { aborted = true @@ -56,7 +123,9 @@ export const uploadToFeathersService = ( promise: new Promise((resolve, reject) => { request.upload.addEventListener('progress', (e) => { if (aborted) return - if (onUploadProgress) onUploadProgress(e.loaded / e.total) + const progress = e.loaded / e.total + FileUploadState.updateFileUpload(files, progress) + if (onUploadProgress) onUploadProgress(progress) }) request.upload.addEventListener('error', (error) => { @@ -66,6 +135,7 @@ export const uploadToFeathersService = ( request.addEventListener('readystatechange', (e) => { if (request.readyState === XMLHttpRequest.DONE) { + FileUploadState.endFileUpload(files) const status = request.status if (status === 0 || (status >= 200 && status < 400)) { diff --git a/packages/ui/src/components/editor/panels/Assets/container/index.tsx b/packages/ui/src/components/editor/panels/Assets/container/index.tsx index 1fa0e0e289..b4352af83e 100644 --- a/packages/ui/src/components/editor/panels/Assets/container/index.tsx +++ b/packages/ui/src/components/editor/panels/Assets/container/index.tsx @@ -60,6 +60,7 @@ import Tooltip from '../../../../../primitives/tailwind/Tooltip' import { ContextMenu } from '../../../../tailwind/ContextMenu' import DeleteFileModal from '../../Files/browserGrid/DeleteFileModal' import { FileIcon } from '../../Files/icon' +import { FileUploadProgress } from '../../Files/upload/FileUploadProgress' import { AssetIconMap } from '../icons' type Category = { @@ -646,6 +647,7 @@ const AssetPanel = () => { {t('editor:layout.filebrowser.uploadAssets')} +
= (props) {t('editor:layout.filebrowser.uploadFolder')}
+ {isLoading && ( )} diff --git a/packages/ui/src/components/editor/panels/Files/upload/FileUploadProgress.tsx b/packages/ui/src/components/editor/panels/Files/upload/FileUploadProgress.tsx new file mode 100644 index 0000000000..876c71c265 --- /dev/null +++ b/packages/ui/src/components/editor/panels/Files/upload/FileUploadProgress.tsx @@ -0,0 +1,47 @@ +/* +CPAL-1.0 License + +The contents of this file are subject to the Common Public Attribution License +Version 1.0. (the "License"); you may not use this file except in compliance +with the License. You may obtain a copy of the License at +https://github.com/EtherealEngine/etherealengine/blob/dev/LICENSE. +The License is based on the Mozilla Public License Version 1.1, but Sections 14 +and 15 have been added to cover use of software over a computer network and +provide for limited attribution for the Original Developer. In addition, +Exhibit A has been modified to be consistent with Exhibit B. + +Software distributed under the License is distributed on an "AS IS" basis, +WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License for the +specific language governing rights and limitations under the License. + +The Original Code is Ethereal Engine. + +The Original Developer is the Initial Developer. The Initial Developer of the +Original Code is the Ethereal Engine team. + +All portions of the code written by the Ethereal Engine team are Copyright © 2021-2023 +Ethereal Engine. All Rights Reserved. +*/ + +import { useUploadingFiles } from '@etherealengine/client-core/src/util/upload' +import React from 'react' +import { useTranslation } from 'react-i18next' +import Progress from '../../../../../primitives/tailwind/Progress' + +export const FileUploadProgress = () => { + const { t } = useTranslation() + const { completed, total, progress } = useUploadingFiles() + + return total ? ( +
+
+ + {t('editor:layout.filebrowser.uploadingFiles', { completed, total })} + +
+ +
+
+
+ ) : null +}