"use client"; import { useAtom } from "jotai"; import { ChevronDown, Crown, Dot, File as FileIcon, FolderOpen, Upload, X, Zap, } from "lucide-react"; import { useTranslations } from "next-intl"; import { type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useDropzone } from "react-dropzone"; import { toast } from "sonner"; import { uploadDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms"; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Progress } from "@/components/ui/progress"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; import type { ProcessingMode } from "@/contracts/types/document.types"; import { useElectronAPI } from "@/hooks/use-platform"; import { documentsApiService } from "@/lib/apis/documents-api.service"; import { trackDocumentUploadFailure, trackDocumentUploadStarted, trackDocumentUploadSuccess, } from "@/lib/posthog/events"; import { getAcceptedFileTypes, getSupportedExtensions, getSupportedExtensionsSet, } from "@/lib/supported-extensions"; interface DocumentUploadTabProps { searchSpaceId: string; onSuccess?: () => void; onAccordionStateChange?: (isExpanded: boolean) => void; } interface FileWithId { id: string; file: File; } interface FolderEntry { id: string; file: File; relativePath: string; } interface FolderUploadData { folderName: string; entries: FolderEntry[]; } interface FolderTreeNode { name: string; isFolder: boolean; size?: number; children: FolderTreeNode[]; } function buildFolderTree(entries: FolderEntry[]): FolderTreeNode[] { const root: FolderTreeNode = { name: "", isFolder: true, children: [] }; for (const entry of entries) { const parts = entry.relativePath.split("/"); let current = root; for (let i = 0; i < parts.length - 1; i++) { let child = current.children.find((c) => c.name === parts[i] && c.isFolder); if (!child) { child = { name: parts[i], isFolder: true, children: [] }; current.children.push(child); } current = child; } current.children.push({ name: parts[parts.length - 1], isFolder: false, size: entry.file.size, children: [], }); } function sortNodes(node: FolderTreeNode) { node.children.sort((a, b) => { if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; return a.name.localeCompare(b.name); }); for (const child of node.children) sortNodes(child); } sortNodes(root); return root.children; } function flattenTree( nodes: FolderTreeNode[], depth = 0 ): { name: string; isFolder: boolean; depth: number; size?: number }[] { const items: { name: string; isFolder: boolean; depth: number; size?: number }[] = []; for (const node of nodes) { items.push({ name: node.name, isFolder: node.isFolder, depth, size: node.size }); if (node.isFolder && node.children.length > 0) { items.push(...flattenTree(node.children, depth + 1)); } } return items; } const FOLDER_BATCH_SIZE_BYTES = 20 * 1024 * 1024; const FOLDER_BATCH_MAX_FILES = 10; const MAX_FILE_SIZE_MB = 500; const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024; const toggleRowClass = "flex items-center justify-between rounded-lg bg-slate-400/5 dark:bg-white/5 p-3"; export function DocumentUploadTab({ searchSpaceId, onSuccess, onAccordionStateChange, }: DocumentUploadTabProps) { const t = useTranslations("upload_documents"); const [files, setFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState(0); const [accordionValue, setAccordionValue] = useState(""); const [shouldSummarize, setShouldSummarize] = useState(false); const [useVisionLlm, setUseVisionLlm] = useState(false); const [processingMode, setProcessingMode] = useState("basic"); const [uploadDocumentMutation] = useAtom(uploadDocumentMutationAtom); const { mutate: uploadDocuments, isPending: isUploading } = uploadDocumentMutation; const fileInputRef = useRef(null); const folderInputRef = useRef(null); const progressIntervalRef = useRef | null>(null); const [folderUpload, setFolderUpload] = useState(null); const [isFolderUploading, setIsFolderUploading] = useState(false); useEffect(() => { return () => { if (progressIntervalRef.current) { clearInterval(progressIntervalRef.current); } }; }, []); const electronAPI = useElectronAPI(); const isElectron = !!electronAPI?.browseFiles; const acceptedFileTypes = useMemo(() => getAcceptedFileTypes(), []); const supportedExtensions = useMemo( () => getSupportedExtensions(acceptedFileTypes), [acceptedFileTypes] ); const supportedExtensionsSet = useMemo( () => getSupportedExtensionsSet(acceptedFileTypes), [acceptedFileTypes] ); const addFiles = useCallback( (incoming: File[]) => { const oversized = incoming.filter((f) => f.size > MAX_FILE_SIZE_BYTES); if (oversized.length > 0) { toast.error(t("file_too_large"), { description: t("file_too_large_desc", { name: oversized[0].name, maxMB: MAX_FILE_SIZE_MB, }), }); } const valid = incoming.filter((f) => f.size <= MAX_FILE_SIZE_BYTES); if (valid.length === 0) return; setFolderUpload(null); setFiles((prev) => { const newEntries = valid.map((f) => ({ id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, file: f, })); return [...prev, ...newEntries]; }); }, [t] ); const onDrop = useCallback( (acceptedFiles: File[]) => { addFiles(acceptedFiles); }, [addFiles] ); const { getRootProps, getInputProps, isDragActive } = useDropzone({ onDrop, accept: acceptedFileTypes, maxSize: MAX_FILE_SIZE_BYTES, noClick: isElectron, }); const handleFileInputClick = useCallback((e: React.MouseEvent) => { e.stopPropagation(); }, []); const handleBrowseFiles = useCallback(async () => { if (!electronAPI?.browseFiles) return; const paths = await electronAPI.browseFiles(); if (!paths || paths.length === 0) return; const fileDataList = await electronAPI.readLocalFiles(paths); const filtered = fileDataList.filter( (fd: { name: string; data: ArrayBuffer; mimeType: string }) => { const ext = fd.name.includes(".") ? `.${fd.name.split(".").pop()?.toLowerCase()}` : ""; return ext !== "" && supportedExtensionsSet.has(ext); } ); if (filtered.length === 0) { toast.error(t("no_supported_files_in_folder")); return; } const newFiles: FileWithId[] = filtered.map( (fd: { name: string; data: ArrayBuffer; mimeType: string }) => ({ id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, file: new File([fd.data], fd.name, { type: fd.mimeType }), }) ); setFolderUpload(null); setFiles((prev) => [...prev, ...newFiles]); }, [electronAPI, supportedExtensionsSet, t]); const handleFolderChange = useCallback( (e: ChangeEvent) => { const fileList = e.target.files; if (!fileList || fileList.length === 0) return; const allFiles = Array.from(fileList); const firstPath = allFiles[0]?.webkitRelativePath || ""; const folderName = firstPath.split("/")[0]; if (!folderName) { addFiles(allFiles); e.target.value = ""; return; } const entries: FolderEntry[] = allFiles .filter((f) => { const ext = f.name.includes(".") ? `.${f.name.split(".").pop()?.toLowerCase()}` : ""; return ext !== "" && supportedExtensionsSet.has(ext); }) .map((f) => ({ id: crypto.randomUUID?.() ?? `file-${Date.now()}-${Math.random().toString(36)}`, file: f, relativePath: f.webkitRelativePath.substring(folderName.length + 1), })); if (entries.length === 0) { toast.error(t("no_supported_files_in_folder")); e.target.value = ""; return; } setFiles([]); setFolderUpload({ folderName, entries }); e.target.value = ""; }, [addFiles, supportedExtensionsSet, t] ); const formatFileSize = (bytes: number) => { if (bytes === 0) return "0 Bytes"; const k = 1024; const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const i = Math.floor(Math.log(bytes) / Math.log(k)); return `${parseFloat((bytes / k ** i).toFixed(2))} ${sizes[i]}`; }; const totalFileSize = folderUpload ? folderUpload.entries.reduce((total, entry) => total + entry.file.size, 0) : files.reduce((total, entry) => total + entry.file.size, 0); const fileCount = folderUpload ? folderUpload.entries.length : files.length; const hasContent = files.length > 0 || folderUpload !== null; const isAnyUploading = isUploading || isFolderUploading; const folderTreeItems = useMemo(() => { if (!folderUpload) return []; return flattenTree(buildFolderTree(folderUpload.entries)); }, [folderUpload]); const handleAccordionChange = useCallback( (value: string) => { setAccordionValue(value); onAccordionStateChange?.(value === "supported-file-types"); }, [onAccordionStateChange] ); const handleFolderUpload = async () => { if (!folderUpload) return; setUploadProgress(0); setIsFolderUploading(true); const total = folderUpload.entries.length; trackDocumentUploadStarted(Number(searchSpaceId), total, totalFileSize); try { const batches: FolderEntry[][] = []; let currentBatch: FolderEntry[] = []; let currentSize = 0; for (const entry of folderUpload.entries) { const size = entry.file.size; if (size >= FOLDER_BATCH_SIZE_BYTES) { if (currentBatch.length > 0) { batches.push(currentBatch); currentBatch = []; currentSize = 0; } batches.push([entry]); continue; } if ( currentBatch.length >= FOLDER_BATCH_MAX_FILES || currentSize + size > FOLDER_BATCH_SIZE_BYTES ) { batches.push(currentBatch); currentBatch = []; currentSize = 0; } currentBatch.push(entry); currentSize += size; } if (currentBatch.length > 0) { batches.push(currentBatch); } let rootFolderId: number | null = null; let uploaded = 0; for (const batch of batches) { const result = await documentsApiService.folderUploadFiles( batch.map((e) => e.file), { folder_name: folderUpload.folderName, search_space_id: Number(searchSpaceId), relative_paths: batch.map((e) => e.relativePath), root_folder_id: rootFolderId, enable_summary: shouldSummarize, use_vision_llm: useVisionLlm, processing_mode: processingMode, } ); if (result.root_folder_id && !rootFolderId) { rootFolderId = result.root_folder_id; } uploaded += batch.length; setUploadProgress(Math.round((uploaded / total) * 100)); } trackDocumentUploadSuccess(Number(searchSpaceId), total); toast(t("upload_initiated"), { description: t("upload_initiated_desc") }); setFolderUpload(null); onSuccess?.(); } catch (error) { const message = error instanceof Error ? error.message : "Upload failed"; trackDocumentUploadFailure(Number(searchSpaceId), message); toast(t("upload_error"), { description: `${t("upload_error_desc")}: ${message}`, }); } finally { setIsFolderUploading(false); setUploadProgress(0); } }; const handleUpload = async () => { if (folderUpload) { await handleFolderUpload(); return; } setUploadProgress(0); trackDocumentUploadStarted(Number(searchSpaceId), files.length, totalFileSize); progressIntervalRef.current = setInterval(() => { setUploadProgress((prev) => (prev >= 90 ? prev : prev + Math.random() * 10)); }, 200); const rawFiles = files.map((entry) => entry.file); uploadDocuments( { files: rawFiles, search_space_id: Number(searchSpaceId), should_summarize: shouldSummarize, use_vision_llm: useVisionLlm, processing_mode: processingMode, }, { onSuccess: () => { if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); setUploadProgress(100); trackDocumentUploadSuccess(Number(searchSpaceId), files.length); toast(t("upload_initiated"), { description: t("upload_initiated_desc") }); onSuccess?.(); }, onError: (error: unknown) => { if (progressIntervalRef.current) clearInterval(progressIntervalRef.current); setUploadProgress(0); const message = error instanceof Error ? error.message : "Upload failed"; trackDocumentUploadFailure(Number(searchSpaceId), message); toast(t("upload_error"), { description: `${t("upload_error_desc")}: ${message}`, }); }, } ); }; const renderBrowseButton = (options?: { compact?: boolean; fullWidth?: boolean }) => { const { compact, fullWidth } = options ?? {}; const sizeClass = compact ? "h-7" : "h-8"; const widthClass = fullWidth ? "w-full" : ""; if (isElectron) { return ( e.stopPropagation()}> e.stopPropagation()} > Files folderInputRef.current?.click()}> Folder ); } return ( e.stopPropagation()}> e.stopPropagation()} > fileInputRef.current?.click()}> {t("browse_files")} folderInputRef.current?.click()}> {t("browse_folder")} ); }; return (
{/* Hidden file input */} {/* Hidden folder input for web folder browsing */} )} /> {/* MOBILE DROP ZONE */}
{hasContent ? ( isElectron ? (
{renderBrowseButton({ compact: true, fullWidth: true })}
) : ( ) ) : ( )}
{/* DESKTOP DROP ZONE */}
{hasContent ? (
{isDragActive ? t("drop_files") : t("drag_drop_more")} {renderBrowseButton({ compact: true })}
) : (
{isDragActive && (

{t("drop_files")}

)}

{t("drag_drop")}

{t("file_size_limit")}

{renderBrowseButton()}
)}
{/* FILES SELECTED */} {hasContent && (

{folderUpload ? ( <> {folderUpload.folderName} {folderUpload.entries.length}{" "} {folderUpload.entries.length === 1 ? "file" : "files"} {formatFileSize(totalFileSize)} ) : ( <> {t("selected_files", { count: files.length })} {formatFileSize(totalFileSize)} )}

{folderUpload ? folderTreeItems.map((item, i) => (
{item.isFolder ? ( ) : ( )} {item.name} {!item.isFolder && item.size != null && ( {formatFileSize(item.size)} )}
)) : files.map((entry) => (
{entry.file.name.split(".").pop() || "?"} {entry.file.name} {formatFileSize(entry.file.size)}
))}
{isAnyUploading && (
{folderUpload ? t("uploading_folder") : t("uploading_files")} {Math.round(uploadProgress)}%
)}

Enable AI Summary

Improves search quality but adds latency

Enable Vision LLM

Describes images using AI vision (costly, slower)

{t("processing_mode")}

)} {/* SUPPORTED FORMATS */} {t("supported_file_types")}
{supportedExtensions.map((ext) => ( {ext} ))}
); }