From 9c31c56115c3c5b980e7f94cc94b4561b09798d5 Mon Sep 17 00:00:00 2001 From: StyleZhang Date: Tue, 30 Jul 2024 16:19:20 +0800 Subject: [PATCH] file uploader --- .../file-from-link-or-local/index.tsx | 87 +++++++++++++++++++ .../file-list-flex/file-list-flex-preview.tsx | 22 +++++ .../base/file-uploader/file-type-icon.tsx | 8 +- .../file-in-attachment-item.tsx | 22 +++-- .../file-uploader-in-attachment/index.tsx | 54 +++++++++--- .../file-uploader-in-chat-input/index.tsx | 52 ++++------- .../components/base/file-uploader/hooks.ts | 5 ++ .../components/base/file-uploader/store.tsx | 52 +++++++++++ .../components/base/file-uploader/utils.ts | 36 ++++++++ .../base/progress-bar/progress-circle.tsx | 61 +++++++++++++ 10 files changed, 342 insertions(+), 57 deletions(-) create mode 100644 web/app/components/base/file-uploader/file-from-link-or-local/index.tsx create mode 100644 web/app/components/base/file-uploader/file-list-flex/file-list-flex-preview.tsx create mode 100644 web/app/components/base/file-uploader/hooks.ts create mode 100644 web/app/components/base/file-uploader/store.tsx create mode 100644 web/app/components/base/file-uploader/utils.ts create mode 100644 web/app/components/base/progress-bar/progress-circle.tsx diff --git a/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx new file mode 100644 index 0000000000..93c18626bc --- /dev/null +++ b/web/app/components/base/file-uploader/file-from-link-or-local/index.tsx @@ -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 ( + + setOpen(v => !v)} asChild> + {trigger(open)} + + +
+ { + showFromLink && ( +
+ setUrl(e.target.value)} + /> + +
+ ) + } + { + showFromLink && showFromLocal && ( +
+
+ OR +
+
+ ) + } + { + showFromLocal && ( + + ) + } +
+ + + ) +} + +export default memo(FileFromLinkOrLocal) diff --git a/web/app/components/base/file-uploader/file-list-flex/file-list-flex-preview.tsx b/web/app/components/base/file-uploader/file-list-flex/file-list-flex-preview.tsx new file mode 100644 index 0000000000..6d6afb0eb3 --- /dev/null +++ b/web/app/components/base/file-uploader/file-list-flex/file-list-flex-preview.tsx @@ -0,0 +1,22 @@ +import { + forwardRef, + memo, +} from 'react' +import FileListItem from './file-list-item' + +const FileListFlexPreview = forwardRef((_, ref) => { + return ( +
+ + + + +
+ ) +}) +FileListFlexPreview.displayName = 'FileListFlexPreview' + +export default memo(FileListFlexPreview) diff --git a/web/app/components/base/file-uploader/file-type-icon.tsx b/web/app/components/base/file-uploader/file-type-icon.tsx index 6ca32928aa..73d733f0cd 100644 --- a/web/app/components/base/file-uploader/file-type-icon.tsx +++ b/web/app/components/base/file-uploader/file-type-icon.tsx @@ -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 + return } export default memo(FileTypeIcon) diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx index ee3460039d..fe0d1a4987 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/file-in-attachment-item.tsx @@ -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 ( -
-
- -
+
+
Yellow mountain range.jpg
@@ -19,9 +22,14 @@ const FileInAttachmentItem = () => { 21.5 MB
- - - +
+ + + + +
) } diff --git a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx index 5715231fc1..f2274fa822 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-attachment/index.tsx @@ -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 ( + + ) + }, []) + 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 ( + + ) + } + }, [renderButton, renderTrigger]) + return (
- { - options.map(option => ( - - )) - } + {options.map(renderOption)}
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx index d645d2b68f..0380b60d87 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx @@ -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 ( + + + + ) + }, []) return ( - - setOpen(v => !v)}> - - - - - -
-
- - -
-
-
-
+ ) } diff --git a/web/app/components/base/file-uploader/hooks.ts b/web/app/components/base/file-uploader/hooks.ts new file mode 100644 index 0000000000..5f96a0974a --- /dev/null +++ b/web/app/components/base/file-uploader/hooks.ts @@ -0,0 +1,5 @@ +import { useFileStore } from './store' + +export const useFile = () => { + const fileStore = useFileStore() +} diff --git a/web/app/components/base/file-uploader/store.tsx b/web/app/components/base/file-uploader/store.tsx new file mode 100644 index 0000000000..78ade49fd9 --- /dev/null +++ b/web/app/components/base/file-uploader/store.tsx @@ -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(set => ({ + files: [], + setFiles: files => set({ files }), + })) +} + +type FileStore = ReturnType +export const FileContext = createContext(null) + +export function useStore(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() + + if (!storeRef.current) + storeRef.current = createFileStore() + + return ( + + {children} + + ) +} diff --git a/web/app/components/base/file-uploader/utils.ts b/web/app/components/base/file-uploader/utils.ts new file mode 100644 index 0000000000..a82b7fd541 --- /dev/null +++ b/web/app/components/base/file-uploader/utils.ts @@ -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() + }) +} diff --git a/web/app/components/base/progress-bar/progress-circle.tsx b/web/app/components/base/progress-bar/progress-circle.tsx new file mode 100644 index 0000000000..d1812dba37 --- /dev/null +++ b/web/app/components/base/progress-bar/progress-circle.tsx @@ -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 = ({ + 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 ( + + + + + ) +} + +export default memo(ProgressCircle)