Files
dataroom/components/ui/file-upload.tsx
2025-09-25 12:48:37 +00:00

290 lines
7.6 KiB
TypeScript

import { DragEvent, ReactNode, useState } from "react";
import { VariantProps, cva } from "class-variance-authority";
import { CloudUpload, LoaderCircle } from "lucide-react";
import { toast } from "sonner";
import { cn } from "@/lib/utils";
import { resizeImage } from "@/lib/utils/resize-image";
type AcceptedFileFormats = "any" | "images" | "csv";
const acceptFileTypes: Record<
AcceptedFileFormats,
{ types: string[]; errorMessage?: string }
> = {
any: { types: [] },
images: {
types: ["image/png", "image/jpg", "image/jpeg"],
errorMessage: "File type not supported (.png or .jpg only)",
},
csv: {
types: ["text/csv", "text/tab-separated-values"],
errorMessage: "File type not supported (.csv or .tsv only)",
},
};
const imageUploadVariants = cva(
"group relative isolate flex aspect-[1200/630] w-full flex-col items-center justify-center overflow-hidden bg-white transition-all hover:bg-gray-50",
{
variants: {
variant: {
default: "rounded-md border border-gray-300 shadow-sm",
plain: "",
},
},
defaultVariants: {
variant: "default",
},
},
);
type FileUploadReadFileProps =
| {
/**
* Whether to automatically read the file and return the result as `src` to onChange
*/
readFile?: false;
onChange?: (data: { file: File }) => void;
}
| {
/**
* Whether to automatically read the file and return the result as `src` to onChange
*/
readFile: true;
onChange?: (data: { file: File; src: string }) => void;
};
export type FileUploadProps = FileUploadReadFileProps & {
accept: AcceptedFileFormats;
className?: string;
iconClassName?: string;
previewClassName?: string;
/**
* Custom preview component to display instead of the default
*/
customPreview?: ReactNode;
/**
* Image to display (generally for image uploads)
*/
imageSrc?: string | null;
/**
* Whether to display a loading spinner
*/
loading?: boolean;
/**
* Whether to allow clicking on the area to upload
*/
clickToUpload?: boolean;
/**
* Whether to show instruction overlay when hovered
*/
showHoverOverlay?: boolean;
/**
* Content to display below the upload icon (null to only display the icon)
*/
content?: ReactNode | null;
/**
* Desired resolution to suggest and optionally resize to
*/
targetResolution?: { width: number; height: number; quality: number };
/**
* A maximum file size (in megabytes) to check upon file selection
*/
maxFileSizeMB?: number;
/**
* Accessibility label for screen readers
*/
accessibilityLabel?: string;
disabled?: boolean;
} & VariantProps<typeof imageUploadVariants>;
export function FileUpload({
readFile,
onChange,
variant,
className,
iconClassName,
previewClassName,
customPreview,
accept = "any",
imageSrc,
loading = false,
clickToUpload = true,
showHoverOverlay = true,
content,
maxFileSizeMB = 0,
targetResolution,
accessibilityLabel = "File upload",
disabled = false,
}: FileUploadProps) {
const [dragActive, setDragActive] = useState(false);
const [fileName, setFileName] = useState<string | null>(null);
const onFileChange = async (
e: React.ChangeEvent<HTMLInputElement> | DragEvent,
) => {
const file =
"dataTransfer" in e
? e.dataTransfer.files && e.dataTransfer.files[0]
: e.target.files && e.target.files[0];
if (!file) return;
setFileName(file.name);
if (maxFileSizeMB > 0 && file.size / 1024 / 1024 > maxFileSizeMB) {
toast.error(`File size too big (max ${maxFileSizeMB} MB)`);
return;
}
const acceptedTypes = acceptFileTypes[accept].types;
if (acceptedTypes.length && !acceptedTypes.includes(file.type)) {
toast.error(
acceptFileTypes[accept].errorMessage ?? "File type not supported",
);
return;
}
let fileToUse = file;
// Add image resizing logic
if (targetResolution && file.type.startsWith("image/")) {
try {
const resizedFile = await resizeImage(file, targetResolution);
const blob = await fetch(resizedFile).then((r) => r.blob());
fileToUse = new File([blob], file.name, { type: file.type });
} catch (error) {
console.error("Error resizing image:", error);
// Fallback to original file if resize fails
}
}
// File reading logic
if (readFile) {
const reader = new FileReader();
reader.onload = (e) =>
onChange?.({ src: e.target?.result as string, file: fileToUse });
reader.readAsDataURL(fileToUse);
return;
}
onChange?.({ file: fileToUse });
};
return (
<label
className={cn(
imageUploadVariants({ variant }),
!disabled
? cn(clickToUpload && "cursor-pointer")
: "cursor-not-allowed",
className,
)}
>
{loading && (
<div className="absolute inset-0 z-[5] flex items-center justify-center rounded-[inherit] bg-white">
<LoaderCircle />
</div>
)}
<div
className="absolute inset-0 z-[5]"
onDragOver={(e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
}}
onDragEnter={(e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(true);
}}
onDragLeave={(e) => {
e.preventDefault();
e.stopPropagation();
setDragActive(false);
}}
onDrop={async (e) => {
e.preventDefault();
e.stopPropagation();
onFileChange(e);
setDragActive(false);
}}
/>
<div
className={cn(
"absolute inset-0 z-[3] flex flex-col items-center justify-center rounded-[inherit] border-2 border-transparent bg-white transition-all",
disabled && "bg-gray-50",
dragActive &&
!disabled &&
"cursor-copy border-black bg-gray-50 opacity-100",
imageSrc
? cn(
"opacity-0",
showHoverOverlay && !disabled && "group-hover:opacity-100",
)
: cn(!disabled && "group-hover:bg-gray-50"),
)}
>
<CloudUpload
className={cn(
"size-7 transition-all duration-75",
!disabled
? cn(
"text-gray-500 group-hover:scale-110 group-active:scale-95",
dragActive ? "scale-110" : "scale-100",
)
: "text-gray-400",
iconClassName,
)}
/>
{content !== null && (
<div
className={cn(
"mt-2 text-center text-sm text-gray-500",
disabled && "text-gray-400",
)}
>
{content ?? (
<>
<p>Drag and drop {clickToUpload && "or click"} to upload.</p>
</>
)}
</div>
)}
<span className="sr-only">{accessibilityLabel}</span>
</div>
{imageSrc &&
(customPreview ?? (
<img
src={imageSrc}
alt="Preview"
className={cn(
"h-full w-full rounded-[inherit] object-cover",
previewClassName,
)}
/>
))}
{clickToUpload && (
<div className="sr-only mt-1 flex shadow-sm">
<input
key={fileName} // Gets us a fresh input every time a file is uploaded
type="file"
accept={acceptFileTypes[accept].types.join(",")}
onChange={onFileChange}
disabled={disabled}
/>
</div>
)}
</label>
);
}