diff --git a/frontend/appflowy_web_app/package.json b/frontend/appflowy_web_app/package.json index 4e0c87a259..484e8fe426 100644 --- a/frontend/appflowy_web_app/package.json +++ b/frontend/appflowy_web_app/package.json @@ -56,6 +56,7 @@ "jest": "^29.5.0", "js-base64": "^3.7.5", "katex": "^0.16.7", + "lightgallery": "^2.7.2", "lodash-es": "^4.17.21", "nanoid": "^4.0.0", "notistack": "^3.0.1", @@ -85,6 +86,7 @@ "react-virtualized-auto-sizer": "^1.0.20", "react-vtree": "^2.0.4", "react-window": "^1.8.10", + "react-zoom-pan-pinch": "^3.6.1", "react18-input-otp": "^1.1.2", "redux": "^4.2.1", "rxjs": "^7.8.0", diff --git a/frontend/appflowy_web_app/pnpm-lock.yaml b/frontend/appflowy_web_app/pnpm-lock.yaml index 321468236c..78828fd414 100644 --- a/frontend/appflowy_web_app/pnpm-lock.yaml +++ b/frontend/appflowy_web_app/pnpm-lock.yaml @@ -101,6 +101,9 @@ dependencies: katex: specifier: ^0.16.7 version: 0.16.10 + lightgallery: + specifier: ^2.7.2 + version: 2.7.2 lodash-es: specifier: ^4.17.21 version: 4.17.21 @@ -188,6 +191,9 @@ dependencies: react-window: specifier: ^1.8.10 version: 1.8.10(react-dom@18.2.0)(react@18.2.0) + react-zoom-pan-pinch: + specifier: ^3.6.1 + version: 3.6.1(react-dom@18.2.0)(react@18.2.0) react18-input-otp: specifier: ^1.1.2 version: 1.1.4(react-dom@18.2.0)(react@18.2.0) @@ -8227,6 +8233,11 @@ packages: isomorphic.js: 0.2.5 dev: false + /lightgallery@2.7.2: + resolution: {integrity: sha512-Ewdcg9UPDqV0HGZeD7wNE4uYejwH2u0fMo5VAr6GHzlPYlhItJvjhLTR0cL0V1HjhMsH39PAom9iv69ewitLWw==} + engines: {node: '>=6.0.0'} + dev: false + /lilconfig@2.1.0: resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} engines: {node: '>=10'} @@ -9824,6 +9835,17 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /react-zoom-pan-pinch@3.6.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-SdPqdk7QDSV7u/WulkFOi+cnza8rEZ0XX4ZpeH7vx3UZEg7DoyuAy3MCmm+BWv/idPQL2Oe73VoC0EhfCN+sZQ==} + engines: {node: '>=8', npm: '>=5'} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /react18-input-otp@1.1.4(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-35xvmTeuPWIxd0Z0Opx4z3OoMaTmKN4ubirQCx1YMZiNoe+2h1hsOSUco4aKPlGXWZCtXrfOFieAh46vqiK9mA==} peerDependencies: diff --git a/frontend/appflowy_web_app/src/application/collab.type.ts b/frontend/appflowy_web_app/src/application/collab.type.ts index 81378c8099..34a6c1094e 100644 --- a/frontend/appflowy_web_app/src/application/collab.type.ts +++ b/frontend/appflowy_web_app/src/application/collab.type.ts @@ -35,6 +35,7 @@ export enum BlockType { TableCell = 'table/cell', LinkPreview = 'link_preview', FileBlock = 'file', + GalleryBlock = 'multi_image', } export enum InlineBlockType { @@ -112,6 +113,19 @@ export interface ImageBlockData extends BlockData { height?: number; } +export enum GalleryLayout { + Carousel = 0, + Grid = 1, +} + +export interface GalleryBlockData extends BlockData { + images: { + type: ImageType, + url: string, + }[]; + layout: GalleryLayout; +} + export interface OutlineBlockData extends BlockData { depth?: number; } diff --git a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts index 3ce200bd5d..4668af7d90 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/http/gotrue.ts @@ -3,7 +3,7 @@ import axios, { AxiosInstance } from 'axios'; let axiosInstance: AxiosInstance | null = null; -export function initGrantService(baseURL: string) { +export function initGrantService (baseURL: string) { if (axiosInstance) { return; } @@ -21,7 +21,7 @@ export function initGrantService(baseURL: string) { }); } -export async function refreshToken(refresh_token: string) { +export async function refreshToken (refresh_token: string) { const response = await axiosInstance?.post<{ access_token: string; expires_at: number; @@ -39,7 +39,7 @@ export async function refreshToken(refresh_token: string) { return newToken; } -export async function signInWithMagicLink(email: string, authUrl: string) { +export async function signInWithMagicLink (email: string, authUrl: string) { const res = await axiosInstance?.post( '/magiclink', { @@ -52,19 +52,19 @@ export async function signInWithMagicLink(email: string, authUrl: string) { headers: { Redirect_to: authUrl, }, - } + }, ); return res?.data; } -export async function settings() { +export async function settings () { const res = await axiosInstance?.get('/settings'); return res?.data; } -export function signInGoogle(authUrl: string) { +export function signInGoogle (authUrl: string) { const provider = 'google'; const redirectTo = encodeURIComponent(authUrl); const accessType = 'offline'; @@ -75,7 +75,16 @@ export function signInGoogle(authUrl: string) { window.open(url, '_current'); } -export function signInGithub(authUrl: string) { +export function signInApple (authUrl: string) { + const provider = 'apple'; + const redirectTo = encodeURIComponent(authUrl); + const baseURL = axiosInstance?.defaults.baseURL; + const url = `${baseURL}/authorize?provider=${provider}&redirect_to=${redirectTo}`; + + window.open(url, '_current'); +} + +export function signInGithub (authUrl: string) { const provider = 'github'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; @@ -84,7 +93,7 @@ export function signInGithub(authUrl: string) { window.open(url, '_current'); } -export function signInDiscord(authUrl: string) { +export function signInDiscord (authUrl: string) { const provider = 'discord'; const redirectTo = encodeURIComponent(authUrl); const baseURL = axiosInstance?.defaults.baseURL; diff --git a/frontend/appflowy_web_app/src/application/services/js-services/index.ts b/frontend/appflowy_web_app/src/application/services/js-services/index.ts index fe75849a7f..268c5170ad 100644 --- a/frontend/appflowy_web_app/src/application/services/js-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/js-services/index.ts @@ -191,6 +191,11 @@ export class AFClientService implements AFService { return APIService.signInGoogle(AUTH_CALLBACK_URL); } + @withSignIn() + async signInApple (_: { redirectTo: string }) { + return APIService.signInApple(AUTH_CALLBACK_URL); + } + @withSignIn() async signInGithub (_: { redirectTo: string }) { return APIService.signInGithub(AUTH_CALLBACK_URL); diff --git a/frontend/appflowy_web_app/src/application/services/services.type.ts b/frontend/appflowy_web_app/src/application/services/services.type.ts index bde0cc4dbe..435153e2c5 100644 --- a/frontend/appflowy_web_app/src/application/services/services.type.ts +++ b/frontend/appflowy_web_app/src/application/services/services.type.ts @@ -51,6 +51,7 @@ export interface PublishService { signInGoogle: (params: { redirectTo: string }) => Promise; signInGithub: (params: { redirectTo: string }) => Promise; signInDiscord: (params: { redirectTo: string }) => Promise; + signInApple: (params: { redirectTo: string }) => Promise; getWorkspaces: () => Promise; getWorkspaceFolder: (workspaceId: string) => Promise; diff --git a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts index cfdcda4acb..8ac0fd8844 100644 --- a/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts +++ b/frontend/appflowy_web_app/src/application/services/tauri-services/index.ts @@ -52,6 +52,10 @@ export class AFClientService implements AFService { return Promise.reject('Method not implemented'); } + signInApple (_params: { redirectTo: string }): Promise { + return Promise.reject('Method not implemented'); + } + signInMagicLink (_params: { email: string; redirectTo: string }): Promise { return Promise.reject('Method not implemented'); } diff --git a/frontend/appflowy_web_app/src/assets/arrow_right.svg b/frontend/appflowy_web_app/src/assets/arrow_right.svg index 990748cab3..268e69e559 100644 --- a/frontend/appflowy_web_app/src/assets/arrow_right.svg +++ b/frontend/appflowy_web_app/src/assets/arrow_right.svg @@ -1,5 +1,5 @@ - + diff --git a/frontend/appflowy_web_app/src/assets/close.svg b/frontend/appflowy_web_app/src/assets/close.svg index 6eb7ce67e9..c6807bc1af 100644 --- a/frontend/appflowy_web_app/src/assets/close.svg +++ b/frontend/appflowy_web_app/src/assets/close.svg @@ -1,5 +1,5 @@ - + diff --git a/frontend/appflowy_web_app/src/assets/full_view.svg b/frontend/appflowy_web_app/src/assets/full_view.svg new file mode 100644 index 0000000000..d4fe3090cb --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/full_view.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/frontend/appflowy_web_app/src/assets/login/apple.svg b/frontend/appflowy_web_app/src/assets/login/apple.svg new file mode 100644 index 0000000000..eefe3371a1 --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/login/apple.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/assets/minus.svg b/frontend/appflowy_web_app/src/assets/minus.svg new file mode 100644 index 0000000000..8be3fe893d --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/minus.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/src/assets/reload.svg b/frontend/appflowy_web_app/src/assets/reload.svg new file mode 100644 index 0000000000..c8f2dcb3bf --- /dev/null +++ b/frontend/appflowy_web_app/src/assets/reload.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/appflowy_web_app/src/components/_shared/appflowy-power/AppFlowyPower.tsx b/frontend/appflowy_web_app/src/components/_shared/appflowy-power/AppFlowyPower.tsx index 2b3698c327..2285514bc3 100644 --- a/frontend/appflowy_web_app/src/components/_shared/appflowy-power/AppFlowyPower.tsx +++ b/frontend/appflowy_web_app/src/components/_shared/appflowy-power/AppFlowyPower.tsx @@ -12,11 +12,10 @@ function AppFlowyPower ({ return (
{divider && } @@ -28,7 +27,7 @@ function AppFlowyPower ({ width, }} className={ - 'flex w-full cursor-pointer gap-2 items-center justify-center py-4 text-sm text-text-title opacity-50' + 'flex w-full cursor-pointer gap-2 items-center justify-center py-4 text-sm text-text-title opacity-50' } > Powered by diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx new file mode 100644 index 0000000000..7b6d4656a6 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/GalleryPreview.tsx @@ -0,0 +1,184 @@ +import { notify } from '@/components/_shared/notify'; +import { copyTextToClipboard } from '@/utils/copy'; +import { IconButton, Portal, Tooltip } from '@mui/material'; +import React, { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; +import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg'; +import { ReactComponent as ReloadIcon } from '@/assets/reload.svg'; +import { ReactComponent as AddIcon } from '@/assets/add.svg'; +import { ReactComponent as MinusIcon } from '@/assets/minus.svg'; +import { ReactComponent as LinkIcon } from '@/assets/link.svg'; +import { ReactComponent as DownloadIcon } from '@/assets/download.svg'; +import { ReactComponent as CloseIcon } from '@/assets/close.svg'; + +export interface GalleryImage { + src: string; + thumb: string; + responsive: string; +} + +export interface GalleryPreviewProps { + images: GalleryImage[]; + open: boolean; + onClose: () => void; + previewIndex: number; +} + +const buttonClassName = 'p-1 hover:bg-transparent text-white hover:text-content-blue-400 p-0'; + +function GalleryPreview ({ + images, + open, + onClose, + previewIndex, +}: GalleryPreviewProps) { + const { t } = useTranslation(); + const [index, setIndex] = useState(previewIndex); + const handleToPrev = useCallback(() => { + setIndex((prev) => prev === 0 ? images.length - 1 : prev - 1); + }, [images.length]); + + const handleToNext = useCallback(() => { + setIndex((prev) => prev === images.length - 1 ? 0 : prev + 1); + }, [images.length]); + + const handleCopy = useCallback(async () => { + const image = images[index]; + + if (!image) { + return; + } + + await copyTextToClipboard(image.src); + notify.success(t('publish.copy.imageBlock')); + }, [images, index, t]); + + const handleDownload = useCallback(() => { + const image = images[index]; + + if (!image) { + return; + } + + window.open(image.src, '_blank'); + }, [images, index]); + + const handleKeydown = useCallback((e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + switch (true) { + case e.key === 'ArrowLeft': + case e.key === 'ArrowUp': + handleToPrev(); + break; + case e.key === 'ArrowRight': + case e.key === 'ArrowDown': + handleToNext(); + break; + case e.key === 'Escape': + onClose(); + break; + } + }, [handleToNext, handleToPrev, onClose]); + + useEffect(() => { + (document.activeElement as HTMLElement)?.blur(); + window.addEventListener('keydown', handleKeydown); + + return () => { + window.removeEventListener('keydown', handleKeydown); + }; + }, [handleKeydown]); + + if (!open) { + return null; + } + + return ( + +
+ + + {({ zoomIn, zoomOut, resetTransform }) => ( + +
e.stopPropagation()} + > + {images.length > 1 && +
+ + + + + + {index + 1}/{images.length} + + + + + +
} +
+ + zoomIn()} className={buttonClassName}> + + + + {/**/} + + zoomOut()} className={buttonClassName}> + + + + + resetTransform()} className={buttonClassName}> + + + +
+
+ + + + + + + + + + + +
+ + + + + +
+ e.stopPropagation(), + }} wrapperStyle={{ width: '100%', height: '100%' }} + > + {images[index].src} + +
+ )} +
+
+
+ ); +} + +export default memo(GalleryPreview); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts new file mode 100644 index 0000000000..ca0290c05e --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export const GalleryPreview = lazy(() => import('./GalleryPreview')); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss new file mode 100644 index 0000000000..61d4aaa803 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/_shared/gallery-preview/preview.scss @@ -0,0 +1,13 @@ +.gallery-preview { + .lg-outer .lg-thumb-item { + @apply rounded-[8px]; + img { + @apply rounded-[6px]; + } + } + + .lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover { + border: 2px solid var(--content-blue-400); + padding: 2px; + } +} \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/Carousel.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/Carousel.tsx new file mode 100644 index 0000000000..e666747fe1 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/Carousel.tsx @@ -0,0 +1,80 @@ +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import LightGallery from 'lightgallery/react'; +import 'lightgallery/css/lightgallery.css'; +import 'lightgallery/css/lg-thumbnail.css'; +import 'lightgallery/css/lg-autoplay.css'; +import './carousel.scss'; +import lgThumbnail from 'lightgallery/plugins/thumbnail'; +import lgAutoplay from 'lightgallery/plugins/autoplay'; + +const plugins = [lgThumbnail, lgAutoplay]; + +function Carousel ({ images, onPreview, autoplay }: { + images: { + src: string; + thumb: string; + responsive: string; + }[]; + onPreview: (index: number) => void; + autoplay?: boolean; +}) { + const containerRef = useRef(null); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const instance = useRef(null); + const [rendered, setRendered] = useState(false); + + useEffect(() => { + setRendered(true); + + }, []); + + useEffect(() => { + if (!instance.current) return; + if (autoplay) { + instance.current.plugins[1].startAutoPlay(); + } else { + instance.current.plugins[1].stopAutoPlay(); + } + + }, [autoplay]); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const handleInit = useCallback((detail: any) => { + instance.current = detail.instance; + detail.instance.openGallery(Math.ceil(images.length / 2) - 1); + }, [images]); + + const handleAfterSlide = useCallback((detail: { index: number }) => { + onPreview(detail.index); + }, [onPreview]); + + const renderCarousel = useMemo(() => { + if (!containerRef.current || !rendered) return; + return ; + }, [handleAfterSlide, handleInit, images, rendered]); + + return ( +
+
+ + {renderCarousel} + +
+ ); +} + +export default memo(Carousel); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/GalleryBlock.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/GalleryBlock.tsx new file mode 100644 index 0000000000..7853821830 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/GalleryBlock.tsx @@ -0,0 +1,113 @@ +import { GalleryLayout } from '@/application/collab.type'; +import { GalleryPreview } from '@/components/_shared/gallery-preview'; +import { notify } from '@/components/_shared/notify'; +import Carousel from '@/components/editor/components/blocks/gallery/Carousel'; +import GalleryToolbar from '@/components/editor/components/blocks/gallery/GalleryToolbar'; +import ImageGallery from '@/components/editor/components/blocks/gallery/ImageGallery'; +import { EditorElementProps, GalleryBlockNode } from '@/components/editor/editor.type'; +import { copyTextToClipboard } from '@/utils/copy'; +import React, { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +const GalleryBlock = memo( + forwardRef>(({ + node, + children, + ...attributes + }, ref) => { + const { t } = useTranslation(); + const { images, layout } = useMemo(() => node.data || {}, [node.data]); + const [openPreview, setOpenPreview] = React.useState(false); + const previewIndexRef = React.useRef(0); + const [hovered, setHovered] = useState(false); + + const className = useMemo(() => { + const classList = ['gallery-block', 'relative', 'w-full', 'cursor-default', attributes.className || '']; + + return classList.join(' '); + }, [attributes.className]); + + const photos = useMemo(() => { + return images.map(image => { + const url = new URL(image.url); + + url.searchParams.set('auto', 'format'); + url.searchParams.set('fit', 'crop'); + return { + src: image.url, + thumb: url.toString() + '&w=240&q=80', + responsive: [ + url.toString() + '&w=480&q=80 480', + url.toString() + '&w=800&q=80 800', + ].join(', '), + }; + }); + }, [images]); + + const handleOpenPreview = useCallback(() => { + setOpenPreview(true); + }, []); + + const handleCopy = useCallback(async () => { + const image = photos[previewIndexRef.current]; + + if (!image) { + return; + } + + await copyTextToClipboard(image.src); + notify.success(t('publish.copy.imageBlock')); + }, [photos, t]); + + const handleDownload = useCallback(() => { + const image = photos[previewIndexRef.current]; + + if (!image) { + return; + } + + window.open(image.src, '_blank'); + }, [photos]); + + const handlePreviewIndex = useCallback((index: number) => { + previewIndexRef.current = index; + }, []); + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + > +
+ {children} +
+ {photos.length > 0 ? + (layout === GalleryLayout.Carousel ? + : + { + previewIndexRef.current = index; + handleOpenPreview(); + }} images={photos} + /> + ) : null} + {hovered && + } + + {openPreview && { + setOpenPreview(false); + }} + />} + +
+ ); + })); + +export default GalleryBlock; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/GalleryToolbar.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/GalleryToolbar.tsx new file mode 100644 index 0000000000..1ff8ddb6ab --- /dev/null +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/GalleryToolbar.tsx @@ -0,0 +1,42 @@ +import { IconButton, Tooltip } from '@mui/material'; +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ReactComponent as CopyIcon } from '@/assets/link.svg'; +import { ReactComponent as DownloadIcon } from '@/assets/download.svg'; +import { ReactComponent as PreviewIcon } from '@/assets/full_view.svg'; + +function GalleryToolbar ({ + onOpenPreview, + onDownload, + onCopy, +}: { + onOpenPreview: () => void; + onDownload: () => void; + onCopy: () => void; +}) { + const { t } = useTranslation(); + const buttons = useMemo(() => [ + { label: t('gallery.preview'), onClick: onOpenPreview, Icon: PreviewIcon }, + { label: t('gallery.copy'), onClick: onCopy, Icon: CopyIcon }, + { label: t('gallery.download'), onClick: onDownload, Icon: DownloadIcon }, + ], [t, onOpenPreview, onDownload, onCopy]); + + return ( +
+
+ {buttons.map(({ label, onClick, Icon }, index) => ( + + + + + + ))} +
+
+ ); +} + +export default GalleryToolbar; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/ImageGallery.tsx b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/ImageGallery.tsx new file mode 100644 index 0000000000..9ae8669fba --- /dev/null +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/ImageGallery.tsx @@ -0,0 +1,123 @@ +import React, { useCallback, useMemo } from 'react'; + +const GROUP_SIZE = 4; + +interface ImageType { + src: string; + thumb: string; + responsive: string; +} + +interface ImageGalleryProps { + images: ImageType[]; + onPreview: (index: number) => void; +} + +const ImageGallery: React.FC = ({ images, onPreview }) => { + const optimizeImageUrl = useCallback((src: string, width: number, height: number): string => { + const url = new URL(src); + + url.searchParams.set('auto', 'format'); + url.searchParams.set('fit', 'crop'); + url.searchParams.set('q', '80'); + return `${url.toString()}&w=${width}&h=${height}`; + }, []); + + const groupImages = useCallback((images: ImageType[], groupSize: number = GROUP_SIZE): string[][] => { + return images.reduce((acc, _, index) => { + if (index % groupSize === 0) { + acc.push(images.slice(index, index + groupSize).map(img => img.src)); + } + + return acc; + }, [] as string[][]); + }, []); + + const imageGroups = useMemo(() => groupImages(images), [images, groupImages]); + + const renderImage = useCallback((image: string, width: number, height: number, index: number) => ( + {`Image onPreview(index)} + /> + ), [optimizeImageUrl, onPreview]); + + const renderGroup = useCallback((group: string[], groupIndex: number) => { + const startIndex = groupIndex * GROUP_SIZE; + const isOdd = groupIndex % 2 !== 0; + + const renderLargeImage = (image: string, index: number) => ( +
+ {renderImage(image, 600, 800, index)} +
+ ); + + const renderSmallImages = (images: string[], startIdx: number) => ( +
+ {images.length === 2 ? ( + <> +
{images[0] && renderImage(images[0], 600, 400, startIdx)}
+
{images[1] && renderImage(images[1], 600, 400, startIdx + 1)}
+ + ) : ( + <> +
+
{images[0] && renderImage(images[0], 300, 400, startIdx)}
+
{images[1] && renderImage(images[1], 300, 400, startIdx + 1)}
+
+
{images[2] && renderImage(images[2], 600, 400, startIdx + 2)}
+ + )} +
+ ); + + if (group.length === 1) { + return
+ {renderImage(group[0], 1200, 800, startIndex)} +
; + } + + if (group.length === 2) { + return ( + <> + {renderLargeImage(group[0], startIndex)} + {renderLargeImage(group[1], startIndex + 1)} + + ); + } + + if (isOdd) { + const smallImages = group.length === 3 ? [group[0], group[2]] : [group[0], group[1], group[3]]; + const largeImage = group.length === 3 ? group[1] : group[2]; + + return ( + <> + {renderSmallImages(smallImages, startIndex)} + {largeImage && renderLargeImage(largeImage, startIndex + 2)} + + ); + } else { + return ( + <> + {renderLargeImage(group[0], startIndex)} + {renderSmallImages(group.slice(1), startIndex + 1)} + + ); + } + }, [renderImage]); + + return ( +
+ {imageGroups.map((group, groupIndex) => ( +
+ {renderGroup(group, groupIndex)} +
+ ))} +
+ ); +}; + +export default ImageGallery; \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/carousel.scss b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/carousel.scss new file mode 100644 index 0000000000..fd71ed0d94 --- /dev/null +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/carousel.scss @@ -0,0 +1,48 @@ +.images-carousel { + .carousel-container { + @apply h-[600px] max-sm:h-[360px]; + } + + .lg-thumb-item { + display: flex; + } + + .lg-toolbar { + display: none; + } + + + .lg-backdrop, .lg-outer .lg-thumb-outer { + background: var(--bg-body); + } + + .lg-outer .lg-thumb-outer { + max-height: 100px; + } + + .lg-outer.lg-animate-thumb .lg-thumb { + @apply max-sm:top-[-46px] top-[-50px]; + } + + .lg-outer .lg-thumb-item { + @apply rounded-[8px]; + img { + @apply rounded-[6px]; + } + } + + .lg-outer .lg-thumb-item.active, .lg-outer .lg-thumb-item:hover { + border: 2px solid var(--content-blue-400); + padding: 2px; + } + + + .lg-next, .lg-prev { + background: transparent; + color: var(--text-caption); + @apply max-md:hidden; + &:hover:not(.disabled) { + color: var(--text-title); + } + } +} diff --git a/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/index.ts b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/index.ts new file mode 100644 index 0000000000..a5c05a957c --- /dev/null +++ b/frontend/appflowy_web_app/src/components/editor/components/blocks/gallery/index.ts @@ -0,0 +1,3 @@ +import { lazy } from 'react'; + +export const GalleryBlock = lazy(() => import('./GalleryBlock')); \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx b/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx index 73ffd10ddb..e357b267ab 100644 --- a/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx +++ b/frontend/appflowy_web_app/src/components/editor/components/element/Element.tsx @@ -4,6 +4,7 @@ import { Callout } from '@/components/editor/components/blocks/callout'; import { CodeBlock } from '@/components/editor/components/blocks/code'; import { DatabaseBlock } from '@/components/editor/components/blocks/database'; import { DividerNode } from '@/components/editor/components/blocks/divider'; +import { GalleryBlock } from '@/components/editor/components/blocks/gallery'; import { Heading } from '@/components/editor/components/blocks/heading'; import { ImageBlock } from '@/components/editor/components/blocks/image'; import { LinkPreview } from '@/components/editor/components/blocks/link-preview'; @@ -77,6 +78,8 @@ export const Element = ({ return LinkPreview; case BlockType.FileBlock: return FileBlock; + case BlockType.GalleryBlock: + return GalleryBlock; default: return UnSupportedBlock; } diff --git a/frontend/appflowy_web_app/src/components/editor/editor.type.ts b/frontend/appflowy_web_app/src/components/editor/editor.type.ts index 95072a481e..6618e74600 100644 --- a/frontend/appflowy_web_app/src/components/editor/editor.type.ts +++ b/frontend/appflowy_web_app/src/components/editor/editor.type.ts @@ -17,7 +17,7 @@ import { BlockId, BlockData, DatabaseNodeData, - LinkPreviewBlockData, FileBlockData, + LinkPreviewBlockData, FileBlockData, GalleryBlockData, } from '@/application/collab.type'; import { HTMLAttributes } from 'react'; import { Element } from 'slate'; @@ -116,6 +116,12 @@ export interface ImageBlockNode extends BlockNode { data: ImageBlockData; } +export interface GalleryBlockNode extends BlockNode { + type: BlockType.GalleryBlock; + blockId: string; + data: GalleryBlockData; +} + export interface OutlineNode extends BlockNode { type: BlockType.OutlineBlock; blockId: string; diff --git a/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx b/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx index dc8d664a01..e623da321b 100644 --- a/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx +++ b/frontend/appflowy_web_app/src/components/error/ErrorModal.tsx @@ -4,10 +4,10 @@ import { Button } from '@mui/material'; export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => { return ( -
+
; + }, [handleClick]); return (
- {options.map((option) => ( - - ))} + {options.slice(0, 2).map(renderOption)} + + {options.slice(2).map(renderOption)}
); } diff --git a/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx b/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx index 14d6e01cdb..2f686f317c 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/BreadcrumbItem.tsx @@ -48,7 +48,12 @@ function BreadcrumbItem ({ crumb, disableClick = false }: { crumb: Crumb; disabl try { await onNavigateToView?.(viewId); } catch (e) { - notify.default(t('publish.hasNotBeenPublished')); + if (extraObj.is_space) { + notify.warning(t('publish.spaceHasNotBeenPublished')); + return; + } + + notify.warning(t('publish.hasNotBeenPublished')); } }} > diff --git a/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx index 82e28412d9..745706ed00 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/PublishViewHeader.tsx @@ -105,7 +105,7 @@ export function PublishViewHeader ({
void loadWorkspaces, loadSpaces, } = useLoadWorkspaces(); - - useEffect(() => { - if (!open) { - setSelectedWorkspaceId(workspaceList[0]?.id || ''); - setSelectedSpaceId(''); - } - }, [open, setSelectedSpaceId, setSelectedWorkspaceId, workspaceList]); - + useEffect(() => { if (open) { void loadWorkspaces(); @@ -122,7 +115,7 @@ function DuplicateModal ({ open, onClose }: { open: boolean; onClose: () => void maxWidth: 420, }, }} - okText={t('publish.openApp')} + okText={t('publish.useThisTemplate')} cancelText={t('publish.downloadIt')} onOk={() => window.open(openAppFlowySchema, '_self')} onCancel={() => { diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx index dacedf2fdc..fa3ea1c47f 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/SelectWorkspace.tsx @@ -15,7 +15,7 @@ export interface SelectWorkspaceProps { loading?: boolean; } -function stringAvatar(name: string) { +function stringAvatar (name: string) { return { sx: { bgcolor: stringToColor(name), @@ -24,7 +24,7 @@ function stringAvatar(name: string) { }; } -function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWorkspaceProps) { +function SelectWorkspace ({ loading, value, onChange, workspaceList }: SelectWorkspaceProps) { const { t } = useTranslation(); const email = useContext(AFConfigContext)?.currentUser?.email || ''; const selectedWorkspace = useMemo(() => { @@ -58,7 +58,7 @@ function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWork
); }, - [t] + [t], ); return ( @@ -120,6 +120,7 @@ function SelectWorkspace({ loading, value, onChange, workspaceList }: SelectWork onClick={() => { onChange?.(workspace.id); setSelectOpen(false); + localStorage.setItem('duplicate_selected_workspace', workspace.id); }} className={'w-full px-3 py-2'} variant={'text'} diff --git a/frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts b/frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts index b1cd74b7c8..dda3576f43 100644 --- a/frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts +++ b/frontend/appflowy_web_app/src/components/publish/header/duplicate/useDuplicate.ts @@ -49,7 +49,9 @@ export function useDuplicate () { export function useLoadWorkspaces () { const [spaceLoading, setSpaceLoading] = useState(false); const [workspaceLoading, setWorkspaceLoading] = useState(false); - const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(''); + const [selectedWorkspaceId, setSelectedWorkspaceId] = useState(() => { + return localStorage.getItem('duplicate_selected_workspace') || ''; + }); const [selectedSpaceId, setSelectedSpaceId] = useState(''); const [workspaceList, setWorkspaceList] = useState([]); @@ -65,7 +67,10 @@ export function useLoadWorkspaces () { if (workspaces) { setWorkspaceList(workspaces); - setSelectedWorkspaceId(workspaces[0].id); + setSelectedWorkspaceId(prev => { + if (!prev || !workspaces.find(item => item.id === prev)) return workspaces[0].id; + return prev; + }); } else { setWorkspaceList([]); setSelectedWorkspaceId(''); diff --git a/frontend/appflowy_web_app/src/components/publish/outline/OutlineItem.tsx b/frontend/appflowy_web_app/src/components/publish/outline/OutlineItem.tsx index 36798653b7..e6489be326 100644 --- a/frontend/appflowy_web_app/src/components/publish/outline/OutlineItem.tsx +++ b/frontend/appflowy_web_app/src/components/publish/outline/OutlineItem.tsx @@ -75,7 +75,12 @@ function OutlineItem ({ view, level = 0, width }: { view: View; width: number; l try { await navigateToView?.(view_id); } catch (e) { - notify.default(t('publish.hasNotBeenPublished')); + if (isSpace) { + notify.warning(t('publish.spaceHasNotBeenPublished')); + return; + } + + notify.warning(t('publish.hasNotBeenPublished')); } }} style={{ diff --git a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css index b131a70c53..75aa2c967f 100644 --- a/frontend/appflowy_web_app/src/styles/variables/dark.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/dark.variables.css @@ -19,7 +19,7 @@ --fill-toolbar: #0F111C; --fill-selector: #232b38; --fill-list-active: #3c4557; - --fill-list-hover: rgba(255, 255, 255, 0.1); + --fill-list-hover: #FFFFFF19; --content-blue-400: #00bcf0; --content-blue-300: #52d1f4; --content-blue-600: #009fd1; @@ -29,7 +29,7 @@ --content-blue-50: #232b38; --bg-body: #1a202c; --bg-base: #232b38; - --bg-mask: rgba(0, 0, 0, 0.7); + --bg-mask: #000000B2; --bg-tips: #005174; --bg-brand: #2c144b; --function-error: #d32772; @@ -61,6 +61,6 @@ --gradient5: linear-gradient(56.2deg, #5749CA 0%, #BB4A97 100%); --gradient6: linear-gradient(180deg, #036FFA 0%, #00B8E5 100%); --gradient7: linear-gradient(38.2deg, #F0C6CF 0%, #DECCE2 40.4754%, #CAD3F9 100%); - --header: rgba(0, 0, 0, 0.7); - --footer: rgba(0, 0, 0, 0); + --bg-header: #1a202ccc; + --bg-footer: #00000000; } \ No newline at end of file diff --git a/frontend/appflowy_web_app/src/styles/variables/light.variables.css b/frontend/appflowy_web_app/src/styles/variables/light.variables.css index b486b9794d..b486df56a8 100644 --- a/frontend/appflowy_web_app/src/styles/variables/light.variables.css +++ b/frontend/appflowy_web_app/src/styles/variables/light.variables.css @@ -20,7 +20,7 @@ --fill-hover: #52d1f4; --fill-pressed: #009fd1; --fill-active: #e0f8ff; - --fill-list-hover: rgba(31, 35, 41, 6%); + --fill-list-hover: #1F23290F; --fill-list-active: #f9fafd; --content-blue-400: #00bcf0; --content-blue-300: #52d1f4; @@ -32,7 +32,7 @@ --content-on-tag: #4f4f4f; --bg-body: #ffffff; --bg-base: #f9fafd; - --bg-mask: rgba(0, 0, 0, 0.55); + --bg-mask: #0000008C; --bg-tips: #e0f8ff; --bg-brand: #2c144b; --function-error: #fb006d; @@ -64,6 +64,6 @@ --gradient5: linear-gradient(56.2deg, #5749CA 0%, #BB4A97 100%); --gradient6: linear-gradient(180deg, #036FFA 0%, #00B8E5 100%); --gradient7: linear-gradient(38.2deg, #F0C6CF 0%, #DECCE2 40.4754%, #CAD3F9 100%); - --header: rgba(255, 255, 255, 0.8); - --footer: rgba(255, 255, 255, 0.8); + --bg-header: #FFFFFFCC; + --bg-footer: #FFFFFFCC; } \ No newline at end of file diff --git a/frontend/appflowy_web_app/tailwind/box-shadow.cjs b/frontend/appflowy_web_app/tailwind/box-shadow.cjs index d72c255227..1ab72c8764 100644 --- a/frontend/appflowy_web_app/tailwind/box-shadow.cjs +++ b/frontend/appflowy_web_app/tailwind/box-shadow.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Mon, 27 May 2024 06:26:20 GMT +* Generated on Fri, 06 Sep 2024 02:15:53 GMT * Generated from $pnpm css:variables */ diff --git a/frontend/appflowy_web_app/tailwind/colors.cjs b/frontend/appflowy_web_app/tailwind/colors.cjs index 27a3b07c30..f954297ae3 100644 --- a/frontend/appflowy_web_app/tailwind/colors.cjs +++ b/frontend/appflowy_web_app/tailwind/colors.cjs @@ -1,7 +1,7 @@ /** * Do not edit directly -* Generated on Mon, 27 May 2024 06:26:20 GMT +* Generated on Fri, 06 Sep 2024 02:15:53 GMT * Generated from $pnpm css:variables */ @@ -50,13 +50,19 @@ module.exports = { "bg": { "body": "var(--bg-body)", "base": "var(--bg-base)", + "mask": "var(--bg-mask)", "tips": "var(--bg-tips)", - "brand": "var(--bg-brand)" + "brand": "var(--bg-brand)", + "header": "var(--bg-header)", + "footer": "var(--bg-footer)" }, "function": { "error": "var(--function-error)", + "error-hover": "var(--function-error-hover)", "waring": "var(--function-waring)", + "waring-hover": "var(--function-waring-hover)", "success": "var(--function-success)", + "success-hover": "var(--function-success-hover)", "info": "var(--function-info)" }, "tint": { diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index b2ff19e50e..3a93a07584 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -2361,6 +2361,7 @@ }, "publish": { "hasNotBeenPublished": "This page hasn't been published yet", + "spaceHasNotBeenPublished": "Haven't supported publishing a space yet", "reportPage": "Report page", "databaseHasNotBeenPublished": "Publishing a database is not supported yet.", "createdWith": "Created with", @@ -2394,8 +2395,8 @@ "duplicateTitle": "Where would you like to add", "selectWorkspace": "Select a workspace", "addTo": "Add to", - "duplicateSuccessfully": "Duplicated success. Want to view documents?", - "duplicateSuccessfullyDescription": "Don't have the app? Your download will begin automatically after clicking the 'Download'.", + "duplicateSuccessfully": "Added to your workspace", + "duplicateSuccessfullyDescription": "Don't have AppFlowy installed? The download will start automatically after you click 'Download'.", "downloadIt": "Download", "openApp": "Open in app", "duplicateFailed": "Duplicated failed", @@ -2404,7 +2405,8 @@ "one": "1 member", "many": "{count} members", "other": "{count} members" - } + }, + "useThisTemplate": "Use the template" }, "web": { "continue": "Continue", @@ -2412,6 +2414,9 @@ "continueWithGoogle": "Continue with Google", "continueWithGithub": "Continue with GitHub", "continueWithDiscord": "Continue with Discord", + "continueWithApple": "Continue with Apple", + "moreOptions": "More options", + "collapse": "Collapse", "signInAgreement": "By clicking \"Continue\" above, you agreed to AppFlowy's", "and": "and", "termOfUse": "Terms", @@ -2535,5 +2540,15 @@ "uploadSuccessDescription": "The file has been uploaded successfully", "uploadFailedDescription": "The file upload failed", "uploadingDescription": "The file is being uploaded" + }, + "gallery": { + "preview": "Open in full screen", + "copy": "Copy", + "download": "Download", + "prev": "Previous", + "next": "Next", + "resetZoom": "Reset zoom", + "zoomIn": "Zoom in", + "zoomOut": "Zoom out" } }