diff --git a/src/application/types.ts b/src/application/types.ts index ac162a8f..a8c7cede 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -996,6 +996,7 @@ export interface ViewMetaProps { extra?: ViewExtra | null; readOnly?: boolean; updatePage?: (viewId: string, data: UpdatePagePayload) => Promise; + uploadFile?: (file: File) => Promise; onEnter?: (text: string) => void; maxWidth?: number; } diff --git a/src/components/_shared/LoadingDots.tsx b/src/components/_shared/LoadingDots.tsx new file mode 100644 index 00000000..5a602178 --- /dev/null +++ b/src/components/_shared/LoadingDots.tsx @@ -0,0 +1,26 @@ +export default function LoadingDots({ + className, + colors = ['#00b5ff', '#e3006d', '#f7931e'], +}: { + className?: string; + colors?: [string, string, string]; +}) { + return ( +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/_shared/image-upload/UploadImage.tsx b/src/components/_shared/image-upload/UploadImage.tsx index 225e6dc6..663297da 100644 --- a/src/components/_shared/image-upload/UploadImage.tsx +++ b/src/components/_shared/image-upload/UploadImage.tsx @@ -1,4 +1,5 @@ import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; +import LoadingDots from '@/components/_shared/LoadingDots'; import { notify } from '@/components/_shared/notify'; import React, { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -10,24 +11,28 @@ export function UploadImage({ onDone, uploadAction }: { uploadAction?: (file: File) => Promise }) { const { t } = useTranslation(); - const handleFileChange = useCallback(async (files: File[]) => { + const [loading, setLoading] = React.useState(false); + const handleFileChange = useCallback(async(files: File[]) => { + setLoading(true); const file = files[0]; - if (!file) return; + if(!file) return; try { const url = await uploadAction?.(file); - if (!url) { + if(!url) { onDone?.(URL.createObjectURL(file)); return; } onDone?.(url); // eslint-disable-next-line - } catch (e: any) { + } catch(e: any) { notify.error(e.message); onDone?.(URL.createObjectURL(file)); + } finally { + setLoading(false); } }, [onDone, uploadAction]); @@ -39,6 +44,10 @@ export function UploadImage({ onDone, uploadAction }: { onChange={handleFileChange} accept={ALLOWED_IMAGE_EXTENSIONS.join(',')} /> + {loading && +
+ +
}
); diff --git a/src/components/_shared/view-icon/ChangeIconPopover.tsx b/src/components/_shared/view-icon/ChangeIconPopover.tsx index f2fe3591..1e567ffe 100644 --- a/src/components/_shared/view-icon/ChangeIconPopover.tsx +++ b/src/components/_shared/view-icon/ChangeIconPopover.tsx @@ -1,6 +1,7 @@ import { ViewIconType } from '@/application/types'; import { EmojiPicker } from '@/components/_shared/emoji-picker'; import IconPicker from '@/components/_shared/icon-picker/IconPicker'; +import { UploadImage } from '@/components/_shared/image-upload'; import { Popover } from '@/components/_shared/popover'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; import { Button } from '@mui/material'; @@ -8,7 +9,7 @@ import { PopoverProps } from '@mui/material/Popover'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -function ChangeIconPopover ({ +function ChangeIconPopover({ open, anchorEl, onClose, @@ -20,16 +21,20 @@ function ChangeIconPopover ({ removeIcon, anchorPosition, hideRemove, + uploadEnabled, + onUploadFile, }: { open: boolean, anchorEl?: HTMLElement | null, anchorPosition?: PopoverProps['anchorPosition'], onClose: () => void, - defaultType: 'emoji' | 'icon', + defaultType: 'emoji' | 'icon' | 'upload', emojiEnabled?: boolean, + uploadEnabled?: boolean, iconEnabled?: boolean, popoverProps?: Partial, onSelectIcon?: (icon: { ty: ViewIconType, value: string, color?: string, content?: string }) => void, + onUploadFile?: (file: File) => Promise, removeIcon?: () => void, hideRemove?: boolean, }) { @@ -80,6 +85,16 @@ function ChangeIconPopover ({ /> ) } + { + uploadEnabled && ( + + ) + } {!hideRemove && { updatePage, onRendered, onEditorConnected, + uploadFile, } = props; const blockId = search.get('blockId') || undefined; @@ -76,6 +77,7 @@ export const Document = (props: DocumentProps) => { updatePage={updatePage} onEnter={readOnly ? undefined : handleEnter} maxWidth={988} + uploadFile={uploadFile} /> }>
diff --git a/src/components/view-meta/AddIconCover.tsx b/src/components/view-meta/AddIconCover.tsx index a391ce5e..66bed21d 100644 --- a/src/components/view-meta/AddIconCover.tsx +++ b/src/components/view-meta/AddIconCover.tsx @@ -15,6 +15,7 @@ function AddIconCover({ setIconAnchorEl, maxWidth, visible, + onUploadFile, }: { visible: boolean; hasIcon: boolean; @@ -24,6 +25,7 @@ function AddIconCover({ iconAnchorEl: HTMLElement | null; setIconAnchorEl: (el: HTMLElement | null) => void; maxWidth?: number; + onUploadFile: (file: File) => Promise; }) { const { t } = useTranslation(); @@ -80,6 +82,8 @@ function AddIconCover({ setIconAnchorEl(null); onUpdateIcon?.({ ty: ViewIconType.Emoji, value: '' }); }} + uploadEnabled + onUploadFile={onUploadFile} /> diff --git a/src/components/view-meta/ViewMetaPreview.tsx b/src/components/view-meta/ViewMetaPreview.tsx index 96173563..0285a9d7 100644 --- a/src/components/view-meta/ViewMetaPreview.tsx +++ b/src/components/view-meta/ViewMetaPreview.tsx @@ -2,13 +2,13 @@ import { CoverType, ViewIconType, ViewLayout, ViewMetaCover, ViewMetaIcon, ViewM import { notify } from '@/components/_shared/notify'; import TitleEditable from '@/components/view-meta/TitleEditable'; import ViewCover from '@/components/view-meta/ViewCover'; -import React, { lazy, Suspense, useEffect, useMemo } from 'react'; +import React, { lazy, Suspense, useCallback, useEffect, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import PageIcon from '@/components/_shared/view-icon/PageIcon'; const AddIconCover = lazy(() => import('@/components/view-meta/AddIconCover')); -export function ViewMetaPreview ({ +export function ViewMetaPreview({ icon: iconProp, cover: coverProp, name, @@ -18,6 +18,7 @@ export function ViewMetaPreview ({ updatePage, onEnter, maxWidth, + uploadFile, }: ViewMetaProps) { const [iconAnchorEl, setIconAnchorEl] = React.useState(null); const [cover, setCover] = React.useState(coverProp || null); @@ -32,21 +33,21 @@ export function ViewMetaPreview ({ }, [iconProp]); const coverType = useMemo(() => { - if (cover && [CoverType.NormalColor, CoverType.GradientColor].includes(cover.type)) { + if(cover && [CoverType.NormalColor, CoverType.GradientColor].includes(cover.type)) { return 'color'; } - if (CoverType.BuildInImage === cover?.type) { + if(CoverType.BuildInImage === cover?.type) { return 'built_in'; } - if (cover && [CoverType.CustomImage, CoverType.UpsplashImage].includes(cover.type)) { + if(cover && [CoverType.CustomImage, CoverType.UpsplashImage].includes(cover.type)) { return 'custom'; } }, [cover]); const coverValue = useMemo(() => { - if (coverType === CoverType.BuildInImage) { + if(coverType === CoverType.BuildInImage) { return { 1: '/covers/m_cover_image_1.png', 2: '/covers/m_cover_image_2.png', @@ -63,8 +64,8 @@ export function ViewMetaPreview ({ const [isHover, setIsHover] = React.useState(false); - const handleUpdateIcon = React.useCallback(async (icon: { ty: ViewIconType, value: string }) => { - if (!updatePage || !viewId) return; + const handleUpdateIcon = React.useCallback(async(icon: { ty: ViewIconType, value: string }) => { + if(!updatePage || !viewId) return; setIcon(icon); try { await updatePage(viewId, { @@ -73,15 +74,15 @@ export function ViewMetaPreview ({ extra: extra || {}, }); // eslint-disable-next-line - } catch (e: any) { + } catch(e: any) { notify.error(e.message); } }, [updatePage, viewId, name, extra]); - const handleUpdateName = React.useCallback(async (newName: string) => { - if (!updatePage || !viewId) return; + const handleUpdateName = React.useCallback(async(newName: string) => { + if(!updatePage || !viewId) return; try { - if (name === newName) return; + if(name === newName) return; await updatePage(viewId, { icon: icon || { ty: ViewIconType.Emoji, @@ -91,16 +92,16 @@ export function ViewMetaPreview ({ extra: extra || {}, }); // eslint-disable-next-line - } catch (e: any) { + } catch(e: any) { notify.error(e.message); } }, [name, updatePage, viewId, icon, extra]); - const handleUpdateCover = React.useCallback(async (cover?: { + const handleUpdateCover = React.useCallback(async(cover?: { type: CoverType; value: string; }) => { - if (!updatePage || !viewId) return; + if(!updatePage || !viewId) return; setCover(cover ? cover : null); try { @@ -116,11 +117,16 @@ export function ViewMetaPreview ({ }, }); // eslint-disable-next-line - } catch (e: any) { + } catch(e: any) { notify.error(e.message); } }, [extra, icon, name, updatePage, viewId]); + const onUploadFile = useCallback(async(file: File) => { + if(!uploadFile) return Promise.reject(); + return uploadFile(file); + }, [uploadFile]); + const ref = React.useRef(null); useEffect(() => { @@ -133,13 +139,13 @@ export function ViewMetaPreview ({ setIsHover(false); }; - if (el) { + if(el) { el.addEventListener('mouseenter', handleMouseEnter); el.addEventListener('mouseleave', handleMouseLeave); } return () => { - if (el) { + if(el) { el.removeEventListener('mouseenter', handleMouseEnter); el.removeEventListener('mouseleave', handleMouseLeave); } @@ -174,8 +180,10 @@ export function ViewMetaPreview ({ maxWidth={maxWidth} iconAnchorEl={iconAnchorEl} setIconAnchorEl={setIconAnchorEl} + onUploadFile={onUploadFile} />} +
{ - if (readOnly) return; + if(readOnly) return; setIconAnchorEl(e.currentTarget); }} className={`view-icon flex h-[1.25em] px-1.5 items-center justify-center ${readOnly ? 'cursor-default' : 'cursor-pointer hover:bg-fill-list-hover '}`}