feat: support gallery block (#6205)
@ -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",
|
||||
|
22
frontend/appflowy_web_app/pnpm-lock.yaml
generated
@ -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:
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
|
@ -51,6 +51,7 @@ export interface PublishService {
|
||||
signInGoogle: (params: { redirectTo: string }) => Promise<void>;
|
||||
signInGithub: (params: { redirectTo: string }) => Promise<void>;
|
||||
signInDiscord: (params: { redirectTo: string }) => Promise<void>;
|
||||
signInApple: (params: { redirectTo: string }) => Promise<void>;
|
||||
|
||||
getWorkspaces: () => Promise<Workspace[]>;
|
||||
getWorkspaceFolder: (workspaceId: string) => Promise<FolderView>;
|
||||
|
@ -52,6 +52,10 @@ export class AFClientService implements AFService {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
signInApple (_params: { redirectTo: string }): Promise<void> {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
||||
signInMagicLink (_params: { email: string; redirectTo: string }): Promise<void> {
|
||||
return Promise.reject('Method not implemented');
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icons/ Arrow / right" opacity="0.5">
|
||||
<g id="Icons/ Arrow / right" opacity="1">
|
||||
<path id="Vector 15" d="M4.5 9.375L7.875 6L4.5 2.625" stroke="currentColor" stroke-width="0.9"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</g>
|
||||
|
Before Width: | Height: | Size: 326 B After Width: | Height: | Size: 324 B |
@ -1,5 +1,5 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g opacity="0.7">
|
||||
<g opacity="1">
|
||||
<path d="M15.1924 15.1924C15.4853 14.8995 15.4875 14.4268 15.1946 14.1339L10.061 9.00027L15.1945 3.86672C15.4874 3.57382 15.4853 3.10107 15.1924 2.80818C14.8995 2.51529 14.4268 2.51316 14.1339 2.80606C13.841 3.09895 9.00031 7.93961 9.00031 7.93961L3.86671 2.80601C3.57382 2.51312 3.10107 2.51524 2.80817 2.80814C2.51528 3.10103 2.51316 3.57378 2.80605 3.86667L7.93965 9.00027L2.80601 14.1339C2.51312 14.4268 2.51524 14.8996 2.80814 15.1924C3.10103 15.4853 3.57378 15.4875 3.86667 15.1946L9.00031 10.0609L14.1339 15.1945C14.4268 15.4874 14.8995 15.4853 15.1924 15.1924Z"
|
||||
fill="currentColor"/>
|
||||
</g>
|
||||
|
Before Width: | Height: | Size: 747 B After Width: | Height: | Size: 745 B |
6
frontend/appflowy_web_app/src/assets/full_view.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 13H3V10" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M10 3H13V6" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M3 13L7 9" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13 3L9 7" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 449 B |
46
frontend/appflowy_web_app/src/assets/login/apple.svg
Normal file
@ -0,0 +1,46 @@
|
||||
<svg fill="#000000" height="800px" width="800px" version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 22.773 22.773" xml:space="preserve">
|
||||
<g>
|
||||
<g>
|
||||
<path d="M15.769,0c0.053,0,0.106,0,0.162,0c0.13,1.606-0.483,2.806-1.228,3.675c-0.731,0.863-1.732,1.7-3.351,1.573
|
||||
c-0.108-1.583,0.506-2.694,1.25-3.561C13.292,0.879,14.557,0.16,15.769,0z"/>
|
||||
<path d="M20.67,16.716c0,0.016,0,0.03,0,0.045c-0.455,1.378-1.104,2.559-1.896,3.655c-0.723,0.995-1.609,2.334-3.191,2.334
|
||||
c-1.367,0-2.275-0.879-3.676-0.903c-1.482-0.024-2.297,0.735-3.652,0.926c-0.155,0-0.31,0-0.462,0
|
||||
c-0.995-0.144-1.798-0.932-2.383-1.642c-1.725-2.098-3.058-4.808-3.306-8.276c0-0.34,0-0.679,0-1.019
|
||||
c0.105-2.482,1.311-4.5,2.914-5.478c0.846-0.52,2.009-0.963,3.304-0.765c0.555,0.086,1.122,0.276,1.619,0.464
|
||||
c0.471,0.181,1.06,0.502,1.618,0.485c0.378-0.011,0.754-0.208,1.135-0.347c1.116-0.403,2.21-0.865,3.652-0.648
|
||||
c1.733,0.262,2.963,1.032,3.723,2.22c-1.466,0.933-2.625,2.339-2.427,4.74C17.818,14.688,19.086,15.964,20.67,16.716z"/>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
<g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 1.3 KiB |
3
frontend/appflowy_web_app/src/assets/minus.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect x="12" y="7.5" width="1" height="8" rx="0.5" transform="rotate(90 12 7.5)" fill="#333333"/>
|
||||
</svg>
|
After Width: | Height: | Size: 201 B |
3
frontend/appflowy_web_app/src/assets/reload.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M3 8C3 5.23858 5.23858 3 8 3C10.7614 3 13 5.23858 13 8C13 10.7614 10.7614 13 8 13C6.69875 13 5.51361 12.5029 4.62408 11.6883M3 8L4.5 7M3 8L2 6.5" stroke="#333333" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
After Width: | Height: | Size: 324 B |
@ -12,11 +12,10 @@ function AppFlowyPower ({
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
backdropFilter: 'saturate(180%) blur(16px)',
|
||||
width,
|
||||
boxShadow: 'var(--footer) 0px -4px 14px 13px',
|
||||
boxShadow: 'var(--bg-footer) 0px -4px 14px 13px',
|
||||
}}
|
||||
className={'flex bg-bg-body sticky bottom-0 w-full flex-col items-center justify-center'}
|
||||
className={'flex bg-bg-body sticky bottom-[-0.5px] w-full flex-col items-center justify-center'}
|
||||
>
|
||||
{divider && <Divider className={'w-full my-0'} />}
|
||||
|
||||
@ -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
|
||||
|
@ -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 (
|
||||
<Portal container={document.body}>
|
||||
<div className={'fixed inset-0 bg-black bg-opacity-80 z-50'} onClick={onClose}>
|
||||
|
||||
<TransformWrapper
|
||||
initialScale={1}
|
||||
centerOnInit={true}
|
||||
maxScale={1.5}
|
||||
minScale={0.5}
|
||||
>
|
||||
{({ zoomIn, zoomOut, resetTransform }) => (
|
||||
<React.Fragment>
|
||||
<div className="absolute bottom-20 left-1/2 z-10 transform flex gap-4 -translate-x-1/2 p-4"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{images.length > 1 &&
|
||||
<div className={'flex gap-2 w-fit bg-bg-mask rounded-[8px] p-2'}>
|
||||
<Tooltip title={t('gallery.prev')}>
|
||||
<IconButton size={'small'} onClick={handleToPrev} className={buttonClassName}>
|
||||
<RightIcon className={'transform rotate-180'} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<span className={'text-text-caption'}>{index + 1}/{images.length}</span>
|
||||
<Tooltip title={t('gallery.next')}>
|
||||
<IconButton size={'small'} onClick={handleToNext} className={buttonClassName}>
|
||||
<RightIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>}
|
||||
<div className={'flex items-center gap-2 w-fit bg-bg-mask rounded-[8px] p-2'}>
|
||||
<Tooltip title={t('gallery.zoomIn')}>
|
||||
<IconButton size={'small'} onClick={() => zoomIn()} className={buttonClassName}>
|
||||
<AddIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
{/*<Button color={'inherit'} size={'small'}>*/}
|
||||
{/* {scale * 100}%*/}
|
||||
{/*</Button>*/}
|
||||
<Tooltip title={t('gallery.zoomOut')}>
|
||||
<IconButton size={'small'} onClick={() => zoomOut()} className={buttonClassName}>
|
||||
<MinusIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('gallery.resetZoom')}>
|
||||
<IconButton size={'small'} onClick={() => resetTransform()} className={buttonClassName}>
|
||||
<ReloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={'flex gap-2 w-fit bg-bg-mask rounded-[8px] p-2'}>
|
||||
<Tooltip title={t('gallery.copy')}>
|
||||
<IconButton size={'small'} className={buttonClassName} onClick={handleCopy}>
|
||||
<LinkIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('button.download')}>
|
||||
<IconButton size={'small'} className={buttonClassName} onClick={handleDownload}>
|
||||
<DownloadIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
</div>
|
||||
<Tooltip title={t('button.close')}>
|
||||
<IconButton
|
||||
size={'small'} onClick={onClose}
|
||||
className={'bg-bg-mask px-3.5 rounded-[8px] text-white hover:text-content-blue-400'}
|
||||
>
|
||||
<CloseIcon className={'w-3.5 h-3.5'} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<TransformComponent contentProps={{
|
||||
onClick: e => e.stopPropagation(),
|
||||
}} wrapperStyle={{ width: '100%', height: '100%' }}
|
||||
>
|
||||
<img src={images[index].src} alt={images[index].src}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</TransformWrapper>
|
||||
</div>
|
||||
</Portal>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(GalleryPreview);
|
@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const GalleryPreview = lazy(() => import('./GalleryPreview'));
|
@ -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;
|
||||
}
|
||||
}
|
@ -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<HTMLDivElement>(null);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const instance = useRef<any | null>(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 <LightGallery
|
||||
container={containerRef.current}
|
||||
onInit={handleInit}
|
||||
onAfterSlide={handleAfterSlide}
|
||||
plugins={plugins}
|
||||
dynamic={true}
|
||||
dynamicEl={images}
|
||||
autoplay={true}
|
||||
progressBar={false}
|
||||
speed={500}
|
||||
slideDelay={0}
|
||||
thumbWidth={90}
|
||||
thumbMargin={6}
|
||||
closable={false}
|
||||
/>;
|
||||
}, [handleAfterSlide, handleInit, images, rendered]);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col images-carousel'}>
|
||||
<div className={'relative carousel-container'} ref={containerRef}></div>
|
||||
|
||||
{renderCarousel}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Carousel);
|
@ -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<HTMLDivElement, EditorElementProps<GalleryBlockNode>>(({
|
||||
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 (
|
||||
<div ref={ref} {...attributes} className={className} onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className={'absolute left-0 top-0 h-full w-full pointer-events-none'}>
|
||||
{children}
|
||||
</div>
|
||||
{photos.length > 0 ?
|
||||
(layout === GalleryLayout.Carousel ?
|
||||
<Carousel
|
||||
onPreview={handlePreviewIndex}
|
||||
images={photos}
|
||||
autoplay={!openPreview}
|
||||
/> :
|
||||
<ImageGallery
|
||||
onPreview={(index) => {
|
||||
previewIndexRef.current = index;
|
||||
handleOpenPreview();
|
||||
}} images={photos}
|
||||
/>
|
||||
) : null}
|
||||
{hovered &&
|
||||
<GalleryToolbar onCopy={handleCopy} onDownload={handleDownload} onOpenPreview={handleOpenPreview} />}
|
||||
|
||||
{openPreview && <GalleryPreview
|
||||
images={photos}
|
||||
previewIndex={previewIndexRef.current}
|
||||
open={openPreview}
|
||||
onClose={() => {
|
||||
setOpenPreview(false);
|
||||
}}
|
||||
/>}
|
||||
|
||||
</div>
|
||||
);
|
||||
}));
|
||||
|
||||
export default GalleryBlock;
|
@ -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 (
|
||||
<div className={'absolute z-10 top-0 right-0'}>
|
||||
<div className={'flex space-x-1 rounded-[8px] p-1 bg-bg-body shadow border border-line-divider '}>
|
||||
{buttons.map(({ label, onClick, Icon }, index) => (
|
||||
<Tooltip title={label} key={index}>
|
||||
<IconButton
|
||||
size={'small'} onClick={onClick}
|
||||
className={'p-1 hover:bg-transparent hover:text-content-blue-400'}
|
||||
>
|
||||
<Icon className={'h-5 w-5'} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GalleryToolbar;
|
@ -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<ImageGalleryProps> = ({ 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) => (
|
||||
<img
|
||||
key={image}
|
||||
alt={`Image ${index + 1}`}
|
||||
src={optimizeImageUrl(image, width, height)}
|
||||
className="w-full h-full object-cover rounded cursor-pointer transition-transform hover:scale-105"
|
||||
onClick={() => 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) => (
|
||||
<div className="w-1/2 h-96 p-1">
|
||||
{renderImage(image, 600, 800, index)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSmallImages = (images: string[], startIdx: number) => (
|
||||
<div className="w-1/2 h-96 flex flex-col">
|
||||
{images.length === 2 ? (
|
||||
<>
|
||||
<div className="h-1/2 p-1">{images[0] && renderImage(images[0], 600, 400, startIdx)}</div>
|
||||
<div className="h-1/2 p-1">{images[1] && renderImage(images[1], 600, 400, startIdx + 1)}</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-1/2 flex">
|
||||
<div className="w-1/2 p-1">{images[0] && renderImage(images[0], 300, 400, startIdx)}</div>
|
||||
<div className="w-1/2 p-1">{images[1] && renderImage(images[1], 300, 400, startIdx + 1)}</div>
|
||||
</div>
|
||||
<div className="h-1/2 p-1">{images[2] && renderImage(images[2], 600, 400, startIdx + 2)}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (group.length === 1) {
|
||||
return <div className="w-full h-96 p-1">
|
||||
{renderImage(group[0], 1200, 800, startIndex)}
|
||||
</div>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="container mx-auto">
|
||||
{imageGroups.map((group, groupIndex) => (
|
||||
<div key={groupIndex} className="flex -mx-1">
|
||||
{renderGroup(group, groupIndex)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImageGallery;
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,3 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
export const GalleryBlock = lazy(() => import('./GalleryBlock'));
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -4,10 +4,10 @@ import { Button } from '@mui/material';
|
||||
|
||||
export const ErrorModal = ({ message, onClose }: { message: string; onClose: () => void }) => {
|
||||
return (
|
||||
<div className={'fixed inset-0 z-10 flex items-center justify-center bg-white/30 backdrop-blur-sm'}>
|
||||
<div className={'fixed inset-0 z-10 flex items-center justify-center bg-bg-mask backdrop-blur-sm'}>
|
||||
<div
|
||||
className={
|
||||
'border-shade-5 relative flex flex-col items-center gap-8 rounded-xl border bg-white px-16 py-8 shadow-md'
|
||||
'border-shade-5 relative flex flex-col items-center gap-8 rounded-xl border border-line-divider bg-bg-body px-16 py-8 shadow-md'
|
||||
}
|
||||
>
|
||||
<button
|
||||
|
@ -1,14 +1,16 @@
|
||||
import { notify } from '@/components/_shared/notify';
|
||||
import { AFConfigContext } from '@/components/app/app.hooks';
|
||||
import { Button } from '@mui/material';
|
||||
import React, { useContext, useMemo } from 'react';
|
||||
import { Button, Collapse, Divider } from '@mui/material';
|
||||
import React, { useCallback, useContext, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ReactComponent as GoogleSvg } from '@/assets/login/google.svg';
|
||||
import { ReactComponent as GithubSvg } from '@/assets/login/github.svg';
|
||||
import { ReactComponent as DiscordSvg } from '@/assets/login/discord.svg';
|
||||
import { ReactComponent as AppleSvg } from '@/assets/login/apple.svg';
|
||||
|
||||
function LoginProvider({ redirectTo }: { redirectTo: string }) {
|
||||
function LoginProvider ({ redirectTo }: { redirectTo: string }) {
|
||||
const { t } = useTranslation();
|
||||
const [expand, setExpand] = React.useState(false);
|
||||
const options = useMemo(
|
||||
() => [
|
||||
{
|
||||
@ -16,6 +18,11 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
|
||||
Icon: GoogleSvg,
|
||||
value: 'google',
|
||||
},
|
||||
{
|
||||
label: t('web.continueWithApple'),
|
||||
Icon: AppleSvg,
|
||||
value: 'apple',
|
||||
},
|
||||
{
|
||||
label: t('web.continueWithGithub'),
|
||||
value: 'github',
|
||||
@ -27,16 +34,19 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
|
||||
Icon: DiscordSvg,
|
||||
},
|
||||
],
|
||||
[t]
|
||||
[t],
|
||||
);
|
||||
const service = useContext(AFConfigContext)?.service;
|
||||
|
||||
const handleClick = async (option: string) => {
|
||||
const handleClick = useCallback(async (option: string) => {
|
||||
try {
|
||||
switch (option) {
|
||||
case 'google':
|
||||
await service?.signInGoogle({ redirectTo });
|
||||
break;
|
||||
case 'apple':
|
||||
await service?.signInApple({ redirectTo });
|
||||
break;
|
||||
case 'github':
|
||||
await service?.signInGithub({ redirectTo });
|
||||
break;
|
||||
@ -47,24 +57,38 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
|
||||
} catch (e) {
|
||||
notify.error(t('web.signInError'));
|
||||
}
|
||||
};
|
||||
}, [service, t, redirectTo]);
|
||||
|
||||
const renderOption = useCallback((option: typeof options[0]) => {
|
||||
|
||||
return <Button
|
||||
key={option.value}
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
onClick={() => handleClick(option.value)}
|
||||
className={
|
||||
`flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium max-sm:w-full ${option.value === 'apple' ? 'text-black bg-white hover:bg-fill-list-hover' : 'text-text-title'}`
|
||||
}
|
||||
>
|
||||
<option.Icon className={'h-[20px] w-[20px]'} />
|
||||
{option.label}
|
||||
</Button>;
|
||||
}, [handleClick]);
|
||||
|
||||
return (
|
||||
<div className={'flex w-full flex-col items-center justify-center gap-[10px]'}>
|
||||
{options.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
color={'inherit'}
|
||||
variant={'outlined'}
|
||||
onClick={() => handleClick(option.value)}
|
||||
className={
|
||||
'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium text-text-title max-sm:w-full'
|
||||
}
|
||||
>
|
||||
<option.Icon className={'h-[20px] w-[20px]'} />
|
||||
{option.label}
|
||||
</Button>
|
||||
))}
|
||||
{options.slice(0, 2).map(renderOption)}
|
||||
<Button
|
||||
color={'inherit'}
|
||||
size={'small'}
|
||||
onClick={() => setExpand(!expand)}
|
||||
className={'text-sm w-full flex gap-2 items-center hover:bg-transparent hover:text-text-title font-medium text-text-caption'}
|
||||
>
|
||||
<Divider className={'flex-1'} />
|
||||
{expand ? t('web.collapse') : t('web.moreOptions')}
|
||||
<Divider className={'flex-1'} />
|
||||
</Button>
|
||||
<Collapse in={expand}>{options.slice(2).map(renderOption)}</Collapse>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
@ -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'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
@ -105,7 +105,7 @@ export function PublishViewHeader ({
|
||||
<div
|
||||
style={{
|
||||
backdropFilter: 'saturate(180%) blur(16px)',
|
||||
background: 'var(--header)',
|
||||
background: 'var(--bg-header)',
|
||||
height: HEADER_HEIGHT,
|
||||
}}
|
||||
className={'appflowy-top-bar sticky top-0 z-10 flex px-5'}
|
||||
|
@ -43,14 +43,7 @@ function DuplicateModal ({ open, onClose }: { open: boolean; onClose: () => 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={() => {
|
||||
|
@ -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
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[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'}
|
||||
|
@ -49,7 +49,9 @@ export function useDuplicate () {
|
||||
export function useLoadWorkspaces () {
|
||||
const [spaceLoading, setSpaceLoading] = useState<boolean>(false);
|
||||
const [workspaceLoading, setWorkspaceLoading] = useState<boolean>(false);
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>('');
|
||||
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>(() => {
|
||||
return localStorage.getItem('duplicate_selected_workspace') || '';
|
||||
});
|
||||
const [selectedSpaceId, setSelectedSpaceId] = useState<string>('');
|
||||
|
||||
const [workspaceList, setWorkspaceList] = useState<Workspace[]>([]);
|
||||
@ -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('');
|
||||
|
@ -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={{
|
||||
|
@ -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;
|
||||
}
|
@ -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;
|
||||
}
|
@ -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
|
||||
*/
|
||||
|
||||
|
@ -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": {
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|