import * as React from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { useGetSet } from "../../hooks"; import { useGetKey } from "../../hooks/useGetKey"; import { mergeDefaults } from "../../utils/mergeDefaults"; import { setRef } from "../../utils/mergeRefs"; enum AcceptType { Image = "image/*", Video = "video/*", File = "application/*", } const defaultExtensions: Record = { [AcceptType.Image]: [ ".jpg", ".jpeg", ".png", ".svg", ".gif", ".webp", ".avif", ].join(","), [AcceptType.Video]: [".mp4", ".webm", ".mov", ".avi", ".mkv"].join(","), [AcceptType.File]: "", }; type Dimensions = { width: number; height: number; }; function getImageSize(file: File): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = function onload() { const { width, height } = img; resolve({ width, height }); }; img.onerror = reject; img.src = URL.createObjectURL(file); }); } export interface FileProps extends React.HTMLAttributes { children?: React.ReactNode; "data-id": string; allowMultiple?: boolean; minSize?: number; maxSize?: number; acceptType?: AcceptType; handleDrop?: (files: File[] | File) => Promise; onSuccess?: (e: Event) => void; extensions?: string; required?: boolean; capture?: "user" | "environment" | "implementation"; } function rafDebounce(fn) { let raf; return (...args) => { if (raf) { return; } raf = window.requestAnimationFrame(() => { fn(...args); // run useful code raf = undefined; }); }; } const BREVITY_UPLOAD_URL = "/api/upload/public"; const noop = () => {}; const MAX_FILE_SIZE = 1024 * 1024 * 1024 * 4; // 4 GB in bytes const DEFAULT_VALUE = { file: null, error: false, progress: 0, }; export const FileUpload = React.forwardRef(function FileUpload( args: FileProps, ref, ) { const { children, allowMultiple, minSize, maxSize, acceptType, handleDrop, onSuccess, extensions, required, capture, ...props } = mergeDefaults( args, { allowMultiple: false, minSize: 0, maxSize: MAX_FILE_SIZE, acceptType: AcceptType.Image, handleDrop: noop as any, onSuccess: noop, required: false, extensions: defaultExtensions[args.acceptType ?? AcceptType.File], }, true, ); const key = useGetKey(props); const [_, setState] = useGetSet(key, DEFAULT_VALUE); const onDrop = React.useCallback( async (acceptedFiles: File[]) => { if (acceptedFiles.length === 0) { inputRef.current?.setCustomValidity( allowMultiple ? "Please upload one or more files" : "Please upload a file", ); return; } inputRef.current?.setCustomValidity(""); const maybeFiles = allowMultiple ? acceptedFiles : acceptedFiles[0]; await handleDrop?.(maybeFiles); let payload: File | FormData; const request = new XMLHttpRequest(); const headers: Array<[string, string]> = []; if (allowMultiple) { payload = new FormData(); acceptedFiles.forEach((file) => { (payload as FormData).append("file", file); }); } else { payload = acceptedFiles[0]; if (payload.type.startsWith("image")) { const { width, height } = await getImageSize(payload); headers.push(["X-Image-Width", width.toString()]); headers.push(["X-Image-Height", height.toString()]); } } const method = allowMultiple ? "POST" : "PUT"; const url = allowMultiple ? BREVITY_UPLOAD_URL : `${BREVITY_UPLOAD_URL}/${(payload as File).name}`; request.open(method, url); headers.forEach(([key, value]) => { request.setRequestHeader(key, value); }); request.upload.addEventListener( "progress", rafDebounce((e) => { const percent_completed = (e.loaded / e.total) * 100; setState({ progress: percent_completed }); }), ); request.addEventListener("load", (e) => { if (request.readyState === request.DONE && request.status < 300) { const response = JSON.parse(request.responseText); setState({ progress: 100, file: response, error: false }); onSuccess?.(e); } else { setState({ progress: 0, file: null, error: true }); } }); // send POST request to server request.send(payload); }, [allowMultiple, handleDrop, onSuccess], ); const accept = React.useMemo(() => { if (acceptType && extensions) { return { accept: { [acceptType]: extensions.split(",").map((ext: string) => ext.trim()), }, }; } return {}; }, [acceptType, extensions]); const { getRootProps, getInputProps, isDragActive, inputRef, rootRef } = useDropzone({ ...accept, minSize, maxSize, multiple: allowMultiple, onDrop, onError(err) { toast.error(`Error uploading file`, { description: err.message, }); }, }); setRef(ref, rootRef.current); React.useEffect(() => { const parentForm = inputRef.current?.form; const handler = () => { setState(DEFAULT_VALUE); }; parentForm?.addEventListener("reset", handler); return () => { parentForm?.removeEventListener("reset", handler); }; }, []); React.useEffect(() => { inputRef.current?.setCustomValidity( required ? allowMultiple ? "Please upload one or more files" : "Please upload a file" : "", ); }, [required]); return (
{children}
); });