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:
dswbx
2025-01-09 15:43:43 +01:00
parent 5c7bfeab8f
commit d182640981
11 changed files with 358 additions and 127 deletions

View File

@@ -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 { Adapters } from "media";
import { registries } from "modules/registries"; import { registries } from "modules/registries";
@@ -47,3 +47,4 @@ export function buildMediaSchema() {
} }
export const mediaConfigSchema = buildMediaSchema(); export const mediaConfigSchema = buildMediaSchema();
export type TAppMediaConfig = Static<typeof mediaConfigSchema>;

View File

@@ -143,6 +143,8 @@ export const useEntityQuery = <
return { return {
...swr, ...swr,
...mapped, ...mapped,
mutate: mutateAll,
mutateRaw: swr.mutate,
api, api,
key key
}; };

View File

@@ -1 +1,2 @@
export { Auth } from "ui/modules/auth/index"; export { Auth } from "ui/modules/auth/index";
export * from "./media";

View 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";

View File

@@ -10,13 +10,11 @@ import {
} from "data"; } from "data";
import { MediaField } from "media/MediaField"; import { MediaField } from "media/MediaField";
import { type ComponentProps, Suspense } from "react"; import { type ComponentProps, Suspense } from "react";
import { useApi, useBaseUrl, useInvalidate } from "ui/client";
import { JsonEditor } from "ui/components/code/JsonEditor"; import { JsonEditor } from "ui/components/code/JsonEditor";
import * as Formy from "ui/components/form/Formy"; import * as Formy from "ui/components/form/Formy";
import { FieldLabel } 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 { 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 { EntityJsonSchemaFormField } from "./fields/EntityJsonSchemaFormField";
import { EntityRelationalFormField } from "./fields/EntityRelationalFormField"; import { EntityRelationalFormField } from "./fields/EntityRelationalFormField";
@@ -215,9 +213,6 @@ function EntityMediaFormField({
}) { }) {
if (!entityId) return; if (!entityId) return;
const api = useApi();
const baseUrl = useBaseUrl();
const invalidate = useInvalidate();
const value = formApi.useStore((state) => { const value = formApi.useStore((state) => {
const val = state.values[field.name]; const val = state.values[field.name];
if (!val || typeof val === "undefined") return []; if (!val || typeof val === "undefined") return [];
@@ -225,37 +220,20 @@ function EntityMediaFormField({
return [val]; return [val];
}); });
const initialItems: FileState[] = const key = JSON.stringify([entity, entityId, field.name, value.length]);
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);
});
return ( return (
<Formy.Group> <Formy.Group>
<FieldLabel field={field} /> <FieldLabel field={field} />
<Dropzone <Media.Dropzone
key={`${entity.name}-${entityId}-${field.name}-${value.length === 0 ? "initial" : "loaded"}`} key={key}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
initialItems={initialItems}
maxItems={field.getMaxItems()} 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> </Formy.Group>
); );

View File

