| import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader' | import ChatImageUploader from '@/app/components/base/image-uploader/chat-image-uploader' | ||||
| import ImageList from '@/app/components/base/image-uploader/image-list' | import ImageList from '@/app/components/base/image-uploader/image-list' | ||||
| import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app' | import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app' | ||||
| import { useClipboardUploader, useImageFiles } from '@/app/components/base/image-uploader/hooks' | |||||
| import { useClipboardUploader, useDraggableUploader, useImageFiles } from '@/app/components/base/image-uploader/hooks' | |||||
| export type IChatProps = { | export type IChatProps = { | ||||
| configElem?: React.ReactNode | configElem?: React.ReactNode | ||||
| onClear, | onClear, | ||||
| } = useImageFiles() | } = useImageFiles() | ||||
| const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files }) | const { onPaste } = useClipboardUploader({ onUpload, visionConfig, files }) | ||||
| const { onDragEnter, onDragLeave, onDragOver, onDrop, isDragActive } = useDraggableUploader<HTMLTextAreaElement>({ onUpload, files, visionConfig }) | |||||
| const isUseInputMethod = useRef(false) | const isUseInputMethod = useRef(false) | ||||
| const [query, setQuery] = React.useState('') | const [query, setQuery] = React.useState('') | ||||
| </div> | </div> | ||||
| </div>) | </div>) | ||||
| } | } | ||||
| <div className='p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto'> | |||||
| <div className={cn('p-[5.5px] max-h-[150px] bg-white border-[1.5px] border-gray-200 rounded-xl overflow-y-auto', isDragActive && 'border-primary-600')}> | |||||
| { | { | ||||
| visionConfig?.enabled && ( | visionConfig?.enabled && ( | ||||
| <> | <> | ||||
| onKeyUp={handleKeyUp} | onKeyUp={handleKeyUp} | ||||
| onKeyDown={handleKeyDown} | onKeyDown={handleKeyDown} | ||||
| onPaste={onPaste} | onPaste={onPaste} | ||||
| onDragEnter={onDragEnter} | |||||
| onDragLeave={onDragLeave} | |||||
| onDragOver={onDragOver} | |||||
| onDrop={onDrop} | |||||
| autoSize | autoSize | ||||
| /> | /> | ||||
| <div className="absolute bottom-2 right-2 flex items-center h-8"> | <div className="absolute bottom-2 right-2 flex items-center h-8"> |
| } | } | ||||
| } | } | ||||
| type useClipboardUploaderProps = { | |||||
| files: ImageFile[] | |||||
| visionConfig?: VisionSettings | |||||
| type useLocalUploaderProps = { | |||||
| disabled?: boolean | |||||
| limit?: number | |||||
| onUpload: (imageFile: ImageFile) => void | onUpload: (imageFile: ImageFile) => void | ||||
| } | } | ||||
| export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => { | |||||
| export const useLocalFileUploader = ({ limit, disabled = false, onUpload }: useLocalUploaderProps) => { | |||||
| const { notify } = useToastContext() | const { notify } = useToastContext() | ||||
| const params = useParams() | const params = useParams() | ||||
| const { t } = useTranslation() | const { t } = useTranslation() | ||||
| const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => { | |||||
| if (!visionConfig || !visionConfig.enabled) | |||||
| return | |||||
| const disabled = files.length >= visionConfig.number_limits | |||||
| if (disabled) | |||||
| const handleLocalFileUpload = useCallback((file: File) => { | |||||
| if (disabled) { | |||||
| // TODO: leave some warnings? | // TODO: leave some warnings? | ||||
| return | return | ||||
| } | |||||
| const file = e.clipboardData?.files[0] | |||||
| if (!file || !ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1])) | |||||
| if (!ALLOW_FILE_EXTENSIONS.includes(file.type.split('/')[1])) | |||||
| return | return | ||||
| const limit = +visionConfig.image_file_size_limit! | |||||
| if (file.size > limit * 1024 * 1024) { | |||||
| if (limit && file.size > limit * 1024 * 1024) { | |||||
| notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) }) | notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) }) | ||||
| return | return | ||||
| } | } | ||||
| false, | false, | ||||
| ) | ) | ||||
| reader.readAsDataURL(file) | reader.readAsDataURL(file) | ||||
| }, [visionConfig, files.length, notify, t, onUpload, params.token]) | |||||
| }, [disabled, limit, notify, t, onUpload, params.token]) | |||||
| return { disabled, handleLocalFileUpload } | |||||
| } | |||||
| type useClipboardUploaderProps = { | |||||
| files: ImageFile[] | |||||
| visionConfig?: VisionSettings | |||||
| onUpload: (imageFile: ImageFile) => void | |||||
| } | |||||
| export const useClipboardUploader = ({ visionConfig, onUpload, files }: useClipboardUploaderProps) => { | |||||
| const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file) | |||||
| const disabled = useMemo(() => | |||||
| !visionConfig | |||||
| || !visionConfig?.enabled | |||||
| || !allowLocalUpload | |||||
| || files.length >= visionConfig.number_limits!, | |||||
| [allowLocalUpload, files.length, visionConfig]) | |||||
| const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig]) | |||||
| const { handleLocalFileUpload } = useLocalFileUploader({ limit, onUpload, disabled }) | |||||
| const handleClipboardPaste = useCallback((e: ClipboardEvent<HTMLTextAreaElement>) => { | |||||
| e.preventDefault() | |||||
| const file = e.clipboardData?.files[0] | |||||
| if (!file) | |||||
| return | |||||
| handleLocalFileUpload(file) | |||||
| }, [handleLocalFileUpload]) | |||||
| return { | return { | ||||
| onPaste: handleClipboardPaste, | onPaste: handleClipboardPaste, | ||||
| } | } | ||||
| } | } | ||||
| type useDraggableUploaderProps = { | |||||
| files: ImageFile[] | |||||
| visionConfig?: VisionSettings | |||||
| onUpload: (imageFile: ImageFile) => void | |||||
| } | |||||
| export const useDraggableUploader = <T extends HTMLElement>({ visionConfig, onUpload, files }: useDraggableUploaderProps) => { | |||||
| const allowLocalUpload = visionConfig?.transfer_methods?.includes(TransferMethod.local_file) | |||||
| const disabled = useMemo(() => | |||||
| !visionConfig | |||||
| || !visionConfig?.enabled | |||||
| || !allowLocalUpload | |||||
| || files.length >= visionConfig.number_limits!, | |||||
| [allowLocalUpload, files.length, visionConfig]) | |||||
| const limit = useMemo(() => visionConfig ? +visionConfig.image_file_size_limit! : 0, [visionConfig]) | |||||
| const { handleLocalFileUpload } = useLocalFileUploader({ disabled, onUpload, limit }) | |||||
| const [isDragActive, setIsDragActive] = useState(false) | |||||
| const handleDragEnter = useCallback((e: React.DragEvent<T>) => { | |||||
| e.preventDefault() | |||||
| e.stopPropagation() | |||||
| if (!disabled) | |||||
| setIsDragActive(true) | |||||
| }, [disabled]) | |||||
| const handleDragOver = useCallback((e: React.DragEvent<T>) => { | |||||
| e.preventDefault() | |||||
| e.stopPropagation() | |||||
| }, []) | |||||
| const handleDragLeave = useCallback((e: React.DragEvent<T>) => { | |||||
| e.preventDefault() | |||||
| e.stopPropagation() | |||||
| setIsDragActive(false) | |||||
| }, []) | |||||
| const handleDrop = useCallback((e: React.DragEvent<T>) => { | |||||
| e.preventDefault() | |||||
| e.stopPropagation() | |||||
| setIsDragActive(false) | |||||
| const file = e.dataTransfer.files[0] | |||||
| if (!file) | |||||
| return | |||||
| handleLocalFileUpload(file) | |||||
| }, [handleLocalFileUpload]) | |||||
| return { | |||||
| onDragEnter: handleDragEnter, | |||||
| onDragOver: handleDragOver, | |||||
| onDragLeave: handleDragLeave, | |||||
| onDrop: handleDrop, | |||||
| isDragActive, | |||||
| } | |||||
| } |
| import type { ChangeEvent, FC } from 'react' | import type { ChangeEvent, FC } from 'react' | ||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import { useParams } from 'next/navigation' | |||||
| import { useTranslation } from 'react-i18next' | |||||
| import { imageUpload } from './utils' | |||||
| import { useLocalFileUploader } from './hooks' | |||||
| import type { ImageFile } from '@/types/app' | import type { ImageFile } from '@/types/app' | ||||
| import { ALLOW_FILE_EXTENSIONS, TransferMethod } from '@/types/app' | |||||
| import { useToastContext } from '@/app/components/base/toast' | |||||
| import { ALLOW_FILE_EXTENSIONS } from '@/types/app' | |||||
| type UploaderProps = { | type UploaderProps = { | ||||
| children: (hovering: boolean) => JSX.Element | children: (hovering: boolean) => JSX.Element | ||||
| disabled, | disabled, | ||||
| }) => { | }) => { | ||||
| const [hovering, setHovering] = useState(false) | const [hovering, setHovering] = useState(false) | ||||
| const params = useParams() | |||||
| const { notify } = useToastContext() | |||||
| const { t } = useTranslation() | |||||
| const { handleLocalFileUpload } = useLocalFileUploader({ limit, onUpload, disabled }) | |||||
| const handleChange = (e: ChangeEvent<HTMLInputElement>) => { | const handleChange = (e: ChangeEvent<HTMLInputElement>) => { | ||||
| const file = e.target.files?.[0] | const file = e.target.files?.[0] | ||||
| if (!file) | if (!file) | ||||
| return | return | ||||
| if (limit && file.size > limit * 1024 * 1024) { | |||||
| notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerLimit', { size: limit }) }) | |||||
| return | |||||
| } | |||||
| const reader = new FileReader() | |||||
| reader.addEventListener( | |||||
| 'load', | |||||
| () => { | |||||
| const imageFile = { | |||||
| type: TransferMethod.local_file, | |||||
| _id: `${Date.now()}`, | |||||
| fileId: '', | |||||
| file, | |||||
| url: reader.result as string, | |||||
| base64Url: reader.result as string, | |||||
| progress: 0, | |||||
| } | |||||
| onUpload(imageFile) | |||||
| imageUpload({ | |||||
| file: imageFile.file, | |||||
| onProgressCallback: (progress) => { | |||||
| onUpload({ ...imageFile, progress }) | |||||
| }, | |||||
| onSuccessCallback: (res) => { | |||||
| onUpload({ ...imageFile, fileId: res.id, progress: 100 }) | |||||
| }, | |||||
| onErrorCallback: () => { | |||||
| notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') }) | |||||
| onUpload({ ...imageFile, progress: -1 }) | |||||
| }, | |||||
| }, !!params.token) | |||||
| }, | |||||
| false, | |||||
| ) | |||||
| reader.addEventListener( | |||||
| 'error', | |||||
| () => { | |||||
| notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerReadError') }) | |||||
| }, | |||||
| false, | |||||
| ) | |||||
| reader.readAsDataURL(file) | |||||
| handleLocalFileUpload(file) | |||||
| } | } | ||||
| return ( | return ( |