mirror of
https://github.com/get-drexa/drive.git
synced 2025-12-01 14:01:40 +00:00
feat: upload file dialog err handling & new flow
- add basic err handling to upload file dialog. - rework the upload flow. now, on all successful uploads, the dialog won't auto disappear. if some fails, the dialog will allow for retry.
This commit is contained in:
71
packages/web/src/files/PickedFileItem.tsx
Normal file
71
packages/web/src/files/PickedFileItem.tsx
Normal file
@@ -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 = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(pickedFile)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
} else {
|
||||
switch (fileUpload.kind) {
|
||||
case FileUploadStatusKind.InProgress:
|
||||
statusIndicator = <Progress value={fileUpload.progress * 100} />
|
||||
break
|
||||
case FileUploadStatusKind.Error:
|
||||
statusIndicator = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<CircleAlertIcon />
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center"
|
||||
key={id}
|
||||
>
|
||||
<span>{file.name}</span>
|
||||
{fileUpload ? (
|
||||
<Progress
|
||||
className="max-w-20"
|
||||
value={fileUpload.progress * 100}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(pickedFile)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
@@ -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<Record<string, FileUpload>>({})
|
||||
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<Record<string, FileUploadStatus>>({})
|
||||
|
||||
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
|
||||
})
|
||||
|
||||
@@ -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<HTMLInputElement>(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
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
onClick={startFileUpload}
|
||||
<UploadButton
|
||||
disabled={isUploading}
|
||||
loading={isUploading}
|
||||
>
|
||||
{pickedFiles.length === 0
|
||||
? "Upload"
|
||||
: `Upload ${pickedFiles.length} files`}
|
||||
</Button>
|
||||
onClick={onUploadButtonClick}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</WithAtom>
|
||||
@@ -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 (
|
||||
<Button onClick={onClick} disabled={disabled} loading={loading}>
|
||||
{label}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function UploadFileDropContainer({ children }: React.PropsWithChildren) {
|
||||
const [draggedFiles, setDraggedFiles] = useState<DataTransferItem[]>([])
|
||||
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 = (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(pickedFile)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
} else {
|
||||
switch (fileUpload.kind) {
|
||||
case FileUploadStatusKind.InProgress:
|
||||
statusIndicator = (
|
||||
<Progress
|
||||
className="max-w-20"
|
||||
value={fileUpload.progress * 100}
|
||||
/>
|
||||
)
|
||||
break
|
||||
case FileUploadStatusKind.Error:
|
||||
statusIndicator = (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<CircleAlertIcon className="pr-2 text-destructive" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Failed to upload file:{" "}
|
||||
{formatError(fileUpload.error)}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
break
|
||||
case FileUploadStatusKind.Success:
|
||||
statusIndicator = (
|
||||
<CircleCheckIcon className="pr-2 text-green-500" />
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center"
|
||||
key={id}
|
||||
>
|
||||
<span>{file.name}</span>
|
||||
{fileUpload ? (
|
||||
<Progress
|
||||
className="max-w-20"
|
||||
value={fileUpload.progress * 100}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onRemove(pickedFile)}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{statusIndicator}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
<Button size="sm" type="button" loading onClick={handleClick}>
|
||||
Uploading {fileUploadCount} files
|
||||
Uploading {inProgressFileUploadCount} files
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user