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 (
)
}