mirror of
https://github.com/bknd-io/bknd/
synced 2026-08-03 16:46:00 +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;
|
return (await res.blob()) as File;
|
||||||
}
|
}
|
||||||
|
|
||||||
getFileUploadUrl(file?: FileWithPath): string {
|
getFileUploadUrl(file?: { path: string }): string {
|
||||||
if (!file) return this.getUrl("/upload");
|
if (!file) return this.getUrl("/upload");
|
||||||
return this.getUrl(`/upload/${file.path}`);
|
return this.getUrl(`/upload/${file.path}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,8 +112,8 @@ export const useEntityQuery = <
|
|||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
|
|
||||||
const mutateAll = async () => {
|
const mutateFn = async (id?: PrimaryFieldType) => {
|
||||||
const entityKey = makeKey(api, entity as string);
|
const entityKey = makeKey(api, entity as string, id);
|
||||||
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
|
return mutate((key) => typeof key === "string" && key.startsWith(entityKey), undefined, {
|
||||||
revalidate: true,
|
revalidate: true,
|
||||||
});
|
});
|
||||||
@@ -126,7 +126,7 @@ export const useEntityQuery = <
|
|||||||
|
|
||||||
// mutate all keys of entity by default
|
// mutate all keys of entity by default
|
||||||
if (options?.revalidateOnMutate !== false) {
|
if (options?.revalidateOnMutate !== false) {
|
||||||
await mutateAll();
|
await mutateFn();
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
};
|
};
|
||||||
@@ -135,7 +135,7 @@ export const useEntityQuery = <
|
|||||||
return {
|
return {
|
||||||
...swr,
|
...swr,
|
||||||
...mapped,
|
...mapped,
|
||||||
mutate: mutateAll,
|
mutate: mutateFn,
|
||||||
mutateRaw: swr.mutate,
|
mutateRaw: swr.mutate,
|
||||||
api,
|
api,
|
||||||
key,
|
key,
|
||||||
|
|||||||
@@ -1,20 +1,21 @@
|
|||||||
import type { DB } from "core";
|
import type { DB } from "core";
|
||||||
import {
|
import {
|
||||||
type ComponentPropsWithRef,
|
type ComponentPropsWithRef,
|
||||||
type ComponentPropsWithoutRef,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
type RefObject,
|
type RefObject,
|
||||||
memo,
|
useCallback,
|
||||||
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} 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 { 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 = {
|
export type FileState = {
|
||||||
body: FileWithPath | string;
|
body: FileWithPath | string;
|
||||||
@@ -29,27 +30,23 @@ export type FileState = {
|
|||||||
export type FileStateWithData = FileState & { data: DB["media"] };
|
export type FileStateWithData = FileState & { data: DB["media"] };
|
||||||
|
|
||||||
export type DropzoneRenderProps = {
|
export type DropzoneRenderProps = {
|
||||||
|
store: ReturnType<typeof createDropzoneStore>;
|
||||||
wrapperRef: RefObject<HTMLDivElement | null>;
|
wrapperRef: RefObject<HTMLDivElement | null>;
|
||||||
inputProps: ComponentPropsWithRef<"input">;
|
inputProps: ComponentPropsWithRef<"input">;
|
||||||
state: {
|
|
||||||
files: FileState[];
|
|
||||||
isOver: boolean;
|
|
||||||
isOverAccepted: boolean;
|
|
||||||
showPlaceholder: boolean;
|
|
||||||
};
|
|
||||||
actions: {
|
actions: {
|
||||||
uploadFile: (file: FileState) => Promise<void>;
|
uploadFile: (file: { path: string }) => Promise<void>;
|
||||||
deleteFile: (file: FileState) => Promise<void>;
|
deleteFile: (file: { path: string }) => Promise<void>;
|
||||||
openFileInput: () => void;
|
openFileInput: () => void;
|
||||||
};
|
};
|
||||||
onClick?: (file: FileState) => void;
|
showPlaceholder: boolean;
|
||||||
|
onClick?: (file: { path: string }) => void;
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
dropzoneProps: Pick<DropzoneProps, "maxItems" | "placeholder" | "autoUpload" | "flow">;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DropzoneProps = {
|
export type DropzoneProps = {
|
||||||
getUploadInfo: (file: FileWithPath) => { url: string; headers?: Headers; method?: string };
|
getUploadInfo: (file: { path: string }) => { url: string; headers?: Headers; method?: string };
|
||||||
handleDelete: (file: FileState) => Promise<boolean>;
|
handleDelete: (file: { path: string }) => Promise<boolean>;
|
||||||
initialItems?: FileState[];
|
initialItems?: FileState[];
|
||||||
flow?: "start" | "end";
|
flow?: "start" | "end";
|
||||||
maxItems?: number;
|
maxItems?: number;
|
||||||
@@ -57,7 +54,7 @@ export type DropzoneProps = {
|
|||||||
overwrite?: boolean;
|
overwrite?: boolean;
|
||||||
autoUpload?: boolean;
|
autoUpload?: boolean;
|
||||||
onRejected?: (files: FileWithPath[]) => void;
|
onRejected?: (files: FileWithPath[]) => void;
|
||||||
onDeleted?: (file: FileState) => void;
|
onDeleted?: (file: { path: string }) => void;
|
||||||
onUploaded?: (files: FileStateWithData[]) => void;
|
onUploaded?: (files: FileStateWithData[]) => void;
|
||||||
onClick?: (file: FileState) => void;
|
onClick?: (file: FileState) => void;
|
||||||
placeholder?: {
|
placeholder?: {
|
||||||
@@ -65,7 +62,7 @@ export type DropzoneProps = {
|
|||||||
text?: string;
|
text?: string;
|
||||||
};
|
};
|
||||||
footer?: ReactNode;
|
footer?: ReactNode;
|
||||||
children?: (props: DropzoneRenderProps) => ReactNode;
|
children?: ReactNode | ((props: DropzoneRenderProps) => ReactNode);
|
||||||
};
|
};
|
||||||
|
|
||||||
function handleUploadError(e: unknown) {
|
function handleUploadError(e: unknown) {
|
||||||
@@ -94,30 +91,21 @@ export function Dropzone({
|
|||||||
onClick,
|
onClick,
|
||||||
footer,
|
footer,
|
||||||
}: DropzoneProps) {
|
}: DropzoneProps) {
|
||||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
const store = useRef(createDropzoneStore()).current;
|
||||||
const [uploading, setUploading] = useState<boolean>(false);
|
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 inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [isOverAccepted, setIsOverAccepted] = useState(false);
|
|
||||||
|
|
||||||
function isMaxReached(added: number): boolean {
|
useEffect(() => {
|
||||||
if (!maxItems) {
|
// @todo: potentially keep pending ones
|
||||||
console.log("maxItems is undefined, never reached");
|
setFiles(() => initialItems);
|
||||||
return false;
|
}, [initialItems.length]);
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean {
|
function isAllowed(i: DataTransferItem | DataTransferItem[] | File | File[]): boolean {
|
||||||
const items = Array.isArray(i) ? i : [i];
|
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[]) => {
|
onDropped: (newFiles: FileWithPath[]) => {
|
||||||
|
console.log("onDropped", newFiles);
|
||||||
if (!isAllowed(newFiles)) return;
|
if (!isAllowed(newFiles)) return;
|
||||||
|
|
||||||
let to_drop = 0;
|
|
||||||
const added = newFiles.length;
|
const added = newFiles.length;
|
||||||
|
|
||||||
if (maxItems) {
|
// Check max files using the current state, not a stale closure
|
||||||
if (isMaxReached(added)) {
|
setFiles((currentFiles) => {
|
||||||
if (onRejected) {
|
let to_drop = 0;
|
||||||
onRejected(newFiles);
|
|
||||||
} else {
|
if (maxItems) {
|
||||||
console.warn("maxItems reached");
|
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
|
// drop amount calculated
|
||||||
const _prev = prev.slice(to_drop);
|
const _prev = currentFiles.slice(to_drop);
|
||||||
|
|
||||||
// prep new files
|
// prep new files
|
||||||
const currentPaths = _prev.map((f) => f.path);
|
const currentPaths = _prev.map((f) => f.path);
|
||||||
@@ -175,24 +173,35 @@ export function Dropzone({
|
|||||||
progress: 0,
|
progress: 0,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles];
|
const updatedFiles =
|
||||||
});
|
flow === "start" ? [...filteredFiles, ..._prev] : [..._prev, ...filteredFiles];
|
||||||
|
|
||||||
if (autoUpload) {
|
if (autoUpload && filteredFiles.length > 0) {
|
||||||
setUploading(true);
|
// Schedule upload for the next tick to ensure state is updated
|
||||||
}
|
setTimeout(() => setUploading(true), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedFiles;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
onOver: (items) => {
|
onOver: (items) => {
|
||||||
if (!isAllowed(items)) {
|
if (!isAllowed(items)) {
|
||||||
setIsOverAccepted(false);
|
setIsOver(true, false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const max_reached = isMaxReached(items.length);
|
const current = getFilesLength();
|
||||||
setIsOverAccepted(!max_reached);
|
const $max = checkMaxReached({
|
||||||
|
maxItems,
|
||||||
|
overwrite,
|
||||||
|
added: items.length,
|
||||||
|
current,
|
||||||
|
});
|
||||||
|
console.log("--files in onOver", current, $max);
|
||||||
|
setIsOver(true, !$max.reject);
|
||||||
},
|
},
|
||||||
onLeave: () => {
|
onLeave: () => {
|
||||||
setIsOverAccepted(false);
|
setIsOver(false, false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -223,40 +232,6 @@ export function Dropzone({
|
|||||||
}
|
}
|
||||||
}, [uploading]);
|
}, [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> {
|
function uploadFileProgress(file: FileState): Promise<FileStateWithData> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!file.body) {
|
if (!file.body) {
|
||||||
@@ -273,7 +248,7 @@ export function Dropzone({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadInfo = getUploadInfo(file.body);
|
const uploadInfo = getUploadInfo({ path: file.body.path! });
|
||||||
console.log("dropzone:uploadInfo", uploadInfo);
|
console.log("dropzone:uploadInfo", uploadInfo);
|
||||||
const { url, headers, method = "POST" } = uploadInfo;
|
const { url, headers, method = "POST" } = uploadInfo;
|
||||||
|
|
||||||
@@ -322,7 +297,7 @@ export function Dropzone({
|
|||||||
state: "uploaded",
|
state: "uploaded",
|
||||||
};
|
};
|
||||||
|
|
||||||
replaceFileState(file.path, newState);
|
setFileState(file.path, newState.state);
|
||||||
resolve({ ...response, ...file, ...newState });
|
resolve({ ...response, ...file, ...newState });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setFileState(file.path, "uploaded", 1);
|
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);
|
console.log("deleteFile", file);
|
||||||
switch (file.state) {
|
switch (file.state) {
|
||||||
case "uploaded":
|
case "uploaded":
|
||||||
@@ -358,232 +333,97 @@ export function Dropzone({
|
|||||||
console.log('setting state to "deleting"', file);
|
console.log('setting state to "deleting"', file);
|
||||||
setFileState(file.path, "deleting");
|
setFileState(file.path, "deleting");
|
||||||
await handleDelete(file);
|
await handleDelete(file);
|
||||||
removeFileFromState(file.path);
|
removeFile(file.path);
|
||||||
onDeleted?.(file);
|
onDeleted?.(file);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
async function uploadFile(file: FileState) {
|
const uploadFile = useCallback(async (file: FileState) => {
|
||||||
const result = await uploadFileProgress(file);
|
const result = await uploadFileProgress(file);
|
||||||
onUploaded?.([result]);
|
onUploaded?.([result]);
|
||||||
}
|
}, []);
|
||||||
|
|
||||||
const openFileInput = () => inputRef.current?.click();
|
const openFileInput = useCallback(() => inputRef.current?.click(), [inputRef]);
|
||||||
const showPlaceholder = Boolean(
|
const showPlaceholder = useMemo(
|
||||||
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems),
|
() =>
|
||||||
|
Boolean(placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)),
|
||||||
|
[placeholder, maxItems, files.length],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderProps: DropzoneRenderProps = {
|
const renderProps = useMemo(
|
||||||
wrapperRef: ref,
|
() => ({
|
||||||
inputProps: {
|
store,
|
||||||
ref: inputRef,
|
wrapperRef: ref,
|
||||||
type: "file",
|
inputProps: {
|
||||||
multiple: !maxItems || maxItems > 1,
|
ref: inputRef,
|
||||||
onChange: handleFileInputChange,
|
type: "file",
|
||||||
},
|
multiple: !maxItems || maxItems > 1,
|
||||||
state: {
|
onChange: handleFileInputChange,
|
||||||
files,
|
},
|
||||||
isOver,
|
|
||||||
isOverAccepted,
|
|
||||||
showPlaceholder,
|
showPlaceholder,
|
||||||
},
|
actions: {
|
||||||
actions: {
|
uploadFile,
|
||||||
uploadFile,
|
deleteFile,
|
||||||
deleteFile,
|
openFileInput,
|
||||||
openFileInput,
|
},
|
||||||
},
|
dropzoneProps: {
|
||||||
dropzoneProps: {
|
maxItems,
|
||||||
maxItems,
|
placeholder,
|
||||||
placeholder,
|
autoUpload,
|
||||||
autoUpload,
|
flow,
|
||||||
flow,
|
},
|
||||||
},
|
onClick,
|
||||||
onClick,
|
footer,
|
||||||
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 = ({
|
const DropzoneContext = createContext<DropzoneRenderProps>(undefined!);
|
||||||
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} />
|
|
||||||
);
|
|
||||||
|
|
||||||
async function uploadHandler(file: FileState) {
|
export function useDropzoneContext() {
|
||||||
try {
|
return useContext(DropzoneContext);
|
||||||
return await uploadFile(file);
|
}
|
||||||
} catch (e) {
|
|
||||||
handleUploadError(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
export const useDropzoneState = () => {
|
||||||
<div
|
const { store } = useDropzoneContext();
|
||||||
ref={wrapperRef}
|
const files = useStore(store, (state) => state.files);
|
||||||
className={twMerge(
|
const isOver = useStore(store, (state) => state.isOver);
|
||||||
"dropzone w-full h-full align-start flex flex-col select-none",
|
const isOverAccepted = useStore(store, (state) => state.isOverAccepted);
|
||||||
isOver && isOverAccepted && "bg-green-200/10",
|
|
||||||
isOver && !isOverAccepted && "bg-red-200/40 cursor-not-allowed",
|
return {
|
||||||
)}
|
files,
|
||||||
>
|
isOver,
|
||||||
<div className="hidden">
|
isOverAccepted,
|
||||||
<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" }) => {
|
export const useDropzoneFileState = <R = any>(
|
||||||
return (
|
pathOrFile: string | FileState,
|
||||||
<div
|
selector: (file: FileState) => R,
|
||||||
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"
|
): R | undefined => {
|
||||||
onClick={onClick}
|
const { store } = useDropzoneContext();
|
||||||
>
|
return useStore(store, (state) => {
|
||||||
<span className="">{text}</span>
|
const file =
|
||||||
</div>
|
typeof pathOrFile === "string"
|
||||||
);
|
? state.files.find((f) => f.path === pathOrFile)
|
||||||
};
|
: state.files.find((f) => f.path === pathOrFile.path);
|
||||||
|
return file ? selector(file) : undefined;
|
||||||
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>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,23 +2,14 @@ import type { Api } from "bknd/client";
|
|||||||
import type { RepoQueryIn } from "data";
|
import type { RepoQueryIn } from "data";
|
||||||
import type { MediaFieldSchema } from "media/AppMedia";
|
import type { MediaFieldSchema } from "media/AppMedia";
|
||||||
import type { TAppMediaConfig } from "media/media-schema";
|
import type { TAppMediaConfig } from "media/media-schema";
|
||||||
import {
|
import { useId, useEffect, useRef, useState } from "react";
|
||||||
type ReactNode,
|
|
||||||
createContext,
|
|
||||||
useContext,
|
|
||||||
useId,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
|
import { useApi, useApiInfiniteQuery, useApiQuery, useInvalidate } from "ui/client";
|
||||||
import { useEvent } from "ui/hooks/use-event";
|
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 { mediaItemsToFileStates } from "./helper";
|
||||||
import { useInViewport } from "@mantine/hooks";
|
import { useInViewport } from "@mantine/hooks";
|
||||||
|
|
||||||
export type DropzoneContainerProps = {
|
export type DropzoneContainerProps = {
|
||||||
children?: ReactNode;
|
|
||||||
initialItems?: MediaFieldSchema[] | false;
|
initialItems?: MediaFieldSchema[] | false;
|
||||||
infinite?: boolean;
|
infinite?: boolean;
|
||||||
entity?: {
|
entity?: {
|
||||||
@@ -29,16 +20,13 @@ export type DropzoneContainerProps = {
|
|||||||
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
media?: Pick<TAppMediaConfig, "entity_name" | "storage">;
|
||||||
query?: RepoQueryIn;
|
query?: RepoQueryIn;
|
||||||
randomFilename?: boolean;
|
randomFilename?: boolean;
|
||||||
} & Omit<Partial<DropzoneProps>, "children" | "initialItems">;
|
} & Omit<Partial<DropzoneProps>, "initialItems">;
|
||||||
|
|
||||||
const DropzoneContainerContext = createContext<DropzoneRenderProps>(undefined!);
|
|
||||||
|
|
||||||
export function DropzoneContainer({
|
export function DropzoneContainer({
|
||||||
initialItems,
|
initialItems,
|
||||||
media,
|
media,
|
||||||
entity,
|
entity,
|
||||||
query,
|
query,
|
||||||
children,
|
|
||||||
randomFilename,
|
randomFilename,
|
||||||
infinite = false,
|
infinite = false,
|
||||||
...props
|
...props
|
||||||
@@ -51,33 +39,28 @@ export function DropzoneContainer({
|
|||||||
const defaultQuery = (page: number) => ({
|
const defaultQuery = (page: number) => ({
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset: page * pageSize,
|
offset: page * pageSize,
|
||||||
sort: "-id",
|
|
||||||
});
|
});
|
||||||
const entity_name = (media?.entity_name ?? "media") as "media";
|
const entity_name = (media?.entity_name ?? "media") as "media";
|
||||||
|
|
||||||
const selectApi = (api: Api, page: number = 0) =>
|
const selectApi = (api: Api, page: number = 0) =>
|
||||||
entity
|
entity
|
||||||
? api.data.readManyByReference(entity.name, entity.id, entity.field, {
|
? api.data.readManyByReference(entity.name, entity.id, entity.field, {
|
||||||
...query,
|
|
||||||
where: {
|
|
||||||
reference: `${entity.name}.${entity.field}`,
|
|
||||||
entity_id: entity.id,
|
|
||||||
...query?.where,
|
|
||||||
},
|
|
||||||
...defaultQuery(page),
|
...defaultQuery(page),
|
||||||
|
...query,
|
||||||
})
|
})
|
||||||
: api.data.readMany(entity_name, {
|
: api.data.readMany(entity_name, {
|
||||||
...query,
|
|
||||||
...defaultQuery(page),
|
...defaultQuery(page),
|
||||||
|
...query,
|
||||||
});
|
});
|
||||||
|
|
||||||
const $q = infinite
|
const $q = infinite
|
||||||
? useApiInfiniteQuery(selectApi, {})
|
? useApiInfiniteQuery(selectApi, {})
|
||||||
: useApiQuery(selectApi, {
|
: useApiQuery(selectApi, {
|
||||||
enabled: initialItems !== false && !initialItems,
|
enabled: initialItems !== false && !initialItems,
|
||||||
|
revalidateOnFocus: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const getUploadInfo = useEvent((file) => {
|
const getUploadInfo = useEvent((file: { path: string }) => {
|
||||||
const url = entity
|
const url = entity
|
||||||
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
|
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
|
||||||
: api.media.getFileUploadUrl(randomFilename ? undefined : file);
|
: api.media.getFileUploadUrl(randomFilename ? undefined : file);
|
||||||
@@ -93,7 +76,7 @@ export function DropzoneContainer({
|
|||||||
await invalidate($q.promise.key({ search: false }));
|
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);
|
return api.media.deleteFile(file.path);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -108,8 +91,8 @@ export function DropzoneContainer({
|
|||||||
key={id + key}
|
key={id + key}
|
||||||
getUploadInfo={getUploadInfo}
|
getUploadInfo={getUploadInfo}
|
||||||
handleDelete={handleDelete}
|
handleDelete={handleDelete}
|
||||||
onUploaded={refresh}
|
/* onUploaded={refresh}
|
||||||
onDeleted={refresh}
|
onDeleted={refresh} */
|
||||||
autoUpload
|
autoUpload
|
||||||
initialItems={_initialItems}
|
initialItems={_initialItems}
|
||||||
footer={
|
footer={
|
||||||
@@ -126,15 +109,7 @@ export function DropzoneContainer({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
{...props}
|
{...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[] {
|
): FileState[] {
|
||||||
return items.map((item) => mediaItemToFileState(item, options));
|
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 { PreviewWrapperMemoized } from "./DropzoneInner";
|
||||||
import { DropzoneContainer, useDropzone } from "./DropzoneContainer";
|
import { DropzoneContainer } from "./DropzoneContainer";
|
||||||
|
import { useDropzoneContext, useDropzoneFileState, useDropzoneState } from "./Dropzone";
|
||||||
|
|
||||||
export const Media = {
|
export const Media = {
|
||||||
Dropzone: DropzoneContainer,
|
Dropzone: DropzoneContainer,
|
||||||
Preview: PreviewWrapperMemoized,
|
Preview: PreviewWrapperMemoized,
|
||||||
useDropzone: useDropzone,
|
useDropzone: useDropzoneContext,
|
||||||
|
useDropzoneState,
|
||||||
|
useDropzoneFileState,
|
||||||
};
|
};
|
||||||
|
|
||||||
export { useDropzone as useMediaDropzone };
|
export {
|
||||||
|
useDropzoneContext as useMediaDropzone,
|
||||||
|
useDropzoneState as useMediaDropzoneState,
|
||||||
|
useDropzoneFileState as useMediaDropzoneFileState,
|
||||||
|
};
|
||||||
|
|
||||||
export type {
|
export type { FileState, FileStateWithData, DropzoneProps, DropzoneRenderProps } from "./Dropzone";
|
||||||
PreviewComponentProps,
|
export type { PreviewComponentProps } from "./DropzoneInner";
|
||||||
FileState,
|
|
||||||
FileStateWithData,
|
|
||||||
DropzoneProps,
|
|
||||||
DropzoneRenderProps,
|
|
||||||
} from "./Dropzone";
|
|
||||||
export type { DropzoneContainerProps } from "./DropzoneContainer";
|
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,
|
id: entityId,
|
||||||
field: field.name,
|
field: field.name,
|
||||||
}}
|
}}
|
||||||
|
query={{
|
||||||
|
sort: "-id",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</Formy.Group>
|
</Formy.Group>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function MediaIndex() {
|
|||||||
return (
|
return (
|
||||||
<AppShell.Scrollable>
|
<AppShell.Scrollable>
|
||||||
<div className="flex flex-1 p-3">
|
<div className="flex flex-1 p-3">
|
||||||
<Media.Dropzone onClick={onClick} infinite />
|
<Media.Dropzone onClick={onClick} infinite query={{ sort: "-id" }} />
|
||||||
</div>
|
</div>
|
||||||
</AppShell.Scrollable>
|
</AppShell.Scrollable>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ export default function DropzoneElementTest() {
|
|||||||
return (
|
return (
|
||||||
<Scrollable>
|
<Scrollable>
|
||||||
<div className="flex flex-col w-full h-full p-4 gap-4">
|
<div className="flex flex-col w-full h-full p-4 gap-4">
|
||||||
<div>
|
{/*<div>
|
||||||
<b>Dropzone no auto avif only</b>
|
<b>Dropzone no auto avif only</b>
|
||||||
<Media.Dropzone autoUpload={false} allowedMimeTypes={["image/avif"]} />
|
<Media.Dropzone autoUpload={false} allowedMimeTypes={["image/avif"]} />
|
||||||
|
|
||||||
@@ -17,18 +17,28 @@ export default function DropzoneElementTest() {
|
|||||||
>
|
>
|
||||||
<CustomUserAvatarDropzone />
|
<CustomUserAvatarDropzone />
|
||||||
</Media.Dropzone>
|
</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>
|
||||||
|
|
||||||
{/*<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>
|
<b>Dropzone User Avatar 1</b>
|
||||||
<Media.Dropzone entity={{ name: "users", id: 1, field: "avatar" }} maxItems={1} />
|
<Media.Dropzone entity={{ name: "users", id: 1, field: "avatar" }} maxItems={1} />
|
||||||
</div>
|
</div>
|
||||||
@@ -38,7 +48,7 @@ export default function DropzoneElementTest() {
|
|||||||
<Media.Dropzone query={{ limit: 2 }} />
|
<Media.Dropzone query={{ limit: 2 }} />
|
||||||
</div>*/}
|
</div>*/}
|
||||||
|
|
||||||
<div>
|
{/*<div>
|
||||||
<b>Dropzone Container blank</b>
|
<b>Dropzone Container blank</b>
|
||||||
<Media.Dropzone />
|
<Media.Dropzone />
|
||||||
</div>
|
</div>
|
||||||
@@ -46,7 +56,7 @@ export default function DropzoneElementTest() {
|
|||||||
<div>
|
<div>
|
||||||
<b>Dropzone Post 12</b>
|
<b>Dropzone Post 12</b>
|
||||||
<Media.Dropzone entity={{ name: "posts", id: 12, field: "images" }} />
|
<Media.Dropzone entity={{ name: "posts", id: 12, field: "images" }} />
|
||||||
</div>
|
</div>*/}
|
||||||
</div>
|
</div>
|
||||||
</Scrollable>
|
</Scrollable>
|
||||||
);
|
);
|
||||||
@@ -56,10 +66,14 @@ function CustomUserAvatarDropzone() {
|
|||||||
const {
|
const {
|
||||||
wrapperRef,
|
wrapperRef,
|
||||||
inputProps,
|
inputProps,
|
||||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
showPlaceholder,
|
||||||
actions: { openFileInput },
|
actions: { openFileInput },
|
||||||
} = Media.useDropzone();
|
} = Media.useDropzone();
|
||||||
const file = files[0];
|
const {
|
||||||
|
isOver,
|
||||||
|
isOverAccepted,
|
||||||
|
files: [file] = [],
|
||||||
|
} = Media.useDropzoneState();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -80,3 +94,34 @@ function CustomUserAvatarDropzone() {
|
|||||||
</div>
|
</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
@@ -51,6 +51,7 @@ if (example) {
|
|||||||
|
|
||||||
let app: App;
|
let app: App;
|
||||||
const recreate = import.meta.env.VITE_APP_FRESH === "1";
|
const recreate = import.meta.env.VITE_APP_FRESH === "1";
|
||||||
|
const debugRerenders = import.meta.env.VITE_DEBUG_RERENDERS === "1";
|
||||||
let firstStart = true;
|
let firstStart = true;
|
||||||
export default {
|
export default {
|
||||||
async fetch(request: Request) {
|
async fetch(request: Request) {
|
||||||
@@ -61,7 +62,7 @@ export default {
|
|||||||
app.emgr.onEvent(
|
app.emgr.onEvent(
|
||||||
App.Events.AppBuiltEvent,
|
App.Events.AppBuiltEvent,
|
||||||
async () => {
|
async () => {
|
||||||
app.registerAdminController({ forceDev: true });
|
app.registerAdminController({ forceDev: true, debugRerenders });
|
||||||
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
app.module.server.client.get("/assets/*", serveStatic({ root: "./" }));
|
||||||
},
|
},
|
||||||
"sync",
|
"sync",
|
||||||
@@ -73,11 +74,14 @@ export default {
|
|||||||
// log routes
|
// log routes
|
||||||
if (firstStart) {
|
if (firstStart) {
|
||||||
firstStart = false;
|
firstStart = false;
|
||||||
|
// biome-ignore lint/suspicious/noConsoleLog:
|
||||||
console.log("[DB]", credentials);
|
console.log("[DB]", credentials);
|
||||||
|
|
||||||
if (import.meta.env.VITE_SHOW_ROUTES === "1") {
|
if (import.meta.env.VITE_SHOW_ROUTES === "1") {
|
||||||
|
// biome-ignore lint/suspicious/noConsoleLog:
|
||||||
console.log("\n[APP ROUTES]");
|
console.log("\n[APP ROUTES]");
|
||||||
showRoutes(app.server);
|
showRoutes(app.server);
|
||||||
|
// biome-ignore lint/suspicious/noConsoleLog:
|
||||||
console.log("-------\n");
|
console.log("-------\n");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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):
|
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
|
```tsx
|
||||||
import { Media, useMediaDropzone } from "bknd/elements";
|
import { Media, useMediaDropzone, useMediaDropzoneState } from "bknd/elements";
|
||||||
|
|
||||||
export default function CustomUserAvatar() {
|
export default function CustomUserAvatar() {
|
||||||
return <Media.Dropzone
|
return <Media.Dropzone
|
||||||
@@ -68,11 +68,10 @@ function CustomUserAvatar() {
|
|||||||
const {
|
const {
|
||||||
wrapperRef,
|
wrapperRef,
|
||||||
inputProps,
|
inputProps,
|
||||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
showPlaceholder,
|
||||||
actions: { openFileInput }
|
actions: { openFileInput }
|
||||||
} = useMediaDropzone();
|
} = useMediaDropzone();
|
||||||
|
const { files: [ file ], isOver, isOverAccepted } = useMediaDropzoneState();
|
||||||
const file = files[0];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
Reference in New Issue
Block a user