feat!: affine cloud support (#3813)

Co-authored-by: Hongtao Lye <codert.sn@gmail.com>
Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: X1a0t <405028157@qq.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
Alex Yang
2023-08-29 05:07:05 -05:00
committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
414 changed files with 19469 additions and 7591 deletions
@@ -0,0 +1,13 @@
import { assertExists } from '@blocksuite/global/utils';
import type { FC, PropsWithChildren } from 'react';
import { WorkspaceAdapters } from '../adapters/workspace';
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
export const AdapterProviderWrapper: FC<PropsWithChildren> = ({ children }) => {
const [currentWorkspace] = useCurrentWorkspace();
const Provider = WorkspaceAdapters[currentWorkspace.flavour].UI.Provider;
assertExists(Provider);
return <Provider>{children}</Provider>;
};
@@ -0,0 +1,11 @@
import type { ReactElement } from 'react';
import type { FallbackProps } from 'react-error-boundary';
export const AnyErrorBoundary = (props: FallbackProps): ReactElement => {
return (
<div>
<p>Something went wrong:</p>
<p>{props.error.toString()}</p>
</div>
);
};
@@ -0,0 +1,67 @@
import {
AuthContent,
BackButton,
ModalHeader,
ResendButton,
} from '@affine/component/auth-components';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { signIn } from 'next-auth/react';
import { type FC, useCallback } from 'react';
import { buildCallbackUrl } from './callback-url';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
export const AfterSignInSendEmail: FC<AuthPanelProps> = ({
setAuthState,
email,
}) => {
const t = useAFFiNEI18N();
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['com.affine.auth.sign.in.sent.email.subtitle']()}
/>
<AuthContent style={{ height: 162 }}>
{t['com.affine.auth.sign.sent.email.message.start']()}
<a href={`mailto:${email}`}>{email}</a>
{t['com.affine.auth.sign.sent.email.message.end']()}
</AuthContent>
<ResendButton
onClick={useCallback(() => {
signIn('email', {
email,
callbackUrl: buildCallbackUrl('signIn'),
redirect: true,
}).catch(console.error);
}, [email])}
/>
<div className={style.authMessage} style={{ marginTop: 20 }}>
{/*prettier-ignore*/}
<Trans i18nKey="com.affine.auth.sign.auth.code.message.password">
If you haven&apos;t received the email, please check your spam folder.
Or <span
className="link"
data-testid='sign-in-with-password'
onClick={useCallback(() => {
setAuthState('signInWithPassword');
}, [setAuthState])}
>
sign in with password
</span> instead.
</Trans>
</div>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};
@@ -0,0 +1,54 @@
import {
AuthContent,
BackButton,
ModalHeader,
ResendButton,
} from '@affine/component/auth-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { signIn } from 'next-auth/react';
import { type FC, useCallback } from 'react';
import { buildCallbackUrl } from './callback-url';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
setAuthState,
email,
}) => {
const t = useAFFiNEI18N();
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.up']()}
subTitle={t['com.affine.auth.sign.up.sent.email.subtitle']()}
/>
<AuthContent style={{ height: 162 }}>
{t['com.affine.auth.sign.sent.email.message.start']()}
<a href={`mailto:${email}`}>{email}</a>
{t['com.affine.auth.sign.sent.email.message.end']()}
</AuthContent>
<ResendButton
onClick={useCallback(() => {
signIn('email', {
email: email,
callbackUrl: buildCallbackUrl('signUp'),
redirect: true,
}).catch(console.error);
}, [email])}
/>
<div className={style.authMessage} style={{ marginTop: 20 }}>
{t['com.affine.auth.sign.auth.code.message']()}
</div>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};
@@ -0,0 +1,16 @@
import { isDesktop } from '@affine/env/constant';
type Action = 'signUp' | 'changePassword' | 'signIn' | 'signUp';
export function buildCallbackUrl(action: Action) {
const callbackUrl = `/auth/${action}`;
const params: string[][] = [];
if (isDesktop && window.appInfo.schema) {
params.push(['schema', window.appInfo.schema]);
}
const query =
params.length > 0
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
: '';
return callbackUrl + query;
}
@@ -0,0 +1,155 @@
import {
AuthModal as AuthModalBase,
type AuthModalProps as AuthModalBaseProps,
} from '@affine/component/auth-components';
import { isDesktop } from '@affine/env/constant';
import { atom, useAtom } from 'jotai';
import { type FC, useCallback, useEffect, useMemo } from 'react';
import { AfterSignInSendEmail } from './after-sign-in-send-email';
import { AfterSignUpSendEmail } from './after-sign-up-send-email';
import { SendEmail } from './send-email';
import { SignIn } from './sign-in';
import { SignInWithPassword } from './sign-in-with-password';
export type AuthProps = {
state:
| 'signIn'
| 'afterSignUpSendEmail'
| 'afterSignInSendEmail'
// throw away
| 'signInWithPassword'
| 'sendEmail';
setAuthState: (state: AuthProps['state']) => void;
setAuthEmail: (state: AuthProps['email']) => void;
setEmailType: (state: AuthProps['emailType']) => void;
email: string;
emailType: 'setPassword' | 'changePassword' | 'changeEmail';
onSignedIn?: () => void;
};
export type AuthPanelProps = {
email: string;
setAuthState: AuthProps['setAuthState'];
setAuthEmail: AuthProps['setAuthEmail'];
setEmailType: AuthProps['setEmailType'];
emailType: AuthProps['emailType'];
onSignedIn?: () => void;
authStore: AuthStoreAtom;
setAuthStore: (data: Partial<AuthStoreAtom>) => void;
};
const config: {
[k in AuthProps['state']]: FC<AuthPanelProps>;
} = {
signIn: SignIn,
afterSignUpSendEmail: AfterSignUpSendEmail,
afterSignInSendEmail: AfterSignInSendEmail,
signInWithPassword: SignInWithPassword,
sendEmail: SendEmail,
};
type AuthStoreAtom = {
hasSentEmail: boolean;
resendCountDown: number;
};
export const authStoreAtom = atom<AuthStoreAtom>({
hasSentEmail: false,
resendCountDown: 60,
});
export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
open,
state,
setOpen,
email,
setAuthEmail,
setAuthState,
setEmailType,
emailType,
}) => {
const [, setAuthStore] = useAtom(authStoreAtom);
useEffect(() => {
if (!open) {
setAuthStore({
hasSentEmail: false,
resendCountDown: 60,
});
setAuthEmail('');
}
}, [open, setAuthEmail, setAuthStore]);
useEffect(() => {
if (isDesktop) {
return window.events?.ui.onFinishLogin(() => {
setOpen(false);
});
}
return;
}, [setOpen]);
const onSignedIn = useCallback(() => {
setOpen(false);
}, [setOpen]);
return (
<AuthModalBase open={open} setOpen={setOpen}>
<AuthPanel
state={state}
email={email}
setAuthEmail={setAuthEmail}
setAuthState={setAuthState}
setEmailType={setEmailType}
emailType={emailType}
onSignedIn={onSignedIn}
/>
</AuthModalBase>
);
};
export const AuthPanel: FC<AuthProps> = ({
state,
email,
setAuthEmail,
setAuthState,
setEmailType,
emailType,
onSignedIn,
}) => {
const [authStore, setAuthStore] = useAtom(authStoreAtom);
const CurrentPanel = useMemo(() => {
return config[state];
}, [state]);
useEffect(() => {
return () => {
setAuthStore({
hasSentEmail: false,
resendCountDown: 60,
});
};
}, [setAuthEmail, setAuthStore]);
return (
<CurrentPanel
email={email}
setAuthState={setAuthState}
setAuthEmail={setAuthEmail}
setEmailType={setEmailType}
authStore={authStore}
emailType={emailType}
onSignedIn={onSignedIn}
setAuthStore={useCallback(
(data: Partial<AuthStoreAtom>) => {
setAuthStore(prev => ({
...prev,
...data,
}));
},
[setAuthStore]
)}
/>
);
};
@@ -0,0 +1,185 @@
import { Wrapper } from '@affine/component';
import {
AuthContent,
AuthInput,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { isDesktop } from '@affine/env/constant';
import {
sendChangeEmailMutation,
sendChangePasswordEmailMutation,
sendSetPasswordEmailMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai/react';
import { type FC, useCallback } from 'react';
import type { AuthPanelProps } from './index';
const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {
const t = useAFFiNEI18N();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.set.password']();
case 'changePassword':
return t['com.affine.auth.reset.password']();
case 'changeEmail':
return t['com.affine.settings.email.action']();
}
};
const useNotificationHint = (emailType: AuthPanelProps['emailType']) => {
const t = useAFFiNEI18N();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.sent.set.password.hint']();
case 'changePassword':
return t['com.affine.auth.sent.change.password.hint']();
case 'changeEmail':
return t['com.affine.auth.sent.change.email.hint']();
}
};
const useButtonContent = (emailType: AuthPanelProps['emailType']) => {
const t = useAFFiNEI18N();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.send.set.password.link']();
case 'changePassword':
return t['com.affine.auth.send.reset.password.link']();
case 'changeEmail':
return t['com.affine.auth.send.change.email.link']();
}
};
const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
const {
trigger: sendChangePasswordEmail,
isMutating: isChangePasswordMutating,
} = useMutation({
mutation: sendChangePasswordEmailMutation,
});
const { trigger: sendSetPasswordEmail, isMutating: isSetPasswordMutating } =
useMutation({
mutation: sendSetPasswordEmailMutation,
});
const { trigger: sendChangeEmail, isMutating: isChangeEmailMutating } =
useMutation({
mutation: sendChangeEmailMutation,
});
return {
loading:
isChangePasswordMutating ||
isSetPasswordMutating ||
isChangeEmailMutating,
sendEmail: useCallback(
(email: string) => {
let trigger: (args: {
email: string;
callbackUrl: string;
}) => Promise<unknown>;
let callbackUrl;
switch (emailType) {
case 'setPassword':
trigger = sendSetPasswordEmail;
callbackUrl = 'setPassword';
break;
case 'changePassword':
trigger = sendChangePasswordEmail;
callbackUrl = 'changePassword';
break;
case 'changeEmail':
trigger = sendChangeEmail;
callbackUrl = 'changeEmail';
break;
}
// TODO: add error handler
return trigger({
email,
callbackUrl: `/auth/${callbackUrl}?isClient=${
isDesktop ? 'true' : 'false'
}`,
});
},
[
emailType,
sendChangeEmail,
sendChangePasswordEmail,
sendSetPasswordEmail,
]
),
};
};
export const SendEmail: FC<AuthPanelProps> = ({
setAuthState,
setAuthStore,
email,
authStore: { hasSentEmail },
emailType,
}) => {
const t = useAFFiNEI18N();
const pushNotification = useSetAtom(pushNotificationAtom);
const title = useEmailTitle(emailType);
const hint = useNotificationHint(emailType);
const buttonContent = useButtonContent(emailType);
const { loading, sendEmail } = useSendEmail(emailType);
const onSendEmail = useCallback(async () => {
// TODO: add error handler
await sendEmail(email);
pushNotification({
title: hint,
message: '',
key: Date.now().toString(),
type: 'success',
});
setAuthStore({ hasSentEmail: true });
}, [email, hint, pushNotification, sendEmail, setAuthStore]);
return (
<>
<ModalHeader title={t['AFFiNE Cloud']()} subTitle={title} />
<AuthContent>{t['com.affine.auth.reset.password.message']()}</AuthContent>
<Wrapper
marginTop={30}
marginBottom={50}
style={{
position: 'relative',
}}
>
<AuthInput
label={t['com.affine.settings.email']()}
disabled={true}
value={email}
/>
</Wrapper>
<Button
type="primary"
size="extraLarge"
style={{ width: '100%' }}
disabled={hasSentEmail}
loading={loading}
onClick={onSendEmail}
>
{hasSentEmail ? t['com.affine.auth.sent']() : buttonContent}
</Button>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};
@@ -0,0 +1,111 @@
import { Wrapper } from '@affine/component';
import {
AuthInput,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signIn, useSession } from 'next-auth/react';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import type { AuthPanelProps } from './index';
import { forgetPasswordButton } from './style.css';
export const SignInWithPassword: FC<AuthPanelProps> = ({
setAuthState,
email,
onSignedIn,
}) => {
const t = useAFFiNEI18N();
const { update } = useSession();
const pushNotification = useSetAtom(pushNotificationAtom);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState(false);
const onSignIn = useCallback(async () => {
const res = await signIn('credentials', {
redirect: false,
email,
password,
}).catch(console.error);
if (!res?.ok) {
return setPasswordError(true);
}
await update();
onSignedIn?.();
pushNotification({
title: `${email}${t['com.affine.auth.has.signed']()}`,
message: '',
key: Date.now().toString(),
type: 'success',
});
}, [email, password, pushNotification, onSignedIn, t, update]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['AFFiNE Cloud']()}
/>
<Wrapper
marginTop={30}
marginBottom={50}
style={{
position: 'relative',
}}
>
<AuthInput
label={t['com.affine.settings.email']()}
disabled={true}
value={email}
/>
<AuthInput
data-testid="password-input"
label={t['com.affine.auth.password']()}
value={password}
type="password"
onChange={useCallback((value: string) => {
setPassword(value);
}, [])}
error={passwordError}
errorHint={t['com.affine.auth.password.error']()}
onEnter={onSignIn}
/>
<span></span>
<button
className={forgetPasswordButton}
// onClick={useCallback(() => {
// setAuthState('sendPasswordEmail');
// }, [setAuthState])}
>
{t['com.affine.auth.forget']()}
</button>
</Wrapper>
<Button
data-testid="sign-in-button"
type="primary"
size="extraLarge"
style={{ width: '100%' }}
onClick={onSignIn}
>
{t['com.affine.auth.sign.in']()}
</Button>
<BackButton
onClick={useCallback(() => {
setAuthState('afterSignInSendEmail');
}, [setAuthState])}
/>
</>
);
};
@@ -0,0 +1,151 @@
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import type { Notification } from '@affine/component/notification-center/index.jotai';
import { getUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import { signIn, type SignInResponse } from 'next-auth/react';
import { type FC, useState } from 'react';
import { useCallback } from 'react';
import { emailRegex } from '../../../utils/email-regex';
import { buildCallbackUrl } from './callback-url';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
function validateEmail(email: string) {
return emailRegex.test(email);
}
function handleSendEmailError(
res: SignInResponse | undefined,
pushNotification: (notification: Notification) => void
) {
if (res?.error) {
pushNotification({
title: 'Send email error',
message: 'Please back to home and try again',
type: 'error',
});
}
}
export const SignIn: FC<AuthPanelProps> = ({
setAuthState,
setAuthEmail,
email,
}) => {
const t = useAFFiNEI18N();
const { trigger: verifyUser, isMutating } = useMutation({
mutation: getUserQuery,
});
const [isValidEmail, setIsValidEmail] = useState(true);
const pushNotification = useSetAtom(pushNotificationAtom);
const onContinue = useCallback(async () => {
if (!validateEmail(email)) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
const { user } = await verifyUser({ email });
setAuthEmail(email);
if (user) {
signIn('email', {
email: email,
callbackUrl: buildCallbackUrl('signIn'),
redirect: false,
})
.then(res => handleSendEmailError(res, pushNotification))
.catch(console.error);
setAuthState('afterSignInSendEmail');
} else {
signIn('email', {
email: email,
callbackUrl: buildCallbackUrl('signUp'),
redirect: false,
})
.then(res => handleSendEmailError(res, pushNotification))
.catch(console.error);
setAuthState('afterSignUpSendEmail');
}
}, [email, setAuthEmail, setAuthState, verifyUser, pushNotification]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['AFFiNE Cloud']()}
/>
<Button
type="primary"
block
size="extraLarge"
style={{
marginTop: 30,
}}
icon={<GoogleDuotoneIcon />}
onClick={useCallback(() => {
signIn('google').catch(console.error);
}, [])}
>
{t['Continue with Google']()}
</Button>
<div className={style.authModalContent}>
<AuthInput
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
value={email}
onChange={useCallback(
(value: string) => {
setAuthEmail(value);
},
[setAuthEmail]
)}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
/>
<Button
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
icon={
<ArrowDownBigIcon
width={20}
height={20}
style={{
transform: 'rotate(-90deg)',
color: 'var(--affine-blue)',
}}
/>
}
iconPosition="end"
onClick={onContinue}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
<div className={style.authMessage}>
{/*prettier-ignore*/}
<Trans i18nKey="com.affine.auth.sign.message">
By clicking &quot;Continue with Google/Email&quot; above, you acknowledge that
you agree to AFFiNE&apos;s <a href="https://affine.pro/terms" target="_blank" rel="noreferrer">Terms of Conditions</a> and <a href="https://affine.pro/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>.
</Trans>
</div>
</div>
</>
);
};
@@ -0,0 +1,28 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const authModalContent = style({
marginTop: '30px',
});
export const authMessage = style({
marginTop: '30px',
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
lineHeight: 1.5,
});
globalStyle(`${authMessage} a`, {
color: 'var(--affine-link-color)',
});
globalStyle(`${authMessage} .link`, {
cursor: 'pointer',
color: 'var(--affine-link-color)',
});
export const forgetPasswordButton = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
position: 'absolute',
right: 0,
bottom: 0,
display: 'none',
});
@@ -1,9 +1,9 @@
import { Modal, ModalWrapper, Wrapper } from '@affine/component';
import { Modal, ModalWrapper } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { Content, ContentTitle, Header, StyleTips } from './style';
import { ButtonContainer, Content, Header, StyleTips, Title } from './style';
interface EnableAffineCloudModalProps {
open: boolean;
@@ -20,32 +20,32 @@ export const EnableAffineCloudModal = ({
return (
<Modal open={open} onClose={onClose} data-testid="logout-modal">
<ModalWrapper width={560} height={292}>
<ModalWrapper width={480}>
<Header>
<Title>{t['Enable AFFiNE Cloud']()}</Title>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Header>
<Content>
<ContentTitle>{t['Enable AFFiNE Cloud']()}?</ContentTitle>
<StyleTips>{t['Enable AFFiNE Cloud Description']()}</StyleTips>
{/* <StyleTips>{t('Retain cached cloud data')}</StyleTips> */}
<Wrapper width={284} margin="auto">
<Button
data-testid="confirm-enable-affine-cloud-button"
type="primary"
block
onClick={onConfirm}
style={{
marginBottom: '16px',
}}
>
{t['Sign in and Enable']()}
</Button>
<Button onClick={onClose} block>
{t['Not now']()}
</Button>
</Wrapper>
<ButtonContainer>
<div>
<Button onClick={onClose} block>
{t['Cancel']()}
</Button>
</div>
<div>
<Button
data-testid="confirm-enable-affine-cloud-button"
type="primary"
block
onClick={onConfirm}
>
{t['Sign in and Enable']()}
</Button>
</div>
</ButtonContainer>
</Content>
</ModalWrapper>
</Modal>
@@ -1,31 +1,35 @@
import { styled } from '@affine/component';
export const Header = styled('div')({
height: '44px',
display: 'flex',
flexDirection: 'row-reverse',
paddingRight: '10px',
paddingTop: '10px',
flexShrink: 0,
justifyContent: 'space-between',
paddingRight: '20px',
paddingTop: '20px',
paddingLeft: '24px',
alignItems: 'center',
});
export const Content = styled('div')({
textAlign: 'center',
padding: '12px 24px 20px 24px',
});
export const ContentTitle = styled('h1')({
fontSize: '20px',
lineHeight: '28px',
export const Title = styled('div')({
fontSize: 'var(--affine-font-h6)',
lineHeight: '26px',
fontWeight: 600,
textAlign: 'center',
});
export const StyleTips = styled('div')(() => {
return {
userSelect: 'none',
width: '400px',
margin: 'auto',
marginBottom: '32px',
marginTop: '12px',
marginBottom: '20px',
};
});
export const ButtonContainer = styled('div')(() => {
return {
display: 'flex',
justifyContent: 'flex-end',
gap: '20px',
paddingTop: '20px',
};
});
@@ -1,13 +1,12 @@
import { Input, Modal, ModalCloseButton } from '@affine/component';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { useCallback, useState } from 'react';
import { useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../../../shared';
import { toast } from '../../../../../utils';
import {
StyledButtonContent,
StyledInputContent,
@@ -21,14 +20,14 @@ interface WorkspaceDeleteProps {
open: boolean;
onClose: () => void;
workspace: AffineOfficialWorkspace;
onDeleteWorkspace: (id: string) => Promise<void>;
onConfirm: () => void;
}
export const WorkspaceDeleteModal = ({
open,
onClose,
onConfirm,
workspace,
onDeleteWorkspace,
}: WorkspaceDeleteProps) => {
const [workspaceName] = useBlockSuiteWorkspaceName(
workspace.blockSuiteWorkspace
@@ -37,19 +36,6 @@ export const WorkspaceDeleteModal = ({
const allowDelete = deleteStr === workspaceName;
const t = useAFFiNEI18N();
const handleDelete = useCallback(() => {
onDeleteWorkspace(workspace.id)
.then(() => {
toast(t['Successfully deleted'](), {
portal: document.body,
});
onClose();
})
.catch(() => {
// ignore error
});
}, [onClose, onDeleteWorkspace, t, workspace.id]);
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
@@ -99,7 +85,7 @@ export const WorkspaceDeleteModal = ({
<Button
data-testid="delete-workspace-confirm-button"
disabled={!allowDelete}
onClick={handleDelete}
onClick={onConfirm}
size="large"
type="error"
style={{ marginLeft: '24px' }}
@@ -1,29 +1,55 @@
import { ConfirmModal } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../../shared';
import type { WorkspaceSettingDetailProps } from '../index';
import { WorkspaceDeleteModal } from './delete';
import { WorkspaceLeave } from './leave';
interface DeleteLeaveWorkspaceProps {
export interface DeleteLeaveWorkspaceProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
onDeleteWorkspace: WorkspaceSettingDetailProps['onDeleteWorkspace'];
}
export const DeleteLeaveWorkspace = ({
workspace,
onDeleteWorkspace,
onDeleteCloudWorkspace,
onDeleteLocalWorkspace,
onLeaveWorkspace,
isOwner,
}: DeleteLeaveWorkspaceProps) => {
const t = useAFFiNEI18N();
// fixme: cloud regression
const isOwner = true;
const [showDelete, setShowDelete] = useState(false);
const [showLeave, setShowLeave] = useState(false);
const onLeaveOrDelete = useCallback(() => {
if (isOwner) {
setShowDelete(true);
} else {
setShowLeave(true);
}
}, [isOwner]);
const onCloseLeaveModal = useCallback(() => {
setShowLeave(false);
}, []);
const onLeaveConfirm = useCallback(() => {
return onLeaveWorkspace();
}, [onLeaveWorkspace]);
const onDeleteConfirm = useCallback(() => {
if (workspace.flavour === WorkspaceFlavour.LOCAL) {
return onDeleteLocalWorkspace();
}
if (workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
return onDeleteCloudWorkspace();
}
}, [onDeleteCloudWorkspace, onDeleteLocalWorkspace, workspace.flavour]);
return (
<>
<SettingRow
@@ -36,16 +62,14 @@ export const DeleteLeaveWorkspace = ({
}
desc={t['com.affine.settings.remove-workspace-description']()}
style={{ cursor: 'pointer' }}
onClick={() => {
setShowDelete(true);
}}
onClick={onLeaveOrDelete}
data-testid="delete-workspace-button"
>
<ArrowRightSmallIcon />
</SettingRow>
{isOwner ? (
<WorkspaceDeleteModal
onDeleteWorkspace={onDeleteWorkspace}
onConfirm={onDeleteConfirm}
open={showDelete}
onClose={() => {
setShowDelete(false);
@@ -53,11 +77,15 @@ export const DeleteLeaveWorkspace = ({
workspace={workspace}
/>
) : (
<WorkspaceLeave
<ConfirmModal
open={showLeave}
onClose={() => {
setShowLeave(false);
}}
onConfirm={onLeaveConfirm}
onCancel={onCloseLeaveModal}
onClose={onCloseLeaveModal}
title={`${t['Leave Workspace']()}?`}
content={t['Leave Workspace hint']()}
confirmType="warning"
confirmText={t['Leave']()}
/>
)}
</>
@@ -1,49 +0,0 @@
import { Modal } from '@affine/component';
import { ModalCloseButton } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import {
StyledButtonContent,
StyledModalHeader,
StyledModalWrapper,
StyledTextContent,
} from './style';
interface WorkspaceDeleteProps {
open: boolean;
onClose: () => void;
}
export const WorkspaceLeave = ({ open, onClose }: WorkspaceDeleteProps) => {
// const { leaveWorkSpace } = useWorkspaceHelper();
const t = useAFFiNEI18N();
const handleLeave = async () => {
// await leaveWorkSpace();
onClose();
};
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
<ModalCloseButton onClick={onClose} />
<StyledModalHeader>{t['Leave Workspace']()}</StyledModalHeader>
<StyledTextContent>
{t['Leave Workspace Description']()}
</StyledTextContent>
<StyledButtonContent>
<Button shape="circle" onClick={onClose}>
{t['Cancel']()}
</Button>
<Button
onClick={handleLeave}
type="error"
style={{ marginLeft: '24px' }}
>
{t['Leave']()}
</Button>
</StyledButtonContent>
</StyledModalWrapper>
</Modal>
);
};
@@ -1,44 +0,0 @@
import { styled } from '@affine/component';
export const StyledModalWrapper = styled('div')(() => {
return {
position: 'relative',
padding: '0px',
width: '460px',
background: 'var(--affine-white)',
borderRadius: '12px',
};
});
export const StyledModalHeader = styled('div')(() => {
return {
margin: '44px 0px 12px 0px',
width: '460px',
fontWeight: '600',
fontSize: '20px;',
textAlign: 'center',
};
});
// export const StyledModalContent = styled('div')(({ theme }) => {});
export const StyledTextContent = styled('div')(() => {
return {
margin: 'auto',
width: '425px',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
};
});
export const StyledButtonContent = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
margin: '0px 0 32px 0',
};
});
@@ -1,13 +1,12 @@
import { toast } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { isDesktop } from '@affine/env/constant';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { SaveDBFileResult } from '@toeverything/infra/type';
import { useCallback } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
async function syncBlobsToSqliteDb(workspace: AffineOfficialWorkspace) {
if (window.apis && isDesktop) {
const bs = workspace.blockSuiteWorkspace.blobs;
@@ -41,7 +40,7 @@ export const ExportPanel = ({ workspace }: ExportPanelProps) => {
const result: SaveDBFileResult =
await window.apis?.dialog.saveDBFileAs(workspaceId);
if (result?.error) {
toast(t[result.error]());
toast(result.error);
} else if (!result?.canceled) {
toast(t['Export success']());
}
@@ -14,13 +14,17 @@ import { useMemo } from 'react';
import { useWorkspace } from '../../../hooks/use-workspace';
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
import { ExportPanel } from './export';
import { MembersPanel } from './members';
import { ProfilePanel } from './profile';
import { PublishPanel } from './publish';
import { StoragePanel } from './storage';
export interface WorkspaceSettingDetailProps {
workspaceId: string;
onDeleteWorkspace: (id: string) => Promise<void>;
isOwner: boolean;
onDeleteLocalWorkspace: () => void;
onDeleteCloudWorkspace: () => void;
onLeaveWorkspace: () => void;
onTransferWorkspace: <
From extends WorkspaceFlavour,
To extends WorkspaceFlavour,
@@ -31,11 +35,8 @@ export interface WorkspaceSettingDetailProps {
) => void;
}
export const WorkspaceSettingDetail = ({
workspaceId,
onDeleteWorkspace,
...props
}: WorkspaceSettingDetailProps) => {
export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
const { workspaceId } = props;
const t = useAFFiNEI18N();
const workspace = useWorkspace(workspaceId);
const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace);
@@ -67,22 +68,16 @@ export const WorkspaceSettingDetail = ({
desc={t['com.affine.settings.workspace.not-owner']()}
spreadCol={false}
>
<ProfilePanel workspace={workspace} />
<ProfilePanel workspace={workspace} {...props} />
</SettingRow>
</SettingWrapper>
<SettingWrapper title={t['AFFiNE Cloud']()}>
<PublishPanel
workspace={workspace}
onDeleteWorkspace={onDeleteWorkspace}
{...props}
/>
<PublishPanel workspace={workspace} {...props} />
<MembersPanel workspace={workspace} {...props} />
</SettingWrapper>
{storageAndExportSetting}
<SettingWrapper>
<DeleteLeaveWorkspace
workspace={workspace}
onDeleteWorkspace={onDeleteWorkspace}
/>
<DeleteLeaveWorkspace workspace={workspace} {...props} />
</SettingWrapper>
</>
);
@@ -0,0 +1,228 @@
import { Menu, MenuItem } from '@affine/component';
import {
InviteModal,
type InviteModalProps,
} from '@affine/component/member-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { SettingRow } from '@affine/component/setting-components';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Permission } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MoreVerticalIcon } from '@blocksuite/icons';
import { Avatar } from '@toeverything/components/avatar';
import { Button, IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import { useSetAtom } from 'jotai/react';
import type { ReactElement } from 'react';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import type { CheckedUser } from '../../../hooks/affine/use-current-user';
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
import { useInviteMember } from '../../../hooks/affine/use-invite-member';
import { type Member, useMembers } from '../../../hooks/affine/use-members';
import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission';
import { AnyErrorBoundary } from '../any-error-boundary';
import { type WorkspaceSettingDetailProps } from './index';
import * as style from './style.css';
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
}
const MembersPanelLocal = () => {
const t = useAFFiNEI18N();
return (
<Tooltip content={t['com.affine.settings.member-tooltip']()}>
<div className={style.fakeWrapper}>
<SettingRow name={`${t['Members']()} (0)`} desc={t['Members hint']()}>
<Button size="large">{t['Invite Members']()}</Button>
</SettingRow>
</div>
</Tooltip>
);
};
export const CloudWorkspaceMembersPanel = ({
workspace,
isOwner,
}: MembersPanelProps): ReactElement => {
const workspaceId = workspace.id;
const members = useMembers(workspaceId);
const t = useAFFiNEI18N();
const currentUser = useCurrentUser();
const { invite, isMutating } = useInviteMember(workspaceId);
const [open, setOpen] = useState(false);
const pushNotification = useSetAtom(pushNotificationAtom);
const revokeMemberPermission = useRevokeMemberPermission(workspaceId);
const memberCount = members.length;
const memberList = useMemo(
() =>
members.sort((a, b) => {
if (
a.permission === Permission.Owner &&
b.permission !== Permission.Owner
) {
return -1;
}
if (
a.permission !== Permission.Owner &&
b.permission === Permission.Owner
) {
return 1;
}
return 0;
}),
[members]
);
const openModal = useCallback(() => {
setOpen(true);
}, []);
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
async ({ email, permission }) => {
const success = await invite(
email,
permission,
// send invite email
true
);
if (success) {
pushNotification({
title: t['Invitation sent'](),
message: t['Invitation sent hint'](),
type: 'success',
});
setOpen(false);
}
},
[invite, pushNotification, t]
);
return (
<>
<SettingRow
name={`${t['Members']()} (${memberCount})`}
desc={t['Members hint']()}
>
{isOwner ? (
<>
<Button onClick={openModal}>{t['Invite Members']()}</Button>
<InviteModal
open={open}
setOpen={setOpen}
onConfirm={onInviteConfirm}
isMutating={isMutating}
/>
</>
) : null}
</SettingRow>
<div className={style.membersList}>
{memberList.map(member => (
<MemberItem
key={member.id}
member={member}
isOwner={isOwner}
currentUser={currentUser}
onRevoke={revokeMemberPermission}
/>
))}
</div>
</>
);
};
const MemberItem = ({
member,
isOwner,
currentUser,
onRevoke,
}: {
member: Member;
isOwner: boolean;
currentUser: CheckedUser;
onRevoke: (memberId: string) => void;
}) => {
const t = useAFFiNEI18N();
const handleRevoke = useCallback(() => {
onRevoke(member.id);
}, [onRevoke, member.id]);
const operationButtonInfo = useMemo(() => {
return {
show: isOwner && currentUser.id !== member.id,
leaveOrRevokeText: t['Remove from workspace'](),
};
}, [currentUser.id, isOwner, member.id, t]);
return (
<>
<div key={member.id} className={style.listItem}>
<Avatar
size={36}
url={member.avatarUrl}
name={(member.emailVerified ? member.name : member.email) as string}
/>
<div className={style.memberContainer}>
{member.emailVerified ? (
<>
<div className={style.memberName}>{member.name}</div>
<div className={style.memberEmail}>{member.email}</div>
</>
) : (
<div className={style.memberName}>{member.email}</div>
)}
</div>
<div
className={clsx(style.roleOrStatus, {
pending: !member.accepted,
})}
>
{member.accepted
? member.permission === Permission.Owner
? 'Workspace Owner'
: 'Member'
: 'Pending'}
</div>
<Menu
content={
<MenuItem data-member-id={member.id} onClick={handleRevoke}>
{operationButtonInfo.leaveOrRevokeText}
</MenuItem>
}
placement="bottom"
disablePortal={true}
trigger="click"
>
<IconButton
disabled={!operationButtonInfo.show}
style={{
visibility: operationButtonInfo.show ? 'visible' : 'hidden',
flexShrink: 0,
}}
>
<MoreVerticalIcon />
</IconButton>
</Menu>
</div>
</>
);
};
export const MembersPanel = (props: MembersPanelProps): ReactElement | null => {
if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return <MembersPanelLocal />;
}
return (
<ErrorBoundary FallbackComponent={AnyErrorBoundary}>
<Suspense>
<CloudWorkspaceMembersPanel {...props} />
</Suspense>
</ErrorBoundary>
);
};
@@ -1,21 +1,23 @@
import { FlexWrapper, Input, toast, Wrapper } from '@affine/component';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CameraIcon, DoneIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import { Upload } from '../../pure/file-upload';
import { type WorkspaceSettingDetailProps } from './index';
import * as style from './style.css';
interface ProfilePanelProps {
export interface ProfilePanelProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
}
export const ProfilePanel = ({ workspace }: ProfilePanelProps) => {
export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
const t = useAFFiNEI18N();
const [, update] = useBlockSuiteWorkspaceAvatarUrl(
@@ -38,7 +40,7 @@ export const ProfilePanel = ({ workspace }: ProfilePanelProps) => {
return (
<div className={style.profileWrapper}>
<div className={style.avatarWrapper}>
<div className={clsx(style.avatarWrapper, { disable: !isOwner })}>
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={update}
@@ -59,6 +61,7 @@ export const ProfilePanel = ({ workspace }: ProfilePanelProps) => {
<div className={style.label}>{t['Workspace Name']()}</div>
<FlexWrapper alignItems="center" flexGrow="1">
<Input
disabled={!isOwner}
width={280}
height={32}
defaultValue={input}
@@ -1,18 +1,19 @@
import { FlexWrapper, Switch } from '@affine/component';
import { FlexWrapper, Input, Switch } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { Unreachable } from '@affine/env/constant';
import type {
AffineCloudWorkspace,
AffinePublicWorkspace,
LocalWorkspace,
} from '@affine/env/workspace';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import { toast } from '../../../utils';
import { EnableAffineCloudModal } from '../enable-affine-cloud-modal';
import { TmpDisableAffineCloudModal } from '../tmp-disable-affine-cloud-modal';
@@ -29,14 +30,16 @@ export interface PublishPanelLocalProps
}
export interface PublishPanelAffineProps
extends Omit<WorkspaceSettingDetailProps, 'workspaceId'> {
workspace: AffineCloudWorkspace;
workspace: AffineCloudWorkspace | AffinePublicWorkspace;
}
const PublishPanelAffine = (props: PublishPanelAffineProps) => {
const { workspace } = props;
const t = useAFFiNEI18N();
// const toggleWorkspacePublish = useToggleWorkspacePublish(workspace);
const isPublic = useMemo(() => {
return workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC;
}, [workspace]);
const [origin, setOrigin] = useState('');
const shareUrl = origin + '/public-workspace/' + workspace.id;
@@ -54,35 +57,35 @@ const PublishPanelAffine = (props: PublishPanelAffineProps) => {
}, [shareUrl, t]);
return (
<>
<div style={{ display: 'none' }}>
<SettingRow
name={t['Publish']()}
desc={
// workspace.public ? t['Unpublished hint']() : t['Published hint']()
'UNFINISHED'
}
desc={isPublic ? t['Unpublished hint']() : t['Published hint']()}
style={{
marginBottom: isPublic ? '12px' : '25px',
}}
>
{/* <Switch
checked={workspace.public}
onChange={checked => toggleWorkspacePublish(checked)}
/> */}
<Switch
checked={isPublic}
// onChange={useCallback(value => {
// console.log('onChange', value);
// }, [])}
/>
</SettingRow>
<FlexWrapper justifyContent="space-between">
<Button
className={style.urlButton}
size="large"
onClick={useCallback(() => {
window.open(shareUrl, '_blank');
}, [shareUrl])}
title={shareUrl}
>
{shareUrl}
</Button>
<Button size="large" onClick={copyUrl}>
{t['Copy']()}
</Button>
</FlexWrapper>
</>
{isPublic ? (
<FlexWrapper justifyContent="space-between" marginBottom={25}>
<Input value={shareUrl} disabled />
<Button
onClick={copyUrl}
style={{
marginLeft: '20px',
}}
>
{t['Copy']()}
</Button>
</FlexWrapper>
) : null}
</div>
);
};
@@ -164,7 +167,10 @@ const PublishPanelLocal = ({
};
export const PublishPanel = (props: PublishPanelProps) => {
if (props.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
if (
props.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ||
props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC
) {
return <PublishPanelAffine {...props} workspace={props.workspace} />;
} else if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return <PublishPanelLocal {...props} workspace={props.workspace} />;
@@ -1,5 +1,6 @@
import { FlexWrapper, toast } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
@@ -7,9 +8,6 @@ import type { MoveDBFileResult } from '@toeverything/infra/type';
import { useMemo } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import * as style from './style.css';
const useDBFileSecondaryPath = (workspaceId: string) => {
const [path, setPath] = useState<string | undefined>(undefined);
useEffect(() => {
@@ -83,7 +81,7 @@ export const StoragePanel = ({ workspace }: StoragePanelProps) => {
>
<Button
data-testid="move-folder"
className={style.urlButton}
// className={style.urlButton}
size="large"
onClick={handleMoveTo}
>
@@ -5,6 +5,12 @@ export const profileWrapper = style({
alignItems: 'flex-end',
marginTop: '12px',
});
export const profileHandlerWrapper = style({
flexGrow: '1',
display: 'flex',
alignItems: 'center',
marginLeft: '20px',
});
export const avatarWrapper = style({
width: '56px',
@@ -24,6 +30,9 @@ export const avatarWrapper = style({
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
display: 'flex',
});
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
display: 'flex',
});
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
width: '100%',
height: '100%',
@@ -68,6 +77,70 @@ export const fakeWrapper = style({
},
});
export const membersList = style({
marginTop: '24px',
padding: '4px',
borderRadius: '12px',
background: 'var(--affine-background-primary-color)',
maxHeight: '464px',
overflow: 'hidden',
});
export const listItem = style({
padding: '0 4px 0 16px',
height: '58px',
display: 'flex',
width: '100%',
alignItems: 'center',
':hover': {
background: 'var(--affine-hover-color)',
borderRadius: '8px',
},
});
export const memberContainer = style({
width: '250px',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
marginLeft: '12px',
marginRight: '20px',
});
export const roleOrStatus = style({
// width: '20%',
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 'var(--affine-font-sm)',
selectors: {
'&.pending': {
color: 'var(--affine-primary-color)',
},
},
});
export const memberName = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-primary-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '22px',
});
export const memberEmail = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '20px',
});
export const iconButton = style({});
globalStyle(`${listItem}:hover ${iconButton}`, {
opacity: 1,
pointerEvents: 'all',
});
export const label = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
@@ -1,3 +1,185 @@
export const AccountSetting = () => {
return <div>AccountSetting</div>;
import { FlexWrapper, Input } from '@affine/component';
import {
SettingHeader,
SettingRow,
} from '@affine/component/setting-components';
import { UserAvatar } from '@affine/component/user-avatar';
import { uploadAvatarMutation } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon, CameraIcon, DoneIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useAtom } from 'jotai/index';
import { signOut } from 'next-auth/react';
import { type FC, useCallback, useState } from 'react';
import { authAtom } from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { Upload } from '../../../pure/file-upload';
import * as style from './style.css';
export const AvatarAndName = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const [input, setInput] = useState<string>(user.name);
const { trigger: avatarTrigger } = useMutation({
mutation: uploadAvatarMutation,
});
const handleUpdateUserName = useCallback(
(newName: string) => {
user.update({ name: newName }).catch(console.error);
},
[user]
);
const handleUpdateUserAvatar = useCallback(
async (file: File) => {
await avatarTrigger({
id: user.id,
avatar: file,
});
// XXX: This is a hack to force the user to update, since next-auth can not only use update function without params
user.update({ name: user.name }).catch(console.error);
},
[avatarTrigger, user]
);
return (
<>
<SettingRow
name={t['com.affine.settings.profile']()}
desc={t['com.affine.settings.profile.message']()}
spreadCol={false}
>
<FlexWrapper style={{ margin: '12px 0 24px 0' }} alignItems="center">
<div className={style.avatarWrapper}>
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={handleUpdateUserAvatar}
data-testid="upload-user-avatar"
>
<>
<div className="camera-icon-wrapper">
<CameraIcon />
</div>
<UserAvatar
size={56}
name={user.name}
url={user.image}
className="avatar"
/>
</>
</Upload>
</div>
<div className={style.profileInputWrapper}>
<label>{t['com.affine.settings.profile.name']()}</label>
<FlexWrapper alignItems="center">
<Input
defaultValue={input}
data-testid="user-name-input"
placeholder={t['com.affine.settings.profile.placeholder']()}
maxLength={64}
minLength={0}
width={280}
height={28}
onChange={setInput}
/>
{input && input === user.name ? null : (
<IconButton
data-testid="save-user-name"
onClick={() => {
handleUpdateUserName(input);
}}
style={{
color: 'var(--affine-primary-color)',
marginLeft: '12px',
}}
>
<DoneIcon />
</IconButton>
)}
</FlexWrapper>
</div>
</FlexWrapper>
</SettingRow>
</>
);
};
export const AccountSetting: FC = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const [, setAuthModal] = useAtom(authAtom);
const onChangeEmail = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: 'changeEmail',
});
}, [setAuthModal, user.email]);
const onChangePassword = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: 'changePassword',
});
}, [setAuthModal, user.email]);
return (
<>
<SettingHeader
title={t['com.affine.setting.account']()}
subtitle={t['com.affine.setting.account.message']()}
data-testid="account-title"
/>
<AvatarAndName />
<SettingRow name={t['com.affine.settings.email']()} desc={user.email}>
<Button onClick={onChangeEmail}>
{t['com.affine.settings.email.action']()}
</Button>
</SettingRow>
<SettingRow
name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onChangePassword}>
{user.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}
style={{ cursor: 'pointer' }}
onClick={useCallback(() => {
signOut().catch(console.error);
}, [])}
>
<ArrowRightSmallIcon />
</SettingRow>
{/*<SettingRow*/}
{/* name={*/}
{/* <span style={{ color: 'var(--affine-warning-color)' }}>*/}
{/* {t['com.affine.setting.account.delete']()}*/}
{/* </span>*/}
{/* }*/}
{/* desc={t['com.affine.setting.account.delete.message']()}*/}
{/* style={{ cursor: 'pointer' }}*/}
{/* onClick={useCallback(() => {*/}
{/* toast('Function coming soon');*/}
{/* }, [])}*/}
{/* testId="delete-account-button"*/}
{/*>*/}
{/* <ArrowRightSmallIcon />*/}
{/*</SettingRow>*/}
</>
);
};
@@ -0,0 +1,41 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const profileInputWrapper = style({
marginLeft: '20px',
});
globalStyle(`${profileInputWrapper} label`, {
display: 'block',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
marginBottom: '4px',
});
export const avatarWrapper = style({
width: '56px',
height: '56px',
borderRadius: '50%',
position: 'relative',
overflow: 'hidden',
cursor: 'pointer',
flexShrink: '0',
selectors: {
'&.disable': {
cursor: 'default',
pointerEvents: 'none',
},
},
});
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
display: 'flex',
});
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
width: '100%',
height: '100%',
position: 'absolute',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(60, 61, 63, 0.5)',
zIndex: '1',
color: 'var(--affine-white)',
fontSize: 'var(--affine-font-h-4)',
});
@@ -7,6 +7,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ContactWithUsIcon } from '@blocksuite/icons';
import { Suspense, useCallback } from 'react';
import { useCurrenLoginStatus } from '../../../hooks/affine/use-curren-login-status';
import { AccountSetting } from './account-setting';
import {
GeneralSetting,
@@ -38,6 +39,7 @@ export const SettingModal = ({
onSettingClick,
}: SettingModalProps) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus();
const generalSettingList = useGeneralSettingList();
@@ -85,7 +87,9 @@ export const SettingModal = ({
{generalSettingList.find(v => v.key === activeTab) ? (
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
) : null}
{activeTab === 'account' ? <AccountSetting /> : null}
{activeTab === 'account' && loginStatus === 'authenticated' ? (
<AccountSetting />
) : null}
</div>
<div className="footer">
<div className={footerIconWrapper}>
@@ -1,41 +1,101 @@
import { ScrollableContainer } from '@affine/component';
import {
WorkspaceListItemSkeleton,
WorkspaceListSkeleton,
} from '@affine/component/setting-components';
import { UserAvatar } from '@affine/component/user-avatar';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { Logo1Icon } from '@blocksuite/icons';
import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { useStaticBlockSuiteWorkspace } from '@toeverything/infra/__internal__/react';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { Suspense, useRef } from 'react';
import { useAtom, useAtomValue } from 'jotai/react';
import {
type ReactElement,
Suspense,
useCallback,
useMemo,
useRef,
} from 'react';
import { authAtom } from '../../../../atoms';
import { useCurrenLoginStatus } from '../../../../hooks/affine/use-curren-login-status';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import type {
GeneralSettingKeys,
GeneralSettingList,
} from '../general-setting';
import {
accountButton,
currentWorkspaceLabel,
settingSlideBar,
sidebarFooter,
sidebarItemsWrapper,
sidebarSelectItem,
sidebarSubtitle,
sidebarTitle,
} from './style.css';
interface SettingSidebarProps {
generalSettingList: GeneralSettingList;
onGeneralSettingClick: (key: GeneralSettingKeys) => void;
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
selectedGeneralKey: string | null;
export type UserInfoProps = {
onAccountSettingClick: () => void;
}
};
export const UserInfo = ({
onAccountSettingClick,
}: UserInfoProps): ReactElement => {
const user = useCurrentUser();
return (
<div className={accountButton} onClick={onAccountSettingClick}>
<UserAvatar
size={28}
name={user.name}
url={user.image}
className="avatar"
/>
<div className="content">
<div className="name" title="xxx">
{user.name}
</div>
<div className="email" title="xxx">
{user.email}
</div>
</div>
</div>
);
};
export const SignInButton = () => {
const t = useAFFiNEI18N();
const [, setAuthModal] = useAtom(authAtom);
return (
<div
className={accountButton}
onClick={useCallback(() => {
setAuthModal({ openModal: true, state: 'signIn' });
}, [setAuthModal])}
>
<div className="avatar not-sign">
<Logo1Icon />
</div>
<div className="content">
<div className="name" title={t['com.affine.settings.sign']()}>
{t['com.affine.settings.sign']()}
</div>
<div className="email" title={t['com.affine.setting.sign.message']()}>
{t['com.affine.setting.sign.message']()}
</div>
</div>
</div>
);
};
export const SettingSidebar = ({
generalSettingList,
@@ -43,9 +103,17 @@ export const SettingSidebar = ({
onWorkspaceSettingClick,
selectedWorkspaceId,
selectedGeneralKey,
}: SettingSidebarProps) => {
onAccountSettingClick,
}: {
generalSettingList: GeneralSettingList;
onGeneralSettingClick: (key: GeneralSettingKeys) => void;
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
selectedGeneralKey: string | null;
onAccountSettingClick: () => void;
}) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus();
return (
<div className={settingSlideBar} data-testid="settings-sidebar">
<div className={sidebarTitle}>{t['Settings']()}</div>
@@ -79,32 +147,43 @@ export const SettingSidebar = ({
</div>
<div className={clsx(sidebarItemsWrapper, 'scroll')}>
<Suspense fallback={<WorkspaceListSkeleton />}>
<ScrollableContainer>
<WorkspaceList
onWorkspaceSettingClick={onWorkspaceSettingClick}
selectedWorkspaceId={selectedWorkspaceId}
/>
</ScrollableContainer>
<WorkspaceList
onWorkspaceSettingClick={onWorkspaceSettingClick}
selectedWorkspaceId={selectedWorkspaceId}
/>
</Suspense>
</div>
<div className={sidebarFooter}>
{runtimeConfig.enableCloud && loginStatus === 'unauthenticated' ? (
<SignInButton />
) : null}
{runtimeConfig.enableCloud && loginStatus === 'authenticated' ? (
<UserInfo onAccountSettingClick={onAccountSettingClick} />
) : null}
</div>
</div>
);
};
interface WorkspaceListProps {
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
}
export const WorkspaceList = ({
onWorkspaceSettingClick,
selectedWorkspaceId,
}: WorkspaceListProps) => {
}: {
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
}) => {
const workspaces = useAtomValue(rootWorkspacesMetadataAtom);
const [currentWorkspace] = useCurrentWorkspace();
const workspaceList = useMemo(() => {
return workspaces.filter(
({ flavour }) => flavour !== WorkspaceFlavour.AFFINE_PUBLIC
);
}, [workspaces]);
return (
<>
{workspaces.map(workspace => {
{workspaceList.map(workspace => {
return (
<Suspense key={workspace.id} fallback={<WorkspaceListItemSkeleton />}>
<WorkspaceListItem
@@ -122,19 +201,17 @@ export const WorkspaceList = ({
);
};
interface WorkspaceListItemProps {
meta: RootWorkspaceMetadata;
onClick: () => void;
isCurrent: boolean;
isActive: boolean;
}
const WorkspaceListItem = ({
meta,
onClick,
isCurrent,
isActive,
}: WorkspaceListItemProps) => {
}: {
meta: RootWorkspaceMetadata;
onClick: () => void;
isCurrent: boolean;
isActive: boolean;
}) => {
const workspace = useStaticBlockSuiteWorkspace(meta.id);
const [workspaceName] = useBlockSuiteWorkspaceName(workspace);
const ref = useRef(null);
@@ -34,7 +34,7 @@ export const sidebarItemsWrapper = style({
selectors: {
'&.scroll': {
flexGrow: 1,
overflowY: 'hidden',
overflowY: 'auto',
},
},
});
@@ -92,6 +92,8 @@ export const currentWorkspaceLabel = style({
},
});
export const sidebarFooter = style({ padding: '0 16px' });
export const accountButton = style({
height: '42px',
padding: '4px 8px',
@@ -110,6 +112,20 @@ globalStyle(`${accountButton} .avatar`, {
border: '1px solid',
borderColor: 'var(--affine-white)',
marginRight: '10px',
flexShrink: 0,
});
globalStyle(`${accountButton} .avatar.not-sign`, {
width: '28px',
height: '28px',
borderRadius: '50%',
fontSize: '22px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderColor: 'var(--affine-border-color)',
color: 'var(--affine-border-color)',
background: 'var(--affine-white)',
});
globalStyle(`${accountButton} .content`, {
flexGrow: '1',
@@ -3,16 +3,17 @@ import { globalStyle, style } from '@vanilla-extract/css';
export const settingContent = style({
flexGrow: '1',
height: '100%',
padding: '40px 15px 20px',
overflow: 'auto',
padding: '40px 15px',
overflow: 'hidden',
});
globalStyle(`${settingContent} .wrapper`, {
width: '66%',
minWidth: '450px',
width: '60%',
padding: '0 15px',
height: '100%',
maxWidth: '560px',
minWidth: '560px',
margin: '0 auto',
overflowY: 'auto',
});
globalStyle(`${settingContent} .wrapper::-webkit-scrollbar`, {
@@ -1,9 +1,16 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/react';
import { useSetAtom } from 'jotai';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { getUIAdapter } from '../../../../adapters/workspace';
import { openSettingModalAtom } from '../../../../atoms';
import { useLeaveWorkspace } from '../../../../hooks/affine/use-leave-workspace';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { useOnTransformWorkspace } from '../../../../hooks/root/use-on-transform-workspace';
import {
RouteLogic,
@@ -13,28 +20,83 @@ import { useWorkspace } from '../../../../hooks/use-workspace';
import { useAppHelper } from '../../../../hooks/use-workspaces';
export const WorkspaceSetting = ({ workspaceId }: { workspaceId: string }) => {
const t = useAFFiNEI18N();
const { jumpToSubPath, jumpToIndex } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
const workspace = useWorkspace(workspaceId);
const workspaces = useAtomValue(rootWorkspacesMetadataAtom);
const pushNotification = useSetAtom(pushNotificationAtom);
const leaveWorkspace = useLeaveWorkspace();
usePassiveWorkspaceEffect(workspace.blockSuiteWorkspace);
const setSettingModal = useSetAtom(openSettingModalAtom);
const helper = useAppHelper();
const { jumpToIndex } = useNavigateHelper();
const { deleteWorkspace } = useAppHelper();
const { NewSettingsDetail } = getUIAdapter(workspace.flavour);
const onDeleteWorkspace = useCallback(
async (id: string) => {
await helper.deleteWorkspace(id);
setSettingModal(prev => ({ ...prev, open: false, workspaceId: null }));
jumpToIndex(RouteLogic.REPLACE);
},
[helper, jumpToIndex, setSettingModal]
);
const closeAndJumpOut = useCallback(() => {
setSettingModal(prev => ({ ...prev, open: false, workspaceId: null }));
if (currentWorkspace.id === workspaceId) {
const backWorkspace = workspaces.find(ws => ws.id !== workspaceId);
// TODO: if there is no workspace, jump to a new page(wait for design)
if (backWorkspace) {
jumpToSubPath(
backWorkspace?.id || '',
WorkspaceSubPath.ALL,
RouteLogic.REPLACE
);
} else {
setTimeout(() => {
jumpToIndex(RouteLogic.REPLACE);
}, 100);
}
}
}, [
currentWorkspace.id,
jumpToIndex,
jumpToSubPath,
setSettingModal,
workspaceId,
workspaces,
]);
const handleDeleteWorkspace = useCallback(async () => {
closeAndJumpOut();
await deleteWorkspace(workspaceId);
pushNotification({
title: t['Successfully deleted'](),
type: 'success',
});
}, [closeAndJumpOut, deleteWorkspace, pushNotification, t, workspaceId]);
const handleLeaveWorkspace = useCallback(async () => {
closeAndJumpOut();
await leaveWorkspace(workspaceId);
pushNotification({
title: 'Successfully leave',
type: 'success',
});
}, [closeAndJumpOut, leaveWorkspace, pushNotification, workspaceId]);
const onTransformWorkspace = useOnTransformWorkspace();
// const handleDelete = useCallback(async () => {
// await onDeleteWorkspace();
// toast(t['Successfully deleted'](), {
// portal: document.body,
// });
// onClose();
// }, [onClose, onDeleteWorkspace, t, workspace.id]);
return (
<NewSettingsDetail
onDeleteCloudWorkspace={handleDeleteWorkspace}
onDeleteLocalWorkspace={handleDeleteWorkspace}
onLeaveWorkspace={handleLeaveWorkspace}
onTransformWorkspace={onTransformWorkspace}
onDeleteWorkspace={onDeleteWorkspace}
currentWorkspaceId={workspaceId}
/>
);
@@ -0,0 +1,48 @@
import { ShareMenu } from '@affine/component/share-menu';
import {
type AffineOfficialWorkspace,
WorkspaceFlavour,
} from '@affine/env/workspace';
import type { Page } from '@blocksuite/store';
import { useState } from 'react';
import { useIsSharedPage } from '../../../hooks/affine/use-is-shared-page';
import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace';
import { EnableAffineCloudModal } from '../enable-affine-cloud-modal';
type SharePageModalProps = {
workspace: AffineOfficialWorkspace;
page: Page;
};
export const SharePageModal = ({ workspace, page }: SharePageModalProps) => {
const onTransformWorkspace = useOnTransformWorkspace();
const [open, setOpen] = useState(false);
return (
<>
<ShareMenu
workspace={workspace}
currentPage={page}
useIsSharedPage={useIsSharedPage}
onEnableAffineCloud={() => setOpen(true)}
togglePagePublic={async () => {}}
/>
{workspace.flavour === WorkspaceFlavour.LOCAL ? (
<EnableAffineCloudModal
open={open}
onClose={() => {
setOpen(false);
}}
onConfirm={() => {
onTransformWorkspace(
WorkspaceFlavour.LOCAL,
WorkspaceFlavour.AFFINE_CLOUD,
workspace
);
setOpen(false);
}}
/>
) : null}
</>
);
};
@@ -1,3 +1,4 @@
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import {
useBlockSuitePageMeta,
@@ -12,7 +13,6 @@ import {
useState,
} from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import { EditorModeSwitch } from '../block-suite-mode-switch';
import { PageMenu } from './operation-menu';
import * as styles from './styles.css';
@@ -139,7 +139,7 @@ const BlockSuiteTitleWithRename = (props: BlockSuiteHeaderTitleProps) => {
};
export const BlockSuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
if (props.workspace.flavour === WorkspaceFlavour.PUBLIC) {
if (props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC) {
return <StableTitle {...props} />;
}
return <BlockSuiteTitleWithRename {...props} />;
@@ -0,0 +1,53 @@
import { UserAvatar } from '@affine/component/user-avatar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { signIn } from 'next-auth/react';
import { useCurrenLoginStatus } from '../../hooks/affine/use-curren-login-status';
import { useCurrentUser } from '../../hooks/affine/use-current-user';
import { StyledSignInButton } from '../pure/footer/styles';
export const LoginCard = () => {
const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus();
if (loginStatus === 'authenticated') {
return <UserCard />;
}
return (
<StyledSignInButton
data-testid="sign-in-button"
onClick={async () => {
// jump to login page
signIn().catch(console.error);
}}
>
<div className="circle">
<CloudWorkspaceIcon />
</div>{' '}
{t['Sign in']()}
</StyledSignInButton>
);
};
const UserCard = () => {
const user = useCurrentUser();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
}}
>
<UserAvatar
size={28}
name={user.name}
url={user.image}
className="avatar"
/>
<div style={{ marginLeft: '15px' }}>
<div>{user.name}</div>
<div>{user.email}</div>
</div>
</div>
);
};
@@ -0,0 +1,58 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { assertExists } from '@blocksuite/global/utils';
import { GraphQLError } from 'graphql';
import { useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactElement } from 'react';
import { useCallback } from 'react';
import type { SWRConfiguration } from 'swr';
import { SWRConfig } from 'swr';
const cloudConfig: SWRConfiguration = {
suspense: true,
use: [
useSWRNext => (key, fetcher, config) => {
const pushNotification = useSetAtom(pushNotificationAtom);
const fetcherWrapper = useCallback(
async (...args: any[]) => {
assertExists(fetcher);
const d = fetcher(...args);
if (d instanceof Promise) {
return d.catch(e => {
if (
e instanceof GraphQLError ||
(Array.isArray(e) && e[0] instanceof GraphQLError)
) {
const graphQLError = e instanceof GraphQLError ? e : e[0];
pushNotification({
title: 'GraphQL Error',
message: graphQLError.toString(),
key: Date.now().toString(),
type: 'error',
});
} else {
pushNotification({
title: 'Error',
message: e.toString(),
key: Date.now().toString(),
type: 'error',
});
}
throw e;
});
}
return d;
},
[fetcher, pushNotification]
);
return useSWRNext(key, fetcher ? fetcherWrapper : fetcher, config);
},
],
};
export const Provider = (props: PropsWithChildren): ReactElement => {
if (!runtimeConfig.enableCloud) {
return <>{props.children}</>;
}
return <SWRConfig value={cloudConfig}>{props.children}</SWRConfig>;
};
+28 -25
View File
@@ -1,39 +1,42 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import { type CSSProperties, forwardRef } from 'react';
import { signIn } from 'next-auth/react';
import { type CSSProperties, type FC, forwardRef, useCallback } from 'react';
import { openDisableCloudAlertModalAtom } from '../../../atoms';
import { useCurrenLoginStatus } from '../../../hooks/affine/use-curren-login-status';
// import { openDisableCloudAlertModalAtom } from '../../../atoms';
import { stringToColour } from '../../../utils';
import { StyledFooter } from './styles';
export const Footer = () => {
const t = useAFFiNEI18N();
const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
import { StyledFooter, StyledSignInButton } from './styles';
export const Footer: FC = () => {
const loginStatus = useCurrenLoginStatus();
// const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
return (
<StyledFooter data-testid="workspace-list-modal-footer">
<Button
data-testid="sign-in-button"
type="plain"
icon={
<CloudWorkspaceIcon
style={{ color: 'var(--affine-primary-color)' }}
/>
}
onClick={async () => {
if (!runtimeConfig.enableCloud) {
setOpen(true);
}
}}
>
{t['Sign in']()}
</Button>
{loginStatus === 'authenticated' ? null : <SignInButton />}
</StyledFooter>
);
};
const SignInButton = () => {
const t = useAFFiNEI18N();
return (
<StyledSignInButton
data-testid="sign-in-button"
onClick={useCallback(() => {
signIn().catch(console.error);
}, [])}
>
<div className="circle">
<CloudWorkspaceIcon />
</div>
{t['Sign in']()}
</StyledSignInButton>
);
};
interface WorkspaceAvatarProps {
size: number;
name: string | undefined;
+41 -1
View File
@@ -1,4 +1,19 @@
import { displayFlex, styled, textEllipsis } from '@affine/component';
import {
displayFlex,
displayInlineFlex,
styled,
textEllipsis,
} from '@affine/component';
export const StyledSplitLine = styled('div')(() => {
return {
width: '1px',
height: '20px',
background: 'var(--affine-border-color)',
marginRight: '24px',
};
});
export const StyleWorkspaceInfo = styled('div')(() => {
return {
marginLeft: '15px',
@@ -110,3 +125,28 @@ export const StyledModalHeader = styled('div')(() => {
...displayFlex('space-between', 'center'),
};
});
export const StyledSignInButton = styled('button')(() => {
return {
fontWeight: 600,
paddingLeft: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingRight: '15px',
borderRadius: '8px',
'&:hover': {
backgroundColor: 'var(--affine-hover-color)',
},
'.circle': {
width: '40px',
height: '40px',
borderRadius: '20px',
color: 'var(--affine-primary-color)',
fontSize: '24px',
flexShrink: 0,
marginRight: '16px',
...displayInlineFlex('center', 'center'),
},
};
});
@@ -8,19 +8,27 @@ import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import {
AccountIcon,
CloudWorkspaceIcon,
ImportIcon,
MoreHorizontalIcon,
PlusIcon,
SignOutIcon,
} from '@blocksuite/icons';
import type { DragEndEvent } from '@dnd-kit/core';
import { Popover } from '@mui/material';
import { IconButton } from '@toeverything/components/button';
import { Divider } from '@toeverything/components/divider';
import { useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signOut, useSession } from 'next-auth/react';
import { useCallback } from 'react';
import { openDisableCloudAlertModalAtom } from '../../../atoms';
import {
authAtom,
openDisableCloudAlertModalAtom,
openSettingModalAtom,
} from '../../../atoms';
import type { AllWorkspace } from '../../../shared';
import {
StyledCreateWorkspaceCardPill,
@@ -39,6 +47,7 @@ import {
StyledSignInCardPillTextCotainer,
StyledSignInCardPillTextPrimary,
StyledSignInCardPillTextSecondary,
StyledWorkspaceFlavourTitle,
} from './styles';
interface WorkspaceModalProps {
@@ -56,18 +65,31 @@ interface WorkspaceModalProps {
const AccountMenu = () => {
const t = useAFFiNEI18N();
const setOpen = useSetAtom(openSettingModalAtom);
return (
<div>
<div>Unlimted</div>
{/* <div>Unlimted</div>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
<MenuItem icon={<ImportIcon />} data-testid="editor-option-menu-import">
{t['com.affine.workspace.cloud.join']()}
</MenuItem>
<MenuItem icon={<ImportIcon />} data-testid="editor-option-menu-import">
</MenuItem> */}
<MenuItem
icon={<AccountIcon />}
data-testid="editor-option-menu-import"
onClick={useCallback(() => {
setOpen(prev => ({ ...prev, open: true, activeTab: 'account' }));
}, [setOpen])}
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
<MenuItem icon={<ImportIcon />} data-testid="editor-option-menu-import">
<Divider />
<MenuItem
icon={<SignOutIcon />}
data-testid="editor-option-menu-import"
onClick={useCallback(() => {
signOut().catch(console.error);
}, [])}
>
{t['com.affine.workspace.cloud.account.logout']()}
</MenuItem>
</div>
@@ -89,31 +111,16 @@ const CloudWorkSpaceList = ({
<StyledModalHeader>
<StyledModalHeaderLeft>
<StyledModalTitle>
{t['com.affine.workspace.cloud.sync']()}
{t['com.affine.workspace.cloud']()}
</StyledModalTitle>
</StyledModalHeaderLeft>
<StyledOperationWrapper>
<Menu
placement="bottom-end"
trigger={['click']}
content={<AccountMenu />}
zIndex={1000}
>
<IconButton
data-testid="previous-image-button"
icon={<MoreHorizontalIcon />}
type="plain"
/>
</Menu>
</StyledOperationWrapper>
</StyledModalHeader>
<StyledModalContent>
<WorkspaceList
disabled={disabled}
items={
workspaces.filter(
({ flavour }) => flavour !== WorkspaceFlavour.PUBLIC
({ flavour }) => flavour === WorkspaceFlavour.AFFINE_CLOUD
) as (AffineCloudWorkspace | LocalWorkspace)[]
}
currentWorkspaceId={currentWorkspaceId}
@@ -129,7 +136,6 @@ const CloudWorkSpaceList = ({
[onMoveWorkspace]
)}
/>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
</StyledModalContent>
</>
);
@@ -148,11 +154,18 @@ export const WorkspaceListModal = ({
onMoveWorkspace,
}: WorkspaceModalProps) => {
const t = useAFFiNEI18N();
const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
const setOpen = useSetAtom(authAtom);
const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom);
// TODO: AFFiNE Cloud support
const isLoggedIn = false;
const { data: session, status } = useSession();
const isLoggedIn = status === 'authenticated' ? true : false;
const anchorEl = document.getElementById('current-workspace');
const cloudWorkspaces = workspaces.filter(
({ flavour }) => flavour === WorkspaceFlavour.AFFINE_CLOUD
) as (AffineCloudWorkspace | LocalWorkspace)[];
const localWorkspaces = workspaces.filter(
({ flavour }) => flavour === WorkspaceFlavour.LOCAL
) as (AffineCloudWorkspace | LocalWorkspace)[];
return (
<Popover
sx={{
@@ -164,6 +177,7 @@ export const WorkspaceListModal = ({
flexDirection: 'column',
boxShadow: 'var(--affine-shadow-2)',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
padding: '16px 12px',
},
maxHeight: '90vh',
}}
@@ -171,63 +185,92 @@ export const WorkspaceListModal = ({
anchorEl={anchorEl}
onClose={onClose}
>
<StyledModalHeaderContent>
<StyledSignInCardPill>
<MenuItem
style={{
height: 'auto',
padding: '8px 12px',
}}
onClick={async () => {
if (!runtimeConfig.enableCloud) {
setOpen(true);
}
}}
data-testid="cloud-signin-button"
>
<StyledCreateWorkspaceCardPillContent>
<StyledCreateWorkspaceCardPillIcon>
<CloudWorkspaceIcon />
</StyledCreateWorkspaceCardPillIcon>
<StyledSignInCardPillTextCotainer>
<StyledSignInCardPillTextPrimary>
{t['com.affine.workspace.cloud.auth']()}
</StyledSignInCardPillTextPrimary>
<StyledSignInCardPillTextSecondary>
Sync with AFFiNE Cloud
</StyledSignInCardPillTextSecondary>
</StyledSignInCardPillTextCotainer>
</StyledCreateWorkspaceCardPillContent>
</MenuItem>
</StyledSignInCardPill>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
</StyledModalHeaderContent>
{!isLoggedIn ? (
<StyledModalHeaderContent>
<StyledSignInCardPill>
<MenuItem
style={{
height: 'auto',
padding: '0px 12px',
}}
onClick={async () => {
if (!runtimeConfig.enableCloud) {
setDisableCloudOpen(true);
} else {
setOpen(state => ({
...state,
openModal: true,
}));
}
}}
data-testid="cloud-signin-button"
>
<StyledCreateWorkspaceCardPillContent>
<StyledCreateWorkspaceCardPillIcon>
<CloudWorkspaceIcon />
</StyledCreateWorkspaceCardPillIcon>
<StyledSignInCardPillTextCotainer>
<StyledSignInCardPillTextPrimary>
{t['com.affine.workspace.cloud.auth']()}
</StyledSignInCardPillTextPrimary>
<StyledSignInCardPillTextSecondary>
{t['com.affine.workspace.cloud.description']()}
</StyledSignInCardPillTextSecondary>
</StyledSignInCardPillTextCotainer>
</StyledCreateWorkspaceCardPillContent>
</MenuItem>
</StyledSignInCardPill>
<Divider style={{ margin: '12px 0px' }} />
</StyledModalHeaderContent>
) : (
<StyledModalHeaderContent>
<StyledModalHeader>
<StyledModalTitle>{session?.user.email}</StyledModalTitle>
<StyledOperationWrapper>
<Menu
placement="bottom-end"
trigger={['click']}
content={<AccountMenu />}
zIndex={1000}
>
<IconButton
data-testid="more-button"
icon={<MoreHorizontalIcon />}
type="plain"
/>
</Menu>
</StyledOperationWrapper>
</StyledModalHeader>
<Divider style={{ margin: '12px 0px' }} />
</StyledModalHeaderContent>
)}
<StyledModalBody>
{isLoggedIn ? (
<CloudWorkSpaceList
disabled={disabled}
open={open}
onClose={onClose}
workspaces={workspaces}
onClickWorkspace={onClickWorkspace}
onClickWorkspaceSetting={onClickWorkspaceSetting}
onNewWorkspace={onNewWorkspace}
onAddWorkspace={onAddWorkspace}
currentWorkspaceId={currentWorkspaceId}
onMoveWorkspace={onMoveWorkspace}
/>
{isLoggedIn && cloudWorkspaces.length !== 0 ? (
<>
<CloudWorkSpaceList
disabled={disabled}
open={open}
onClose={onClose}
workspaces={workspaces}
onClickWorkspace={onClickWorkspace}
onClickWorkspaceSetting={onClickWorkspaceSetting}
onNewWorkspace={onNewWorkspace}
onAddWorkspace={onAddWorkspace}
currentWorkspaceId={currentWorkspaceId}
onMoveWorkspace={onMoveWorkspace}
/>
<Divider style={{ margin: '12px 0px', minHeight: '1px' }} />
</>
) : null}
<StyledModalHeader>
<StyledModalTitle>{t['Local Workspace']()}</StyledModalTitle>
<StyledWorkspaceFlavourTitle>
{t['com.affine.workspace.local']()}
</StyledWorkspaceFlavourTitle>
</StyledModalHeader>
<StyledModalContent>
<WorkspaceList
disabled={disabled}
items={
workspaces.filter(
({ flavour }) => flavour !== WorkspaceFlavour.PUBLIC
) as (AffineCloudWorkspace | LocalWorkspace)[]
}
items={localWorkspaces}
currentWorkspaceId={currentWorkspaceId}
onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting}
@@ -70,7 +70,6 @@ export const StyledCreateWorkspaceCard = styled('div')(() => {
});
export const StyledCreateWorkspaceCardPillContainer = styled('div')(() => {
return {
padding: '12px',
borderRadius: '10px',
display: 'flex',
margin: '-8px -4px',
@@ -173,6 +172,7 @@ export const StyledModalContent = styled('div')({
flexWrap: 'wrap',
flexDirection: 'column',
width: '100%',
gap: '4px',
});
export const StyledModalFooterContent = styled('div')({
@@ -180,7 +180,7 @@ export const StyledModalFooterContent = styled('div')({
flexWrap: 'wrap',
flexDirection: 'column',
width: '100%',
padding: '12px',
marginTop: '12px',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
});
@@ -189,7 +189,6 @@ export const StyledModalHeaderContent = styled('div')({
flexWrap: 'wrap',
flexDirection: 'column',
width: '100%',
padding: '12px 12px 0px 12px',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
});
@@ -219,19 +218,27 @@ export const StyledModalHeader = styled('div')(() => {
left: 0,
top: 0,
borderRadius: '24px 24px 0 0',
padding: '12px 14px',
padding: '0px 14px',
...displayFlex('space-between', 'center'),
};
});
export const StyledModalBody = styled('div')(() => {
return {
padding: '0px 12px',
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '12px',
gap: '4px',
flex: 1,
overflowY: 'auto',
};
});
export const StyledWorkspaceFlavourTitle = styled('div')(() => {
return {
fontSize: '12px',
fontWeight: 600,
color: 'var(--affine-text-secondary-color)',
lineHeight: '20px',
};
});
+18 -4
View File
@@ -6,15 +6,16 @@ import {
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type {
import {
WorkspaceFlavour,
WorkspaceHeaderProps,
type WorkspaceHeaderProps,
} from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useCallback } from 'react';
import { useGetPageInfoById } from '../hooks/use-get-page-info';
import { useWorkspace } from '../hooks/use-workspace';
import { SharePageModal } from './affine/share-page-modal';
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
import { filterContainerStyle } from './filter-container.css';
import { Header } from './pure/header';
@@ -77,7 +78,6 @@ export function WorkspaceHeader({
const setting = useCollectionManager(currentWorkspaceId);
const currentWorkspace = useWorkspace(currentWorkspaceId);
const getPageInfoById = useGetPageInfoById(
currentWorkspace.blockSuiteWorkspace
);
@@ -117,6 +117,15 @@ export function WorkspaceHeader({
// route in edit page
if ('pageId' in currentEntry) {
const isCloudWorkspace =
currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const currentPage = currentWorkspace.blockSuiteWorkspace.getPage(
currentEntry.pageId
);
const sharePageModal =
isCloudWorkspace && currentPage ? (
<SharePageModal workspace={currentWorkspace} page={currentPage} />
) : null;
return (
<Header
center={
@@ -125,7 +134,12 @@ export function WorkspaceHeader({
pageId={currentEntry.pageId}
/>
}
right={<PluginHeader />}
right={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{sharePageModal}
<PluginHeader />
</div>
}
/>
);
}