feat: support upload image

This commit is contained in:
StyleZhang
2023-11-22 11:08:09 +08:00
parent ae70d2fee1
commit 5dc3658e19
37 changed files with 1674 additions and 33 deletions

View File

@ -0,0 +1,150 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Uploader from './uploader'
import ImageLinkInput from './image-link-input'
import ImagePlus from '@/app/components/base/icons/line/image-plus'
import { TransferMethod } from '@/types/app'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import Upload03 from '@/app/components/base/icons/line/upload-03'
import type { ImageFile, VisionSettings } from '@/types/app'
type UploadOnlyFromLocalProps = {
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
limit?: number
}
const UploadOnlyFromLocal: FC<UploadOnlyFromLocalProps> = ({
onUpload,
disabled,
limit,
}) => {
return (
<Uploader onUpload={onUpload} disabled={disabled} limit={limit}>
{
hovering => (
<div className={`
relative flex items-center justify-center w-8 h-8 rounded-lg cursor-pointer
${hovering && 'bg-gray-100'}
`}>
<ImagePlus className='w-4 h-4 text-gray-500' />
</div>
)
}
</Uploader>
)
}
type UploaderButtonProps = {
methods: VisionSettings['transfer_methods']
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
limit?: number
}
const UploaderButton: FC<UploaderButtonProps> = ({
methods,
onUpload,
disabled,
limit,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const hasUploadFromLocal = methods.find(method => method === TransferMethod.local_file)
const handleUpload = (imageFile: ImageFile) => {
setOpen(false)
onUpload(imageFile)
}
const handleToggle = () => {
if (disabled)
return
setOpen(v => !v)
}
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='top-start'
>
<PortalToFollowElemTrigger onClick={handleToggle}>
<div className={`
relative flex items-center justify-center w-8 h-8 hover:bg-gray-100 rounded-lg
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}>
<ImagePlus className='w-4 h-4 text-gray-500' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='p-2 w-[260px] bg-white rounded-lg border-[0.5px] border-gray-200 shadow-lg'>
<ImageLinkInput onUpload={handleUpload} />
{
hasUploadFromLocal && (
<>
<div className='flex items-center mt-2 px-2 text-xs font-medium text-gray-400'>
<div className='mr-3 w-[93px] h-[1px] bg-gradient-to-l from-[#F3F4F6]' />
OR
<div className='ml-3 w-[93px] h-[1px] bg-gradient-to-r from-[#F3F4F6]' />
</div>
<Uploader onUpload={handleUpload} limit={limit}>
{
hovering => (
<div className={`
flex items-center justify-center h-8 text-[13px] font-medium text-[#155EEF] rounded-lg cursor-pointer
${hovering && 'bg-primary-50'}
`}>
<Upload03 className='mr-1 w-4 h-4' />
{t('common.imageUploader.uploadFromComputer')}
</div>
)
}
</Uploader>
</>
)
}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
type ChatImageUploaderProps = {
settings: VisionSettings
onUpload: (imageFile: ImageFile) => void
disabled?: boolean
}
const ChatImageUploader: FC<ChatImageUploaderProps> = ({
settings,
onUpload,
disabled,
}) => {
const onlyUploadLocal = settings.transfer_methods.length === 1 && settings.transfer_methods[0] === TransferMethod.local_file
if (onlyUploadLocal) {
return (
<UploadOnlyFromLocal
onUpload={onUpload}
disabled={disabled}
limit={+settings.image_file_size_limit!}
/>
)
}
return (
<UploaderButton
methods={settings.transfer_methods}
onUpload={onUpload}
disabled={disabled}
limit={+settings.image_file_size_limit!}
/>
)
}
export default ChatImageUploader

View File

@ -0,0 +1,108 @@
import { useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUpload } from './utils'
import Toast from '@/app/components/base/toast'
import type { ImageFile } from '@/types/app'
export const useImageFiles = () => {
const { t } = useTranslation()
const { notify } = Toast
const [files, setFiles] = useState<ImageFile[]>([])
const filesRef = useRef<ImageFile[]>([])
const handleUpload = (imageFile: ImageFile) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFile._id)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, ...imageFile }, ...files.slice(index + 1)]
setFiles(newFiles)
filesRef.current = newFiles
}
else {
const newFiles = [...files, imageFile]
setFiles(newFiles)
filesRef.current = newFiles
}
}
const handleRemove = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, deleted: true }, ...files.slice(index + 1)]
setFiles(newFiles)
filesRef.current = newFiles
}
}
const handleImageLinkLoadError = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
}
}
const handleImageLinkLoadSuccess = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentImageFile = files[index]
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: 100 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
}
}
const handleReUpload = (imageFileId: string) => {
const files = filesRef.current
const index = files.findIndex(file => file._id === imageFileId)
if (index > -1) {
const currentImageFile = files[index]
imageUpload({
file: currentImageFile.file!,
onProgressCallback: (progress) => {
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
onSuccessCallback: (res) => {
const newFiles = [...files.slice(0, index), { ...currentImageFile, fileId: res.id, progress: 100 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
onErrorCallback: () => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerUploadError') })
const newFiles = [...files.slice(0, index), { ...currentImageFile, progress: -1 }, ...files.slice(index + 1)]
filesRef.current = newFiles
setFiles(newFiles)
},
})
}
}
const handleClear = () => {
setFiles([])
filesRef.current = []
}
const filteredFiles = useMemo(() => {
return files.filter(file => !file.deleted)
}, [files])
return {
files: filteredFiles,
onUpload: handleUpload,
onRemove: handleRemove,
onImageLinkLoadError: handleImageLinkLoadError,
onImageLinkLoadSuccess: handleImageLinkLoadSuccess,
onReUpload: handleReUpload,
onClear: handleClear,
}
}

View File

@ -0,0 +1,50 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
type ImageLinkInputProps = {
onUpload: (imageFile: ImageFile) => void
}
const regex = /^(https?|ftp):\/\//
const ImageLinkInput: FC<ImageLinkInputProps> = ({
onUpload,
}) => {
const { t } = useTranslation()
const [imageLink, setImageLink] = useState('')
const handleClick = () => {
const imageFile = {
type: TransferMethod.remote_url,
_id: `${Date.now()}`,
fileId: '',
progress: regex.test(imageLink) ? 0 : -1,
url: imageLink,
}
onUpload(imageFile)
}
return (
<div className='flex items-center pl-1.5 pr-1 h-8 border border-gray-200 bg-white shadow-xs rounded-lg'>
<input
className='grow mr-0.5 px-1 h-[18px] text-[13px] outline-none appearance-none'
value={imageLink}
onChange={e => setImageLink(e.target.value)}
placeholder={t('common.imageUploader.pasteImageLinkInputPlaceholder') || ''}
/>
<Button
type='primary'
className='!h-6 text-xs font-medium'
disabled={!imageLink}
onClick={handleClick}
>
{t('common.operation.ok')}
</Button>
</div>
)
}
export default ImageLinkInput

View File

@ -0,0 +1,130 @@
import type { FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Loading02 from '@/app/components/base/icons/line/loading-02'
import XClose from '@/app/components/base/icons/line/x-close'
import RefreshCcw01 from '@/app/components/base/icons/line/refresh-ccw-01'
import AlertTriangle from '@/app/components/base/icons/solid/alert-triangle'
import TooltipPlus from '@/app/components/base/tooltip-plus'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
type ImageListProps = {
list: ImageFile[]
readonly?: boolean
onRemove?: (imageFileId: string) => void
onReUpload?: (imageFileId: string) => void
onImageLinkLoadSuccess?: (imageFileId: string) => void
onImageLinkLoadError?: (imageFileId: string) => void
}
const ImageList: FC<ImageListProps> = ({
list,
readonly,
onRemove,
onReUpload,
onImageLinkLoadSuccess,
onImageLinkLoadError,
}) => {
const { t } = useTranslation()
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
const handleImageLinkLoadSuccess = (item: ImageFile) => {
if (item.type === TransferMethod.remote_url && onImageLinkLoadSuccess && item.progress !== -1)
onImageLinkLoadSuccess(item._id)
}
const handleImageLinkLoadError = (item: ImageFile) => {
if (item.type === TransferMethod.remote_url && onImageLinkLoadError)
onImageLinkLoadError(item._id)
}
return (
<div className='flex flex-wrap'>
{
list.map(item => (
<div
key={item._id}
className='group relative mr-1 border-[0.5px] border-black/5 rounded-lg'
>
{
item.type === TransferMethod.local_file && item.progress !== 100 && (
<>
<div
className='absolute inset-0 flex items-center justify-center z-[1] bg-black/30'
style={{ left: item.progress > -1 ? `${item.progress}%` : 0 }}
>
{
item.progress === -1 && (
<RefreshCcw01 className='w-5 h-5 text-white' onClick={() => onReUpload && onReUpload(item._id)} />
)
}
</div>
{
item.progress > -1 && (
<span className='absolute top-[50%] left-[50%] translate-x-[-50%] translate-y-[-50%] text-sm text-white mix-blend-lighten z-[1]'>{item.progress}%</span>
)
}
</>
)
}
{
item.type === TransferMethod.remote_url && item.progress !== 100 && (
<div className={`
absolute inset-0 flex items-center justify-center rounded-lg z-[1] border
${item.progress === -1 ? 'bg-[#FEF0C7] border-[#DC6803]' : 'bg-black/[0.16] border-transparent'}
`}>
{
item.progress > -1 && (
<Loading02 className='animate-spin w-5 h-5 text-white' />
)
}
{
item.progress === -1 && (
<TooltipPlus popupContent={t('common.imageUploader.pasteImageLinkInvalid')}>
<AlertTriangle className='w-4 h-4 text-[#DC6803]' />
</TooltipPlus>
)
}
</div>
)
}
<img
className='w-16 h-16 rounded-lg object-cover cursor-pointer border-[0.5px] border-black/5'
alt=''
onLoad={() => handleImageLinkLoadSuccess(item)}
onError={() => handleImageLinkLoadError(item)}
src={item.type === TransferMethod.remote_url ? item.url : item.base64Url}
onClick={() => item.progress === 100 && setImagePreviewUrl((item.type === TransferMethod.remote_url ? item.url : item.base64Url) as string)}
/>
{
!readonly && (
<div
className={`
absolute z-10 -top-[9px] -right-[9px] items-center justify-center w-[18px] h-[18px]
bg-white hover:bg-gray-50 border-[0.5px] border-black/[0.02] rounded-2xl shadow-lg
cursor-pointer
${item.progress === -1 ? 'flex' : 'hidden group-hover:flex'}
`}
onClick={() => onRemove && onRemove(item._id)}
>
<XClose className='w-3 h-3 text-gray-500' />
</div>
)
}
</div>
))
}
{
imagePreviewUrl && (
<ImagePreview
url={imagePreviewUrl}
onCancel={() => setImagePreviewUrl('')}
/>
)
}
</div>
)
}
export default ImageList

View File

@ -0,0 +1,31 @@
import type { FC } from 'react'
import { createPortal } from 'react-dom'
import XClose from '@/app/components/base/icons/line/x-close'
type ImagePreviewProps = {
url: string
onCancel: () => void
}
const ImagePreview: FC<ImagePreviewProps> = ({
url,
onCancel,
}) => {
return createPortal(
<div className='fixed inset-0 p-8 flex items-center justify-center bg-black/80 z-[1000]' onClick={e => e.stopPropagation()}>
<img
alt='preview image'
src={url}
className='max-w-full max-h-full'
/>
<div
className='absolute top-6 right-6 flex items-center justify-center w-8 h-8 bg-white/[0.08] rounded-lg backdrop-blur-[2px] cursor-pointer'
onClick={onCancel}
>
<XClose className='w-4 h-4 text-white' />
</div>
</div>,
document.body,
)
}
export default ImagePreview

View File

@ -0,0 +1,101 @@
'use client'
import type { ChangeEvent, FC } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { imageUpload } from './utils'
import type { ImageFile } from '@/types/app'
import { TransferMethod } from '@/types/app'
import Toast from '@/app/components/base/toast'
type UploaderProps = {
children: (hovering: boolean) => JSX.Element
onUpload: (imageFile: ImageFile) => void
limit?: number
disabled?: boolean
}
const Uploader: FC<UploaderProps> = ({
children,
onUpload,
limit,
disabled,
}) => {
const [hovering, setHovering] = useState(false)
const { notify } = Toast
const { t } = useTranslation()
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file)
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 })
},
})
},
false,
)
reader.addEventListener(
'error',
() => {
notify({ type: 'error', message: t('common.imageUploader.uploadFromComputerReadError') })
},
false,
)
reader.readAsDataURL(file)
}
return (
<div
className='relative'
onMouseEnter={() => setHovering(true)}
onMouseLeave={() => setHovering(false)}
>
{children(hovering)}
<input
className={`
absolute block inset-0 opacity-0 text-[0] w-full
${disabled ? 'cursor-not-allowed' : 'cursor-pointer'}
`}
onClick={e => (e.target as HTMLInputElement).value = ''}
type='file'
accept='.png, .jpg, .jpeg, .webp, .gif'
onChange={handleChange}
disabled={disabled}
/>
</div>
)
}
export default Uploader

View File

@ -0,0 +1,38 @@
'use client'
import { upload } from '@/service/base'
type ImageUploadParams = {
file: File
onProgressCallback: (progress: number) => void
onSuccessCallback: (res: { id: string }) => void
onErrorCallback: () => void
}
type ImageUpload = (v: ImageUploadParams) => void
export const imageUpload: ImageUpload = ({
file,
onProgressCallback,
onSuccessCallback,
onErrorCallback,
}) => {
const formData = new FormData()
formData.append('file', file)
const onProgress = (e: ProgressEvent) => {
if (e.lengthComputable) {
const percent = Math.floor(e.loaded / e.total * 100)
onProgressCallback(percent)
}
}
upload({
xhr: new XMLHttpRequest(),
data: formData,
onprogress: onProgress,
})
.then((res: { id: string }) => {
onSuccessCallback(res)
})
.catch(() => {
onErrorCallback()
})
}