mirror of
https://github.com/shishantbiswas/bknd.git
synced 2026-03-15 20:17:22 +00:00
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 "bknd/utils";
|
|
import type { DropzoneRenderProps, FileState } from "./Dropzone";
|
|
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>
|
|
);
|
|
};
|