-
Notifications
You must be signed in to change notification settings - Fork 13
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: bulk upload of all files at once (#8)
- Loading branch information
1 parent
258a207
commit 4f4a7c2
Showing
11 changed files
with
282 additions
and
158 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,134 +1,25 @@ | ||
'use client'; | ||
|
||
import { useState, useCallback, useRef, useEffect } from 'react'; | ||
import { twMerge } from 'tailwind-merge'; | ||
import { bytesToString } from '@/utils'; | ||
import type { FileInfo } from '@/app/api/bucket/[bucket]/route'; | ||
import { UploadSimple } from '../icons'; | ||
|
||
type Props = { | ||
bucket: string | null; | ||
dirPath: string; | ||
file: File; | ||
}; | ||
|
||
export const UploadFileRow = ({ bucket, dirPath, file }: Props) => { | ||
const req = useRef<XMLHttpRequest>(new XMLHttpRequest()); | ||
|
||
const [isUploading, setIsUploading] = useState(false); | ||
const [isDone, setIsDone] = useState(false); | ||
const [progress, setProgress] = useState(0); | ||
const [error, setError] = useState<string | null>(null); | ||
|
||
useEffect(() => { | ||
const reqInstance = req.current; | ||
|
||
const onProgress = ({ loaded, total }: ProgressEvent) => | ||
setProgress(Math.round((loaded / total) * 100)); | ||
const onLoad = () => { | ||
setIsUploading(false); | ||
setProgress(0); | ||
}; | ||
const onError = () => { | ||
setIsUploading(false); | ||
setError('An error occurred while uploading the file'); | ||
}; | ||
const onAbort = () => { | ||
setIsUploading(false); | ||
setError('The upload has been canceled by the user or the browser dropped the connection'); | ||
}; | ||
const onStart = () => { | ||
setIsDone(false); | ||
setIsUploading(true); | ||
setError(null); | ||
}; | ||
const onDone = () => { | ||
setIsUploading(false); | ||
setIsDone(true); | ||
|
||
if (reqInstance?.status !== 200) { | ||
const resp = reqInstance?.responseText; | ||
setError(resp || 'An error occurred while uploading the file'); | ||
} else { | ||
setProgress(100); | ||
} | ||
}; | ||
|
||
reqInstance?.upload?.addEventListener('progress', onProgress); | ||
reqInstance?.addEventListener('load', onLoad); | ||
reqInstance?.addEventListener('loadstart', onStart); | ||
reqInstance?.addEventListener('loadend', onDone); | ||
reqInstance?.addEventListener('error', onError); | ||
reqInstance?.addEventListener('abort', onAbort); | ||
|
||
return () => { | ||
reqInstance?.upload?.removeEventListener('progress', onProgress); | ||
reqInstance?.removeEventListener('load', onLoad); | ||
reqInstance?.removeEventListener('loadend', onDone); | ||
reqInstance?.removeEventListener('loadstart', onStart); | ||
reqInstance?.removeEventListener('error', onError); | ||
reqInstance?.removeEventListener('abort', onAbort); | ||
}; | ||
}, []); | ||
|
||
const uploadFile = useCallback(async () => { | ||
const reqInstance = req.current; | ||
|
||
const key = `${dirPath}/${file.name}`.replace(/\/+/g, '/').replace(/^\//, ''); | ||
const fileInfo: FileInfo = { bucket: bucket as string, key, lastMod: file.lastModified }; | ||
const fileInfoStr = btoa(JSON.stringify(fileInfo)); | ||
|
||
reqInstance?.open('PUT', `/api/bucket/${bucket}`); | ||
reqInstance?.setRequestHeader('x-content-type', file.type); | ||
|
||
const formData = new FormData(); | ||
formData.append(fileInfoStr, file); | ||
reqInstance?.send(formData); | ||
}, [bucket, dirPath, file]); | ||
|
||
return ( | ||
<div key={`${file.name}-${file.size}`} className="flex w-full flex-row items-center gap-1"> | ||
<div className="flex flex-grow flex-col truncate"> | ||
<span className="truncate" title={file.name}> | ||
{file.name} | ||
export const UploadFileRow = ({ file }: Props) => ( | ||
<div className="flex w-full flex-row items-center gap-1"> | ||
<div className="flex flex-grow flex-col truncate pr-1"> | ||
<span className="truncate" title={file.name}> | ||
{file.name} | ||
</span> | ||
|
||
<div className="grid grid-cols-4 text-xs"> | ||
<span className="col-span-2 truncate pr-1">{file.type || 'unknown type'}</span> | ||
<span>{bytesToString(file.size)}</span> | ||
<span suppressHydrationWarning className="text-right"> | ||
{new Date(file.lastModified).toLocaleDateString()} | ||
</span> | ||
|
||
<div className="grid grid-cols-2 text-xs"> | ||
<span>{file.type || 'unknown type'}</span> | ||
<span>{bytesToString(file.size)}</span> | ||
</div> | ||
|
||
{error && <span className="text-xs text-status-error">{error}</span>} | ||
</div> | ||
|
||
<button | ||
disabled={isUploading || progress > 0} | ||
type="button" | ||
aria-label="Upload file" | ||
className={twMerge( | ||
'flex h-7 w-7 items-center justify-center rounded-full border-1 border-secondary-dark/20 bg-background px-1 transition-[background] duration-75 hover:bg-secondary hover:bg-opacity-30 disabled:cursor-not-allowed dark:border-secondary-dark/20 dark:bg-background-dark dark:hover:bg-secondary-dark', | ||
isUploading && | ||
'border-status-info text-status-info dark:border-status-info dark:text-status-info', | ||
isDone && | ||
progress === 100 && | ||
'border-status-success text-status-success dark:border-status-success dark:text-status-success', | ||
error && | ||
'border-status-error text-status-error dark:border-status-error dark:text-status-error', | ||
)} | ||
style={ | ||
{ | ||
'--upload-progress': `${progress}%`, | ||
...(!isDone && | ||
isUploading && { | ||
background: | ||
'radial-gradient(closest-side, rgb(250 250 250 / var(--tw-bg-opacity)) 79%, transparent 80% 100%), conic-gradient(rgb(33 150 253 / var(--tw-bg-opacity)) var(--upload-progress), rgb(33 150 253 / 0.3) 0)', | ||
}), | ||
} as React.CSSProperties | ||
} | ||
onClick={() => !isUploading && progress === 0 && uploadFile()} | ||
> | ||
<UploadSimple weight="bold" className="h-4 w-4" /> | ||
</button> | ||
</div> | ||
); | ||
}; | ||
</div> | ||
); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,74 @@ | ||
'use client'; | ||
|
||
import { useXhr } from '@/utils/hooks'; | ||
import { createContext, useCallback, useContext, useMemo, useState } from 'react'; | ||
import type { FileInfo } from '@/app/api/bucket/[bucket]/route'; | ||
import { useLocation } from './location-provider'; | ||
|
||
type FileWithDir = { dir: string; file: File }; | ||
|
||
export type IUploadFilesContext = { | ||
files: FileWithDir[]; | ||
updateFiles: (newFiles: FileWithDir[]) => void; | ||
uploadFiles: () => void; | ||
error: string | null; | ||
progress: number; | ||
isDone: boolean; | ||
isUploading: boolean; | ||
}; | ||
|
||
const UploadFilesContext = createContext<IUploadFilesContext>({ | ||
files: [], | ||
updateFiles: () => {}, | ||
uploadFiles: () => {}, | ||
error: null, | ||
progress: 0, | ||
isDone: false, | ||
isUploading: false, | ||
}); | ||
|
||
export const useUploadFiles = () => useContext(UploadFilesContext); | ||
|
||
type Props = { | ||
children: React.ReactNode; | ||
}; | ||
|
||
export const UploadFilesProvider = ({ children }: Props): JSX.Element => { | ||
const { currentBucket } = useLocation(); | ||
const [files, setFiles] = useState<FileWithDir[]>([]); | ||
|
||
const { error, progress, isDone, isUploading, putFormData } = useXhr(); | ||
|
||
const updateFiles = useCallback((newFiles: FileWithDir[]) => { | ||
setFiles(newFiles); | ||
}, []); | ||
|
||
const uploadFiles = useCallback(async () => { | ||
if (!currentBucket?.raw) return; | ||
|
||
const processedFiles = files.map(({ dir, file }) => { | ||
const key = `${dir}/${file.name}`.replace(/\/+/g, '/').replace(/^\//, ''); | ||
const fileInfo: FileInfo = { bucket: currentBucket.raw, key, lastMod: file.lastModified }; | ||
const fileInfoStr = btoa(JSON.stringify(fileInfo)); | ||
|
||
return { key: fileInfoStr, value: file }; | ||
}); | ||
|
||
if (processedFiles.length) { | ||
putFormData(`/api/bucket/${currentBucket.raw}`, processedFiles); | ||
} | ||
}, [currentBucket, putFormData, files]); | ||
|
||
return ( | ||
<UploadFilesContext.Provider | ||
value={useMemo( | ||
() => ({ files, updateFiles, uploadFiles, error, progress, isDone, isUploading }), | ||
[files, updateFiles, uploadFiles, error, progress, isDone, isUploading], | ||
)} | ||
> | ||
{children} | ||
</UploadFilesContext.Provider> | ||
); | ||
}; | ||
|
||
export type { Props as UploadFilesProviderProps }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.