supermemory/apps/web/components/add-document/file.tsx

367 lines
10 KiB
TypeScript

"use client"
import { useEffect, useCallback, useRef, useState } from "react"
import { cn } from "@lib/utils"
import { dmSansClassName } from "@/lib/fonts"
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 {
items: FileQueueItem[]
title: string
description: string
}
interface FileContentProps {
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({
data,
onDataChange,
onRequestSubmit,
isSubmitting,
isOpen,
}: FileContentProps) {
const inputRef = useRef<HTMLInputElement>(null)
const [isDragging, setIsDragging] = useState(false)
const anyUploading = data.items.some((i) => i.status === "uploading")
const canSubmit =
data.items.some((i) => i.status === "pending") &&
!isSubmitting &&
!anyUploading
const updateData = useCallback(
(partial: Partial<FileData>) => {
onDataChange({ ...data, ...partial })
},
[data, onDataChange],
)
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 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 removeItem = useCallback(
(id: string) => {
onDataChange({
...data,
items: data.items.filter((i) => i.id !== id),
})
},
[data, onDataChange],
)
const handleTitleChange = useCallback(
(title: string) => updateData({ title }),
[updateData],
)
const handleDescriptionChange = useCallback(
(description: string) => updateData({ description }),
[updateData],
)
useHotkeys("mod+enter", onRequestSubmit, {
enabled: Boolean(isOpen && canSubmit),
enableOnFormTags: ["INPUT", "TEXTAREA"],
})
useEffect(() => {
if (!isOpen && inputRef.current) {
inputRef.current.value = ""
}
}, [isOpen])
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
if (e.dataTransfer.files?.length) {
addFiles(e.dataTransfer.files)
}
}
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<div className={cn("h-full flex flex-col gap-6 pt-4", dmSansClassName())}>
<div className="flex flex-col gap-2">
<p className="text-[16px] font-medium pl-2">
Upload files (images, PDF, documents, sheets, markdown)
</p>
<label
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={cn(
"relative flex flex-col items-center justify-center gap-3 p-8 rounded-[14px] bg-[#14161A] shadow-inside-out border-2 border-dashed cursor-pointer transition-all",
isDragging
? "border-[#4BA0FA] bg-[#4BA0FA]/10"
: "border-[#737373]/30 hover:border-[#737373]/50",
(isSubmitting || anyUploading) && "opacity-50 pointer-events-none",
)}
>
<input
ref={inputRef}
type="file"
multiple
onChange={handleFileSelect}
disabled={isSubmitting}
className="absolute inset-0 size-full opacity-0 cursor-pointer disabled:cursor-not-allowed"
accept={FILE_ACCEPT}
/>
<div className="flex items-center justify-center size-12 rounded-full bg-[#0F1217]">
<FileIcon className="size-6 text-[#737373]" />
</div>
{hasItems ? (
<p className="text-center text-[#737373] text-sm pointer-events-none">
Add more files or use the list below
</p>
) : (
<div className="text-center pointer-events-none">
<p className="text-white">
<span className="text-[#4BA0FA]">Click to upload</span> or drag
and drop
</p>
<p className="text-[#737373] text-sm mt-1">
Multiple files allowed
</p>
</div>
)}
</label>
</div>
{hasItems ? (
<ul
id="file-upload-queue"
className="flex flex-col gap-2 max-h-[220px] overflow-y-auto scrollbar-thin pr-1"
>
{data.items.map((item) => (
<li
key={item.id}
className="relative overflow-hidden rounded-[12px] bg-[#14161A] shadow-inside-out text-sm"
>
{item.status === "uploading" ? (
<div
className={cn(
"absolute bottom-0 left-0 right-0 h-0.5 overflow-hidden pointer-events-none",
"bg-[rgb(46_53_61/0.65)]",
)}
aria-hidden
>
<div
className={cn(
"h-full w-0 bg-linear-to-r from-[#3580c4] via-[#4ba0fa] to-[#6ec5fc]",
"animate-file-upload-grow motion-reduce:animate-none",
"motion-reduce:w-[88%] motion-reduce:opacity-85",
)}
/>
</div>
) : null}
{item.status === "success" ? (
<div
className="absolute bottom-0 left-0 right-0 h-0.5 pointer-events-none bg-emerald-500/80"
aria-hidden
/>
) : null}
<div className="flex items-start gap-3 p-3 pb-3.5">
<div className="min-w-0 flex-1">
{item.status === "uploading" ? (
<span className="sr-only">Uploading {item.file.name}</span>
) : null}
<p className="text-white font-medium truncate">
{item.file.name}
</p>
<p className="text-[#737373] text-xs">
{(item.file.size / 1024 / 1024).toFixed(2)} MB
</p>
{item.status === "error" && item.errorMessage ? (
<p className="text-red-400 text-xs mt-1 flex items-center gap-1">
<AlertCircleIcon className="size-3.5 shrink-0" />
{item.errorMessage}
</p>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0">
{item.status === "pending" ? (
<button
type="button"
onClick={() => removeItem(item.id)}
disabled={isSubmitting}
className="p-1.5 rounded-lg text-[#737373] hover:text-white hover:bg-[#2E353D] disabled:opacity-50"
aria-label={`Remove ${item.file.name}`}
>
<XIcon className="size-4" />
</button>
) : null}
{item.status === "success" ? (
<CheckIcon className="size-4 text-green-500" aria-hidden />
) : null}
{item.status === "error" ? (
<button
type="button"
onClick={() => {
onDataChange({
...data,
items: data.items.map((i) =>
i.id === item.id
? {
...i,
status: "pending" as const,
errorMessage: undefined,
}
: i,
),
})
}}
disabled={isSubmitting}
className="text-xs text-[#4BA0FA] hover:underline disabled:opacity-50"
>
Retry
</button>
) : null}
</div>
</div>
</li>
))}
</ul>
) : null}
{showTitleDescription ? (
<>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">Title (optional)</p>
<input
type="text"
value={data.title}
onChange={(e) => handleTitleChange(e.target.value)}
placeholder="Give this file a title"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-[14px] font-semibold pl-2">
Description (optional)
</p>
<textarea
value={data.description}
onChange={(e) => handleDescriptionChange(e.target.value)}
placeholder="Add notes or context about this file"
disabled={isSubmitting}
className="w-full p-4 rounded-[14px] bg-[#14161A] shadow-inside-out disabled:opacity-50"
/>
</div>
</>
) : (
<p className="text-[#737373] text-sm pl-2">
Title and description apply only when uploading a single file. With
multiple files, each document uses its file name.
</p>
)}
</div>
)
}