mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-16 04:27:21 +00:00
Add Media.Dropzone and Media.Preview as isolated elements
Introduce `Media.*` to modularize and customize file upload handling. Refactor media-related components to improve usability, add max item limits, overwrite support, and event callbacks.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Const, Type, objectTransform } from "core/utils";
|
||||
import { Const, type Static, Type, objectTransform } from "core/utils";
|
||||
import { Adapters } from "media";
|
||||
import { registries } from "modules/registries";
|
||||
|
||||
@@ -47,3 +47,4 @@ export function buildMediaSchema() {
|
||||
}
|
||||
|
||||
export const mediaConfigSchema = buildMediaSchema();
|
||||
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;
|
||||
|
||||
@@ -143,6 +143,8 @@ export const useEntityQuery = <
|
||||
return {
|
||||
...swr,
|
||||
...mapped,
|
||||
mutate: mutateAll,
|
||||
mutateRaw: swr.mutate,
|
||||
api,
|
||||
key
|
||||
};
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export { Auth } from "ui/modules/auth/index";
|
||||
export * from "./media";
|
||||
|
||||
15
app/src/ui/elements/media.ts
Normal file
15
app/src/ui/elements/media.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { PreviewWrapperMemoized } from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { DropzoneContainer } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
|
||||
export const Media = {
|
||||
Dropzone: DropzoneContainer,
|
||||
Preview: PreviewWrapperMemoized
|
||||
};
|
||||
|
||||
export type {
|
||||
PreviewComponentProps,
|
||||
FileState,
|
||||
DropzoneProps,
|
||||
DropzoneRenderProps
|
||||
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||
export type { DropzoneContainerProps } from "ui/modules/media/components/dropzone/DropzoneContainer";
|
||||
@@ -10,13 +10,11 @@ import {
|
||||
} from "data";
|
||||
import { MediaField } from "media/MediaField";
|
||||
import { type ComponentProps, Suspense } from "react";
|
||||
import { useApi, useBaseUrl, useInvalidate } from "ui/client";
|
||||
import { JsonEditor } from "ui/components/code/JsonEditor";
|
||||
import * as Formy from "ui/components/form/Formy";
|
||||
import { FieldLabel } from "ui/components/form/Formy";
|
||||
import { Media } from "ui/elements";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import { Dropzone, type FileState } from "../../media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "../../media/helper";
|
||||
import { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
|
||||
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
|
||||
|
||||
@@ -215,9 +213,6 @@ function EntityMediaFormField({
|
||||
}) {
|
||||
if (!entityId) return;
|
||||
|
||||
const api = useApi();
|
||||
const baseUrl = useBaseUrl();
|
||||
const invalidate = useInvalidate();
|
||||
const value = formApi.useStore((state) => {
|
||||
const val = state.values[field.name];
|
||||
if (!val || typeof val === "undefined") return [];
|
||||
@@ -225,37 +220,20 @@ function EntityMediaFormField({
|
||||
return [val];
|
||||
});
|
||||
|
||||
const initialItems: FileState[] =
|
||||
value.length === 0
|
||||
? []
|
||||
: mediaItemsToFileStates(value, {
|
||||
baseUrl: api.baseUrl,
|
||||
overrides: { state: "uploaded" }
|
||||
});
|
||||
|
||||
const getUploadInfo = useEvent(() => {
|
||||
return {
|
||||
url: api.media.getEntityUploadUrl(entity.name, entityId, field.name),
|
||||
headers: api.media.getUploadHeaders(),
|
||||
method: "POST"
|
||||
};
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file: FileState) => {
|
||||
invalidate((api) => api.data.readOne(entity.name, entityId));
|
||||
return api.media.deleteFile(file.path);
|
||||
});
|
||||
const key = JSON.stringify([entity, entityId, field.name, value.length]);
|
||||
|
||||
return (
|
||||
<Formy.Group>
|
||||
<FieldLabel field={field} />
|
||||
<Dropzone
|
||||
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
initialItems={initialItems}
|
||||
<Media.Dropzone
|
||||
key={key}
|
||||
maxItems={field.getMaxItems()}
|
||||
autoUpload
|
||||
initialItems={value} /* @todo: test if better be omitted, so it fetches */
|
||||
entity={{
|
||||
name: entity.name,
|
||||
id: entityId,
|
||||
field: field.name
|
||||
}}
|
||||
/>
|
||||
</Formy.Group>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
type ComponentPropsWithRef,
|
||||
type ComponentPropsWithoutRef,
|
||||
type RefObject,
|
||||
memo,
|
||||
useEffect,
|
||||
@@ -28,10 +29,11 @@ export type DropzoneRenderProps = {
|
||||
state: {
|
||||
files: FileState[];
|
||||
isOver: boolean;
|
||||
isOverAccepted: boolean;
|
||||
showPlaceholder: boolean;
|
||||
};
|
||||
actions: {
|
||||
uploadFileProgress: (file: FileState) => Promise<void>;
|
||||
uploadFile: (file: FileState) => Promise<void>;
|
||||
deleteFile: (file: FileState) => Promise<void>;
|
||||
openFileInput: () => void;
|
||||
};
|
||||
@@ -43,11 +45,16 @@ export type DropzoneProps = {
|
||||
handleDelete: (file: FileState) => Promise<boolean>;
|
||||
initialItems?: FileState[];
|
||||
maxItems?: number;
|
||||
overwrite?: boolean;
|
||||
autoUpload?: boolean;
|
||||
onRejected?: (files: FileWithPath[]) => void;
|
||||
onDeleted?: (file: FileState) => void;
|
||||
onUploaded?: (file: FileState) => void;
|
||||
placeholder?: {
|
||||
show?: boolean;
|
||||
text?: string;
|
||||
};
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
};
|
||||
|
||||
export function Dropzone({
|
||||
@@ -55,23 +62,65 @@ export function Dropzone({
|
||||
handleDelete,
|
||||
initialItems = [],
|
||||
maxItems,
|
||||
overwrite,
|
||||
autoUpload,
|
||||
placeholder
|
||||
placeholder,
|
||||
onRejected,
|
||||
onDeleted,
|
||||
onUploaded,
|
||||
children
|
||||
}: DropzoneProps) {
|
||||
const [files, setFiles] = useState<FileState[]>(initialItems);
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
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;
|
||||
}
|
||||
|
||||
const { isOver, handleFileInputChange, ref } = useDropzone({
|
||||
onDropped: (newFiles: FileWithPath[]) => {
|
||||
if (maxItems && files.length + newFiles.length > maxItems) {
|
||||
alert("Max items reached");
|
||||
return;
|
||||
let to_drop = 0;
|
||||
const added = newFiles.length;
|
||||
|
||||
if (maxItems) {
|
||||
if (isMaxReached(added)) {
|
||||
if (onRejected) {
|
||||
onRejected(newFiles);
|
||||
} else {
|
||||
console.warn("maxItems reached");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
to_drop = added;
|
||||
}
|
||||
|
||||
console.log("files", newFiles);
|
||||
console.log("files", newFiles, { to_drop });
|
||||
setFiles((prev) => {
|
||||
const currentPaths = prev.map((f) => f.path);
|
||||
// drop amount calculated
|
||||
const _prev = prev.slice(to_drop);
|
||||
|
||||
// prep new files
|
||||
const currentPaths = _prev.map((f) => f.path);
|
||||
const filteredFiles: FileState[] = newFiles
|
||||
.filter((f) => f.path && !currentPaths.includes(f.path))
|
||||
.map((f) => ({
|
||||
@@ -84,7 +133,7 @@ export function Dropzone({
|
||||
progress: 0
|
||||
}));
|
||||
|
||||
return [...prev, ...filteredFiles];
|
||||
return [..._prev, ...filteredFiles];
|
||||
});
|
||||
|
||||
if (autoUpload) {
|
||||
@@ -92,17 +141,12 @@ export function Dropzone({
|
||||
}
|
||||
},
|
||||
onOver: (items) => {
|
||||
if (maxItems && files.length + items.length >= maxItems) {
|
||||
// indicate that the drop is not allowed
|
||||
return;
|
||||
}
|
||||
const max_reached = isMaxReached(items.length);
|
||||
setIsOverAccepted(!max_reached);
|
||||
},
|
||||
onLeave: () => {
|
||||
setIsOverAccepted(false);
|
||||
}
|
||||
/*onOver: (items) =>
|
||||
console.log(
|
||||
"onOver",
|
||||
items,
|
||||
items.map((i) => [i.kind, i.type].join(":"))
|
||||
)*/
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,7 +224,14 @@ export function Dropzone({
|
||||
formData.append("file", file.body);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open(method, url, true);
|
||||
const urlWithParams = new URL(url);
|
||||
if (overwrite) {
|
||||
urlWithParams.searchParams.append("overwrite", "1");
|
||||
}
|
||||
console.log("url", urlWithParams.toString());
|
||||
//return;
|
||||
|
||||
xhr.open(method, urlWithParams.toString(), true);
|
||||
|
||||
if (headers) {
|
||||
headers.forEach((value, key) => {
|
||||
@@ -207,6 +258,8 @@ export function Dropzone({
|
||||
if (xhr.status === 200) {
|
||||
//setFileState(file.path, "uploaded", 1);
|
||||
console.log("Upload complete");
|
||||
onUploaded?.(file);
|
||||
|
||||
try {
|
||||
const response = JSON.parse(xhr.responseText);
|
||||
|
||||
@@ -252,6 +305,7 @@ export function Dropzone({
|
||||
setFileState(file.path, "deleting");
|
||||
await handleDelete(file);
|
||||
removeFileFromState(file.path);
|
||||
onDeleted?.(file);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -262,54 +316,61 @@ export function Dropzone({
|
||||
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems)
|
||||
);
|
||||
|
||||
const Component = DropzoneInner;
|
||||
const renderProps: DropzoneRenderProps = {
|
||||
wrapperRef: ref,
|
||||
inputProps: {
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange
|
||||
},
|
||||
state: {
|
||||
files,
|
||||
isOver,
|
||||
isOverAccepted,
|
||||
showPlaceholder
|
||||
},
|
||||
actions: {
|
||||
uploadFile: uploadFileProgress,
|
||||
deleteFile,
|
||||
openFileInput
|
||||
},
|
||||
dropzoneProps: {
|
||||
maxItems,
|
||||
placeholder,
|
||||
autoUpload
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Component
|
||||
wrapperRef={ref}
|
||||
inputProps={{
|
||||
ref: inputRef,
|
||||
type: "file",
|
||||
multiple: !maxItems || maxItems > 1,
|
||||
onChange: handleFileInputChange
|
||||
}}
|
||||
state={{ files, isOver, showPlaceholder }}
|
||||
actions={{ uploadFileProgress, deleteFile, openFileInput }}
|
||||
dropzoneProps={{ maxItems, placeholder, autoUpload }}
|
||||
/>
|
||||
);
|
||||
return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
|
||||
}
|
||||
|
||||
const DropzoneInner = ({
|
||||
wrapperRef,
|
||||
inputProps,
|
||||
state: { files, isOver, showPlaceholder },
|
||||
actions: { uploadFileProgress, deleteFile, openFileInput },
|
||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||
actions: { uploadFile, deleteFile, openFileInput },
|
||||
dropzoneProps: { placeholder }
|
||||
}: DropzoneRenderProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
/*data-drag-over={"1"}*/
|
||||
data-drag-over={isOver ? "1" : undefined}
|
||||
className="dropzone data-[drag-over]:bg-green-200/10 w-full h-full align-start flex flex-col select-none"
|
||||
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}
|
||||
/*ref={inputRef}
|
||||
type="file"
|
||||
multiple={!maxItems || maxItems > 1}
|
||||
onChange={handleFileInputChange}*/
|
||||
/>
|
||||
<input {...inputProps} />
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-row flex-wrap gap-2 md:gap-3">
|
||||
{files.map((file, i) => (
|
||||
{files.map((file) => (
|
||||
<Preview
|
||||
key={file.path}
|
||||
file={file}
|
||||
handleUpload={uploadFileProgress}
|
||||
handleUpload={uploadFile}
|
||||
handleDelete={deleteFile}
|
||||
/>
|
||||
))}
|
||||
@@ -333,18 +394,29 @@ const UploadPlaceholder = ({ onClick, text = "Upload files" }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Wrapper = ({ file }: { file: FileState }) => {
|
||||
export type PreviewComponentProps = {
|
||||
file: FileState;
|
||||
fallback?: (props: { file: FileState }) => JSX.Element;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
onTouchStart?: () => void;
|
||||
};
|
||||
|
||||
const Wrapper = ({ file, fallback, ...props }: PreviewComponentProps) => {
|
||||
if (file.type.startsWith("image/")) {
|
||||
return <ImagePreview file={file} />;
|
||||
return <ImagePreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
if (file.type.startsWith("video/")) {
|
||||
return <VideoPreview file={file} />;
|
||||
return <VideoPreview {...props} file={file} />;
|
||||
}
|
||||
|
||||
return <FallbackPreview file={file} />;
|
||||
return fallback ? fallback({ file }) : null;
|
||||
};
|
||||
const WrapperMemoized = memo(Wrapper, (prev, next) => prev.file.path === next.file.path);
|
||||
export const PreviewWrapperMemoized = memo(
|
||||
Wrapper,
|
||||
(prev, next) => prev.file.path === next.file.path
|
||||
);
|
||||
|
||||
type PreviewProps = {
|
||||
file: FileState;
|
||||
@@ -370,7 +442,6 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
||||
file.state === "deleting" && "opacity-70"
|
||||
)}
|
||||
>
|
||||
{/*{file.state}*/}
|
||||
<div className="absolute top-2 right-2">
|
||||
<Dropdown items={dropdownItems} position="bottom-end">
|
||||
<IconButton Icon={TbDots} />
|
||||
@@ -385,7 +456,11 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
||||
</div>
|
||||
)}
|
||||
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center">
|
||||
<WrapperMemoized file={file} />
|
||||
<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">{file.name}</p>
|
||||
@@ -398,14 +473,20 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
|
||||
);
|
||||
};
|
||||
|
||||
const ImagePreview = ({ file }: { file: FileState }) => {
|
||||
const ImagePreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: FileState } & ComponentPropsWithoutRef<"img">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <img className="max-w-full max-h-full" src={objectUrl} />;
|
||||
return <img {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const VideoPreview = ({ file }: { file: FileState }) => {
|
||||
const VideoPreview = ({
|
||||
file,
|
||||
...props
|
||||
}: { file: FileState } & ComponentPropsWithoutRef<"video">) => {
|
||||
const objectUrl = typeof file.body === "string" ? file.body : URL.createObjectURL(file.body);
|
||||
return <video src={objectUrl} />;
|
||||
return <video {...props} src={objectUrl} />;
|
||||
};
|
||||
|
||||
const FallbackPreview = ({ file }: { file: FileState }) => {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { RepoQuery } from "data";
|
||||
import type { MediaFieldSchema } from "media/AppMedia";
|
||||
import type { TAppMediaConfig } from "media/media-schema";
|
||||
import { useId } from "react";
|
||||
import { useApi, useBaseUrl, useEntityQuery, useInvalidate } from "ui/client";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import {
|
||||
Dropzone,
|
||||
type DropzoneProps,
|
||||
type DropzoneRenderProps,
|
||||
type FileState
|
||||
} from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
||||
|
||||
export type DropzoneContainerProps = {
|
||||
children?: (props: DropzoneRenderProps) => JSX.Element;
|
||||
initialItems?: MediaFieldSchema[];
|
||||
entity?: {
|
||||
name: string;
|
||||
id: number;
|
||||
field: string;
|
||||
};
|
||||
query?: Partial<RepoQuery>;
|
||||
} & Partial<Pick<TAppMediaConfig, "basepath" | "entity_name" | "storage">> &
|
||||
Partial<DropzoneProps>;
|
||||
|
||||
export function DropzoneContainer({
|
||||
initialItems,
|
||||
basepath = "/api/media",
|
||||
storage = {},
|
||||
entity_name = "media",
|
||||
entity,
|
||||
query,
|
||||
...props
|
||||
}: DropzoneContainerProps) {
|
||||
const id = useId();
|
||||
const baseUrl = useBaseUrl();
|
||||
const api = useApi();
|
||||
const invalidate = useInvalidate();
|
||||
const limit = query?.limit ? query?.limit : props.maxItems ? props.maxItems : 50;
|
||||
|
||||
const $q = useEntityQuery(
|
||||
entity_name as "media",
|
||||
undefined,
|
||||
{
|
||||
...query,
|
||||
limit,
|
||||
where: entity
|
||||
? {
|
||||
reference: `${entity.name}.${entity.field}`,
|
||||
entity_id: entity.id,
|
||||
...query?.where
|
||||
}
|
||||
: query?.where
|
||||
},
|
||||
{ enabled: !initialItems }
|
||||
);
|
||||
|
||||
const getUploadInfo = useEvent((file) => {
|
||||
const url = entity
|
||||
? api.media.getEntityUploadUrl(entity.name, entity.id, entity.field)
|
||||
: api.media.getFileUploadUrl(file);
|
||||
|
||||
return {
|
||||
url,
|
||||
headers: api.media.getUploadHeaders(),
|
||||
method: "POST"
|
||||
};
|
||||
});
|
||||
|
||||
const refresh = useEvent(async () => {
|
||||
if (entity) {
|
||||
invalidate((api) => api.data.readOne(entity.name, entity.id));
|
||||
}
|
||||
await $q.mutate();
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file: FileState) => {
|
||||
return api.media.deleteFile(file.path);
|
||||
});
|
||||
|
||||
const actualItems = initialItems ?? (($q.data || []) as MediaFieldSchema[]);
|
||||
const _initialItems = mediaItemsToFileStates(actualItems, { baseUrl });
|
||||
|
||||
const key = id + JSON.stringify(_initialItems);
|
||||
return (
|
||||
<Dropzone
|
||||
key={id + key}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
onUploaded={refresh}
|
||||
onDeleted={refresh}
|
||||
autoUpload
|
||||
initialItems={_initialItems}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -4,15 +4,16 @@ import { type FileWithPath, fromEvent } from "./file-selector";
|
||||
type DropzoneProps = {
|
||||
onDropped: (files: FileWithPath[]) => void;
|
||||
onOver?: (items: DataTransferItem[]) => void;
|
||||
onLeave?: () => void;
|
||||
};
|
||||
|
||||
const events = {
|
||||
enter: ["dragenter", "dragover", "dragstart"],
|
||||
leave: ["dragleave", "drop"],
|
||||
leave: ["dragleave", "drop"]
|
||||
};
|
||||
const allEvents = [...events.enter, ...events.leave];
|
||||
|
||||
export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
||||
export function useDropzone({ onDropped, onOver, onLeave }: DropzoneProps) {
|
||||
const [isOver, setIsOver] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const onOverCalled = useRef(false);
|
||||
@@ -31,8 +32,10 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
||||
}
|
||||
|
||||
setIsOver(_isOver);
|
||||
|
||||
if (_isOver === false && onOverCalled.current) {
|
||||
onOverCalled.current = false;
|
||||
onLeave?.();
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -42,7 +45,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
||||
onDropped?.(files as any);
|
||||
onOverCalled.current = false;
|
||||
},
|
||||
[onDropped],
|
||||
[onDropped]
|
||||
);
|
||||
|
||||
const handleFileInputChange = useCallback(
|
||||
@@ -50,7 +53,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
|
||||
const files = await fromEvent(e);
|
||||
onDropped?.(files as any);
|
||||
},
|
||||
[onDropped],
|
||||
[onDropped]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import { IconPhoto } from "@tabler/icons-react";
|
||||
import type { MediaFieldSchema } from "modules";
|
||||
import { TbSettings } from "react-icons/tb";
|
||||
import { useApi, useBaseUrl, useEntityQuery } from "ui/client";
|
||||
import { useBknd } from "ui/client/BkndProvider";
|
||||
import { IconButton } from "ui/components/buttons/IconButton";
|
||||
import { Empty } from "ui/components/display/Empty";
|
||||
import { Link } from "ui/components/wouter/Link";
|
||||
import { Media } from "ui/elements";
|
||||
import { useBrowserTitle } from "ui/hooks/use-browser-title";
|
||||
import { useEvent } from "ui/hooks/use-event";
|
||||
import * as AppShell from "ui/layouts/AppShell/AppShell";
|
||||
import { Dropzone, type FileState } from "ui/modules/media/components/dropzone/Dropzone";
|
||||
import { mediaItemsToFileStates } from "ui/modules/media/helper";
|
||||
import { useLocation } from "wouter";
|
||||
|
||||
export function MediaRoot({ children }) {
|
||||
@@ -63,35 +59,11 @@ export function MediaRoot({ children }) {
|
||||
// @todo: add infinite load
|
||||
export function MediaEmpty() {
|
||||
useBrowserTitle(["Media"]);
|
||||
const baseUrl = useBaseUrl();
|
||||
const api = useApi();
|
||||
const $q = useEntityQuery("media", undefined, { limit: 50 });
|
||||
|
||||
const getUploadInfo = useEvent((file) => {
|
||||
return {
|
||||
url: api.media.getFileUploadUrl(file),
|
||||
headers: api.media.getUploadHeaders(),
|
||||
method: "POST"
|
||||
};
|
||||
});
|
||||
|
||||
const handleDelete = useEvent(async (file: FileState) => {
|
||||
return api.media.deleteFile(file.path);
|
||||
});
|
||||
|
||||
const media = ($q.data || []) as MediaFieldSchema[];
|
||||
const initialItems = mediaItemsToFileStates(media, { baseUrl });
|
||||
|
||||
return (
|
||||
<AppShell.Scrollable>
|
||||
<div className="flex flex-1 p-3">
|
||||
<Dropzone
|
||||
key={$q.isLoading ? "loaded" : "initial"}
|
||||
getUploadInfo={getUploadInfo}
|
||||
handleDelete={handleDelete}
|
||||
autoUpload
|
||||
initialItems={initialItems}
|
||||
/>
|
||||
<Media.Dropzone />
|
||||
</div>
|
||||
</AppShell.Scrollable>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,7 @@ import FlowFormTest from "../../routes/test/tests/flow-form-test";
|
||||
import ModalTest from "../../routes/test/tests/modal-test";
|
||||
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
|
||||
import DropdownTest from "./tests/dropdown-test";
|
||||
import DropzoneElementTest from "./tests/dropzone-element-test";
|
||||
import EntityFieldsForm from "./tests/entity-fields-form";
|
||||
import FlowsTest from "./tests/flows-test";
|
||||
import JsonFormTest from "./tests/jsonform-test";
|
||||
@@ -41,7 +42,8 @@ const tests = {
|
||||
AppShellAccordionsTest,
|
||||
SwaggerTest,
|
||||
SWRAndAPI,
|
||||
SwrAndDataApi
|
||||
SwrAndDataApi,
|
||||
DropzoneElementTest
|
||||
} as const;
|
||||
|
||||
export default function TestRoutes() {
|
||||
|
||||
78
app/src/ui/routes/test/tests/dropzone-element-test.tsx
Normal file
78
app/src/ui/routes/test/tests/dropzone-element-test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { type DropzoneRenderProps, Media } from "ui/elements";
|
||||
import { Scrollable } from "ui/layouts/AppShell/AppShell";
|
||||
|
||||
export default function DropzoneElementTest() {
|
||||
return (
|
||||
<Scrollable>
|
||||
<div className="flex flex-col w-full h-full p-4 gap-4">
|
||||
<div>
|
||||
<b>Dropzone User Avatar 1 (fully customized)</b>
|
||||
<Media.Dropzone
|
||||
entity={{ name: "users", id: 1, field: "avatar" }}
|
||||
maxItems={1}
|
||||
overwrite
|
||||
>
|
||||
{(props) => <CustomUserAvatarDropzone {...props} />}
|
||||
</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>
|
||||
|
||||
<div>
|
||||
<b>Dropzone Container blank w/ query</b>
|
||||
<Media.Dropzone query={{ limit: 2 }} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<b>Dropzone Container blank</b>
|
||||
<Media.Dropzone />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<b>Dropzone Post 12</b>
|
||||
<Media.Dropzone entity={{ name: "posts", id: 12, field: "images" }} />
|
||||
</div>
|
||||
</div>
|
||||
</Scrollable>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomUserAvatarDropzone({
|
||||
wrapperRef,
|
||||
inputProps,
|
||||
state: { files, isOver, isOverAccepted, showPlaceholder },
|
||||
actions: { openFileInput }
|
||||
}: DropzoneRenderProps) {
|
||||
const file = files[0];
|
||||
|
||||
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>
|
||||
{showPlaceholder && <>{isOver && isOverAccepted ? "let it drop" : "drop here"}</>}
|
||||
{file && (
|
||||
<Media.Preview
|
||||
file={file}
|
||||
className="object-cover w-full h-full"
|
||||
onClick={openFileInput}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user