import type { ComponentProps, ComponentPropsWithRef, FC } from "react"; import { useId, useRef, useState } from "react"; import type { FileIcon } from "@untitledui/file-icons"; import { FileIcon as FileTypeIcon } from "@untitledui/file-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCircleCheck, faTrash, faCloudArrowUp, faCircleXmark } from "@fortawesome/pro-duotone-svg-icons"; const Trash01: FC<{ className?: string }> = ({ className }) => ; import { AnimatePresence, motion } from "motion/react"; import { Button } from "@/components/base/buttons/button"; import { ButtonUtility } from "@/components/base/buttons/button-utility"; import { ProgressBar } from "@/components/base/progress-indicators/progress-indicators"; import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon"; import { cx } from "@/utils/cx"; /** * Returns a human-readable file size. * @param bytes - The size of the file in bytes. * @returns A string representing the file size in a human-readable format. */ export const getReadableFileSize = (bytes: number) => { if (bytes === 0) return "0 KB"; const suffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; const i = Math.floor(Math.log(bytes) / Math.log(1024)); return Math.floor(bytes / Math.pow(1024, i)) + " " + suffixes[i]; }; interface FileUploadDropZoneProps { /** The class name of the drop zone. */ className?: string; /** * A hint text explaining what files can be dropped. */ hint?: string; /** * Disables dropping or uploading files. */ isDisabled?: boolean; /** * Specifies the types of files that the server accepts. * Examples: "image/*", ".pdf,image/*", "image/*,video/mpeg,application/pdf" */ accept?: string; /** * Allows multiple file uploads. */ allowsMultiple?: boolean; /** * Maximum file size in bytes. */ maxSize?: number; /** * Callback function that is called with the list of dropped files * when files are dropped on the drop zone. */ onDropFiles?: (files: FileList) => void; /** * Callback function that is called with the list of unaccepted files * when files are dropped on the drop zone. */ onDropUnacceptedFiles?: (files: FileList) => void; /** * Callback function that is called with the list of files that exceed * the size limit when files are dropped on the drop zone. */ onSizeLimitExceed?: (files: FileList) => void; } export const FileUploadDropZone = ({ className, hint, isDisabled, accept, allowsMultiple = true, maxSize, onDropFiles, onDropUnacceptedFiles, onSizeLimitExceed, }: FileUploadDropZoneProps) => { const id = useId(); const inputRef = useRef(null); const [isInvalid, setIsInvalid] = useState(false); const [isDraggingOver, setIsDraggingOver] = useState(false); const isFileTypeAccepted = (file: File): boolean => { if (!accept) return true; // Split the accept string into individual types const acceptedTypes = accept.split(",").map((type) => type.trim()); return acceptedTypes.some((acceptedType) => { // Handle file extensions (e.g., .pdf, .doc) if (acceptedType.startsWith(".")) { const extension = `.${file.name.split(".").pop()?.toLowerCase()}`; return extension === acceptedType.toLowerCase(); } // Handle wildcards (e.g., image/*) if (acceptedType.endsWith("/*")) { const typePrefix = acceptedType.split("/")[0]; return file.type.startsWith(`${typePrefix}/`); } // Handle exact MIME types (e.g., application/pdf) return file.type === acceptedType; }); }; const handleDragIn = (event: React.DragEvent) => { if (isDisabled) return; event.preventDefault(); event.stopPropagation(); setIsDraggingOver(true); }; const handleDragOut = (event: React.DragEvent) => { if (isDisabled) return; event.preventDefault(); event.stopPropagation(); setIsDraggingOver(false); }; const processFiles = (files: File[]): void => { // Reset the invalid state when processing files. setIsInvalid(false); const acceptedFiles: File[] = []; const unacceptedFiles: File[] = []; const oversizedFiles: File[] = []; // If multiple files are not allowed, only process the first file const filesToProcess = allowsMultiple ? files : files.slice(0, 1); filesToProcess.forEach((file) => { // Check file size first if (maxSize && file.size > maxSize) { oversizedFiles.push(file); return; } // Then check file type if (isFileTypeAccepted(file)) { acceptedFiles.push(file); } else { unacceptedFiles.push(file); } }); // Handle oversized files if (oversizedFiles.length > 0 && typeof onSizeLimitExceed === "function") { const dataTransfer = new DataTransfer(); oversizedFiles.forEach((file) => dataTransfer.items.add(file)); setIsInvalid(true); onSizeLimitExceed(dataTransfer.files); } // Handle accepted files if (acceptedFiles.length > 0 && typeof onDropFiles === "function") { const dataTransfer = new DataTransfer(); acceptedFiles.forEach((file) => dataTransfer.items.add(file)); onDropFiles(dataTransfer.files); } // Handle unaccepted files if (unacceptedFiles.length > 0 && typeof onDropUnacceptedFiles === "function") { const unacceptedDataTransfer = new DataTransfer(); unacceptedFiles.forEach((file) => unacceptedDataTransfer.items.add(file)); setIsInvalid(true); onDropUnacceptedFiles(unacceptedDataTransfer.files); } // Clear the input value to ensure the same file can be selected again if (inputRef.current) { inputRef.current.value = ""; } }; const handleDrop = (event: React.DragEvent) => { if (isDisabled) return; handleDragOut(event); processFiles(Array.from(event.dataTransfer.files)); }; const handleInputFileChange = (event: React.ChangeEvent) => { processFiles(Array.from(event.target.files || [])); }; return (
or drag and drop

{hint || "SVG, PNG, JPG or GIF (max. 800x400px)"}

); }; export interface FileListItemProps { /** The name of the file. */ name: string; /** The size of the file. */ size: number; /** The upload progress of the file. */ progress: number; /** Whether the file failed to upload. */ failed?: boolean; /** The type of the file. */ type?: ComponentProps["type"]; /** The class name of the file list item. */ className?: string; /** The variant of the file icon. */ fileIconVariant?: ComponentProps["variant"]; /** The function to call when the file is deleted. */ onDelete?: () => void; /** The function to call when the file upload is retried. */ onRetry?: () => void; } export const FileListItemProgressBar = ({ name, size, progress, failed, type, fileIconVariant, onDelete, onRetry, className }: FileListItemProps) => { const isComplete = progress === 100; return (

{name}

{getReadableFileSize(size)}


{isComplete && } {isComplete &&

Complete

} {!isComplete && !failed && } {!isComplete && !failed &&

Uploading...

} {failed && } {failed &&

Failed

}
{!failed && (
)} {failed && ( )}
); }; export const FileListItemProgressFill = ({ name, size, progress, failed, type, fileIconVariant, onDelete, onRetry, className }: FileListItemProps) => { const isComplete = progress === 100; return ( {/* Progress fill. */}
{/* Inner ring. */}

{name}

{failed ? "Upload failed, please try again" : getReadableFileSize(size)}

{!failed && ( <>
{isComplete && } {!isComplete && }

{progress}%

)}
{failed && ( )}
); }; const FileUploadRoot = (props: ComponentPropsWithRef<"div">) => (
{props.children}
); const FileUploadList = (props: ComponentPropsWithRef<"ul">) => (
    {props.children}
); export const FileUpload = { Root: FileUploadRoot, List: FileUploadList, DropZone: FileUploadDropZone, ListItemProgressBar: FileListItemProgressBar, ListItemProgressFill: FileListItemProgressFill, };