import type { DB } from "bknd"; import { type ComponentPropsWithRef, createContext, type ReactNode, type RefObject, useCallback, useContext, useEffect, useMemo, useRef, } from "react"; import { isFileAccepted } from "bknd/utils"; import { type FileWithPath, useDropzone } from "./use-dropzone"; import { checkMaxReached } from "./helper"; import { DropzoneInner } from "./DropzoneInner"; import { createDropzoneStore } from "ui/elements/media/dropzone-state"; import { useStore } from "zustand"; 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 FileStateWithData = FileState & { data: DB["media"] }; export type DropzoneRenderProps = { store: ReturnType; wrapperRef: RefObject; inputProps: ComponentPropsWithRef<"input">; actions: { uploadFile: (file: { path: string }) => Promise; deleteFile: (file: { path: string }) => Promise; openFileInput: () => void; addFiles: (files: (File | FileWithPath)[]) => void; }; showPlaceholder: boolean; onClick?: (file: { path: string }) => void; footer?: ReactNode; dropzoneProps: Pick< DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow" | "allowedMimeTypes" >; }; export type DropzoneProps = { /** * Get the upload info for a file */ getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string }; /** * Handle the deletion of a file */ handleDelete: (file: { path: string }) => Promise; /** * The initial items to display */ initialItems?: FileState[]; /** * Maximum number of media items that can be uploaded */ maxItems?: number; /** * The allowed mime types */ allowedMimeTypes?: string[]; /** * If true, the media item will be overwritten on entity media uploads if limit was reached */ overwrite?: boolean; /** * If true, the media items will be uploaded automatically */ autoUpload?: boolean; /** * Whether to add new items to the start or end of the list * @default "start" */ flow?: "start" | "end"; /** * The on rejected callback */ onRejected?: (files: FileWithPath[]) => void; /** * The on deleted callback */ onDeleted?: (file: { path: string }) => void; /** * The on uploaded all callback */ onUploadedAll?: (files: FileStateWithData[]) => void; /** * The on uploaded callback */ onUploaded?: (file: FileStateWithData) => void; /** * The on clicked callback */ onClick?: (file: FileState) => void; /** * The placeholder to use */ placeholder?: { show?: boolean; text?: string; }; /** * The footer to render */ footer?: ReactNode; /** * The children to render */ children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode); }; function handleUploadError(e: unknown) { if (e && e instanceof XMLHttpRequest) { const res = JSON.parse(e.responseText) as any; alert(`Upload failed with code ${e.status}: ${res.error}`); } else { alert("Upload failed"); } } export function Dropzone({ getUploadInfo, handleDelete, initialItems = [], flow = "start", allowedMimeTypes, maxItems, overwrite, autoUpload, placeholder, onRejected, onDeleted, onUploadedAll, onUploaded, children, onClick, footer, }: DropzoneProps) { const store = useRef(createDropzoneStore()).current; const files = useStore(store, (state) => state.files); const setFiles = useStore(store, (state) => state.setFiles); const getFilesLength = useStore(store, (state) => state.getFilesLength); const setUploading = useStore(store, (state) => state.setUploading); const setIsOver = useStore(store, (state) => state.setIsOver); const uploading = useStore(store, (state) => state.uploading); const setFileState = useStore(store, (state) => state.setFileState); const overrideFile = useStore(store, (state) => state.overrideFile); const removeFile = useStore(store, (state) => state.removeFile); const inputRef = useRef(null); useEffect(() => { // @todo: potentially keep pending ones setFiles(() => initialItems); }, [initialItems.length]); function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean { const items = Array.isArray(i) ? i : [i]; const specs = items.map((item) => ({ kind: "kind" in item ? item.kind : "file", type: item.type, size: "size" in item ? item.size : 0, })); return specs.every((spec) => { if (spec.kind !== "file") { console.warn("file not accepted: not a file", spec.kind); return false; } if (allowedMimeTypes && allowedMimeTypes.length > 0) { if (!isFileAccepted(i, allowedMimeTypes)) { console.warn("file not accepted: not allowed mimetype", spec.type); return false; } } return true; }); } const addFiles = useCallback( (newFiles: (File | FileWithPath)[]) => { console.log("onDropped", newFiles); if (!isAllowed(newFiles)) return; const added = newFiles.length; // Check max files using the current state, not a stale closure setFiles((currentFiles) => { let to_drop = 0; if (maxItems) { const $max = checkMaxReached({ maxItems, overwrite, added, current: currentFiles.length, }); if ($max.reject) { if (onRejected) { onRejected(newFiles); } else { console.warn("maxItems reached"); } // Return current state unchanged if rejected return currentFiles; } to_drop = $max.to_drop; } // drop amount calculated const _prev = currentFiles.slice(to_drop); // prep new files const currentPaths = _prev.map((f) => f.path); const filteredFiles: FileState[] = newFiles .filter((f) => !("path" in f) || (f.path && !currentPaths.includes(f.path))) .map((f) => ({ body: f, path: "path" in f ? f.path! : f.name, name: f.name, size: f.size, type: f.type, state: "pending", progress: 0, })); const updatedFiles = flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles]; if (autoUpload && filteredFiles.length > 0) { // Schedule upload for the next tick to ensure state is updated setTimeout(() => setUploading(true), 0); } return updatedFiles; }); }, [autoUpload, flow, maxItems, overwrite], ); const { handleFileInputChange, ref } = useDropzone({ onDropped: (newFiles: FileWithPath[]) => { console.log("onDropped", newFiles); addFiles(newFiles); }, onOver: (items) => { if (!isAllowed(items)) { setIsOver(true, false); return; } const current = getFilesLength(); const $max = checkMaxReached({ maxItems, overwrite, added: items.length, current, }); console.log("--files in onOver", current, $max); setIsOver(true, !$max.reject); }, onLeave: () => { setIsOver(false, 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 { const uploaded: FileStateWithData[] = []; for (const file of pendingFiles) { try { const progress = await uploadFileProgress(file); uploaded.push(progress); onUploaded?.(progress); } catch (e) { handleUploadError(e); } } setUploading(false); onUploadedAll?.(uploaded); } })(); } }, [uploading]); function uploadFileProgress(file: FileState): Promise { 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({ path: file.body.path! }); 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 && xhr.status < 300) { //setFileState(file.path, "uploaded", 1); console.log("Upload complete"); try { const response = JSON.parse(xhr.responseText); console.log("Response:", file, response); const newState = { ...response.state, progress: 1, state: "uploaded", }; overrideFile(file.path, newState); resolve({ ...response, ...file, ...newState }); } catch (e) { setFileState(file.path, "uploaded", 1); console.error("Error parsing response", e); reject(e); } } else { setFileState(file.path, "failed", 1); console.error("Upload failed with status: ", xhr.status, xhr.statusText); reject(xhr); } }; xhr.onerror = () => { console.error("Error during the upload process."); }; xhr.onloadstart = () => { setFileState(file.path, "uploading", 0); console.log("loadstart"); }; xhr.setRequestHeader("Accept", "application/json"); const formData = new FormData(); formData.append("file", file.body); xhr.send(formData); }); } const deleteFile = useCallback(async (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); removeFile(file.path); onDeleted?.(file); } break; } }, []); const uploadFile = useCallback(async (file: FileState) => { const result = await uploadFileProgress(file); onUploadedAll?.([result]); onUploaded?.(result); }, []); const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]); const showPlaceholder = useMemo( () => Boolean( placeholder?.show !== false && (!maxItems || (maxItems && files.length < maxItems)), ), [placeholder, maxItems, files.length], ); const renderProps = useMemo( () => ({ store, wrapperRef: ref, inputProps: { ref: inputRef, type: "file", multiple: !maxItems || maxItems > 1, onChange: handleFileInputChange, accept: allowedMimeTypes?.join(","), }, showPlaceholder, actions: { uploadFile, deleteFile, openFileInput, addFiles, }, dropzoneProps: { maxItems, placeholder, autoUpload, flow, allowedMimeTypes, }, onClick, footer, }), [maxItems, files.length, flow, placeholder, autoUpload, footer, allowedMimeTypes], ) as unknown as DropzoneRenderProps; return ( {children ? ( typeof children === "function" ? ( children(renderProps) ) : ( children ) ) : ( )} ); } const DropzoneContext = createContext(undefined!); export function useDropzoneContext() { return useContext(DropzoneContext); } export const useDropzoneState = () => { const { store } = useDropzoneContext(); const files = useStore(store, (state) => state.files); const isOver = useStore(store, (state) => state.isOver); const isOverAccepted = useStore(store, (state) => state.isOverAccepted); const uploading = useStore(store, (state) => state.uploading); return { files, isOver, isOverAccepted, uploading, }; }; export const useDropzoneFileState = ( pathOrFile: string | FileState, selector: (file: FileState) => R, ): R | undefined => { const { store } = useDropzoneContext(); return useStore(store, (state) => { const file = typeof pathOrFile === "string" ? state.files.find((f) => f.path === pathOrFile) : state.files.find((f) => f.path === pathOrFile.path); return file ? selector(file) : undefined; }); };