Skip to main content

Multi-file

See it in action

Usage

tsx
'use client';
import {
MultiFileDropzone,
type FileState,
} from '@/components/MultiFileDropzone';
import { useEdgeStore } from '@/lib/edgestore';
import { useState } from 'react';
export function MultiFileDropzoneUsage() {
const [fileStates, setFileStates] = useState<FileState[]>([]);
const { edgestore } = useEdgeStore();
function updateFileProgress(key: string, progress: FileState['progress']) {
setFileStates((fileStates) => {
const newFileStates = structuredClone(fileStates);
const fileState = newFileStates.find(
(fileState) => fileState.key === key,
);
if (fileState) {
fileState.progress = progress;
}
return newFileStates;
});
}
return (
<div>
<MultiFileDropzone
value={fileStates}
onChange={(files) => {
setFileStates(files);
}}
onFilesAdded={async (addedFiles) => {
setFileStates([...fileStates, ...addedFiles]);
await Promise.all(
addedFiles.map(async (addedFileState) => {
try {
const res = await edgestore.publicFiles.upload({
file: addedFileState.file,
onProgressChange: async (progress) => {
updateFileProgress(addedFileState.key, progress);
if (progress === 100) {
// wait 1 second to set it to complete
// so that the user can see the progress bar at 100%
await new Promise((resolve) => setTimeout(resolve, 1000));
updateFileProgress(addedFileState.key, 'COMPLETE');
}
},
});
console.log(res);
} catch (err) {
updateFileProgress(addedFileState.key, 'ERROR');
}
}),
);
}}
/>
</div>
);
}

Installation

info

This component uses tailwind for styling.
Feel free to change lucide-react to any other icon library you prefer.

First, let's install the required dependencies:

bash
npm install tailwind-merge react-dropzone lucide-react

Now just copy the following component into your components folder.

