198 lines
5.9 KiB
TypeScript
198 lines
5.9 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import { type FileError, type FileRejection, useDropzone } from 'react-dropzone'
|
|
import {type SupabaseClient} from '@supabase/supabase-js'
|
|
|
|
interface FileWithPreview extends File {
|
|
preview?: string
|
|
errors: readonly FileError[]
|
|
}
|
|
|
|
type UseSupabaseUploadOptions = {
|
|
/**
|
|
* Name of bucket to upload files to in your Supabase project
|
|
*/
|
|
bucketName: string
|
|
/**
|
|
* Folder to upload files to in the specified bucket within your Supabase project.
|
|
*
|
|
* Defaults to uploading files to the root of the bucket
|
|
*
|
|
* e.g If specified path is `test`, your file will be uploaded as `test/file_name`
|
|
*/
|
|
path?: string
|
|
/**
|
|
* Allowed MIME types for each file upload (e.g `image/png`, `text/html`, etc). Wildcards are also supported (e.g `image/*`).
|
|
*
|
|
* Defaults to allowing uploading of all MIME types.
|
|
*/
|
|
allowedMimeTypes?: string[]
|
|
/**
|
|
* Maximum upload size of each file allowed in bytes. (e.g 1000 bytes = 1 KB)
|
|
*/
|
|
maxFileSize?: number
|
|
/**
|
|
* Maximum number of files allowed per upload.
|
|
*/
|
|
maxFiles?: number
|
|
/**
|
|
* The number of seconds the asset is cached in the browser and in the Supabase CDN.
|
|
*
|
|
* This is set in the Cache-Control: max-age=<seconds> header. Defaults to 3600 seconds.
|
|
*/
|
|
cacheControl?: number
|
|
/**
|
|
* When set to true, the file is overwritten if it exists.
|
|
*
|
|
* When set to false, an error is thrown if the object already exists. Defaults to `false`
|
|
*/
|
|
upsert?: boolean
|
|
|
|
/**
|
|
* initialized Supabase client instance
|
|
*/
|
|
supabase: SupabaseClient
|
|
}
|
|
|
|
type UseSupabaseUploadReturn = ReturnType<typeof useSupabaseUpload>
|
|
|
|
const useSupabaseUpload = (options: UseSupabaseUploadOptions) => {
|
|
const {
|
|
bucketName,
|
|
path,
|
|
allowedMimeTypes = [],
|
|
maxFileSize = Number.POSITIVE_INFINITY,
|
|
maxFiles = 1,
|
|
cacheControl = 3600,
|
|
upsert = false,
|
|
supabase
|
|
} = options
|
|
|
|
const [files, setFiles] = useState<FileWithPreview[]>([])
|
|
const [loading, setLoading] = useState<boolean>(false)
|
|
const [errors, setErrors] = useState<{ name: string; message: string }[]>([])
|
|
const [successes, setSuccesses] = useState<string[]>([])
|
|
|
|
const isSuccess = useMemo(() => {
|
|
if (errors.length === 0 && successes.length === 0) {
|
|
return false
|
|
}
|
|
if (errors.length === 0 && successes.length === files.length) {
|
|
return true
|
|
}
|
|
return false
|
|
}, [errors.length, successes.length, files.length])
|
|
|
|
const onDrop = useCallback(
|
|
(acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
|
const validFiles = acceptedFiles
|
|
.filter((file) => !files.find((x) => x.name === file.name))
|
|
.map((file) => {
|
|
;(file as FileWithPreview).preview = URL.createObjectURL(file)
|
|
;(file as FileWithPreview).errors = []
|
|
return file as FileWithPreview
|
|
})
|
|
|
|
const invalidFiles = fileRejections.map(({ file, errors }) => {
|
|
;(file as FileWithPreview).preview = URL.createObjectURL(file)
|
|
;(file as FileWithPreview).errors = errors
|
|
return file as FileWithPreview
|
|
})
|
|
|
|
const newFiles = [...files, ...validFiles, ...invalidFiles]
|
|
|
|
setFiles(newFiles)
|
|
},
|
|
[files, setFiles]
|
|
)
|
|
|
|
const dropzoneProps = useDropzone({
|
|
onDrop,
|
|
noClick: true,
|
|
accept: allowedMimeTypes.reduce((acc, type) => ({ ...acc, [type]: [] }), {}),
|
|
maxSize: maxFileSize,
|
|
maxFiles: maxFiles,
|
|
multiple: maxFiles !== 1,
|
|
})
|
|
|
|
const onUpload = useCallback(async () => {
|
|
setLoading(true)
|
|
|
|
// [Joshen] This is to support handling partial successes
|
|
// If any files didn't upload for any reason, hitting "Upload" again will only upload the files that had errors
|
|
const filesWithErrors = errors.map((x) => x.name)
|
|
const filesToUpload =
|
|
filesWithErrors.length > 0
|
|
? [
|
|
...files.filter((f) => filesWithErrors.includes(f.name)),
|
|
...files.filter((f) => !successes.includes(f.name)),
|
|
]
|
|
: files
|
|
|
|
const responses = await Promise.all(
|
|
filesToUpload.map(async (file) => {
|
|
const { error } = await supabase.storage
|
|
.from(bucketName)
|
|
.upload(!!path ? `${path}/${file.name}` : file.name, file, {
|
|
cacheControl: cacheControl.toString(),
|
|
upsert,
|
|
})
|
|
if (error) {
|
|
return { name: file.name, message: error.message }
|
|
} else {
|
|
return { name: file.name, message: undefined }
|
|
}
|
|
})
|
|
)
|
|
|
|
const responseErrors = responses.filter((x) => x.message !== undefined)
|
|
// if there were errors previously, this function tried to upload the files again so we should clear/overwrite the existing errors.
|
|
setErrors(responseErrors)
|
|
|
|
const responseSuccesses = responses.filter((x) => x.message === undefined)
|
|
const newSuccesses = Array.from(
|
|
new Set([...successes, ...responseSuccesses.map((x) => x.name)])
|
|
)
|
|
setSuccesses(newSuccesses)
|
|
|
|
setLoading(false)
|
|
}, [files, path, bucketName, errors, successes])
|
|
|
|
useEffect(() => {
|
|
if (files.length === 0) {
|
|
setErrors([])
|
|
}
|
|
|
|
// If the number of files doesn't exceed the maxFiles parameter, remove the error 'Too many files' from each file
|
|
if (files.length <= maxFiles) {
|
|
let changed = false
|
|
const newFiles = files.map((file) => {
|
|
if (file.errors.some((e) => e.code === 'too-many-files')) {
|
|
file.errors = file.errors.filter((e) => e.code !== 'too-many-files')
|
|
changed = true
|
|
}
|
|
return file
|
|
})
|
|
if (changed) {
|
|
setFiles(newFiles)
|
|
}
|
|
}
|
|
}, [files.length, setFiles, maxFiles])
|
|
|
|
return {
|
|
files,
|
|
setFiles,
|
|
successes,
|
|
isSuccess,
|
|
loading,
|
|
errors,
|
|
setErrors,
|
|
onUpload,
|
|
maxFileSize: maxFileSize,
|
|
maxFiles: maxFiles,
|
|
allowedMimeTypes,
|
|
...dropzoneProps,
|
|
}
|
|
}
|
|
|
|
export { useSupabaseUpload, type UseSupabaseUploadOptions, type UseSupabaseUploadReturn }
|