feat: support duplicate UI on web

This commit is contained in:
qinluhe 2024-07-24 10:22:35 +08:00
parent 6334255e15
commit 6fde3b9e3e
20 changed files with 614 additions and 36 deletions

View File

@ -28,7 +28,6 @@ export function withSignIn() {
saveRedirectTo(redirectTo);
console.log('=====saveRedirectTo', redirectTo);
try {
await originalMethod.apply(this, [args]);
} catch (e) {

View File

@ -0,0 +1,12 @@
export interface Workspace {
icon: string;
id: string;
name: string;
memberCount: number;
}
export interface SpaceView {
id: string;
extra?: string;
name: string;
}

View File

@ -0,0 +1,4 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="10.4751" y="4.81812" width="1" height="8" rx="0.5" transform="rotate(45 10.4751 4.81812)" fill="#333333"/>
<rect x="11.1821" y="10.4749" width="1" height="8" rx="0.5" transform="rotate(135 11.1821 10.4749)" fill="#333333"/>
</svg>

After

Width:  |  Height:  |  Size: 343 B

View 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="M5 8.2L6.84615 10L13 4" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

View File

@ -0,0 +1,56 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Dialog, DialogProps, IconButton } from '@mui/material';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
export interface NormalModalProps extends DialogProps {
okText?: string;
cancelText?: string;
onOk?: () => void;
onCancel?: () => void;
danger?: boolean;
title?: string;
}
export function NormalModal({
okText,
title,
cancelText,
onOk,
onCancel,
danger,
children,
...dialogProps
}: NormalModalProps) {
const { t } = useTranslation();
const modalOkText = okText || t('button.ok');
const modalCancelText = cancelText || t('button.cancel');
const buttonColor = danger ? 'var(--function-error)' : undefined;
return (
<Dialog {...dialogProps}>
<div className={'relative flex flex-col gap-4 p-5'}>
<div className={'flex w-full items-center justify-between text-base font-medium'}>
<div className={'flex-1 text-center '}>{title}</div>
<div className={'relative -right-1.5'}>
<IconButton size={'small'} color={'inherit'} className={'h-8 w-8'} onClick={onCancel}>
<CloseIcon className={'h-8 w-8'} />
</IconButton>
</div>
</div>
<div className={'flex-1'}>{children}</div>
<div className={'flex w-full justify-end gap-4'}>
<Button color={'inherit'} variant={'outlined'} onClick={onCancel}>
{modalCancelText}
</Button>
<Button color={'primary'} variant={'contained'} style={{ backgroundColor: buttonColor }} onClick={onOk}>
{modalOkText}
</Button>
</div>
</div>
</Dialog>
);
}
export default NormalModal;

View File

@ -0,0 +1 @@
export * from './NormalModal';

View File

@ -46,15 +46,29 @@ function AppTheme({ children }: { children: React.ReactNode }) {
},
MuiButton: {
styleOverrides: {
text: {
borderRadius: '8px',
'&:hover': {
backgroundColor: 'var(--fill-list-hover)',
},
},
contained: {
color: 'var(--content-on-fill)',
boxShadow: 'none',
'&:hover': {
backgroundColor: 'var(--content-blue-600)',
},
borderRadius: '8px',
},
outlined: {
'&.MuiButton-outlinedInherit': {
borderColor: 'var(--line-divider)',
},
borderRadius: '8px',
},
},
},
MuiButtonBase: {
styleOverrides: {
root: {
@ -78,10 +92,16 @@ function AppTheme({ children }: { children: React.ReactNode }) {
root: {
backgroundImage: 'none',
boxShadow: 'var(--shadow)',
borderRadius: '10px',
},
},
},
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: '12px',
},
},
defaultProps: {
sx: {
'& .MuiBackdrop-root': {

View File

@ -1,23 +1,13 @@
import { AFConfigContext } from '@/components/app/AppConfig';
import LoginProvider from '@/components/login/LoginProvider';
import MagicLink from '@/components/login/MagicLink';
import { Divider } from '@mui/material';
import React, { useContext, useEffect } from 'react';
import React from 'react';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { useTranslation } from 'react-i18next';
import { useSearchParams } from 'react-router-dom';
export function Login() {
export function Login({ redirectTo }: { redirectTo: string }) {
const { t } = useTranslation();
const [search] = useSearchParams();
const redirectTo = search.get('redirectTo') || '';
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
useEffect(() => {
if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = redirectTo;
}
}, [isAuthenticated, redirectTo]);
return (
<div className={'my-10 flex flex-col items-center justify-center gap-[24px] px-4'}>
<div className={'flex flex-col items-center justify-center gap-[14px]'}>

View File

@ -0,0 +1,19 @@
import React from 'react';
import { Dialog, IconButton } from '@mui/material';
import { Login } from './Login';
import { ReactComponent as CloseIcon } from '@/assets/close.svg';
export function LoginModal({ redirectTo, open, onClose }: { redirectTo: string; open: boolean; onClose: () => void }) {
return (
<Dialog open={open} onClose={onClose}>
<div className={'relative px-6'}>
<Login redirectTo={redirectTo} />
<div className={'absolute top-2 right-2'}>
<IconButton color={'inherit'} onClick={onClose}>
<CloseIcon className={'h-8 w-8'} />
</IconButton>
</div>
</div>
</Dialog>
);
}

View File

@ -58,7 +58,7 @@ function LoginProvider({ redirectTo }: { redirectTo: string }) {
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'
'flex h-[46px] w-[380px] items-center justify-center gap-[10px] rounded-[12px] border border-line-divider text-sm font-medium text-text-title'
}
>
<option.Icon className={'h-[20px] w-[20px]'} />

View File

@ -1 +1,2 @@
export * from './Login';
export * from './LoginModal';

View File

@ -1,20 +1,20 @@
// import { invalidToken } from '@/application/session/token';
import { invalidToken } from '@/application/session/token';
import { Popover } from '@/components/_shared/popover';
// import { AFConfigContext } from '@/components/app/AppConfig';
import { AFConfigContext } from '@/components/app/AppConfig';
import { ThemeModeContext } from '@/components/app/useAppThemeMode';
import { openUrl } from '@/utils/url';
import { IconButton } from '@mui/material';
import React, { useContext, useMemo } from 'react';
import React, { useCallback, useContext, useMemo } from 'react';
import { ReactComponent as MoreIcon } from '@/assets/more.svg';
import { ReactComponent as MoonIcon } from '@/assets/moon.svg';
import { ReactComponent as SunIcon } from '@/assets/sun.svg';
// import { ReactComponent as LoginIcon } from '@/assets/login.svg';
import { ReactComponent as LoginIcon } from '@/assets/login.svg';
import { ReactComponent as ReportIcon } from '@/assets/report.svg';
import { useTranslation } from 'react-i18next';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import { ReactComponent as AppflowyLogo } from '@/assets/appflowy.svg';
// import { useNavigate } from 'react-router-dom';
import { useNavigate } from 'react-router-dom';
function MoreActions() {
const { isDark, setDark } = useContext(ThemeModeContext) || {};
@ -31,21 +31,21 @@ function MoreActions() {
const { t } = useTranslation();
// const navigate = useNavigate();
const navigate = useNavigate();
// const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
//
// const handleLogin = useCallback(() => {
// invalidToken();
// navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
// }, [navigate]);
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
const handleLogin = useCallback(() => {
invalidToken();
navigate('/login?redirectTo=' + encodeURIComponent(window.location.href));
}, [navigate]);
const actions = useMemo(() => {
return [
// {
// Icon: LoginIcon,
// label: isAuthenticated ? t('button.logout') : t('web.login'),
// onClick: handleLogin,
// },
{
Icon: LoginIcon,
label: isAuthenticated ? t('button.logout') : t('web.login'),
onClick: handleLogin,
},
isDark
? {
Icon: SunIcon,
@ -69,7 +69,7 @@ function MoreActions() {
},
},
];
}, [t, isDark, setDark]);
}, [isAuthenticated, t, handleLogin, isDark, setDark]);
return (
<>

View File

@ -9,6 +9,7 @@ import Breadcrumb from './Breadcrumb';
import { ReactComponent as Logo } from '@/assets/logo.svg';
import MoreActions from './MoreActions';
import { ReactComponent as SideOutlined } from '@/assets/side_outlined.svg';
import Duplicate from './duplicate/Duplicate';
export const HEADER_HEIGHT = 48;
@ -92,6 +93,7 @@ export function PublishViewHeader({ onOpenDrawer, openDrawer }: { onOpenDrawer:
<div className={'flex items-center gap-2'}>
<MoreActions />
<Duplicate />
<Divider orientation={'vertical'} className={'mx-2'} flexItem />
<Tooltip title={t('publish.downloadApp')}>
<button onClick={openOrDownload}>

View File

@ -0,0 +1,31 @@
import React, { useCallback } from 'react';
import { Button } from '@mui/material';
import { useTranslation } from 'react-i18next';
import { LoginModal } from '@/components/login/LoginModal';
import { useSearchParams } from 'react-router-dom';
import { useDuplicate } from '@/components/publish/header/duplicate/useDuplicate';
import DuplicateModal from '@/components/publish/header/duplicate/DuplicateModal';
function Duplicate() {
const { t } = useTranslation();
const { loginOpen, duplicateOpen, handleDuplicateClose, handleLoginClose, url } = useDuplicate();
const [search, setSearch] = useSearchParams();
const handleClick = useCallback(() => {
setSearch({
...search,
action: 'duplicate',
});
}, [search, setSearch]);
return (
<>
<Button onClick={handleClick} size={'small'} variant={'outlined'} color={'inherit'}>
{t('button.duplicate')}
</Button>
<LoginModal redirectTo={url} open={loginOpen} onClose={handleLoginClose} />
<DuplicateModal open={duplicateOpen} onClose={handleDuplicateClose} />
</>
);
}
export default Duplicate;

View File

@ -0,0 +1,36 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { NormalModal } from '@/components/_shared/modal';
import SelectWorkspace from '@/components/publish/header/duplicate/SelectWorkspace';
import { useLoadWorkspaces } from '@/components/publish/header/duplicate/useDuplicate';
import SpaceList from '@/components/publish/header/duplicate/SpaceList';
import { notify } from '@/components/_shared/notify';
function DuplicateModal({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useTranslation();
const { workspaceList, spaceList, setSelectedSpaceId, setSelectedWorkspaceId, selectedWorkspaceId, selectedSpaceId } =
useLoadWorkspaces();
return (
<NormalModal
onCancel={onClose}
okText={t('button.add')}
title={t('publish.duplicateTitle')}
open={open}
onClose={onClose}
onOk={async () => {
// submit form
notify.success(t('publish.duplicateSuccessfully'));
onClose();
}}
>
<div className={'flex flex-col gap-4'}>
<SelectWorkspace workspaceList={workspaceList} value={selectedWorkspaceId} onChange={setSelectedWorkspaceId} />
<SpaceList spaceList={spaceList} value={selectedSpaceId} onChange={setSelectedSpaceId} />
</div>
</NormalModal>
);
}
export default DuplicateModal;

View File

@ -0,0 +1,112 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Divider, IconButton, Tooltip } from '@mui/material';
import { Workspace } from '@/application/types';
import { ReactComponent as RightIcon } from '@/assets/arrow_right.svg';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { Popover } from '@/components/_shared/popover';
export interface SelectWorkspaceProps {
value: string;
onChange?: (value: string) => void;
workspaceList: Workspace[];
}
function SelectWorkspace({ value, onChange, workspaceList }: SelectWorkspaceProps) {
const { t } = useTranslation();
const email = 'lu@appflowy.io';
const selectedWorkspace = useMemo(() => {
return workspaceList.find((workspace) => workspace.id === value);
}, [value, workspaceList]);
const ref = useRef<HTMLButtonElement | null>(null);
const [selectOpen, setSelectOpen] = useState<boolean>(false);
const renderWorkspace = useCallback(
(workspace: Workspace) => {
return (
<div className={'flex items-center gap-[10px] overflow-hidden'}>
<div className={'h-8 w-8 text-2xl'}>{workspace.icon}</div>
<div className={'flex flex-1 flex-col items-start gap-0.5 overflow-hidden'}>
<div className={'w-full truncate text-left text-sm font-medium'}>{workspace.name}</div>
<div className={'text-xs text-text-caption'}>
{t('publish.membersCount', {
count: workspace.memberCount || 0,
})}
</div>
</div>
</div>
);
},
[t]
);
return (
<div className={'flex w-[360px] flex-col gap-2'}>
<div className={'text-sm text-text-caption'}>{t('publish.selectWorkspace')}</div>
<Button
ref={ref}
onClick={() => {
setSelectOpen(true);
}}
className={'px-3 py-2'}
variant={'outlined'}
color={'inherit'}
>
<div className={'flex w-full items-center gap-[10px]'}>
<div className={'flex-1 overflow-hidden'}>{selectedWorkspace ? renderWorkspace(selectedWorkspace) : null}</div>
<IconButton size={'small'} className={`h-6 w-6 ${selectOpen ? '-rotate-90' : 'rotate-90'} transform`}>
<RightIcon className={'h-6 w-6'} />
</IconButton>
</div>
</Button>
<Popover
anchorEl={ref.current}
open={selectOpen}
transformOrigin={{
vertical: -8,
horizontal: 'left',
}}
onClose={() => {
setSelectOpen(false);
}}
>
<div className={'flex max-h-[360px] w-[360px] flex-col gap-1 p-2'}>
<div className={'w-full px-3 py-2 text-sm font-medium text-text-caption'}>{email}</div>
<Divider />
<div className={'flex flex-1 flex-col overflow-y-auto overflow-x-hidden'}>
{workspaceList.map((workspace) => {
const isSelected = workspace.id === selectedWorkspace?.id;
return (
<Tooltip
title={workspace.name}
key={workspace.id}
placement={'bottom'}
enterDelay={1000}
enterNextDelay={1000}
>
<Button
onClick={() => {
onChange?.(workspace.id);
setSelectOpen(false);
}}
className={'w-full px-3 py-2'}
variant={'text'}
color={'inherit'}
>
<div className={'flex-1 overflow-hidden'}>{renderWorkspace(workspace)}</div>
<div className={'h-6 w-6'}>
{isSelected && <CheckIcon className={'h-6 w-6 text-content-blue-400'} />}
</div>
</Button>
</Tooltip>
);
})}
</div>
</div>
</Popover>
</div>
);
}
export default SelectWorkspace;

View File

@ -0,0 +1,84 @@
import React, { useCallback } from 'react';
import { SpaceView } from '@/application/types';
import { useTranslation } from 'react-i18next';
import { ReactComponent as CheckIcon } from '@/assets/selected.svg';
import { renderColor } from '@/utils/color';
import SpaceIcon from '@/components/publish/header/SpaceIcon';
import { Button, Tooltip } from '@mui/material';
export interface SpaceListProps {
value: string;
onChange?: (value: string) => void;
spaceList: SpaceView[];
}
function SpaceList({ spaceList, value, onChange }: SpaceListProps) {
const { t } = useTranslation();
const getExtraObj = useCallback((extra: string) => {
try {
return extra
? (JSON.parse(extra) as {
is_space?: boolean;
space_icon?: string;
space_icon_color?: string;
})
: {};
} catch (e) {
return {};
}
}, []);
const renderSpace = useCallback(
(space: SpaceView) => {
const extraObj = getExtraObj(space.extra || '');
return (
<div className={'flex items-center gap-[10px] overflow-hidden text-sm'}>
<span
className={'icon h-5 w-5'}
style={{
backgroundColor: extraObj.space_icon_color ? renderColor(extraObj.space_icon_color) : undefined,
borderRadius: '8px',
}}
>
<SpaceIcon value={extraObj.space_icon || ''} />
</span>
<div className={'flex-1 truncate'}>{space.name}</div>
</div>
);
},
[getExtraObj]
);
return (
<div className={'flex max-h-[260px] w-[360px] flex-col gap-2 overflow-hidden'}>
<div className={'text-sm text-text-caption'}>{t('publish.addTo')}</div>
<div className={'flex w-full flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden'}>
{spaceList.map((space) => {
const isSelected = value === space.id;
return (
<Tooltip title={space.name} key={space.id} placement={'bottom'} enterDelay={1000} enterNextDelay={1000}>
<Button
variant={'text'}
color={'inherit'}
className={'flex items-center p-1 font-normal'}
onClick={() => {
onChange?.(space.id);
}}
>
<div className={'flex-1 overflow-hidden text-left'}>{renderSpace(space)}</div>
<div className={'h-6 w-6'}>
{isSelected && <CheckIcon className={'h-6 w-6 text-content-blue-400'} />}
</div>
</Button>
</Tooltip>
);
})}
</div>
</div>
);
}
export default SpaceList;

View File

@ -0,0 +1,183 @@
import React, { useCallback, useContext, useEffect, useState } from 'react';
import { AFConfigContext } from '@/components/app/AppConfig';
import { useSearchParams } from 'react-router-dom';
import { SpaceView, Workspace } from '@/application/types';
export function useDuplicate() {
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
const [search, setSearch] = useSearchParams();
const [loginOpen, setLoginOpen] = React.useState(false);
const [duplicateOpen, setDuplicateOpen] = React.useState(false);
useEffect(() => {
const isDuplicate = search.get('action') === 'duplicate';
if (!isDuplicate) return;
setLoginOpen(!isAuthenticated);
setDuplicateOpen(isAuthenticated);
}, [isAuthenticated, search, setSearch]);
const url = window.location.href;
const handleLoginClose = useCallback(() => {
setLoginOpen(false);
setSearch((prev) => {
prev.delete('action');
return prev;
});
}, [setSearch]);
const handleDuplicateClose = useCallback(() => {
setDuplicateOpen(false);
setSearch((prev) => {
prev.delete('action');
return prev;
});
}, [setSearch]);
return {
loginOpen,
handleLoginClose,
url,
duplicateOpen,
handleDuplicateClose,
};
}
export function useLoadWorkspaces() {
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string>('1');
const [selectedSpaceId, setSelectedSpaceId] = useState<string>('1');
const [workspaceList] = useState<Workspace[]>([
{
icon: '😊',
id: '1',
name: 'AppFlowy',
memberCount: 0,
},
{
icon: '😍',
id: '2',
name: `Kilu's Workspace`,
memberCount: 12,
},
{
icon: '😎',
id: '3',
name: 'Workspace 3 djskh dhjsa dhsjkahdkja dshjkahd kashdjkashd askhdkjas',
memberCount: 5,
},
{
icon: '😇',
id: '4',
name: 'Workspace 4',
memberCount: 3,
},
{
icon: '😜',
id: '5',
name: 'Workspace 5',
memberCount: 1,
},
{
icon: '😉',
id: '6',
name: 'Workspace 6',
memberCount: 0,
},
]);
const [spaceList] = useState<SpaceView[]>([
{
id: '1',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_1',
space_icon_color: 'appflowy_them_color_tint2',
}),
name: 'Space 1',
},
{
id: '2',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_2',
space_icon_color: 'appflowy_them_color_tint1',
}),
name: 'Space 2 djisa djiso adiohjsa hdiosahdk ksahkjdhskjahdaskj',
},
{
id: '3',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_3',
space_icon_color: 'appflowy_them_color_tint3',
}),
name: 'Space 3',
},
{
id: '4',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_4',
space_icon_color: 'appflowy_them_color_tint4',
}),
name: 'Space 4',
},
{
id: '5',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_5',
space_icon_color: 'appflowy_them_color_tint5',
}),
name: 'Space 5',
},
{
id: '6',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_6',
space_icon_color: 'appflowy_them_color_tint6',
}),
name: 'Space 6',
},
{
id: '7',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_7',
space_icon_color: 'appflowy_them_color_tint7',
}),
name: 'Space 7',
},
{
id: '8',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_8',
space_icon_color: 'appflowy_them_color_tint8',
}),
name: 'Space 8',
},
{
id: '9',
extra: JSON.stringify({
is_space: true,
space_icon: 'space_icon_9',
space_icon_color: 'appflowy_them_color_tint9',
}),
name: 'Space',
},
]);
return {
workspaceList,
spaceList,
selectedWorkspaceId,
setSelectedWorkspaceId,
selectedSpaceId,
setSelectedSpaceId,
};
}

