You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
102 lines
3.4 KiB
102 lines
3.4 KiB
import mediaUpload, { UPLOAD_ABORTED_ERROR_MSG } from '@/services/media-upload.service' |
|
import { useRef } from 'react' |
|
import { toast } from 'sonner' |
|
import logger from '@/lib/logger' |
|
|
|
export default function Uploader({ |
|
children, |
|
onUploadSuccess, |
|
onUploadStart, |
|
onUploadEnd, |
|
onProgress, |
|
onUploadCompressPhase, |
|
onUploadCompressProgress, |
|
className, |
|
accept = 'image/*', |
|
maxFileSizeMb, |
|
maxCompressedSizeMb |
|
}: { |
|
children: React.ReactNode |
|
onUploadSuccess: (result: { url: string; tags: string[][]; file?: File }) => void |
|
onUploadStart?: (file: File, cancel: () => void) => void |
|
onUploadEnd?: (file: File) => void |
|
onProgress?: (file: File, progress: number) => void |
|
/** After local compression (before network upload). */ |
|
onUploadCompressPhase?: (file: File, phase: 'compressing' | 'uploading') => void |
|
/** 0–100 during local compression only. */ |
|
onUploadCompressProgress?: (file: File, percent: number) => void |
|
className?: string |
|
accept?: string |
|
/** Reject files whose original size exceeds this limit (before compression). */ |
|
maxFileSizeMb?: number |
|
/** Reject when compressed size exceeds this limit (after local encode, before upload). */ |
|
maxCompressedSizeMb?: number |
|
}) { |
|
const fileInputRef = useRef<HTMLInputElement>(null) |
|
|
|
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => { |
|
if (!event.target.files) return |
|
|
|
const abortControllerMap = new Map<File, AbortController>() |
|
|
|
for (const file of event.target.files) { |
|
const abortController = new AbortController() |
|
abortControllerMap.set(file, abortController) |
|
onUploadStart?.(file, () => abortController.abort()) |
|
} |
|
|
|
for (const file of event.target.files) { |
|
if (maxFileSizeMb !== undefined && file.size > maxFileSizeMb * 1024 * 1024) { |
|
toast.error( |
|
`"${file.name}" is too large (${(file.size / 1024 / 1024).toFixed(1)} MB). Maximum is ${maxFileSizeMb} MB.` |
|
) |
|
onUploadEnd?.(file) |
|
continue |
|
} |
|
try { |
|
const abortController = abortControllerMap.get(file) |
|
const result = await mediaUpload.upload(file, { |
|
onProgress: (p) => onProgress?.(file, p), |
|
signal: abortController?.signal, |
|
onCompressStart: () => onUploadCompressPhase?.(file, 'compressing'), |
|
onCompressEnd: () => onUploadCompressPhase?.(file, 'uploading'), |
|
onCompressProgress: (p) => onUploadCompressProgress?.(file, p), |
|
maxCompressedSizeMb |
|
}) |
|
onUploadSuccess({ ...result, file }) |
|
onUploadEnd?.(file) |
|
} catch (error) { |
|
logger.error('Error uploading file', { error, file: file.name }) |
|
const message = (error as Error).message |
|
if (message !== UPLOAD_ABORTED_ERROR_MSG) { |
|
toast.error(`Failed to upload file: ${message}`) |
|
} |
|
if (fileInputRef.current) { |
|
fileInputRef.current.value = '' |
|
} |
|
onUploadEnd?.(file) |
|
} |
|
} |
|
} |
|
|
|
const handleUploadClick = () => { |
|
if (fileInputRef.current) { |
|
fileInputRef.current.value = '' // clear the value so that the same file can be uploaded again |
|
fileInputRef.current.click() |
|
} |
|
} |
|
|
|
return ( |
|
<div className={className}> |
|
<div onClick={handleUploadClick}>{children}</div> |
|
<input |
|
type="file" |
|
ref={fileInputRef} |
|
style={{ display: 'none' }} |
|
onChange={handleFileChange} |
|
accept={accept} |
|
multiple |
|
/> |
|
</div> |
|
) |
|
}
|
|
|