From c96e21e1655184f22f069ad6cd670c687d268671 Mon Sep 17 00:00:00 2001 From: Jakub Barczewski <86159966+DeutscherDude@users.noreply.github.com> Date: Fri, 18 Oct 2024 19:50:25 +0200 Subject: [PATCH] feat: add progress bar on upload (#126) --- apps/frontend/@/components/ui/progress.tsx | 26 ++++ apps/frontend/package.json | 1 + .../createResourceModal.tsx | 32 +++-- .../useFileUpload/useFileUpload.tsx | 115 ++++++++++-------- package-lock.json | 96 +++++++++++++++ 5 files changed, 210 insertions(+), 60 deletions(-) create mode 100644 apps/frontend/@/components/ui/progress.tsx diff --git a/apps/frontend/@/components/ui/progress.tsx b/apps/frontend/@/components/ui/progress.tsx new file mode 100644 index 0000000..105fb65 --- /dev/null +++ b/apps/frontend/@/components/ui/progress.tsx @@ -0,0 +1,26 @@ +import * as React from "react" +import * as ProgressPrimitive from "@radix-ui/react-progress" + +import { cn } from "@/lib/utils" + +const Progress = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } diff --git a/apps/frontend/package.json b/apps/frontend/package.json index f5390f1..8085483 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", diff --git a/apps/frontend/src/modules/resource/components/createResourceModal/createResourceModal.tsx b/apps/frontend/src/modules/resource/components/createResourceModal/createResourceModal.tsx index 5a23704..25d57d6 100644 --- a/apps/frontend/src/modules/resource/components/createResourceModal/createResourceModal.tsx +++ b/apps/frontend/src/modules/resource/components/createResourceModal/createResourceModal.tsx @@ -6,6 +6,8 @@ import { Dialog, DialogContent, DialogTitle, DialogTrigger } from '../../../../. import { FileInput } from '../../../../../@/components/ui/input'; import { LoadingSpinner } from '../../../../../@/components/ui/loadingSpinner'; import { useFileUpload } from '../../composable/useFileUpload/useFileUpload'; +import { Progress } from '../../../../../@/components/ui/progress'; +import { cn } from '../../../../../@/lib/utils'; interface CreateResourceModalProps { bucketName: string; @@ -29,7 +31,7 @@ export const CreateResourceModal: FC = ({ bucketName } const fileInputRef = useRef(null); - const { abortController, isUploading, upload } = useFileUpload({ + const { abortController, isUploading, upload, uploadPercentage, uploadedFilesCount } = useFileUpload({ files, setFiles, bucketName, @@ -153,14 +155,26 @@ export const CreateResourceModal: FC = ({ bucketName } multiple={true} fileName={fileName} > - +
+ + {files.length > 0 && isUploading && +
+
40 && 'text-black')}> + Uploaded {uploadedFilesCount} / {files.length} +
+
+ } +
+
+ +
diff --git a/apps/frontend/src/modules/resource/composable/useFileUpload/useFileUpload.tsx b/apps/frontend/src/modules/resource/composable/useFileUpload/useFileUpload.tsx index 6ad1f63..5704114 100644 --- a/apps/frontend/src/modules/resource/composable/useFileUpload/useFileUpload.tsx +++ b/apps/frontend/src/modules/resource/composable/useFileUpload/useFileUpload.tsx @@ -1,8 +1,8 @@ import { useQueryClient } from '@tanstack/react-query'; -import { type MutableRefObject, useRef } from 'react'; +import { type MutableRefObject, useRef, useState } from 'react'; import { useToast } from '../../../../../@/components/ui/use-toast'; -import { useUserTokensStore } from '../../../core/stores/userTokens/userTokens'; +import { userAccessTokenSelector, useUserTokensStore } from '../../../core/stores/userTokens/userTokens'; import { useCreateResourcesMutation } from '../../api/user/mutations/createResourceMutation'; interface UseFileUploadPayload { @@ -16,13 +16,15 @@ interface UseFileUploadReturn { upload: () => Promise; abortController: MutableRefObject; isUploading: boolean; + uploadPercentage: number; + uploadedFilesCount: number; } const MAX_CHUNK_SIZE = 100_000_000; // ~100MB -const parsedFileUpload = Number(import.meta.env['VITE_MAX_FILE_UPLOAD_TIMEOUT']); +const PARSED_FILE_UPLOAD = Number(import.meta.env['VITE_MAX_FILE_UPLOAD_TIMEOUT']); -const FILE_UPLOAD_TIMEOUT = Number.isNaN(parsedFileUpload) ? 18 * 1000 : parsedFileUpload; +const FILE_UPLOAD_TIMEOUT = Number.isNaN(PARSED_FILE_UPLOAD) ? 18 * 1000 : PARSED_FILE_UPLOAD; export const useFileUpload = ({ files, @@ -31,91 +33,97 @@ export const useFileUpload = ({ onUploaded, }: UseFileUploadPayload): UseFileUploadReturn => { const queryClient = useQueryClient(); - - const accessToken = useUserTokensStore((selector) => selector.accessToken); - + const accessToken = useUserTokensStore(userAccessTokenSelector); const abortController = useRef(new AbortController()); + const [uploadPercentage, setUploadPercentage] = useState(0); + const [uploadedFilesCount, setUploadedFilesCount] = useState(0); const { toast } = useToast(); const { mutateAsync, isPending: isUploading } = useCreateResourcesMutation({}); - const upload = async (): Promise => { - let runningTotalSize = 0; + const sendFiles = async (files: File[]) => { + const timeout = setTimeout(() => { + abortController.current.abort(); + }, FILE_UPLOAD_TIMEOUT); - const filesCount = files.length; + await mutateAsync({ + accessToken: accessToken, + bucketName, + files, + signal: abortController.current.signal, + }); + clearTimeout(timeout); + } + + const calculatePercentage = (a: number, b: number) => Math.ceil((a / b) * 100); + + const resetState = () => { + setFiles([]); + setUploadPercentage(0); + setUploadedFilesCount(0); + } + + const upload = async (): Promise => { + let runningTotalSize = 0; + let uploadsSize = 0; + let uploadedFiles = 0; let filesToSend: File[] = []; + const filesCount = files.length; + const totalFilesSize = files.reduce( + (agg, file) => agg + file.size,0) try { + abortController.current.signal.addEventListener('abort', () => { + resetState(); + runningTotalSize = 0; + }); + for (let i = 0; i < filesCount; i += 1) { const fileSize = files[i].size; runningTotalSize += fileSize; - abortController.current.signal.addEventListener('abort', () => { - setFiles([]); - - runningTotalSize = 0; - }); - if (fileSize > MAX_CHUNK_SIZE) { - const timeout = setTimeout(() => { - abortController.current.abort(); - }, FILE_UPLOAD_TIMEOUT); - - await mutateAsync({ - accessToken: accessToken as string, - bucketName, - files: [files[i] as File], - signal: abortController.current.signal, - }); + await sendFiles([files[i]]); - clearTimeout(timeout); + uploadedFiles += 1; + setUploadedFilesCount(uploadedFiles); + uploadsSize += runningTotalSize; + setUploadPercentage(calculatePercentage(uploadsSize, totalFilesSize)); continue; } filesToSend.push(files[i] as unknown as File); if (runningTotalSize >= MAX_CHUNK_SIZE) { - const timeout = setTimeout(() => { - abortController.current.abort(); - }, FILE_UPLOAD_TIMEOUT); - - await mutateAsync({ - accessToken: accessToken as string, - bucketName, - files: filesToSend, - signal: abortController.current.signal, - }); + await sendFiles(filesToSend); - clearTimeout(timeout); + uploadedFiles += filesToSend.length; + setUploadedFilesCount(uploadedFiles); + uploadsSize += runningTotalSize; filesToSend = []; - runningTotalSize = 0; + setUploadPercentage(calculatePercentage(uploadsSize, totalFilesSize)); + continue; } if (i === files.length - 1) { - const timeout = setTimeout(() => { - abortController.current.abort(); - }, FILE_UPLOAD_TIMEOUT); - - await mutateAsync({ - accessToken: accessToken as string, - bucketName, - files, - signal: abortController.current.signal, - }); + await sendFiles(filesToSend); - clearTimeout(timeout); + uploadedFiles += filesToSend.length; + setUploadedFilesCount(uploadedFiles); + uploadsSize += runningTotalSize; filesToSend = []; + setUploadPercentage(calculatePercentage(uploadsSize, totalFilesSize)); } } } catch (error) { - setFiles([]); + resetState(); if (error instanceof Error) { toast({ @@ -138,6 +146,9 @@ export const useFileUpload = ({ await queryClient.invalidateQueries({ predicate: (query) => query.queryKey[0] === 'findBucketResources' && query.queryKey[1] === bucketName, }); + setTimeout(() => { + resetState(); + }, 500) setFiles([]); }; @@ -146,5 +157,7 @@ export const useFileUpload = ({ upload, abortController, isUploading, + uploadPercentage, + uploadedFilesCount }; }; diff --git a/package-lock.json b/package-lock.json index eb76c55..6dc64b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -151,6 +151,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-popover": "^1.1.1", + "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-toast": "^1.2.1", @@ -5594,6 +5595,101 @@ } } }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.0.tgz", + "integrity": "sha512-aSzvnYpP725CROcxAOEBVZZSIQVQdHgBr2QQFKySsaD14u8dNT0batuXI+AAGDdAHfXH8rbnHmjYFqVJ21KkRg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.0", + "@radix-ui/react-primitive": "2.0.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz", + "integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-context": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz", + "integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-primitive": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz", + "integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress/node_modules/@radix-ui/react-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", + "integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-roving-focus": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",