feat: show upload success in upload dialog title

This commit is contained in:
2025-10-13 22:55:25 +00:00
parent 2ed8be94f1
commit 49b76934b2
3 changed files with 246 additions and 128 deletions

View File

@@ -34,6 +34,7 @@
"jotai": "^2.14.0", "jotai": "^2.14.0",
"jotai-effect": "^2.1.3", "jotai-effect": "^2.1.3",
"jotai-scope": "^0.9.5", "jotai-scope": "^0.9.5",
"jotai-tanstack-query": "^0.11.0",
"lucide-react": "^0.544.0", "lucide-react": "^0.544.0",
"motion": "^12.23.16", "motion": "^12.23.16",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",

View File

@@ -26,46 +26,46 @@ export type FileUploadStatus =
| FileUploadError | FileUploadError
| FileUploadSuccess | FileUploadSuccess
export const fileUploadsAtom = atom<Record<string, FileUploadStatus>>({}) export const fileUploadStatusesAtom = atom<Record<string, FileUploadStatus>>({})
export const fileUploadStatusAtomFamily = atomFamily((id: string) => export const fileUploadStatusAtomFamily = atomFamily((id: string) =>
atom( atom(
(get) => get(fileUploadsAtom)[id], (get) => get(fileUploadStatusesAtom)[id],
(get, set, status: FileUploadStatus) => { (get, set, status: FileUploadStatus) => {
const fileUploads = { ...get(fileUploadsAtom) } const fileUploads = { ...get(fileUploadStatusesAtom) }
fileUploads[id] = status fileUploads[id] = status
set(fileUploadsAtom, fileUploads) set(fileUploadStatusesAtom, fileUploads)
}, },
), ),
) )
export const clearFileUploadAtom = atom(null, (get, set, id: string) => {
const fileUploads = { ...get(fileUploadsAtom) }
delete fileUploads[id]
fileUploadStatusAtomFamily.remove(id)
set(fileUploadsAtom, fileUploads)
})
export const clearFileUploadStatusesAtom = atom( export const clearFileUploadStatusesAtom = atom(
null, null,
(get, set, ids: string[]) => { (get, set, ids: string[]) => {
const fileUploads = { ...get(fileUploadsAtom) } const fileUploads = { ...get(fileUploadStatusesAtom) }
for (const id of ids) { for (const id of ids) {
if (fileUploads[id]) { if (fileUploads[id]) {
delete fileUploads[id] delete fileUploads[id]
} }
fileUploadStatusAtomFamily.remove(id) fileUploadStatusAtomFamily.remove(id)
} }
set(fileUploadsAtom, fileUploads) set(fileUploadStatusesAtom, fileUploads)
},
)
export const clearAllFileUploadStatusesAtom = atom(
null,
(get, set) => {
set(fileUploadStatusesAtom, {})
}, },
) )
export const fileUploadCountAtom = atom( export const fileUploadCountAtom = atom(
(get) => Object.keys(get(fileUploadsAtom)).length, (get) => Object.keys(get(fileUploadStatusesAtom)).length,
) )
export const inProgressFileUploadCountAtom = atom((get) => { export const inProgressFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadsAtom) const statuses = get(fileUploadStatusesAtom)
let count = 0 let count = 0
for (const status in statuses) { for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.InProgress) { if (statuses[status]?.kind === FileUploadStatusKind.InProgress) {
@@ -76,7 +76,7 @@ export const inProgressFileUploadCountAtom = atom((get) => {
}) })
export const successfulFileUploadCountAtom = atom((get) => { export const successfulFileUploadCountAtom = atom((get) => {
const statuses = get(fileUploadsAtom) const statuses = get(fileUploadStatusesAtom)
let count = 0 let count = 0
for (const status in statuses) { for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Success) { if (statuses[status]?.kind === FileUploadStatusKind.Success) {
@@ -87,7 +87,7 @@ export const successfulFileUploadCountAtom = atom((get) => {
}) })
export const hasFileUploadsErrorAtom = atom((get) => { export const hasFileUploadsErrorAtom = atom((get) => {
const statuses = get(fileUploadsAtom) const statuses = get(fileUploadStatusesAtom)
for (const status in statuses) { for (const status in statuses) {
if (statuses[status]?.kind === FileUploadStatusKind.Error) { if (statuses[status]?.kind === FileUploadStatusKind.Error) {
return true return true

View File

@@ -1,7 +1,8 @@
import type { Doc } from "@fileone/convex/_generated/dataModel" import type { Doc } from "@fileone/convex/_generated/dataModel"
import { useMutation } from "@tanstack/react-query" import { mutationOptions } from "@tanstack/react-query"
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai" import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
import { atomEffect } from "jotai-effect" import { atomEffect } from "jotai-effect"
import { atomWithMutation } from "jotai-tanstack-query"
import { import {
CircleAlertIcon, CircleAlertIcon,
CircleCheckIcon, CircleCheckIcon,
@@ -28,15 +29,16 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip" } from "@/components/ui/tooltip"
import { WithAtom } from "@/components/with-atom"
import { formatError } from "@/lib/error" import { formatError } from "@/lib/error"
import { import {
clearAllFileUploadStatusesAtom,
clearFileUploadStatusesAtom, clearFileUploadStatusesAtom,
FileUploadStatusKind, FileUploadStatusKind,
fileUploadCountAtom, fileUploadCountAtom,
fileUploadStatusAtomFamily, fileUploadStatusAtomFamily,
fileUploadsAtom, fileUploadStatusesAtom,
hasFileUploadsErrorAtom, hasFileUploadsErrorAtom,
successfulFileUploadCountAtom,
} from "./store" } from "./store"
import useUploadFile from "./use-upload-file" import useUploadFile from "./use-upload-file"
@@ -53,6 +55,88 @@ export type PickedFile = {
export const pickedFilesAtom = atom<PickedFile[]>([]) export const pickedFilesAtom = atom<PickedFile[]>([])
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<typeof useUploadFilesAtom>
export function UploadFileDialog({ export function UploadFileDialog({
targetDirectory, targetDirectory,
onClose, onClose,
@@ -79,69 +163,9 @@ export function UploadFileDialog({
) )
useAtom(updateFileInputEffect) useAtom(updateFileInputEffect)
const uploadFile = useUploadFile({ const uploadFilesAtom = useUploadFilesAtom({
targetDirectory, targetDirectory,
}) })
const { mutate: uploadFiles, isPending: isUploading } = useMutation({
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,
})
},
})
.then(() => {
// clearFileUpload(pickedFile.id)
})
.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")
}
},
})
function handleSubmit(event: React.FormEvent<HTMLFormElement>) { function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault() event.preventDefault()
@@ -162,9 +186,11 @@ export function UploadFileDialog({
} }
function onUploadButtonClick() { function onUploadButtonClick() {
const uploadStatuses = store.get(fileUploadsAtom) const uploadStatuses = store.get(fileUploadStatusesAtom)
const fileUploadCount = store.get(fileUploadCountAtom) const fileUploadCount = store.get(fileUploadCountAtom)
const pickedFiles = store.get(pickedFilesAtom) const pickedFiles = store.get(pickedFilesAtom)
const { mutate: uploadFiles, reset: restUploadFilesMutation } =
store.get(uploadFilesAtom)
if (pickedFiles.length === 0) { if (pickedFiles.length === 0) {
// no files are picked, nothing to upload // no files are picked, nothing to upload
@@ -203,24 +229,11 @@ export function UploadFileDialog({
} else { } else {
// some files were not successfully uploaded, set the next picked files // some files were not successfully uploaded, set the next picked files
setPickedFiles(nextPickedFiles) setPickedFiles(nextPickedFiles)
restUploadFilesMutation()
uploadFiles(nextPickedFiles) uploadFiles(nextPickedFiles)
} }
} }
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 (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 ( return (
<Dialog <Dialog
open open
@@ -229,10 +242,10 @@ export function UploadFileDialog({
}} }}
> >
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <UploadDialogHeader
<DialogTitle>{dialogTitle}</DialogTitle> uploadFilesAtom={uploadFilesAtom}
<DialogDescription>{dialogDescription}</DialogDescription> targetDirectory={targetDirectory}
</DialogHeader> />
<form id={formId} onSubmit={handleSubmit}> <form id={formId} onSubmit={handleSubmit}>
<input <input
@@ -250,44 +263,141 @@ export function UploadFileDialog({
</form> </form>
<DialogFooter> <DialogFooter>
<WithAtom atom={pickedFilesAtom}> <ContinueUploadAfterSuccessfulUploadButton
{(pickedFiles) => ( uploadFilesAtom={uploadFilesAtom}
<> />
{pickedFiles.length > 0 ? ( <SelectMoreFilesButton
<Button onClick={openFilePicker}
variant="outline" uploadFilesAtom={uploadFilesAtom}
onClick={openFilePicker} />
disabled={isUploading} <UploadButton
> uploadFilesAtom={uploadFilesAtom}
Select more files onClick={onUploadButtonClick}
</Button> />
) : null}
<UploadButton
disabled={isUploading}
loading={isUploading}
onClick={onUploadButtonClick}
/>
</>
)}
</WithAtom>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) )
} }
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 (
<DialogHeader>
<DialogTitle>{dialogTitle}</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
)
}
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 (
<Button
variant="outline"
onClick={resetUploadState}
disabled={isUploading}
>
Upload more files
</Button>
)
}
/**
* 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 (
<Button variant="outline" onClick={onClick} disabled={isUploading}>
Select more files
</Button>
)
}
function UploadButton({ function UploadButton({
disabled, uploadFilesAtom,
loading,
onClick, onClick,
}: { }: {
disabled: boolean uploadFilesAtom: UploadFilesAtom
loading: boolean
onClick: () => void onClick: () => void
}) { }) {
const pickedFiles = useAtomValue(pickedFilesAtom) const pickedFiles = useAtomValue(pickedFilesAtom)
const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom) const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom)
const fileUploadCount = useAtomValue(fileUploadCountAtom) const fileUploadCount = useAtomValue(fileUploadCountAtom)
const { isPending: isUploading } = useAtomValue(uploadFilesAtom)
let label: string let label: string
if (hasUploadErrors) { if (hasUploadErrors) {
@@ -303,7 +413,7 @@ function UploadButton({
} }
return ( return (
<Button onClick={onClick} disabled={disabled} loading={loading}> <Button onClick={onClick} disabled={isUploading} loading={isUploading}>
{label} {label}
</Button> </Button>
) )
@@ -464,7 +574,14 @@ function PickedFileItem({
break break
case FileUploadStatusKind.Success: case FileUploadStatusKind.Success:
statusIndicator = ( statusIndicator = (
<CircleCheckIcon className="pr-2 text-green-500" /> <Tooltip>
<TooltipTrigger>
<CircleCheckIcon className="pr-2 text-green-500" />
</TooltipTrigger>
<TooltipContent>
<p>File uploaded</p>
</TooltipContent>
</Tooltip>
) )
break break
} }
@@ -472,10 +589,10 @@ function PickedFileItem({
return ( return (
<li <li
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center" className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center border-b border-border"
key={id} key={id}
> >
<span>{file.name}</span> <p>{file.name} </p>
{statusIndicator} {statusIndicator}
</li> </li>
) )