View File

@ -1,10 +1,21 @@
import { Login } from '@/components/login';
import React from 'react';
import React, { useContext, useEffect } from 'react';
import { useSearchParams } from 'react-router-dom';
import { AFConfigContext } from '@/components/app/AppConfig';
function LoginPage() {
const [search] = useSearchParams();
const redirectTo = search.get('redirectTo') || '';
const isAuthenticated = useContext(AFConfigContext)?.isAuthenticated || false;
useEffect(() => {
if (isAuthenticated && redirectTo && encodeURIComponent(redirectTo) !== window.location.href) {
window.location.href = redirectTo;
}
}, [isAuthenticated, redirectTo]);
return (
<div className={'bg-body flex h-screen w-screen items-center justify-center'}>
<Login />
<Login redirectTo={redirectTo} />
</div>
);
}

View File

@ -2130,11 +2130,25 @@
"zero": "Publish {} selected view",
"one": "Publish {} selected views",
"many": "Publish {} selected views",
"other":"Publish {} selected views"
"other": "Publish {} selected views"
},
"mustSelectPrimaryDatabase": "The primary view must be selected",
"noDatabaseSelected": "No database selected, please select at least one database.",
"unableToDeselectPrimaryDatabase": "Unable to deselect primary database"
"unableToDeselectPrimaryDatabase": "Unable to deselect primary database",
"duplicateTitle": "Where would you like to add",
"selectWorkspace": "Select a workspace",
"addTo": "Add to",
"duplicateSuccessfully": "Duplicated success",
"duplicateSuccessfullyDescription": "Open in AppFlowy's desktop app? If don't have the app, you can",
"downloadIt": "download it here",
"openApp": "Open in app",
"duplicateFailed": "Duplicated failed",
"membersCount": {
"zero": "No members",
"one": "1 member",
"many": "{count} members",
"other": "{count} members"
}
},
"web": {
"continue": "Continue",