From d18264098162b6b75a2e476a0062d152ff92d1eb Mon Sep 17 00:00:00 2001 From: dswbx Date: Thu, 9 Jan 2025 15:43:43 +0100 Subject: [PATCH] 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. --- app/src/media/media-schema.ts | 3 +- app/src/ui/client/api/use-entity.ts | 2 + app/src/ui/elements/index.ts | 1 + app/src/ui/elements/media.ts | 15 ++ .../ui/modules/data/components/EntityForm.tsx | 42 +--- .../media/components/dropzone/Dropzone.tsx | 199 ++++++++++++------ .../components/dropzone/DropzoneContainer.tsx | 98 +++++++++ .../media/components/dropzone/use-dropzone.ts | 11 +- app/src/ui/routes/media/_media.root.tsx | 32 +-- app/src/ui/routes/test/index.tsx | 4 +- .../test/tests/dropzone-element-test.tsx | 78 +++++++ 11 files changed, 358 insertions(+), 127 deletions(-) create mode 100644 app/src/ui/elements/media.ts create mode 100644 app/src/ui/modules/media/components/dropzone/DropzoneContainer.tsx create mode 100644 app/src/ui/routes/test/tests/dropzone-element-test.tsx diff --git a/app/src/media/media-schema.ts b/app/src/media/media-schema.ts index 045a0ca..64a52ba 100644 --- a/app/src/media/media-schema.ts +++ b/app/src/media/media-schema.ts @@ -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; diff --git a/app/src/ui/client/api/use-entity.ts b/app/src/ui/client/api/use-entity.ts index 4900972..5f4da99 100644 --- a/app/src/ui/client/api/use-entity.ts +++ b/app/src/ui/client/api/use-entity.ts @@ -143,6 +143,8 @@ export const useEntityQuery = < return { ...swr, ...mapped, + mutate: mutateAll, + mutateRaw: swr.mutate, api, key }; diff --git a/app/src/ui/elements/index.ts b/app/src/ui/elements/index.ts index 0e3c055..83a292b 100644 --- a/app/src/ui/elements/index.ts +++ b/app/src/ui/elements/index.ts @@ -1 +1,2 @@ export { Auth } from "ui/modules/auth/index"; +export * from "./media"; diff --git a/app/src/ui/elements/media.ts b/app/src/ui/elements/media.ts new file mode 100644 index 0000000..5ed6e11 --- /dev/null +++ b/app/src/ui/elements/media.ts @@ -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"; diff --git a/app/src/ui/modules/data/components/EntityForm.tsx b/app/src/ui/modules/data/components/EntityForm.tsx index 131bd61..fb5fc5f 100644 --- a/app/src/ui/modules/data/components/EntityForm.tsx +++ b/app/src/ui/modules/data/components/EntityForm.tsx @@ -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 ( - ); diff --git a/app/src/ui/modules/media/components/dropzone/Dropzone.tsx b/app/src/ui/modules/media/components/dropzone/Dropzone.tsx index 19d2690..4f6fe20 100644 --- a/app/src/ui/modules/media/components/dropzone/Dropzone.tsx +++ b/app/src/ui/modules/media/components/dropzone/Dropzone.tsx @@ -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; + uploadFile: (file: FileState) => Promise; deleteFile: (file: FileState) => Promise; openFileInput: () => void; }; @@ -43,11 +45,16 @@ export type DropzoneProps = { handleDelete: (file: FileState) => Promise; 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(initialItems); const [uploading, setUploading] = useState(false); const inputRef = useRef(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 ( - 1, - onChange: handleFileInputChange - }} - state={{ files, isOver, showPlaceholder }} - actions={{ uploadFileProgress, deleteFile, openFileInput }} - dropzoneProps={{ maxItems, placeholder, autoUpload }} - /> - ); + return children ? children(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 (
- 1} - onChange={handleFileInputChange}*/ - /> +
- {files.map((file, i) => ( + {files.map((file) => ( ))} @@ -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 ; + return ; } if (file.type.startsWith("video/")) { - return ; + return ; } - return ; + 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 = ({ file, handleUpload, handleDelete }) = file.state === "deleting" && "opacity-70" )} > - {/*{file.state}*/}
@@ -385,7 +456,11 @@ const Preview: React.FC = ({ file, handleUpload, handleDelete }) =
)}
- +

{file.name}

@@ -398,14 +473,20 @@ const Preview: React.FC = ({ 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 ; + return ; }; -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