import { type ComponentPropsWithRef, type ComponentPropsWithoutRef, type RefObject, memo, useEffect, useRef, useState } from "react"; import { TbDots } from "react-icons/tb"; import { twMerge } from "tailwind-merge"; import { IconButton } from "ui/components/buttons/IconButton"; import { Dropdown } from "ui/components/overlay/Dropdown"; import { type FileWithPath, useDropzone } from "./use-dropzone"; export type FileState = { body: FileWithPath | string; path: string; name: string; size: number; type: string; state: "pending" | "uploading" | "uploaded" | "failed" | "initial" | "deleting"; progress: number; }; export type DropzoneRenderProps = { wrapperRef: RefObject; inputProps: ComponentPropsWithRef<"input">; state: { files: FileState[]; isOver: boolean; isOverAccepted: boolean; showPlaceholder: boolean; }; actions: { uploadFile: (file: FileState) => Promise; deleteFile: (file: FileState) => Promise; openFileInput: () => void; }; dropzoneProps: Pick; }; export type DropzoneProps = { getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string }; handleDelete: (file: FileState) => Promise; initialItems?: FileState[]; flow?: "start" | "end"; maxItems?: number; overwrite?: boolean; autoUpload?: boolean; onRejected?: (files: FileWithPath[]) => void; onDeleted?: (file: FileState) => void; onUploaded?: (files: FileState[]) => void; placeholder?: { show?: boolean; text?: string; }; children?: (props: DropzoneRenderProps) => JSX.Element; }; export function Dropzone({ getUploadInfo, handleDelete, initialItems = [], flow = "start", maxItems, overwrite, autoUpload, placeholder, onRejected, onDeleted, onUploaded, children }: DropzoneProps) { const [files, setFiles] = useState(initialItems); const [uploading, setUploading] = useState(false); const inputRef = useRef(null); const [isOverAccepted, setIsOverAccepted] = useState(false); function isMaxReached(added: number): boolean { if (!maxItems) { console.log("maxItems is undefined, never reached"); return false; } const current = files.length; const remaining = maxItems - current; console.log("isMaxReached", { added, current, remaining, maxItems, overwrite }); // if overwrite is set, but added is bigger than max items if (overwrite) { console.log("added > maxItems, stop?", added > maxItems); return added > maxItems; } console.log("remaining > added, stop?", remaining > added); // or remaining doesn't suffice, stop return added > remaining; } const { isOver, handleFileInputChange, ref } = useDropzone({ onDropped: (newFiles: FileWithPath[]) => { let to_drop = 0; const added = newFiles.length; if (maxItems) { if (isMaxReached(added)) { if (onRejected) { onRejected(newFiles); } else { console.warn("maxItems reached"); } return; } to_drop = added; } console.log("files", newFiles, { to_drop }); setFiles((prev) => { // drop amount calculated const _prev = prev.slice(to_drop); // prep new files const currentPaths = _prev.map((f) => f.path); const filteredFiles: FileState[] = newFiles .filter((f) => f.path && !currentPaths.includes(f.path)) .map((f) => ({ body: f, path: f.path!, name: f.name, size: f.size, type: f.type, state: "pending", progress: 0 })); return flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; }); if (autoUpload) { setUploading(true); } }, onOver: (items) => { const max_reached = isMaxReached(items.length); setIsOverAccepted(!max_reached); }, onLeave: () => { setIsOverAccepted(false); } }); useEffect(() => { console.log("files updated"); }, [files]); useEffect(() => { if (uploading) { (async () => { const pendingFiles = files.filter((f) => f.state === "pending"); if (pendingFiles.length === 0) { setUploading(false); return; } else { for (const file of pendingFiles) { await uploadFileProgress(file); } setUploading(false); onUploaded?.(files); } })(); } }, [uploading]); function setFileState(path: string, state: FileState["state"], progress?: number) { setFiles((prev) => prev.map((f) => { //console.log("compare", f.path, path, f.path === path); if (f.path === path) { return { ...f, state, progress: progress ?? f.progress }; } return f; }) ); } function replaceFileState(prevPath: string, newState: Partial) { setFiles((prev) => prev.map((f) => { if (f.path === prevPath) { return { ...f, ...newState }; } return f; }) ); } function removeFileFromState(path: string) { setFiles((prev) => prev.filter((f) => f.path !== path)); } function uploadFileProgress(file: FileState) { return new Promise((resolve, reject) => { if (!file.body) { console.error("File has no body"); reject(); return; } else if (file.state !== "pending") { console.error("File is not pending"); reject(); return; } else if (file.body instanceof File === false) { console.error("File body is not a File instance"); reject(); return; } const uploadInfo = getUploadInfo(file.body); console.log("dropzone:uploadInfo", uploadInfo); const { url, headers, method = "POST" } = uploadInfo; const xhr = new XMLHttpRequest(); console.log("xhr:url", url); const searchParams = new URLSearchParams(); if (overwrite) { searchParams.append("overwrite", "1"); } xhr.open(method, String(url) + "?" + String(searchParams), true); if (headers) { headers.forEach((value, key) => { xhr.setRequestHeader(key, value); }); } // Handle progress events xhr.upload.addEventListener("progress", (event) => { console.log("progress", event.loaded, event.total); if (event.lengthComputable) { setFileState(file.path, "uploading", event.loaded / event.total); const percentComplete = (event.loaded / event.total) * 100; console.log(`Progress: ${percentComplete.toFixed(2)}%`); } else { console.log( "Unable to compute progress information since the total size is unknown" ); } }); xhr.onload = () => { console.log("onload", file.path, xhr.status); if (xhr.status === 200) { //setFileState(file.path, "uploaded", 1); console.log("Upload complete"); try { const response = JSON.parse(xhr.responseText); console.log("Response:", file, response); console.log("New state", response.state); replaceFileState(file.path, { ...response.state, progress: 1, state: "uploaded" }); } catch (e) { setFileState(file.path, "uploaded", 1); console.error("Error parsing response", e); } resolve(); } else { setFileState(file.path, "failed", 1); console.error("Upload failed with status: ", xhr.status); reject(); } }; xhr.onerror = () => { console.error("Error during the upload process."); }; xhr.onloadstart = () => { setFileState(file.path, "uploading", 0); console.log("loadstart"); }; xhr.setRequestHeader("Accept", "application/json"); xhr.send(file.body); }); } async function deleteFile(file: FileState) { console.log("deleteFile", file); switch (file.state) { case "uploaded": case "initial": if (window.confirm("Are you sure you want to delete this file?")) { console.log('setting state to "deleting"', file); setFileState(file.path, "deleting"); await handleDelete(file); removeFileFromState(file.path); onDeleted?.(file); } break; } } async function uploadFile(file: FileState) { await uploadFileProgress(file); onUploaded?.([file]); } const openFileInput = () => inputRef.current?.click(); const showPlaceholder = Boolean( placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems) ); const renderProps: DropzoneRenderProps = { wrapperRef: ref, inputProps: { ref: inputRef, type: "file", multiple: !maxItems || maxItems > 1, onChange: handleFileInputChange }, state: { files, isOver, isOverAccepted, showPlaceholder }, actions: { uploadFile, deleteFile, openFileInput }, dropzoneProps: { maxItems, placeholder, autoUpload, flow } }; return children ? children(renderProps) : ; } const DropzoneInner = ({ wrapperRef, inputProps, state: { files, isOver, isOverAccepted, showPlaceholder }, actions: { uploadFile, deleteFile, openFileInput }, dropzoneProps: { placeholder, flow } }: DropzoneRenderProps) => { const Placeholder = showPlaceholder && ( ); return (
{flow === "start" && Placeholder} {files.map((file) => ( ))} {flow === "end" && Placeholder}
); }; const UploadPlaceholder = ({ onClick, text = "Upload files" }) => { return (
{text}
); }; export type PreviewComponentProps = { file: FileState; fallback?: (props: { file: FileState }) => JSX.Element; className?: string; onClick?: () => void; onTouchStart?: () => void; }; const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => { if (file.type.startsWith("image/")) { return ; } if (file.type.startsWith("video/")) { return ; } return fallback ? fallback({ file }) : null; }; export const PreviewWrapperMemoized = memo( Wrapper, (prev, next) => prev.file.path === next.file.path ); type PreviewProps = { file: FileState; handleUpload: (file: FileState) => Promise; handleDelete: (file: FileState) => Promise; }; const Preview: React.FC = ({ file, handleUpload, handleDelete }) => { const dropdownItems = [ ["initial", "uploaded"].includes(file.state) && { label: "Delete", onClick: () => handleDelete(file) }, ["initial", "pending"].includes(file.state) && { label: "Upload", onClick: () => handleUpload(file) } ]; return (
{file.state === "uploading" && (
)}

{file.name}

{file.type} {(file.size / 1024).toFixed(1)} KB
); }; const ImagePreview = ({ file, ...props }: { file: FileState } & ComponentPropsWithoutRef<"img">) => { const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); return ; }; const VideoPreview = ({ file, ...props }: { file: FileState } & ComponentPropsWithoutRef<"video">) => { const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body); return