| 1 | "use client"; |
| 2 | |
| 3 | import { Input } from "@/components/ui/input"; |
| 4 | import { ScrollArea } from "@/components/ui/scroll-area"; |
| 5 | import { cn } from "@/lib/utils"; |
| 6 | import { |
| 7 | AudioWaveform, |
| 8 | File, |
| 9 | FileImage, |
| 10 | FolderArchive, |
| 11 | UploadCloud, |
| 12 | Video, |
| 13 | X, |
| 14 | } from "lucide-react"; |
| 15 | import { useCallback } from "react"; |
| 16 | import { type FileRejection, useDropzone } from "react-dropzone"; |
| 17 | import { Button } from "./button"; |
| 18 | import { useToast } from "./use-toast"; |
| 19 | |
| 20 | enum FileTypes { |
| 21 | Image = "image", |
| 22 | Pdf = "pdf", |
| 23 | Audio = "audio", |
| 24 | Video = "video", |
| 25 | Other = "other", |
| 26 | } |
| 27 | |
| 28 | const ImageColor = { |
| 29 | bgColor: "bg-purple-600", |
| 30 | fillColor: "fill-purple-600", |
| 31 | }; |
| 32 | |
| 33 | const PdfColor = { |
| 34 | bgColor: "bg-blue-400", |
| 35 | fillColor: "fill-blue-400", |
| 36 | }; |
| 37 | |
| 38 | const AudioColor = { |
| 39 | bgColor: "bg-yellow-400", |
| 40 | fillColor: "fill-yellow-400", |
| 41 | }; |
| 42 | |
| 43 | const VideoColor = { |
| 44 | bgColor: "bg-green-400", |
| 45 | fillColor: "fill-green-400", |
| 46 | }; |
| 47 | |
| 48 | const OtherColor = { |
| 49 | bgColor: "bg-gray-400", |
| 50 | fillColor: "fill-gray-400", |
| 51 | }; |
| 52 | |
| 53 | export default function FileUpload({ |
| 54 | files, |
| 55 | setFiles, |
| 56 | onUpload, |
| 57 | isLoading, |
| 58 | multiple = false, |
| 59 | maxFiles = 1, |
| 60 | maxSize = 16 * 1024 * 1024, |
| 61 | acceptedTypes = ["pdf", "docx", "txt"], |
| 62 | info, |
| 63 | showUploadButton = true, |
| 64 | dropzoneMinHeightClassName = "min-h-[350px]", |
| 65 | dropzoneClassName, |
| 66 | }: { |
| 67 | files: File[]; |
| 68 | setFiles: (files: File[] | ((prevFiles: File[]) => File[])) => void; |
| 69 | onUpload?: (files: File[]) => void; |
| 70 | isLoading: boolean; |
| 71 | multiple?: boolean; |
| 72 | maxFiles?: number; |
| 73 | maxSize?: number; |
| 74 | acceptedTypes?: string[]; |
| 75 | info?: string; |
| 76 | showUploadButton?: boolean; |
| 77 | dropzoneMinHeightClassName?: string; |
| 78 | dropzoneClassName?: string; |
| 79 | }) { |
| 80 | const { toast } = useToast(); |
| 81 | const getFileIconAndColor = (file: File) => { |
| 82 | if (file.type.includes(FileTypes.Image)) { |
| 83 | return { |
| 84 | icon: <FileImage size={40} className={ImageColor.fillColor} />, |
| 85 | color: ImageColor.bgColor, |
| 86 | }; |
| 87 | } |
| 88 | |
| 89 | if (file.type.includes(FileTypes.Pdf)) { |
| 90 | return { |
| 91 | icon: <File size={40} className={PdfColor.fillColor} />, |
| 92 | color: PdfColor.bgColor, |
| 93 | }; |
| 94 | } |
| 95 | |
| 96 | if (file.type.includes(FileTypes.Audio)) { |
| 97 | return { |
| 98 | icon: <AudioWaveform size={40} className={AudioColor.fillColor} />, |
| 99 | color: AudioColor.bgColor, |
| 100 | }; |
| 101 | } |
| 102 | |
| 103 | if (file.type.includes(FileTypes.Video)) { |
| 104 | return { |
| 105 | icon: <Video size={40} className={VideoColor.fillColor} />, |
| 106 | color: VideoColor.bgColor, |
| 107 | }; |
| 108 | } |
| 109 | |
| 110 | return { |
| 111 | icon: <FolderArchive size={40} className={OtherColor.fillColor} />, |
| 112 | color: OtherColor.bgColor, |
| 113 | }; |
| 114 | }; |
| 115 | |
| 116 | // feel free to mode all these functions to separate utils |
| 117 | // here is just for simplicity |
| 118 | |
| 119 | const removeFile = (file: File) => { |
| 120 | setFiles((prevUploadProgress) => { |
| 121 | return prevUploadProgress.filter((item) => item !== file); |
| 122 | }); |
| 123 | }; |
| 124 | |
| 125 | const onDrop = useCallback( |
| 126 | (acceptedFiles: File[], fileRejected: FileRejection[]) => { |
| 127 | setFiles((prevUploadProgress) => { |
| 128 | return [ |
| 129 | ...prevUploadProgress, |
| 130 | ...acceptedFiles.map((file) => { |
| 131 | return file; |
| 132 | }), |
| 133 | ]; |
| 134 | }); |
| 135 | if (fileRejected.length > 0) { |
| 136 | toast({ |
| 137 | title: "File type not allowed", |
| 138 | description: "Please upload a valid file type", |
| 139 | }); |
| 140 | } |
| 141 | }, |
| 142 | [], |
| 143 | ); |
| 144 | |
| 145 | const { getRootProps, getInputProps } = useDropzone({ |
| 146 | onDrop, |
| 147 | accept: acceptedTypes.reduce( |
| 148 | (acc, type) => { |
| 149 | switch (type) { |
| 150 | case "pdf": |
| 151 | acc["application/pdf"] = [".pdf"]; |
| 152 | break; |
| 153 | case "doc": |
| 154 | acc["application/msword"] = [".doc"]; |
| 155 | break; |
| 156 | case "docx": |
| 157 | acc[ |
| 158 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document" |
| 159 | ] = [".docx"]; |
| 160 | break; |
| 161 | case "txt": |
| 162 | acc["text/plain"] = [".txt"]; |
| 163 | break; |
| 164 | case "webm": |
| 165 | acc["video/webm"] = [".webm"]; |
| 166 | break; |
| 167 | case "mp4": |
| 168 | acc["video/mp4"] = [".mp4"]; |
| 169 | break; |
| 170 | case "mov": |
| 171 | acc["video/quicktime"] = [".mov"]; |
| 172 | break; |
| 173 | // Add more cases for other file types as needed |
| 174 | } |
| 175 | return acc; |
| 176 | }, |
| 177 | {} as Record<string, string[]>, |
| 178 | ), |
| 179 | maxFiles, |
| 180 | maxSize, |
| 181 | multiple, |
| 182 | onDragEnter: () => {}, |
| 183 | onDragLeave: () => {}, |
| 184 | onDragOver: () => {}, |
| 185 | }); |
| 186 | |
| 187 | return ( |
| 188 | <div className="min-h-full w-full max-w-full overflow-x-auto"> |
| 189 | <div className={cn("grid", dropzoneMinHeightClassName)}> |
| 190 | <label |
| 191 | {...getRootProps()} |
| 192 | className={cn( |
| 193 | "relative flex h-full w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-primary bg-background py-6 hover:bg-muted", |
| 194 | dropzoneClassName, |
| 195 | )} |
| 196 | > |
| 197 | <div className="text-center"> |
| 198 | <div className="mx-auto max-w-min rounded-md border p-2"> |
| 199 | <UploadCloud className="text-primary" size={20} /> |
| 200 | </div> |
| 201 | |
| 202 | <p className="mt-2 text-sm text-gray-600"> |
| 203 | <span className="font-semibold"> |
| 204 | Click to upload or drag and drop |
| 205 | </span> |
| 206 | </p> |
| 207 | <p className="text-xs text-gray-500"> |
| 208 | {acceptedTypes.join(", ").toUpperCase()} (MAX{" "} |
| 209 | {maxSize / 1024 / 1024} MB) |
| 210 | </p> |
| 211 | {info && ( |
| 212 | <p className="text-xs whitespace-pre-line text-gray-500"> |
| 213 | {info} |
| 214 | </p> |
| 215 | )} |
| 216 | </div> |
| 217 | </label> |
| 218 | |
| 219 | <Input |
| 220 | {...getInputProps()} |
| 221 | id="dropzone-file" |
| 222 | accept={acceptedTypes.map((type) => `.${type}`).join(", ")} |
| 223 | type="file" |
| 224 | className="hidden" |
| 225 | /> |
| 226 | </div> |
| 227 | |
| 228 | {showUploadButton && files.length > 0 && ( |
| 229 | <div> |
| 230 | <p className="my-2 mt-6 text-sm font-medium text-muted-foreground"> |
| 231 | Files to upload |
| 232 | </p> |
| 233 | <ScrollArea className="max-h-48"> |
| 234 | <div className="space-y-2 pr-3"> |
| 235 | {files.map((file) => { |
| 236 | return ( |
| 237 | <div |
| 238 | key={file.lastModified} |
| 239 | className="group flex justify-between gap-2 overflow-hidden rounded-lg border border-primary pr-2 hover:pr-0" |
| 240 | > |
| 241 | <div className="flex flex-1 items-center p-2"> |
| 242 | <div className="text-primary"> |
| 243 | {getFileIconAndColor(file).icon} |
| 244 | </div> |
| 245 | |
| 246 | <div className="ml-2 w-full space-y-1"> |
| 247 | <div className="flex justify-between text-sm"> |
| 248 | <p className="text-muted-foreground"> |
| 249 | {file.name.slice(0, 25)} |
| 250 | </p> |
| 251 | </div> |
| 252 | </div> |
| 253 | </div> |
| 254 | <button |
| 255 | onClick={() => removeFile(file)} |
| 256 | className="hidden items-center justify-center bg-red-500 px-2 text-primary transition-all group-hover:flex" |
| 257 | > |
| 258 | <X size={20} /> |
| 259 | </button> |
| 260 | </div> |
| 261 | ); |
| 262 | })} |
| 263 | </div> |
| 264 | </ScrollArea> |
| 265 | </div> |
| 266 | )} |
| 267 | |
| 268 | {showUploadButton && files.length > 0 && ( |
| 269 | <div className="flex justify-end pt-4"> |
| 270 | <Button |
| 271 | variant={isLoading ? "loading" : "default"} |
| 272 | onClick={async () => onUpload?.(files)} |
| 273 | > |
| 274 | Upload |
| 275 | </Button> |
| 276 | </div> |
| 277 | )} |
| 278 | </div> |
| 279 | ); |
| 280 | } |
| 281 |