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
+164
View File
@@ -0,0 +1,164 @@
import type {
AffineCloudWorkspace,
WorkspaceCRUD,
} from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import {
createWorkspaceMutation,
deleteWorkspaceMutation,
getWorkspaceQuery,
getWorkspacesQuery,
} from '@affine/graphql';
import { fetcher } from '@affine/workspace/affine/gql';
import { getOrCreateWorkspace } from '@affine/workspace/manager';
import { createIndexeddbStorage, Workspace } from '@blocksuite/store';
import { migrateLocalBlobStorage } from '@toeverything/infra/blocksuite';
import {
createIndexedDBProvider,
DEFAULT_DB_NAME,
} from '@toeverything/y-indexeddb';
import { getSession } from 'next-auth/react';
import { proxy } from 'valtio/vanilla';
const Y = Workspace.Y;
async function deleteLocalBlobStorage(id: string) {
const storage = createIndexeddbStorage(id);
const keys = await storage.crud.list();
for (const key of keys) {
await storage.crud.delete(key);
}
}
// we don't need to persistence the state into local storage
// because if a user clicks create multiple time and nothing happened
// because of the server delay or something, he/she will wait.
// and also the user journey of creating workspace is long.
const createdWorkspaces = proxy<string[]>([]);
export const CRUD: WorkspaceCRUD<WorkspaceFlavour.AFFINE_CLOUD> = {
create: async blockSuiteWorkspace => {
if (createdWorkspaces.some(id => id === blockSuiteWorkspace.id)) {
throw new Error('workspace already created');
}
const { createWorkspace } = await fetcher({
query: createWorkspaceMutation,
variables: {
init: new File(
[Y.encodeStateAsUpdate(blockSuiteWorkspace.doc)],
'initBinary.yDoc'
),
},
});
createdWorkspaces.push(blockSuiteWorkspace.id);
const newBLockSuiteWorkspace = getOrCreateWorkspace(
createWorkspace.id,
WorkspaceFlavour.AFFINE_CLOUD
);
Y.applyUpdate(
newBLockSuiteWorkspace.doc,
Y.encodeStateAsUpdate(blockSuiteWorkspace.doc)
);
await Promise.all(
[...blockSuiteWorkspace.doc.subdocs].map(async subdoc => {
subdoc.load();
return subdoc.whenLoaded.then(() => {
newBLockSuiteWorkspace.doc.subdocs.forEach(newSubdoc => {
if (newSubdoc.guid === subdoc.guid) {
Y.applyUpdate(newSubdoc, Y.encodeStateAsUpdate(subdoc));
}
});
});
})
);
const provider = createIndexedDBProvider(
newBLockSuiteWorkspace.doc,
DEFAULT_DB_NAME
);
provider.connect();
migrateLocalBlobStorage(blockSuiteWorkspace.id, createWorkspace.id)
.then(() => deleteLocalBlobStorage(blockSuiteWorkspace.id))
.catch(e => {
console.error('error when moving blob storage:', e);
});
// todo(himself65): delete old workspace in the future
return createWorkspace.id;
},
delete: async workspace => {
await fetcher({
query: deleteWorkspaceMutation,
variables: {
id: workspace.id,
},
});
},
get: async id => {
if (!environment.isServer && !navigator.onLine) {
// no network
return null;
}
if (
!(await getSession()
.then(() => true)
.catch(() => false))
) {
return null;
}
try {
await fetcher({
query: getWorkspaceQuery,
variables: {
id,
},
});
return {
id,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
blockSuiteWorkspace: getOrCreateWorkspace(
id,
WorkspaceFlavour.AFFINE_CLOUD
),
} satisfies AffineCloudWorkspace;
} catch (e) {
console.error('error when fetching cloud workspace:', e);
return null;
}
},
list: async () => {
if (!environment.isServer && !navigator.onLine) {
// no network
return [];
}
if (
!(await getSession()
.then(() => true)
.catch(() => false))
) {
return [];
}
try {
const { workspaces } = await fetcher({
query: getWorkspacesQuery,
});
const ids = workspaces.map(({ id }) => id);
return ids.map(
id =>
({
id,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
blockSuiteWorkspace: getOrCreateWorkspace(
id,
WorkspaceFlavour.AFFINE_CLOUD
),
}) satisfies AffineCloudWorkspace
);
} catch (e) {
console.error('error when fetching cloud workspaces:', e);
return [];
}
},
};
+75
View File
@@ -0,0 +1,75 @@
import { initEmptyPage } from '@affine/env/blocksuite';
import { PageNotFoundError } from '@affine/env/constant';
import type {
WorkspaceFlavour,
WorkspaceUISchema,
} from '@affine/env/workspace';
import { lazy, useCallback } from 'react';
import { useIsWorkspaceOwner } from '../../hooks/affine/use-is-workspace-owner';
import { useWorkspace } from '../../hooks/use-workspace';
import {
BlockSuitePageList,
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
WorkspaceHeader,
} from '../shared';
const LoginCard = lazy(() =>
import('../../components/cloud/login-card').then(({ LoginCard }) => ({
default: LoginCard,
}))
);
export const UI = {
Provider,
LoginCard,
Header: WorkspaceHeader,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
if (!page) {
throw new PageNotFoundError(workspace.blockSuiteWorkspace, currentPageId);
}
return (
<>
<PageDetailEditor
pageId={currentPageId}
onInit={useCallback(async page => initEmptyPage(page), [])}
onLoad={onLoadEditor}
workspace={workspace.blockSuiteWorkspace}
/>
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
return (
<BlockSuitePageList
listType="all"
collection={collection}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: ({
currentWorkspaceId,
onTransformWorkspace,
onDeleteLocalWorkspace,
onDeleteCloudWorkspace,
onLeaveWorkspace,
}) => {
const isOwner = useIsWorkspaceOwner(currentWorkspaceId);
return (
<NewWorkspaceSettingDetail
onDeleteLocalWorkspace={onDeleteLocalWorkspace}
onDeleteCloudWorkspace={onDeleteCloudWorkspace}
onLeaveWorkspace={onLeaveWorkspace}
workspaceId={currentWorkspaceId}
onTransferWorkspace={onTransformWorkspace}
isOwner={isOwner}
/>
);
},
} satisfies WorkspaceUISchema<WorkspaceFlavour.AFFINE_CLOUD>;
+10 -5
View File
@@ -29,6 +29,7 @@ import {
BlockSuitePageList,
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
WorkspaceHeader,
} from '../shared';
@@ -39,6 +40,7 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
flavour: WorkspaceFlavour.LOCAL,
loadPriority: LoadPriority.LOW,
Events: {
'app:access': async () => true,
'app:init': () => {
const blockSuiteWorkspace = getOrCreateWorkspace(
nanoid(),
@@ -79,9 +81,7 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
CRUD,
UI: {
Header: WorkspaceHeader,
Provider: ({ children }) => {
return <>{children}</>;
},
Provider,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useStaticBlockSuiteWorkspace(currentWorkspaceId);
const page = workspace.getPage(currentPageId);
@@ -111,14 +111,19 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
},
NewSettingsDetail: ({
currentWorkspaceId,
onDeleteWorkspace,
onTransformWorkspace,
onDeleteLocalWorkspace,
onDeleteCloudWorkspace,
onLeaveWorkspace,
}) => {
return (
<NewWorkspaceSettingDetail
onDeleteWorkspace={onDeleteWorkspace}
onDeleteLocalWorkspace={onDeleteLocalWorkspace}
onDeleteCloudWorkspace={onDeleteCloudWorkspace}
onLeaveWorkspace={onLeaveWorkspace}
workspaceId={currentWorkspaceId}
onTransferWorkspace={onTransformWorkspace}
isOwner={true}
/>
);
},
@@ -0,0 +1,45 @@
import { initEmptyPage } from '@affine/env/blocksuite';
import { PageNotFoundError } from '@affine/env/constant';
import type { WorkspaceFlavour } from '@affine/env/workspace';
import { type WorkspaceUISchema } from '@affine/env/workspace';
import { useCallback } from 'react';
import { useWorkspace } from '../../hooks/use-workspace';
import { BlockSuitePageList, PageDetailEditor, Provider } from '../shared';
export const UI = {
Provider,
Header: () => {
return null;
},
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
if (!page) {
throw new PageNotFoundError(workspace.blockSuiteWorkspace, currentPageId);
}
return (
<>
<PageDetailEditor
pageId={currentPageId}
onInit={useCallback(async page => initEmptyPage(page), [])}
onLoad={onLoadEditor}
workspace={workspace.blockSuiteWorkspace}
/>
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
return (
<BlockSuitePageList
listType="all"
collection={collection}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: () => {
throw new Error('Not implemented');
},
} satisfies WorkspaceUISchema<WorkspaceFlavour.AFFINE_PUBLIC>;
+6
View File
@@ -1,5 +1,11 @@
import { lazy } from 'react';
export const Provider = lazy(() =>
import('../components/cloud/provider').then(({ Provider }) => ({
default: Provider,
}))
);
export const NewWorkspaceSettingDetail = lazy(() =>
import('../components/affine/new-workspace-setting-detail').then(
({ WorkspaceSettingDetail }) => ({
+20 -26
View File
@@ -10,7 +10,10 @@ import {
WorkspaceFlavour,
} from '@affine/env/workspace';
import { CRUD as CloudCRUD } from './cloud/crud';
import { UI as CloudUI } from './cloud/ui';
import { LocalAdapter } from './local';
import { UI as PublicCloudUI } from './public-cloud/ui';
const unimplemented = () => {
throw new Error('Not implemented');
@@ -26,26 +29,24 @@ export const WorkspaceAdapters = {
releaseType: ReleaseType.UNRELEASED,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
loadPriority: LoadPriority.HIGH,
Events: {} as Partial<AppEvents>,
// todo: implement this
CRUD: {
get: unimplemented,
list: bypassList,
delete: unimplemented,
create: unimplemented,
},
// todo: implement this
UI: {
Provider: unimplemented,
Header: unimplemented,
PageDetail: unimplemented,
PageList: unimplemented,
NewSettingsDetail: unimplemented,
},
Events: {
'app:access': async () => {
try {
const { getSession } = await import('next-auth/react');
const session = await getSession();
return !!session;
} catch (e) {
console.error('failed to get session', e);
return false;
}
},
} as Partial<AppEvents>,
CRUD: CloudCRUD,
UI: CloudUI,
},
[WorkspaceFlavour.PUBLIC]: {
[WorkspaceFlavour.AFFINE_PUBLIC]: {
releaseType: ReleaseType.UNRELEASED,
flavour: WorkspaceFlavour.PUBLIC,
flavour: WorkspaceFlavour.AFFINE_PUBLIC,
loadPriority: LoadPriority.LOW,
Events: {} as Partial<AppEvents>,
// todo: implement this
@@ -55,14 +56,7 @@ export const WorkspaceAdapters = {
delete: unimplemented,
create: unimplemented,
},
// todo: implement this
UI: {
Provider: unimplemented,
Header: unimplemented,
PageDetail: unimplemented,
PageList: unimplemented,
NewSettingsDetail: unimplemented,
},
UI: PublicCloudUI,
},
} satisfies {
[Key in WorkspaceFlavour]: WorkspaceAdapter<Key>;
+14 -11
View File
@@ -7,6 +7,7 @@ import { WorkspaceFallback } from '@affine/component/workspace';
import { CacheProvider } from '@emotion/react';
import { getCurrentStore } from '@toeverything/infra/atom';
import { use } from 'foxact/use';
import { SessionProvider } from 'next-auth/react';
import type { PropsWithChildren, ReactElement } from 'react';
import { lazy, memo, Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
@@ -47,16 +48,18 @@ const languageLoadingPromise = loadLanguage().catch(console.error);
export const App = memo(function App() {
use(languageLoadingPromise);
return (
<CacheProvider value={cache}>
<AffineContext store={getCurrentStore()}>
<DebugProvider>
<RouterProvider
fallbackElement={<WorkspaceFallback key="RouterFallback" />}
router={router}
future={future}
/>
</DebugProvider>
</AffineContext>
</CacheProvider>
<SessionProvider refetchOnWindowFocus>
<CacheProvider value={cache}>
<AffineContext store={getCurrentStore()}>
<DebugProvider>
<RouterProvider
fallbackElement={<WorkspaceFallback key="RouterFallback" />}
router={router}
future={future}
/>
</DebugProvider>
</AffineContext>
</CacheProvider>
</SessionProvider>
);
});
+17 -1
View File
@@ -3,9 +3,9 @@ import { atom } from 'jotai';
import { atomFamily, atomWithStorage } from 'jotai/utils';
import type { AtomFamily } from 'jotai/vanilla/utils/atomFamily';
import type { AuthProps } from '../components/affine/auth';
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
import type { SettingProps } from '../components/affine/setting-modal';
// modal atoms
export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
@@ -22,6 +22,22 @@ export const openSettingModalAtom = atom<SettingAtom>({
open: false,
});
export type AuthAtom = {
openModal: boolean;
state: AuthProps['state'];
email?: string;
emailType?: AuthProps['emailType'];
// Only used for sign in page callback, after called, it will be set to undefined
onceSignedIn?: () => void;
};
export const authAtom = atom<AuthAtom>({
openModal: false,
state: 'signIn',
email: '',
emailType: 'changeEmail',
});
export const openDisableCloudAlertModalAtom = atom(false);
type PageMode = 'page' | 'edgeless';
@@ -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>
}
/>
);
}
@@ -0,0 +1,10 @@
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
export function useCurrenLoginStatus():
| 'authenticated'
| 'unauthenticated'
| 'loading' {
const session = useSession();
return session.status;
}
@@ -0,0 +1,45 @@
import type { DefaultSession } from 'next-auth';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useSession } from 'next-auth/react';
export type CheckedUser = {
id: string;
name: string;
email: string;
image: string;
hasPassword: boolean;
update: ReturnType<typeof useSession>['update'];
};
// FIXME: Should this namespace be here?
declare module 'next-auth' {
interface Session {
user: {
id: string;
hasPassword: boolean;
} & DefaultSession['user'];
}
}
/**
* This hook checks if the user is logged in.
* If not, it will throw an error.
*/
export function useCurrentUser(): CheckedUser {
const { data: session, status, update } = useSession();
// If you are seeing this error, it means that you are not logged in.
// This should be prohibited in the development environment, please re-write your component logic.
if (status === 'unauthenticated') {
throw new Error('session.status should be authenticated');
}
const user = session?.user;
return {
id: user?.id ?? 'REPLACE_ME_DEFAULT_ID',
name: user?.name ?? 'REPLACE_ME_DEFAULT_NAME',
email: user?.email ?? 'REPLACE_ME_DEFAULT_EMAIL',
image: user?.image ?? 'REPLACE_ME_DEFAULT_URL',
hasPassword: user?.hasPassword ?? false,
update,
};
}
@@ -0,0 +1,30 @@
import type { Permission } from '@affine/graphql';
import { inviteByEmailMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useInviteMember(workspaceId: string) {
const { trigger, isMutating } = useMutation({
mutation: inviteByEmailMutation,
});
const mutate = useMutateCloud();
return {
invite: useCallback(
async (email: string, permission: Permission, sendInviteMail = false) => {
const res = await trigger({
workspaceId,
email,
permission,
sendInviteMail,
});
await mutate();
// return is successful
return res?.invite;
},
[mutate, trigger, workspaceId]
),
isMutating,
};
}
@@ -0,0 +1,57 @@
import {
getWorkspaceSharedPagesQuery,
revokePageMutation,
sharePageMutation,
} from '@affine/graphql';
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
import { useCallback, useMemo } from 'react';
export function useIsSharedPage(
workspaceId: string,
pageId: string
): [isSharedPage: boolean, setSharedPage: (enable: boolean) => void] {
const { data, mutate } = useQuery({
query: getWorkspaceSharedPagesQuery,
variables: {
workspaceId,
},
});
const { trigger: enableSharePage } = useMutation({
mutation: sharePageMutation,
});
const { trigger: disableSharePage } = useMutation({
mutation: revokePageMutation,
});
return [
useMemo(
() => data.workspace.sharedPages.some(id => id === pageId),
[data.workspace.sharedPages, pageId]
),
useCallback(
(enable: boolean) => {
// todo: push notification
if (enable) {
enableSharePage({
workspaceId,
pageId,
})
.then(() => {
return mutate();
})
.catch(console.error);
} else {
disableSharePage({
workspaceId,
pageId,
})
.then(() => {
return mutate();
})
.catch(console.error);
}
mutate().catch(console.error);
},
[disableSharePage, enableSharePage, mutate, pageId, workspaceId]
),
];
}
@@ -0,0 +1,13 @@
import { getIsOwnerQuery } from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export function useIsWorkspaceOwner(workspaceId: string) {
const { data } = useQuery({
query: getIsOwnerQuery,
variables: {
workspaceId,
},
});
return data.isOwner;
}
@@ -0,0 +1,23 @@
import { leaveWorkspaceMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useAppHelper } from '../use-workspaces';
export function useLeaveWorkspace() {
const { deleteWorkspaceMeta } = useAppHelper();
const { trigger: leaveWorkspace } = useMutation({
mutation: leaveWorkspaceMutation,
});
return useCallback(
async (workspaceId: string) => {
deleteWorkspaceMeta(workspaceId);
await leaveWorkspace({
workspaceId,
});
},
[deleteWorkspaceMeta, leaveWorkspace]
);
}
+19
View File
@@ -0,0 +1,19 @@
import {
type GetMembersByWorkspaceIdQuery,
getMembersByWorkspaceIdQuery,
} from '@affine/graphql';
import { useQuery } from '@affine/workspace/affine/gql';
export type Member = Omit<
GetMembersByWorkspaceIdQuery['workspace']['members'][number],
'__typename'
>;
export function useMembers(workspaceId: string) {
const { data } = useQuery({
query: getMembersByWorkspaceIdQuery,
variables: {
workspaceId,
},
});
return data.workspace.members;
}
@@ -0,0 +1,14 @@
import { useCallback } from 'react';
import { useSWRConfig } from 'swr';
export function useMutateCloud() {
const { mutate } = useSWRConfig();
return useCallback(async () => {
return mutate(key => {
if (Array.isArray(key)) {
return key[0] === 'cloud';
}
return false;
});
}, [mutate]);
}
@@ -0,0 +1,23 @@
import { revokeMemberPermissionMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useRevokeMemberPermission(workspaceId: string) {
const mutate = useMutateCloud();
const { trigger } = useMutation({
mutation: revokeMemberPermissionMutation,
});
return useCallback(
async (userId: string) => {
await trigger({
workspaceId,
userId,
});
await mutate();
},
[mutate, trigger, workspaceId]
);
}
@@ -0,0 +1,15 @@
'use client';
import { useMemo } from 'react';
export function useShareLink(workspaceId: string): string {
return useMemo(() => {
if (environment.isServer) {
throw new Error('useShareLink is not available on server side');
}
if (environment.isDesktop) {
return '???';
} else {
return origin + '/share/' + workspaceId;
}
}, [workspaceId]);
}
@@ -0,0 +1,22 @@
import { setWorkspacePublicByIdMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import { useCallback } from 'react';
import { useMutateCloud } from './use-mutate-cloud';
export function useToggleCloudPublic(workspaceId: string) {
const mutate = useMutateCloud();
const { trigger } = useMutation({
mutation: setWorkspacePublicByIdMutation,
});
return useCallback(
async (isPublic: boolean) => {
await trigger({
id: workspaceId,
public: isPublic,
});
await mutate();
},
[mutate, trigger, workspaceId]
);
}
@@ -1,34 +1,62 @@
import type { WorkspaceRegistry } from '@affine/env/workspace';
import type { WorkspaceFlavour } from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace';
import {
rootWorkspacesMetadataAtom,
workspaceAdaptersAtom,
} from '@affine/workspace/atom';
import { currentPageIdAtom } from '@toeverything/infra/atom';
import { useSetAtom } from 'jotai';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import { useAtomValue, useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { useTransformWorkspace } from '../use-transform-workspace';
import { openSettingModalAtom } from '../../atoms';
import { useNavigateHelper } from '../use-navigate-helper';
export function useOnTransformWorkspace() {
const transformWorkspace = useTransformWorkspace();
const setWorkspaceId = useSetAtom(currentPageIdAtom);
const setSettingModal = useSetAtom(openSettingModalAtom);
const WorkspaceAdapters = useAtomValue(workspaceAdaptersAtom);
const setMetadata = useSetAtom(rootWorkspacesMetadataAtom);
const { openPage } = useNavigateHelper();
const currentPageId = useAtomValue(currentPageIdAtom);
return useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<void> => {
const workspaceId = await transformWorkspace(from, to, workspace);
// create first, then delete, in case of failure
const newId = await WorkspaceAdapters[to].CRUD.create(
workspace.blockSuiteWorkspace
);
await WorkspaceAdapters[from].CRUD.delete(workspace.blockSuiteWorkspace);
setMetadata(workspaces => {
const idx = workspaces.findIndex(ws => ws.id === workspace.id);
workspaces.splice(idx, 1, {
id: newId,
flavour: to,
version: WorkspaceVersion.SubDoc,
});
return [...workspaces];
}, newId);
// fixme(himself65): setting modal could still open and open the non-exist workspace
setSettingModal(settings => ({
...settings,
open: false,
}));
window.dispatchEvent(
new CustomEvent('affine-workspace:transform', {
detail: {
from,
to,
oldId: workspace.id,
newId: workspaceId,
newId: newId,
},
})
);
setWorkspaceId(workspaceId);
openPage(newId, currentPageId ?? WorkspaceSubPath.ALL);
},
[setWorkspaceId, transformWorkspace]
[WorkspaceAdapters, setMetadata, setSettingModal, openPage, currentPageId]
);
}
+28 -2
View File
@@ -1,7 +1,11 @@
import type { WorkspaceSubPath } from '@affine/env/workspace';
import { useCallback } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation, useNavigate } from 'react-router-dom';
import {
type NavigateOptions,
useLocation,
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
useNavigate,
} from 'react-router-dom';
export enum RouteLogic {
REPLACE = 'replace',
@@ -78,6 +82,26 @@ export function useNavigateHelper() {
},
[navigate]
);
const jumpToExpired = useCallback(
(logic: RouteLogic = RouteLogic.PUSH) => {
return navigate('/expired', {
replace: logic === RouteLogic.REPLACE,
});
},
[navigate]
);
const jumpToSignIn = useCallback(
(
logic: RouteLogic = RouteLogic.PUSH,
otherOptions?: Omit<NavigateOptions, 'replace'>
) => {
return navigate('/signIn', {
replace: logic === RouteLogic.REPLACE,
...otherOptions,
});
},
[navigate]
);
return {
jumpToPage,
@@ -86,5 +110,7 @@ export function useNavigateHelper() {
jumpToIndex,
jumpTo404,
openPage,
jumpToExpired,
jumpToSignIn,
};
}
@@ -1,41 +0,0 @@
import type { WorkspaceFlavour } from '@affine/env/workspace';
import type { WorkspaceRegistry } from '@affine/env/workspace';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { WorkspaceVersion } from '@toeverything/infra/blocksuite';
import { useSetAtom } from 'jotai';
import { useCallback } from 'react';
import { WorkspaceAdapters } from '../adapters/workspace';
/**
* Transform workspace from one flavor to another
*
* The logic here is to delete the old workspace and create a new one.
*/
export function useTransformWorkspace() {
const set = useSetAtom(rootWorkspacesMetadataAtom);
return useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<string> => {
// create first, then delete, in case of failure
const newId = await WorkspaceAdapters[to].CRUD.create(
workspace.blockSuiteWorkspace
);
await WorkspaceAdapters[from].CRUD.delete(workspace as any);
set(workspaces => {
const idx = workspaces.findIndex(ws => ws.id === workspace.id);
workspaces.splice(idx, 1, {
id: newId,
flavour: to,
version: WorkspaceVersion.DatabaseV3,
});
return [...workspaces];
});
return newId;
},
[set]
);
}
+2 -3
View File
@@ -1,3 +1,4 @@
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { assertExists } from '@blocksuite/global/utils';
import type { Workspace } from '@blocksuite/store';
@@ -5,8 +6,6 @@ import { useStaticBlockSuiteWorkspace } from '@toeverything/infra/__internal__/r
import type { Atom } from 'jotai';
import { atom, useAtomValue } from 'jotai';
import type { AffineOfficialWorkspace } from '../shared';
const workspaceWeakMap = new WeakMap<
Workspace,
Atom<Promise<AffineOfficialWorkspace>>
@@ -18,7 +17,7 @@ export function useWorkspace(workspaceId: string): AffineOfficialWorkspace {
const baseAtom = atom(async get => {
const metadata = await get(rootWorkspacesMetadataAtom);
const flavour = metadata.find(({ id }) => id === workspaceId)?.flavour;
assertExists(flavour);
assertExists(flavour, 'workspace flavour not found');
return {
id: workspaceId,
flavour,
+21
View File
@@ -43,6 +43,21 @@ export function useAppHelper() {
},
[set]
),
addCloudWorkspace: useCallback(
(workspaceId: string) => {
getOrCreateWorkspace(workspaceId, WorkspaceFlavour.AFFINE_CLOUD);
set(workspaces => [
...workspaces,
{
id: workspaceId,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
version: WorkspaceVersion.DatabaseV3,
},
]);
logger.debug('imported cloud workspace', workspaceId);
},
[set]
),
createLocalWorkspace: useCallback(
async (name: string): Promise<string> => {
const blockSuiteWorkspace = getOrCreateWorkspace(
@@ -97,5 +112,11 @@ export function useAppHelper() {
},
[jotaiWorkspaces, set]
),
deleteWorkspaceMeta: useCallback(
(workspaceId: string) => {
set(workspaces => workspaces.filter(ws => ws.id !== workspaceId));
},
[set]
),
};
}
+11 -28
View File
@@ -33,16 +33,16 @@ import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/reac
import { currentWorkspaceIdAtom } from '@toeverything/infra/atom';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactElement } from 'react';
import { lazy, Suspense, useCallback, useMemo } from 'react';
import { lazy, Suspense, useCallback } from 'react';
import { useLocation, useParams } from 'react-router-dom';
import { WorkspaceAdapters } from '../adapters/workspace';
import {
openQuickSearchModalAtom,
openSettingModalAtom,
openWorkspacesModalAtom,
} from '../atoms';
import { useAppSetting } from '../atoms/settings';
import { AdapterProviderWrapper } from '../components/adapter-worksapce-wrapper';
import { AppContainer } from '../components/affine/app-container';
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
import type { IslandItemNames } from '../components/pure/help-island';
@@ -68,10 +68,6 @@ const QuickSearchModal = lazy(() =>
}))
);
function DefaultProvider({ children }: PropsWithChildren) {
return <>{children}</>;
}
export const QuickSearch = () => {
const [currentWorkspace] = useCurrentWorkspace();
const [openQuickSearchModal, setOpenQuickSearchModalAtom] = useAtom(
@@ -117,32 +113,19 @@ export const CurrentWorkspaceContext = ({
export const WorkspaceLayout = function WorkspacesSuspense({
children,
}: PropsWithChildren) {
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
const jotaiWorkspaces = useAtomValue(rootWorkspacesMetadataAtom);
const meta = useMemo(
() => jotaiWorkspaces.find(x => x.id === currentWorkspaceId),
[currentWorkspaceId, jotaiWorkspaces]
);
const Provider =
(meta && WorkspaceAdapters[meta.flavour].UI.Provider) ?? DefaultProvider;
return (
<>
{/* load all workspaces is costly, do not block the whole UI */}
<Suspense fallback={null}>
<AllWorkspaceModals />
<CurrentWorkspaceContext>
{/* fixme(himself65): don't re-render whole modals */}
<CurrentWorkspaceModals key={currentWorkspaceId} />
</CurrentWorkspaceContext>
</Suspense>
<AdapterProviderWrapper>
<CurrentWorkspaceContext>
<Provider>
{/* load all workspaces is costly, do not block the whole UI */}
<Suspense>
<AllWorkspaceModals />
<CurrentWorkspaceModals />
</Suspense>
<Suspense fallback={<WorkspaceFallback />}>
<WorkspaceLayoutInner>{children}</WorkspaceLayoutInner>
</Provider>
</Suspense>
</CurrentWorkspaceContext>
</>
</AdapterProviderWrapper>
);
};
+133
View File
@@ -0,0 +1,133 @@
import {
ChangeEmailPage,
ChangePasswordPage,
SetPasswordPage,
SignInSuccessPage,
SignUpPage,
} from '@affine/component/auth-components';
import { isDesktop } from '@affine/env/constant';
import { changeEmailMutation, changePasswordMutation } from '@affine/graphql';
import { useMutation } from '@affine/workspace/affine/gql';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import { type LoaderFunction, redirect, useParams } from 'react-router-dom';
import { z } from 'zod';
import { useCurrenLoginStatus } from '../hooks/affine/use-curren-login-status';
import { useCurrentUser } from '../hooks/affine/use-current-user';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
const authTypeSchema = z.enum([
'setPassword',
'signIn',
'changePassword',
'signUp',
'changeEmail',
]);
export const AuthPage = (): ReactElement | null => {
const user = useCurrentUser();
const { authType } = useParams();
const { trigger: changePassword } = useMutation({
mutation: changePasswordMutation,
});
const { trigger: changeEmail } = useMutation({
mutation: changeEmailMutation,
});
const { jumpToIndex } = useNavigateHelper();
const onChangeEmail = useCallback(
async (email: string) => {
const res = await changeEmail({
id: user.id,
newEmail: email,
});
return !!res?.changeEmail;
},
[changeEmail, user.id]
);
const onSetPassword = useCallback(
(password: string) => {
changePassword({
id: user.id,
newPassword: password,
}).catch(console.error);
},
[changePassword, user.id]
);
const onOpenAffine = useCallback(() => {
if (isDesktop) {
window.apis.ui.handleFinishLogin();
} else {
jumpToIndex(RouteLogic.REPLACE);
}
}, [jumpToIndex]);
switch (authType) {
case 'signUp': {
return (
<SignUpPage
user={user}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'signIn': {
return <SignInSuccessPage onOpenAffine={onOpenAffine} />;
}
case 'changePassword': {
return (
<ChangePasswordPage
user={user}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'setPassword': {
return (
<SetPasswordPage
user={user}
onSetPassword={onSetPassword}
onOpenAffine={onOpenAffine}
/>
);
}
case 'changeEmail': {
return (
<ChangeEmailPage
user={user}
onChangeEmail={onChangeEmail}
onOpenAffine={onOpenAffine}
/>
);
}
}
return null;
};
export const loader: LoaderFunction = async args => {
if (!args.params.authType) {
return redirect('/404');
}
if (!authTypeSchema.safeParse(args.params.authType).success) {
return redirect('/404');
}
return null;
};
export const Component = () => {
const loginStatus = useCurrenLoginStatus();
const { jumpToExpired } = useNavigateHelper();
if (loginStatus === 'unauthenticated') {
jumpToExpired(RouteLogic.REPLACE);
}
if (loginStatus === 'authenticated') {
return <AuthPage />;
}
return null;
};
+25
View File
@@ -0,0 +1,25 @@
import { AuthPageContainer } from '@affine/component/auth-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useCallback } from 'react';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
export const Component = () => {
const t = useAFFiNEI18N();
const { jumpToIndex } = useNavigateHelper();
const onOpenAffine = useCallback(() => {
jumpToIndex(RouteLogic.REPLACE);
}, [jumpToIndex]);
return (
<AuthPageContainer
title={t['com.affine.expired.page.title']()}
subtitle={t['com.affine.expired.page.subtitle']()}
>
<Button type="primary" size="large" onClick={onOpenAffine}>
{t['com.affine.auth.open.affine']()}
</Button>
</AuthPageContainer>
);
};
+103
View File
@@ -0,0 +1,103 @@
import { AcceptInvitePage } from '@affine/component/member-components';
import { WorkspaceSubPath } from '@affine/env/workspace';
import {
acceptInviteByInviteIdMutation,
type GetInviteInfoQuery,
getInviteInfoQuery,
} from '@affine/graphql';
import { fetcher } from '@affine/workspace/affine/gql';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
import { type LoaderFunction, redirect, useLoaderData } from 'react-router-dom';
import { authAtom } from '../atoms';
import { useCurrenLoginStatus } from '../hooks/affine/use-curren-login-status';
import { RouteLogic, useNavigateHelper } from '../hooks/use-navigate-helper';
import { useAppHelper } from '../hooks/use-workspaces';
export const loader: LoaderFunction = async args => {
const inviteId = args.params.inviteId || '';
const res = await fetcher({
query: getInviteInfoQuery,
variables: {
inviteId,
},
}).catch(console.error);
// If the inviteId is invalid, redirect to 404 page
if (!res || !res?.getInviteInfo) {
return redirect('/404');
}
// No mater sign in or not, we need to accept the invite
await fetcher({
query: acceptInviteByInviteIdMutation,
variables: {
workspaceId: res.getInviteInfo.workspace.id,
inviteId,
},
}).catch(console.error);
return {
inviteId,
inviteInfo: res.getInviteInfo,
};
};
export const Component = () => {
const loginStatus = useCurrenLoginStatus();
const { jumpToSignIn } = useNavigateHelper();
const { addCloudWorkspace } = useAppHelper();
const { jumpToSubPath } = useNavigateHelper();
const [, setAuthAtom] = useAtom(authAtom);
const { inviteInfo } = useLoaderData() as {
inviteId: string;
inviteInfo: GetInviteInfoQuery['getInviteInfo'];
};
const loadWorkspaceAfterSignIn = useCallback(() => {
addCloudWorkspace(inviteInfo.workspace.id);
}, [addCloudWorkspace, inviteInfo.workspace.id]);
const openWorkspace = useCallback(() => {
addCloudWorkspace(inviteInfo.workspace.id);
jumpToSubPath(
inviteInfo.workspace.id,
WorkspaceSubPath.ALL,
RouteLogic.REPLACE
);
}, [addCloudWorkspace, inviteInfo.workspace.id, jumpToSubPath]);
useEffect(() => {
if (loginStatus === 'unauthenticated') {
// We can not pass function to navigate state, so we need to save it in atom
setAuthAtom(prev => ({
...prev,
onceSignedIn: loadWorkspaceAfterSignIn,
}));
jumpToSignIn(RouteLogic.REPLACE, {
state: {
callbackURL: `/workspace/${inviteInfo.workspace.id}/all`,
},
});
}
}, [
inviteInfo.workspace.id,
jumpToSignIn,
loadWorkspaceAfterSignIn,
loginStatus,
setAuthAtom,
]);
if (loginStatus === 'authenticated') {
return (
<AcceptInvitePage
inviteInfo={inviteInfo}
onOpenWorkspace={openWorkspace}
/>
);
}
return null;
};
+73
View File
@@ -0,0 +1,73 @@
import { MainContainer } from '@affine/component/workspace';
import { DebugLogger } from '@affine/debug';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { getOrCreateWorkspace } from '@affine/workspace/manager';
import { downloadBinaryFromCloud } from '@affine/workspace/providers';
import { assertExists } from '@blocksuite/global/utils';
import type { Page } from '@blocksuite/store';
import { noop } from 'foxact/noop';
import type { ReactElement } from 'react';
import { useCallback } from 'react';
import type { LoaderFunction } from 'react-router-dom';
import { redirect, useLoaderData } from 'react-router-dom';
import { applyUpdate } from 'yjs';
import { PageDetailEditor } from '../../adapters/shared';
import { AppContainer } from '../../components/affine/app-container';
function assertArrayBuffer(value: unknown): asserts value is ArrayBuffer {
if (!(value instanceof ArrayBuffer)) {
throw new Error('value is not ArrayBuffer');
}
}
const logger = new DebugLogger('public:share-page');
export const loader: LoaderFunction = async ({ params }) => {
const workspaceId = params?.workspaceId;
const pageId = params?.pageId;
if (!workspaceId || !pageId) {
return redirect('/404');
}
const workspace = getOrCreateWorkspace(
workspaceId,
WorkspaceFlavour.AFFINE_PUBLIC
);
// download root workspace
{
const buffer = await downloadBinaryFromCloud(workspaceId, workspaceId);
assertArrayBuffer(buffer);
applyUpdate(workspace.doc, new Uint8Array(buffer));
}
const page = workspace.getPage(pageId);
assertExists(page, 'cannot find page');
// download page
{
const buffer = await downloadBinaryFromCloud(
workspaceId,
page.spaceDoc.guid
);
assertArrayBuffer(buffer);
applyUpdate(page.spaceDoc, new Uint8Array(buffer));
}
logger.info('workspace', workspace);
workspace.awarenessStore.setReadonly(page, true);
return page;
};
export const Component = (): ReactElement => {
const page = useLoaderData() as Page;
return (
<AppContainer>
<MainContainer>
<PageDetailEditor
isPublic
workspace={page.workspace}
pageId={page.id}
onInit={useCallback(() => noop, [])}
onLoad={useCallback(() => noop, [])}
/>
</MainContainer>
</AppContainer>
);
};
+75
View File
@@ -0,0 +1,75 @@
import { SignInPageContainer } from '@affine/component/auth-components';
import { useAtom } from 'jotai';
import { useCallback, useEffect } from 'react';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { useLocation, useNavigate } from 'react-router-dom';
import { authAtom } from '../atoms';
import { AuthPanel } from '../components/affine/auth';
import { useCurrenLoginStatus } from '../hooks/affine/use-curren-login-status';
interface LocationState {
state: {
callbackURL?: string;
};
}
export const Component = () => {
const [
{ state, email = '', emailType = 'changePassword', onceSignedIn },
setAuthAtom,
] = useAtom(authAtom);
const loginStatus = useCurrenLoginStatus();
const location = useLocation() as LocationState;
const navigate = useNavigate();
useEffect(() => {
const afterSignedIn = async () => {
if (loginStatus === 'authenticated') {
if (onceSignedIn) {
await onceSignedIn();
setAuthAtom(prev => ({ ...prev, onceSignedIn: undefined }));
}
if (location.state.callbackURL) {
navigate(location.state.callbackURL, {
replace: true,
});
}
}
};
afterSignedIn();
}, [
location.state.callbackURL,
loginStatus,
navigate,
onceSignedIn,
setAuthAtom,
]);
return (
<SignInPageContainer>
<AuthPanel
state={state}
email={email}
emailType={emailType}
setEmailType={useCallback(
emailType => {
setAuthAtom(prev => ({ ...prev, emailType }));
},
[setAuthAtom]
)}
setAuthState={useCallback(
state => {
setAuthAtom(prev => ({ ...prev, state }));
},
[setAuthAtom]
)}
setAuthEmail={useCallback(
email => {
setAuthAtom(prev => ({ ...prev, email }));
},
[setAuthAtom]
)}
/>
</SignInPageContainer>
);
};
+54 -10
View File
@@ -12,6 +12,7 @@ import { lazy, Suspense, useCallback, useTransition } from 'react';
import type { SettingAtom } from '../atoms';
import {
authAtom,
openCreateWorkspaceModalAtom,
openDisableCloudAlertModalAtom,
openSettingModalAtom,
@@ -25,6 +26,11 @@ const SettingModal = lazy(() =>
default: module.SettingModal,
}))
);
const Auth = lazy(() =>
import('../components/affine/auth').then(module => ({
default: module.AuthModal,
}))
);
const WorkspaceListModal = lazy(() =>
import('../components/pure/workspace-list-modal').then(module => ({
@@ -84,6 +90,45 @@ export const Setting = () => {
);
};
export const AuthModal = (): ReactElement => {
const [
{ openModal, state, email = '', emailType = 'changePassword' },
setAuthAtom,
] = useAtom(authAtom);
return (
<Auth
open={openModal}
state={state}
email={email}
emailType={emailType}
setEmailType={useCallback(
emailType => {
setAuthAtom(prev => ({ ...prev, emailType }));
},
[setAuthAtom]
)}
setOpen={useCallback(
open => {
setAuthAtom(prev => ({ ...prev, openModal: open }));
},
[setAuthAtom]
)}
setAuthState={useCallback(
state => {
setAuthAtom(prev => ({ ...prev, state }));
},
[setAuthAtom]
)}
setAuthEmail={useCallback(
email => {
setAuthAtom(prev => ({ ...prev, email }));
},
[setAuthAtom]
)}
/>
);
};
export function CurrentWorkspaceModals() {
const [currentWorkspace] = useCurrentWorkspace();
const [openDisableCloudAlertModal, setOpenDisableCloudAlertModal] = useAtom(
@@ -203,22 +248,21 @@ export const AllWorkspaceModals = (): ReactElement => {
}, [setOpenCreateWorkspaceModal])}
onCreate={useCallback(
id => {
startTransition(() => {
setOpenCreateWorkspaceModal(false);
setOpenWorkspacesModal(false);
setCurrentWorkspaceId(id);
setOpenCreateWorkspaceModal(false);
setOpenWorkspacesModal(false);
// if jumping immediately, the page may stuck in loading state
// not sure why yet .. here is a workaround
setTimeout(() => {
jumpToSubPath(id, WorkspaceSubPath.ALL);
});
},
[
jumpToSubPath,
setCurrentWorkspaceId,
setOpenCreateWorkspaceModal,
setOpenWorkspacesModal,
]
[jumpToSubPath, setOpenCreateWorkspaceModal, setOpenWorkspacesModal]
)}
/>
</Suspense>
<Suspense>
<AuthModal />
</Suspense>
</>
);
};
+20
View File
@@ -24,10 +24,30 @@ export const routes = [
},
],
},
{
path: '/share/:workspaceId/:pageId',
lazy: () => import('./pages/share/detail-page'),
},
{
path: '/404',
lazy: () => import('./pages/404'),
},
{
path: '/auth/:authType',
lazy: () => import('./pages/auth'),
},
{
path: '/expired',
lazy: () => import('./pages/expired'),
},
{
path: '/invite/:inviteId',
lazy: () => import('./pages/invite'),
},
{
path: '/signIn',
lazy: () => import('./pages/sign-in'),
},
{
path: '*',
lazy: () => import('./pages/404'),
-146
View File
@@ -1,146 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { uploadAvatarMutation } from '@affine/graphql';
import { render } from '@testing-library/react';
import type { Mock } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useMutation, useQuery } from '../gql';
let fetch: Mock;
describe('GraphQL wrapper for SWR', () => {
beforeEach(() => {
fetch = vi.fn(() =>
Promise.resolve(
new Response(JSON.stringify({ data: { hello: 1 } }), {
headers: {
'content-type': 'application/json',
},
})
)
);
vi.stubGlobal('fetch', fetch);
});
afterEach(() => {
fetch.mockReset();
});
describe('useQuery', () => {
const Component = ({ id }: { id: number }) => {
const { data, isLoading, error } = useQuery({
query: {
id: 'query',
query: `
query {
hello
}
`,
operationName: 'query',
definitionName: 'query',
},
// @ts-expect-error forgive the fake variables
variables: { id },
});
if (isLoading) {
return <div>loading</div>;
}
if (error) {
return <div>error</div>;
}
// @ts-expect-error
return <div>number: {data!.hello}</div>;
};
it('should send query correctly', async () => {
const component = <Component id={1} />;
const renderer = render(component);
const el = await renderer.findByText('number: 1');
expect(el).toMatchInlineSnapshot(`
<div>
number:${' '}
1
</div>
`);
});
it('should not send request if cache hit', async () => {
const component = <Component id={2} />;
const renderer = render(component);
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(fetch).toBeCalledTimes(1);
render(<Component id={3} />);
expect(fetch).toBeCalledTimes(2);
});
});
describe('useMutation', () => {
const Component = () => {
const { trigger, error, isMutating } = useMutation({
mutation: {
id: 'mutation',
query: `
mutation {
hello
}
`,
operationName: 'mutation',
definitionName: 'mutation',
},
});
if (isMutating) {
return <div>mutating</div>;
}
if (error) {
return <div>error</div>;
}
return (
<div>
<button onClick={() => trigger()}>click</button>
</div>
);
};
it('should trigger mutation', async () => {
const component = <Component />;
const renderer = render(component);
const button = await renderer.findByText('click');
button.click();
expect(fetch).toBeCalledTimes(1);
renderer.rerender(component);
expect(renderer.asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div>
mutating
</div>
</DocumentFragment>
`);
});
it('should get rid of generated types', async () => {
function _NotActuallyRunDefinedForTypeTesting() {
const { trigger } = useMutation({
mutation: uploadAvatarMutation,
});
trigger({
id: '1',
avatar: new File([''], 'avatar.png'),
});
}
expect(_NotActuallyRunDefinedForTypeTesting).toBeTypeOf('function');
});
});
});
-114
View File
@@ -1,114 +0,0 @@
import type {
GraphQLQuery,
MutationOptions,
QueryOptions,
QueryResponse,
QueryVariables,
} from '@affine/graphql';
import { gqlFetcherFactory } from '@affine/graphql';
import type { GraphQLError } from 'graphql';
import type { SWRConfiguration, SWRResponse } from 'swr';
import useSWR from 'swr';
import type {
SWRMutationConfiguration,
SWRMutationResponse,
} from 'swr/mutation';
import useSWRMutation from 'swr/mutation';
const fetcher = gqlFetcherFactory(prefixUrl + '/graphql');
/**
* A `useSWR` wrapper for sending graphql queries
*
* @example
*
* ```ts
* import { someQuery, someQueryWithNoVars } from '@affine/graphql'
*
* const swrResponse1 = useQuery({
* query: workspaceByIdQuery,
* variables: { id: '1' }
* })
*
* const swrResponse2 = useQuery({
* query: someQueryWithNoVars
* })
* ```
*/
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>
): SWRResponse<QueryResponse<Query>, GraphQLError | GraphQLError[]>;
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>,
config: Omit<
SWRConfiguration<
QueryResponse<Query>,
GraphQLError | GraphQLError[],
typeof fetcher<Query>
>,
'fetcher'
>
): SWRResponse<QueryResponse<Query>, GraphQLError | GraphQLError[]>;
export function useQuery<Query extends GraphQLQuery>(
options: QueryOptions<Query>,
config?: any
) {
return useSWR(
() => [options.query.id, options.variables],
() => fetcher(options),
config
);
}
/**
* A useSWRMutation wrapper for sending graphql mutations
*
* @example
*
* ```ts
* import { someMutation } from '@affine/graphql'
*
* const { trigger } = useMutation({
* mutation: someMutation,
* })
*
* trigger({ name: 'John Doe' })
*/
export function useMutation<Mutation extends GraphQLQuery>(
options: Omit<MutationOptions<Mutation>, 'variables'>
): SWRMutationResponse<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
string,
QueryVariables<Mutation>
>;
export function useMutation<Mutation extends GraphQLQuery>(
options: Omit<MutationOptions<Mutation>, 'variables'>,
config: Omit<
SWRMutationConfiguration<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
string,
QueryVariables<Mutation>
>,
'fetcher'
>
): SWRMutationResponse<
QueryResponse<Mutation>,
GraphQLError | GraphQLError[],
string,
QueryVariables<Mutation>
>;
export function useMutation(
options: Omit<MutationOptions<GraphQLQuery>, 'variables'>,
config?: any
) {
return useSWRMutation(
options.mutation.id,
(_: string, { arg }: { arg: any }) =>
fetcher({ ...options, query: options.mutation, variables: arg }),
config
);
}
export const gql = fetcher;
-10
View File
@@ -1,18 +1,8 @@
import type {
AffineCloudWorkspace,
LocalWorkspace,
} from '@affine/env/workspace';
import type { AffinePublicWorkspace } from '@affine/env/workspace';
import type { WorkspaceRegistry } from '@affine/env/workspace';
import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
export { BlockSuiteWorkspace };
export type AffineOfficialWorkspace =
| AffineCloudWorkspace
| LocalWorkspace
| AffinePublicWorkspace;
export type AllWorkspace = WorkspaceRegistry[keyof WorkspaceRegistry];
export enum WorkspaceSubPath {
+2
View File
@@ -0,0 +1,2 @@
export const emailRegex =
/^(?:(?:[^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@(?:(?:\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|((?:[a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
+5 -2
View File
@@ -2,11 +2,14 @@ import type { ToastOptions } from '@affine/component';
import { toast as basicToast } from '@affine/component';
export const toast = (message: string, options?: ToastOptions) => {
const modal = document.querySelector(
'[role=presentation]'
) as HTMLElement | null;
const mainContainer = document.querySelector(
'.main-container'
) as HTMLElement;
) as HTMLElement | null;
return basicToast(message, {
portal: mainContainer || document.body,
portal: modal || mainContainer || document.body,
...options,
});
};