Skip to content

Commit

Permalink
feat: add progress bar on upload (#126)
Browse files Browse the repository at this point in the history
  • Loading branch information
DeutscherDude authored Oct 18, 2024
1 parent 76980fc commit c96e21e
Show file tree
Hide file tree
Showing 5 changed files with 210 additions and 60 deletions.
26 changes: 26 additions & 0 deletions apps/frontend/@/components/ui/progress.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName

export { Progress }
1 change: 1 addition & 0 deletions apps/frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -29,7 +31,7 @@ export const CreateResourceModal: FC<CreateResourceModalProps> = ({ bucketName }

const fileInputRef = useRef<HTMLInputElement | null>(null);

const { abortController, isUploading, upload } = useFileUpload({
const { abortController, isUploading, upload, uploadPercentage, uploadedFilesCount } = useFileUpload({
files,
setFiles,
bucketName,
Expand Down Expand Up @@ -153,14 +155,26 @@ export const CreateResourceModal: FC<CreateResourceModalProps> = ({ bucketName }
multiple={true}
fileName={fileName}
></FileInput>
<Button
className='w-40'
onClick={onUpload}
disabled={(files?.length === 0 || false) || isUploading}
>
{isUploading && <LoadingSpinner />}
{!isUploading && <>Upload</>}
</Button>
<div className='relative'>
<Progress value={uploadPercentage} />
{files.length > 0 && isUploading &&
<div className='absolute top-0 h-4 w-full rounded-full opacity-0 hover:opacity-100'>
<div className={cn('flex w-full justify-center items-center text-sm', uploadPercentage > 40 && 'text-black')}>
Uploaded {uploadedFilesCount} / {files.length}
</div>
</div>
}
</div>
<div className="w-full flex justify-end">
<Button
className='w-40'
onClick={onUpload}
disabled={(files?.length === 0 || false) || isUploading}
>
{isUploading && <LoadingSpinner />}
{!isUploading && <>Upload</>}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -16,13 +16,15 @@ interface UseFileUploadReturn {
upload: () => Promise<void>;
abortController: MutableRefObject<AbortController>;
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,
Expand All @@ -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<void> => {
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<void> => {
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({
Expand All @@ -138,6 +146,9 @@ export const useFileUpload = ({
await queryClient.invalidateQueries({
predicate: (query) => query.queryKey[0] === 'findBucketResources' && query.queryKey[1] === bucketName,
});
setTimeout(() => {
resetState();
}, 500)

setFiles([]);
};
Expand All @@ -146,5 +157,7 @@ export const useFileUpload = ({
upload,
abortController,
isUploading,
uploadPercentage,
uploadedFilesCount
};
};
96 changes: 96 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit c96e21e

Please sign in to comment.