From bc9120f751cee1523be2e3e4c298ca958c9195a9 Mon Sep 17 00:00:00 2001 From: MaheshtheDev <38828053+MaheshtheDev@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:14:17 +0000 Subject: [PATCH] feat: multi-file upload queue for Add Document (#789) Adds multi-select and multi-file drag-and-drop on the Add Document Upload files tab, a per-file queue with status, retry for failed rows, and batched uploads to the existing `POST /v3/documents/file` endpoint with a client-side concurrency limit of 3. Optional title/description apply only when a single file is queued. .md / .mdx (and text/markdown) are accepted. Upload progress is shown as a bottom strip with a slow width animation --- apps/web/components/add-document/file.tsx | 360 ++++++++++++++++----- apps/web/components/add-document/index.tsx | 93 ++++-- apps/web/globals.css | 11 + apps/web/hooks/use-document-mutations.ts | 166 +++++++--- 4 files changed, 476 insertions(+), 154 deletions(-) diff --git a/apps/web/components/add-document/file.tsx b/apps/web/components/add-document/file.tsx index bb605f03..67e70d01 100644 --- a/apps/web/components/add-document/file.tsx +++ b/apps/web/components/add-document/file.tsx @@ -1,84 +1,161 @@ "use client" -import { useState, useEffect } from "react" +import { useEffect, useCallback, useRef, useState } from "react" import { cn } from "@lib/utils" import { dmSansClassName } from "@/lib/fonts" -import { FileIcon } from "lucide-react" +import { FileIcon, XIcon, AlertCircleIcon, CheckIcon } from "lucide-react" import { useHotkeys } from "react-hotkeys-hook" +import { toast } from "sonner" + +export const FILE_ACCEPT = + "image/*,.pdf,.doc,.docx,.xls,.xlsx,.csv,.txt,.md,.mdx,text/markdown" + +export type FileQueueItemStatus = "pending" | "uploading" | "success" | "error" + +export interface FileQueueItem { + id: string + file: File + status: FileQueueItemStatus + errorMessage?: string +} export interface FileData { - file: File | null + items: FileQueueItem[] title: string description: string } interface FileContentProps { - onSubmit?: (data: { file: File; title: string; description: string }) => void - onDataChange?: (data: FileData) => void + data: FileData + onDataChange: (data: FileData) => void + onRequestSubmit: () => void isSubmitting?: boolean isOpen?: boolean } +function isAcceptedFile(file: File): boolean { + const name = file.name.toLowerCase() + const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : "" + const allowedExt = new Set([ + ".pdf", + ".doc", + ".docx", + ".xls", + ".xlsx", + ".csv", + ".txt", + ".md", + ".mdx", + ]) + if (allowedExt.has(ext)) return true + if (file.type.startsWith("image/")) return true + if (file.type === "text/markdown") return true + return false +} + +function fileQueueKey(file: File): string { + return `${file.name}:${file.size}:${file.lastModified}` +} + export function FileContent({ - onSubmit, + data, onDataChange, + onRequestSubmit, isSubmitting, isOpen, }: FileContentProps) { + const inputRef = useRef(null) const [isDragging, setIsDragging] = useState(false) - const [selectedFile, setSelectedFile] = useState(null) - const [title, setTitle] = useState("") - const [description, setDescription] = useState("") - const canSubmit = selectedFile !== null && !isSubmitting + const anyUploading = data.items.some((i) => i.status === "uploading") + const canSubmit = + data.items.some((i) => i.status === "pending") && + !isSubmitting && + !anyUploading - const handleSubmit = () => { - if (canSubmit && onSubmit && selectedFile) { - onSubmit({ file: selectedFile, title, description }) - } - } + const updateData = useCallback( + (partial: Partial) => { + onDataChange({ ...data, ...partial }) + }, + [data, onDataChange], + ) - const updateData = ( - newFile: File | null, - newTitle: string, - newDescription: string, - ) => { - onDataChange?.({ - file: newFile, - title: newTitle, - description: newDescription, - }) - } + const addFiles = useCallback( + (fileList: FileList | File[]) => { + const incoming = Array.from(fileList) + const accepted = incoming.filter(isAcceptedFile) + const rejected = incoming.length - accepted.length + if (rejected > 0) { + toast.error( + rejected === 1 + ? "One file type is not supported" + : `${rejected} files are not supported`, + ) + } + if (accepted.length === 0) return - const handleFileChange = (file: File | null) => { - setSelectedFile(file) - updateData(file, title, description) - } + const existingKeys = new Set(data.items.map((i) => fileQueueKey(i.file))) + let duplicateCount = 0 + const toAdd: FileQueueItem[] = [] + for (const file of accepted) { + const key = fileQueueKey(file) + if (existingKeys.has(key)) { + duplicateCount++ + continue + } + existingKeys.add(key) + toAdd.push({ + id: crypto.randomUUID(), + file, + status: "pending", + }) + } + if (duplicateCount > 0) { + toast.message( + duplicateCount === 1 + ? "Skipped duplicate file" + : `Skipped ${duplicateCount} duplicate files`, + ) + } + if (toAdd.length === 0) return + onDataChange({ + ...data, + items: [...data.items, ...toAdd], + }) + }, + [data, onDataChange], + ) - const handleTitleChange = (newTitle: string) => { - setTitle(newTitle) - updateData(selectedFile, newTitle, description) - } + const removeItem = useCallback( + (id: string) => { + onDataChange({ + ...data, + items: data.items.filter((i) => i.id !== id), + }) + }, + [data, onDataChange], + ) - const handleDescriptionChange = (newDescription: string) => { - setDescription(newDescription) - updateData(selectedFile, title, newDescription) - } + const handleTitleChange = useCallback( + (title: string) => updateData({ title }), + [updateData], + ) - useHotkeys("mod+enter", handleSubmit, { - enabled: isOpen && canSubmit, + const handleDescriptionChange = useCallback( + (description: string) => updateData({ description }), + [updateData], + ) + + useHotkeys("mod+enter", onRequestSubmit, { + enabled: Boolean(isOpen && canSubmit), enableOnFormTags: ["INPUT", "TEXTAREA"], }) - // Reset content when modal closes useEffect(() => { - if (!isOpen) { - setSelectedFile(null) - setTitle("") - setDescription("") - onDataChange?.({ file: null, title: "", description: "" }) + if (!isOpen && inputRef.current) { + inputRef.current.value = "" } - }, [isOpen, onDataChange]) + }, [isOpen]) const handleDragOver = (e: React.DragEvent) => { e.preventDefault() @@ -93,24 +170,27 @@ export function FileContent({ const handleDrop = (e: React.DragEvent) => { e.preventDefault() setIsDragging(false) - const file = e.dataTransfer.files[0] - if (file) { - handleFileChange(file) + if (e.dataTransfer.files?.length) { + addFiles(e.dataTransfer.files) } } const handleFileSelect = (e: React.ChangeEvent) => { - const file = e.target.files?.[0] - if (file) { - handleFileChange(file) + const list = e.target.files + if (list?.length) { + addFiles(list) } + e.target.value = "" } + const showTitleDescription = data.items.length <= 1 + const hasItems = data.items.length > 0 + return (

- Upload a file (image, pdf, document, sheet) + Upload files (images, PDF, documents, sheets, markdown)