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:
dswbx
2025-04-25 22:37:28 -07:00
committed by GitHub
parent b60fd01960
commit eec8b64bae
15 changed files with 664 additions and 389 deletions
+1 -1
View File
@@ -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}`);
}
+4 -4
View File
@@ -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,
+125 -285
View File
@@ -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;
// Check max files using the current state, not a stale closure
setFiles((currentFiles) => {
let to_drop = 0;
if (maxItems) {
if (isMaxReached(added)) {
const $max = checkMaxReached({
maxItems,
overwrite,
added,
current: currentFiles.length,
});
if ($max.reject) {
if (onRejected) {
onRejected(newFiles);
} else {
console.warn("maxItems reached");
}
return;
// Return current state unchanged if rejected
return currentFiles;
}
to_drop = added;
to_drop = $max.to_drop;
}
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,24 +333,28 @@ 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 = {
const renderProps = useMemo(
() => ({
store,
wrapperRef: ref,
inputProps: {
ref: inputRef,
@@ -383,12 +362,7 @@ export function Dropzone({
multiple: !maxItems || maxItems > 1,
onChange: handleFileInputChange,
},
state: {
files,
isOver,
isOverAccepted,
showPlaceholder,
},
actions: {
uploadFile,
deleteFile,
@@ -402,188 +376,54 @@ export function Dropzone({
},
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;
});
};
+11 -40
View File
@@ -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);
}
+276
View File
@@ -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,
),
})),
}),
),
);
};
+63
View File
@@ -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,
}),
);
});
});
+23
View File
@@ -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,
};
}
+13 -11
View File
@@ -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";
+7
View File
@@ -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>
);
+1 -1
View File
@@ -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>
);
}
+5 -1
View File
@@ -51,6 +51,7 @@ if (example) {
let app: App;
const recreate = import.meta.env.VITE_APP_FRESH === "1";
const debugRerenders = import.meta.env.VITE_DEBUG_RERENDERS === "1";
let firstStart = true;
export default {
async fetch(request: Request) {
@@ -61,7 +62,7 @@ export default {
app.emgr.onEvent(
App.Events.AppBuiltEvent,
async () => {
app.registerAdminController({ forceDev: true });
app.registerAdminController({ forceDev: true, debugRerenders });
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
},
"sync",
@@ -73,11 +74,14 @@ export default {
// log routes
if (firstStart) {
firstStart = false;
// biome-ignore lint/suspicious/noConsoleLog:
console.log("[DB]", credentials);
if (import.meta.env.VITE_SHOW_ROUTES === "1") {
// biome-ignore lint/suspicious/noConsoleLog:
console.log("\n[APP ROUTES]");
showRoutes(app.server);
// biome-ignore lint/suspicious/noConsoleLog:
console.log("-------\n");
}
}
+3 -4
View File
@@ -52,7 +52,7 @@ export default function UserAvatar() {
You can also customize the rendering of the media items and its uploading by passing a react element as a child. Here is an example of a custom `Media.Dropzone` that renders an user avatar (styled using tailwind):
```tsx
import { Media, useMediaDropzone } from "bknd/elements";
import { Media, useMediaDropzone, useMediaDropzoneState } from "bknd/elements";
export default function CustomUserAvatar() {
return <Media.Dropzone
@@ -68,11 +68,10 @@ function CustomUserAvatar() {
const {
wrapperRef,
inputProps,
state: { files, isOver, isOverAccepted, showPlaceholder },
showPlaceholder,
actions: { openFileInput }
} = useMediaDropzone();
const file = files[0];
const { files: [ file ], isOver, isOverAccepted } = useMediaDropzoneState();
return (
<div