import type { Doc } from "@fileone/convex/dataModel" import { mutationOptions } from "@tanstack/react-query" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { atomEffect } from "jotai-effect" import { atomWithMutation } from "jotai-tanstack-query" import { CircleAlertIcon, CircleCheckIcon, FilePlus2Icon, UploadCloudIcon, XIcon, } from "lucide-react" import { nanoid } from "nanoid" import type React from "react" import { useId, useMemo, useRef, useState } from "react" import { toast } from "sonner" import { Button } from "@/components/ui/button" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "@/components/ui/dialog" import { Progress } from "@/components/ui/progress" import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" import { formatError } from "@/lib/error" import { clearAllFileUploadStatusesAtom, clearFileUploadStatusesAtom, FileUploadStatusKind, fileUploadCountAtom, fileUploadStatusAtomFamily, fileUploadStatusesAtom, hasFileUploadsErrorAtom, successfulFileUploadCountAtom, } from "./store" import useUploadFile from "./use-upload-file" type UploadFileDialogProps = { targetDirectory: Doc<"directories"> onClose: () => void } // Upload file atoms export type PickedFile = { id: string file: File } export const pickedFilesAtom = atom([]) function useUploadFilesAtom({ targetDirectory, }: { targetDirectory: Doc<"directories"> }) { const uploadFile = useUploadFile({ targetDirectory }) const store = useStore() const options = useMemo( () => mutationOptions({ mutationFn: async (files: PickedFile[]) => { const promises = files.map((pickedFile) => uploadFile({ file: pickedFile.file, onStart: () => { store.set( fileUploadStatusAtomFamily(pickedFile.id), { kind: FileUploadStatusKind.InProgress, progress: 0, }, ) }, onProgress: (progress) => { store.set( fileUploadStatusAtomFamily(pickedFile.id), { kind: FileUploadStatusKind.InProgress, progress, }, ) }, }).catch((error) => { console.log("error", error) store.set( fileUploadStatusAtomFamily(pickedFile.id), { kind: FileUploadStatusKind.Error, error, }, ) throw error }), ) return await Promise.allSettled(promises) }, onSuccess: (results, files) => { const remainingPickedFiles: PickedFile[] = [] results.forEach((result, i) => { // biome-ignore lint/style/noNonNullAssertion: results lenght must match input files array length const pickedFile = files[i]! const statusAtom = fileUploadStatusAtomFamily( pickedFile.id, ) switch (result.status) { case "fulfilled": store.set(statusAtom, { kind: FileUploadStatusKind.Success, }) break case "rejected": store.set(statusAtom, { kind: FileUploadStatusKind.Error, error: result.reason, }) remainingPickedFiles.push(pickedFile) break } }) // setPickedFiles(remainingPickedFiles) if (remainingPickedFiles.length === 0) { toast.success("All files uploaded successfully") } }, }), [uploadFile, store.set], ) return useMemo(() => atomWithMutation(() => options), [options]) } type UploadFilesAtom = ReturnType export function UploadFileDialog({ targetDirectory, onClose, }: UploadFileDialogProps) { const formId = useId() const fileInputRef = useRef(null) const setPickedFiles = useSetAtom(pickedFilesAtom) const clearFileUploadStatuses = useSetAtom(clearFileUploadStatusesAtom) const store = useStore() const updateFileInputEffect = useMemo( () => atomEffect((get) => { const dataTransfer = new DataTransfer() const pickedFiles = get(pickedFilesAtom) for (const { file } of pickedFiles) { dataTransfer.items.add(file) } if (fileInputRef.current) { fileInputRef.current.files = dataTransfer.files } }), [], ) useAtom(updateFileInputEffect) const uploadFilesAtom = useUploadFilesAtom({ targetDirectory, }) function handleSubmit(event: React.FormEvent) { event.preventDefault() } function openFilePicker() { fileInputRef.current?.click() } function handleFileChange(event: React.ChangeEvent) { const files = event.target.files if (files) { setPickedFiles((prev) => [ ...prev, ...Array.from(files).map((file) => ({ id: nanoid(), file })), ]) } } function onUploadButtonClick() { const uploadStatuses = store.get(fileUploadStatusesAtom) const fileUploadCount = store.get(fileUploadCountAtom) const pickedFiles = store.get(pickedFilesAtom) const { mutate: uploadFiles, reset: restUploadFilesMutation } = store.get(uploadFilesAtom) if (pickedFiles.length === 0) { // no files are picked, nothing to upload return } if (fileUploadCount === 0) { // no files are being uploaded, upload all picked files uploadFiles(pickedFiles) return } const successfulUploads: PickedFile["id"][] = [] const nextPickedFiles: PickedFile[] = [] for (const file of pickedFiles) { const uploadStatus = uploadStatuses[file.id] if (uploadStatus) { switch (uploadStatus.kind) { case FileUploadStatusKind.Success: successfulUploads.push(file.id) continue case FileUploadStatusKind.InProgress: continue case FileUploadStatusKind.Error: nextPickedFiles.push(file) break } } } clearFileUploadStatuses(successfulUploads) if (successfulUploads.length === pickedFiles.length) { // all files were successfully uploaded, close the dialog onClose() } else { // some files were not successfully uploaded, set the next picked files setPickedFiles(nextPickedFiles) restUploadFilesMutation() uploadFiles(nextPickedFiles) } } return ( { if (!open) onClose() }} >
) } function UploadDialogHeader({ uploadFilesAtom, targetDirectory, }: { uploadFilesAtom: UploadFilesAtom targetDirectory: Doc<"directories"> }) { const { data: uploadResults, isPending: isUploading } = useAtomValue(uploadFilesAtom) const successfulUploadCount = useAtomValue(successfulFileUploadCountAtom) let dialogTitle: string let dialogDescription: string if (isUploading) { dialogTitle = "Uploading files" dialogDescription = "You can close the dialog while they are being uploaded in the background." } else if ( uploadResults && uploadResults.length > 0 && successfulUploadCount === uploadResults.length ) { dialogTitle = "Files uploaded" dialogDescription = "Click 'Done' to close the dialog, or select more files to upload." } else if (targetDirectory.name) { dialogTitle = `Upload file to "${targetDirectory.name}"` dialogDescription = "Drag and drop files here or click to select files" } else { dialogTitle = "Upload file" dialogDescription = "Drag and drop files here or click to select files" } return ( {dialogTitle} {dialogDescription} ) } function ContinueUploadAfterSuccessfulUploadButton({ uploadFilesAtom, }: { uploadFilesAtom: UploadFilesAtom }) { const setPickedFiles = useSetAtom(pickedFilesAtom) const clearAllFileUploadStatuses = useSetAtom( clearAllFileUploadStatusesAtom, ) const { data: uploadResults, isPending: isUploading, reset: resetUploadFilesMutation, } = useAtomValue(uploadFilesAtom) const successfulUploadCount = useAtomValue(successfulFileUploadCountAtom) if ( !uploadResults || uploadResults.length === 0 || successfulUploadCount !== uploadResults.length ) { return null } function resetUploadState() { setPickedFiles([]) clearAllFileUploadStatuses() resetUploadFilesMutation() } return ( ) } /** * allows the user to select more files after they have selected some files for upload. only visible before any upload has been started. */ function SelectMoreFilesButton({ onClick, uploadFilesAtom, }: { onClick: () => void uploadFilesAtom: UploadFilesAtom }) { const pickedFiles = useAtomValue(pickedFilesAtom) const { data: uploadResults, isPending: isUploading } = useAtomValue(uploadFilesAtom) if (pickedFiles.length === 0 || uploadResults) { return null } return ( ) } function UploadButton({ uploadFilesAtom, onClick, }: { uploadFilesAtom: UploadFilesAtom onClick: () => void }) { const pickedFiles = useAtomValue(pickedFilesAtom) const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom) const fileUploadCount = useAtomValue(fileUploadCountAtom) const { isPending: isUploading } = useAtomValue(uploadFilesAtom) let label: string if (hasUploadErrors) { label = "Retry failed uploads" } else if (pickedFiles.length > 0) { if (fileUploadCount > 0) { label = "Done" } else { label = `Upload ${pickedFiles.length} files` } } else { label = "Upload" } return ( ) } function UploadFileDropContainer({ children }: React.PropsWithChildren) { const [draggedFiles, setDraggedFiles] = useState([]) const setPickedFiles = useSetAtom(pickedFilesAtom) function handleDragOver(e: React.DragEvent) { e.preventDefault() const items = Array.from(e.dataTransfer.items) const draggedFiles = [] for (const item of items) { if (item.kind === "file") { draggedFiles.push(item) } } setDraggedFiles(draggedFiles) } function handleDragLeave() { setDraggedFiles([]) } function handleDrop(e: React.DragEvent) { e.preventDefault() const items = Array.from(e.dataTransfer.items) const droppedFiles: PickedFile[] = [] for (const item of items) { const file = item.getAsFile() if (file) { droppedFiles.push({ id: nanoid(), file, }) } } setPickedFiles((prev) => [...prev, ...droppedFiles]) setDraggedFiles([]) } return (
{children} {draggedFiles.length > 0 ? (

Drop {draggedFiles.length} files here

) : null}
) } // tag: uploadfilearea area fileuploadarea function UploadFileArea({ onClick }: { onClick: () => void }) { const [pickedFiles, setPickedFiles] = useAtom(pickedFilesAtom) function removeSelectedFile(file: PickedFile) { setPickedFiles((prev) => prev.filter((f) => f.id !== file.id)) } if (pickedFiles.length > 0) { return ( ) } return ( ) } function PickedFilesList({ pickedFiles, onRemoveFile, }: { pickedFiles: PickedFile[] onRemoveFile: (file: PickedFile) => void }) { return (
    {pickedFiles.map((file: PickedFile) => ( ))}
) } function PickedFileItem({ file: pickedFile, onRemove, }: { file: PickedFile onRemove: (file: PickedFile) => void }) { const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id) const fileUpload = useAtomValue(fileUploadAtom) console.log("fileUpload", fileUpload) const { file, id } = pickedFile let statusIndicator: React.ReactNode if (!fileUpload) { statusIndicator = ( ) } else { switch (fileUpload.kind) { case FileUploadStatusKind.InProgress: statusIndicator = ( ) break case FileUploadStatusKind.Error: statusIndicator = (

Failed to upload file:{" "} {formatError(fileUpload.error)}

) break case FileUploadStatusKind.Success: statusIndicator = (

File uploaded

) break } } return (
  • {file.name}

    {statusIndicator}
  • ) }