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.
345 lines
10 KiB
TypeScript
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>
|
|
);
|
|
};
|