mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
513 lines
15 KiB
TypeScript
513 lines
15 KiB
TypeScript
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<typeof createDropzoneStore>;
|
|
wrapperRef: RefObject<HTMLDivElement | null>;
|
|
inputProps: ComponentPropsWithRef<"input">;
|
|
actions: {
|
|
uploadFile: (file: { path: string }) => Promise<void>;
|
|
deleteFile: (file: { path: string }) => Promise<void>;
|
|
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<boolean>;
|
|
/**
|
|
* 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<HTMLInputElement>(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<FileStateWithData> {
|
|
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 (
|
|
<DropzoneContext.Provider value={renderProps}>
|
|
{children ? (
|
|
typeof children === "function" ? (
|
|
children(renderProps)
|
|
) : (
|
|
children
|
|
)
|
|
) : (
|
|
<DropzoneInner {...renderProps} />
|
|
)}
|
|
</DropzoneContext.Provider>
|
|
);
|
|
}
|
|
|
|
const DropzoneContext = createContext<DropzoneRenderProps>(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 = <R = any>(
|
|
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;
|
|
});
|
|
};
|