file uploader

This commit is contained in:
StyleZhang
2024-07-30 16:19:20 +08:00
parent 56507c9f7a
commit 9c31c56115
10 changed files with 342 additions and 57 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -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>
)
}

View File

@@ -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 />

View File

@@ -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}
/>
)
}

View File

@@ -0,0 +1,5 @@
import { useFileStore } from './store'
export const useFile = () => {
const fileStore = useFileStore()
}

View 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>
)
}

View 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()
})
}

View 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)