mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-02 16:16:02 +00:00
refactor(dropzone): extract DropzoneInner and unify state management with zustand (#165)
Simplified Dropzone implementation by extracting inner logic to a new component, `DropzoneInner`. Replaced local dropzone state logic with centralized state management using zustand. Adjusted API exports and props accordingly for consistency and maintainability.
This commit is contained in:
@@ -48,7 +48,7 @@ export class MediaApi extends ModuleApi<MediaApiOptions> {
|
||||
return (await res.blob()) as File;
|
||||
}
|
||||
|
||||
getFileUploadUrl(file?: FileWithPath): string {
|
||||
getFileUploadUrl(file?: { path: string }): string {
|
||||
if (!file) return this.getUrl("/upload");
|
||||
return this.getUrl(`/upload/${file.path}`);
|
||||
}
|
||||
|
||||
@@ -112,8 +112,8 @@ export const useEntityQuery = <
|
||||
...options,
|
||||
});
|
||||
|
||||
const mutateAll = async () => {
|
||||
const entityKey = makeKey(api, entity as string);
|
||||
const mutateFn = async (id?: PrimaryFieldType) => {
|
||||
const entityKey = makeKey(api, entity as string, id);
|
||||
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
|
||||
revalidate: true,
|
||||
});
|
||||
@@ -126,7 +126,7 @@ export const useEntityQuery = <
|
||||
|
||||
// mutate all keys of entity by default
|
||||
if (options?.revalidateOnMutate !== false) {
|
||||
await mutateAll();
|
||||
await mutateFn();
|
||||
}
|
||||
return res;
|
||||
};
|
||||
@@ -135,7 +135,7 @@ export const useEntityQuery = <
|
||||
return {
|
||||
...swr,
|
||||
...mapped,
|
||||
mutate: mutateAll,
|
||||
mutate: mutateFn,
|
||||
mutateRaw: swr.mutate,
|
||||
api,
|
||||
key,
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { DB } from "core";
|
||||
import {
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
type RefObject,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { type FileWithPath, useDropzone } from "./use-dropzone";
|
||||
import { formatNumber } from "core/utils";
|
||||
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;
|
||||
@@ -29,27 +30,23 @@ export type FileState = {
|
||||
export type FileStateWithData = FileState & { data: DB["media"] };
|
||||
|
||||
export type DropzoneRenderProps = {
|
||||
store: ReturnType<typeof createDropzoneStore>;
|
||||
wrapperRef: RefObject<HTMLDivElement | null>;
|
||||
inputProps: ComponentPropsWithRef<"input">;
|
||||
state: {
|
||||
files: FileState[];
|
||||
isOver: boolean;
|
||||
isOverAccepted: boolean;
|
||||
showPlaceholder: boolean;
|
||||
};
|
||||
actions: {
|
||||
uploadFile: (file: FileState) => Promise<void>;
|
||||
deleteFile: (file: FileState) => Promise<void>;
|
||||
uploadFile: (file: { path: string }) => Promise<void>;
|
||||
deleteFile: (file: { path: string }) => Promise<void>;
|
||||
openFileInput: () => void;
|
||||
};
|
||||
onClick?: (file: FileState) => void;
|
||||
showPlaceholder: boolean;
|
||||
onClick?: (file: { path: string }) => void;
|
||||
footer?: ReactNode;
|
||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||
};
|
||||
|
||||
export type DropzoneProps = {
|
||||
getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string };
|
||||
handleDelete: (file: FileState) => Promise<boolean>;
|
||||
getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string };
|
||||
handleDelete: (file: { path: string }) => Promise<boolean>;
|
||||
initialItems?: FileState[];
|
||||
flow?: "start" | "end";
|
||||
maxItems?: number;
|
||||
@@ -57,7 +54,7 @@ export type DropzoneProps = {
|
||||
overwrite?: boolean;
|
||||
autoUpload?: boolean;
|
||||
onRejected?: (files: FileWithPath[]) => void;
|
||||
onDeleted?: (file: FileState) => void;
|
||||
onDeleted?: (file: { path: string }) => void;
|
||||
onUploaded?: (files: FileStateWithData[]) => void;
|
||||
onClick?: (file: FileState) => void;
|
||||
placeholder?: {
|
||||
@@ -65,7 +62,7 @@ export type DropzoneProps = {
|
||||
text?: string;
|
||||
};
|
||||
footer?: ReactNode;
|
||||
children?: (props: DropzoneRenderProps) => ReactNode;
|
||||
children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode);
|
||||
};
|
||||
|
||||
function handleUploadError(e: unknown) {
|
||||
@@ -94,30 +91,21 @@ export function Dropzone({
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneProps) {
|
||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
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 removeFile = useStore(store, (state) => state.removeFile);
|
||||
const inputRef = useRef<HTMLInputElement>(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;
|
||||
}
|
||||
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];
|
||||
@@ -135,31 +123,41 @@ export function Dropzone({
|
||||
});
|
||||
}
|
||||
|
||||
const { isOver, handleFileInputChange, ref } = useDropzone({
|
||||
const { handleFileInputChange, ref } = useDropzone({
|
||||
onDropped: (newFiles: FileWithPath[]) => {
|
||||
console.log("onDropped", newFiles);
|
||||
if (!isAllowed(newFiles)) return;
|
||||
|
||||
let to_drop = 0;
|
||||
const added = newFiles.length;
|
||||
|
||||
if (maxItems) {
|
||||
if (isMaxReached(added)) {
|
||||
if (onRejected) {
|
||||
onRejected(newFiles);
|
||||
} else {
|
||||
console.warn("maxItems reached");
|
||||
// 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;
|
||||
}
|
||||
|
||||
return;
|
||||
to_drop = $max.to_drop;
|
||||
}
|
||||
|
||||
to_drop = added;
|
||||
}
|
||||
|
||||
console.log("files", newFiles, { to_drop });
|
||||
setFiles((prev) => {
|
||||
// drop amount calculated
|
||||
const _prev = prev.slice(to_drop);
|
||||
const _prev = currentFiles.slice(to_drop);
|
||||
|
||||
// prep new files
|
||||
const currentPaths = _prev.map((f) => f.path);
|
||||
@@ -175,24 +173,35 @@ export function Dropzone({
|
||||
progress: 0,
|
||||
}));
|
||||
|
||||
return flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles];
|
||||
});
|
||||
const updatedFiles =
|
||||
flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles];
|
||||
|
||||
if (autoUpload) {
|
||||
setUploading(true);
|
||||
}
|
||||
if (autoUpload && filteredFiles.length > 0) {
|
||||
// Schedule upload for the next tick to ensure state is updated
|
||||
setTimeout(() => setUploading(true), 0);
|
||||
}
|
||||
|
||||
return updatedFiles;
|
||||
});
|
||||
},
|
||||
onOver: (items) => {
|
||||
if (!isAllowed(items)) {
|
||||
setIsOverAccepted(false);
|
||||
setIsOver(true, false);
|
||||
return;
|
||||
}
|
||||
|
||||
const max_reached = isMaxReached(items.length);
|
||||
setIsOverAccepted(!max_reached);
|
||||
const current = getFilesLength();
|
||||
const $max = checkMaxReached({
|
||||
maxItems,
|
||||
overwrite,
|
||||
added: items.length,
|
||||
current,
|
||||
});
|
||||
console.log("--files in onOver", current, $max);
|
||||
setIsOver(true, !$max.reject);
|
||||
},
|
||||
onLeave: () => {
|
||||
setIsOverAccepted(false);
|
||||
setIsOver(false, false);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -223,40 +232,6 @@ export function Dropzone({
|
||||
}
|
||||
}, [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<FileState>) {
|
||||
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): Promise<FileStateWithData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!file.body) {
|
||||
@@ -273,7 +248,7 @@ export function Dropzone({
|
||||
return;
|
||||
}
|
||||
|
||||
const uploadInfo = getUploadInfo(file.body);
|
||||
const uploadInfo = getUploadInfo({ path: file.body.path! });
|
||||
console.log("dropzone:uploadInfo", uploadInfo);
|
||||
const { url, headers, method = "POST" } = uploadInfo;
|
||||
|
||||
@@ -322,7 +297,7 @@ export function Dropzone({
|
||||
state: "uploaded",
|
||||
};
|
||||
|
||||
replaceFileState(file.path, newState);
|
||||
setFileState(file.path, newState.state);
|
||||
resolve({ ...response, ...file, ...newState });
|
||||
} catch (e) {
|
||||
setFileState(file.path, "uploaded", 1);
|
||||
@@ -349,7 +324,7 @@ export function Dropzone({
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteFile(file: FileState) {
|
||||
const deleteFile = useCallback(async (file: FileState) => {
|
||||
console.log("deleteFile", file);
|
||||
switch (file.state) {
|
||||
case "uploaded":
|
||||
@@ -358,232 +333,97 @@ export function Dropzone({
|
||||
console.log('setting state to "deleting"', file);
|
||||
setFileState(file.path, "deleting");
|
||||
await handleDelete(file);
|
||||
removeFileFromState(file.path);
|
||||
removeFile(file.path);
|
||||
onDeleted?.(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
async function uploadFile(file: FileState) {
|
||||
const uploadFile = useCallback(async (file: FileState) => {
|
||||
const result = await uploadFileProgress(file);
|
||||
onUploaded?.([result]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openFileInput = () => inputRef.current?.click();
|
||||
const showPlaceholder = Boolean(
|
||||
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems),
|
||||
const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
|
||||
const showPlaceholder = useMemo(
|
||||
() =>
|
||||
Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)),
|
||||
[placeholder, maxItems, files.length],
|
||||
);
|
||||
|
||||
const renderProps: DropzoneRenderProps = {
|
||||
wrapperRef: ref,
|
||||
inputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange,
|
||||
},
|
||||
state: {
|
||||
files,
|
||||
isOver,
|
||||
isOverAccepted,
|
||||
const renderProps = useMemo(
|
||||
() => ({
|
||||
store,
|
||||
wrapperRef: ref,
|
||||
inputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange,
|
||||
},
|
||||
showPlaceholder,
|
||||
},
|
||||
actions: {
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
openFileInput,
|
||||
},
|
||||
dropzoneProps: {
|
||||
maxItems,
|
||||
placeholder,
|
||||
autoUpload,
|
||||
flow,
|
||||
},
|
||||
onClick,
|
||||
footer,
|
||||
};
|
||||
actions: {
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
openFileInput,
|
||||
},
|
||||
dropzoneProps: {
|
||||
maxItems,
|
||||
placeholder,
|
||||
autoUpload,
|
||||
flow,
|
||||
},
|
||||
onClick,
|
||||
footer,
|
||||
}),
|
||||
[maxItems, flow, placeholder, autoUpload, footer],
|
||||
) as unknown as DropzoneRenderProps;
|
||||
|
||||
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
||||
return (
|
||||
<DropzoneContext.Provider value={renderProps}>
|
||||
{children ? (
|
||||
typeof children === "function" ? (
|
||||
children(renderProps)
|
||||
) : (
|
||||
children
|
||||
)
|
||||
) : (
|
||||
<DropzoneInner {...renderProps} />
|
||||
)}
|
||||
</DropzoneContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
const DropzoneInner = ({
|
||||
wrapperRef,
|
||||
inputProps,
|
||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
const Placeholder = showPlaceholder && (
|
||||
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
|
||||
);
|
||||
const DropzoneContext = createContext<DropzoneRenderProps>(undefined!);
|
||||
|
||||
async function uploadHandler(file: FileState) {
|
||||
try {
|
||||
return await uploadFile(file);
|
||||
} catch (e) {
|
||||
handleUploadError(e);
|
||||
}
|
||||
}
|
||||
export function useDropzoneContext() {
|
||||
return useContext(DropzoneContext);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={twMerge(
|
||||
"dropzone w-full h-full align-start flex flex-col select-none",
|
||||
isOver && isOverAccepted && "bg-green-200/10",
|
||||
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="hidden">
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
||||
{flow === "start" && Placeholder}
|
||||
{files.map((file) => (
|
||||
<Preview
|
||||
key={file.path}
|
||||
file={file}
|
||||
handleUpload={uploadHandler}
|
||||
handleDelete={deleteFile}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
{flow === "end" && Placeholder}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
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);
|
||||
|
||||
return {
|
||||
files,
|
||||
isOver,
|
||||
isOverAccepted,
|
||||
};
|
||||
};
|
||||
|
||||
const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
return (
|
||||
<div
|
||||
className="w-[49%] aspect-square md:w-60 flex flex-col border-2 border-dashed border-muted relative justify-center items-center text-primary/30 hover:border-primary/30 hover:text-primary/50 hover:cursor-pointer hover:bg-muted/20 transition-colors duration-200"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="">{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type PreviewComponentProps = {
|
||||
file: FileState;
|
||||
fallback?: (props: { file: FileState }) => ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onTouchStart?: () => void;
|
||||
};
|
||||
|
||||
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return <ImagePreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("video/")) {
|
||||
return <VideoPreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
handleDelete: (file: FileState) => Promise<void>;
|
||||
onClick?: (file: FileState) => void;
|
||||
};
|
||||
const Preview = ({ file, handleUpload, handleDelete, onClick }: PreviewProps) => {
|
||||
const dropdownItems = [
|
||||
file.state === "uploaded" &&
|
||||
typeof file.body === "string" && {
|
||||
label: "Open",
|
||||
icon: TbExternalLink,
|
||||
onClick: () => {
|
||||
window.open(file.body as string, "_blank");
|
||||
},
|
||||
},
|
||||
["initial", "uploaded"].includes(file.state) && {
|
||||
label: "Delete",
|
||||
destructive: true,
|
||||
icon: TbTrash,
|
||||
onClick: () => handleDelete(file),
|
||||
},
|
||||
["initial", "pending"].includes(file.state) && {
|
||||
label: "Upload",
|
||||
icon: TbUpload,
|
||||
onClick: () => handleUpload(file),
|
||||
},
|
||||
] satisfies (DropdownItem | boolean)[];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-[49%] md:w-60 aspect-square flex flex-col border border-muted relative hover:bg-primary/5 cursor-pointer transition-colors",
|
||||
file.state === "failed" && "border-red-500 bg-red-200/20",
|
||||
file.state === "deleting" && "opacity-70",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (onClick) {
|
||||
onClick(file);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="absolute top-2 right-2">
|
||||
<Dropdown items={dropdownItems} position="bottom-end">
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
</div>
|
||||
{file.state === "uploading" && (
|
||||
<div className="absolute w-full top-0 left-0 right-0 h-1">
|
||||
<div
|
||||
className="bg-blue-600 h-1 transition-all duration-75"
|
||||
style={{ width: (file.progress * 100).toFixed(0) + "%" }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex bg-primary/5 aspect-[1/0.78] overflow-hidden items-center justify-center">
|
||||
<PreviewWrapperMemoized
|
||||
file={file}
|
||||
fallback={FallbackPreview}
|
||||
className="max-w-full max-h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col px-1.5 py-1">
|
||||
<p className="truncate select-text">{file.name}</p>
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate select-text">{file.type}</span>
|
||||
<span>{formatNumber.fileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: FileState } & ComponentPropsWithoutRef<"img">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <img {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const VideoPreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: FileState } & ComponentPropsWithoutRef<"video">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const FallbackPreview = ({ file }: { file: FileState }) => {
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -2,23 +2,14 @@ import type { Api } from "bknd/client";
|
||||
import type { RepoQueryIn } from "data";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import {
|
||||
type ReactNode,
|
||||
createContext,
|
||||
useContext,
|
||||
useId,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useId, useEffect, useRef, useState } from "react";
|
||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type DropzoneProps, type DropzoneRenderProps, type FileState } from "./Dropzone";
|
||||
import { Dropzone, type DropzoneProps } from "./Dropzone";
|
||||
import { mediaItemsToFileStates } from "./helper";
|
||||
import { useInViewport } from "@mantine/hooks";
|
||||
|
||||
export type DropzoneContainerProps = {
|
||||
children?: ReactNode;
|
||||
initialItems?: MediaFieldSchema[] | false;
|
||||
infinite?: boolean;
|
||||
entity?: {
|
||||
@@ -29,16 +20,13 @@ export type DropzoneContainerProps = {
|
||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
||||
query?: RepoQueryIn;
|
||||
randomFilename?: boolean;
|
||||
} & Omit<Partial<DropzoneProps>, "children" | "initialItems">;
|
||||
|
||||
const DropzoneContainerContext = createContext<DropzoneRenderProps>(undefined!);
|
||||
} & Omit<Partial<DropzoneProps>, "initialItems">;
|
||||
|
||||
export function DropzoneContainer({
|
||||
initialItems,
|
||||
media,
|
||||
entity,
|
||||
query,
|
||||
children,
|
||||
randomFilename,
|
||||
infinite = false,
|
||||
...props
|
||||
@@ -51,33 +39,28 @@ export function DropzoneContainer({
|
||||
const defaultQuery = (page: number) => ({
|
||||
limit: pageSize,
|
||||
offset: page * pageSize,
|
||||
sort: "-id",
|
||||
});
|
||||
const entity_name = (media?.entity_name ?? "media") as "media";
|
||||
|
||||
const selectApi = (api: Api, page: number = 0) =>
|
||||
entity
|
||||
? api.data.readManyByReference(entity.name, entity.id, entity.field, {
|
||||
...query,
|
||||
where: {
|
||||
reference: `${entity.name}.${entity.field}`,
|
||||
entity_id: entity.id,
|
||||
...query?.where,
|
||||
},
|
||||
...defaultQuery(page),
|
||||
...query,
|
||||
})
|
||||
: api.data.readMany(entity_name, {
|
||||
...query,
|
||||
...defaultQuery(page),
|
||||
...query,
|
||||
});
|
||||
|
||||
const $q = infinite
|
||||
? useApiInfiniteQuery(selectApi, {})
|
||||
: useApiQuery(selectApi, {
|
||||
enabled: initialItems !== false && !initialItems,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
|
||||
const getUploadInfo = useEvent((file) => {
|
||||
const getUploadInfo = useEvent((file: { path: string }) => {
|
||||
const url = entity
|
||||
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
|
||||
: api.media.getFileUploadUrl(randomFilename ? undefined : file);
|
||||
@@ -93,7 +76,7 @@ export function DropzoneContainer({
|
||||
await invalidate($q.promise.key({ search: false }));
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file: FileState) => {
|
||||
const handleDelete = useEvent(async (file: { path: string }) => {
|
||||
return api.media.deleteFile(file.path);
|
||||
});
|
||||
|
||||
@@ -108,8 +91,8 @@ export function DropzoneContainer({
|
||||
key={id + key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
onUploaded={refresh}
|
||||
onDeleted={refresh}
|
||||
/* onUploaded={refresh}
|
||||
onDeleted={refresh} */
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
footer={
|
||||
@@ -126,15 +109,7 @@ export function DropzoneContainer({
|
||||
)
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children
|
||||
? (props) => (
|
||||
<DropzoneContainerContext.Provider value={props}>
|
||||
{children}
|
||||
</DropzoneContainerContext.Provider>
|
||||
)
|
||||
: undefined}
|
||||
</Dropzone>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -163,7 +138,3 @@ const Footer = ({ items = 0, length = 0, onFirstVisible }) => {
|
||||
/>
|
||||
));
|
||||
};
|
||||
|
||||
export function useDropzone() {
|
||||
return useContext(DropzoneContainerContext);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { type ComponentPropsWithoutRef, memo, type ReactNode, useCallback, useMemo } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import { useRenderCount } from "ui/hooks/use-render-count";
|
||||
import { TbDots, TbExternalLink, TbTrash, TbUpload } from "react-icons/tb";
|
||||
import { Dropdown, type DropdownItem } from "ui/components/overlay/Dropdown";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { formatNumber } from "core/utils";
|
||||
import type { DropzoneRenderProps, FileState } from "ui/elements";
|
||||
import { useDropzoneFileState, useDropzoneState } from "./Dropzone";
|
||||
|
||||
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 const DropzoneInner = ({
|
||||
wrapperRef,
|
||||
inputProps,
|
||||
showPlaceholder,
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder, flow },
|
||||
onClick,
|
||||
footer,
|
||||
}: DropzoneRenderProps) => {
|
||||
const { files, isOver, isOverAccepted } = useDropzoneState();
|
||||
const Placeholder = showPlaceholder && (
|
||||
<UploadPlaceholder onClick={openFileInput} text={placeholder?.text} />
|
||||
);
|
||||
|
||||
const uploadHandler = useCallback(
|
||||
async (file: { path: string }) => {
|
||||
try {
|
||||
return await uploadFile(file);
|
||||
} catch (e) {
|
||||
handleUploadError(e);
|
||||
}
|
||||
},
|
||||
[uploadFile],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={twMerge(
|
||||
"dropzone w-full h-full align-start flex flex-col select-none",
|
||||
isOver && isOverAccepted && "bg-green-200/10",
|
||||
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<div className="hidden">
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
||||
{flow === "start" && Placeholder}
|
||||
{files.map((file) => (
|
||||
<Preview
|
||||
key={file.path}
|
||||
file={file}
|
||||
handleUpload={uploadHandler}
|
||||
handleDelete={deleteFile}
|
||||
onClick={onClick}
|
||||
/>
|
||||
))}
|
||||
{flow === "end" && Placeholder}
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
return (
|
||||
<div
|
||||
className="w-[49%] aspect-square md:w-60 flex flex-col border-2 border-dashed border-muted relative justify-center items-center text-primary/30 hover:border-primary/30 hover:text-primary/50 hover:cursor-pointer hover:bg-muted/20 transition-colors duration-200"
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="">{text}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ReducedFile = Pick<FileState, "body" | "type" | "path" | "name" | "size">;
|
||||
export type PreviewComponentProps = {
|
||||
file: ReducedFile;
|
||||
fallback?: (props: { file: ReducedFile }) => ReactNode;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onTouchStart?: () => void;
|
||||
};
|
||||
|
||||
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return <ImagePreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("video/")) {
|
||||
return <VideoPreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
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<void>;
|
||||
handleDelete: (file: FileState) => Promise<void>;
|
||||
onClick?: (file: { path: string }) => void;
|
||||
};
|
||||
const Preview = memo(
|
||||
({ file: _file, handleUpload, handleDelete, onClick }: PreviewProps) => {
|
||||
const rcount = useRenderCount();
|
||||
const file = useDropzoneFileState(_file, (file) => {
|
||||
const { progress, ...rest } = file;
|
||||
return rest;
|
||||
});
|
||||
if (!file) return null;
|
||||
const onClickHandler = useCallback(() => {
|
||||
if (onClick) {
|
||||
onClick(file);
|
||||
}
|
||||
}, [onClick, file.path]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"w-[49%] md:w-60 aspect-square flex flex-col border border-muted relative hover:bg-primary/5 cursor-pointer transition-colors",
|
||||
file.state === "failed" && "border-red-500 bg-red-200/20",
|
||||
file.state === "deleting" && "opacity-70",
|
||||
)}
|
||||
onClick={onClickHandler}
|
||||
>
|
||||
<div className="absolute top-2 right-2">
|
||||
<PreviewDropdown
|
||||
file={file as any}
|
||||
handleDelete={handleDelete}
|
||||
handleUpload={handleUpload}
|
||||
/>
|
||||
</div>
|
||||
<PreviewUploadProgress file={file} />
|
||||
<div className="flex bg-primary/5 aspect-[1/0.78] overflow-hidden items-center justify-center">
|
||||
<PreviewWrapperMemoized
|
||||
file={file}
|
||||
fallback={FallbackPreview}
|
||||
className="max-w-full max-h-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col px-1.5 py-1">
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<p className="truncate select-text w-[calc(100%-10px)]">{file.name}</p>
|
||||
<StateIndicator file={file} />
|
||||
</div>
|
||||
<div className="flex flex-row justify-between text-sm font-mono opacity-50 text-nowrap gap-2">
|
||||
<span className="truncate select-text">{file.type}</span>
|
||||
<span>{formatNumber.fileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prev, next) => prev.file.path === next.file.path && prev.file.state === next.file.state,
|
||||
);
|
||||
|
||||
const PreviewUploadProgress = ({ file: _file }: { file: { path: string } }) => {
|
||||
const fileState = useDropzoneFileState(_file.path, (file) => ({
|
||||
state: file.state,
|
||||
progress: file.progress,
|
||||
}));
|
||||
if (!fileState) return null;
|
||||
if (fileState.state !== "uploading") return null;
|
||||
|
||||
return (
|
||||
<div className="absolute w-full top-0 left-0 right-0 h-1">
|
||||
<div
|
||||
className="bg-blue-600 h-1 transition-all duration-75"
|
||||
style={{ width: (fileState.progress * 100).toFixed(0) + "%" }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PreviewDropdown = memo(
|
||||
({
|
||||
file: _file,
|
||||
handleDelete,
|
||||
handleUpload,
|
||||
}: {
|
||||
file: FileState;
|
||||
handleDelete: (file: FileState) => Promise<void>;
|
||||
handleUpload: (file: FileState) => Promise<void>;
|
||||
}) => {
|
||||
const file = useDropzoneFileState(_file.path, (file) => {
|
||||
const { progress, ...rest } = file;
|
||||
return rest;
|
||||
});
|
||||
if (!file) return null;
|
||||
|
||||
const dropdownItems = useMemo(
|
||||
() =>
|
||||
[
|
||||
file.state === "uploaded" &&
|
||||
typeof file.body === "string" && {
|
||||
label: "Open",
|
||||
icon: TbExternalLink,
|
||||
onClick: () => {
|
||||
window.open(file.body as string, "_blank");
|
||||
},
|
||||
},
|
||||
["initial", "uploaded"].includes(file.state) && {
|
||||
label: "Delete",
|
||||
destructive: true,
|
||||
icon: TbTrash,
|
||||
onClick: () => handleDelete(file as any),
|
||||
},
|
||||
["initial", "pending"].includes(file.state) && {
|
||||
label: "Upload",
|
||||
icon: TbUpload,
|
||||
onClick: () => handleUpload(file as any),
|
||||
},
|
||||
] satisfies (DropdownItem | boolean)[],
|
||||
[file, handleDelete, handleUpload],
|
||||
);
|
||||
return (
|
||||
<Dropdown items={dropdownItems} position="bottom-end">
|
||||
<IconButton Icon={TbDots} />
|
||||
</Dropdown>
|
||||
);
|
||||
},
|
||||
(prev, next) => prev.file.path === next.file.path,
|
||||
);
|
||||
|
||||
const StateIndicator = ({ file: _file }: { file: { path: string } }) => {
|
||||
const fileState = useDropzoneFileState(_file.path, (file) => file.state);
|
||||
if (!fileState) return null;
|
||||
if (fileState === "uploaded") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const color =
|
||||
{
|
||||
failed: "bg-red-500",
|
||||
deleting: "bg-orange-500 animate-pulse",
|
||||
uploading: "bg-blue-500 animate-pulse",
|
||||
}[fileState] ?? "bg-primary/50";
|
||||
|
||||
return <div className={"w-2 h-2 rounded-full mt-px " + color} title={fileState} />;
|
||||
};
|
||||
|
||||
const ImagePreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: ReducedFile } & ComponentPropsWithoutRef<"img">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <img {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const VideoPreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: ReducedFile } & ComponentPropsWithoutRef<"video">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
|
||||
return <div className="text-xs text-primary/50 text-center">{file.type}</div>;
|
||||
};
|
||||
@@ -0,0 +1,42 @@
|
||||
import { createStore } from "zustand";
|
||||
import { combine } from "zustand/middleware";
|
||||
import type { FileState } from "./Dropzone";
|
||||
|
||||
export const createDropzoneStore = () => {
|
||||
return createStore(
|
||||
combine(
|
||||
{
|
||||
files: [] as FileState[],
|
||||
isOver: false,
|
||||
isOverAccepted: false,
|
||||
uploading: false,
|
||||
},
|
||||
(set, get) => ({
|
||||
setFiles: (fn: (files: FileState[]) => FileState[]) =>
|
||||
set(({ files }) => ({ files: fn(files) })),
|
||||
getFilesLength: () => get().files.length,
|
||||
setIsOver: (isOver: boolean, isOverAccepted: boolean) =>
|
||||
set({ isOver, isOverAccepted }),
|
||||
setUploading: (uploading: boolean) => set({ uploading }),
|
||||
setIsOverAccepted: (isOverAccepted: boolean) => set({ isOverAccepted }),
|
||||
reset: () => set({ files: [], isOver: false, isOverAccepted: false }),
|
||||
addFile: (file: FileState) => set((state) => ({ files: [...state.files, file] })),
|
||||
removeFile: (path: string) =>
|
||||
set((state) => ({ files: state.files.filter((f) => f.path !== path) })),
|
||||
removeAllFiles: () => set({ files: [] }),
|
||||
setFileProgress: (path: string, progress: number) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((f) => (f.path === path ? { ...f, progress } : f)),
|
||||
})),
|
||||
setFileState: (path: string, newState: FileState["state"], progress?: number) =>
|
||||
set((state) => ({
|
||||
files: state.files.map((f) =>
|
||||
f.path === path
|
||||
? { ...f, state: newState, progress: progress ?? f.progress }
|
||||
: f,
|
||||
),
|
||||
})),
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, test, expect } from "bun:test";
|
||||
import { checkMaxReached } from "./helper";
|
||||
|
||||
describe("media helper", () => {
|
||||
test("checkMaxReached", () => {
|
||||
expect(
|
||||
checkMaxReached({
|
||||
added: 1,
|
||||
}),
|
||||
).toEqual({ reject: false, to_drop: 0 });
|
||||
expect(
|
||||
checkMaxReached({
|
||||
maxItems: 1,
|
||||
added: 1,
|
||||
}),
|
||||
).toEqual({ reject: false, to_drop: 0 });
|
||||
expect(
|
||||
checkMaxReached({
|
||||
maxItems: 1,
|
||||
added: 2,
|
||||
}),
|
||||
).toEqual({ reject: true, to_drop: 2 });
|
||||
expect(
|
||||
checkMaxReached({
|
||||
maxItems: 2,
|
||||
overwrite: true,
|
||||
added: 4,
|
||||
}),
|
||||
).toEqual({ reject: true, to_drop: 4 });
|
||||
expect(
|
||||
checkMaxReached({
|
||||
maxItems: 2,
|
||||
current: 2,
|
||||
overwrite: true,
|
||||
added: 2,
|
||||
}),
|
||||
).toEqual({ reject: false, to_drop: 2 });
|
||||
expect(
|
||||
checkMaxReached({
|
||||
maxItems: 6,
|
||||
current: 5,
|
||||
overwrite: true,
|
||||
added: 1,
|
||||
}),
|
||||
).toEqual({ reject: false, to_drop: 0 });
|
||||
expect(
|
||||
checkMaxReached({
|
||||
maxItems: 6,
|
||||
current: 6,
|
||||
overwrite: true,
|
||||
added: 1,
|
||||
}),
|
||||
).toEqual({ reject: false, to_drop: 1 });
|
||||
console.log(
|
||||
checkMaxReached({
|
||||
maxItems: 6,
|
||||
current: 0,
|
||||
overwrite: true,
|
||||
added: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,3 +29,26 @@ export function mediaItemsToFileStates(
|
||||
): FileState[] {
|
||||
return items.map((item) => mediaItemToFileState(item, options));
|
||||
}
|
||||
|
||||
export function checkMaxReached({
|
||||
maxItems,
|
||||
current = 0,
|
||||
overwrite,
|
||||
added,
|
||||
}: { maxItems?: number; current?: number; overwrite?: boolean; added: number }) {
|
||||
if (!maxItems) {
|
||||
return {
|
||||
reject: false,
|
||||
to_drop: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const remaining = maxItems - current;
|
||||
const to_drop = added > remaining ? added : added - remaining > 0 ? added - remaining : 0;
|
||||
const reject = overwrite ? added > maxItems : remaining - added < 0;
|
||||
|
||||
return {
|
||||
reject,
|
||||
to_drop,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
import { PreviewWrapperMemoized } from "./Dropzone";
|
||||
import { DropzoneContainer, useDropzone } from "./DropzoneContainer";
|
||||
import { PreviewWrapperMemoized } from "./DropzoneInner";
|
||||
import { DropzoneContainer } from "./DropzoneContainer";
|
||||
import { useDropzoneContext, useDropzoneFileState, useDropzoneState } from "./Dropzone";
|
||||
|
||||
export const Media = {
|
||||
Dropzone: DropzoneContainer,
|
||||
Preview: PreviewWrapperMemoized,
|
||||
useDropzone: useDropzone,
|
||||
useDropzone: useDropzoneContext,
|
||||
useDropzoneState,
|
||||
useDropzoneFileState,
|
||||
};
|
||||
|
||||
export { useDropzone as useMediaDropzone };
|
||||
export {
|
||||
useDropzoneContext as useMediaDropzone,
|
||||
useDropzoneState as useMediaDropzoneState,
|
||||
useDropzoneFileState as useMediaDropzoneFileState,
|
||||
};
|
||||
|
||||
export type {
|
||||
PreviewComponentProps,
|
||||
FileState,
|
||||
FileStateWithData,
|
||||
DropzoneProps,
|
||||
DropzoneRenderProps,
|
||||
} from "./Dropzone";
|
||||
export type { FileState, FileStateWithData, DropzoneProps, DropzoneRenderProps } from "./Dropzone";
|
||||
export type { PreviewComponentProps } from "./DropzoneInner";
|
||||
export type { DropzoneContainerProps } from "./DropzoneContainer";
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
export function useRenderCount() {
|
||||
const count = useRef(0);
|
||||
count.current++;
|
||||
return count.current;
|
||||
}
|
||||
@@ -257,6 +257,9 @@ function EntityMediaFormField({
|
||||
id: entityId,
|
||||
field: field.name,
|
||||
}}
|
||||
query={{
|
||||
sort: "-id",
|
||||
}}
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
|
||||
@@ -35,7 +35,7 @@ export function MediaIndex() {
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Media.Dropzone onClick={onClick} infinite />
|
||||
<Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ export default function DropzoneElementTest() {
|
||||
return (
|
||||
<Scrollable>
|
||||
<div className="flex flex-col w-full h-full p-4 gap-4">
|
||||
<div>
|
||||
{/*<div>
|
||||
<b>Dropzone no auto avif only</b>
|
||||
<Media.Dropzone autoUpload={false} allowedMimeTypes={["image/avif"]} />
|
||||
|
||||
@@ -17,18 +17,28 @@ export default function DropzoneElementTest() {
|
||||
>
|
||||
<CustomUserAvatarDropzone />
|
||||
</Media.Dropzone>
|
||||
</div>*/}
|
||||
|
||||
<div>
|
||||
<b>Dropzone User Avatar 1 (overwrite)</b>
|
||||
<Media.Dropzone
|
||||
entity={{ name: "models", id: 38, field: "inputs" }}
|
||||
maxItems={6}
|
||||
autoUpload={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<b>Dropzone User Avatar 1 (overwrite)</b>
|
||||
<Media.Dropzone
|
||||
entity={{ name: "models", id: 38, field: "outputs" }}
|
||||
maxItems={6}
|
||||
autoUpload={false}
|
||||
>
|
||||
<Custom />
|
||||
</Media.Dropzone>
|
||||
</div>
|
||||
|
||||
{/*<div>
|
||||
<b>Dropzone User Avatar 1 (overwrite)</b>
|
||||
<Media.Dropzone
|
||||
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||
maxItems={1}
|
||||
overwrite
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<b>Dropzone User Avatar 1</b>
|
||||
<Media.Dropzone entity={{ name: "users", id: 1, field: "avatar" }} maxItems={1} />
|
||||
</div>
|
||||
@@ -38,7 +48,7 @@ export default function DropzoneElementTest() {
|
||||
<Media.Dropzone query={{ limit: 2 }} />
|
||||
</div>*/}
|
||||
|
||||
<div>
|
||||
{/*<div>
|
||||
<b>Dropzone Container blank</b>
|
||||
<Media.Dropzone />
|
||||
</div>
|
||||
@@ -46,7 +56,7 @@ export default function DropzoneElementTest() {
|
||||
<div>
|
||||
<b>Dropzone Post 12</b>
|
||||
<Media.Dropzone entity={{ name: "posts", id: 12, field: "images" }} />
|
||||
</div>
|
||||
</div>*/}
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
@@ -56,10 +66,14 @@ function CustomUserAvatarDropzone() {
|
||||
const {
|
||||
wrapperRef,
|
||||
inputProps,
|
||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||
showPlaceholder,
|
||||
actions: { openFileInput },
|
||||
} = Media.useDropzone();
|
||||
const file = files[0];
|
||||
const {
|
||||
isOver,
|
||||
isOverAccepted,
|
||||
files: [file] = [],
|
||||
} = Media.useDropzoneState();
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -80,3 +94,34 @@ function CustomUserAvatarDropzone() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Custom() {
|
||||
const {
|
||||
wrapperRef,
|
||||
inputProps,
|
||||
showPlaceholder,
|
||||
actions: { openFileInput },
|
||||
} = Media.useDropzone();
|
||||
const { isOver, isOverAccepted, files } = Media.useDropzoneState();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="size-32 rounded-full border border-gray-200 flex justify-center items-center leading-none overflow-hidden"
|
||||
>
|
||||
<div className="hidden">
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
<span>asdf</span>
|
||||
{showPlaceholder && <>{isOver && isOverAccepted ? "let it drop" : "drop here"}</>}
|
||||
{files.map((file) => (
|
||||
<Media.Preview
|
||||
key={file.path}
|
||||
file={file}
|
||||
className="object-cover w-full h-full"
|
||||
onClick={openFileInput}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user