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,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}
</>
);
};