From 49b76934b2f95d3ec509b6b5108781e4dd978735 Mon Sep 17 00:00:00 2001 From: kenneth Date: Mon, 13 Oct 2025 22:55:25 +0000 Subject: [PATCH] feat: show upload success in upload dialog title --- packages/web/package.json | 1 + packages/web/src/files/store.ts | 34 +- packages/web/src/files/upload-file-dialog.tsx | 339 ++++++++++++------ 3 files changed, 246 insertions(+), 128 deletions(-) diff --git a/packages/web/package.json b/packages/web/package.json index d669eca..f5fdbc8 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -34,6 +34,7 @@ "jotai": "^2.14.0", "jotai-effect": "^2.1.3", "jotai-scope": "^0.9.5", + "jotai-tanstack-query": "^0.11.0", "lucide-react": "^0.544.0", "motion": "^12.23.16", "nanoid": "^5.1.6", diff --git a/packages/web/src/files/store.ts b/packages/web/src/files/store.ts index 4d4513a..94491b4 100644 --- a/packages/web/src/files/store.ts +++ b/packages/web/src/files/store.ts @@ -26,46 +26,46 @@ export type FileUploadStatus = | FileUploadError | FileUploadSuccess -export const fileUploadsAtom = atom>({}) +export const fileUploadStatusesAtom = atom>({}) export const fileUploadStatusAtomFamily = atomFamily((id: string) => atom( - (get) => get(fileUploadsAtom)[id], + (get) => get(fileUploadStatusesAtom)[id], (get, set, status: FileUploadStatus) => { - const fileUploads = { ...get(fileUploadsAtom) } + const fileUploads = { ...get(fileUploadStatusesAtom) } 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( null, (get, set, ids: string[]) => { - const fileUploads = { ...get(fileUploadsAtom) } + const fileUploads = { ...get(fileUploadStatusesAtom) } for (const id of ids) { if (fileUploads[id]) { delete fileUploads[id] } fileUploadStatusAtomFamily.remove(id) } - set(fileUploadsAtom, fileUploads) + set(fileUploadStatusesAtom, fileUploads) + }, +) + +export const clearAllFileUploadStatusesAtom = atom( + null, + (get, set) => { + set(fileUploadStatusesAtom, {}) }, ) export const fileUploadCountAtom = atom( - (get) => Object.keys(get(fileUploadsAtom)).length, + (get) => Object.keys(get(fileUploadStatusesAtom)).length, ) export const inProgressFileUploadCountAtom = atom((get) => { - const statuses = get(fileUploadsAtom) + const statuses = get(fileUploadStatusesAtom) let count = 0 for (const status in statuses) { if (statuses[status]?.kind === FileUploadStatusKind.InProgress) { @@ -76,7 +76,7 @@ export const inProgressFileUploadCountAtom = atom((get) => { }) export const successfulFileUploadCountAtom = atom((get) => { - const statuses = get(fileUploadsAtom) + const statuses = get(fileUploadStatusesAtom) let count = 0 for (const status in statuses) { if (statuses[status]?.kind === FileUploadStatusKind.Success) { @@ -87,7 +87,7 @@ export const successfulFileUploadCountAtom = atom((get) => { }) export const hasFileUploadsErrorAtom = atom((get) => { - const statuses = get(fileUploadsAtom) + const statuses = get(fileUploadStatusesAtom) for (const status in statuses) { if (statuses[status]?.kind === FileUploadStatusKind.Error) { return true diff --git a/packages/web/src/files/upload-file-dialog.tsx b/packages/web/src/files/upload-file-dialog.tsx index de8e519..2ed05f0 100644 --- a/packages/web/src/files/upload-file-dialog.tsx +++ b/packages/web/src/files/upload-file-dialog.tsx @@ -1,7 +1,8 @@ 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 { atomEffect } from "jotai-effect" +import { atomWithMutation } from "jotai-tanstack-query" import { CircleAlertIcon, CircleCheckIcon, @@ -28,15 +29,16 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip" -import { WithAtom } from "@/components/with-atom" import { formatError } from "@/lib/error" import { + clearAllFileUploadStatusesAtom, clearFileUploadStatusesAtom, FileUploadStatusKind, fileUploadCountAtom, fileUploadStatusAtomFamily, - fileUploadsAtom, + fileUploadStatusesAtom, hasFileUploadsErrorAtom, + successfulFileUploadCountAtom, } from "./store" import useUploadFile from "./use-upload-file" @@ -53,6 +55,88 @@ export type PickedFile = { 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, @@ -79,69 +163,9 @@ export function UploadFileDialog({ ) useAtom(updateFileInputEffect) - const uploadFile = useUploadFile({ + const uploadFilesAtom = useUploadFilesAtom({ 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) { event.preventDefault() @@ -162,9 +186,11 @@ export function UploadFileDialog({ } function onUploadButtonClick() { - const uploadStatuses = store.get(fileUploadsAtom) + 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 @@ -203,24 +229,11 @@ export function UploadFileDialog({ } else { // some files were not successfully uploaded, set the next picked files setPickedFiles(nextPickedFiles) + restUploadFilesMutation() 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 ( - - {dialogTitle} - {dialogDescription} - +
- - {(pickedFiles) => ( - <> - {pickedFiles.length > 0 ? ( - - ) : null} - - - )} - + + +
) } +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({ - disabled, - loading, + uploadFilesAtom, onClick, }: { - disabled: boolean - loading: boolean + 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) { @@ -303,7 +413,7 @@ function UploadButton({ } return ( - ) @@ -464,7 +574,14 @@ function PickedFileItem({ break case FileUploadStatusKind.Success: statusIndicator = ( - + + + + + +

File uploaded

+
+
) break } @@ -472,10 +589,10 @@ function PickedFileItem({ return (
  • - {file.name} +

    {file.name}

    {statusIndicator}
  • )