2025-10-18 19:32:05 +00:00
|
|
|
import type { Doc } from "@fileone/convex/dataModel"
|
2025-10-13 22:55:25 +00:00
|
|
|
import { mutationOptions } from "@tanstack/react-query"
|
2025-10-12 17:09:42 +00:00
|
|
|
import { atom, useAtom, useAtomValue, useSetAtom, useStore } from "jotai"
|
2025-10-12 00:43:31 +00:00
|
|
|
import { atomEffect } from "jotai-effect"
|
2025-10-13 22:55:25 +00:00
|
|
|
import { atomWithMutation } from "jotai-tanstack-query"
|
2025-10-12 23:48:21 +00:00
|
|
|
import {
|
|
|
|
|
CircleAlertIcon,
|
|
|
|
|
CircleCheckIcon,
|
|
|
|
|
FilePlus2Icon,
|
|
|
|
|
UploadCloudIcon,
|
|
|
|
|
XIcon,
|
|
|
|
|
} from "lucide-react"
|
2025-10-12 00:43:31 +00:00
|
|
|
import { nanoid } from "nanoid"
|
|
|
|
|
import type React from "react"
|
|
|
|
|
import { useId, useMemo, useRef, useState } from "react"
|
|
|
|
|
import { toast } from "sonner"
|
|
|
|
|
import { Button } from "@/components/ui/button"
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogDescription,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from "@/components/ui/dialog"
|
|
|
|
|
import { Progress } from "@/components/ui/progress"
|
2025-10-12 23:48:21 +00:00
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from "@/components/ui/tooltip"
|
|
|
|
|
import { formatError } from "@/lib/error"
|
|
|
|
|
import {
|
2025-10-13 22:55:25 +00:00
|
|
|
clearAllFileUploadStatusesAtom,
|
2025-10-12 23:48:21 +00:00
|
|
|
clearFileUploadStatusesAtom,
|
|
|
|
|
FileUploadStatusKind,
|
|
|
|
|
fileUploadCountAtom,
|
|
|
|
|
fileUploadStatusAtomFamily,
|
2025-10-13 22:55:25 +00:00
|
|
|
fileUploadStatusesAtom,
|
2025-10-12 23:48:21 +00:00
|
|
|
hasFileUploadsErrorAtom,
|
2025-10-13 22:55:25 +00:00
|
|
|
successfulFileUploadCountAtom,
|
2025-10-12 23:48:21 +00:00
|
|
|
} from "./store"
|
2025-10-12 00:43:31 +00:00
|
|
|
import useUploadFile from "./use-upload-file"
|
|
|
|
|
|
|
|
|
|
type UploadFileDialogProps = {
|
|
|
|
|
targetDirectory: Doc<"directories">
|
|
|
|
|
onClose: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Upload file atoms
|
|
|
|
|
export type PickedFile = {
|
|
|
|
|
id: string
|
|
|
|
|
file: File
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export const pickedFilesAtom = atom<PickedFile[]>([])
|
|
|
|
|
|
2025-10-13 22:55:25 +00:00
|
|
|
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>
|
|
|
|
|
|
2025-10-12 00:43:31 +00:00
|
|
|
export function UploadFileDialog({
|
|
|
|
|
targetDirectory,
|
|
|
|
|
onClose,
|
|
|
|
|
}: UploadFileDialogProps) {
|
|
|
|
|
const formId = useId()
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
|
|
|
const setPickedFiles = useSetAtom(pickedFilesAtom)
|
2025-10-12 23:48:21 +00:00
|
|
|
const clearFileUploadStatuses = useSetAtom(clearFileUploadStatusesAtom)
|
2025-10-12 00:43:31 +00:00
|
|
|
const store = useStore()
|
|
|
|
|
|
|
|
|
|
const updateFileInputEffect = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
atomEffect((get) => {
|
|
|
|
|
const dataTransfer = new DataTransfer()
|
|
|
|
|
const pickedFiles = get(pickedFilesAtom)
|
|
|
|
|
for (const { file } of pickedFiles) {
|
|
|
|
|
dataTransfer.items.add(file)
|
|
|
|
|
}
|
|
|
|
|
if (fileInputRef.current) {
|
|
|
|
|
fileInputRef.current.files = dataTransfer.files
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
[],
|
|
|
|
|
)
|
|
|
|
|
useAtom(updateFileInputEffect)
|
|
|
|
|
|
2025-10-13 22:55:25 +00:00
|
|
|
const uploadFilesAtom = useUploadFilesAtom({
|
2025-10-12 00:43:31 +00:00
|
|
|
targetDirectory,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function openFilePicker() {
|
|
|
|
|
fileInputRef.current?.click()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleFileChange(event: React.ChangeEvent<HTMLInputElement>) {
|
|
|
|
|
const files = event.target.files
|
|
|
|
|
if (files) {
|
|
|
|
|
setPickedFiles((prev) => [
|
|
|
|
|
...prev,
|
|
|
|
|
...Array.from(files).map((file) => ({ id: nanoid(), file })),
|
|
|
|
|
])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-12 23:48:21 +00:00
|
|
|
function onUploadButtonClick() {
|
2025-10-13 22:55:25 +00:00
|
|
|
const uploadStatuses = store.get(fileUploadStatusesAtom)
|
2025-10-12 23:48:21 +00:00
|
|
|
const fileUploadCount = store.get(fileUploadCountAtom)
|
2025-10-12 00:43:31 +00:00
|
|
|
const pickedFiles = store.get(pickedFilesAtom)
|
2025-10-13 22:55:25 +00:00
|
|
|
const { mutate: uploadFiles, reset: restUploadFilesMutation } =
|
|
|
|
|
store.get(uploadFilesAtom)
|
2025-10-12 23:48:21 +00:00
|
|
|
|
|
|
|
|
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)
|
2025-10-13 22:55:25 +00:00
|
|
|
restUploadFilesMutation()
|
2025-10-12 23:48:21 +00:00
|
|
|
uploadFiles(nextPickedFiles)
|
|
|
|
|
}
|
2025-10-12 00:43:31 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog
|
|
|
|
|
open
|
|
|
|
|
onOpenChange={(open) => {
|
|
|
|
|
if (!open) onClose()
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<DialogContent className="sm:max-w-2xl">
|
2025-10-13 22:55:25 +00:00
|
|
|
<UploadDialogHeader
|
|
|
|
|
uploadFilesAtom={uploadFilesAtom}
|
|
|
|
|
targetDirectory={targetDirectory}
|
|
|
|
|
/>
|
2025-10-12 00:43:31 +00:00
|
|
|
|
|
|
|
|
<form id={formId} onSubmit={handleSubmit}>
|
|
|
|
|
<input
|
|
|
|
|
hidden
|
|
|
|
|
multiple
|
|
|
|
|
type="file"
|
|
|
|
|
name="files"
|
|
|
|
|
ref={fileInputRef}
|
|
|
|
|
onChange={handleFileChange}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<UploadFileDropContainer>
|
|
|
|
|
<UploadFileArea onClick={openFilePicker} />
|
|
|
|
|
</UploadFileDropContainer>
|
|
|
|
|
</form>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
2025-10-13 22:55:25 +00:00
|
|
|
<ContinueUploadAfterSuccessfulUploadButton
|
|
|
|
|
uploadFilesAtom={uploadFilesAtom}
|
|
|
|
|
/>
|
|
|
|
|
<SelectMoreFilesButton
|
|
|
|
|
onClick={openFilePicker}
|
|
|
|
|
uploadFilesAtom={uploadFilesAtom}
|
|
|
|
|
/>
|
|
|
|
|
<UploadButton
|
|
|
|
|
uploadFilesAtom={uploadFilesAtom}
|
|
|
|
|
onClick={onUploadButtonClick}
|
|
|
|
|
/>
|
2025-10-12 00:43:31 +00:00
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-13 22:55:25 +00:00
|
|
|
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>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-12 23:48:21 +00:00
|
|
|
function UploadButton({
|
2025-10-13 22:55:25 +00:00
|
|
|
uploadFilesAtom,
|
2025-10-12 23:48:21 +00:00
|
|
|
onClick,
|
|
|
|
|
}: {
|
2025-10-13 22:55:25 +00:00
|
|
|
uploadFilesAtom: UploadFilesAtom
|
2025-10-12 23:48:21 +00:00
|
|
|
onClick: () => void
|
|
|
|
|
}) {
|
|
|
|
|
const pickedFiles = useAtomValue(pickedFilesAtom)
|
|
|
|
|
const hasUploadErrors = useAtomValue(hasFileUploadsErrorAtom)
|
|
|
|
|
const fileUploadCount = useAtomValue(fileUploadCountAtom)
|
2025-10-13 22:55:25 +00:00
|
|
|
const { isPending: isUploading } = useAtomValue(uploadFilesAtom)
|
2025-10-12 23:48:21 +00:00
|
|
|
|
|
|
|
|
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 (
|
2025-10-13 22:55:25 +00:00
|
|
|
<Button onClick={onClick} disabled={isUploading} loading={isUploading}>
|
2025-10-12 23:48:21 +00:00
|
|
|
{label}
|
|
|
|
|
</Button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-12 00:43:31 +00:00
|
|
|
function UploadFileDropContainer({ children }: React.PropsWithChildren) {
|
|
|
|
|
const [draggedFiles, setDraggedFiles] = useState<DataTransferItem[]>([])
|
|
|
|
|
const setPickedFiles = useSetAtom(pickedFilesAtom)
|
|
|
|
|
|
|
|
|
|
function handleDragOver(e: React.DragEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
const items = Array.from(e.dataTransfer.items)
|
|
|
|
|
const draggedFiles = []
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
if (item.kind === "file") {
|
|
|
|
|
draggedFiles.push(item)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setDraggedFiles(draggedFiles)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDragLeave() {
|
|
|
|
|
setDraggedFiles([])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function handleDrop(e: React.DragEvent) {
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
const items = Array.from(e.dataTransfer.items)
|
|
|
|
|
const droppedFiles: PickedFile[] = []
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const file = item.getAsFile()
|
|
|
|
|
if (file) {
|
|
|
|
|
droppedFiles.push({
|
|
|
|
|
id: nanoid(),
|
|
|
|
|
file,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
setPickedFiles((prev) => [...prev, ...droppedFiles])
|
|
|
|
|
setDraggedFiles([])
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<section
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
onDragLeave={handleDragLeave}
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
aria-label="File drop area"
|
|
|
|
|
className="relative"
|
|
|
|
|
>
|
|
|
|
|
{children}
|
|
|
|
|
{draggedFiles.length > 0 ? (
|
|
|
|
|
<div className="border border-accent bg-primary text-primary-foreground absolute inset-0 rounded flex flex-col items-center justify-center text-sm space-y-1">
|
|
|
|
|
<FilePlus2Icon className="animate-bounce" />
|
|
|
|
|
<p>Drop {draggedFiles.length} files here</p>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</section>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-12 23:48:21 +00:00
|
|
|
// tag: uploadfilearea area fileuploadarea
|
2025-10-12 00:43:31 +00:00
|
|
|
function UploadFileArea({ onClick }: { onClick: () => void }) {
|
|
|
|
|
const [pickedFiles, setPickedFiles] = useAtom(pickedFilesAtom)
|
|
|
|
|
|
|
|
|
|
function removeSelectedFile(file: PickedFile) {
|
|
|
|
|
setPickedFiles((prev) => prev.filter((f) => f.id !== file.id))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (pickedFiles.length > 0) {
|
|
|
|
|
return (
|
|
|
|
|
<PickedFilesList
|
|
|
|
|
pickedFiles={pickedFiles}
|
|
|
|
|
onRemoveFile={removeSelectedFile}
|
|
|
|
|
/>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="w-full h-48 border-2 rounded border-dashed border-border flex flex-col items-center justify-center text-muted-foreground text-sm space-y-1 hover:bg-muted transition-all hover:border-solid"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
>
|
|
|
|
|
<UploadCloudIcon />
|
|
|
|
|
<span>Click to select files or drag and drop them here</span>
|
|
|
|
|
</button>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PickedFilesList({
|
|
|
|
|
pickedFiles,
|
|
|
|
|
onRemoveFile,
|
|
|
|
|
}: {
|
|
|
|
|
pickedFiles: PickedFile[]
|
|
|
|
|
onRemoveFile: (file: PickedFile) => void
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<ul className="min-h-48 border border-border rounded bg-card text-sm">
|
|
|
|
|
{pickedFiles.map((file: PickedFile) => (
|
|
|
|
|
<PickedFileItem
|
|
|
|
|
key={file.id}
|
|
|
|
|
file={file}
|
|
|
|
|
onRemove={onRemoveFile}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function PickedFileItem({
|
|
|
|
|
file: pickedFile,
|
|
|
|
|
onRemove,
|
|
|
|
|
}: {
|
|
|
|
|
file: PickedFile
|
|
|
|
|
onRemove: (file: PickedFile) => void
|
|
|
|
|
}) {
|
2025-10-12 23:48:21 +00:00
|
|
|
const fileUploadAtom = fileUploadStatusAtomFamily(pickedFile.id)
|
2025-10-12 00:43:31 +00:00
|
|
|
const fileUpload = useAtomValue(fileUploadAtom)
|
|
|
|
|
console.log("fileUpload", fileUpload)
|
|
|
|
|
const { file, id } = pickedFile
|
2025-10-12 23:48:21 +00:00
|
|
|
|
|
|
|
|
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 = (
|
2025-10-13 22:55:25 +00:00
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger>
|
|
|
|
|
<CircleCheckIcon className="pr-2 text-green-500" />
|
|
|
|
|
</TooltipTrigger>
|
|
|
|
|
<TooltipContent>
|
|
|
|
|
<p>File uploaded</p>
|
|
|
|
|
</TooltipContent>
|
|
|
|
|
</Tooltip>
|
2025-10-12 23:48:21 +00:00
|
|
|
)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-10-12 00:43:31 +00:00
|
|
|
return (
|
|
|
|
|
<li
|
2025-10-13 22:55:25 +00:00
|
|
|
className="pl-3 pr-1 py-0.5 h-8 hover:bg-muted flex justify-between items-center border-b border-border"
|
2025-10-12 00:43:31 +00:00
|
|
|
key={id}
|
|
|
|
|
>
|
2025-10-13 22:55:25 +00:00
|
|
|
<p>{file.name} </p>
|
2025-10-12 23:48:21 +00:00
|
|
|
{statusIndicator}
|
2025-10-12 00:43:31 +00:00
|
|
|
</li>
|
|
|
|
|
)
|
|
|
|
|
}
|