@@ -1,5 +1,6 @@
import { import {
type ComponentPropsWithRef, type ComponentPropsWithRef,
type ComponentPropsWithoutRef,
type RefObject, type RefObject,
memo, memo,
useEffect, useEffect,
@@ -28,10 +29,11 @@ export type DropzoneRenderProps = {
state: { state: {
files: FileState[]; files: FileState[];
isOver: boolean; isOver: boolean;
isOverAccepted: boolean;
showPlaceholder: boolean; showPlaceholder: boolean;
}; };
actions: { actions: {
uploadFileProgress: (file: FileState) => Promise<void>; uploadFile: (file: FileState) => Promise<void>;
deleteFile: (file: FileState) => Promise<void>; deleteFile: (file: FileState) => Promise<void>;
openFileInput: () => void; openFileInput: () => void;
}; };
@@ -43,11 +45,16 @@ export type DropzoneProps = {
handleDelete: (file: FileState) => Promise<boolean>; handleDelete: (file: FileState) => Promise<boolean>;
initialItems?: FileState[]; initialItems?: FileState[];
maxItems?: number; maxItems?: number;
overwrite?: boolean;
autoUpload?: boolean; autoUpload?: boolean;
onRejected?: (files: FileWithPath[]) => void;
onDeleted?: (file: FileState) => void;
onUploaded?: (file: FileState) => void;
placeholder?: { placeholder?: {
show?: boolean; show?: boolean;
text?: string; text?: string;
}; };
children?: (props: DropzoneRenderProps) => JSX.Element;
}; };
export function Dropzone({ export function Dropzone({
@@ -55,23 +62,65 @@ export function Dropzone({
handleDelete, handleDelete,
initialItems = [], initialItems = [],
maxItems, maxItems,
overwrite,
autoUpload, autoUpload,
placeholder placeholder,
onRejected,
onDeleted,
onUploaded,
children
}: DropzoneProps) { }: DropzoneProps) {
const [files, setFiles] = useState<FileState[]>(initialItems); const [files, setFiles] = useState<FileState[]>(initialItems);
const [uploading, setUploading] = useState<boolean>(false); const [uploading, setUploading] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement>(null); 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({ const { isOver, handleFileInputChange, ref } = useDropzone({
onDropped: (newFiles: FileWithPath[]) => { onDropped: (newFiles: FileWithPath[]) => {
if (maxItems && files.length + newFiles.length > maxItems) { let to_drop = 0;
alert("Max items reached"); const added = newFiles.length;
return;
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) => { 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 const filteredFiles: FileState[] = newFiles
.filter((f) => f.path && !currentPaths.includes(f.path)) .filter((f) => f.path && !currentPaths.includes(f.path))
.map((f) => ({ .map((f) => ({
@@ -84,7 +133,7 @@ export function Dropzone({
progress: 0 progress: 0
})); }));
return [...prev, ...filteredFiles]; return [..._prev, ...filteredFiles];
}); });
if (autoUpload) { if (autoUpload) {
@@ -92,17 +141,12 @@ export function Dropzone({
} }
}, },
onOver: (items) => { onOver: (items) => {
if (maxItems && files.length + items.length >= maxItems) { const max_reached = isMaxReached(items.length);
// indicate that the drop is not allowed setIsOverAccepted(!max_reached);
return; },
} onLeave: () => {
setIsOverAccepted(false);
} }
/*onOver: (items) =>
console.log(
"onOver",
items,
items.map((i) => [i.kind, i.type].join(":"))
)*/
}); });
useEffect(() => { useEffect(() => {
@@ -180,7 +224,14 @@ export function Dropzone({
formData.append("file", file.body); formData.append("file", file.body);
const xhr = new XMLHttpRequest(); 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) { if (headers) {
headers.forEach((value, key) => { headers.forEach((value, key) => {
@@ -207,6 +258,8 @@ export function Dropzone({
if (xhr.status === 200) { if (xhr.status === 200) {
//setFileState(file.path, "uploaded", 1); //setFileState(file.path, "uploaded", 1);
console.log("Upload complete"); console.log("Upload complete");
onUploaded?.(file);
try { try {
const response = JSON.parse(xhr.responseText); const response = JSON.parse(xhr.responseText);
@@ -252,6 +305,7 @@ export function Dropzone({
setFileState(file.path, "deleting"); setFileState(file.path, "deleting");
await handleDelete(file); await handleDelete(file);
removeFileFromState(file.path); removeFileFromState(file.path);
onDeleted?.(file);
} }
break; break;
} }
@@ -262,54 +316,61 @@ export function Dropzone({
placeholder?.show === true || !maxItems || (maxItems && files.length < maxItems) 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 ( return children ? children(renderProps) : <DropzoneInner {...renderProps} />;
<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 }}
/>
);
} }
const DropzoneInner = ({ const DropzoneInner = ({
wrapperRef, wrapperRef,
inputProps, inputProps,
state: { files, isOver, showPlaceholder }, state: { files, isOver, isOverAccepted, showPlaceholder },
actions: { uploadFileProgress, deleteFile, openFileInput }, actions: { uploadFile, deleteFile, openFileInput },
dropzoneProps: { placeholder } dropzoneProps: { placeholder }
}: DropzoneRenderProps) => { }: DropzoneRenderProps) => {
return ( return (
<div <div
ref={wrapperRef} ref={wrapperRef}
/*data-drag-over={"1"}*/ className={twMerge(
data-drag-over={isOver ? "1" : undefined} "dropzone w-full h-full align-start flex flex-col select-none",
className="dropzone data-[drag-over]:bg-green-200/10 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"> <div className="hidden">
<input <input {...inputProps} />
{...inputProps}
/*ref={inputRef}
type="file"
multiple={!maxItems || maxItems > 1}
onChange={handleFileInputChange}*/
/>
</div> </div>
<div className="flex flex-1 flex-col"> <div className="flex flex-1 flex-col">
<div className="flex flex-row flex-wrap gap-2 md:gap-3"> <div className="flex flex-row flex-wrap gap-2 md:gap-3">
{files.map((file, i) => ( {files.map((file) => (
<Preview <Preview
key={file.path} key={file.path}
file={file} file={file}
handleUpload={uploadFileProgress} handleUpload={uploadFile}
handleDelete={deleteFile} 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/")) { if (file.type.startsWith("image/")) {
return <ImagePreview file={file} />; return <ImagePreview {...props} file={file} />;
} }
if (file.type.startsWith("video/")) { 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 = { type PreviewProps = {
file: FileState; file: FileState;
@@ -370,7 +442,6 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
file.state === "deleting" && "opacity-70" file.state === "deleting" && "opacity-70"
)} )}
> >
{/*{file.state}*/}
<div className="absolute top-2 right-2"> <div className="absolute top-2 right-2">
<Dropdown items={dropdownItems} position="bottom-end"> <Dropdown items={dropdownItems} position="bottom-end">
<IconButton Icon={TbDots} /> <IconButton Icon={TbDots} />
@@ -385,7 +456,11 @@ const Preview: React.FC<PreviewProps> = ({ file, handleUpload, handleDelete }) =
</div> </div>
)} )}
<div className="flex bg-primary/5 aspect-[1/0.8] overflow-hidden items-center justify-center"> <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>
<div className="flex flex-col px-1.5 py-1"> <div className="flex flex-col px-1.5 py-1">
<p className="truncate">{file.name}</p> <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); 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); 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 }) => { const FallbackPreview = ({ file }: { file: FileState }) => {

View File

@@ -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}
/>
);
}

View File

@@ -4,15 +4,16 @@ import { type FileWithPath, fromEvent } from "./file-selector";
type DropzoneProps = { type DropzoneProps = {
onDropped: (files: FileWithPath[]) => void; onDropped: (files: FileWithPath[]) => void;
onOver?: (items: DataTransferItem[]) => void; onOver?: (items: DataTransferItem[]) => void;
onLeave?: () => void;
}; };
const events = { const events = {
enter: ["dragenter", "dragover", "dragstart"], enter: ["dragenter", "dragover", "dragstart"],
leave: ["dragleave", "drop"], leave: ["dragleave", "drop"]
}; };
const allEvents = [...events.enter, ...events.leave]; 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 [isOver, setIsOver] = useState(false);
const ref = useRef<HTMLDivElement>(null); const ref = useRef<HTMLDivElement>(null);
const onOverCalled = useRef(false); const onOverCalled = useRef(false);
@@ -31,8 +32,10 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
} }
setIsOver(_isOver); setIsOver(_isOver);
if (_isOver === false && onOverCalled.current) { if (_isOver === false && onOverCalled.current) {
onOverCalled.current = false; onOverCalled.current = false;
onLeave?.();
} }
}, []); }, []);
@@ -42,7 +45,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
onDropped?.(files as any); onDropped?.(files as any);
onOverCalled.current = false; onOverCalled.current = false;
}, },
[onDropped], [onDropped]
); );
const handleFileInputChange = useCallback( const handleFileInputChange = useCallback(
@@ -50,7 +53,7 @@ export function useDropzone({ onDropped, onOver }: DropzoneProps) {
const files = await fromEvent(e); const files = await fromEvent(e);
onDropped?.(files as any); onDropped?.(files as any);
}, },
[onDropped], [onDropped]
); );
useEffect(() => { useEffect(() => {

View File

@@ -1,16 +1,12 @@
import { IconPhoto } from "@tabler/icons-react"; import { IconPhoto } from "@tabler/icons-react";
import type { MediaFieldSchema } from "modules";
import { TbSettings } from "react-icons/tb"; import { TbSettings } from "react-icons/tb";
import { useApi, useBaseUrl, useEntityQuery } from "ui/client";
import { useBknd } from "ui/client/BkndProvider"; import { useBknd } from "ui/client/BkndProvider";
import { IconButton } from "ui/components/buttons/IconButton"; import { IconButton } from "ui/components/buttons/IconButton";
import { Empty } from "ui/components/display/Empty"; import { Empty } from "ui/components/display/Empty";
import { Link } from "ui/components/wouter/Link"; import { Link } from "ui/components/wouter/Link";
import { Media } from "ui/elements";
import { useBrowserTitle } from "ui/hooks/use-browser-title"; import { useBrowserTitle } from "ui/hooks/use-browser-title";
import { useEvent } from "ui/hooks/use-event";
import * as AppShell from "ui/layouts/AppShell/AppShell"; 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"; import { useLocation } from "wouter";
export function MediaRoot({ children }) { export function MediaRoot({ children }) {
@@ -63,35 +59,11 @@ export function MediaRoot({ children }) {
// @todo: add infinite load // @todo: add infinite load
export function MediaEmpty() { export function MediaEmpty() {
useBrowserTitle(["Media"]); 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 ( return (
<AppShell.Scrollable> <AppShell.Scrollable>
<div className="flex flex-1 p-3"> <div className="flex flex-1 p-3">
<Dropzone <Media.Dropzone />
key={$q.isLoading ? "loaded" : "initial"}
getUploadInfo={getUploadInfo}
handleDelete={handleDelete}
autoUpload
initialItems={initialItems}
/>
</div> </div>
</AppShell.Scrollable> </AppShell.Scrollable>
); );

View File

@@ -11,6 +11,7 @@ import FlowFormTest from "../../routes/test/tests/flow-form-test";
import ModalTest from "../../routes/test/tests/modal-test"; import ModalTest from "../../routes/test/tests/modal-test";
import QueryJsonFormTest from "../../routes/test/tests/query-jsonform"; import QueryJsonFormTest from "../../routes/test/tests/query-jsonform";
import DropdownTest from "./tests/dropdown-test"; import DropdownTest from "./tests/dropdown-test";
import DropzoneElementTest from "./tests/dropzone-element-test";
import EntityFieldsForm from "./tests/entity-fields-form"; import EntityFieldsForm from "./tests/entity-fields-form";
import FlowsTest from "./tests/flows-test"; import FlowsTest from "./tests/flows-test";
import JsonFormTest from "./tests/jsonform-test"; import JsonFormTest from "./tests/jsonform-test";
@@ -41,7 +42,8 @@ const tests = {
AppShellAccordionsTest, AppShellAccordionsTest,
SwaggerTest, SwaggerTest,
SWRAndAPI, SWRAndAPI,
SwrAndDataApi SwrAndDataApi,
DropzoneElementTest
} as const; } as const;
export default function TestRoutes() { export default function TestRoutes() {

View 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>
);
}