mirror of
https://github.com/langgenius/dify.git
synced 2026-01-08 07:14:14 +00:00
file uploader
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { RiUploadCloud2Line } from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
type FileFromLinkOrLocalProps = {
|
||||
showFromLink?: boolean
|
||||
onLink?: (url: string) => void
|
||||
showFromLocal?: boolean
|
||||
trigger: (open: boolean) => React.ReactNode
|
||||
}
|
||||
const FileFromLinkOrLocal = ({
|
||||
showFromLink = true,
|
||||
onLink,
|
||||
showFromLocal = true,
|
||||
trigger,
|
||||
}: FileFromLinkOrLocalProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [url, setUrl] = useState('')
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
placement='top'
|
||||
offset={4}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)} asChild>
|
||||
{trigger(open)}
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className='p-3 w-[280px] bg-components-panel-bg-blur border-[0.5px] border-components-panel-border rounded-xl shadow-lg'>
|
||||
{
|
||||
showFromLink && (
|
||||
<div className='flex items-center p-1 h-8 bg-components-input-bg-active border border-components-input-border-active rounded-lg shadow-xs'>
|
||||
<input
|
||||
className='grow block mr-0.5 px-1 bg-transparent system-sm-regular outline-none appearance-none'
|
||||
placeholder='Enter URL...'
|
||||
value={url}
|
||||
onChange={e => setUrl(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
className='shrink-0'
|
||||
size='small'
|
||||
variant='primary'
|
||||
disabled={!url}
|
||||
onClick={() => onLink?.(url)}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLink && showFromLocal && (
|
||||
<div className='flex items-center p-2 h-7 system-2xs-medium-uppercase text-text-quaternary'>
|
||||
<div className='mr-2 w-[93px] h-[1px] bg-gradient-to-l from-[rgba(16,24,40,0.08)]' />
|
||||
OR
|
||||
<div className='ml-2 w-[93px] h-[1px] bg-gradient-to-r from-[rgba(16,24,40,0.08)]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
showFromLocal && (
|
||||
<Button
|
||||
className='w-full'
|
||||
variant='secondary-accent'
|
||||
>
|
||||
<RiUploadCloud2Line className='mr-1 w-4 h-4' />
|
||||
Upload from computer
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(FileFromLinkOrLocal)
|
||||
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
} from 'react'
|
||||
import FileListItem from './file-list-item'
|
||||
|
||||
const FileListFlexPreview = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className='flex flex-wrap gap-2'
|
||||
>
|
||||
<FileListItem />
|
||||
<FileListItem />
|
||||
<FileListItem isFile />
|
||||
<FileListItem isFile />
|
||||
</div>
|
||||
)
|
||||
})
|
||||
FileListFlexPreview.displayName = 'FileListFlexPreview'
|
||||
|
||||
export default memo(FileListFlexPreview)
|
||||
@@ -68,10 +68,16 @@ const FILE_TYPE_ICON_MAP = {
|
||||
}
|
||||
type FileTypeIconProps = {
|
||||
type: keyof typeof FileTypeEnum
|
||||
size?: 'sm' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
const SizeMap = {
|
||||
sm: 'w-4 h-4',
|
||||
lg: 'w-6 h-6',
|
||||
}
|
||||
const FileTypeIcon = ({
|
||||
type,
|
||||
size = 'sm',
|
||||
className,
|
||||
}: FileTypeIconProps) => {
|
||||
const Icon = FILE_TYPE_ICON_MAP[type].component
|
||||
@@ -80,7 +86,7 @@ const FileTypeIcon = ({
|
||||
if (!Icon)
|
||||
return null
|
||||
|
||||
return <Icon className={cn('w-5 h-5', color, className)} />
|
||||
return <Icon className={cn('shrink-0', SizeMap[size], color, className)} />
|
||||
}
|
||||
|
||||
export default memo(FileTypeIcon)
|
||||
|
||||
@@ -4,13 +4,16 @@ import {
|
||||
} from '@remixicon/react'
|
||||
import FileTypeIcon from '../file-type-icon'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import ProgressCircle from '@/app/components/base/progress-bar/progress-circle'
|
||||
|
||||
const FileInAttachmentItem = () => {
|
||||
return (
|
||||
<div className='flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs'>
|
||||
<div className='shrink-0 flex items-center justify-center w-12 h-12'>
|
||||
<FileTypeIcon type='AUDIO' />
|
||||
</div>
|
||||
<div className='flex items-center px-3 h-12 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs'>
|
||||
<FileTypeIcon
|
||||
type='AUDIO'
|
||||
size='lg'
|
||||
className='mr-3'
|
||||
/>
|
||||
<div className='grow'>
|
||||
<div className='mb-0.5 system-xs-medium text-text-secondary'>Yellow mountain range.jpg</div>
|
||||
<div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
|
||||
@@ -19,9 +22,14 @@ const FileInAttachmentItem = () => {
|
||||
<span>21.5 MB</span>
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton className='shrink-0 mr-3'>
|
||||
<RiDeleteBinLine className='w-4 h-4' />
|
||||
</ActionButton>
|
||||
<div className='shrink-0 flex items-center'>
|
||||
<ProgressCircle
|
||||
percentage={10}
|
||||
/>
|
||||
<ActionButton>
|
||||
<RiDeleteBinLine className='w-4 h-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
import { memo } from 'react'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiLink,
|
||||
RiUploadCloud2Line,
|
||||
} from '@remixicon/react'
|
||||
import FileFromLinkOrLocal from '../file-from-link-or-local'
|
||||
import FileInAttachmentItem from './file-in-attachment-item'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Option = {
|
||||
value: string
|
||||
label: string
|
||||
icon: JSX.Element
|
||||
}
|
||||
const FileUploaderInAttachment = () => {
|
||||
const options = [
|
||||
{
|
||||
@@ -20,21 +30,39 @@ const FileUploaderInAttachment = () => {
|
||||
},
|
||||
]
|
||||
|
||||
const renderButton = useCallback((option: Option, open?: boolean) => {
|
||||
return (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant='tertiary'
|
||||
className={cn('grow', open && 'bg-components-button-tertiary-bg-hover')}
|
||||
>
|
||||
{option.icon}
|
||||
<span className='ml-1'>{option.label}</span>
|
||||
</Button>
|
||||
)
|
||||
}, [])
|
||||
const renderTrigger = useCallback((option: Option) => {
|
||||
return (open: boolean) => renderButton(option, open)
|
||||
}, [renderButton])
|
||||
const renderOption = useCallback((option: Option) => {
|
||||
if (option.value === 'local')
|
||||
return renderButton(option)
|
||||
|
||||
if (option.value === 'link') {
|
||||
return (
|
||||
<FileFromLinkOrLocal
|
||||
showFromLocal={false}
|
||||
trigger={renderTrigger(option)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
}, [renderButton, renderTrigger])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
{
|
||||
options.map(option => (
|
||||
<Button
|
||||
key={option.value}
|
||||
variant='tertiary'
|
||||
className='grow'
|
||||
>
|
||||
{option.icon}
|
||||
<span className='ml-1'>{option.label}</span>
|
||||
</Button>
|
||||
))
|
||||
}
|
||||
{options.map(renderOption)}
|
||||
</div>
|
||||
<div className='mt-1 space-y-1'>
|
||||
<FileInAttachmentItem />
|
||||
|
||||
@@ -1,50 +1,30 @@
|
||||
import {
|
||||
memo,
|
||||
useState,
|
||||
useCallback,
|
||||
} from 'react'
|
||||
import {
|
||||
RiAttachmentLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import FileFromLinkOrLocal from '../file-from-link-or-local'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const FileUploaderInChatInput = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const renderTrigger = useCallback((open: boolean) => {
|
||||
return (
|
||||
<ActionButton
|
||||
size='l'
|
||||
className={cn(open && 'bg-state-base-hover')}
|
||||
>
|
||||
<RiAttachmentLine className='w-5 h-5' />
|
||||
</ActionButton>
|
||||
)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='top'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<ActionButton size='l'>
|
||||
<RiAttachmentLine className='w-5 h-5' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent>
|
||||
<div className='p-3 w-[280px] bg-components-panel-bg-blur border-[0.5px] border-components-panel-border rounded-xl shadow-lg'>
|
||||
<div className='flex items-center p-1 bg-components-input-bg-active border border-components-input-border-active rounded-lg shadow-xs'>
|
||||
<input
|
||||
className='mr-0.5 p-1 appearance-none system-sm-regular text-text-secondary'
|
||||
placeholder='Enter URL...'
|
||||
/>
|
||||
<Button
|
||||
size='small'
|
||||
variant='primary'
|
||||
>
|
||||
OK ↩
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
<FileFromLinkOrLocal
|
||||
trigger={renderTrigger}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
5
web/app/components/base/file-uploader/hooks.ts
Normal file
5
web/app/components/base/file-uploader/hooks.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { useFileStore } from './store'
|
||||
|
||||
export const useFile = () => {
|
||||
const fileStore = useFileStore()
|
||||
}
|
||||
52
web/app/components/base/file-uploader/store.tsx
Normal file
52
web/app/components/base/file-uploader/store.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useRef,
|
||||
} from 'react'
|
||||
import {
|
||||
useStore as useZustandStore,
|
||||
} from 'zustand'
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
|
||||
type Shape = {
|
||||
files: any[]
|
||||
setFiles: (files: any[]) => void
|
||||
}
|
||||
|
||||
export const createFileStore = () => {
|
||||
return createStore<Shape>(set => ({
|
||||
files: [],
|
||||
setFiles: files => set({ files }),
|
||||
}))
|
||||
}
|
||||
|
||||
type FileStore = ReturnType<typeof createFileStore>
|
||||
export const FileContext = createContext<FileStore | null>(null)
|
||||
|
||||
export function useStore<T>(selector: (state: Shape) => T): T {
|
||||
const store = useContext(FileContext)
|
||||
if (!store)
|
||||
throw new Error('Missing FileContext.Provider in the tree')
|
||||
|
||||
return useZustandStore(store, selector)
|
||||
}
|
||||
|
||||
export const useFileStore = () => {
|
||||
return useContext(FileContext)!
|
||||
}
|
||||
|
||||
type FileProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
export const FileContextProvider = ({ children }: FileProviderProps) => {
|
||||
const storeRef = useRef<FileStore>()
|
||||
|
||||
if (!storeRef.current)
|
||||
storeRef.current = createFileStore()
|
||||
|
||||
return (
|
||||
<FileContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</FileContext.Provider>
|
||||
)
|
||||
}
|
||||
36
web/app/components/base/file-uploader/utils.ts
Normal file
36
web/app/components/base/file-uploader/utils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { upload } from '@/service/base'
|
||||
|
||||
type FileUploadParams = {
|
||||
file: File
|
||||
onProgressCallback: (progress: number) => void
|
||||
onSuccessCallback: (res: { id: string }) => void
|
||||
onErrorCallback: () => void
|
||||
}
|
||||
type FileUpload = (v: FileUploadParams, isPublic?: boolean, url?: string) => void
|
||||
export const imageUpload: FileUpload = ({
|
||||
file,
|
||||
onProgressCallback,
|
||||
onSuccessCallback,
|
||||
onErrorCallback,
|
||||
}, isPublic, url) => {
|
||||
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,
|
||||
}, isPublic, url)
|
||||
.then((res: { id: string }) => {
|
||||
onSuccessCallback(res)
|
||||
})
|
||||
.catch(() => {
|
||||
onErrorCallback()
|
||||
})
|
||||
}
|
||||
61
web/app/components/base/progress-bar/progress-circle.tsx
Normal file
61
web/app/components/base/progress-bar/progress-circle.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { memo } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ProgressCircleProps = {
|
||||
percentage?: number
|
||||
size?: number
|
||||
circleStrokeWidth?: number
|
||||
circleStrokeColor?: string
|
||||
circleFillColor?: string
|
||||
sectorFillColor?: string
|
||||
}
|
||||
|
||||
const ProgressCircle: React.FC<ProgressCircleProps> = ({
|
||||
percentage = 0,
|
||||
size = 12,
|
||||
circleStrokeWidth = 1,
|
||||
circleStrokeColor = 'components-progress-brand-border',
|
||||
circleFillColor = 'components-progress-brand-bg',
|
||||
sectorFillColor = 'components-progress-brand-progress',
|
||||
}) => {
|
||||
const radius = size / 2
|
||||
const center = size / 2
|
||||
const angle = (percentage / 100) * 360
|
||||
const radians = (angle * Math.PI) / 180
|
||||
const x = center + radius * Math.cos(radians - Math.PI / 2)
|
||||
const y = center + radius * Math.sin(radians - Math.PI / 2)
|
||||
const largeArcFlag = percentage > 50 ? 1 : 0
|
||||
|
||||
const pathData = `
|
||||
M ${center},${center}
|
||||
L ${center},${center - radius}
|
||||
A ${radius},${radius} 0 ${largeArcFlag} 1 ${x},${y}
|
||||
Z
|
||||
`
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size + circleStrokeWidth}
|
||||
height={size + circleStrokeWidth}
|
||||
viewBox={`0 0 ${size + circleStrokeWidth} ${size + circleStrokeWidth}`}
|
||||
>
|
||||
<circle
|
||||
className={cn(
|
||||
`fill-${circleFillColor}`,
|
||||
`stroke-${circleStrokeColor}`,
|
||||
)}
|
||||
cx={center + circleStrokeWidth / 2}
|
||||
cy={center + circleStrokeWidth / 2}
|
||||
r={radius}
|
||||
strokeWidth={circleStrokeWidth}
|
||||
/>
|
||||
<path
|
||||
className={cn(`fill-${sectorFillColor}`)}
|
||||
d={pathData}
|
||||
transform={`translate(${circleStrokeWidth / 2}, ${circleStrokeWidth / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ProgressCircle)
|
||||
Reference in New Issue
Block a user