diff --git a/services/platform/app/components/ui/forms/file-upload.tsx b/services/platform/app/components/ui/forms/file-upload.tsx index 573816adc..e3454474b 100644 --- a/services/platform/app/components/ui/forms/file-upload.tsx +++ b/services/platform/app/components/ui/forms/file-upload.tsx @@ -9,29 +9,11 @@ import { type ReactNode, } from 'react'; import { ImagePlus } from 'lucide-react'; -import { toast } from '@/app/hooks/use-toast'; -import { useGenerateUploadUrl } from '@/app/features/chat/hooks/use-generate-upload-url'; -import { compressImage } from '@/lib/utils/compress-image'; import { cn } from '@/lib/utils/cn'; import { useT } from '@/lib/i18n/client'; -import type { Id } from '@/convex/_generated/dataModel'; - -interface FileAttachment { - fileId: Id<'_storage'>; - fileName: string; - fileType: string; - fileSize: number; - previewUrl?: string; -} interface FileUploadContextValue { - attachments: FileAttachment[]; - uploadingFiles: string[]; isDragOver: boolean; - isUploading: boolean; - uploadFiles: (files: FileList) => Promise; - removeAttachment: (fileId: Id<'_storage'>) => void; - clearAttachments: () => FileAttachment[]; setIsDragOver: (value: boolean) => void; } @@ -47,162 +29,19 @@ function useFileUploadContext() { return context; } -interface FileUploadConfig { - maxFileSize?: number; - allowedTypes?: string[]; -} - -const DEFAULT_CONFIG: Required = { - maxFileSize: 10 * 1024 * 1024, // 10MB - allowedTypes: [ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - 'application/pdf', - 'text/plain', - 'application/msword', - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'application/vnd.ms-powerpoint', - 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - ], -}; - interface RootProps { children: ReactNode; - config?: FileUploadConfig; } -function Root({ children, config }: RootProps) { - const { t } = useT('chat'); - const [attachments, setAttachments] = useState([]); - const [uploadingFiles, setUploadingFiles] = useState([]); +function Root({ children }: RootProps) { const [isDragOver, setIsDragOver] = useState(false); - const generateUploadUrl = useGenerateUploadUrl(); - - const mergedConfig = useMemo( - () => ({ ...DEFAULT_CONFIG, ...config }), - [config], - ); - - const uploadFiles = useCallback( - async (files: FileList) => { - const fileArray = Array.from(files); - - const invalidFiles = fileArray.filter( - (file) => - file.size > mergedConfig.maxFileSize || - !mergedConfig.allowedTypes.includes(file.type), - ); - - if (invalidFiles.length > 0) { - toast({ - title: t('invalidFiles'), - description: t('filesNotSupported'), - variant: 'destructive', - }); - return; - } - - const uploadPromises = fileArray.map(async (file) => { - const fileId = `${file.name}-${Date.now()}`; - setUploadingFiles((prev) => [...prev, fileId]); - - try { - let fileToUpload = file; - - if (file.type.startsWith('image/')) { - const compressionResult = await compressImage(file); - fileToUpload = compressionResult.file; - } - - const uploadUrl = await generateUploadUrl(); - - const result = await fetch(uploadUrl, { - method: 'POST', - headers: { 'Content-Type': fileToUpload.type }, - body: fileToUpload, - }); - - if (!result.ok) { - throw new Error(t('uploadFailed')); - } - - const { storageId } = await result.json(); - - const attachment: FileAttachment = { - fileId: storageId, - fileName: fileToUpload.name, - fileType: fileToUpload.type, - fileSize: fileToUpload.size, - previewUrl: fileToUpload.type.startsWith('image/') - ? URL.createObjectURL(fileToUpload) - : undefined, - }; - - setAttachments((prev) => [...prev, attachment]); - - toast({ - title: t('fileUploaded'), - description: t('uploadedSuccessfully', { filename: file.name }), - }); - } catch (error) { - console.error('Upload error:', error); - toast({ - title: t('uploadFailed'), - description: t('failedToUpload', { filename: file.name }), - variant: 'destructive', - }); - } finally { - setUploadingFiles((prev) => prev.filter((id) => id !== fileId)); - } - }); - - await Promise.all(uploadPromises); - }, - [generateUploadUrl, mergedConfig, t], - ); - - const removeAttachment = useCallback((fileId: Id<'_storage'>) => { - setAttachments((prev) => { - const attachment = prev.find((att) => att.fileId === fileId); - if (attachment?.previewUrl) { - URL.revokeObjectURL(attachment.previewUrl); - } - return prev.filter((att) => att.fileId !== fileId); - }); - }, []); - - const clearAttachments = useCallback(() => { - const current = attachments; - current.forEach((att) => { - if (att.previewUrl) { - URL.revokeObjectURL(att.previewUrl); - } - }); - setAttachments([]); - return current; - }, [attachments]); const value = useMemo( () => ({ - attachments, - uploadingFiles, isDragOver, - isUploading: uploadingFiles.length > 0, - uploadFiles, - removeAttachment, - clearAttachments, setIsDragOver, }), - [ - attachments, - uploadingFiles, - isDragOver, - uploadFiles, - removeAttachment, - clearAttachments, - ], + [isDragOver], ); return ( @@ -215,18 +54,35 @@ function Root({ children, config }: RootProps) { interface DropZoneProps { children: ReactNode; className?: string; + onFilesSelected: (files: File[]) => void; + accept?: string; + disabled?: boolean; + inputId?: string; + multiple?: boolean; + 'aria-label'?: string; } -function DropZone({ children, className }: DropZoneProps) { - const { setIsDragOver, uploadFiles } = useFileUploadContext(); +function DropZone({ + children, + className, + onFilesSelected, + accept, + disabled, + inputId = 'file-upload', + multiple, + 'aria-label': ariaLabel, +}: DropZoneProps) { + const { setIsDragOver } = useFileUploadContext(); const handleDragOver = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - setIsDragOver(true); + if (!disabled) { + setIsDragOver(true); + } }, - [setIsDragOver], + [setIsDragOver, disabled], ); const handleDragLeave = useCallback( @@ -244,22 +100,67 @@ function DropZone({ children, className }: DropZoneProps) { e.stopPropagation(); setIsDragOver(false); + if (disabled) return; + const files = e.dataTransfer.files; if (files && files.length > 0) { - uploadFiles(files); + onFilesSelected(Array.from(files)); + } + }, + [setIsDragOver, onFilesSelected, disabled], + ); + + const handleClick = useCallback(() => { + if (disabled) return; + const input = document.getElementById(inputId) as HTMLInputElement; + input?.click(); + }, [inputId, disabled]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (disabled) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleClick(); + } + }, + [handleClick, disabled], + ); + + const handleFileInputChange = useCallback( + (e: React.ChangeEvent) => { + const selectedFiles = e.target.files; + if (selectedFiles && selectedFiles.length > 0) { + onFilesSelected(Array.from(selectedFiles)); } + e.target.value = ''; }, - [setIsDragOver, uploadFiles], + [onFilesSelected], ); return (
{children} +
); } @@ -270,7 +171,7 @@ interface OverlayProps { } function Overlay({ className, label }: OverlayProps) { - const { t } = useT('chat'); + const { t } = useT('common'); const { isDragOver } = useFileUploadContext(); if (!isDragOver) return null; @@ -284,7 +185,7 @@ function Overlay({ className, label }: OverlayProps) { > - {label ?? t('dropFilesToAdd')} + {label ?? t('upload.dropFilesHere')} ); @@ -297,4 +198,4 @@ export const FileUpload = { useContext: useFileUploadContext, }; -export type { FileAttachment, FileUploadContextValue }; +export type { FileUploadContextValue }; diff --git a/services/platform/app/features/automations/components/automation-assistant.tsx b/services/platform/app/features/automations/components/automation-assistant.tsx index ba7133407..4e0ef9053 100644 --- a/services/platform/app/features/automations/components/automation-assistant.tsx +++ b/services/platform/app/features/automations/components/automation-assistant.tsx @@ -32,6 +32,7 @@ import { useUIMessages, type UIMessage } from '@convex-dev/agent/react'; import { Image } from '@/app/components/ui/data-display/image'; import { ImagePreviewDialog } from '@/app/features/chat/components/message-bubble'; import { FileUpload } from '@/app/components/ui/forms/file-upload'; +import { useConvexFileUpload } from '@/app/features/chat/hooks/use-convex-file-upload'; import { useT } from '@/lib/i18n/client'; // Module-level guard to prevent duplicate sends (survives component remounts) @@ -269,7 +270,7 @@ function AutomationAssistantContent({ uploadFiles, removeAttachment, clearAttachments, - } = FileUpload.useContext(); + } = useConvexFileUpload(); const { user } = useAuth(); const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); @@ -300,7 +301,6 @@ function AutomationAssistantContent({ automationId ? { wfDefinitionId: automationId } : 'skip', ); - const { results: uiMessages } = useUIMessages( api.threads.queries.getThreadMessagesStreaming as any, threadId ? { threadId } : 'skip', @@ -439,7 +439,7 @@ function AutomationAssistantContent({ const handleFileInputChange = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { - uploadFiles(files); + uploadFiles(Array.from(files)); } // Reset input to allow selecting the same file again e.target.value = ''; @@ -469,9 +469,7 @@ function AutomationAssistantContent({ } if (imageFiles.length > 0) { - const dataTransfer = new DataTransfer(); - imageFiles.forEach((file) => dataTransfer.items.add(file)); - uploadFiles(dataTransfer.files); + uploadFiles(imageFiles); } }; @@ -784,7 +782,10 @@ function AutomationAssistantContent({ /> {/* Chat input */} - +
{/* Attachment previews */} diff --git a/services/platform/app/features/chat/components/chat-input.tsx b/services/platform/app/features/chat/components/chat-input.tsx index 46486c537..2b2914f36 100644 --- a/services/platform/app/features/chat/components/chat-input.tsx +++ b/services/platform/app/features/chat/components/chat-input.tsx @@ -8,10 +8,11 @@ import { EnterKeyIcon } from '@/app/components/icons/enter-key-icon'; import { LoaderCircleIcon } from 'lucide-react'; import { useT } from '@/lib/i18n/client'; import { cn } from '@/lib/utils/cn'; +import { FileUpload } from '@/app/components/ui/forms/file-upload'; import { - FileUpload, + useConvexFileUpload, type FileAttachment, -} from '@/app/components/ui/forms/file-upload'; +} from '../hooks/use-convex-file-upload'; import { ImagePreviewDialog } from './message-bubble'; interface ChatInputProps extends Omit< @@ -49,7 +50,7 @@ export function ChatInput({ uploadFiles, removeAttachment, clearAttachments, - } = FileUpload.useContext(); + } = useConvexFileUpload(); const defaultPlaceholder = placeholder || tChat('typeMessageHere'); @@ -106,23 +107,24 @@ export function ChatInput({ } if (imageFiles.length > 0) { - const dataTransfer = new DataTransfer(); - imageFiles.forEach((file) => dataTransfer.items.add(file)); - uploadFiles(dataTransfer.files); + uploadFiles(imageFiles); } }; const handleFileInputChange = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { - uploadFiles(files); + uploadFiles(Array.from(files)); } e.target.value = ''; }; return (
- + ; + fileName: string; + fileType: string; + fileSize: number; + previewUrl?: string; +} + +interface ConvexFileUploadConfig { + maxFileSize?: number; + allowedTypes?: string[]; +} + +const DEFAULT_CONFIG: Required = { + maxFileSize: 10 * 1024 * 1024, // 10MB + allowedTypes: [ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'application/pdf', + 'text/plain', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + ], +}; + +export function useConvexFileUpload(config?: ConvexFileUploadConfig) { + const { t } = useT('chat'); + const [attachments, setAttachments] = useState([]); + const [uploadingFiles, setUploadingFiles] = useState([]); + const generateUploadUrl = useGenerateUploadUrl(); + + const mergedConfig = useMemo( + () => ({ ...DEFAULT_CONFIG, ...config }), + [config], + ); + + const uploadFiles = useCallback( + async (files: File[]) => { + const validFiles: File[] = []; + const invalidFiles: File[] = []; + + for (const file of files) { + if ( + file.size > mergedConfig.maxFileSize || + !mergedConfig.allowedTypes.includes(file.type) + ) { + invalidFiles.push(file); + } else { + validFiles.push(file); + } + } + + if (invalidFiles.length > 0) { + toast({ + title: t('invalidFiles'), + description: t('filesNotSupported'), + variant: 'destructive', + }); + } + + if (validFiles.length === 0) return; + + const uploadPromises = validFiles.map(async (file) => { + const fileId = `${file.name}-${Date.now()}`; + setUploadingFiles((prev) => [...prev, fileId]); + + try { + let fileToUpload = file; + + if (file.type.startsWith('image/')) { + const compressionResult = await compressImage(file); + fileToUpload = compressionResult.file; + } + + const uploadUrl = await generateUploadUrl(); + + const result = await fetch(uploadUrl, { + method: 'POST', + headers: { 'Content-Type': fileToUpload.type }, + body: fileToUpload, + }); + + if (!result.ok) { + throw new Error(t('uploadFailed')); + } + + const { storageId } = await result.json(); + + if (!storageId) { + throw new Error(t('uploadFailed')); + } + + const attachment: FileAttachment = { + fileId: storageId, + fileName: fileToUpload.name, + fileType: fileToUpload.type, + fileSize: fileToUpload.size, + previewUrl: fileToUpload.type.startsWith('image/') + ? URL.createObjectURL(fileToUpload) + : undefined, + }; + + setAttachments((prev) => [...prev, attachment]); + + toast({ + title: t('fileUploaded'), + description: t('uploadedSuccessfully', { filename: file.name }), + }); + } catch (error) { + console.error('Upload error:', error); + toast({ + title: t('uploadFailed'), + description: t('failedToUpload', { filename: file.name }), + variant: 'destructive', + }); + } finally { + setUploadingFiles((prev) => prev.filter((id) => id !== fileId)); + } + }); + + await Promise.all(uploadPromises); + }, + [generateUploadUrl, mergedConfig, t], + ); + + const removeAttachment = useCallback((fileId: Id<'_storage'>) => { + setAttachments((prev) => { + const attachment = prev.find((att) => att.fileId === fileId); + if (attachment?.previewUrl) { + URL.revokeObjectURL(attachment.previewUrl); + } + return prev.filter((att) => att.fileId !== fileId); + }); + }, []); + + const clearAttachments = useCallback(() => { + let clearedAttachments: FileAttachment[] = []; + setAttachments((prev) => { + clearedAttachments = prev; + for (const att of prev) { + if (att.previewUrl) { + URL.revokeObjectURL(att.previewUrl); + } + } + return []; + }); + return clearedAttachments; + }, []); + + const attachmentsRef = useRef(attachments); + attachmentsRef.current = attachments; + + useEffect(() => { + return () => { + for (const att of attachmentsRef.current) { + if (att.previewUrl) { + URL.revokeObjectURL(att.previewUrl); + } + } + }; + }, []); + + return { + attachments, + uploadingFiles, + isUploading: uploadingFiles.length > 0, + uploadFiles, + removeAttachment, + clearAttachments, + }; +} + +export type { FileAttachment, ConvexFileUploadConfig }; diff --git a/services/platform/app/features/chat/types.ts b/services/platform/app/features/chat/types.ts index 6900bf2bf..b2a3588c1 100644 --- a/services/platform/app/features/chat/types.ts +++ b/services/platform/app/features/chat/types.ts @@ -1 +1 @@ -export type { FileAttachment } from '@/app/components/ui/forms/file-upload'; +export type { FileAttachment } from './hooks/use-convex-file-upload'; diff --git a/services/platform/app/features/customers/components/customer-import-form.tsx b/services/platform/app/features/customers/components/customer-import-form.tsx index 23bbd81b3..f56f800f5 100644 --- a/services/platform/app/features/customers/components/customer-import-form.tsx +++ b/services/platform/app/features/customers/components/customer-import-form.tsx @@ -4,13 +4,11 @@ import { TabsList, TabsTrigger, } from '@/app/components/ui/navigation/tabs'; -import { Input } from '@/app/components/ui/forms/input'; import { useFormContext } from 'react-hook-form'; import { Form } from '@/app/components/ui/forms/form'; import { Description } from '@/app/components/ui/forms/description'; import { Stack, HStack, VStack } from '@/app/components/ui/layout/layout'; import { Upload, Trash2 } from 'lucide-react'; -import { useState } from 'react'; import { ShopifyIcon } from '@/app/components/icons/shopify-icon'; import { Link } from '@tanstack/react-router'; import { cn } from '@/lib/utils/cn'; @@ -18,6 +16,8 @@ import { DocumentIcon } from '@/app/components/ui/data-display/document-icon'; import { Button } from '@/app/components/ui/primitives/button'; import { Doc } from '@/convex/_generated/dataModel'; import { useT } from '@/lib/i18n/client'; +import { FileUpload } from '@/app/components/ui/forms/file-upload'; +import { toast } from '@/app/hooks/use-toast'; interface CustomerImportFormProps { hideTabs?: boolean; @@ -45,59 +45,31 @@ export function CustomerImportForm({ ? 'manual_import' : 'file_upload' : watch('dataSource'); - const [isDragOver, setIsDragOver] = useState(false); - const handleFileChange = (file: File) => { - if (file) { - setValue('file', file); - } - }; - - const handleFileInputChange = (e: React.ChangeEvent) => { - const selectedFile = e.target.files?.[0]; - if (selectedFile) { - handleFileChange(selectedFile); - } - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; - - const handleDragLeave = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - - const files = Array.from(e.dataTransfer.files); + const handleFilesSelected = (files: File[]) => { const file = files[0]; + if (!file) return; - if (file && (file.type.includes('sheet') || file.name.endsWith('.csv'))) { - handleFileChange(file); - } - }; - - const handleClick = () => { - const input = document.getElementById('file-upload') as HTMLInputElement; - input?.click(); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - handleClick(); + const fileName = file.name.toLowerCase(); + if ( + fileName.endsWith('.xlsx') || + fileName.endsWith('.xls') || + fileName.endsWith('.csv') + ) { + setValue('file', file); + } else { + toast({ + title: tCommon('validation.unsupportedFileType'), + description: tCommon('upload.supportedFormats'), + variant: 'destructive', + }); } }; const fileValue = watch('file') as File | null; return ( -
+ {!hideTabs && !mode && ( -
- + > + + +

+ {tCommon('upload.clickToUpload')} +

+

+ {tCommon('upload.supportedFormats')} +

+ +
  • {t('importForm.localeHint')}
  • diff --git a/services/platform/app/features/documents/components/document-upload-dialog.tsx b/services/platform/app/features/documents/components/document-upload-dialog.tsx index a88dc29af..436c975f3 100644 --- a/services/platform/app/features/documents/components/document-upload-dialog.tsx +++ b/services/platform/app/features/documents/components/document-upload-dialog.tsx @@ -1,15 +1,19 @@ 'use client'; -import { useState, useCallback, useRef, type ChangeEvent, type KeyboardEvent } from 'react'; +import { useState, useCallback } from 'react'; import { useQuery } from 'convex/react'; import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; import { Checkbox } from '@/app/components/ui/forms/checkbox'; +import { FileUpload } from '@/app/components/ui/forms/file-upload'; import { Stack } from '@/app/components/ui/layout/layout'; import { Users, Upload, X, FileText } from 'lucide-react'; import { Button } from '@/app/components/ui/primitives/button'; import { useT } from '@/lib/i18n/client'; import { api } from '@/convex/_generated/api'; -import { useDocumentUpload, MAX_FILE_SIZE_BYTES } from '../hooks/use-document-upload'; +import { + useDocumentUpload, + MAX_FILE_SIZE_BYTES, +} from '../hooks/use-document-upload'; import { cn } from '@/lib/utils/cn'; import { toast } from '@/app/hooks/use-toast'; @@ -31,7 +35,6 @@ export function DocumentUploadDialog({ const [selectedTeams, setSelectedTeams] = useState>(new Set()); const [selectedFiles, setSelectedFiles] = useState([]); - const fileInputRef = useRef(null); // Fetch user's teams via Convex query const teamsResult = useQuery( @@ -50,13 +53,16 @@ export function DocumentUploadDialog({ }); // Reset state when dialog closes - const handleOpenChange = useCallback((newOpen: boolean) => { - if (!newOpen) { - setSelectedTeams(new Set()); - setSelectedFiles([]); - } - onOpenChange(newOpen); - }, [onOpenChange]); + const handleOpenChange = useCallback( + (newOpen: boolean) => { + if (!newOpen) { + setSelectedTeams(new Set()); + setSelectedFiles([]); + } + onOpenChange(newOpen); + }, + [onOpenChange], + ); const handleToggleTeam = useCallback((teamId: string) => { setSelectedTeams((prev) => { @@ -70,70 +76,58 @@ export function DocumentUploadDialog({ }); }, []); - const handleFileSelect = useCallback((e?: React.MouseEvent | React.KeyboardEvent) => { - e?.stopPropagation(); - fileInputRef.current?.click(); - }, []); + const processFiles = useCallback( + (files: File[]) => { + if (files.length === 0) return; - const handleKeyDown = useCallback((event: KeyboardEvent) => { - if ((event.key === 'Enter' || event.key === ' ') && !isUploading) { - event.preventDefault(); - handleFileSelect(); - } - }, [handleFileSelect, isUploading]); + const maxSizeMB = MAX_FILE_SIZE_BYTES / (1024 * 1024); + const validFiles: File[] = []; + const rejectedFiles: File[] = []; - const handleFileChange = useCallback((event: ChangeEvent) => { - const files = Array.from(event.target.files || []); - if (files.length === 0) return; - - // Filter out files that are too large and notify user - const maxSizeMB = MAX_FILE_SIZE_BYTES / (1024 * 1024); - const validFiles: File[] = []; - const rejectedFiles: File[] = []; - - for (const file of files) { - if (file.size <= MAX_FILE_SIZE_BYTES) { - validFiles.push(file); - } else { - rejectedFiles.push(file); + for (const file of files) { + if (file.size <= MAX_FILE_SIZE_BYTES) { + validFiles.push(file); + } else { + rejectedFiles.push(file); + } } - } - if (rejectedFiles.length > 0) { - for (const file of rejectedFiles) { - const currentSizeMB = (file.size / (1024 * 1024)).toFixed(1); - toast({ - title: tDocuments('upload.fileTooLarge'), - description: tDocuments('upload.fileSizeExceeded', { - name: file.name, - maxSize: maxSizeMB.toString(), - currentSize: currentSizeMB, - }), - variant: 'destructive', - }); + if (rejectedFiles.length > 0) { + for (const file of rejectedFiles) { + const currentSizeMB = (file.size / (1024 * 1024)).toFixed(1); + toast({ + title: tDocuments('upload.fileTooLarge'), + description: tDocuments('upload.fileSizeExceeded', { + name: file.name, + maxSize: maxSizeMB.toString(), + currentSize: currentSizeMB, + }), + variant: 'destructive', + }); + } } - } - setSelectedFiles((prev) => [...prev, ...validFiles]); - - // Reset the input - if (event.target) { - event.target.value = ''; - } - }, [tDocuments]); + setSelectedFiles((prev) => [...prev, ...validFiles]); + }, + [tDocuments], + ); const handleRemoveFile = useCallback((index: number) => { setSelectedFiles((prev) => prev.filter((_, i) => i !== index)); }, []); - const handleSubmit = useCallback(async (e: React.FormEvent) => { - e.preventDefault(); + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); - if (selectedFiles.length === 0) return; + if (selectedFiles.length === 0) return; - const teamTags = selectedTeams.size > 0 ? Array.from(selectedTeams) : undefined; - await uploadFiles(selectedFiles, { teamTags }); - }, [selectedFiles, selectedTeams, uploadFiles]); + const teamTags = + selectedTeams.size > 0 ? Array.from(selectedTeams) : undefined; + await uploadFiles(selectedFiles, { teamTags }); + }, + [selectedFiles, selectedTeams, uploadFiles], + ); const formatFileSize = (bytes: number) => { if (bytes < 1024) return `${bytes} B`; @@ -157,43 +151,38 @@ export function DocumentUploadDialog({ > {/* File selection area */} -
    -
    + handleFileSelect(e) : undefined} - onKeyDown={handleKeyDown} > + -

    {tCommon('upload.clickToUpload')}

    +

    + {tCommon('upload.clickToUpload')} +

    {tDocuments('upload.fromComputerDescription')}

    -
    - - -
    + + {/* Selected files list */} {selectedFiles.length > 0 && (

    - {tDocuments('upload.uploadingCount', { count: selectedFiles.length })} + {tDocuments('upload.uploadingCount', { + count: selectedFiles.length, + })}

    {selectedFiles.map((file, index) => ( @@ -202,8 +191,10 @@ export function DocumentUploadDialog({ className="flex items-start gap-2 p-2 rounded-md bg-muted/50" > - {file.name} - + + {file.name} + + {formatFileSize(file.size)}