Docs
Dropzone (File Upload)

Dropzone (File Upload)

Displays a control for easier uploading of files directly to Supabase Storage

Loading...

Installation

Folder structure

  • components
  • hooks
  • lib
    • supabase
1'use client'
2
3import { CheckCircle, File, Loader2, Upload, X } from 'lucide-react'
4import { createContext, useCallback, useContext, type PropsWithChildren } from 'react'
5
6import { cn } from '@/lib/utils'
7import { type UseSupabaseUploadReturn } from '@/hooks/use-supabase-upload'
8import { Button } from '@/components/ui/button'
9
10export const formatBytes = (
11  bytes: number,
12  decimals = 2,
13  size?: 'bytes' | 'KB' | 'MB' | 'GB' | 'TB' | 'PB' | 'EB' | 'ZB' | 'YB'
14) => {
15  const k = 1000
16  const dm = decimals < 0 ? 0 : decimals
17  const sizes = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
18
19  if (bytes === 0 || bytes === undefined) return size !== undefined ? `0 ${size}` : '0 bytes'
20  const i = size !== undefined ? sizes.indexOf(size) : Math.floor(Math.log(bytes) / Math.log(k))
21  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
22}
23
24type DropzoneContextType = Omit<UseSupabaseUploadReturn, 'getRootProps' | 'getInputProps'>
25
26const DropzoneContext = createContext<DropzoneContextType | undefined>(undefined)
27
28type DropzoneProps = UseSupabaseUploadReturn & {
29  className?: string
30}
31
32const Dropzone = ({
33  className,
34  children,
35  getRootProps,
36  getInputProps,
37  ...restProps
38}: PropsWithChildren<DropzoneProps>) => {
39  const isSuccess = restProps.isSuccess
40  const isActive = restProps.isDragActive
41  const isInvalid =
42    (restProps.isDragActive && restProps.isDragReject) ||
43    (restProps.errors.length > 0 && !restProps.isSuccess) ||
44    restProps.files.some((file) => file.errors.length !== 0)
45
46  return (
47    <DropzoneContext.Provider value={{ ...restProps }}>
48      <div
49        {...getRootProps({
50          className: cn(
51            'border-2 border-gray-300 rounded-lg p-6 text-center bg-card transition-colors duration-300 text-foreground',
52            className,
53            isSuccess ? 'border-solid' : 'border-dashed',
54            isActive && 'border-primary bg-primary/10',
55            isInvalid && 'border-destructive bg-destructive/10'
56          ),
57        })}
58      >
59        <input {...getInputProps()} />
60        {children}
61      </div>
62    </DropzoneContext.Provider>
63  )
64}
65const DropzoneContent = ({ className }: { className?: string }) => {
66  const {
67    files,
68    setFiles,
69    onUpload,
70    loading,
71    successes,
72    errors,
73    maxFileSize,
74    maxFiles,
75    isSuccess,
76  } = useDropzoneContext()
77
78  const exceedMaxFiles = files.length > maxFiles
79
80  const handleRemoveFile = useCallback(
81    (fileName: string) => {
82      setFiles(files.filter((file) => file.name !== fileName))
83    },
84    [files, setFiles]
85  )
86
87  if (isSuccess) {
88    return (
89      <div className={cn('flex flex-row items-center gap-x-2 justify-center', className)}>
90        <CheckCircle size={16} className="text-primary" />
91        <p className="text-primary text-sm">
92          Successfully uploaded {files.length} file{files.length > 1 ? 's' : ''}
93        </p>
94      </div>
95    )
96  }
97
98  return (
99    <div className={cn('flex flex-col', className)}>
100      {files.map((file, idx) => {
101        const fileError = errors.find((e) => e.name === file.name)
102        const isSuccessfullyUploaded = !!successes.find((e) => e === file.name)
103
104        return (
105          <div
106            key={`${file.name}-${idx}`}
107            className="flex items-center gap-x-4 border-b py-2 first:mt-4 last:mb-4 "
108          >
109            {file.type.startsWith('image/') ? (
110              <div className="h-10 w-10 rounded border overflow-hidden shrink-0 bg-muted flex items-center justify-center">
111                <img src={file.preview} alt={file.name} className="object-cover" />
112              </div>
113            ) : (
114              <div className="h-10 w-10 rounded border bg-muted flex items-center justify-center">
115                <File size={18} />
116              </div>
117            )}
118
119            <div className="shrink grow flex flex-col items-start truncate">
120              <p title={file.name} className="text-sm truncate max-w-full">
121                {file.name}
122              </p>
123              {file.errors.length > 0 ? (
124                <p className="text-xs text-destructive">
125                  {file.errors
126                    .map((e) =>
127                      e.message.startsWith('File is larger than')
128                        ? `File is larger than ${formatBytes(maxFileSize, 2)} (Size: ${formatBytes(file.size, 2)})`
129                        : e.message
130                    )
131                    .join(', ')}
132                </p>
133              ) : loading && !isSuccessfullyUploaded ? (
134                <p className="text-xs text-muted-foreground">Uploading file...</p>
135              ) : !!fileError ? (
136                <p className="text-xs text-destructive">Failed to upload: {fileError.message}</p>
137              ) : isSuccessfullyUploaded ? (
138                <p className="text-xs text-primary">Successfully uploaded file</p>
139              ) : (
140                <p className="text-xs text-muted-foreground">{formatBytes(file.size, 2)}</p>
141              )}
142            </div>
143
144            {!loading && !isSuccessfullyUploaded && (
145              <Button
146                size="icon"
147                variant="link"
148                className="shrink-0 justify-self-end text-muted-foreground hover:text-foreground"
149                onClick={() => handleRemoveFile(file.name)}
150              >
151                <X />
152              </Button>
153            )}
154          </div>
155        )
156      })}
157      {exceedMaxFiles && (
158        <p className="text-sm text-left mt-2 text-destructive">
159          You may upload only up to {maxFiles} files, please remove {files.length - maxFiles} file
160          {files.length - maxFiles > 1 ? 's' : ''}.
161        </p>
162      )}
163      {files.length > 0 && !exceedMaxFiles && (
164        <div className="mt-2">
165          <Button
166            variant="outline"
167            onClick={onUpload}
168            disabled={files.some((file) => file.errors.length !== 0) || loading}
169          >
170            {loading ? (
171              <>
172                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
173                Uploading...
174              </>
175            ) : (
176              <>Upload files</>
177            )}
178          </Button>
179        </div>
180      )}
181    </div>
182  )
183}
184
185const DropzoneEmptyState = ({ className }: { className?: string }) => {
186  const { maxFiles, maxFileSize, inputRef, isSuccess } = useDropzoneContext()
187
188  if (isSuccess) {
189    return null
190  }
191
192  return (
193    <div className={cn('flex flex-col items-center gap-y-2', className)}>
194      <Upload size={20} className="text-muted-foreground" />
195      <p className="text-sm">
196        Upload{!!maxFiles && maxFiles > 1 ? ` ${maxFiles}` : ''} file
197        {!maxFiles || maxFiles > 1 ? 's' : ''}
198      </p>
199      <div className="flex flex-col items-center gap-y-1">
200        <p className="text-xs text-muted-foreground">
201          Drag and drop or{' '}
202          <a
203            onClick={() => inputRef.current?.click()}
204            className="underline cursor-pointer transition hover:text-foreground"
205          >
206            select {maxFiles === 1 ? `file` : 'files'}
207          </a>{' '}
208          to upload
209        </p>
210        {maxFileSize !== Number.POSITIVE_INFINITY && (
211          <p className="text-xs text-muted-foreground">
212            Maximum file size: {formatBytes(maxFileSize, 2)}
213          </p>
214        )}
215      </div>
216    </div>
217  )
218}
219
220const useDropzoneContext = () => {
221  const context = useContext(DropzoneContext)
222
223  if (!context) {
224    throw new Error('useDropzoneContext must be used within a Dropzone')
225  }
226
227  return context
228}
229
230export { Dropzone, DropzoneContent, DropzoneEmptyState, useDropzoneContext }

