diff --git a/packages/web/src/files/PickedFileItem.tsx b/packages/web/src/files/PickedFileItem.tsx new file mode 100644 index 0000000..1e5ecdd --- /dev/null +++ b/packages/web/src/files/PickedFileItem.tsx @@ -0,0 +1,71 @@ +import { useAtomValue } from "jotai" +import { CircleAlertIcon, XIcon } from "lucide-react" +import type React from "react" +import { Button } from "@/components/ui/button" +import { Progress } from "@/components/ui/progress" +import { Tooltip } from "@/components/ui/tooltip" +import { FileUploadStatusKind, fileUploadStatusAtomFamily } from "./store" +import type { PickedFile } from "./upload-file-dialog" + +export 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 = ( + + + + + + ) + } + } + + return ( +
  • + {file.name} + {fileUpload ? ( + + ) : ( + + )} +
  • + ) +} diff --git a/packages/web/src/files/store.ts b/packages/web/src/files/store.ts index b33cb0b..4d4513a 100644 --- a/packages/web/src/files/store.ts +++ b/packages/web/src/files/store.ts @@ -1,19 +1,39 @@ import { atom } from "jotai" import { atomFamily } from "jotai/utils" -type FileUpload = { - id: string +export enum FileUploadStatusKind { + InProgress = "InProgress", + Error = "Error", + Success = "Success", +} + +export type FileUploadInProgress = { + kind: FileUploadStatusKind.InProgress progress: number } -export const fileUploadsAtom = atom>({}) +export type FileUploadError = { + kind: FileUploadStatusKind.Error + error: unknown +} -export const fileUploadAtomFamily = atomFamily((id: string) => +export type FileUploadSuccess = { + kind: FileUploadStatusKind.Success +} + +export type FileUploadStatus = + | FileUploadInProgress + | FileUploadError + | FileUploadSuccess + +export const fileUploadsAtom = atom>({}) + +export const fileUploadStatusAtomFamily = atomFamily((id: string) => atom( (get) => get(fileUploadsAtom)[id], - (get, set, progress: number) => { + (get, set, status: FileUploadStatus) => { const fileUploads = { ...get(fileUploadsAtom) } - fileUploads[id] = { id, progress } + fileUploads[id] = status set(fileUploadsAtom, fileUploads) }, ), @@ -22,12 +42,56 @@ export const fileUploadAtomFamily = atomFamily((id: string) => export const clearFileUploadAtom = atom(null, (get, set, id: string) => { const fileUploads = { ...get(fileUploadsAtom) } delete fileUploads[id] - fileUploadAtomFamily.remove(id) + fileUploadStatusAtomFamily.remove(id) set(fileUploadsAtom, fileUploads) }) +export const clearFileUploadStatusesAtom = atom( + null, + (get, set, ids: string[]) => { + const fileUploads = { ...get(fileUploadsAtom) } + for (const id of ids) { + if (fileUploads[id]) { + delete fileUploads[id] + } + fileUploadStatusAtomFamily.remove(id) + } + set(fileUploadsAtom, fileUploads) + }, +) + export const fileUploadCountAtom = atom( (get) => Object.keys(get(fileUploadsAtom)).length, ) -export const hasFileUploadsAtom = atom((get) => get(fileUploadCountAtom) > 0) +export const inProgressFileUploadCountAtom = atom((get) => { + const statuses = get(fileUploadsAtom) + let count = 0 + for (const status in statuses) { + if (statuses[status]?.kind === FileUploadStatusKind.InProgress) { + count += 1 + } + } + return count +}) + +export const successfulFileUploadCountAtom = atom((get) => { + const statuses = get(fileUploadsAtom) + let count = 0 + for (const status in statuses) { + if (statuses[status]?.kind === FileUploadStatusKind.Success) { + count += 1 + } + } + return count +}) + +export const hasFileUploadsErrorAtom = atom((get) => { + const statuses = get(fileUploadsAtom) + for (const status in statuses) { + if (statuses[status]?.kind === FileUploadStatusKind.Error) { + return true + } + } + return false +}) diff --git a/packages/web/src/files/upload-file-dialog.tsx b/packages/web/src/files/upload-file-dialog.tsx index a63a35b..de8e519 100644 --- a/packages/web/src/files/upload-file-dialog.tsx +++ b/packages/web/src/files/upload-file-dialog.tsx @@ -2,7 +2,13 @@ import type { Doc } from "@fileone/convex/_generated/dataModel" import { useMutation } from "@tanstack/react-query" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { atomEffect } from "jotai-effect" -import { FilePlus2Icon, UploadCloudIcon, XIcon } from "lucide-react" +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" @@ -17,8 +23,21 @@ import { DialogTitle, } from "@/components/ui/dialog" import { Progress } from "@/components/ui/progress" +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip" import { WithAtom } from "@/components/with-atom" -import { clearFileUploadAtom, fileUploadAtomFamily } from "./store" +import { formatError } from "@/lib/error" +import { + clearFileUploadStatusesAtom, + FileUploadStatusKind, + fileUploadCountAtom, + fileUploadStatusAtomFamily, + fileUploadsAtom, + hasFileUploadsErrorAtom, +} from "./store" import useUploadFile from "./use-upload-file" type UploadFileDialogProps = { @@ -41,7 +60,7 @@ export function UploadFileDialog({ const formId = useId() const fileInputRef = useRef(null) const setPickedFiles = useSetAtom(pickedFilesAtom) - const clearFileUpload = useSetAtom(clearFileUploadAtom) + const clearFileUploadStatuses = useSetAtom(clearFileUploadStatusesAtom) const store = useStore() const updateFileInputEffect = useMemo( @@ -69,21 +88,58 @@ export function UploadFileDialog({ uploadFile({ file: pickedFile.file, onStart: () => { - store.set(fileUploadAtomFamily(pickedFile.id), 0) + store.set(fileUploadStatusAtomFamily(pickedFile.id), { + kind: FileUploadStatusKind.InProgress, + progress: 0, + }) }, onProgress: (progress) => { - store.set(fileUploadAtomFamily(pickedFile.id), progress) + store.set(fileUploadStatusAtomFamily(pickedFile.id), { + kind: FileUploadStatusKind.InProgress, + progress, + }) }, - }).then(() => { - clearFileUpload(pickedFile.id) - }), + }) + .then(() => { + // clearFileUpload(pickedFile.id) + }) + .catch((error) => { + console.log("error", error) + store.set(fileUploadStatusAtomFamily(pickedFile.id), { + kind: FileUploadStatusKind.Error, + error, + }) + throw error + }), ) - await Promise.all(promises) + return await Promise.allSettled(promises) }, - onSuccess: () => { - toast.success("All files uploaded successfully") - setPickedFiles([]) - onClose() + 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") + } }, }) @@ -105,9 +161,50 @@ export function UploadFileDialog({ } } - function startFileUpload() { + function onUploadButtonClick() { + const uploadStatuses = store.get(fileUploadsAtom) + const fileUploadCount = store.get(fileUploadCountAtom) const pickedFiles = store.get(pickedFilesAtom) - uploadFiles(pickedFiles) + + 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) + uploadFiles(nextPickedFiles) + } } let dialogTitle: string @@ -165,15 +262,11 @@ export function UploadFileDialog({ Select more files ) : null} - + onClick={onUploadButtonClick} + /> )} @@ -183,6 +276,39 @@ export function UploadFileDialog({ ) } +function UploadButton({ + disabled, + loading, + onClick, +}: { + disabled: boolean + loading: boolean + onClick: () => void +}) { + const pickedFiles = useAtomValue(pickedFilesAtom) + const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom) + const fileUploadCount = useAtomValue(fileUploadCountAtom) + + 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) @@ -239,6 +365,7 @@ function UploadFileDropContainer({ children }: React.PropsWithChildren) { ) } +// tag: uploadfilearea area fileuploadarea function UploadFileArea({ onClick }: { onClick: () => void }) { const [pickedFiles, setPickedFiles] = useAtom(pickedFilesAtom) @@ -294,30 +421,62 @@ function PickedFileItem({ file: PickedFile onRemove: (file: PickedFile) => void }) { - const fileUploadAtom = fileUploadAtomFamily(pickedFile.id) + 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 = ( + + ) + break + } + } + return (
  • {file.name} - {fileUpload ? ( - - ) : ( - - )} + {statusIndicator}
  • ) } diff --git a/packages/web/src/files/use-upload-file.ts b/packages/web/src/files/use-upload-file.ts index 08a72ec..098b094 100644 --- a/packages/web/src/files/use-upload-file.ts +++ b/packages/web/src/files/use-upload-file.ts @@ -29,7 +29,6 @@ function useUploadFile({ }) xhr.upload.addEventListener("error", reject) xhr.addEventListener("load", () => { - console.log("load", xhr.response) resolve( xhr.response as { storageId: Id<"_storage"> diff --git a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx index 6c197a4..0a23d6c 100644 --- a/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx +++ b/packages/web/src/routes/_authenticated/_sidebar-layout/directories.$directoryId.tsx @@ -44,7 +44,7 @@ import { FilePathBreadcrumb } from "@/directories/directory-page/file-path-bread import { NewDirectoryDialog } from "@/directories/directory-page/new-directory-dialog" import { RenameFileDialog } from "@/directories/directory-page/rename-file-dialog" import { FilePreviewDialog } from "@/files/file-preview-dialog" -import { fileUploadCountAtom } from "@/files/store" +import { inProgressFileUploadCountAtom } from "@/files/store" import { UploadFileDialog } from "@/files/upload-file-dialog" import type { FileDragInfo } from "@/files/use-file-drop" @@ -333,7 +333,9 @@ function RenameMenuItem() { function UploadFileButton() { const { directory } = useContext(DirectoryPageContext) const setActiveDialogData = useSetAtom(activeDialogDataAtom) - const fileUploadCount = useAtomValue(fileUploadCountAtom) + const inProgressFileUploadCount = useAtomValue( + inProgressFileUploadCountAtom, + ) const handleClick = () => { setActiveDialogData({ @@ -342,10 +344,10 @@ function UploadFileButton() { }) } - if (fileUploadCount > 0) { + if (inProgressFileUploadCount > 0) { return ( ) }