mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-09-05 08:06:19 +00:00
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:
parent
8860cb1622
commit
ae1c429e36
3 changed files with 298 additions and 36 deletions
|
|
@ -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}`
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue