Files
userbase/app/src/ui/elements/media/DropzoneInner.tsx
T
dswbx 5e71fc8947 feat: implement media cleanup on entity deletion and enhance Dropzone logging
Added functionality to clean up media files associated with entities upon deletion, ensuring proper resource management. Enhanced logging in the Dropzone component to provide better feedback on file type validation and user interactions, improving overall user experience.
2025-09-29 16:47:44 +02:00

345 lines
10 KiB
TypeScript

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,
TbFileTypeCsv,
TbFileText,
TbJson,
TbFileTypePdf,
TbMarkdown,
TbMusic,
TbTrash,
TbUpload,
TbFileTypeTxt,
TbFileTypeXml,
TbZip,
TbFileTypeSql,
} 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, maxItems, allowedMimeTypes },
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 = Omit<FileState, "state" | "progress">;
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-xs md:text-sm font-mono opacity-50 text-nowrap gap-2">
<span className="truncate select-text">{file.type}</span>
<span className="whitespace-nowrap">{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 Previews = [
{
mime: "text/plain",
Icon: TbFileTypeTxt,
},
{
mime: "text/csv",
Icon: TbFileTypeCsv,
},
{
mime: /(text|application)\/xml/,
Icon: TbFileTypeXml,
},
{
mime: "text/markdown",
Icon: TbMarkdown,
},
{
mime: /^text\/.*$/,
Icon: TbFileText,
},
{
mime: "application/json",
Icon: TbJson,
},
{
mime: "application/pdf",
Icon: TbFileTypePdf,
},
{
mime: /^audio\/.*$/,
Icon: TbMusic,
},
{
mime: "application/zip",
Icon: TbZip,
},
{
mime: "application/sql",
Icon: TbFileTypeSql,
},
];
const FallbackPreview = ({ file }: { file: ReducedFile }) => {
const previewIcon = Previews.find((p) =>
p.mime instanceof RegExp ? p.mime.test(file.type) : p.mime === file.type,
);
if (previewIcon) {
return <previewIcon.Icon className="size-10 text-gray-400" />;
}
return (
<div className="text-xs text-primary/50 text-center font-mono leading-none max-w-[90%] truncate">
{file.type}
</div>
);
};