Skip to main content

Multi-image

See it in action

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>
<MultiImageDropzone
value={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:

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 { 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 callback
progress: '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 a url is passed in, use it to display the image
return fileState.file;
} else {
// in case a file is passed in, create a base64 url to display the image
return URL.createObjectURL(fileState.file);
}
});
}
return [];
}, [value]);
// dropzone configuration
const {
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,
});
// 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>
<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 h-full'}
>
<img
className="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' && (
<div
className="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">
<X
className="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 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 (
<button
className={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">
<svg
className="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"
>
<circle
className="text-gray-400"
stroke="currentColor"
strokeWidth={strokeWidth}
fill="none"
cx={radius + strokeWidth}
cy={radius + strokeWidth}
r={radius}
/>
<circle
className="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>
);
}