tsx
'use client';
import { formatFileSize } from '@edgestore/react/utils';
import {
CheckCircleIcon,
FileIcon,
LucideFileWarning,
Trash2Icon,
UploadCloudIcon,
XIcon,
} from 'lucide-react';
import * as React from 'react';
import { useDropzone, type DropzoneOptions } from 'react-dropzone';
import { twMerge } from 'tailwind-merge';
const variants = {
base: 'relative rounded-md p-4 w-full flex justify-center items-center flex-col cursor-pointer border border-dashed border-gray-400 dark:border-gray-300 transition-colors duration-200 ease-in-out',
active: 'border-2',
disabled:
'bg-gray-200 border-gray-300 cursor-default pointer-events-none bg-opacity-30 dark:bg-gray-700 dark:border-gray-600',
accept: 'border border-blue-500 bg-blue-500 bg-opacity-10',
reject: 'border border-red-700 bg-red-700 bg-opacity-10',
};
export type FileState = {
file: File;
key: string; // used to identify the file in the progress callback
progress: 'PENDING' | 'COMPLETE' | 'ERROR' | number;
abortController?: AbortController;
};
type InputProps = {
className?: string;
value?: FileState[];
onChange?: (files: FileState[]) => void | Promise<void>;
onFilesAdded?: (addedFiles: FileState[]) => void | Promise<void>;
disabled?: boolean;
dropzoneOptions?: Omit<DropzoneOptions, 'disabled'>;
};
const ERROR_MESSAGES = {
fileTooLarge(maxSize: number) {
return `The file is too large. Max size is ${formatFileSize(maxSize)}.`;
},
fileInvalidType() {
return 'Invalid file type.';
},
tooManyFiles(maxFiles: number) {
return `You can only add ${maxFiles} file(s).`;
},
fileNotSupported() {
return 'The file is not supported.';
},
};
const MultiFileDropzone = React.forwardRef<HTMLInputElement, InputProps>(
(
{ dropzoneOptions, value, className, disabled, onFilesAdded, onChange },
ref,
) => {
const [customError, setCustomError] = React.useState<string>();
if (dropzoneOptions?.maxFiles && value?.length) {
disabled = disabled ?? value.length >= dropzoneOptions.maxFiles;
}
// dropzone configuration
const {
getRootProps,
getInputProps,
fileRejections,
isFocused,
isDragAccept,
isDragReject,
} = useDropzone({
disabled,
onDrop: (acceptedFiles) => {
const files = acceptedFiles;
setCustomError(undefined);
if (
dropzoneOptions?.maxFiles &&
(value?.length ?? 0) + files.length > dropzoneOptions.maxFiles
) {
setCustomError(ERROR_MESSAGES.tooManyFiles(dropzoneOptions.maxFiles));
return;
}
if (files) {
const addedFiles = files.map<FileState>((file) => ({
file,
key: Math.random().toString(36).slice(2),
progress: 'PENDING',
}));
void onFilesAdded?.(addedFiles);
void onChange?.([...(value ?? []), ...addedFiles]);
}
},
...dropzoneOptions,
});
// styling
const dropZoneClassName = React.useMemo(
() =>
twMerge(
variants.base,
isFocused && variants.active,
disabled && variants.disabled,
(isDragReject ?? fileRejections[0]) && variants.reject,
isDragAccept && variants.accept,
className,
).trim(),
[
isFocused,
fileRejections,
isDragAccept,
isDragReject,
disabled,
className,
],
);
// error validation messages
const errorMessage = React.useMemo(() => {
if (fileRejections[0]) {
const { errors } = fileRejections[0];
if (errors[0]?.code === 'file-too-large') {
return ERROR_MESSAGES.fileTooLarge(dropzoneOptions?.maxSize ?? 0);
} else if (errors[0]?.code === 'file-invalid-type') {
return ERROR_MESSAGES.fileInvalidType();
} else if (errors[0]?.code === 'too-many-files') {
return ERROR_MESSAGES.tooManyFiles(dropzoneOptions?.maxFiles ?? 0);
} else {
return ERROR_MESSAGES.fileNotSupported();
}
}
return undefined;
}, [fileRejections, dropzoneOptions]);
return (
<div className="w-full">
<div className="flex w-full flex-col gap-2">
<div className="w-full">
{/* Main File Input */}
<div
{...getRootProps({
className: dropZoneClassName,
})}
>
<input ref={ref} {...getInputProps()} />
<div className="flex flex-col items-center justify-center text-xs text-gray-400">
<UploadCloudIcon className="mb-1 h-7 w-7" />
<div className="text-gray-400">
drag & drop or click to upload
</div>
</div>
</div>
{/* Error Text */}
<div className="mt-1 text-xs text-red-500">
{customError ?? errorMessage}
</div>
</div>
{/* Selected Files */}
{value?.map(({ file, abortController, progress }, i) => (
<div
key={i}
className="flex h-16 w-full flex-col justify-center rounded border border-gray-300 px-4 py-2"
>
<div className="flex items-center gap-2 text-gray-500 dark:text-white">
<FileIcon size="30" className="shrink-0" />
<div className="min-w-0 text-sm">
<div className="overflow-hidden overflow-ellipsis whitespace-nowrap">
{file.name}
</div>
<div className="text-xs text-gray-400 dark:text-gray-400">
{formatFileSize(file.size)}
</div>
</div>
<div className="grow" />
<div className="flex w-12 justify-end text-xs">
{progress === 'PENDING' ? (
<button
type="button"
className="rounded-md p-1 transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-700"
onClick={() => {
void onChange?.(
value.filter((_, index) => index !== i),
);
}}
>
<Trash2Icon className="shrink-0" />
</button>
) : progress === 'ERROR' ? (
<LucideFileWarning className="shrink-0 text-red-600 dark:text-red-400" />
) : progress !== 'COMPLETE' ? (
<div className="flex flex-col items-end gap-0.5">
{abortController && (
<button
type="button"
className="rounded-md p-0.5 transition-colors duration-200 hover:bg-gray-100 dark:hover:bg-gray-700"
disabled={progress === 100}
onClick={() => {
abortController.abort();
}}
>
<XIcon className="h-4 w-4 shrink-0 text-gray-400 dark:text-gray-400" />
</button>
)}
<div>{Math.round(progress)}%</div>
</div>
) : (
<CheckCircleIcon className="shrink-0 text-green-600 dark:text-gray-400" />
)}
</div>
</div>
{/* Progress Bar */}
{typeof progress === 'number' && (
<div className="relative h-0">
<div className="absolute top-1 h-1 w-full overflow-clip rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full bg-gray-400 transition-all duration-300 ease-in-out dark:bg-white"
style={{
width: progress ? `${progress}%` : '0%',
}}
/>
</div>
</div>
)}
</div>
))}
</div>
</div>
);
},
);
MultiFileDropzone.displayName = 'MultiFileDropzone';
export { MultiFileDropzone };