Multi-image
See it in action
drag & drop to upload
Usage
tsx
'use client';import {MultiImageDropzone,type FileState,} from '@/components/MultiImageDropzone';import { useEdgeStore } from '@/lib/edgestore';import { useState } from 'react';export function MultiImageDropzoneUsage() {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><MultiImageDropzonevalue={fileStates}dropzoneOptions={{maxFiles: 6,}}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:
- npm
- pnpm
- yarn
bash
npm install tailwind-merge react-dropzone lucide-react
bash
pnpm add tailwind-merge react-dropzone lucide-react
bash
yarn add 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 { UploadCloudIcon, X } 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 aspect-square flex justify-center items-center flex-col cursor-pointer min-h-[150px] min-w-[200px] border border-dashed border-gray-400 dark:border-gray-300 transition-colors duration-200 ease-in-out',image:'border-0 p-0 w-full h-full relative shadow-md bg-slate-200 dark:bg-slate-900 rounded-md',active: 'border-2',disabled:'bg-gray-200 border-gray-300 cursor-default pointer-events-none bg-opacity-30 dark:bg-gray-700',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 | string;key: string; // used to identify the file in the progress callbackprogress: 'PENDING' | 'COMPLETE' | 'ERROR' | number;};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 MultiImageDropzone = React.forwardRef<HTMLInputElement, InputProps>(({ dropzoneOptions, value, className, disabled, onChange, onFilesAdded },ref,) => {const [customError, setCustomError] = React.useState<string>();const imageUrls = React.useMemo(() => {if (value) {return value.map((fileState) => {if (typeof fileState.file === 'string') {// in case an url is passed in, use it to display the imagereturn fileState.file;} else {// in case a file is passed in, create a base64 url to display the imagereturn URL.createObjectURL(fileState.file);}});}return [];}, [value]);// dropzone configurationconst {getRootProps,getInputProps,fileRejections,isFocused,isDragAccept,isDragReject,} = useDropzone({accept: { 'image/*': [] },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,});// stylingconst 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 messagesconst 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><div className="grid grid-cols-[repeat(1,1fr)] gap-2 sm:grid-cols-[repeat(2,1fr)] lg:grid-cols-[repeat(3,1fr)] xl:grid-cols-[repeat(4,1fr)]">{/* Images */}{value?.map(({ file, progress }, index) => (<div key={index} className={variants.image + ' aspect-square'}><imgclassName="h-full w-full rounded-md object-cover"src={imageUrls[index]}alt={typeof file === 'string' ? file : file.name}/>{/* Progress Bar */}{typeof progress === 'number' && (<div className="absolute top-0 left-0 flex h-full w-full items-center justify-center rounded-md bg-black bg-opacity-70"><CircleProgress progress={progress} /></div>)}{/* Remove Image Icon */}{imageUrls[index] && !disabled && progress === 'PENDING' && (<divclassName="group absolute right-0 top-0 -translate-y-1/4 translate-x-1/4 transform"onClick={(e) => {e.stopPropagation();void onChange?.(value.filter((_, i) => i !== index) ?? []);}}><div className="flex h-5 w-5 cursor-pointer items-center justify-center rounded-md border border-solid border-gray-500 bg-white transition-all duration-300 hover:h-6 hover:w-6 dark:border-gray-400 dark:bg-black"><XclassName="text-gray-500 dark:text-gray-400"width={16}height={16}/></div></div>)}</div>))}{/* Dropzone */}{(!value || value.length < (dropzoneOptions?.maxFiles ?? 0)) && (<div{...getRootProps({className: dropZoneClassName,})}>{/* Main File Input */}<input ref={ref} {...getInputProps()} /><div className="flex flex-col items-center justify-center text-xs text-gray-400"><UploadCloudIcon className="mb-2 h-7 w-7" /><div className="text-gray-400">drag & drop to upload</div><div className="mt-3"><Button type="button" disabled={disabled}>select</Button></div></div></div>)}</div>{/* Error Text */}<div className="mt-1 text-xs text-red-500">{customError ?? errorMessage}</div></div>);},);MultiImageDropzone.displayName = 'MultiImageDropzone';const Button = React.forwardRef<HTMLButtonElement,React.ButtonHTMLAttributes<HTMLButtonElement>>(({ className, ...props }, ref) => {return (<buttonclassName={twMerge(// base'focus-visible:ring-ring inline-flex cursor-pointer items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 disabled:pointer-events-none disabled:opacity-50',// color'border border-gray-400 text-gray-400 shadow hover:bg-gray-100 hover:text-gray-500 dark:border-gray-600 dark:text-gray-100 dark:hover:bg-gray-700',// size'h-6 rounded-md px-2 text-xs',className,)}ref={ref}{...props}/>);});Button.displayName = 'Button';export { MultiImageDropzone };function CircleProgress({ progress }: { progress: number }) {const strokeWidth = 10;const radius = 50;const circumference = 2 * Math.PI * radius;return (<div className="relative h-16 w-16"><svgclassName="absolute top-0 left-0 -rotate-90 transform"width="100%"height="100%"viewBox={`0 0 ${(radius + strokeWidth) * 2} ${(radius + strokeWidth) * 2}`}xmlns="http://www.w3.org/2000/svg"><circleclassName="text-gray-400"stroke="currentColor"strokeWidth={strokeWidth}fill="none"cx={radius + strokeWidth}cy={radius + strokeWidth}r={radius}/><circleclassName="text-white transition-all duration-300 ease-in-out"stroke="currentColor"strokeWidth={strokeWidth}strokeDasharray={circumference}strokeDashoffset={((100 - progress) / 100) * circumference}strokeLinecap="round"fill="none"cx={radius + strokeWidth}cy={radius + strokeWidth}r={radius}/></svg><div className="absolute top-0 left-0 flex h-full w-full items-center justify-center text-xs text-white">{Math.round(progress)}%</div></div>);}