Introduction

Uploading files should be easy—this component handles the tricky parts for you.

The File Upload component makes it easy to add file uploads to your app, with built-in support for drag-and-drop, file type restrictions, image previews, and configurable limits on file size and number of files. All the essentials, ready to go.

Features

  • Drag-and-drop support
  • Multiple file uploads
  • File size and count limits
  • Image previews for supported file types
  • MIME type restrictions
  • Invalid file handling
  • Success and error states with clear feedback

Usage

  • Simply add this <Dropzone /> component to your page and it will handle the rest.
  • For control over file upload, you can pass in a props object to the component.
'use client'
 
import { Dropzone, DropzoneContent, DropzoneEmptyState } from '@/components/dropzone'
import { useSupabaseUpload } from '@/hooks/use-supabase-upload'
 
const FileUploadDemo = () => {
  const props = useSupabaseUpload({
    bucketName: 'test',
    path: 'test',
    allowedMimeTypes: ['image/*'],
    maxFiles: 2,
    maxFileSize: 1000 * 1000 * 10, // 10MB,
  })
 
  return (
    <div className="w-[500px]">
      <Dropzone {...props}>
        <DropzoneEmptyState />
        <DropzoneContent />
      </Dropzone>
    </div>
  )
}
 
export { FileUploadDemo }

Props

PropTypeDefaultDescription
bucketNamestringnullThe name of the Supabase Storage bucket to upload to
pathstringnullThe path or subfolder to upload the file to
allowedMimeTypesstring[][]The MIME types to allow for upload
maxFilesnumber1Maximum number of files to upload
maxFileSizenumber1000Maximum file size in bytes

Further reading