feat: add file drag-and-drop support to note tab

- Note tab now accepts file drops with visual drag overlay
- Dropped files shown as compact chips below editor with status indicators
- Deduplication, rejected file type toasts, and remove button for pending files
- 'Save all' mode when both text and files present (coordinated submission)
- skipMutationCloseRef prevents premature modal close during combined saves
- Cmd+Enter now uses same unified submit logic as CTA button
This commit is contained in:
MaheshtheDev 2026-05-12 00:31:22 +00:00
parent 8860cb1622
commit ae1c429e36
3 changed files with 298 additions and 36 deletions

View file

@ -33,7 +33,7 @@ interface FileContentProps {
isOpen?: boolean
}
function isAcceptedFile(file: File): boolean {
export function isAcceptedFile(file: File): boolean {
const name = file.name.toLowerCase()
const ext = name.includes(".") ? name.slice(name.lastIndexOf(".")) : ""
const allowedExt = new Set([
@ -53,7 +53,7 @@ function isAcceptedFile(file: File): boolean {
return false
}
function fileQueueKey(file: File): string {
export function fileQueueKey(file: File): string {
return `${file.name}:${file.size}:${file.lastModified}`
}

View file

@ -10,7 +10,7 @@ import { Button } from "@ui/components/button"
import { ConnectContent } from "./connections"
import { NoteContent } from "./note"
import { LinkContent, type LinkData } from "./link"
import { FileContent, type FileData } from "./file"
import { FileContent, type FileData, type FileQueueItem, fileQueueKey } from "./file"
import { useProject } from "@/stores"
import { toast } from "sonner"
import { useDocumentMutations } from "../../hooks/use-document-mutations"
@ -121,8 +121,16 @@ export function AddDocument({
const fileDataRef = useRef(fileData)
fileDataRef.current = fileData
const [noteDroppedFiles, setNoteDroppedFiles] = useState<FileQueueItem[]>([])
const skipMutationCloseRef = useRef(false)
const { noteMutation, linkMutation, fileMutation } = useDocumentMutations({
onClose,
onClose: () => {
if (!skipMutationCloseRef.current) {
onClose()
}
},
})
useEffect(() => {
@ -133,6 +141,7 @@ export function AddDocument({
if (!isOpen) {
setFileData({ items: [], title: "", description: "" })
setNoteContentType("note")
setNoteDroppedFiles([])
}
}, [isOpen])
@ -222,17 +231,89 @@ export function AddDocument({
[fileMutation, localSelectedProject],
)
const handleNoteFilesDropped = useCallback((files: File[]) => {
setNoteDroppedFiles((prev) => {
const existingKeys = new Set(prev.map((i) => fileQueueKey(i.file)))
let duplicateCount = 0
const toAdd: FileQueueItem[] = []
for (const file of files) {
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 prev
return [...prev, ...toAdd]
})
}, [])
const handleRemoveNoteFile = useCallback((id: string) => {
setNoteDroppedFiles((prev) => prev.filter((f) => f.id !== id))
}, [])
const handleNoteFileSubmit = useCallback(async () => {
const pending = noteDroppedFiles.filter((i) => i.status === "pending")
if (pending.length === 0) return
setNoteDroppedFiles((prev) =>
prev.map((i) =>
i.status === "pending"
? { ...i, status: "uploading" as const }
: i,
),
)
try {
const result = await fileMutation.mutateAsync({
fileEntries: pending.map((i) => ({ id: i.id, file: i.file })),
project: localSelectedProject,
})
setNoteDroppedFiles((prev) =>
prev.map((i) => {
if (i.status !== "uploading") return i
const fail = result.failures.find((f) => f.id === i.id)
if (fail) {
return {
...i,
status: "error" as const,
errorMessage: fail.message,
}
}
return { ...i, status: "success" as const }
}),
)
return result
} catch {
setNoteDroppedFiles((prev) =>
prev.map((i) =>
i.status === "uploading"
? {
...i,
status: "error" as const,
errorMessage: "Upload failed",
}
: i,
),
)
throw new Error("File upload failed")
}
}, [noteDroppedFiles, fileMutation, localSelectedProject])
// Data change handlers
const handleNoteContentChange = useCallback((content: string) => {
setNoteContent(content)
}, [])
const handleNoteRequestSubmit = useCallback(() => {
// This will be called by Cmd+Enter from the editor
// For now it just does the note submit; Task 3 will expand this for files
handleNoteSubmit(noteContent, noteContentType)
}, [handleNoteSubmit, noteContent, noteContentType])
const handleLinkDataChange = useCallback((data: LinkData) => {
setLinkData(data)
}, [])
@ -241,11 +322,47 @@ export function AddDocument({
setFileData(data)
}, [])
const handleButtonClick = () => {
const handleButtonClick = useCallback(async () => {
switch (activeTab) {
case "note":
handleNoteSubmit(noteContent, noteContentType)
case "note": {
const hasPendingFiles = noteDroppedFiles.some(
(i) => i.status === "pending",
)
const hasText = noteContent.trim().length > 0
if (hasText && hasPendingFiles) {
// Save all: text + files
skipMutationCloseRef.current = true
try {
const textPromise =
noteContentType === "link"
? linkMutation.mutateAsync({
url: normalizeUrl(noteContent.trim()),
project: localSelectedProject,
})
: noteMutation.mutateAsync({
content: noteContent,
project: localSelectedProject,
})
const filePromise = handleNoteFileSubmit()
await Promise.all([textPromise, filePromise])
onClose()
} catch {
// At least one failed — modal stays open
} finally {
skipMutationCloseRef.current = false
}
} else if (hasPendingFiles) {
void handleNoteFileSubmit().catch(() => {
/* errors handled in handleNoteFileSubmit */
})
} else if (hasText) {
handleNoteSubmit(noteContent, noteContentType)
} else {
toast.error("Please enter some content or drop a file")
}
break
}
case "link":
handleLinkSubmit(linkData)
break
@ -253,7 +370,12 @@ export function AddDocument({
void handleFileSubmit(fileData)
break
}
}
}, [activeTab, noteDroppedFiles, noteContent, noteContentType, linkMutation, noteMutation, localSelectedProject, handleNoteFileSubmit, onClose, handleNoteSubmit, handleLinkSubmit, linkData, handleFileSubmit, fileData])
const handleNoteRequestSubmit = useCallback(() => {
// Called by Cmd+Enter from the editor — uses same logic as CTA button
void handleButtonClick()
}, [handleButtonClick])
const isSubmitting =
noteMutation.isPending || linkMutation.isPending || fileMutation.isPending
@ -331,8 +453,17 @@ export function AddDocument({
onContentChange={handleNoteContentChange}
onContentTypeChange={setNoteContentType}
onRequestSubmit={handleNoteRequestSubmit}
isSubmitting={noteMutation.isPending}
isSubmitting={noteMutation.isPending || linkMutation.isPending || fileMutation.isPending}
isOpen={isOpen}
onFilesDropped={handleNoteFilesDropped}
onRemoveFile={handleRemoveNoteFile}
droppedFiles={noteDroppedFiles.map((f) => ({
id: f.id,
name: f.file.name,
size: f.file.size,
status: f.status,
errorMessage: f.errorMessage,
}))}
/>
)}
{activeTab === "link" && (
@ -396,7 +527,7 @@ export function AddDocument({
{activeTab !== "connect" && (
<Button
variant="insideOut"
onClick={handleButtonClick}
onClick={() => void handleButtonClick()}
disabled={
activeTab === "file" ? fileTabSubmitDisabled : isSubmitting
}
@ -409,13 +540,30 @@ export function AddDocument({
</>
) : (
<>
{activeTab === "note"
? noteContentType === "link"
? "Save link"
: "Save note"
: activeTab === "link"
? "Save link"
: `+ Add ${activeTab}`}{" "}
{(() => {
if (activeTab === "note") {
const noteHasPendingFiles = noteDroppedFiles.some(
(i) => i.status === "pending",
)
const noteHasText = noteContent.trim().length > 0
if (noteHasText && noteHasPendingFiles) {
return "Save all"
}
if (noteHasPendingFiles) {
const pendingCount = noteDroppedFiles.filter(
(i) => i.status === "pending",
).length
return pendingCount === 1
? "Save file"
: `Save ${pendingCount} files`
}
return noteContentType === "link"
? "Save link"
: "Save note"
}
if (activeTab === "link") return "Save link"
return `+ Add ${activeTab}`
})()}{" "}
{!isMobile && (
<span
className={cn(

View file

@ -1,8 +1,17 @@
"use client"
import { useState, useEffect } from "react"
import { useState, useEffect, useRef } from "react"
import { TextEditor } from "../text-editor"
import { isValidUrl } from "@/lib/url-helpers"
import { isAcceptedFile } from "./file"
import { toast } from "sonner"
import {
FileIcon,
XIcon,
Loader2,
CheckIcon,
AlertCircleIcon,
} from "lucide-react"
function detectContentType(plainText: string): "note" | "link" {
const trimmed = plainText.trim()
@ -33,6 +42,15 @@ interface NoteContentProps {
onRequestSubmit?: () => void
isSubmitting?: boolean
isOpen?: boolean
onFilesDropped?: (files: File[]) => void
onRemoveFile?: (id: string) => void
droppedFiles?: {
id: string
name: string
size: number
status: "pending" | "uploading" | "success" | "error"
errorMessage?: string
}[]
}
export function NoteContent({
@ -42,9 +60,14 @@ export function NoteContent({
onRequestSubmit,
isSubmitting,
isOpen,
onFilesDropped,
onRemoveFile,
droppedFiles,
}: NoteContentProps) {
const [content, setContent] = useState("")
const [plainText, setPlainText] = useState("")
const [isDragging, setIsDragging] = useState(false)
const dragCounter = useRef(0)
const canSubmit = content.trim().length > 0 && !isSubmitting
@ -64,6 +87,45 @@ export function NoteContent({
onContentChange?.(newContent)
}
const handleDragEnter = (e: React.DragEvent) => {
e.preventDefault()
dragCounter.current++
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
dragCounter.current--
if (dragCounter.current === 0) setIsDragging(false)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
e.stopPropagation()
dragCounter.current = 0
setIsDragging(false)
if (e.dataTransfer.files?.length) {
const incoming = Array.from(e.dataTransfer.files)
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) {
onFilesDropped?.(accepted)
}
}
}
// Reset content when modal closes
useEffect(() => {
if (!isOpen) {
@ -74,18 +136,70 @@ export function NoteContent({
}, [isOpen, onContentChange])
return (
<div className="flex h-full min-h-[45dvh] w-full flex-1 overflow-y-auto rounded-[14px] bg-[#10151C] p-3 shadow-inside-out ring-1 ring-[#202A36] md:mb-4! md:bg-[#14161A] md:p-4 md:ring-0">
<TextEditor
content={undefined}
onContentChange={handleContentChange}
onPlainTextChange={(text) => {
setPlainText(text)
const type = detectContentType(text)
onContentTypeChange?.(type)
}}
onSubmit={onRequestSubmit ?? handleSubmit}
debounceMs={0}
/>
<div className="flex h-full flex-col">
<div
className="relative flex min-h-[45dvh] w-full flex-1 overflow-y-auto rounded-[14px] bg-[#10151C] p-3 shadow-inside-out ring-1 ring-[#202A36] md:mb-4! md:bg-[#14161A] md:p-4 md:ring-0"
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
>
<TextEditor
content={undefined}
onContentChange={handleContentChange}
onPlainTextChange={(text) => {
setPlainText(text)
const type = detectContentType(text)
onContentTypeChange?.(type)
}}
onSubmit={onRequestSubmit ?? handleSubmit}
debounceMs={0}
/>
{isDragging && (
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center rounded-[14px] border-2 border-dashed border-[#4BA0FA] bg-[#4BA0FA]/10">
<div className="flex items-center gap-2 text-sm text-[#4BA0FA]">
<FileIcon className="size-4" />
Drop files here
</div>
</div>
)}
</div>
{droppedFiles && droppedFiles.length > 0 && (
<div className="flex flex-wrap gap-2 px-1 pt-2">
{droppedFiles.map((file) => (
<div
key={file.id}
className="flex items-center gap-1.5 rounded-lg bg-[#14161A] px-2.5 py-1.5 text-xs text-[#D7DEE8]"
>
<FileIcon className="size-3 text-[#737373]" />
<span className="max-w-[150px] truncate">{file.name}</span>
<span className="text-[#737373]">
{(file.size / 1024 / 1024).toFixed(1)}MB
</span>
{file.status === "pending" && onRemoveFile && (
<button
type="button"
onClick={() => onRemoveFile(file.id)}
className="ml-0.5 text-[#737373] hover:text-white"
>
<XIcon className="size-3" />
</button>
)}
{file.status === "uploading" && (
<Loader2 className="size-3 animate-spin text-[#4BA0FA]" />
)}
{file.status === "success" && (
<CheckIcon className="size-3 text-green-500" />
)}
{file.status === "error" && (
<span className="text-red-400" title={file.errorMessage}>
<AlertCircleIcon className="size-3" />
</span>
)}
</div>
))}
</div>
)}
</div>
)
}