mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
refactor(infra): directory structure (#4615)
This commit is contained in:
@@ -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>;
|
||||
};
|
||||
19
packages/frontend/core/src/components/affine/README.md
Normal file
19
packages/frontend/core/src/components/affine/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# Affine Official Workspace Component
|
||||
|
||||
This component need specific configuration to work properly.
|
||||
|
||||
## Configuration
|
||||
|
||||
### SWR
|
||||
|
||||
Each component use SWR to fetch data from the API. You need to provide a configuration to SWR to make it work.
|
||||
|
||||
```tsx
|
||||
const Wrapper = () => {
|
||||
return (
|
||||
<AffineSWRConfigProvider>
|
||||
<Component />
|
||||
</AffineSWRConfigProvider>
|
||||
);
|
||||
};
|
||||
```
|
||||
@@ -0,0 +1,114 @@
|
||||
import type {
|
||||
QueryParamError,
|
||||
Unreachable,
|
||||
WorkspaceNotFoundError,
|
||||
} from '@affine/env/constant';
|
||||
import { PageNotFoundError } from '@affine/env/constant';
|
||||
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
|
||||
import {
|
||||
currentPageIdAtom,
|
||||
currentWorkspaceIdAtom,
|
||||
getCurrentStore,
|
||||
} from '@toeverything/infra/atom';
|
||||
import { useAtomValue } from 'jotai/react';
|
||||
import { Provider } from 'jotai/react';
|
||||
import type { ErrorInfo, ReactElement, ReactNode } from 'react';
|
||||
import type React from 'react';
|
||||
import { Component } from 'react';
|
||||
import { useLocation, useParams } from 'react-router-dom';
|
||||
export type AffineErrorBoundaryProps = React.PropsWithChildren;
|
||||
|
||||
type AffineError =
|
||||
| QueryParamError
|
||||
| Unreachable
|
||||
| WorkspaceNotFoundError
|
||||
| PageNotFoundError
|
||||
| Error;
|
||||
|
||||
interface AffineErrorBoundaryState {
|
||||
error: AffineError | null;
|
||||
}
|
||||
|
||||
export const DumpInfo = () => {
|
||||
const location = useLocation();
|
||||
const metadata = useAtomValue(rootWorkspacesMetadataAtom);
|
||||
const currentWorkspaceId = useAtomValue(currentWorkspaceIdAtom);
|
||||
const currentPageId = useAtomValue(currentPageIdAtom);
|
||||
const path = location.pathname;
|
||||
const query = useParams();
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
Please copy the following information and send it to the developer.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid red',
|
||||
}}
|
||||
>
|
||||
<div>path: {path}</div>
|
||||
<div>query: {JSON.stringify(query)}</div>
|
||||
<div>currentWorkspaceId: {currentWorkspaceId}</div>
|
||||
<div>currentPageId: {currentPageId}</div>
|
||||
<div>metadata: {JSON.stringify(metadata)}</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export class AffineErrorBoundary extends Component<
|
||||
AffineErrorBoundaryProps,
|
||||
AffineErrorBoundaryState
|
||||
> {
|
||||
public override state: AffineErrorBoundaryState = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
public static getDerivedStateFromError(
|
||||
error: AffineError
|
||||
): AffineErrorBoundaryState {
|
||||
return { error };
|
||||
}
|
||||
|
||||
public override componentDidCatch(error: AffineError, errorInfo: ErrorInfo) {
|
||||
console.error('Uncaught error:', error, errorInfo);
|
||||
}
|
||||
|
||||
public override render(): ReactNode {
|
||||
if (this.state.error) {
|
||||
let errorDetail: ReactElement | null = null;
|
||||
const error = this.state.error;
|
||||
if (error instanceof PageNotFoundError) {
|
||||
errorDetail = (
|
||||
<>
|
||||
<h1>Sorry.. there was an error</h1>
|
||||
<>
|
||||
<span> Page error </span>
|
||||
<span>
|
||||
Cannot find page {error.pageId} in workspace{' '}
|
||||
{error.workspace.id}
|
||||
</span>
|
||||
</>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
errorDetail = (
|
||||
<>
|
||||
<h1>Sorry.. there was an error</h1>
|
||||
{error.message ?? error.toString()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{errorDetail}
|
||||
<Provider key="JotaiProvider" store={getCurrentStore()}>
|
||||
<DumpInfo />
|
||||
</Provider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
@@ -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,22 @@
|
||||
import {
|
||||
AppContainer as AppContainerWithoutSettings,
|
||||
type WorkspaceRootProps,
|
||||
} from '@affine/component/workspace';
|
||||
|
||||
import { useAppSetting } from '../../atoms/settings';
|
||||
|
||||
export const AppContainer = (props: WorkspaceRootProps) => {
|
||||
const [appSettings] = useAppSetting();
|
||||
|
||||
return (
|
||||
<AppContainerWithoutSettings
|
||||
useNoisyBackground={appSettings.enableNoisyBackground}
|
||||
useBlurBackground={
|
||||
appSettings.enableBlurBackground &&
|
||||
environment.isDesktop &&
|
||||
environment.isMacOs
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,100 @@
|
||||
import {
|
||||
AuthContent,
|
||||
BackButton,
|
||||
CountDownRender,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { useAuth } from './use-auth';
|
||||
import { Captcha, useCaptcha } from './use-captcha';
|
||||
|
||||
export const AfterSignInSendEmail = ({
|
||||
setAuthState,
|
||||
email,
|
||||
onSignedIn,
|
||||
}: AuthPanelProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
const [verifyToken, challenge] = useCaptcha();
|
||||
|
||||
const { resendCountDown, allowSendEmail, signIn } = useAuth();
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
const onResendClick = useCallback(async () => {
|
||||
if (verifyToken) {
|
||||
await signIn(email, verifyToken, challenge);
|
||||
}
|
||||
}, [challenge, email, signIn, verifyToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['com.affine.auth.sign.in']()}
|
||||
subTitle={t['com.affine.auth.sign.in.sent.email.subtitle']()}
|
||||
/>
|
||||
<AuthContent style={{ height: 100 }}>
|
||||
{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>
|
||||
|
||||
<div className={style.resendWrapper}>
|
||||
{allowSendEmail ? (
|
||||
<>
|
||||
<Captcha />
|
||||
<Button
|
||||
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
||||
disabled={!verifyToken}
|
||||
type="plain"
|
||||
size="large"
|
||||
onClick={onResendClick}
|
||||
>
|
||||
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="resend-code-hint">
|
||||
{t['com.affine.auth.sign.auth.code.on.resend.hint']()}
|
||||
</span>
|
||||
<CountDownRender
|
||||
className={style.resendCountdown}
|
||||
timeLeft={resendCountDown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.authMessage} style={{ marginTop: 20 }}>
|
||||
{/*prettier-ignore*/}
|
||||
<Trans i18nKey="com.affine.auth.sign.auth.code.message.password">
|
||||
If you haven'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,88 @@
|
||||
import {
|
||||
AuthContent,
|
||||
BackButton,
|
||||
CountDownRender,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { type FC, useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { useAuth } from './use-auth';
|
||||
import { Captcha, useCaptcha } from './use-captcha';
|
||||
|
||||
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
|
||||
setAuthState,
|
||||
email,
|
||||
onSignedIn,
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
const [verifyToken, challenge] = useCaptcha();
|
||||
|
||||
const { resendCountDown, allowSendEmail, signUp } = useAuth();
|
||||
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
const onResendClick = useCallback(async () => {
|
||||
if (verifyToken) {
|
||||
await signUp(email, verifyToken, challenge);
|
||||
}
|
||||
}, [challenge, email, signUp, verifyToken]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['com.affine.auth.sign.up']()}
|
||||
subTitle={t['com.affine.auth.sign.up.sent.email.subtitle']()}
|
||||
/>
|
||||
<AuthContent style={{ height: 100 }}>
|
||||
{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>
|
||||
|
||||
<div className={style.resendWrapper}>
|
||||
{allowSendEmail ? (
|
||||
<>
|
||||
<Captcha />
|
||||
<Button
|
||||
style={!verifyToken ? { cursor: 'not-allowed' } : {}}
|
||||
disabled={!verifyToken}
|
||||
type="plain"
|
||||
size="large"
|
||||
onClick={onResendClick}
|
||||
>
|
||||
{t['com.affine.auth.sign.auth.code.resend.hint']()}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="resend-code-hint">
|
||||
{t['com.affine.auth.sign.auth.code.on.resend.hint']()}
|
||||
</span>
|
||||
<CountDownRender
|
||||
className={style.resendCountdown}
|
||||
timeLeft={resendCountDown}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={style.authMessage} style={{ marginTop: 20 }}>
|
||||
{t['com.affine.auth.sign.auth.code.message']()}
|
||||
</div>
|
||||
|
||||
<BackButton
|
||||
onClick={useCallback(() => {
|
||||
setAuthState('signIn');
|
||||
}, [setAuthState])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
103
packages/frontend/core/src/components/affine/auth/index.tsx
Normal file
103
packages/frontend/core/src/components/affine/auth/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
AuthModal as AuthModalBase,
|
||||
type AuthModalProps as AuthModalBaseProps,
|
||||
} from '@affine/component/auth-components';
|
||||
import { type FC, useCallback, useMemo } from 'react';
|
||||
|
||||
import { AfterSignInSendEmail } from './after-sign-in-send-email';
|
||||
import { AfterSignUpSendEmail } from './after-sign-up-send-email';
|
||||
import { NoAccess } from './no-access';
|
||||
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'
|
||||
| 'noAccess';
|
||||
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;
|
||||
};
|
||||
|
||||
const config: {
|
||||
[k in AuthProps['state']]: FC<AuthPanelProps>;
|
||||
} = {
|
||||
signIn: SignIn,
|
||||
afterSignUpSendEmail: AfterSignUpSendEmail,
|
||||
afterSignInSendEmail: AfterSignInSendEmail,
|
||||
signInWithPassword: SignInWithPassword,
|
||||
sendEmail: SendEmail,
|
||||
noAccess: NoAccess,
|
||||
};
|
||||
|
||||
export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
|
||||
open,
|
||||
state,
|
||||
setOpen,
|
||||
email,
|
||||
setAuthEmail,
|
||||
setAuthState,
|
||||
setEmailType,
|
||||
emailType,
|
||||
}) => {
|
||||
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 CurrentPanel = useMemo(() => {
|
||||
return config[state];
|
||||
}, [state]);
|
||||
|
||||
return (
|
||||
<CurrentPanel
|
||||
email={email}
|
||||
setAuthState={setAuthState}
|
||||
setAuthEmail={setAuthEmail}
|
||||
setEmailType={setEmailType}
|
||||
emailType={emailType}
|
||||
onSignedIn={onSignedIn}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
AuthContent,
|
||||
BackButton,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { NewIcon } from '@blocksuite/icons';
|
||||
import { type FC, useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
|
||||
export const NoAccess: FC<AuthPanelProps> = ({ setAuthState, onSignedIn }) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['com.affine.brand.affineCloud']()}
|
||||
subTitle={t['Early Access Stage']()}
|
||||
/>
|
||||
<AuthContent style={{ height: 162 }}>
|
||||
{t['com.affine.auth.sign.no.access.hint']()}
|
||||
<a href="https://community.affine.pro/c/insider-general/">
|
||||
{t['com.affine.auth.sign.no.access.link']()}
|
||||
</a>
|
||||
</AuthContent>
|
||||
|
||||
<div className={style.accessMessage}>
|
||||
<NewIcon
|
||||
style={{
|
||||
fontSize: 16,
|
||||
marginRight: 4,
|
||||
color: 'var(--affine-icon-color)',
|
||||
}}
|
||||
/>
|
||||
{t['com.affine.auth.sign.no.access.wait']()}
|
||||
</div>
|
||||
|
||||
<BackButton
|
||||
onClick={useCallback(() => {
|
||||
setAuthState('signIn');
|
||||
}, [setAuthState])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
201
packages/frontend/core/src/components/affine/auth/send-email.tsx
Normal file
201
packages/frontend/core/src/components/affine/auth/send-email.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import { Wrapper } from '@affine/component';
|
||||
import {
|
||||
AuthContent,
|
||||
AuthInput,
|
||||
BackButton,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
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 { useCallback, useState } 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 useContent = (emailType: AuthPanelProps['emailType'], email: string) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
switch (emailType) {
|
||||
case 'setPassword':
|
||||
return t['com.affine.auth.set.password.message']();
|
||||
case 'changePassword':
|
||||
return t['com.affine.auth.set.password.message']();
|
||||
case 'changeEmail':
|
||||
return t['com.affine.auth.change.email.message']({
|
||||
email,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
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=${
|
||||
environment.isDesktop ? 'true' : 'false'
|
||||
}`,
|
||||
});
|
||||
},
|
||||
[
|
||||
emailType,
|
||||
sendChangeEmail,
|
||||
sendChangePasswordEmail,
|
||||
sendSetPasswordEmail,
|
||||
]
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const SendEmail = ({
|
||||
setAuthState,
|
||||
email,
|
||||
emailType,
|
||||
}: AuthPanelProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [hasSentEmail, setHasSentEmail] = useState(false);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const title = useEmailTitle(emailType);
|
||||
const hint = useNotificationHint(emailType);
|
||||
const content = useContent(emailType, email);
|
||||
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',
|
||||
});
|
||||
setHasSentEmail(true);
|
||||
}, [email, hint, pushNotification, sendEmail]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['com.affine.brand.affineCloud']()}
|
||||
subTitle={title}
|
||||
/>
|
||||
<AuthContent>{content}</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,102 @@
|
||||
import { Wrapper } from '@affine/component';
|
||||
import {
|
||||
AuthInput,
|
||||
BackButton,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
|
||||
import { useSession } from 'next-auth/react';
|
||||
import type { FC } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
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 [password, setPassword] = useState('');
|
||||
const [passwordError, setPasswordError] = useState(false);
|
||||
|
||||
const onSignIn = useCallback(async () => {
|
||||
const res = await signInCloud('credentials', {
|
||||
redirect: false,
|
||||
email,
|
||||
password,
|
||||
}).catch(console.error);
|
||||
|
||||
if (!res?.ok) {
|
||||
return setPasswordError(true);
|
||||
}
|
||||
|
||||
await update();
|
||||
onSignedIn?.();
|
||||
}, [email, password, onSignedIn, update]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['com.affine.auth.sign.in']()}
|
||||
subTitle={t['com.affine.brand.affineCloud']()}
|
||||
/>
|
||||
|
||||
<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])}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
194
packages/frontend/core/src/components/affine/auth/sign-in.tsx
Normal file
194
packages/frontend/core/src/components/affine/auth/sign-in.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
AuthInput,
|
||||
CountDownRender,
|
||||
ModalHeader,
|
||||
} from '@affine/component/auth-components';
|
||||
import { type GetUserQuery, 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 { GraphQLError } from 'graphql';
|
||||
import { type FC, useState } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import { emailRegex } from '../../../utils/email-regex';
|
||||
import type { AuthPanelProps } from './index';
|
||||
import * as style from './style.css';
|
||||
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
||||
import { Captcha, useCaptcha } from './use-captcha';
|
||||
|
||||
function validateEmail(email: string) {
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
export const SignIn: FC<AuthPanelProps> = ({
|
||||
setAuthState,
|
||||
setAuthEmail,
|
||||
email,
|
||||
onSignedIn,
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
const [verifyToken, challenge] = useCaptcha();
|
||||
|
||||
const {
|
||||
isMutating: isSigningIn,
|
||||
resendCountDown,
|
||||
allowSendEmail,
|
||||
signIn,
|
||||
signUp,
|
||||
signInWithGoogle,
|
||||
} = useAuth();
|
||||
|
||||
const { trigger: verifyUser, isMutating } = useMutation({
|
||||
mutation: getUserQuery,
|
||||
});
|
||||
const [isValidEmail, setIsValidEmail] = useState(true);
|
||||
|
||||
if (loginStatus === 'authenticated') {
|
||||
onSignedIn?.();
|
||||
}
|
||||
|
||||
const onContinue = useCallback(async () => {
|
||||
if (!validateEmail(email)) {
|
||||
setIsValidEmail(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsValidEmail(true);
|
||||
// 0 for no access for internal beta
|
||||
let user: GetUserQuery['user'] | null | 0 = null;
|
||||
await verifyUser({ email })
|
||||
.then(({ user: u }) => {
|
||||
user = u;
|
||||
})
|
||||
.catch(err => {
|
||||
const e = err?.[0];
|
||||
if (e instanceof GraphQLError && e.extensions?.code === 402) {
|
||||
setAuthState('noAccess');
|
||||
user = 0;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
if (user === 0) {
|
||||
return;
|
||||
}
|
||||
setAuthEmail(email);
|
||||
|
||||
if (verifyToken) {
|
||||
if (user) {
|
||||
const res = await signIn(email, verifyToken, challenge);
|
||||
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||
return setAuthState('noAccess');
|
||||
}
|
||||
setAuthState('afterSignInSendEmail');
|
||||
} else {
|
||||
const res = await signUp(email, verifyToken, challenge);
|
||||
if (res?.status === 403 && res?.url === INTERNAL_BETA_URL) {
|
||||
return setAuthState('noAccess');
|
||||
} else if (!res || res.status >= 400 || res.error) {
|
||||
return;
|
||||
}
|
||||
setAuthState('afterSignUpSendEmail');
|
||||
}
|
||||
}
|
||||
}, [
|
||||
challenge,
|
||||
email,
|
||||
setAuthEmail,
|
||||
setAuthState,
|
||||
signIn,
|
||||
signUp,
|
||||
verifyToken,
|
||||
verifyUser,
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ModalHeader
|
||||
title={t['com.affine.auth.sign.in']()}
|
||||
subTitle={t['com.affine.brand.affineCloud']()}
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
block
|
||||
size="extraLarge"
|
||||
style={{
|
||||
marginTop: 30,
|
||||
}}
|
||||
icon={<GoogleDuotoneIcon />}
|
||||
onClick={useCallback(() => {
|
||||
signInWithGoogle();
|
||||
}, [signInWithGoogle])}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
|
||||
{verifyToken ? null : <Captcha />}
|
||||
|
||||
{verifyToken ? (
|
||||
<Button
|
||||
size="extraLarge"
|
||||
data-testid="continue-login-button"
|
||||
block
|
||||
loading={isMutating || isSigningIn}
|
||||
disabled={!allowSendEmail}
|
||||
icon={
|
||||
allowSendEmail || isMutating ? (
|
||||
<ArrowDownBigIcon
|
||||
width={20}
|
||||
height={20}
|
||||
style={{
|
||||
transform: 'rotate(-90deg)',
|
||||
color: 'var(--affine-blue)',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<CountDownRender
|
||||
className={style.resendCountdownInButton}
|
||||
timeLeft={resendCountDown}
|
||||
/>
|
||||
)
|
||||
}
|
||||
iconPosition="end"
|
||||
onClick={onContinue}
|
||||
>
|
||||
{t['com.affine.auth.sign.email.continue']()}
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
<div className={style.authMessage}>
|
||||
{/*prettier-ignore*/}
|
||||
<Trans i18nKey="com.affine.auth.sign.message">
|
||||
By clicking "Continue with Google/Email" above, you acknowledge that
|
||||
you agree to AFFiNE'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,64 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const authModalContent = style({
|
||||
marginTop: '30px',
|
||||
});
|
||||
|
||||
export const captchaWrapper = style({
|
||||
margin: 'auto',
|
||||
marginBottom: '4px',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
export const resendWrapper = style({
|
||||
height: 77,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 30,
|
||||
});
|
||||
|
||||
export const resendCountdown = style({ width: 45, textAlign: 'center' });
|
||||
export const resendCountdownInButton = style({
|
||||
width: 40,
|
||||
textAlign: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
marginLeft: 16,
|
||||
color: 'var(--affine-blue)',
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const accessMessage = style({
|
||||
textAlign: 'center',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontWeight: 500,
|
||||
marginTop: 65,
|
||||
marginBottom: 40,
|
||||
});
|
||||
155
packages/frontend/core/src/components/affine/auth/use-auth.ts
Normal file
155
packages/frontend/core/src/components/affine/auth/use-auth.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import type { Notification } from '@affine/component/notification-center/index.jotai';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import { type SignInResponse } from 'next-auth/react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
|
||||
const COUNT_DOWN_TIME = 60;
|
||||
export const INTERNAL_BETA_URL = `https://community.affine.pro/c/insider-general/`;
|
||||
|
||||
function handleSendEmailError(
|
||||
res: SignInResponse | undefined | void,
|
||||
pushNotification: (notification: Notification) => void
|
||||
) {
|
||||
if (res?.error) {
|
||||
pushNotification({
|
||||
title: 'Send email error',
|
||||
message: 'Please back to home and try again',
|
||||
type: 'error',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type AuthStoreAtom = {
|
||||
allowSendEmail: boolean;
|
||||
resendCountDown: number;
|
||||
isMutating: boolean;
|
||||
};
|
||||
|
||||
export const authStoreAtom = atom<AuthStoreAtom>({
|
||||
isMutating: false,
|
||||
allowSendEmail: true,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
|
||||
const countDownAtom = atom(
|
||||
null, // it's a convention to pass `null` for the first argument
|
||||
(get, set) => {
|
||||
const clearId = window.setInterval(() => {
|
||||
const countDown = get(authStoreAtom).resendCountDown;
|
||||
if (countDown === 0) {
|
||||
set(authStoreAtom, {
|
||||
isMutating: false,
|
||||
allowSendEmail: true,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
window.clearInterval(clearId);
|
||||
return;
|
||||
}
|
||||
set(authStoreAtom, {
|
||||
isMutating: false,
|
||||
resendCountDown: countDown - 1,
|
||||
allowSendEmail: false,
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
);
|
||||
|
||||
export const useAuth = () => {
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const [authStore, setAuthStore] = useAtom(authStoreAtom);
|
||||
const startResendCountDown = useSetAtom(countDownAtom);
|
||||
|
||||
const signIn = useCallback(
|
||||
async (email: string, verifyToken: string, challenge?: string) => {
|
||||
setAuthStore(prev => {
|
||||
return {
|
||||
...prev,
|
||||
isMutating: true,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await signInCloud(
|
||||
'email',
|
||||
{
|
||||
email: email,
|
||||
callbackUrl: '/auth/signIn',
|
||||
redirect: false,
|
||||
},
|
||||
challenge
|
||||
? {
|
||||
challenge,
|
||||
token: verifyToken,
|
||||
}
|
||||
: { token: verifyToken }
|
||||
).catch(console.error);
|
||||
|
||||
handleSendEmailError(res, pushNotification);
|
||||
|
||||
setAuthStore({
|
||||
isMutating: false,
|
||||
allowSendEmail: false,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
|
||||
startResendCountDown();
|
||||
|
||||
return res;
|
||||
},
|
||||
[pushNotification, setAuthStore, startResendCountDown]
|
||||
);
|
||||
|
||||
const signUp = useCallback(
|
||||
async (email: string, verifyToken: string, challenge?: string) => {
|
||||
setAuthStore(prev => {
|
||||
return {
|
||||
...prev,
|
||||
isMutating: true,
|
||||
};
|
||||
});
|
||||
|
||||
const res = await signInCloud(
|
||||
'email',
|
||||
{
|
||||
email: email,
|
||||
callbackUrl: '/auth/signUp',
|
||||
redirect: false,
|
||||
},
|
||||
challenge
|
||||
? {
|
||||
challenge,
|
||||
token: verifyToken,
|
||||
}
|
||||
: { token: verifyToken }
|
||||
).catch(console.error);
|
||||
|
||||
handleSendEmailError(res, pushNotification);
|
||||
|
||||
setAuthStore({
|
||||
isMutating: false,
|
||||
allowSendEmail: false,
|
||||
resendCountDown: COUNT_DOWN_TIME,
|
||||
});
|
||||
|
||||
startResendCountDown();
|
||||
|
||||
return res;
|
||||
},
|
||||
[pushNotification, setAuthStore, startResendCountDown]
|
||||
);
|
||||
|
||||
const signInWithGoogle = useCallback(() => {
|
||||
signInCloud('google').catch(console.error);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
allowSendEmail: authStore.allowSendEmail,
|
||||
resendCountDown: authStore.resendCountDown,
|
||||
isMutating: authStore.isMutating,
|
||||
signUp,
|
||||
signIn,
|
||||
signInWithGoogle,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
import { fetchWithTraceReport } from '@affine/graphql';
|
||||
import { Turnstile } from '@marsidev/react-turnstile';
|
||||
import { atom, useAtom, useSetAtom } from 'jotai';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import useSWR from 'swr';
|
||||
|
||||
import * as style from './style.css';
|
||||
|
||||
type Challenge = {
|
||||
challenge: string;
|
||||
resource: string;
|
||||
};
|
||||
|
||||
const challengeFetcher = async (url: string) => {
|
||||
if (!environment.isDesktop) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const res = await fetchWithTraceReport(url);
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch challenge');
|
||||
}
|
||||
const challenge = (await res.json()) as Challenge;
|
||||
if (!challenge || !challenge.challenge || !challenge.resource) {
|
||||
throw new Error('Invalid challenge');
|
||||
}
|
||||
|
||||
return challenge;
|
||||
};
|
||||
const generateChallengeResponse = async (challenge: string) => {
|
||||
if (!environment.isDesktop) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return await window.apis?.ui?.getChallengeResponse(challenge);
|
||||
};
|
||||
|
||||
const captchaAtom = atom<string | undefined>(undefined);
|
||||
const responseAtom = atom<string | undefined>(undefined);
|
||||
|
||||
export const Captcha = () => {
|
||||
const setCaptcha = useSetAtom(captchaAtom);
|
||||
const [response] = useAtom(responseAtom);
|
||||
|
||||
if (!runtimeConfig.enableCaptcha) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
if (environment.isDesktop) {
|
||||
if (response) {
|
||||
return <div className={style.captchaWrapper}>Making Challenge</div>;
|
||||
} else {
|
||||
return <div className={style.captchaWrapper}>Verified Client</div>;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Turnstile
|
||||
className={style.captchaWrapper}
|
||||
siteKey={process.env.CAPTCHA_SITE_KEY || '1x00000000000000000000AA'}
|
||||
onSuccess={setCaptcha}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export const useCaptcha = (): [string | undefined, string?] => {
|
||||
const [verifyToken] = useAtom(captchaAtom);
|
||||
const [response, setResponse] = useAtom(responseAtom);
|
||||
|
||||
const { data: challenge } = useSWR('/api/auth/challenge', challengeFetcher, {
|
||||
suspense: false,
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
const prevChallenge = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
runtimeConfig.enableCaptcha &&
|
||||
environment.isDesktop &&
|
||||
challenge?.challenge &&
|
||||
prevChallenge.current !== challenge.challenge
|
||||
) {
|
||||
prevChallenge.current = challenge.challenge;
|
||||
generateChallengeResponse(challenge.resource)
|
||||
.then(setResponse)
|
||||
.catch(err => {
|
||||
console.error('Error getting challenge response:', err);
|
||||
});
|
||||
}
|
||||
}, [challenge, setResponse]);
|
||||
|
||||
if (!runtimeConfig.enableCaptcha) {
|
||||
return ['XXXX.DUMMY.TOKEN.XXXX'];
|
||||
}
|
||||
|
||||
if (environment.isDesktop) {
|
||||
if (response) {
|
||||
return [response, challenge?.challenge];
|
||||
} else {
|
||||
return [undefined, challenge?.challenge];
|
||||
}
|
||||
}
|
||||
|
||||
return [verifyToken];
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const header = style({
|
||||
position: 'relative',
|
||||
marginTop: '44px',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
padding: '0 40px',
|
||||
fontSize: '18px',
|
||||
lineHeight: '26px',
|
||||
});
|
||||
|
||||
globalStyle(`${content} p`, {
|
||||
marginTop: '12px',
|
||||
marginBottom: '16px',
|
||||
});
|
||||
|
||||
export const contentTitle = style({
|
||||
fontSize: '20px',
|
||||
lineHeight: '28px',
|
||||
fontWeight: 600,
|
||||
paddingBottom: '16px',
|
||||
});
|
||||
|
||||
export const buttonGroup = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: '20px',
|
||||
margin: '24px 0',
|
||||
});
|
||||
|
||||
export const radioGroup = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const radio = style({
|
||||
cursor: 'pointer',
|
||||
appearance: 'auto',
|
||||
marginRight: '12px',
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
import { Input, toast } from '@affine/component';
|
||||
import { DebugLogger } from '@affine/debug';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { HelpIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import {
|
||||
ConfirmModal,
|
||||
type ConfirmModalProps,
|
||||
Modal,
|
||||
} from '@toeverything/components/modal';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import type {
|
||||
LoadDBFileResult,
|
||||
SelectDBFileLocationResult,
|
||||
} from '@toeverything/infra/type';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { KeyboardEvent } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { useLayoutEffect } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { openDisableCloudAlertModalAtom } from '../../../atoms';
|
||||
import { useAppHelper } from '../../../hooks/use-workspaces';
|
||||
import * as style from './index.css';
|
||||
|
||||
type CreateWorkspaceStep =
|
||||
| 'set-db-location'
|
||||
| 'name-workspace'
|
||||
| 'set-syncing-mode';
|
||||
|
||||
export type CreateWorkspaceMode = 'add' | 'new' | false;
|
||||
|
||||
const logger = new DebugLogger('CreateWorkspaceModal');
|
||||
|
||||
interface ModalProps {
|
||||
mode: CreateWorkspaceMode; // false means not open
|
||||
onClose: () => void;
|
||||
onCreate: (id: string) => void;
|
||||
}
|
||||
|
||||
interface NameWorkspaceContentProps extends ConfirmModalProps {
|
||||
onConfirmName: (name: string) => void;
|
||||
}
|
||||
|
||||
const NameWorkspaceContent = ({
|
||||
onConfirmName,
|
||||
...props
|
||||
}: NameWorkspaceContentProps) => {
|
||||
const [workspaceName, setWorkspaceName] = useState('');
|
||||
|
||||
const handleCreateWorkspace = useCallback(() => {
|
||||
onConfirmName(workspaceName);
|
||||
}, [onConfirmName, workspaceName]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' && workspaceName) {
|
||||
handleCreateWorkspace();
|
||||
}
|
||||
},
|
||||
[handleCreateWorkspace, workspaceName]
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<ConfirmModal
|
||||
defaultOpen={true}
|
||||
title={t['com.affine.nameWorkspace.title']()}
|
||||
description={t['com.affine.nameWorkspace.description']()}
|
||||
cancelText={t['com.affine.nameWorkspace.button.cancel']()}
|
||||
confirmButtonOptions={{
|
||||
type: 'primary',
|
||||
disabled: !workspaceName,
|
||||
['data-testid' as string]: 'create-workspace-create-button',
|
||||
children: t['com.affine.nameWorkspace.button.create'](),
|
||||
}}
|
||||
closeButtonOptions={{
|
||||
['data-testid' as string]: 'create-workspace-close-button',
|
||||
}}
|
||||
onConfirm={handleCreateWorkspace}
|
||||
{...props}
|
||||
>
|
||||
<Input
|
||||
ref={ref => {
|
||||
if (ref) {
|
||||
window.setTimeout(() => ref.focus(), 0);
|
||||
}
|
||||
}}
|
||||
data-testid="create-workspace-input"
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={t['com.affine.nameWorkspace.placeholder']()}
|
||||
maxLength={64}
|
||||
minLength={0}
|
||||
onChange={setWorkspaceName}
|
||||
size="large"
|
||||
/>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
|
||||
interface SetDBLocationContentProps {
|
||||
onConfirmLocation: (dir?: string) => void;
|
||||
}
|
||||
|
||||
const useDefaultDBLocation = () => {
|
||||
const [defaultDBLocation, setDefaultDBLocation] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
window.apis?.db
|
||||
.getDefaultStorageLocation()
|
||||
.then(dir => {
|
||||
setDefaultDBLocation(dir);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return defaultDBLocation;
|
||||
};
|
||||
|
||||
const SetDBLocationContent = ({
|
||||
onConfirmLocation,
|
||||
}: SetDBLocationContentProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const defaultDBLocation = useDefaultDBLocation();
|
||||
const [opening, setOpening] = useState(false);
|
||||
|
||||
const handleSelectDBFileLocation = useCallback(() => {
|
||||
if (opening) {
|
||||
return;
|
||||
}
|
||||
setOpening(true);
|
||||
(async function () {
|
||||
const result: SelectDBFileLocationResult =
|
||||
await window.apis?.dialog.selectDBFileLocation();
|
||||
setOpening(false);
|
||||
if (result?.filePath) {
|
||||
onConfirmLocation(result.filePath);
|
||||
} else if (result?.error) {
|
||||
toast(t[result.error]());
|
||||
}
|
||||
})().catch(err => {
|
||||
logger.error(err);
|
||||
});
|
||||
}, [onConfirmLocation, opening, t]);
|
||||
|
||||
return (
|
||||
<div className={style.content}>
|
||||
<div className={style.contentTitle}>
|
||||
{t['com.affine.setDBLocation.title']()}
|
||||
</div>
|
||||
<p>{t['com.affine.setDBLocation.description']()}</p>
|
||||
<div className={style.buttonGroup}>
|
||||
<Button
|
||||
disabled={opening}
|
||||
data-testid="create-workspace-customize-button"
|
||||
type="primary"
|
||||
onClick={handleSelectDBFileLocation}
|
||||
>
|
||||
{t['com.affine.setDBLocation.button.customize']()}
|
||||
</Button>
|
||||
<Tooltip
|
||||
content={t['com.affine.setDBLocation.tooltip.defaultLocation']({
|
||||
location: defaultDBLocation,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
data-testid="create-workspace-default-location-button"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onConfirmLocation();
|
||||
}}
|
||||
icon={<HelpIcon />}
|
||||
iconPosition="end"
|
||||
>
|
||||
{t['com.affine.setDBLocation.button.defaultLocation']()}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SetSyncingModeContentProps {
|
||||
mode: CreateWorkspaceMode;
|
||||
onConfirmMode: (enableCloudSyncing: boolean) => void;
|
||||
}
|
||||
|
||||
const SetSyncingModeContent = ({
|
||||
mode,
|
||||
onConfirmMode,
|
||||
}: SetSyncingModeContentProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [enableCloudSyncing, setEnableCloudSyncing] = useState(false);
|
||||
return (
|
||||
<div className={style.content}>
|
||||
<div className={style.contentTitle}>
|
||||
{mode === 'new'
|
||||
? t['com.affine.setSyncingMode.title.created']()
|
||||
: t['com.affine.setSyncingMode.title.added']()}
|
||||
</div>
|
||||
|
||||
<div className={style.radioGroup}>
|
||||
<label onClick={() => setEnableCloudSyncing(false)}>
|
||||
<input
|
||||
className={style.radio}
|
||||
type="radio"
|
||||
readOnly
|
||||
checked={!enableCloudSyncing}
|
||||
/>
|
||||
{t['com.affine.setSyncingMode.deviceOnly']()}
|
||||
</label>
|
||||
<label onClick={() => setEnableCloudSyncing(true)}>
|
||||
<input
|
||||
className={style.radio}
|
||||
type="radio"
|
||||
readOnly
|
||||
checked={enableCloudSyncing}
|
||||
/>
|
||||
{t['com.affine.setSyncingMode.cloud']()}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className={style.buttonGroup}>
|
||||
<Button
|
||||
data-testid="create-workspace-continue-button"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
onConfirmMode(enableCloudSyncing);
|
||||
}}
|
||||
>
|
||||
{t['com.affine.setSyncingMode.button.continue']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const CreateWorkspaceModal = ({
|
||||
mode,
|
||||
onClose,
|
||||
onCreate,
|
||||
}: ModalProps) => {
|
||||
const { createLocalWorkspace, addLocalWorkspace } = useAppHelper();
|
||||
const [step, setStep] = useState<CreateWorkspaceStep>();
|
||||
const [addedId, setAddedId] = useState<string>();
|
||||
const [workspaceName, setWorkspaceName] = useState<string>();
|
||||
const [dbFileLocation, setDBFileLocation] = useState<string>();
|
||||
const setOpenDisableCloudAlertModal = useSetAtom(
|
||||
openDisableCloudAlertModalAtom
|
||||
);
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
// todo: maybe refactor using xstate?
|
||||
useLayoutEffect(() => {
|
||||
let canceled = false;
|
||||
// if mode changed, reset step
|
||||
if (mode === 'add') {
|
||||
// a hack for now
|
||||
// when adding a workspace, we will immediately let user select a db file
|
||||
// after it is done, it will effectively add a new workspace to app-data folder
|
||||
// so after that, we will be able to load it via importLocalWorkspace
|
||||
(async () => {
|
||||
if (!window.apis) {
|
||||
return;
|
||||
}
|
||||
logger.info('load db file');
|
||||
setStep(undefined);
|
||||
const result: LoadDBFileResult = await window.apis.dialog.loadDBFile();
|
||||
if (result.workspaceId && !canceled) {
|
||||
setAddedId(result.workspaceId);
|
||||
const newWorkspaceId = await addLocalWorkspace(result.workspaceId);
|
||||
onCreate(newWorkspaceId);
|
||||
} else if (result.error || result.canceled) {
|
||||
if (result.error) {
|
||||
toast(t[result.error]());
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
})().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
} else if (mode === 'new') {
|
||||
setStep('name-workspace');
|
||||
} else {
|
||||
setStep(undefined);
|
||||
}
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [addLocalWorkspace, mode, onClose, onCreate, t]);
|
||||
|
||||
const onConfirmEnableCloudSyncing = useCallback(
|
||||
(enableCloudSyncing: boolean) => {
|
||||
(async function () {
|
||||
if (!runtimeConfig.enableCloud && enableCloudSyncing) {
|
||||
setOpenDisableCloudAlertModal(true);
|
||||
} else {
|
||||
let id = addedId;
|
||||
// syncing mode is also the last step
|
||||
if (addedId && mode === 'add') {
|
||||
await addLocalWorkspace(addedId);
|
||||
} else if (mode === 'new' && workspaceName) {
|
||||
id = await createLocalWorkspace(workspaceName);
|
||||
// if dbFileLocation is set, move db file to that location
|
||||
if (dbFileLocation) {
|
||||
await window.apis?.dialog.moveDBFile(id, dbFileLocation);
|
||||
}
|
||||
} else {
|
||||
logger.error('invalid state');
|
||||
return;
|
||||
}
|
||||
if (id) {
|
||||
onCreate(id);
|
||||
}
|
||||
}
|
||||
})().catch(e => {
|
||||
logger.error(e);
|
||||
});
|
||||
},
|
||||
[
|
||||
addLocalWorkspace,
|
||||
addedId,
|
||||
createLocalWorkspace,
|
||||
dbFileLocation,
|
||||
mode,
|
||||
onCreate,
|
||||
setOpenDisableCloudAlertModal,
|
||||
workspaceName,
|
||||
]
|
||||
);
|
||||
|
||||
const onConfirmName = useCallback(
|
||||
(name: string) => {
|
||||
setWorkspaceName(name);
|
||||
// this will be the last step for web for now
|
||||
// fix me later
|
||||
createLocalWorkspace(name).then(id => {
|
||||
onCreate(id);
|
||||
});
|
||||
},
|
||||
[createLocalWorkspace, onCreate]
|
||||
);
|
||||
|
||||
const setDBLocationNode =
|
||||
step === 'set-db-location' ? (
|
||||
<SetDBLocationContent
|
||||
onConfirmLocation={dir => {
|
||||
setDBFileLocation(dir);
|
||||
setStep('name-workspace');
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const setSyncingModeNode =
|
||||
step === 'set-syncing-mode' ? (
|
||||
<SetSyncingModeContent
|
||||
mode={mode}
|
||||
onConfirmMode={onConfirmEnableCloudSyncing}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
if (step === 'name-workspace') {
|
||||
return (
|
||||
<NameWorkspaceContent
|
||||
open={mode !== false && !!step}
|
||||
onOpenChange={onOpenChange}
|
||||
onConfirmName={onConfirmName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={mode !== false && !!step}
|
||||
width={560}
|
||||
onOpenChange={onOpenChange}
|
||||
contentOptions={{
|
||||
style: { padding: '10px' },
|
||||
}}
|
||||
>
|
||||
<div className={style.header}></div>
|
||||
{setDBLocationNode}
|
||||
{setSyncingModeNode}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
ConfirmModal,
|
||||
type ConfirmModalProps,
|
||||
} from '@toeverything/components/modal';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { authAtom } from '../../../atoms';
|
||||
import { setOnceSignedInEventAtom } from '../../../atoms/event';
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
|
||||
export const EnableAffineCloudModal = ({
|
||||
onConfirm: propsOnConfirm,
|
||||
...props
|
||||
}: ConfirmModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
const setAuthAtom = useSetAtom(authAtom);
|
||||
const setOnceSignedInEvent = useSetAtom(setOnceSignedInEventAtom);
|
||||
|
||||
const confirm = useCallback(async () => {
|
||||
return propsOnConfirm?.();
|
||||
}, [propsOnConfirm]);
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
if (loginStatus === 'unauthenticated') {
|
||||
setAuthAtom(prev => ({
|
||||
...prev,
|
||||
openModal: true,
|
||||
}));
|
||||
setOnceSignedInEvent(confirm);
|
||||
}
|
||||
if (loginStatus === 'authenticated') {
|
||||
return propsOnConfirm?.();
|
||||
}
|
||||
}, [confirm, loginStatus, propsOnConfirm, setAuthAtom, setOnceSignedInEvent]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t['Enable AFFiNE Cloud']()}
|
||||
description={t['Enable AFFiNE Cloud Description']()}
|
||||
cancelText={t['com.affine.enableAffineCloudModal.button.cancel']()}
|
||||
onConfirm={onConfirm}
|
||||
confirmButtonOptions={{
|
||||
type: 'primary',
|
||||
['data-testid' as string]: 'confirm-enable-affine-cloud-button',
|
||||
children:
|
||||
loginStatus === 'authenticated'
|
||||
? t['Enable']()
|
||||
: t['Sign in and Enable'](),
|
||||
}}
|
||||
contentOptions={{
|
||||
['data-testid' as string]: 'enable-cloud-modal',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Menu, MenuItem, MenuTrigger } from '@toeverything/components/menu';
|
||||
import { memo, type ReactElement } from 'react';
|
||||
|
||||
import { useLanguageHelper } from '../../../hooks/affine/use-language-helper';
|
||||
|
||||
// Fixme: keyboard focus should be supported by Menu component
|
||||
const LanguageMenuContent = memo(function LanguageMenuContent() {
|
||||
const { currentLanguage, languagesList, onSelect } = useLanguageHelper();
|
||||
return (
|
||||
<>
|
||||
{languagesList.map(option => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={option.name}
|
||||
selected={currentLanguage?.originalName === option.originalName}
|
||||
title={option.name}
|
||||
onSelect={() => onSelect(option.tag)}
|
||||
>
|
||||
{option.originalName}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const LanguageMenu = () => {
|
||||
const { currentLanguage } = useLanguageHelper();
|
||||
return (
|
||||
<Menu
|
||||
items={(<LanguageMenuContent />) as ReactElement}
|
||||
contentOptions={{
|
||||
style: {
|
||||
background: 'var(--affine-white)',
|
||||
},
|
||||
align: 'end',
|
||||
}}
|
||||
>
|
||||
<MenuTrigger
|
||||
data-testid="language-menu-button"
|
||||
style={{ textTransform: 'capitalize', fontWeight: 600 }}
|
||||
block={true}
|
||||
>
|
||||
{currentLanguage?.originalName || ''}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const hoveredLanguageItem = style({
|
||||
background: 'var(--affine-hover-color)',
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Input } 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 {
|
||||
ConfirmModal,
|
||||
type ConfirmModalProps,
|
||||
} from '@toeverything/components/modal';
|
||||
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import * as styles from './style.css';
|
||||
|
||||
interface WorkspaceDeleteProps extends ConfirmModalProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
export const WorkspaceDeleteModal = ({
|
||||
workspace,
|
||||
...props
|
||||
}: WorkspaceDeleteProps) => {
|
||||
const { onConfirm } = props;
|
||||
const [workspaceName] = useBlockSuiteWorkspaceName(
|
||||
workspace.blockSuiteWorkspace
|
||||
);
|
||||
const [deleteStr, setDeleteStr] = useState<string>('');
|
||||
const allowDelete = deleteStr === workspaceName;
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const handleOnEnter = useCallback(() => {
|
||||
if (allowDelete) {
|
||||
return onConfirm?.();
|
||||
}
|
||||
}, [allowDelete, onConfirm]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={`${t['com.affine.workspaceDelete.title']()}?`}
|
||||
cancelText={t['com.affine.workspaceDelete.button.cancel']()}
|
||||
confirmButtonOptions={{
|
||||
type: 'error',
|
||||
disabled: !allowDelete,
|
||||
['data-testid' as string]: 'delete-workspace-confirm-button',
|
||||
children: t['com.affine.workspaceDelete.button.delete'](),
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{workspace.flavour === WorkspaceFlavour.LOCAL ? (
|
||||
<Trans i18nKey="com.affine.workspaceDelete.description">
|
||||
Deleting (
|
||||
<span className={styles.workspaceName}>
|
||||
{{ workspace: workspaceName } as any}
|
||||
</span>
|
||||
) cannot be undone, please proceed with caution. All contents will be
|
||||
lost.
|
||||
</Trans>
|
||||
) : (
|
||||
<Trans i18nKey="com.affine.workspaceDelete.description2">
|
||||
Deleting (
|
||||
<span className={styles.workspaceName}>
|
||||
{{ workspace: workspaceName } as any}
|
||||
</span>
|
||||
) will delete both local and cloud data, this operation cannot be
|
||||
undone, please proceed with caution.
|
||||
</Trans>
|
||||
)}
|
||||
<div className={styles.inputContent}>
|
||||
<Input
|
||||
ref={ref => {
|
||||
if (ref) {
|
||||
window.setTimeout(() => ref.focus(), 0);
|
||||
}
|
||||
}}
|
||||
onChange={setDeleteStr}
|
||||
data-testid="delete-workspace-input"
|
||||
onEnter={handleOnEnter}
|
||||
placeholder={t['com.affine.workspaceDelete.placeholder']()}
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
</ConfirmModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const modalWrapper = style({
|
||||
position: 'relative',
|
||||
padding: '0px',
|
||||
width: '560px',
|
||||
background: 'var(--affine-background-overlay-panel-color)',
|
||||
borderRadius: '12px',
|
||||
});
|
||||
|
||||
export const modalHeader = style({
|
||||
margin: '44px 0px 12px 0px',
|
||||
width: '560px',
|
||||
fontWeight: '600',
|
||||
fontSize: '20px;',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const inputContent = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
margin: '24px 0',
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
});
|
||||
|
||||
export const workspaceName = style({
|
||||
fontWeight: '600',
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
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 { ConfirmModal } from '@toeverything/components/modal';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { WorkspaceSettingDetailProps } from '../types';
|
||||
import { WorkspaceDeleteModal } from './delete';
|
||||
|
||||
export interface DeleteLeaveWorkspaceProps extends WorkspaceSettingDetailProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
export const DeleteLeaveWorkspace = ({
|
||||
workspace,
|
||||
onDeleteCloudWorkspace,
|
||||
onDeleteLocalWorkspace,
|
||||
onLeaveWorkspace,
|
||||
isOwner,
|
||||
}: DeleteLeaveWorkspaceProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
// fixme: cloud regression
|
||||
const [showDelete, setShowDelete] = useState(false);
|
||||
const [showLeave, setShowLeave] = useState(false);
|
||||
|
||||
const onLeaveOrDelete = useCallback(() => {
|
||||
if (isOwner) {
|
||||
setShowDelete(true);
|
||||
} else {
|
||||
setShowLeave(true);
|
||||
}
|
||||
}, [isOwner]);
|
||||
|
||||
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
|
||||
name={
|
||||
<span style={{ color: 'var(--affine-error-color)' }}>
|
||||
{isOwner
|
||||
? t['com.affine.workspaceDelete.title']()
|
||||
: t['com.affine.deleteLeaveWorkspace.leave']()}
|
||||
</span>
|
||||
}
|
||||
desc={t['com.affine.deleteLeaveWorkspace.description']()}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={onLeaveOrDelete}
|
||||
data-testid="delete-workspace-button"
|
||||
>
|
||||
<ArrowRightSmallIcon />
|
||||
</SettingRow>
|
||||
{isOwner ? (
|
||||
<WorkspaceDeleteModal
|
||||
onConfirm={onDeleteConfirm}
|
||||
open={showDelete}
|
||||
onOpenChange={setShowDelete}
|
||||
workspace={workspace}
|
||||
/>
|
||||
) : (
|
||||
<ConfirmModal
|
||||
open={showLeave}
|
||||
cancelText={t['com.affine.confirmModal.button.cancel']()}
|
||||
onConfirm={onLeaveConfirm}
|
||||
onOpenChange={setShowLeave}
|
||||
title={`${t['com.affine.deleteLeaveWorkspace.leave']()}?`}
|
||||
description={t['com.affine.deleteLeaveWorkspace.leaveDescription']()}
|
||||
confirmButtonOptions={{
|
||||
type: 'warning',
|
||||
children: t['Leave'](),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
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 { useSetAtom } from 'jotai';
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { Doc } from 'yjs';
|
||||
import { encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
async function syncBlobsToSqliteDb(workspace: AffineOfficialWorkspace) {
|
||||
if (window.apis && isDesktop) {
|
||||
const bs = workspace.blockSuiteWorkspace.blobs;
|
||||
const blobsInDb = await window.apis.db.getBlobKeys(workspace.id);
|
||||
const blobsInStorage = await bs.list();
|
||||
const blobsToSync = blobsInStorage.filter(
|
||||
blob => !blobsInDb.includes(blob)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
blobsToSync.map(async blobKey => {
|
||||
const blob = await bs.get(blobKey);
|
||||
if (blob) {
|
||||
const bin = new Uint8Array(await blob.arrayBuffer());
|
||||
await window.apis.db.addBlob(workspace.id, blobKey, bin);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function syncDocsToSqliteDb(workspace: AffineOfficialWorkspace) {
|
||||
if (window.apis && isDesktop) {
|
||||
const workspaceId = workspace.blockSuiteWorkspace.doc.guid;
|
||||
const syncDoc = async (doc: Doc) => {
|
||||
await window.apis.db.applyDocUpdate(
|
||||
workspace.id,
|
||||
encodeStateAsUpdate(doc),
|
||||
doc.guid === workspaceId ? undefined : doc.guid
|
||||
);
|
||||
await Promise.all([...doc.subdocs].map(subdoc => syncDoc(subdoc)));
|
||||
};
|
||||
|
||||
return syncDoc(workspace.blockSuiteWorkspace.doc);
|
||||
}
|
||||
}
|
||||
|
||||
interface ExportPanelProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
export const ExportPanel = ({ workspace }: ExportPanelProps) => {
|
||||
const workspaceId = workspace.id;
|
||||
const t = useAFFiNEI18N();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
const onExport = useCallback(async () => {
|
||||
if (syncing) {
|
||||
return;
|
||||
}
|
||||
setSyncing(true);
|
||||
try {
|
||||
await syncBlobsToSqliteDb(workspace);
|
||||
await syncDocsToSqliteDb(workspace);
|
||||
const result: SaveDBFileResult =
|
||||
await window.apis?.dialog.saveDBFileAs(workspaceId);
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
} else if (!result?.canceled) {
|
||||
pushNotification({
|
||||
type: 'success',
|
||||
title: t['Export success'](),
|
||||
});
|
||||
}
|
||||
} catch (e: any) {
|
||||
pushNotification({
|
||||
type: 'error',
|
||||
title: t['Export failed'](),
|
||||
message: e.message,
|
||||
});
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [pushNotification, syncing, t, workspace, workspaceId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
||||
<Button
|
||||
data-testid="export-affine-backup"
|
||||
onClick={onExport}
|
||||
disabled={syncing}
|
||||
>
|
||||
{t['Export']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingRow,
|
||||
SettingWrapper,
|
||||
} from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useWorkspace } from '../../../hooks/use-workspace';
|
||||
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
|
||||
import { ExportPanel } from './export';
|
||||
import { LabelsPanel } from './labels';
|
||||
import { MembersPanel } from './members';
|
||||
import { ProfilePanel } from './profile';
|
||||
import { PublishPanel } from './publish';
|
||||
import { StoragePanel } from './storage';
|
||||
import type { WorkspaceSettingDetailProps } from './types';
|
||||
|
||||
export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
|
||||
const { workspaceId } = props;
|
||||
const t = useAFFiNEI18N();
|
||||
const workspace = useWorkspace(workspaceId);
|
||||
const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace);
|
||||
|
||||
const storageAndExportSetting = useMemo(() => {
|
||||
if (environment.isDesktop) {
|
||||
return (
|
||||
<SettingWrapper title={t['Storage and Export']()}>
|
||||
{runtimeConfig.enableMoveDatabase ? (
|
||||
<StoragePanel workspace={workspace} />
|
||||
) : null}
|
||||
<ExportPanel workspace={workspace} />
|
||||
</SettingWrapper>
|
||||
);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}, [t, workspace]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t[`Workspace Settings with name`]({ name })}
|
||||
subtitle={t['com.affine.settings.workspace.description']()}
|
||||
/>
|
||||
<SettingWrapper title={t['Info']()}>
|
||||
<SettingRow
|
||||
name={t['Workspace Profile']()}
|
||||
desc={t['com.affine.settings.workspace.not-owner']()}
|
||||
spreadCol={false}
|
||||
>
|
||||
<ProfilePanel workspace={workspace} {...props} />
|
||||
<LabelsPanel workspace={workspace} {...props} />
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper title={t['com.affine.brand.affineCloud']()}>
|
||||
<PublishPanel workspace={workspace} {...props} />
|
||||
<MembersPanel workspace={workspace} {...props} />
|
||||
</SettingWrapper>
|
||||
{storageAndExportSetting}
|
||||
<SettingWrapper>
|
||||
<DeleteLeaveWorkspace workspace={workspace} {...props} />
|
||||
</SettingWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import * as style from './style.css';
|
||||
import type { WorkspaceSettingDetailProps } from './types';
|
||||
|
||||
export interface LabelsPanelProps extends WorkspaceSettingDetailProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
type WorkspaceStatus =
|
||||
| 'local'
|
||||
| 'syncCloud'
|
||||
| 'syncDocker'
|
||||
| 'selfHosted'
|
||||
| 'joinedWorkspace'
|
||||
| 'availableOffline'
|
||||
| 'publishedToWeb';
|
||||
|
||||
type LabelProps = {
|
||||
value: string;
|
||||
background: string;
|
||||
};
|
||||
|
||||
type LabelMap = {
|
||||
[key in WorkspaceStatus]: LabelProps;
|
||||
};
|
||||
type labelConditionsProps = {
|
||||
condition: boolean;
|
||||
label: WorkspaceStatus;
|
||||
};
|
||||
const Label = ({ value, background }: LabelProps) => {
|
||||
return (
|
||||
<div>
|
||||
<div className={style.workspaceLabel} style={{ background: background }}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export const LabelsPanel = ({ workspace, isOwner }: LabelsPanelProps) => {
|
||||
const labelMap: LabelMap = useMemo(
|
||||
() => ({
|
||||
local: {
|
||||
value: 'Local',
|
||||
background: 'var(--affine-tag-orange)',
|
||||
},
|
||||
syncCloud: {
|
||||
value: 'Sync with AFFiNE Cloud',
|
||||
background: 'var(--affine-tag-blue)',
|
||||
},
|
||||
syncDocker: {
|
||||
value: 'Sync with AFFiNE Docker',
|
||||
background: 'var(--affine-tag-green)',
|
||||
},
|
||||
selfHosted: {
|
||||
value: 'Self-Hosted Server',
|
||||
background: 'var(--affine-tag-purple)',
|
||||
},
|
||||
joinedWorkspace: {
|
||||
value: 'Joined Workspace',
|
||||
background: 'var(--affine-tag-yellow)',
|
||||
},
|
||||
availableOffline: {
|
||||
value: 'Available Offline',
|
||||
background: 'var(--affine-tag-green)',
|
||||
},
|
||||
publishedToWeb: {
|
||||
value: 'Published to Web',
|
||||
background: 'var(--affine-tag-blue)',
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
const labelConditions: labelConditionsProps[] = [
|
||||
{ condition: !isOwner, label: 'joinedWorkspace' },
|
||||
{ condition: workspace.flavour === 'local', label: 'local' },
|
||||
{ condition: workspace.flavour === 'affine-cloud', label: 'syncCloud' },
|
||||
{
|
||||
condition: workspace.flavour === 'affine-public',
|
||||
label: 'publishedToWeb',
|
||||
},
|
||||
//TODO: add these labels
|
||||
// { status==="synced", label: 'availableOffline' }
|
||||
// { workspace.flavour === 'affine-Docker', label: 'syncDocker' }
|
||||
// { workspace.flavour === 'self-hosted', label: 'selfHosted' }
|
||||
];
|
||||
|
||||
return (
|
||||
<div className={style.labelWrapper}>
|
||||
{labelConditions.map(
|
||||
({ condition, label }) =>
|
||||
condition && (
|
||||
<Label
|
||||
key={label}
|
||||
value={labelMap[label].value}
|
||||
background={labelMap[label].background}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
import {
|
||||
InviteModal,
|
||||
type InviteModalProps,
|
||||
} from '@affine/component/member-components';
|
||||
import {
|
||||
Pagination,
|
||||
type PaginationProps,
|
||||
} 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 { Loading } from '@toeverything/components/loading';
|
||||
import { Menu, MenuItem } from '@toeverything/components/menu';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import clsx from 'clsx';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
Suspense,
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
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 { useMemberCount } from '../../../hooks/affine/use-member-count';
|
||||
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 * as style from './style.css';
|
||||
import type { WorkspaceSettingDetailProps } from './types';
|
||||
|
||||
const COUNT_PER_PAGE = 8;
|
||||
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
type OnRevoke = (memberId: string) => void;
|
||||
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) => {
|
||||
const workspaceId = workspace.id;
|
||||
const memberCount = useMemberCount(workspaceId);
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
const { invite, isMutating } = useInviteMember(workspaceId);
|
||||
const revokeMemberPermission = useRevokeMemberPermission(workspaceId);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const [memberSkip, setMemberSkip] = useState(0);
|
||||
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, []);
|
||||
|
||||
const onPageChange = useCallback<PaginationProps['onPageChange']>(offset => {
|
||||
setMemberSkip(offset);
|
||||
}, []);
|
||||
|
||||
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]
|
||||
);
|
||||
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [memberListHeight, setMemberListHeight] = useState<number | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (
|
||||
memberCount > COUNT_PER_PAGE &&
|
||||
listContainerRef.current &&
|
||||
memberListHeight === null
|
||||
) {
|
||||
const rect = listContainerRef.current.getBoundingClientRect();
|
||||
setMemberListHeight(rect.height);
|
||||
}
|
||||
}, [listContainerRef, memberCount, memberListHeight]);
|
||||
|
||||
const onRevoke = useCallback<OnRevoke>(
|
||||
async memberId => {
|
||||
const res = await revokeMemberPermission(memberId);
|
||||
if (res?.revoke) {
|
||||
pushNotification({
|
||||
title: t['Removed successfully'](),
|
||||
type: 'success',
|
||||
});
|
||||
}
|
||||
},
|
||||
[pushNotification, revokeMemberPermission, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={`${t['Members']()} (${memberCount})`}
|
||||
desc={t['Members hint']()}
|
||||
spreadCol={isOwner}
|
||||
>
|
||||
{isOwner ? (
|
||||
<>
|
||||
<Button onClick={openModal}>{t['Invite Members']()}</Button>
|
||||
<InviteModal
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
onConfirm={onInviteConfirm}
|
||||
isMutating={isMutating}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</SettingRow>
|
||||
|
||||
<div
|
||||
className={style.membersPanel}
|
||||
ref={listContainerRef}
|
||||
style={memberListHeight ? { height: memberListHeight } : {}}
|
||||
>
|
||||
<Suspense fallback={<MemberListFallback memberCount={memberCount} />}>
|
||||
<MemberList
|
||||
workspaceId={workspaceId}
|
||||
isOwner={isOwner}
|
||||
skip={memberSkip}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
</Suspense>
|
||||
|
||||
{memberCount > COUNT_PER_PAGE && (
|
||||
<Pagination
|
||||
totalCount={memberCount}
|
||||
countPerPage={COUNT_PER_PAGE}
|
||||
onPageChange={onPageChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberListFallback = ({ memberCount }: { memberCount: number }) => {
|
||||
// prevent page jitter
|
||||
const height = useMemo(() => {
|
||||
if (memberCount > COUNT_PER_PAGE) {
|
||||
// height and margin-bottom
|
||||
return COUNT_PER_PAGE * 58 + (COUNT_PER_PAGE - 1) * 6;
|
||||
}
|
||||
return 'auto';
|
||||
}, [memberCount]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
className={style.membersFallback}
|
||||
>
|
||||
<Loading size={40} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberList = ({
|
||||
workspaceId,
|
||||
isOwner,
|
||||
skip,
|
||||
onRevoke,
|
||||
}: {
|
||||
workspaceId: string;
|
||||
isOwner: boolean;
|
||||
skip: number;
|
||||
onRevoke: OnRevoke;
|
||||
}) => {
|
||||
const members = useMembers(workspaceId, skip, COUNT_PER_PAGE);
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
return (
|
||||
<div className={style.memberList}>
|
||||
{members.map(member => (
|
||||
<MemberItem
|
||||
key={member.id}
|
||||
member={member}
|
||||
isOwner={isOwner}
|
||||
currentUser={currentUser}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberItem = ({
|
||||
member,
|
||||
isOwner,
|
||||
currentUser,
|
||||
onRevoke,
|
||||
}: {
|
||||
member: Member;
|
||||
isOwner: boolean;
|
||||
currentUser: CheckedUser;
|
||||
onRevoke: OnRevoke;
|
||||
}) => {
|
||||
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.memberListItem}
|
||||
data-testid="member-item"
|
||||
>
|
||||
<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
|
||||
items={
|
||||
<MenuItem data-member-id={member.id} onClick={handleRevoke}>
|
||||
{operationButtonInfo.leaveOrRevokeText}
|
||||
</MenuItem>
|
||||
}
|
||||
>
|
||||
<IconButton
|
||||
disabled={!operationButtonInfo.show}
|
||||
type="plain"
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,134 @@
|
||||
import { FlexWrapper, Input, Wrapper } from '@affine/component';
|
||||
import { pushNotificationAtom } from '@affine/component/notification-center';
|
||||
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CameraIcon } from '@blocksuite/icons';
|
||||
import { Avatar } from '@toeverything/components/avatar';
|
||||
import { Button } 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 { useSetAtom } from 'jotai';
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
type MouseEvent,
|
||||
startTransition,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { Upload } from '../../pure/file-upload';
|
||||
import * as style from './style.css';
|
||||
import type { WorkspaceSettingDetailProps } from './types';
|
||||
|
||||
export interface ProfilePanelProps extends WorkspaceSettingDetailProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const [workspaceAvatar, update] = useBlockSuiteWorkspaceAvatarUrl(
|
||||
workspace.blockSuiteWorkspace
|
||||
);
|
||||
|
||||
const [name, setName] = useBlockSuiteWorkspaceName(
|
||||
workspace.blockSuiteWorkspace
|
||||
);
|
||||
|
||||
const [input, setInput] = useState<string>(name);
|
||||
|
||||
const handleUpdateWorkspaceName = useCallback(
|
||||
(name: string) => {
|
||||
setName(name);
|
||||
pushNotification({
|
||||
title: t['Update workspace name success'](),
|
||||
type: 'success',
|
||||
});
|
||||
},
|
||||
[pushNotification, setName, t]
|
||||
);
|
||||
|
||||
const handleSetInput = useCallback((value: string) => {
|
||||
startTransition(() => {
|
||||
setInput(value);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleKeyUp = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.code === 'Enter' && name !== input) {
|
||||
handleUpdateWorkspaceName(input);
|
||||
}
|
||||
},
|
||||
[handleUpdateWorkspaceName, input, name]
|
||||
);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
handleUpdateWorkspaceName(input);
|
||||
}, [handleUpdateWorkspaceName, input]);
|
||||
|
||||
const handleRemoveUserAvatar = useCallback(
|
||||
async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
await update(null);
|
||||
},
|
||||
[update]
|
||||
);
|
||||
return (
|
||||
<div className={style.profileWrapper}>
|
||||
<Upload
|
||||
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
|
||||
fileChange={update}
|
||||
data-testid="upload-avatar"
|
||||
disabled={!isOwner}
|
||||
>
|
||||
<Avatar
|
||||
size={56}
|
||||
url={workspaceAvatar}
|
||||
name={name}
|
||||
colorfulFallback
|
||||
hoverIcon={isOwner ? <CameraIcon /> : undefined}
|
||||
onRemove={
|
||||
workspaceAvatar && isOwner ? handleRemoveUserAvatar : undefined
|
||||
}
|
||||
avatarTooltipOptions={{ content: t['Click to replace photo']() }}
|
||||
removeTooltipOptions={{ content: t['Remove photo']() }}
|
||||
data-testid="workspace-setting-avatar"
|
||||
removeButtonProps={{
|
||||
['data-testid' as string]: 'workspace-setting-remove-avatar-button',
|
||||
}}
|
||||
/>
|
||||
</Upload>
|
||||
|
||||
<Wrapper marginLeft={20}>
|
||||
<div className={style.label}>{t['Workspace Name']()}</div>
|
||||
<FlexWrapper alignItems="center" flexGrow="1">
|
||||
<Input
|
||||
disabled={!isOwner}
|
||||
width={280}
|
||||
height={32}
|
||||
defaultValue={input}
|
||||
data-testid="workspace-name-input"
|
||||
placeholder={t['Workspace Name']()}
|
||||
maxLength={64}
|
||||
minLength={0}
|
||||
onChange={handleSetInput}
|
||||
onKeyUp={handleKeyUp}
|
||||
/>
|
||||
{input === workspace.blockSuiteWorkspace.meta.name ? null : (
|
||||
<Button
|
||||
data-testid="save-workspace-name"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
}}
|
||||
>
|
||||
{t['com.affine.editCollection.save']()}
|
||||
</Button>
|
||||
)}
|
||||
</FlexWrapper>
|
||||
</Wrapper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
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 { noop } from 'foxact/noop';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { toast } from '../../../utils';
|
||||
import { EnableAffineCloudModal } from '../enable-affine-cloud-modal';
|
||||
import { TmpDisableAffineCloudModal } from '../tmp-disable-affine-cloud-modal';
|
||||
import * as style from './style.css';
|
||||
import type { WorkspaceSettingDetailProps } from './types';
|
||||
|
||||
export interface PublishPanelProps
|
||||
extends Omit<WorkspaceSettingDetailProps, 'workspaceId'> {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
export interface PublishPanelLocalProps
|
||||
extends Omit<WorkspaceSettingDetailProps, 'workspaceId'> {
|
||||
workspace: LocalWorkspace;
|
||||
}
|
||||
export interface PublishPanelAffineProps
|
||||
extends Omit<WorkspaceSettingDetailProps, 'workspaceId'> {
|
||||
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;
|
||||
|
||||
useEffect(() => {
|
||||
setOrigin(
|
||||
typeof window !== 'undefined' && window.location.origin
|
||||
? window.location.origin
|
||||
: ''
|
||||
);
|
||||
}, []);
|
||||
|
||||
const copyUrl = useCallback(async () => {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast(t['Copied link to clipboard']());
|
||||
}, [shareUrl, t]);
|
||||
|
||||
return (
|
||||
<div style={{ display: 'none' }}>
|
||||
<SettingRow
|
||||
name={t['Publish']()}
|
||||
desc={isPublic ? t['Unpublished hint']() : t['Published hint']()}
|
||||
style={{
|
||||
marginBottom: isPublic ? '12px' : '25px',
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
checked={isPublic}
|
||||
// onChange={useCallback(value => {
|
||||
// console.log('onChange', value);
|
||||
// }, [])}
|
||||
/>
|
||||
</SettingRow>
|
||||
{isPublic ? (
|
||||
<FlexWrapper justifyContent="space-between" marginBottom={25}>
|
||||
<Input value={shareUrl} disabled />
|
||||
<Button
|
||||
onClick={copyUrl}
|
||||
style={{
|
||||
marginLeft: '20px',
|
||||
}}
|
||||
>
|
||||
{t['Copy']()}
|
||||
</Button>
|
||||
</FlexWrapper>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface FakePublishPanelAffineProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
const FakePublishPanelAffine = (_props: FakePublishPanelAffineProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<Tooltip content={t['com.affine.settings.workspace.publish-tooltip']()}>
|
||||
<div className={style.fakeWrapper}>
|
||||
<SettingRow name={t['Publish']()} desc={t['Unpublished hint']()}>
|
||||
<Switch checked={false} onChange={noop} />
|
||||
</SettingRow>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const PublishPanelLocal = ({
|
||||
workspace,
|
||||
onTransferWorkspace,
|
||||
}: PublishPanelLocalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace);
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingRow
|
||||
name={t['Workspace saved locally']({ name })}
|
||||
desc={t['Enable cloud hint']()}
|
||||
spreadCol={false}
|
||||
style={{
|
||||
padding: '10px',
|
||||
background: 'var(--affine-background-secondary-color)',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
data-testid="publish-enable-affine-cloud-button"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
style={{ marginTop: '12px' }}
|
||||
>
|
||||
{t['Enable AFFiNE Cloud']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<FakePublishPanelAffine workspace={workspace} />
|
||||
{runtimeConfig.enableCloud ? (
|
||||
<EnableAffineCloudModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onConfirm={() => {
|
||||
onTransferWorkspace(
|
||||
WorkspaceFlavour.LOCAL,
|
||||
WorkspaceFlavour.AFFINE_CLOUD,
|
||||
workspace
|
||||
);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<TmpDisableAffineCloudModal open={open} onOpenChange={setOpen} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const PublishPanel = (props: PublishPanelProps) => {
|
||||
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} />;
|
||||
}
|
||||
throw new Unreachable();
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
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';
|
||||
import type { MoveDBFileResult } from '@toeverything/infra/type';
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const useDBFileSecondaryPath = (workspaceId: string) => {
|
||||
const [path, setPath] = useState<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (window.apis && window.events && environment.isDesktop) {
|
||||
window.apis?.workspace
|
||||
.getMeta(workspaceId)
|
||||
.then(meta => {
|
||||
setPath(meta.secondaryDBPath);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
return window.events.workspace.onMetaChange((newMeta: any) => {
|
||||
if (newMeta.workspaceId === workspaceId) {
|
||||
const meta = newMeta.meta;
|
||||
setPath(meta.secondaryDBPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
return;
|
||||
}, [workspaceId]);
|
||||
return path;
|
||||
};
|
||||
|
||||
interface StoragePanelProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
}
|
||||
|
||||
export const StoragePanel = ({ workspace }: StoragePanelProps) => {
|
||||
const workspaceId = workspace.id;
|
||||
const t = useAFFiNEI18N();
|
||||
const secondaryPath = useDBFileSecondaryPath(workspaceId);
|
||||
|
||||
const [moveToInProgress, setMoveToInProgress] = useState<boolean>(false);
|
||||
const onRevealDBFile = useCallback(() => {
|
||||
window.apis?.dialog.revealDBFile(workspaceId).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [workspaceId]);
|
||||
|
||||
const handleMoveTo = useCallback(() => {
|
||||
if (moveToInProgress) {
|
||||
return;
|
||||
}
|
||||
setMoveToInProgress(true);
|
||||
window.apis?.dialog
|
||||
.moveDBFile(workspaceId)
|
||||
.then((result: MoveDBFileResult) => {
|
||||
if (!result?.error && !result?.canceled) {
|
||||
toast(t['Move folder success']());
|
||||
} else if (result?.error) {
|
||||
toast(t[result.error]());
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast(t['UNKNOWN_ERROR']());
|
||||
})
|
||||
.finally(() => {
|
||||
setMoveToInProgress(false);
|
||||
});
|
||||
}, [moveToInProgress, t, workspaceId]);
|
||||
|
||||
const rowContent = useMemo(
|
||||
() =>
|
||||
secondaryPath ? (
|
||||
<FlexWrapper justifyContent="space-between">
|
||||
<Tooltip
|
||||
content={t['com.affine.settings.storage.db-location.change-hint']()}
|
||||
side="top"
|
||||
align="start"
|
||||
>
|
||||
<Button
|
||||
data-testid="move-folder"
|
||||
// className={style.urlButton}
|
||||
size="large"
|
||||
onClick={handleMoveTo}
|
||||
>
|
||||
{secondaryPath}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Button
|
||||
data-testid="reveal-folder"
|
||||
data-disabled={moveToInProgress}
|
||||
onClick={onRevealDBFile}
|
||||
>
|
||||
{t['Open folder']()}
|
||||
</Button>
|
||||
</FlexWrapper>
|
||||
) : (
|
||||
<Button
|
||||
data-testid="move-folder"
|
||||
data-disabled={moveToInProgress}
|
||||
onClick={handleMoveTo}
|
||||
>
|
||||
{t['Move folder']()}
|
||||
</Button>
|
||||
),
|
||||
[handleMoveTo, moveToInProgress, onRevealDBFile, secondaryPath, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
name={t['Storage']()}
|
||||
desc={t[
|
||||
secondaryPath
|
||||
? 'com.affine.settings.storage.description-alt'
|
||||
: 'com.affine.settings.storage.description'
|
||||
]()}
|
||||
spreadCol={!secondaryPath}
|
||||
>
|
||||
{rowContent}
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,184 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const profileWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'flex-end',
|
||||
marginTop: '12px',
|
||||
});
|
||||
export const profileHandlerWrapper = style({
|
||||
flexGrow: '1',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginLeft: '20px',
|
||||
});
|
||||
|
||||
export const labelWrapper = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
marginTop: '24px',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
|
||||
export const avatarWrapper = style({
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
position: 'relative',
|
||||
cursor: 'pointer',
|
||||
flexShrink: '0',
|
||||
selectors: {
|
||||
'&.disable': {
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
|
||||
display: 'flex',
|
||||
});
|
||||
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
|
||||
display: 'flex',
|
||||
});
|
||||
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
position: 'absolute',
|
||||
display: 'none',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'rgba(60, 61, 63, 0.5)',
|
||||
zIndex: '1',
|
||||
color: 'var(--affine-white)',
|
||||
fontSize: '24px',
|
||||
});
|
||||
|
||||
export const urlButton = style({
|
||||
width: 'calc(100% - 64px - 15px)',
|
||||
justifyContent: 'left',
|
||||
textAlign: 'left',
|
||||
});
|
||||
globalStyle(`${urlButton} span`, {
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'var(--affine-placeholder-color)',
|
||||
fontWeight: '500',
|
||||
});
|
||||
|
||||
export const fakeWrapper = style({
|
||||
position: 'relative',
|
||||
opacity: 0.4,
|
||||
marginTop: '24px',
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: '""',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
cursor: 'not-allowed',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const membersFallback = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-primary-color)',
|
||||
});
|
||||
export const membersPanel = style({
|
||||
padding: '4px',
|
||||
borderRadius: '12px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const memberList = style({});
|
||||
export const memberListItem = style({
|
||||
padding: '0 4px 0 16px',
|
||||
height: '58px',
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '8px',
|
||||
},
|
||||
'&:not(:last-of-type)': {
|
||||
marginBottom: '6px',
|
||||
},
|
||||
},
|
||||
});
|
||||
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(`${memberListItem}:hover ${iconButton}`, {
|
||||
opacity: 1,
|
||||
pointerEvents: 'all',
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
marginBottom: '5px',
|
||||
});
|
||||
export const workspaceLabel = style({
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: '6px',
|
||||
padding: '2px 10px',
|
||||
border: '1px solid var(--affine-white-30)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
lineHeight: '20px',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import type {
|
||||
WorkspaceFlavour,
|
||||
WorkspaceRegistry,
|
||||
} from '@affine/env/workspace';
|
||||
|
||||
export interface WorkspaceSettingDetailProps {
|
||||
workspaceId: string;
|
||||
isOwner: boolean;
|
||||
onDeleteLocalWorkspace: () => void;
|
||||
onDeleteCloudWorkspace: () => void;
|
||||
onLeaveWorkspace: () => void;
|
||||
onTransferWorkspace: <
|
||||
From extends WorkspaceFlavour,
|
||||
To extends WorkspaceFlavour,
|
||||
>(
|
||||
from: From,
|
||||
to: To,
|
||||
workspace: WorkspaceRegistry[From]
|
||||
) => void;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TourModal } from '@affine/component/tour-modal';
|
||||
import { useAtom } from 'jotai';
|
||||
import { memo, useCallback } from 'react';
|
||||
|
||||
import { openOnboardingModalAtom } from '../../atoms';
|
||||
import { guideOnboardingAtom } from '../../atoms/guide';
|
||||
|
||||
export const OnboardingModal = memo(function OnboardingModal() {
|
||||
const [open, setOpen] = useAtom(openOnboardingModalAtom);
|
||||
const [guideOpen, setShowOnboarding] = useAtom(guideOnboardingAtom);
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) return;
|
||||
setShowOnboarding(false);
|
||||
setOpen(false);
|
||||
},
|
||||
[setOpen, setShowOnboarding]
|
||||
);
|
||||
|
||||
return (
|
||||
<TourModal open={!open ? guideOpen : open} onOpenChange={onOpenChange} />
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,246 @@
|
||||
import { FlexWrapper, Input } from '@affine/component';
|
||||
import {
|
||||
SettingHeader,
|
||||
SettingRow,
|
||||
StorageProgress,
|
||||
} from '@affine/component/setting-components';
|
||||
import {
|
||||
allBlobSizesQuery,
|
||||
removeAvatarMutation,
|
||||
uploadAvatarMutation,
|
||||
} from '@affine/graphql';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useMutation, useQuery } from '@affine/workspace/affine/gql';
|
||||
import { ArrowRightSmallIcon, CameraIcon } from '@blocksuite/icons';
|
||||
import { Avatar } from '@toeverything/components/avatar';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import {
|
||||
type FC,
|
||||
type MouseEvent,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { authAtom, openSignOutModalAtom } from '../../../../atoms';
|
||||
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
|
||||
import { Upload } from '../../../pure/file-upload';
|
||||
import * as style from './style.css';
|
||||
|
||||
export const UserAvatar = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const user = useCurrentUser();
|
||||
|
||||
const { trigger: avatarTrigger } = useMutation({
|
||||
mutation: uploadAvatarMutation,
|
||||
});
|
||||
const { trigger: removeAvatarTrigger } = useMutation({
|
||||
mutation: removeAvatarMutation,
|
||||
});
|
||||
|
||||
const handleUpdateUserAvatar = useCallback(
|
||||
async (file: File) => {
|
||||
await avatarTrigger({
|
||||
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]
|
||||
);
|
||||
const handleRemoveUserAvatar = useCallback(
|
||||
async (e: MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
await removeAvatarTrigger();
|
||||
// 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);
|
||||
},
|
||||
[removeAvatarTrigger, user]
|
||||
);
|
||||
return (
|
||||
<Upload
|
||||
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
|
||||
fileChange={handleUpdateUserAvatar}
|
||||
data-testid="upload-user-avatar"
|
||||
>
|
||||
<Avatar
|
||||
size={56}
|
||||
name={user.name}
|
||||
url={user.image}
|
||||
hoverIcon={<CameraIcon />}
|
||||
onRemove={user.image ? handleRemoveUserAvatar : undefined}
|
||||
avatarTooltipOptions={{ content: t['Click to replace photo']() }}
|
||||
removeTooltipOptions={{ content: t['Remove photo']() }}
|
||||
data-testid="user-setting-avatar"
|
||||
removeButtonProps={{
|
||||
['data-testid' as string]: 'user-setting-remove-avatar-button',
|
||||
}}
|
||||
/>
|
||||
</Upload>
|
||||
);
|
||||
};
|
||||
|
||||
export const AvatarAndName = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const user = useCurrentUser();
|
||||
const [input, setInput] = useState<string>(user.name);
|
||||
|
||||
const allowUpdate = !!input && input !== user.name;
|
||||
const handleUpdateUserName = useCallback(() => {
|
||||
if (!allowUpdate) {
|
||||
return;
|
||||
}
|
||||
user.update({ name: input }).catch(console.error);
|
||||
}, [allowUpdate, input, 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">
|
||||
<Suspense>
|
||||
<UserAvatar />
|
||||
</Suspense>
|
||||
|
||||
<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}
|
||||
onEnter={handleUpdateUserName}
|
||||
/>
|
||||
{allowUpdate ? (
|
||||
<Button
|
||||
data-testid="save-user-name"
|
||||
onClick={handleUpdateUserName}
|
||||
style={{
|
||||
marginLeft: '12px',
|
||||
}}
|
||||
>
|
||||
{t['com.affine.editCollection.save']()}
|
||||
</Button>
|
||||
) : null}
|
||||
</FlexWrapper>
|
||||
</div>
|
||||
</FlexWrapper>
|
||||
</SettingRow>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const StoragePanel = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const { data } = useQuery({
|
||||
query: allBlobSizesQuery,
|
||||
});
|
||||
|
||||
const onUpgrade = useCallback(() => {}, []);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
name={t['com.affine.storage.title']()}
|
||||
desc=""
|
||||
spreadCol={false}
|
||||
>
|
||||
<StorageProgress
|
||||
max={10737418240}
|
||||
value={data.collectAllBlobSizes.size}
|
||||
onUpgrade={onUpgrade}
|
||||
/>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const AccountSetting: FC = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const user = useCurrentUser();
|
||||
const setAuthModal = useSetAtom(authAtom);
|
||||
const setSignOutModal = useSetAtom(openSignOutModalAtom);
|
||||
|
||||
const onChangeEmail = useCallback(() => {
|
||||
setAuthModal({
|
||||
openModal: true,
|
||||
state: 'sendEmail',
|
||||
email: user.email,
|
||||
emailType: 'changeEmail',
|
||||
});
|
||||
}, [setAuthModal, user.email]);
|
||||
|
||||
const onPasswordButtonClick = useCallback(() => {
|
||||
setAuthModal({
|
||||
openModal: true,
|
||||
state: 'sendEmail',
|
||||
email: user.email,
|
||||
emailType: user.hasPassword ? 'changePassword' : 'setPassword',
|
||||
});
|
||||
}, [setAuthModal, user.email, user.hasPassword]);
|
||||
|
||||
const onOpenSignOutModal = useCallback(() => {
|
||||
setSignOutModal(true);
|
||||
}, [setSignOutModal]);
|
||||
|
||||
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={onPasswordButtonClick}>
|
||||
{user.hasPassword
|
||||
? t['com.affine.settings.password.action.change']()
|
||||
: t['com.affine.settings.password.action.set']()}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
<Suspense>
|
||||
<StoragePanel />
|
||||
</Suspense>
|
||||
<SettingRow
|
||||
name={t[`Sign out`]()}
|
||||
desc={t['com.affine.setting.sign.out.message']()}
|
||||
style={{ cursor: 'pointer' }}
|
||||
data-testid="sign-out-button"
|
||||
onClick={onOpenSignOutModal}
|
||||
>
|
||||
<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',
|
||||
cursor: 'pointer',
|
||||
flexShrink: '0',
|
||||
selectors: {
|
||||
'&.disable': {
|
||||
cursor: 'default',
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
|
||||
display: 'flex',
|
||||
});
|
||||
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
|
||||
width: '56px',
|
||||
height: '56px',
|
||||
borderRadius: '50%',
|
||||
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)',
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
DiscordIcon,
|
||||
GithubIcon,
|
||||
RedditIcon,
|
||||
TelegramIcon,
|
||||
TwitterIcon,
|
||||
YouTubeIcon,
|
||||
} from './icons';
|
||||
|
||||
export const relatedLinks = [
|
||||
{
|
||||
icon: <GithubIcon />,
|
||||
title: 'GitHub',
|
||||
link: 'https://github.com/toeverything/AFFiNE',
|
||||
},
|
||||
{
|
||||
icon: <TwitterIcon />,
|
||||
title: 'Twitter',
|
||||
link: 'https://twitter.com/AffineOfficial',
|
||||
},
|
||||
{
|
||||
icon: <DiscordIcon />,
|
||||
title: 'Discord',
|
||||
link: 'https://discord.gg/Arn7TqJBvG',
|
||||
},
|
||||
{
|
||||
icon: <YouTubeIcon />,
|
||||
title: 'YouTube',
|
||||
link: 'https://www.youtube.com/@affinepro',
|
||||
},
|
||||
{
|
||||
icon: <TelegramIcon />,
|
||||
title: 'Telegram',
|
||||
link: 'https://t.me/affineworkos',
|
||||
},
|
||||
{
|
||||
icon: <RedditIcon />,
|
||||
title: 'Reddit',
|
||||
link: 'https://www.reddit.com/r/Affine/',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,189 @@
|
||||
export const LogoIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="0 0 50 50"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M21.1996 0L4 50H14.0741L25.0146 15.4186L35.96 50H46L28.7978 0H21.1996Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
export const DocIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="50"
|
||||
height="50"
|
||||
viewBox="0 0 50 50"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2 40.5353V9.46462C2 6.95444 2.99716 4.54708 4.77212 2.77212C6.54708 0.997163 8.95444 0 11.4646 0H37.7552C39.0224 0 40.0497 1.02726 40.0497 2.29445V33.3652C40.0497 33.4357 40.0465 33.5055 40.0403 33.5744C39.9882 34.1502 39.7234 34.6646 39.3251 35.0385C38.9147 35.4237 38.3625 35.6597 37.7552 35.6597H11.4646C11.0129 35.6597 10.5676 35.7224 10.1404 35.8429C8.60419 36.2781 7.37011 37.4505 6.85245 38.9541C6.67955 39.4584 6.58891 39.9922 6.58891 40.5354C6.58891 41.8285 7.1026 43.0687 8.01697 43.983C8.93134 44.8974 10.1715 45.4111 11.4646 45.4111H42.6309V4.68456C42.6309 3.41736 43.6582 2.3901 44.9254 2.3901C46.1926 2.3901 47.2198 3.41736 47.2198 4.68456V47.7055C47.2198 48.9727 46.1926 50 44.9254 50H11.4646C8.95445 50 6.54708 49.0028 4.77212 47.2279C2.99716 45.4529 2 43.0456 2 40.5353ZM12.6596 38.2409C11.3925 38.2409 10.3652 39.2682 10.3652 40.5354C10.3652 41.8026 11.3925 42.8298 12.6596 42.8298H36.5602C37.8274 42.8298 38.8546 41.8026 38.8546 40.5354C38.8546 39.2682 37.8274 38.2409 36.5602 38.2409H12.6596Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const TwitterIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M22 5.88235C21.2639 6.21176 20.4704 6.42824 19.6482 6.53176C20.4895 6.03294 21.1396 5.24235 21.4455 4.29176C20.652 4.76235 19.7725 5.09176 18.8451 5.28C18.0899 4.47059 17.0287 4 15.8241 4C13.5774 4 11.7419 5.80706 11.7419 8.03765C11.7419 8.35765 11.7801 8.66824 11.847 8.96C8.4436 8.79059 5.413 7.18118 3.39579 4.74353C3.04207 5.33647 2.8413 6.03294 2.8413 6.76706C2.8413 8.16941 3.55832 9.41176 4.6673 10.1176C3.98853 10.1176 3.35755 9.92941 2.80306 9.64706V9.67529C2.80306 11.6329 4.21797 13.2706 6.09178 13.6376C5.49018 13.7997 4.8586 13.8223 4.24665 13.7035C4.50632 14.5059 5.01485 15.2079 5.70078 15.711C6.38671 16.2141 7.21553 16.4929 8.07075 16.5082C6.62106 17.6381 4.82409 18.2488 2.97514 18.24C2.6501 18.24 2.32505 18.2212 2 18.1835C3.81644 19.3318 5.97706 20 8.29063 20C15.8241 20 19.9637 13.8447 19.9637 8.50824C19.9637 8.32941 19.9637 8.16 19.9541 7.98118C20.7572 7.41647 21.4455 6.70118 22 5.88235Z"
|
||||
fill="#1D9BF0"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const GithubIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_3073_4801)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.667 2C7.14199 2 2.66699 6.58819 2.66699 12.2529C2.66699 16.7899 5.52949 20.6219 9.50449 21.9804C10.0045 22.0701 10.192 21.7625 10.192 21.4934C10.192 21.2499 10.1795 20.4425 10.1795 19.5838C7.66699 20.058 7.01699 18.9558 6.81699 18.3791C6.70449 18.0843 6.21699 17.1743 5.79199 16.9308C5.44199 16.7386 4.94199 16.2644 5.77949 16.2516C6.56699 16.2388 7.12949 16.9949 7.31699 17.3025C8.21699 18.8533 9.65449 18.4175 10.2295 18.1484C10.317 17.4819 10.5795 17.0334 10.867 16.777C8.64199 16.5207 6.31699 15.6364 6.31699 11.7147C6.31699 10.5997 6.70449 9.67689 7.34199 8.95918C7.24199 8.70286 6.89199 7.65193 7.44199 6.24215C7.44199 6.24215 8.27949 5.97301 10.192 7.29308C10.992 7.06239 11.842 6.94704 12.692 6.94704C13.542 6.94704 14.392 7.06239 15.192 7.29308C17.1045 5.9602 17.942 6.24215 17.942 6.24215C18.492 7.65193 18.142 8.70286 18.042 8.95918C18.6795 9.67689 19.067 10.5868 19.067 11.7147C19.067 15.6492 16.7295 16.5207 14.5045 16.777C14.867 17.0975 15.1795 17.7126 15.1795 18.6738C15.1795 20.0452 15.167 21.1474 15.167 21.4934C15.167 21.7625 15.3545 22.0829 15.8545 21.9804C17.8396 21.2932 19.5646 19.9851 20.7867 18.2401C22.0088 16.4951 22.6664 14.4012 22.667 12.2529C22.667 6.58819 18.192 2 12.667 2Z"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3073_4801">
|
||||
<rect width="25" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
export const DiscordIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clipPath="url(#clip0_3073_4801)">
|
||||
<path
|
||||
d="M19.2565 5.64663C17.9898 5.05614 16.6183 4.62755 15.1897 4.37993C15.1772 4.37953 15.1647 4.38188 15.1532 4.38681C15.1417 4.39175 15.1314 4.39915 15.1231 4.4085C14.9516 4.72279 14.7516 5.13233 14.6183 5.44662C13.103 5.21804 11.562 5.21804 10.0467 5.44662C9.9134 5.1228 9.71339 4.72279 9.53243 4.4085C9.52291 4.38945 9.49434 4.37993 9.46576 4.37993C8.03715 4.62755 6.67521 5.05614 5.39899 5.64663C5.38946 5.64663 5.37994 5.65615 5.37041 5.66568C2.77987 9.54197 2.06556 13.3135 2.41795 17.0469C2.41795 17.066 2.42748 17.085 2.44652 17.0946C4.16086 18.3517 5.80852 19.1137 7.43714 19.6184C7.46571 19.628 7.49428 19.6184 7.50381 19.5994C7.88477 19.0756 8.22764 18.5232 8.52288 17.9422C8.54193 17.9041 8.52288 17.866 8.48479 17.8565C7.94191 17.647 7.42761 17.3993 6.92284 17.1136C6.88474 17.0946 6.88474 17.0374 6.91331 17.0088C7.01808 16.9327 7.12284 16.8469 7.22761 16.7707C7.24666 16.7517 7.27523 16.7517 7.29428 16.7612C10.5706 18.2565 14.104 18.2565 17.3422 16.7612C17.3612 16.7517 17.3898 16.7517 17.4088 16.7707C17.5136 16.8565 17.6184 16.9327 17.7231 17.0184C17.7612 17.0469 17.7612 17.1041 17.7136 17.1231C17.2184 17.4184 16.6945 17.6565 16.1517 17.866C16.1136 17.8755 16.104 17.9232 16.1136 17.9517C16.4183 18.5327 16.7612 19.0851 17.1326 19.6089C17.1612 19.6184 17.1898 19.628 17.2184 19.6184C18.8565 19.1137 20.5042 18.3517 22.2185 17.0946C22.2375 17.085 22.2471 17.066 22.2471 17.0469C22.6661 12.7325 21.5518 8.98958 19.2946 5.66568C19.2851 5.65615 19.2756 5.64663 19.2565 5.64663ZM9.01813 14.7707C8.03715 14.7707 7.21808 13.8659 7.21808 12.7516C7.21808 11.6373 8.01811 10.7325 9.01813 10.7325C10.0277 10.7325 10.8277 11.6468 10.8182 12.7516C10.8182 13.8659 10.0182 14.7707 9.01813 14.7707ZM15.6564 14.7707C14.6754 14.7707 13.8564 13.8659 13.8564 12.7516C13.8564 11.6373 14.6564 10.7325 15.6564 10.7325C16.666 10.7325 17.466 11.6468 17.4565 12.7516C17.4565 13.8659 16.666 14.7707 15.6564 14.7707Z"
|
||||
fill="#5865F2"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_3073_4801">
|
||||
<rect width="25" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const TelegramIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 2C9.34844 2 6.80312 3.05422 4.92969 4.92891C3.05432 6.80434 2.00052 9.34778 2 12C2 14.6511 3.05469 17.1964 4.92969 19.0711C6.80312 20.9458 9.34844 22 12 22C14.6516 22 17.1969 20.9458 19.0703 19.0711C20.9453 17.1964 22 14.6511 22 12C22 9.34891 20.9453 6.80359 19.0703 4.92891C17.1969 3.05422 14.6516 2 12 2Z"
|
||||
fill="url(#paint0_linear_8233_169329)"
|
||||
/>
|
||||
<path
|
||||
d="M6.5267 11.8943C9.44232 10.6243 11.3861 9.78694 12.3579 9.38241C15.1361 8.22726 15.7126 8.02663 16.0892 8.01983C16.172 8.01851 16.3564 8.03898 16.4767 8.13624C16.5767 8.21827 16.6048 8.32921 16.6189 8.4071C16.6314 8.48491 16.6486 8.66226 16.6345 8.80069C16.4845 10.3819 15.8329 14.2191 15.5017 15.9902C15.3626 16.7396 15.0861 16.9908 14.8189 17.0154C14.2376 17.0688 13.797 16.6316 13.2345 16.263C12.3548 15.686 11.8579 15.3269 11.0033 14.764C10.0158 14.1134 10.6564 13.7557 11.2189 13.1713C11.3658 13.0184 13.9251 10.691 13.9736 10.4799C13.9798 10.4535 13.9861 10.3551 13.9267 10.3032C13.8689 10.2512 13.7829 10.269 13.7204 10.283C13.6314 10.303 12.2267 11.2324 9.5017 13.071C9.10326 13.3451 8.74232 13.4787 8.41732 13.4716C8.06107 13.464 7.37357 13.2698 6.86264 13.1038C6.23764 12.9002 5.7392 12.7926 5.78295 12.4468C5.80482 12.2668 6.05326 12.0826 6.5267 11.8943Z"
|
||||
fill="white"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_8233_169329"
|
||||
x1="1002"
|
||||
y1="2"
|
||||
x2="1002"
|
||||
y2="2002"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2AABEE" />
|
||||
<stop offset="1" stopColor="#229ED9" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const RedditIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12.334 22C17.8568 22 22.334 17.5228 22.334 12C22.334 6.47715 17.8568 2 12.334 2C6.81114 2 2.33398 6.47715 2.33398 12C2.33398 17.5228 6.81114 22 12.334 22Z"
|
||||
fill="#FF4500"
|
||||
/>
|
||||
<path
|
||||
d="M18.9863 12.0954C18.9863 11.2848 18.3308 10.641 17.5319 10.641C17.1545 10.6404 16.7915 10.7857 16.5186 11.0463C15.5172 10.331 14.1461 9.86611 12.6202 9.8065L13.2877 6.68299L15.4574 7.14783C15.4814 7.69627 15.9343 8.13744 16.4948 8.13744C17.067 8.13744 17.5319 7.6726 17.5319 7.1001C17.5319 6.52791 17.067 6.06299 16.4948 6.06299C16.0895 6.06299 15.7316 6.30143 15.5648 6.64721L13.1448 6.13455C13.0732 6.12252 13.0016 6.13455 12.9539 6.17033C12.8943 6.20611 12.8586 6.26564 12.8468 6.33721L12.1074 9.8183C10.5577 9.86611 9.16273 10.331 8.14945 11.0583C7.87653 10.7976 7.51349 10.6524 7.13609 10.653C6.32539 10.653 5.68164 11.3085 5.68164 12.1074C5.68164 12.7035 6.03922 13.2041 6.54008 13.4308C6.51576 13.5766 6.50379 13.7241 6.5043 13.8719C6.5043 16.113 9.11524 17.9372 12.3341 17.9372C15.553 17.9372 18.1639 16.125 18.1639 13.8719C18.1638 13.7241 18.1519 13.5766 18.1281 13.4308C18.6288 13.2041 18.9863 12.6914 18.9863 12.0954ZM8.99586 13.1325C8.99586 12.5603 9.4607 12.0954 10.0332 12.0954C10.6054 12.0954 11.0703 12.5603 11.0703 13.1325C11.0703 13.7048 10.6055 14.1699 10.0332 14.1699C9.46078 14.1816 8.99586 13.7048 8.99586 13.1325ZM14.8019 15.8865C14.0866 16.6019 12.7274 16.6496 12.3341 16.6496C11.9288 16.6496 10.5697 16.5899 9.86609 15.8865C9.75898 15.7792 9.75898 15.6123 9.86609 15.505C9.97344 15.3979 10.1403 15.3979 10.2477 15.505C10.7008 15.9581 11.6545 16.113 12.3341 16.113C13.0137 16.113 13.9792 15.9581 14.4203 15.505C14.5277 15.3979 14.6945 15.3979 14.8019 15.505C14.8972 15.6123 14.8972 15.7792 14.8019 15.8865ZM14.611 14.1817C14.0387 14.1817 13.5739 13.7168 13.5739 13.1446C13.5739 12.5723 14.0387 12.1074 14.611 12.1074C15.1834 12.1074 15.6483 12.5723 15.6483 13.1446C15.6483 13.7047 15.1834 14.1817 14.611 14.1817Z"
|
||||
fill="white"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const LinkIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M10.2917 1.33334C10.2917 0.988166 10.5715 0.708344 10.9167 0.708344H14.6667C15.0118 0.708344 15.2917 0.988166 15.2917 1.33334V5.08334C15.2917 5.42852 15.0118 5.70834 14.6667 5.70834C14.3215 5.70834 14.0417 5.42852 14.0417 5.08334V2.84223L8.44194 8.44195C8.19787 8.68603 7.80214 8.68603 7.55806 8.44195C7.31398 8.19787 7.31398 7.80215 7.55806 7.55807L13.1578 1.95834H10.9167C10.5715 1.95834 10.2917 1.67852 10.2917 1.33334ZM3.97464 1.54168L7.58334 1.54168C7.92851 1.54168 8.20834 1.8215 8.20834 2.16668C8.20834 2.51185 7.92851 2.79168 7.58334 2.79168H4C3.52298 2.79168 3.2028 2.79216 2.95623 2.81231C2.71697 2.83186 2.60256 2.86676 2.5271 2.90521C2.33109 3.00508 2.17174 3.16443 2.07187 3.36044C2.03342 3.4359 1.99852 3.55031 1.97897 3.78957C1.95882 4.03614 1.95834 4.35632 1.95834 4.83334V12C1.95834 12.477 1.95882 12.7972 1.97897 13.0438C1.99852 13.283 2.03342 13.3974 2.07187 13.4729C2.17174 13.6689 2.33109 13.8283 2.5271 13.9281C2.60256 13.9666 2.71697 14.0015 2.95623 14.021C3.2028 14.0412 3.52298 14.0417 4 14.0417H11.1667C11.6437 14.0417 11.9639 14.0412 12.2104 14.021C12.4497 14.0015 12.5641 13.9666 12.6396 13.9281C12.8356 13.8283 12.9949 13.6689 13.0948 13.4729C13.1333 13.3974 13.1682 13.283 13.1877 13.0438C13.2079 12.7972 13.2083 12.477 13.2083 12V8.41668C13.2083 8.0715 13.4882 7.79168 13.8333 7.79168C14.1785 7.79168 14.4583 8.0715 14.4583 8.41668V12.0254C14.4583 12.4705 14.4584 12.842 14.4336 13.1456C14.4077 13.4621 14.3518 13.7594 14.2086 14.0404C13.9888 14.4716 13.6383 14.8222 13.2071 15.0419C12.926 15.1851 12.6288 15.241 12.3122 15.2669C12.0087 15.2917 11.6372 15.2917 11.192 15.2917H3.97463C3.5295 15.2917 3.15797 15.2917 2.85444 15.2669C2.53787 15.241 2.24066 15.1851 1.95961 15.0419C1.5284 14.8222 1.17782 14.4716 0.958113 14.0404C0.81491 13.7594 0.758984 13.4621 0.733119 13.1456C0.70832 12.842 0.708327 12.4705 0.708336 12.0254V4.80798C0.708327 4.36285 0.70832 3.99131 0.733119 3.68779C0.758984 3.37121 0.81491 3.074 0.958113 2.79295C1.17782 2.36174 1.5284 2.01116 1.95961 1.79145C2.24066 1.64825 2.53787 1.59232 2.85444 1.56646C3.15797 1.54166 3.52951 1.54167 3.97464 1.54168Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
export const YouTubeIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="25"
|
||||
height="24"
|
||||
viewBox="0 0 25 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M21.7477 7.19232C21.6387 6.76858 21.4261 6.38227 21.1311 6.07186C20.8361 5.76145 20.4689 5.53776 20.0662 5.42308C18.5917 5 12.6575 5 12.6575 5C12.6575 5 6.72304 5.01281 5.24858 5.43589C4.84583 5.55057 4.47865 5.77427 4.18363 6.0847C3.88861 6.39512 3.67602 6.78145 3.56705 7.2052C3.12106 9.96155 2.94806 14.1616 3.5793 16.8077C3.68828 17.2314 3.90087 17.6177 4.19589 17.9281C4.49092 18.2386 4.85808 18.4622 5.26083 18.5769C6.73528 19 12.6696 19 12.6696 19C12.6696 19 18.6039 19 20.0783 18.5769C20.481 18.4623 20.8482 18.2386 21.1432 17.9282C21.4383 17.6177 21.6509 17.2314 21.7599 16.8077C22.2303 14.0474 22.3752 9.85004 21.7477 7.1924V7.19232Z"
|
||||
fill="#FF0000"
|
||||
/>
|
||||
<path d="M10.667 15L15.667 12L10.667 9V15Z" fill="white" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Switch } from '@affine/component';
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { SettingWrapper } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { type AppSetting, useAppSetting } from '../../../../../atoms/settings';
|
||||
import { relatedLinks } from './config';
|
||||
import { communityItem, communityWrapper, link } from './style.css';
|
||||
|
||||
export const AboutAffine = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [appSettings, setAppSettings] = useAppSetting();
|
||||
const changeSwitch = useCallback(
|
||||
(key: keyof AppSetting, checked: boolean) => {
|
||||
setAppSettings({ [key]: checked });
|
||||
},
|
||||
[setAppSettings]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.aboutAFFiNE.title']()}
|
||||
subtitle={t['com.affine.aboutAFFiNE.subtitle']()}
|
||||
data-testid="about-title"
|
||||
/>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.version.title']()}>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.version.app']()}
|
||||
desc={runtimeConfig.appVersion}
|
||||
/>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.version.editor.title']()}
|
||||
desc={runtimeConfig.editorVersion}
|
||||
/>
|
||||
{runtimeConfig.enableNewSettingUnstableApi && environment.isDesktop ? (
|
||||
<>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
|
||||
desc={t['com.affine.aboutAFFiNE.checkUpdate.description']()}
|
||||
/>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.autoCheckUpdate.title']()}
|
||||
desc={t['com.affine.aboutAFFiNE.autoCheckUpdate.description']()}
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.autoCheckUpdate}
|
||||
onChange={checked => changeSwitch('autoCheckUpdate', checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.autoDownloadUpdate.title']()}
|
||||
desc={t[
|
||||
'com.affine.aboutAFFiNE.autoDownloadUpdate.description'
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.autoCheckUpdate}
|
||||
onChange={checked => changeSwitch('autoCheckUpdate', checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.changelog.title']()}
|
||||
desc={t['com.affine.aboutAFFiNE.changelog.description']()}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
window.open(runtimeConfig.changelogUrl, '_blank');
|
||||
}}
|
||||
>
|
||||
<ArrowRightSmallIcon />
|
||||
</SettingRow>
|
||||
</>
|
||||
) : null}
|
||||
</SettingWrapper>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.contact.title']()}>
|
||||
<a
|
||||
className={link}
|
||||
rel="noreferrer"
|
||||
href="https://affine.pro"
|
||||
target="_blank"
|
||||
>
|
||||
{t['com.affine.aboutAFFiNE.contact.website']()}
|
||||
<OpenInNewIcon className="icon" />
|
||||
</a>
|
||||
<a
|
||||
className={link}
|
||||
rel="noreferrer"
|
||||
href="https://community.affine.pro"
|
||||
target="_blank"
|
||||
>
|
||||
{t['com.affine.aboutAFFiNE.contact.community']()}
|
||||
<OpenInNewIcon className="icon" />
|
||||
</a>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.community.title']()}>
|
||||
<div className={communityWrapper}>
|
||||
{relatedLinks.map(({ icon, title, link }) => {
|
||||
return (
|
||||
<div
|
||||
className={communityItem}
|
||||
onClick={() => {
|
||||
window.open(link, '_blank');
|
||||
}}
|
||||
key={title}
|
||||
>
|
||||
{icon}
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.legal.title']()}>
|
||||
<a
|
||||
className={link}
|
||||
rel="noreferrer"
|
||||
href="https://affine.pro/privacy"
|
||||
target="_blank"
|
||||
>
|
||||
{t['com.affine.aboutAFFiNE.legal.privacy']()}
|
||||
<OpenInNewIcon className="icon" />
|
||||
</a>
|
||||
<a
|
||||
className={link}
|
||||
rel="noreferrer"
|
||||
href="https://affine.pro/terms"
|
||||
target="_blank"
|
||||
>
|
||||
{t['com.affine.aboutAFFiNE.legal.tos']()}
|
||||
<OpenInNewIcon className="icon" />
|
||||
</a>
|
||||
</SettingWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const link = style({
|
||||
height: '18px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
marginBottom: '12px',
|
||||
selectors: {
|
||||
'&:last-of-type': {
|
||||
marginBottom: '0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${link} .icon`, {
|
||||
color: 'var(--affine-icon-color)',
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
marginLeft: '5px',
|
||||
});
|
||||
|
||||
export const communityWrapper = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '15% 15% 15% 15% 15% 15%',
|
||||
gap: '2%',
|
||||
});
|
||||
export const communityItem = style({
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 8px',
|
||||
});
|
||||
globalStyle(`${communityItem} svg`, {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
display: 'block',
|
||||
margin: '0 auto 2px',
|
||||
});
|
||||
globalStyle(`${communityItem} p`, {
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
textAlign: 'center',
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Menu, MenuItem, MenuTrigger } from '@toeverything/components/menu';
|
||||
import dayjs from 'dayjs';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
dateFormatOptions,
|
||||
type DateFormats,
|
||||
useAppSetting,
|
||||
} from '../../../../../atoms/settings';
|
||||
|
||||
interface DateFormatMenuContentProps {
|
||||
currentOption: DateFormats;
|
||||
onSelect: (option: DateFormats) => void;
|
||||
}
|
||||
|
||||
const DateFormatMenuContent = ({
|
||||
onSelect,
|
||||
currentOption,
|
||||
}: DateFormatMenuContentProps) => {
|
||||
return (
|
||||
<>
|
||||
{dateFormatOptions.map(option => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={option}
|
||||
selected={currentOption === option}
|
||||
onSelect={() => onSelect(option)}
|
||||
>
|
||||
{dayjs(new Date()).format(option)}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const DateFormatSetting = () => {
|
||||
const [appearanceSettings, setAppSettings] = useAppSetting();
|
||||
const handleSelect = useCallback(
|
||||
(option: DateFormats) => {
|
||||
setAppSettings({ dateFormat: option });
|
||||
},
|
||||
[setAppSettings]
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
items={
|
||||
<DateFormatMenuContent
|
||||
onSelect={handleSelect}
|
||||
currentOption={appearanceSettings.dateFormat}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuTrigger data-testid="date-format-menu-trigger" block>
|
||||
{dayjs(new Date()).format(appearanceSettings.dateFormat)}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { RadioButton, RadioButtonGroup, Switch } from '@affine/component';
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { SettingWrapper } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
type AppSetting,
|
||||
fontStyleOptions,
|
||||
useAppSetting,
|
||||
windowFrameStyleOptions,
|
||||
} from '../../../../../atoms/settings';
|
||||
import { LanguageMenu } from '../../../language-menu';
|
||||
import { DateFormatSetting } from './date-format-setting';
|
||||
import { settingWrapper } from './style.css';
|
||||
|
||||
export const ThemeSettings = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { setTheme, theme } = useTheme();
|
||||
|
||||
return (
|
||||
<RadioButtonGroup
|
||||
width={250}
|
||||
className={settingWrapper}
|
||||
value={theme}
|
||||
onValueChange={useCallback(
|
||||
(value: string) => {
|
||||
setTheme(value);
|
||||
},
|
||||
[setTheme]
|
||||
)}
|
||||
>
|
||||
<RadioButton value="system" data-testid="system-theme-trigger">
|
||||
{t['com.affine.themeSettings.system']()}
|
||||
</RadioButton>
|
||||
<RadioButton value="light" data-testid="light-theme-trigger">
|
||||
{t['com.affine.themeSettings.light']()}
|
||||
</RadioButton>
|
||||
<RadioButton value="dark" data-testid="dark-theme-trigger">
|
||||
{t['com.affine.themeSettings.dark']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
const FontFamilySettings = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [appSettings, setAppSettings] = useAppSetting();
|
||||
return (
|
||||
<RadioButtonGroup
|
||||
width={250}
|
||||
className={settingWrapper}
|
||||
value={appSettings.fontStyle}
|
||||
onValueChange={useCallback(
|
||||
(key: AppSetting['fontStyle']) => {
|
||||
setAppSettings({ fontStyle: key });
|
||||
},
|
||||
[setAppSettings]
|
||||
)}
|
||||
>
|
||||
{fontStyleOptions.map(({ key, value }) => {
|
||||
let font = '';
|
||||
switch (key) {
|
||||
case 'Sans':
|
||||
font = t['com.affine.appearanceSettings.fontStyle.sans']();
|
||||
break;
|
||||
case 'Serif':
|
||||
font = t['com.affine.appearanceSettings.fontStyle.serif']();
|
||||
break;
|
||||
case 'Mono':
|
||||
font = t[`com.affine.appearanceSettings.fontStyle.mono`]();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return (
|
||||
<RadioButton
|
||||
key={key}
|
||||
value={key}
|
||||
data-testid="system-font-style-trigger"
|
||||
style={{
|
||||
fontFamily: value,
|
||||
}}
|
||||
>
|
||||
{font}
|
||||
</RadioButton>
|
||||
);
|
||||
})}
|
||||
</RadioButtonGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export const AppearanceSettings = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const [appSettings, setAppSettings] = useAppSetting();
|
||||
|
||||
const changeSwitch = useCallback(
|
||||
(key: keyof AppSetting, checked: boolean) => {
|
||||
setAppSettings({ [key]: checked });
|
||||
},
|
||||
[setAppSettings]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.appearanceSettings.title']()}
|
||||
subtitle={t['com.affine.appearanceSettings.subtitle']()}
|
||||
/>
|
||||
|
||||
<SettingWrapper title={t['com.affine.appearanceSettings.theme.title']()}>
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.color.title']()}
|
||||
desc={t['com.affine.appearanceSettings.color.description']()}
|
||||
>
|
||||
<ThemeSettings />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.font.title']()}
|
||||
desc={t['com.affine.appearanceSettings.font.description']()}
|
||||
>
|
||||
<FontFamilySettings />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.language.title']()}
|
||||
desc={t['com.affine.appearanceSettings.language.description']()}
|
||||
>
|
||||
<div className={settingWrapper}>
|
||||
<LanguageMenu />
|
||||
</div>
|
||||
</SettingRow>
|
||||
{environment.isDesktop ? (
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.clientBorder.title']()}
|
||||
desc={t['com.affine.appearanceSettings.clientBorder.description']()}
|
||||
data-testid="client-border-style-trigger"
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.clientBorder}
|
||||
onChange={checked => changeSwitch('clientBorder', checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.fullWidth.title']()}
|
||||
desc={t['com.affine.appearanceSettings.fullWidth.description']()}
|
||||
>
|
||||
<Switch
|
||||
data-testid="full-width-layout-trigger"
|
||||
checked={appSettings.fullWidthLayout}
|
||||
onChange={checked => changeSwitch('fullWidthLayout', checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{runtimeConfig.enableNewSettingUnstableApi && environment.isDesktop ? (
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.windowFrame.title']()}
|
||||
desc={t['com.affine.appearanceSettings.windowFrame.description']()}
|
||||
>
|
||||
<RadioButtonGroup
|
||||
className={settingWrapper}
|
||||
width={250}
|
||||
defaultValue={appSettings.windowFrameStyle}
|
||||
onValueChange={(value: AppSetting['windowFrameStyle']) => {
|
||||
setAppSettings({ windowFrameStyle: value });
|
||||
}}
|
||||
>
|
||||
{windowFrameStyleOptions.map(option => {
|
||||
return (
|
||||
<RadioButton value={option} key={option}>
|
||||
{t[`com.affine.appearanceSettings.windowFrame.${option}`]()}
|
||||
</RadioButton>
|
||||
);
|
||||
})}
|
||||
</RadioButtonGroup>
|
||||
</SettingRow>
|
||||
) : null}
|
||||
</SettingWrapper>
|
||||
{runtimeConfig.enableNewSettingUnstableApi ? (
|
||||
<SettingWrapper title={t['com.affine.appearanceSettings.date.title']()}>
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.dateFormat.title']()}
|
||||
desc={t['com.affine.appearanceSettings.dateFormat.description']()}
|
||||
>
|
||||
<div className={settingWrapper}>
|
||||
<DateFormatSetting />
|
||||
</div>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.startWeek.title']()}
|
||||
desc={t['com.affine.appearanceSettings.startWeek.description']()}
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.startWeekOnMonday}
|
||||
onChange={checked => changeSwitch('startWeekOnMonday', checked)}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingWrapper>
|
||||
) : null}
|
||||
|
||||
{environment.isDesktop ? (
|
||||
<SettingWrapper
|
||||
title={t['com.affine.appearanceSettings.sidebar.title']()}
|
||||
>
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.noisyBackground.title']()}
|
||||
desc={t[
|
||||
'com.affine.appearanceSettings.noisyBackground.description'
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.enableNoisyBackground}
|
||||
onChange={checked =>
|
||||
changeSwitch('enableNoisyBackground', checked)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
{environment.isMacOs && (
|
||||
<SettingRow
|
||||
name={t['com.affine.appearanceSettings.translucentUI.title']()}
|
||||
desc={t[
|
||||
'com.affine.appearanceSettings.translucentUI.description'
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.enableBlurBackground}
|
||||
onChange={checked =>
|
||||
changeSwitch('enableBlurBackground', checked)
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</SettingWrapper>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const settingWrapper = style({
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: '150px',
|
||||
maxWidth: '250px',
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
AppearanceIcon,
|
||||
InformationIcon,
|
||||
KeyboardIcon,
|
||||
PluginIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { ReactElement, SVGProps } from 'react';
|
||||
|
||||
import { AboutAffine } from './about';
|
||||
import { AppearanceSettings } from './appearance';
|
||||
import { Plugins } from './plugins';
|
||||
import { Shortcuts } from './shortcuts';
|
||||
|
||||
export type GeneralSettingKeys =
|
||||
| 'shortcuts'
|
||||
| 'appearance'
|
||||
| 'plugins'
|
||||
| 'about';
|
||||
|
||||
interface GeneralSettingListItem {
|
||||
key: GeneralSettingKeys;
|
||||
title: string;
|
||||
icon: (props: SVGProps<SVGSVGElement>) => ReactElement;
|
||||
testId: string;
|
||||
}
|
||||
|
||||
export type GeneralSettingList = GeneralSettingListItem[];
|
||||
|
||||
export const useGeneralSettingList = (): GeneralSettingList => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'appearance',
|
||||
title: t['com.affine.settings.appearance'](),
|
||||
icon: AppearanceIcon,
|
||||
testId: 'appearance-panel-trigger',
|
||||
},
|
||||
{
|
||||
key: 'shortcuts',
|
||||
title: t['com.affine.keyboardShortcuts.title'](),
|
||||
icon: KeyboardIcon,
|
||||
testId: 'shortcuts-panel-trigger',
|
||||
},
|
||||
{
|
||||
key: 'plugins',
|
||||
title: 'Plugins',
|
||||
icon: PluginIcon,
|
||||
testId: 'plugins-panel-trigger',
|
||||
},
|
||||
{
|
||||
key: 'about',
|
||||
title: t['com.affine.aboutAFFiNE.title'](),
|
||||
icon: InformationIcon,
|
||||
testId: 'about-panel-trigger',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
interface GeneralSettingProps {
|
||||
generalKey: GeneralSettingKeys;
|
||||
}
|
||||
|
||||
export const GeneralSetting = ({ generalKey }: GeneralSettingProps) => {
|
||||
switch (generalKey) {
|
||||
case 'shortcuts':
|
||||
return <Shortcuts />;
|
||||
case 'appearance':
|
||||
return <AppearanceSettings />;
|
||||
case 'plugins':
|
||||
return <Plugins />;
|
||||
case 'about':
|
||||
return <AboutAffine />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Switch } from '@affine/component';
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { CallbackMap } from '@affine/sdk/entry';
|
||||
import {
|
||||
addCleanup,
|
||||
enabledPluginAtom,
|
||||
pluginPackageJson,
|
||||
pluginSettingAtom,
|
||||
} from '@toeverything/infra/__internal__/plugin';
|
||||
import { loadedPluginNameAtom } from '@toeverything/infra/atom';
|
||||
import type { packageJsonOutputSchema } from '@toeverything/infra/type';
|
||||
import { useAtom, useAtomValue } from 'jotai/react';
|
||||
import { startTransition, useCallback, useMemo } from 'react';
|
||||
import type { z } from 'zod';
|
||||
|
||||
import { pluginItemStyle } from './style.css';
|
||||
|
||||
type PluginItemProps = {
|
||||
json: z.infer<typeof packageJsonOutputSchema>;
|
||||
};
|
||||
|
||||
type PluginSettingDetailProps = {
|
||||
pluginName: string;
|
||||
create: CallbackMap['setting'];
|
||||
};
|
||||
|
||||
const PluginSettingDetail = ({
|
||||
pluginName,
|
||||
create,
|
||||
}: PluginSettingDetailProps) => {
|
||||
return (
|
||||
<div
|
||||
ref={useCallback(
|
||||
(ref: HTMLDivElement | null) => {
|
||||
if (ref) {
|
||||
const cleanup = create(ref);
|
||||
addCleanup(pluginName, cleanup);
|
||||
}
|
||||
},
|
||||
[pluginName, create]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const PluginItem = ({ json }: PluginItemProps) => {
|
||||
const [plugins, setEnabledPlugins] = useAtom(enabledPluginAtom);
|
||||
const checked = useMemo(
|
||||
() => plugins.includes(json.name),
|
||||
[json.name, plugins]
|
||||
);
|
||||
const create = useAtomValue(pluginSettingAtom)[json.name];
|
||||
return (
|
||||
<div className={pluginItemStyle} key={json.name}>
|
||||
<div>
|
||||
{json.name}
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={useCallback(
|
||||
(checked: boolean) => {
|
||||
startTransition(() => {
|
||||
setEnabledPlugins(plugins => {
|
||||
if (checked) {
|
||||
return [...plugins, json.name];
|
||||
} else {
|
||||
return plugins.filter(plugin => plugin !== json.name);
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
[json.name, setEnabledPlugins]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>{json.description}</div>
|
||||
{create && <PluginSettingDetail pluginName={json.name} create={create} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Plugins = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loadedPlugins = useAtomValue(loadedPluginNameAtom);
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={'Plugins'}
|
||||
subtitle={loadedPlugins.length === 0 && t['None yet']()}
|
||||
data-testid="plugins-title"
|
||||
/>
|
||||
{useAtomValue(pluginPackageJson).map(json => (
|
||||
<PluginItem json={json} key={json.name} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const settingWrapperStyle = style({
|
||||
flexGrow: 1,
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
minWidth: '150px',
|
||||
maxWidth: '250px',
|
||||
});
|
||||
|
||||
export const pluginItemStyle = style({
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
transition: '0.3s',
|
||||
padding: '24px 8px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { SettingHeader } from '@affine/component/setting-components';
|
||||
import { SettingWrapper } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
|
||||
import {
|
||||
type ShortcutsInfo,
|
||||
useEdgelessShortcuts,
|
||||
useGeneralShortcuts,
|
||||
useMarkdownShortcuts,
|
||||
usePageShortcuts,
|
||||
} from '../../../../../hooks/affine/use-shortcuts';
|
||||
import { shortcutKey, shortcutKeyContainer, shortcutRow } from './style.css';
|
||||
|
||||
const ShortcutsPanel = ({
|
||||
shortcutsInfo,
|
||||
}: {
|
||||
shortcutsInfo: ShortcutsInfo;
|
||||
}) => {
|
||||
return (
|
||||
<SettingWrapper title={shortcutsInfo.title}>
|
||||
{Object.entries(shortcutsInfo.shortcuts).map(([title, shortcuts]) => {
|
||||
return (
|
||||
<div key={title} className={shortcutRow}>
|
||||
<span>{title}</span>
|
||||
<div className={shortcutKeyContainer}>
|
||||
{shortcuts.map(key => {
|
||||
return (
|
||||
<span className={shortcutKey} key={key}>
|
||||
{key}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</SettingWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shortcuts = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const markdownShortcutsInfo = useMarkdownShortcuts();
|
||||
const pageShortcutsInfo = usePageShortcuts();
|
||||
const edgelessShortcutsInfo = useEdgelessShortcuts();
|
||||
const generalShortcutsInfo = useGeneralShortcuts();
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
title={t['com.affine.keyboardShortcuts.title']()}
|
||||
subtitle={t['com.affine.keyboardShortcuts.subtitle']()}
|
||||
data-testid="keyboard-shortcuts-title"
|
||||
/>
|
||||
<ShortcutsPanel shortcutsInfo={generalShortcutsInfo} />
|
||||
<ShortcutsPanel shortcutsInfo={pageShortcutsInfo} />
|
||||
<ShortcutsPanel shortcutsInfo={edgelessShortcutsInfo} />
|
||||
<ShortcutsPanel shortcutsInfo={markdownShortcutsInfo} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const shortcutRow = style({
|
||||
height: '32px',
|
||||
marginBottom: '12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
selectors: {
|
||||
'&:last-of-type': {
|
||||
marginBottom: '0',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const shortcutKeyContainer = style({
|
||||
display: 'flex',
|
||||
});
|
||||
export const shortcutKey = style({
|
||||
minWidth: '24px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 6px',
|
||||
borderRadius: '4px',
|
||||
background: 'var(--affine-background-tertiary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
selectors: {
|
||||
'&:not(:last-of-type)': {
|
||||
marginRight: '2px',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ContactWithUsIcon } from '@blocksuite/icons';
|
||||
import { Modal, type ModalProps } from '@toeverything/components/modal';
|
||||
import { Suspense, useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import { AccountSetting } from './account-setting';
|
||||
import {
|
||||
GeneralSetting,
|
||||
type GeneralSettingKeys,
|
||||
useGeneralSettingList,
|
||||
} from './general-setting';
|
||||
import { SettingSidebar } from './setting-sidebar';
|
||||
import * as style from './style.css';
|
||||
import { WorkspaceSetting } from './workspace-setting';
|
||||
|
||||
type ActiveTab = GeneralSettingKeys | 'workspace' | 'account';
|
||||
|
||||
export interface SettingProps extends ModalProps {
|
||||
activeTab: ActiveTab;
|
||||
workspaceId: string | null;
|
||||
onSettingClick: (params: {
|
||||
activeTab: ActiveTab;
|
||||
workspaceId: string | null;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
export const SettingModal = ({
|
||||
activeTab = 'appearance',
|
||||
workspaceId = null,
|
||||
onSettingClick,
|
||||
...modalProps
|
||||
}: SettingProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
const generalSettingList = useGeneralSettingList();
|
||||
|
||||
const onGeneralSettingClick = useCallback(
|
||||
(key: GeneralSettingKeys) => {
|
||||
onSettingClick({
|
||||
activeTab: key,
|
||||
workspaceId: null,
|
||||
});
|
||||
},
|
||||
[onSettingClick]
|
||||
);
|
||||
const onWorkspaceSettingClick = useCallback(
|
||||
(workspaceId: string) => {
|
||||
onSettingClick({
|
||||
activeTab: 'workspace',
|
||||
workspaceId,
|
||||
});
|
||||
},
|
||||
[onSettingClick]
|
||||
);
|
||||
const onAccountSettingClick = useCallback(() => {
|
||||
onSettingClick({ activeTab: 'account', workspaceId: null });
|
||||
}, [onSettingClick]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
width={1080}
|
||||
height={760}
|
||||
contentOptions={{
|
||||
['data-testid' as string]: 'setting-modal',
|
||||
style: {
|
||||
maxHeight: '85vh',
|
||||
maxWidth: '70vw',
|
||||
padding: 0,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
},
|
||||
}}
|
||||
{...modalProps}
|
||||
>
|
||||
<SettingSidebar
|
||||
generalSettingList={generalSettingList}
|
||||
onGeneralSettingClick={onGeneralSettingClick}
|
||||
onWorkspaceSettingClick={onWorkspaceSettingClick}
|
||||
selectedGeneralKey={activeTab}
|
||||
selectedWorkspaceId={workspaceId}
|
||||
onAccountSettingClick={onAccountSettingClick}
|
||||
/>
|
||||
|
||||
<div data-testid="setting-modal-content" className={style.wrapper}>
|
||||
<div className={style.content}>
|
||||
{activeTab === 'workspace' && workspaceId ? (
|
||||
<Suspense fallback={<WorkspaceDetailSkeleton />}>
|
||||
<WorkspaceSetting key={workspaceId} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{generalSettingList.find(v => v.key === activeTab) ? (
|
||||
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
|
||||
) : null}
|
||||
{activeTab === 'account' && loginStatus === 'authenticated' ? (
|
||||
<AccountSetting />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="footer">
|
||||
<a
|
||||
href="https://community.affine.pro/home"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={style.suggestionLink}
|
||||
>
|
||||
<span className={style.suggestionLinkIcon}>
|
||||
<ContactWithUsIcon />
|
||||
</span>
|
||||
{t['com.affine.settings.suggestion']()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
import {
|
||||
WorkspaceListItemSkeleton,
|
||||
WorkspaceListSkeleton,
|
||||
} from '@affine/component/setting-components';
|
||||
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 { Avatar } from '@toeverything/components/avatar';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
|
||||
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
|
||||
import { getBlockSuiteWorkspaceAtom } from '@toeverything/infra/__internal__/workspace';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useAtomValue } from 'jotai/react';
|
||||
import { type ReactElement, Suspense, useCallback, useMemo } from 'react';
|
||||
|
||||
import { authAtom } from '../../../../atoms';
|
||||
import { useCurrentLoginStatus } from '../../../../hooks/affine/use-current-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';
|
||||
|
||||
export type UserInfoProps = {
|
||||
onAccountSettingClick: () => void;
|
||||
};
|
||||
|
||||
export const UserInfo = ({
|
||||
onAccountSettingClick,
|
||||
}: UserInfoProps): ReactElement => {
|
||||
const user = useCurrentUser();
|
||||
return (
|
||||
<div
|
||||
data-testid="user-info-card"
|
||||
className={accountButton}
|
||||
onClick={onAccountSettingClick}
|
||||
>
|
||||
<Avatar size={28} name={user.name} url={user.image} className="avatar" />
|
||||
|
||||
<div className="content">
|
||||
<div className="name" title={user.name}>
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="email" title={user.email}>
|
||||
{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,
|
||||
onGeneralSettingClick,
|
||||
onWorkspaceSettingClick,
|
||||
selectedWorkspaceId,
|
||||
selectedGeneralKey,
|
||||
onAccountSettingClick,
|
||||
}: {
|
||||
generalSettingList: GeneralSettingList;
|
||||
onGeneralSettingClick: (key: GeneralSettingKeys) => void;
|
||||
onWorkspaceSettingClick: (workspaceId: string) => void;
|
||||
selectedWorkspaceId: string | null;
|
||||
selectedGeneralKey: string | null;
|
||||
onAccountSettingClick: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
return (
|
||||
<div className={settingSlideBar} data-testid="settings-sidebar">
|
||||
<div className={sidebarTitle}>
|
||||
{t['com.affine.settingSidebar.title']()}
|
||||
</div>
|
||||
<div className={sidebarSubtitle}>
|
||||
{t['com.affine.settingSidebar.settings.general']()}
|
||||
</div>
|
||||
<div className={sidebarItemsWrapper}>
|
||||
{generalSettingList.map(({ title, icon, key, testId }) => {
|
||||
if (!runtimeConfig.enablePlugin && key === 'plugins') {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className={clsx(sidebarSelectItem, {
|
||||
active: key === selectedGeneralKey,
|
||||
})}
|
||||
key={key}
|
||||
title={title}
|
||||
onClick={() => {
|
||||
onGeneralSettingClick(key);
|
||||
}}
|
||||
data-testid={testId}
|
||||
>
|
||||
{icon({ className: 'icon' })}
|
||||
<span className="setting-name">{title}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={sidebarSubtitle}>
|
||||
{t['com.affine.settingSidebar.settings.workspace']()}
|
||||
</div>
|
||||
<div className={clsx(sidebarItemsWrapper, 'scroll')}>
|
||||
<Suspense fallback={<WorkspaceListSkeleton />}>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export const WorkspaceList = ({
|
||||
onWorkspaceSettingClick,
|
||||
selectedWorkspaceId,
|
||||
}: {
|
||||
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 (
|
||||
<>
|
||||
{workspaceList.map(workspace => {
|
||||
return (
|
||||
<Suspense key={workspace.id} fallback={<WorkspaceListItemSkeleton />}>
|
||||
<WorkspaceListItem
|
||||
meta={workspace}
|
||||
onClick={() => {
|
||||
onWorkspaceSettingClick(workspace.id);
|
||||
}}
|
||||
isCurrent={workspace.id === currentWorkspace.id}
|
||||
isActive={workspace.id === selectedWorkspaceId}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const WorkspaceListItem = ({
|
||||
meta,
|
||||
onClick,
|
||||
isCurrent,
|
||||
isActive,
|
||||
}: {
|
||||
meta: RootWorkspaceMetadata;
|
||||
onClick: () => void;
|
||||
isCurrent: boolean;
|
||||
isActive: boolean;
|
||||
}) => {
|
||||
const [workspaceAtom] = getBlockSuiteWorkspaceAtom(meta.id);
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const [workspaceAvatar] = useBlockSuiteWorkspaceAvatarUrl(workspace);
|
||||
const [workspaceName] = useBlockSuiteWorkspaceName(workspace);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(sidebarSelectItem, { active: isActive })}
|
||||
title={workspaceName}
|
||||
onClick={onClick}
|
||||
data-testid="workspace-list-item"
|
||||
>
|
||||
<Avatar
|
||||
size={14}
|
||||
url={workspaceAvatar}
|
||||
name={workspaceName}
|
||||
colorfulFallback
|
||||
style={{
|
||||
marginRight: '10px',
|
||||
}}
|
||||
/>
|
||||
<span className="setting-name">{workspaceName}</span>
|
||||
{isCurrent ? (
|
||||
<Tooltip content="Current" side="top">
|
||||
<div
|
||||
className={currentWorkspaceLabel}
|
||||
data-testid="current-workspace-label"
|
||||
></div>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const settingSlideBar = style({
|
||||
width: '25%',
|
||||
maxWidth: '242px',
|
||||
background: 'var(--affine-background-secondary-color)',
|
||||
padding: '20px 0px',
|
||||
height: '100%',
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const sidebarTitle = style({
|
||||
fontSize: 'var(--affine-font-h-6)',
|
||||
fontWeight: '600',
|
||||
lineHeight: 'var(--affine-line-height)',
|
||||
padding: '0px 16px 0px 24px',
|
||||
});
|
||||
|
||||
export const sidebarSubtitle = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
lineHeight: 'var(--affine-line-height)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
padding: '0px 16px 0px 24px',
|
||||
marginTop: '20px',
|
||||
marginBottom: '4px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const sidebarItemsWrapper = style({
|
||||
selectors: {
|
||||
'&.scroll': {
|
||||
flexGrow: 1,
|
||||
overflowY: 'auto',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const sidebarSelectItem = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
margin: '0px 16px 4px 16px',
|
||||
padding: '0px 8px',
|
||||
height: '30px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
selectors: {
|
||||
'&.active': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
[`${sidebarItemsWrapper} &:last-of-type`]: {
|
||||
marginBottom: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${settingSlideBar} .icon`, {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
marginRight: '10px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
globalStyle(`${settingSlideBar} .setting-name`, {
|
||||
minWidth: 0,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flexGrow: 1,
|
||||
});
|
||||
export const currentWorkspaceLabel = style({
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&::after': {
|
||||
content: '""',
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
background: 'var(--affine-blue)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const sidebarFooter = style({ padding: '0 16px' });
|
||||
|
||||
export const accountButton = style({
|
||||
height: '42px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
columnGap: '10px',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${accountButton} .avatar`, {
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '50%',
|
||||
fontSize: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
globalStyle(`${accountButton} .avatar.not-sign`, {
|
||||
color: 'var(--affine-icon-secondary)',
|
||||
background: 'var(--affine-white)',
|
||||
paddingBottom: '2px',
|
||||
border: '1px solid var(--affine-icon-secondary)',
|
||||
});
|
||||
globalStyle(`${accountButton} .content`, {
|
||||
flexGrow: '1',
|
||||
minWidth: 0,
|
||||
});
|
||||
globalStyle(`${accountButton} .name`, {
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flexGrow: 1,
|
||||
});
|
||||
globalStyle(`${accountButton} .email`, {
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flexGrow: 1,
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const wrapper = style({
|
||||
flexGrow: '1',
|
||||
height: '100%',
|
||||
maxWidth: '560px',
|
||||
margin: '0 auto',
|
||||
padding: '40px 15px 20px 15px',
|
||||
overflow: 'hidden auto',
|
||||
|
||||
// children
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
|
||||
'::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
width: '100%',
|
||||
marginBottom: '24px',
|
||||
});
|
||||
|
||||
export const suggestionLink = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const suggestionLinkIcon = style({
|
||||
color: 'var(--affine-icon-color)',
|
||||
marginRight: '12px',
|
||||
display: 'flex',
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
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 { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
|
||||
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,
|
||||
useNavigateHelper,
|
||||
} from '../../../../hooks/use-navigate-helper';
|
||||
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 [workspaceName] = useBlockSuiteWorkspaceName(
|
||||
workspace.blockSuiteWorkspace
|
||||
);
|
||||
const workspaces = useAtomValue(rootWorkspacesMetadataAtom);
|
||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||
|
||||
const leaveWorkspace = useLeaveWorkspace();
|
||||
const setSettingModal = useSetAtom(openSettingModalAtom);
|
||||
const { deleteWorkspace } = useAppHelper();
|
||||
|
||||
const { NewSettingsDetail } = getUIAdapter(workspace.flavour);
|
||||
|
||||
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, workspaceName);
|
||||
|
||||
pushNotification({
|
||||
title: 'Successfully leave',
|
||||
type: 'success',
|
||||
});
|
||||
}, [
|
||||
closeAndJumpOut,
|
||||
leaveWorkspace,
|
||||
pushNotification,
|
||||
workspaceId,
|
||||
workspaceName,
|
||||
]);
|
||||
|
||||
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}
|
||||
currentWorkspaceId={workspaceId}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
import { ShareMenu } from '@affine/component/share-menu';
|
||||
import {
|
||||
type AffineOfficialWorkspace,
|
||||
WorkspaceFlavour,
|
||||
} from '@affine/env/workspace';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { useExportPage } from '../../../hooks/affine/use-export-page';
|
||||
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);
|
||||
const exportHandler = useExportPage(page);
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (workspace.flavour !== WorkspaceFlavour.LOCAL) {
|
||||
return;
|
||||
}
|
||||
onTransformWorkspace(
|
||||
WorkspaceFlavour.LOCAL,
|
||||
WorkspaceFlavour.AFFINE_CLOUD,
|
||||
workspace
|
||||
);
|
||||
setOpen(false);
|
||||
}, [onTransformWorkspace, workspace]);
|
||||
return (
|
||||
<>
|
||||
<ShareMenu
|
||||
workspace={workspace}
|
||||
currentPage={page}
|
||||
useIsSharedPage={useIsSharedPage}
|
||||
onEnableAffineCloud={() => setOpen(true)}
|
||||
togglePagePublic={async () => {}}
|
||||
exportHandler={exportHandler}
|
||||
/>
|
||||
{workspace.flavour === WorkspaceFlavour.LOCAL ? (
|
||||
<EnableAffineCloudModal
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
onConfirm={handleConfirm}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
ConfirmModal,
|
||||
type ConfirmModalProps,
|
||||
} from '@toeverything/components/modal';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type SignOutConfirmModalI18NKeys =
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'cancel'
|
||||
| 'confirm';
|
||||
|
||||
export const SignOutModal = ({ ...props }: ConfirmModalProps) => {
|
||||
const { title, description, cancelText, confirmButtonOptions = {} } = props;
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const defaultTexts = useMemo(() => {
|
||||
const getDefaultText = (key: SignOutConfirmModalI18NKeys) => {
|
||||
return t[`com.affine.auth.sign-out.confirm-modal.${key}`]();
|
||||
};
|
||||
return {
|
||||
title: getDefaultText('title'),
|
||||
description: getDefaultText('description'),
|
||||
cancelText: getDefaultText('cancel'),
|
||||
children: getDefaultText('confirm'),
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={title ?? defaultTexts.title}
|
||||
description={description ?? defaultTexts.description}
|
||||
cancelText={cancelText ?? defaultTexts.cancelText}
|
||||
confirmButtonOptions={{
|
||||
type: 'error',
|
||||
['data-testid' as string]: 'confirm-sign-out-button',
|
||||
children: confirmButtonOptions.children ?? defaultTexts.children,
|
||||
}}
|
||||
contentOptions={{
|
||||
['data-testid' as string]: 'confirm-sign-out-modal',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Modal, type ModalProps } from '@toeverything/components/modal';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
StyleButton,
|
||||
StyleButtonContainer,
|
||||
StyleImage,
|
||||
StyleTips,
|
||||
} from './style';
|
||||
|
||||
export const TmpDisableAffineCloudModal = (props: ModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const onClose = useCallback(() => {
|
||||
props.onOpenChange?.(false);
|
||||
}, [props]);
|
||||
return (
|
||||
<Modal
|
||||
title={t['com.affine.cloudTempDisable.title']()}
|
||||
contentOptions={{
|
||||
['data-testid' as string]: 'disable-affine-cloud-modal',
|
||||
}}
|
||||
width={480}
|
||||
{...props}
|
||||
>
|
||||
<StyleTips>
|
||||
<Trans i18nKey="com.affine.cloudTempDisable.description">
|
||||
We are upgrading the AFFiNE Cloud service and it is temporarily
|
||||
unavailable on the client side. If you wish to stay updated on the
|
||||
progress and be notified on availability, you can fill out the
|
||||
<a
|
||||
href="https://6dxre9ihosp.typeform.com/to/B8IHwuyy"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
style={{
|
||||
color: 'var(--affine-link-color)',
|
||||
}}
|
||||
>
|
||||
AFFiNE Cloud Signup
|
||||
</a>
|
||||
.
|
||||
</Trans>
|
||||
</StyleTips>
|
||||
<StyleImage>
|
||||
<Empty
|
||||
containerStyle={{
|
||||
width: '200px',
|
||||
height: '112px',
|
||||
}}
|
||||
/>
|
||||
</StyleImage>
|
||||
<StyleButtonContainer>
|
||||
<StyleButton type="primary" onClick={onClose}>
|
||||
{t['Got it']()}
|
||||
</StyleButton>
|
||||
</StyleButtonContainer>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import { displayFlex, styled } from '@affine/component';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
|
||||
export const Header = styled('div')({
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
flexDirection: 'row-reverse',
|
||||
paddingRight: '10px',
|
||||
paddingTop: '10px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const Content = styled('div')({
|
||||
padding: '0 40px',
|
||||
});
|
||||
|
||||
export const ContentTitle = styled('h1')(() => {
|
||||
return {
|
||||
marginTop: 44,
|
||||
fontSize: 'var(--affine-font-h6)',
|
||||
lineHeight: '28px',
|
||||
fontWeight: 600,
|
||||
};
|
||||
});
|
||||
|
||||
export const StyleTips = styled('div')(() => {
|
||||
return {
|
||||
margin: '0 0 20px 0',
|
||||
a: {
|
||||
color: 'var(--affine-primary-color)',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyleButton = styled(Button)(() => {
|
||||
return {
|
||||
textAlign: 'center',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: 'var(--affine-primary-color)',
|
||||
span: {
|
||||
margin: '0',
|
||||
},
|
||||
};
|
||||
});
|
||||
export const StyleButtonContainer = styled('div')(() => {
|
||||
return {
|
||||
width: '100%',
|
||||
marginTop: 20,
|
||||
...displayFlex('flex-end', 'center'),
|
||||
};
|
||||
});
|
||||
export const StyleImage = styled('div')(() => {
|
||||
return {
|
||||
...displayFlex('center', 'center'),
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { EditorProps } from '@affine/component/block-suite-editor';
|
||||
export { BlockSuiteEditor } from '@affine/component/block-suite-editor';
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
|
||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||
import {
|
||||
useBlockSuitePageMeta,
|
||||
usePageMetaHelper,
|
||||
} from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import {
|
||||
type FocusEvent,
|
||||
type InputHTMLAttributes,
|
||||
type KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import { EditorModeSwitch } from '../block-suite-mode-switch';
|
||||
import { PageMenu } from './operation-menu';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export interface BlockSuiteHeaderTitleProps {
|
||||
workspace: AffineOfficialWorkspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
const EditableTitle = ({
|
||||
value,
|
||||
onFocus: propsOnFocus,
|
||||
...inputProps
|
||||
}: InputHTMLAttributes<HTMLInputElement>) => {
|
||||
const onFocus = useCallback(
|
||||
(e: FocusEvent<HTMLInputElement>) => {
|
||||
e.target.select();
|
||||
propsOnFocus?.(e);
|
||||
},
|
||||
[propsOnFocus]
|
||||
);
|
||||
return (
|
||||
<div className={styles.headerTitleContainer}>
|
||||
<input
|
||||
className={styles.titleInput}
|
||||
autoFocus={true}
|
||||
value={value}
|
||||
type="text"
|
||||
data-testid="title-content"
|
||||
onFocus={onFocus}
|
||||
{...inputProps}
|
||||
/>
|
||||
<span className={styles.shadowTitle}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const StableTitle = ({
|
||||
workspace,
|
||||
pageId,
|
||||
onRename,
|
||||
}: BlockSuiteHeaderTitleProps & {
|
||||
onRename?: () => void;
|
||||
}) => {
|
||||
const currentPage = workspace.blockSuiteWorkspace.getPage(pageId);
|
||||
const pageMeta = useBlockSuitePageMeta(workspace.blockSuiteWorkspace).find(
|
||||
meta => meta.id === currentPage?.id
|
||||
);
|
||||
|
||||
const title = pageMeta?.title;
|
||||
|
||||
return (
|
||||
<div className={styles.headerTitleContainer}>
|
||||
<EditorModeSwitch
|
||||
blockSuiteWorkspace={workspace.blockSuiteWorkspace}
|
||||
pageId={pageId}
|
||||
style={{
|
||||
marginRight: '12px',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
data-testid="title-edit-button"
|
||||
className={styles.titleEditButton}
|
||||
onDoubleClick={onRename}
|
||||
>
|
||||
{title || 'Untitled'}
|
||||
</span>
|
||||
<PageMenu rename={onRename} pageId={pageId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BlockSuiteTitleWithRename = (props: BlockSuiteHeaderTitleProps) => {
|
||||
const { workspace, pageId } = props;
|
||||
const currentPage = workspace.blockSuiteWorkspace.getPage(pageId);
|
||||
const pageMeta = useBlockSuitePageMeta(workspace.blockSuiteWorkspace).find(
|
||||
meta => meta.id === currentPage?.id
|
||||
);
|
||||
const pageTitleMeta = usePageMetaHelper(workspace.blockSuiteWorkspace);
|
||||
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [title, setPageTitle] = useState(pageMeta?.title || 'Untitled');
|
||||
|
||||
const onRename = useCallback(() => {
|
||||
setIsEditable(true);
|
||||
}, []);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setIsEditable(false);
|
||||
if (!currentPage?.id) {
|
||||
return;
|
||||
}
|
||||
pageTitleMeta.setPageTitle(currentPage.id, title);
|
||||
}, [currentPage?.id, pageTitleMeta, title]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' || e.key === 'Escape') {
|
||||
onBlur();
|
||||
}
|
||||
},
|
||||
[onBlur]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPageTitle(pageMeta?.title || '');
|
||||
}, [pageMeta?.title]);
|
||||
|
||||
if (isEditable) {
|
||||
return (
|
||||
<EditableTitle
|
||||
onBlur={onBlur}
|
||||
value={title}
|
||||
onKeyDown={handleKeyDown}
|
||||
onChange={e => {
|
||||
const value = e.target.value;
|
||||
setPageTitle(value);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <StableTitle {...props} onRename={onRename} />;
|
||||
};
|
||||
|
||||
export const BlockSuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
|
||||
if (props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC) {
|
||||
return <StableTitle {...props} />;
|
||||
}
|
||||
return <BlockSuiteTitleWithRename {...props} />;
|
||||
};
|
||||
|
||||
BlockSuiteHeaderTitle.displayName = 'BlockSuiteHeaderTitle';
|
||||
@@ -0,0 +1,234 @@
|
||||
import { FlexWrapper } from '@affine/component';
|
||||
import { Export, MoveToTrash } from '@affine/component/page-list';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import {
|
||||
DuplicateIcon,
|
||||
EdgelessIcon,
|
||||
EditIcon,
|
||||
FavoritedIcon,
|
||||
FavoriteIcon,
|
||||
ImportIcon,
|
||||
PageIcon,
|
||||
} from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import {
|
||||
Menu,
|
||||
MenuIcon,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
} from '@toeverything/components/menu';
|
||||
import {
|
||||
useBlockSuitePageMeta,
|
||||
usePageMetaHelper,
|
||||
} from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useCallback, useRef } from 'react';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { setPageModeAtom } from '../../../atoms';
|
||||
import { currentModeAtom } from '../../../atoms/mode';
|
||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useExportPage } from '../../../hooks/affine/use-export-page';
|
||||
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
|
||||
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||
import { toast } from '../../../utils';
|
||||
import { HeaderDropDownButton } from '../../pure/header-drop-down-button';
|
||||
import { usePageHelper } from '../block-suite-page-list/utils';
|
||||
|
||||
type PageMenuProps = {
|
||||
rename?: () => void;
|
||||
pageId: string;
|
||||
};
|
||||
// fixme: refactor this file
|
||||
export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const ref = useRef(null);
|
||||
const { openPage } = useNavigateHelper();
|
||||
|
||||
// fixme(himself65): remove these hooks ASAP
|
||||
const [workspace] = useCurrentWorkspace();
|
||||
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
|
||||
const currentPage = blockSuiteWorkspace.getPage(pageId);
|
||||
assertExists(currentPage);
|
||||
|
||||
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
|
||||
meta => meta.id === pageId
|
||||
) as PageMeta;
|
||||
const currentMode = useAtomValue(currentModeAtom);
|
||||
const favorite = pageMeta.favorite ?? false;
|
||||
|
||||
const { setPageMeta, setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
|
||||
const { togglePageMode, toggleFavorite } =
|
||||
useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||
const { importFile } = usePageHelper(blockSuiteWorkspace);
|
||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
||||
|
||||
const handleOpenTrashModal = useCallback(() => {
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageId,
|
||||
pageTitle: pageMeta.title,
|
||||
});
|
||||
}, [pageId, pageMeta.title, setTrashModal]);
|
||||
|
||||
const handleFavorite = useCallback(() => {
|
||||
toggleFavorite(pageId);
|
||||
toast(
|
||||
favorite
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
}, [favorite, pageId, t, toggleFavorite]);
|
||||
const handleSwitchMode = useCallback(() => {
|
||||
togglePageMode(pageId);
|
||||
toast(
|
||||
currentMode === 'page'
|
||||
? t['com.affine.toastMessage.edgelessMode']()
|
||||
: t['com.affine.toastMessage.pageMode']()
|
||||
);
|
||||
}, [currentMode, pageId, t, togglePageMode]);
|
||||
const menuItemStyle = {
|
||||
padding: '4px 12px',
|
||||
transition: 'all 0.3s',
|
||||
};
|
||||
|
||||
const exportHandler = useExportPage(currentPage);
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
|
||||
const duplicate = useCallback(async () => {
|
||||
const currentPageMeta = currentPage.meta;
|
||||
const newPage = createPage();
|
||||
await newPage.waitForLoaded();
|
||||
|
||||
const update = encodeStateAsUpdate(currentPage.spaceDoc);
|
||||
applyUpdate(newPage.spaceDoc, update);
|
||||
|
||||
setPageMeta(newPage.id, {
|
||||
tags: currentPageMeta.tags,
|
||||
favorite: currentPageMeta.favorite,
|
||||
});
|
||||
setPageMode(newPage.id, currentMode);
|
||||
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
|
||||
openPage(blockSuiteWorkspace.id, newPage.id);
|
||||
}, [
|
||||
blockSuiteWorkspace.id,
|
||||
createPage,
|
||||
currentMode,
|
||||
currentPage.meta,
|
||||
currentPage.spaceDoc,
|
||||
openPage,
|
||||
setPageMeta,
|
||||
setPageMode,
|
||||
setPageTitle,
|
||||
]);
|
||||
const EditMenu = (
|
||||
<>
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<EditIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="editor-option-menu-rename"
|
||||
onSelect={rename}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
{t['Rename']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
{currentMode === 'page' ? <EdgelessIcon /> : <PageIcon />}
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="editor-option-menu-edgeless"
|
||||
onSelect={handleSwitchMode}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
{t['Convert to ']()}
|
||||
{currentMode === 'page'
|
||||
? t['com.affine.pageMode.edgeless']()
|
||||
: t['com.affine.pageMode.page']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
data-testid="editor-option-menu-favorite"
|
||||
onSelect={handleFavorite}
|
||||
style={menuItemStyle}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
{favorite ? (
|
||||
<FavoritedIcon style={{ color: 'var(--affine-primary-color)' }} />
|
||||
) : (
|
||||
<FavoriteIcon />
|
||||
)}
|
||||
</MenuIcon>
|
||||
}
|
||||
>
|
||||
{favorite
|
||||
? t['com.affine.favoritePageOperation.remove']()
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
{/* {TODO: add tag and duplicate function support} */}
|
||||
{/* <MenuItem
|
||||
icon={<TagsIcon />}
|
||||
data-testid="editor-option-menu-add-tag"
|
||||
onClick={() => {}}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
{t['com.affine.header.option.add-tag']()}
|
||||
</MenuItem> */}
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<DuplicateIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="editor-option-menu-duplicate"
|
||||
onSelect={duplicate}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
{t['com.affine.header.option.duplicate']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<ImportIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="editor-option-menu-import"
|
||||
onSelect={importFile}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
{t['Import']()}
|
||||
</MenuItem>
|
||||
<Export exportHandler={exportHandler} />
|
||||
<MenuSeparator />
|
||||
<MoveToTrash
|
||||
data-testid="editor-option-menu-delete"
|
||||
onSelect={handleOpenTrashModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
if (pageMeta.trash) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<FlexWrapper alignItems="center" justifyContent="center" ref={ref}>
|
||||
<Menu
|
||||
items={EditMenu}
|
||||
contentOptions={{
|
||||
align: 'center',
|
||||
}}
|
||||
>
|
||||
<HeaderDropDownButton />
|
||||
</Menu>
|
||||
</FlexWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { type ComplexStyleRule, style } from '@vanilla-extract/css';
|
||||
|
||||
export const headerTitleContainer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
flexGrow: 1,
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const titleEditButton = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
} as ComplexStyleRule);
|
||||
|
||||
export const titleInput = style({
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
margin: 'auto',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
||||
selectors: {
|
||||
'&:focus': {
|
||||
border: '1px solid var(--affine-black-10)',
|
||||
borderRadius: '8px',
|
||||
height: '32px',
|
||||
padding: '6px 8px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const shadowTitle = style({
|
||||
visibility: 'hidden',
|
||||
});
|
||||
@@ -0,0 +1,919 @@
|
||||
{
|
||||
"v": "5.12.1",
|
||||
"fr": 120,
|
||||
"ip": 0,
|
||||
"op": 76,
|
||||
"w": 240,
|
||||
"h": 240,
|
||||
"nm": "Edgeless",
|
||||
"ddd": 0,
|
||||
"assets": [],
|
||||
"layers": [
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 1,
|
||||
"ty": 4,
|
||||
"nm": "“图层 2”轮廓",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 11
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 10
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [97.5, 138, 0],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [10.35, 13.5, 0],
|
||||
"ix": 1,
|
||||
"l": 2
|
||||
},
|
||||
"s": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": {
|
||||
"x": [0.772, 0.772, 0.667],
|
||||
"y": [1, 1, 1.219]
|
||||
},
|
||||
"o": {
|
||||
"x": [0.462, 0.462, 0.333],
|
||||
"y": [0, 0, 0]
|
||||
},
|
||||
"t": 5,
|
||||
"s": [1100, 1100, 100]
|
||||
},
|
||||
{
|
||||
"i": {
|
||||
"x": [0.562, 0.562, 0.667],
|
||||
"y": [1, 1, 1]
|
||||
},
|
||||
"o": {
|
||||
"x": [0.455, 0.455, 0.333],
|
||||
"y": [0, 0, -0.238]
|
||||
},
|
||||
"t": 26.562,
|
||||
"s": [1070, 1070, 100]
|
||||
},
|
||||
{
|
||||
"t": 50,
|
||||
"s": [1100, 1100, 100]
|
||||
}
|
||||
],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[1.369, 1.18],
|
||||
[1.875, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, -1.885],
|
||||
[-2.094, -1.571],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[3.665, 3.299],
|
||||
[1.57, -1.728],
|
||||
[-3.665, -3.299]
|
||||
],
|
||||
"c": false
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 4
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 1.5,
|
||||
"ix": 5
|
||||
},
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [13.626, 10.441],
|
||||
"ix": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [0, 0],
|
||||
"ix": 1
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [100, 100],
|
||||
"ix": 3
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 6
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 7
|
||||
},
|
||||
"sk": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 4
|
||||
},
|
||||
"sa": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 5
|
||||
},
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "组 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 240,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 2,
|
||||
"ty": 4,
|
||||
"nm": "“图层 3”轮廓",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 11
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 10
|
||||
},
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [69, 119, 0],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [6.9, 11.9, 0],
|
||||
"ix": 1,
|
||||
"l": 2
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [1000, 1000, 100],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[6.818, 9.76],
|
||||
[6.818, 13.949]
|
||||
],
|
||||
"c": false
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 4
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 1.5,
|
||||
"ix": 5
|
||||
},
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [0, 0],
|
||||
"ix": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [0, 0],
|
||||
"ix": 1
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [100, 100],
|
||||
"ix": 3
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 6
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 7
|
||||
},
|
||||
"sk": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 4
|
||||
},
|
||||
"sa": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 5
|
||||
},
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "组 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 240,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 3,
|
||||
"ty": 4,
|
||||
"nm": "“图层 4”轮廓",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 11
|
||||
},
|
||||
"r": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": {
|
||||
"x": [0.363],
|
||||
"y": [1]
|
||||
},
|
||||
"o": {
|
||||
"x": [0.675],
|
||||
"y": [-0.111]
|
||||
},
|
||||
"t": 5,
|
||||
"s": [0]
|
||||
},
|
||||
{
|
||||
"t": 50,
|
||||
"s": [90]
|
||||
}
|
||||
],
|
||||
"ix": 10
|
||||
},
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": {
|
||||
"x": 0.363,
|
||||
"y": 1
|
||||
},
|
||||
"o": {
|
||||
"x": 0.675,
|
||||
"y": 0
|
||||
},
|
||||
"t": 5,
|
||||
"s": [173.5, 171, 0],
|
||||
"to": [0, -1.333, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": {
|
||||
"x": 0.667,
|
||||
"y": 1
|
||||
},
|
||||
"o": {
|
||||
"x": 0.647,
|
||||
"y": 0
|
||||
},
|
||||
"t": 26.562,
|
||||
"s": [173.5, 163, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [0, -1.333, 0]
|
||||
},
|
||||
{
|
||||
"t": 50,
|
||||
"s": [173.5, 171, 0]
|
||||
}
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [17.35, 16.9, 0],
|
||||
"ix": 1,
|
||||
"l": 2
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [1000, 1000, 100],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"o": [
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0],
|
||||
[0, 0]
|
||||
],
|
||||
"v": [
|
||||
[-2.357, -2.357],
|
||||
[2.357, -2.357],
|
||||
[2.357, 2.357],
|
||||
[-2.357, 2.357]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 4
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 1.5,
|
||||
"ix": 5
|
||||
},
|
||||
"lc": 1,
|
||||
"lj": 2,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [17.344, 16.829],
|
||||
"ix": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [0, 0],
|
||||
"ix": 1
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [100, 100],
|
||||
"ix": 3
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 6
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 7
|
||||
},
|
||||
"sk": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 4
|
||||
},
|
||||
"sa": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 5
|
||||
},
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "组 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 240,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 4,
|
||||
"ty": 4,
|
||||
"nm": "“图层 5”轮廓",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 11
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 10
|
||||
},
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": {
|
||||
"x": 0.363,
|
||||
"y": 1
|
||||
},
|
||||
"o": {
|
||||
"x": 0.675,
|
||||
"y": 0
|
||||
},
|
||||
"t": 5,
|
||||
"s": [68.5, 170, 0],
|
||||
"to": [0, -1.333, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": {
|
||||
"x": 0.667,
|
||||
"y": 1
|
||||
},
|
||||
"o": {
|
||||
"x": 0.647,
|
||||
"y": 0
|
||||
},
|
||||
"t": 26.562,
|
||||
"s": [68.5, 162, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [0, -1.333, 0]
|
||||
},
|
||||
{
|
||||
"t": 50,
|
||||
"s": [68.5, 170, 0]
|
||||
}
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [6.85, 17.05, 0],
|
||||
"ix": 1,
|
||||
"l": 2
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [1000, 1000, 100],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[-1.446, 0],
|
||||
[0, -1.446],
|
||||
[1.446, 0],
|
||||
[0, 1.446]
|
||||
],
|
||||
"o": [
|
||||
[1.446, 0],
|
||||
[0, 1.446],
|
||||
[-1.446, 0],
|
||||
[0, -1.446]
|
||||
],
|
||||
"v": [
|
||||
[0, -2.618],
|
||||
[2.618, 0],
|
||||
[0, 2.618],
|
||||
[-2.618, 0]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 4
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 1.5,
|
||||
"ix": 5
|
||||
},
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [6.818, 17.091],
|
||||
"ix": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [0, 0],
|
||||
"ix": 1
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [100, 100],
|
||||
"ix": 3
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 6
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 7
|
||||
},
|
||||
"sk": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 4
|
||||
},
|
||||
"sa": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 5
|
||||
},
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "组 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 240,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
},
|
||||
{
|
||||
"ddd": 0,
|
||||
"ind": 5,
|
||||
"ty": 4,
|
||||
"nm": "“图层 6”轮廓",
|
||||
"sr": 1,
|
||||
"ks": {
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 11
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 10
|
||||
},
|
||||
"p": {
|
||||
"a": 1,
|
||||
"k": [
|
||||
{
|
||||
"i": {
|
||||
"x": 0.363,
|
||||
"y": 1
|
||||
},
|
||||
"o": {
|
||||
"x": 0.675,
|
||||
"y": 0
|
||||
},
|
||||
"t": 5,
|
||||
"s": [68, 65, 0],
|
||||
"to": [0, 1.333, 0],
|
||||
"ti": [0, 0, 0]
|
||||
},
|
||||
{
|
||||
"i": {
|
||||
"x": 0.667,
|
||||
"y": 1
|
||||
},
|
||||
"o": {
|
||||
"x": 0.647,
|
||||
"y": 0
|
||||
},
|
||||
"t": 26.562,
|
||||
"s": [68, 73, 0],
|
||||
"to": [0, 0, 0],
|
||||
"ti": [0, 1.333, 0]
|
||||
},
|
||||
{
|
||||
"t": 50,
|
||||
"s": [68, 65, 0]
|
||||
}
|
||||
],
|
||||
"ix": 2,
|
||||
"l": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [6.8, 6.6, 0],
|
||||
"ix": 1,
|
||||
"l": 2
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [1000, 1000, 100],
|
||||
"ix": 6,
|
||||
"l": 2
|
||||
}
|
||||
},
|
||||
"ao": 0,
|
||||
"shapes": [
|
||||
{
|
||||
"ty": "gr",
|
||||
"it": [
|
||||
{
|
||||
"ind": 0,
|
||||
"ty": "sh",
|
||||
"ix": 1,
|
||||
"ks": {
|
||||
"a": 0,
|
||||
"k": {
|
||||
"i": [
|
||||
[-1.446, 0],
|
||||
[0, -1.446],
|
||||
[1.446, 0],
|
||||
[0, 1.446]
|
||||
],
|
||||
"o": [
|
||||
[1.446, 0],
|
||||
[0, 1.446],
|
||||
[-1.446, 0],
|
||||
[0, -1.446]
|
||||
],
|
||||
"v": [
|
||||
[0, -2.618],
|
||||
[2.618, 0],
|
||||
[0, 2.618],
|
||||
[-2.618, 0]
|
||||
],
|
||||
"c": true
|
||||
},
|
||||
"ix": 2
|
||||
},
|
||||
"nm": "路径 1",
|
||||
"mn": "ADBE Vector Shape - Group",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "st",
|
||||
"c": {
|
||||
"a": 0,
|
||||
"k": [0.466666696586, 0.458823559331, 0.490196108351, 1],
|
||||
"ix": 3
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 4
|
||||
},
|
||||
"w": {
|
||||
"a": 0,
|
||||
"k": 1.5,
|
||||
"ix": 5
|
||||
},
|
||||
"lc": 1,
|
||||
"lj": 1,
|
||||
"ml": 4,
|
||||
"bm": 0,
|
||||
"nm": "描边 1",
|
||||
"mn": "ADBE Vector Graphic - Stroke",
|
||||
"hd": false
|
||||
},
|
||||
{
|
||||
"ty": "tr",
|
||||
"p": {
|
||||
"a": 0,
|
||||
"k": [6.818, 6.618],
|
||||
"ix": 2
|
||||
},
|
||||
"a": {
|
||||
"a": 0,
|
||||
"k": [0, 0],
|
||||
"ix": 1
|
||||
},
|
||||
"s": {
|
||||
"a": 0,
|
||||
"k": [100, 100],
|
||||
"ix": 3
|
||||
},
|
||||
"r": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 6
|
||||
},
|
||||
"o": {
|
||||
"a": 0,
|
||||
"k": 100,
|
||||
"ix": 7
|
||||
},
|
||||
"sk": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 4
|
||||
},
|
||||
"sa": {
|
||||
"a": 0,
|
||||
"k": 0,
|
||||
"ix": 5
|
||||
},
|
||||
"nm": "变换"
|
||||
}
|
||||
],
|
||||
"nm": "组 1",
|
||||
"np": 2,
|
||||
"cix": 2,
|
||||
"bm": 0,
|
||||
"ix": 1,
|
||||
"mn": "ADBE Vector Group",
|
||||
"hd": false
|
||||
}
|
||||
],
|
||||
"ip": 0,
|
||||
"op": 240,
|
||||
"st": 0,
|
||||
"ct": 1,
|
||||
"bm": 0
|
||||
}
|
||||
],
|
||||
"markers": []
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import type { CSSProperties } from 'react';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { currentModeAtom } from '../../../atoms/mode';
|
||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { toast } from '../../../utils';
|
||||
import { StyledEditorModeSwitch, StyledKeyboardItem } from './style';
|
||||
import { EdgelessSwitchItem, PageSwitchItem } from './switch-items';
|
||||
|
||||
export type EditorModeSwitchProps = {
|
||||
// todo(himself65): combine these two properties
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
pageId: string;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
const TooltipContent = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<>
|
||||
{t['Switch']()}
|
||||
<StyledKeyboardItem>
|
||||
{!environment.isServer && environment.isMacOs ? '⌥ + S' : 'Alt + S'}
|
||||
</StyledKeyboardItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
export const EditorModeSwitch = ({
|
||||
style,
|
||||
blockSuiteWorkspace,
|
||||
pageId,
|
||||
}: EditorModeSwitchProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find(
|
||||
meta => meta.id === pageId
|
||||
);
|
||||
assertExists(pageMeta);
|
||||
const { trash } = pageMeta;
|
||||
|
||||
const { togglePageMode, switchToEdgelessMode, switchToPageMode } =
|
||||
useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||
const currentMode = useAtomValue(currentModeAtom);
|
||||
|
||||
useEffect(() => {
|
||||
if (trash) {
|
||||
return;
|
||||
}
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
if (
|
||||
!environment.isServer && environment.isMacOs
|
||||
? e.key === 'ß'
|
||||
: e.key === 's' && e.altKey
|
||||
) {
|
||||
e.preventDefault();
|
||||
togglePageMode(pageId);
|
||||
toast(
|
||||
currentMode === 'page'
|
||||
? t['com.affine.toastMessage.edgelessMode']()
|
||||
: t['com.affine.toastMessage.pageMode']()
|
||||
);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', keydown, { capture: true });
|
||||
return () =>
|
||||
document.removeEventListener('keydown', keydown, { capture: true });
|
||||
}, [currentMode, pageId, t, togglePageMode, trash]);
|
||||
|
||||
const onSwitchToPageMode = useCallback(() => {
|
||||
if (currentMode === 'page') {
|
||||
return;
|
||||
}
|
||||
switchToPageMode(pageId);
|
||||
toast(t['com.affine.toastMessage.pageMode']());
|
||||
}, [currentMode, pageId, switchToPageMode, t]);
|
||||
const onSwitchToEdgelessMode = useCallback(() => {
|
||||
if (currentMode === 'edgeless') {
|
||||
return;
|
||||
}
|
||||
switchToEdgelessMode(pageId);
|
||||
toast(t['com.affine.toastMessage.edgelessMode']());
|
||||
}, [currentMode, pageId, switchToEdgelessMode, t]);
|
||||
|
||||
return (
|
||||
<Tooltip content={<TooltipContent />}>
|
||||
<StyledEditorModeSwitch
|
||||
style={style}
|
||||
switchLeft={currentMode === 'page'}
|
||||
showAlone={trash}
|
||||
>
|
||||
<PageSwitchItem
|
||||
data-testid="switch-page-mode-button"
|
||||
active={currentMode === 'page'}
|
||||
hide={trash && currentMode !== 'page'}
|
||||
trash={trash}
|
||||
onClick={onSwitchToPageMode}
|
||||
/>
|
||||
<EdgelessSwitchItem
|
||||
data-testid="switch-edgeless-mode-button"
|
||||
active={currentMode === 'edgeless'}
|
||||
hide={trash && currentMode !== 'edgeless'}
|
||||
trash={trash}
|
||||
onClick={onSwitchToEdgelessMode}
|
||||
/>
|
||||
</StyledEditorModeSwitch>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import { displayFlex, styled } from '@affine/component';
|
||||
|
||||
export const StyledEditorModeSwitch = styled('div')<{
|
||||
switchLeft: boolean;
|
||||
showAlone?: boolean;
|
||||
}>(({ switchLeft, showAlone }) => {
|
||||
return {
|
||||
maxWidth: showAlone ? '40px' : '70px',
|
||||
gap: '8px',
|
||||
height: '32px',
|
||||
background: showAlone
|
||||
? 'transparent'
|
||||
: 'var(--affine-background-secondary-color)',
|
||||
borderRadius: '12px',
|
||||
...displayFlex('space-between', 'center'),
|
||||
padding: '4px 4px',
|
||||
position: 'relative',
|
||||
|
||||
'::after': {
|
||||
content: '""',
|
||||
display: showAlone ? 'none' : 'block',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
boxShadow: 'var(--affine-shadow-1)',
|
||||
borderRadius: '8px',
|
||||
zIndex: 1,
|
||||
position: 'absolute',
|
||||
transform: `translateX(${switchLeft ? '0' : '32px'})`,
|
||||
transition: 'all .15s',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledSwitchItem = styled('button')<{
|
||||
active?: boolean;
|
||||
hide?: boolean;
|
||||
trash?: boolean;
|
||||
}>(({ active = false, hide = false, trash = false }) => {
|
||||
return {
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
borderRadius: '8px',
|
||||
WebkitAppRegion: 'no-drag',
|
||||
boxShadow: active ? 'var(--affine-shadow-1)' : 'none',
|
||||
color: active
|
||||
? trash
|
||||
? 'var(--affine-error-color)'
|
||||
: 'var(--affine-primary-color)'
|
||||
: 'var(--affine-icon-color)',
|
||||
display: hide ? 'none' : 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
zIndex: 2,
|
||||
fontSize: '20px',
|
||||
path: {
|
||||
stroke: 'currentColor',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledKeyboardItem = styled('span')(() => {
|
||||
return {
|
||||
marginLeft: '10px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
paddingLeft: '5px',
|
||||
paddingRight: '5px',
|
||||
backgroundColor: 'var(--affine-white-10)',
|
||||
borderRadius: '4px',
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { InternalLottie } from '@affine/component/internal-lottie';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import type React from 'react';
|
||||
import { cloneElement, useState } from 'react';
|
||||
|
||||
import edgelessHover from './animation-data/edgeless-hover.json';
|
||||
import pageHover from './animation-data/page-hover.json';
|
||||
import { StyledSwitchItem } from './style';
|
||||
|
||||
type HoverAnimateControllerProps = {
|
||||
active?: boolean;
|
||||
hide?: boolean;
|
||||
trash?: boolean;
|
||||
children: React.ReactElement;
|
||||
} & HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
const HoverAnimateController = ({
|
||||
active,
|
||||
hide,
|
||||
trash,
|
||||
children,
|
||||
...props
|
||||
}: HoverAnimateControllerProps) => {
|
||||
const [startAnimate, setStartAnimate] = useState(false);
|
||||
return (
|
||||
<StyledSwitchItem
|
||||
hide={hide}
|
||||
active={active}
|
||||
trash={trash}
|
||||
onMouseEnter={() => {
|
||||
setStartAnimate(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
setStartAnimate(false);
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{cloneElement(children, {
|
||||
isStopped: !startAnimate,
|
||||
speed: 1,
|
||||
width: 20,
|
||||
height: 20,
|
||||
})}
|
||||
</StyledSwitchItem>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageSwitchItem = (
|
||||
props: Omit<HoverAnimateControllerProps, 'children'>
|
||||
) => {
|
||||
return (
|
||||
<HoverAnimateController {...props}>
|
||||
<InternalLottie
|
||||
options={{
|
||||
loop: false,
|
||||
autoplay: false,
|
||||
animationData: pageHover,
|
||||
rendererSettings: {
|
||||
preserveAspectRatio: 'xMidYMid slice',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</HoverAnimateController>
|
||||
);
|
||||
};
|
||||
|
||||
export const EdgelessSwitchItem = (
|
||||
props: Omit<HoverAnimateControllerProps, 'children'>
|
||||
) => {
|
||||
return (
|
||||
<HoverAnimateController {...props}>
|
||||
<InternalLottie
|
||||
options={{
|
||||
loop: false,
|
||||
autoplay: false,
|
||||
animationData: edgelessHover,
|
||||
rendererSettings: {
|
||||
preserveAspectRatio: 'xMidYMid slice',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</HoverAnimateController>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const pageListEmptyStyle = style({
|
||||
height: 'calc(100% - 52px)',
|
||||
});
|
||||
|
||||
export const emptyDescButton = style({
|
||||
cursor: 'pointer',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
background: 'var(--affine-background-code-block)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
borderRadius: '4px',
|
||||
padding: '0 6px',
|
||||
boxSizing: 'border-box',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const emptyDescKbd = style([
|
||||
emptyDescButton,
|
||||
{
|
||||
cursor: 'text',
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,297 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import type { ListData, TrashListData } from '@affine/component/page-list';
|
||||
import { PageList, PageListTrashView } from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons';
|
||||
import { type PageMeta, type Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { Suspense, useCallback, useMemo } from 'react';
|
||||
|
||||
import { allPageModeSelectAtom } from '../../../atoms';
|
||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
|
||||
import { useGetPageInfoById } from '../../../hooks/use-get-page-info';
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
import { toast } from '../../../utils';
|
||||
import { filterPage } from '../../../utils/filter';
|
||||
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
||||
import { emptyDescButton, emptyDescKbd, pageListEmptyStyle } from './index.css';
|
||||
import { usePageHelper } from './utils';
|
||||
|
||||
export interface BlockSuitePageListProps {
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
listType: 'all' | 'trash' | 'shared' | 'public';
|
||||
isPublic?: boolean;
|
||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
||||
collection?: Collection;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
all: (pageMeta: PageMeta) => !pageMeta.trash,
|
||||
public: (pageMeta: PageMeta) => !pageMeta.trash,
|
||||
trash: (pageMeta: PageMeta, allMetas: PageMeta[]) => {
|
||||
const parentMeta = allMetas.find(m => m.subpageIds?.includes(pageMeta.id));
|
||||
return !parentMeta?.trash && pageMeta.trash;
|
||||
},
|
||||
shared: (pageMeta: PageMeta) => pageMeta.isPublic && !pageMeta.trash,
|
||||
};
|
||||
|
||||
interface PagePreviewInnerProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
const PagePreviewInner = ({ workspace, pageId }: PagePreviewInnerProps) => {
|
||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||
assertExists(page);
|
||||
const previewAtom = useBlockSuitePagePreview(page);
|
||||
const preview = useAtomValue(previewAtom);
|
||||
return preview;
|
||||
};
|
||||
|
||||
interface PagePreviewProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
const PagePreview = ({ workspace, pageId }: PagePreviewProps) => {
|
||||
return (
|
||||
<Suspense>
|
||||
<PagePreviewInner workspace={workspace} pageId={pageId} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageListEmptyProps {
|
||||
createPage?: ReturnType<typeof usePageHelper>['createPage'];
|
||||
listType: BlockSuitePageListProps['listType'];
|
||||
}
|
||||
|
||||
const PageListEmpty = (props: PageListEmptyProps) => {
|
||||
const { listType, createPage } = props;
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const onCreatePage = useCallback(() => {
|
||||
createPage?.();
|
||||
}, [createPage]);
|
||||
|
||||
const getEmptyDescription = () => {
|
||||
if (listType === 'all') {
|
||||
const createNewPageButton = (
|
||||
<button className={emptyDescButton} onClick={onCreatePage}>
|
||||
New Page
|
||||
</button>
|
||||
);
|
||||
if (environment.isDesktop) {
|
||||
const shortcut = environment.isMacOs ? '⌘ + N' : 'Ctrl + N';
|
||||
return (
|
||||
<Trans i18nKey="emptyAllPagesClient">
|
||||
Click on the {createNewPageButton} button Or press
|
||||
<kbd className={emptyDescKbd}>{{ shortcut } as any}</kbd> to create
|
||||
your first page.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Trans i18nKey="emptyAllPages">
|
||||
Click on the
|
||||
{createNewPageButton}
|
||||
button to create your first page.
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
if (listType === 'trash') {
|
||||
return t['emptyTrash']();
|
||||
}
|
||||
if (listType === 'shared') {
|
||||
return t['emptySharedPages']();
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={pageListEmptyStyle}>
|
||||
<Empty
|
||||
title={t['com.affine.emptyDesc']()}
|
||||
description={getEmptyDescription()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const BlockSuitePageList = ({
|
||||
blockSuiteWorkspace,
|
||||
onOpenPage,
|
||||
listType,
|
||||
isPublic = false,
|
||||
collection,
|
||||
}: BlockSuitePageListProps) => {
|
||||
const pageMetas = useBlockSuitePageMeta(blockSuiteWorkspace);
|
||||
const {
|
||||
toggleFavorite,
|
||||
restoreFromTrash,
|
||||
permanentlyDeletePage,
|
||||
cancelPublicPage,
|
||||
} = useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||
const [filterMode] = useAtom(allPageModeSelectAtom);
|
||||
const { createPage, createEdgeless, importFile, isPreferredEdgeless } =
|
||||
usePageHelper(blockSuiteWorkspace);
|
||||
const t = useAFFiNEI18N();
|
||||
const getPageInfo = useGetPageInfoById(blockSuiteWorkspace);
|
||||
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
||||
|
||||
const tagOptionMap = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
(blockSuiteWorkspace.meta.properties.tags?.options ?? []).map(v => [
|
||||
v.id,
|
||||
v,
|
||||
])
|
||||
),
|
||||
[blockSuiteWorkspace.meta.properties.tags?.options]
|
||||
);
|
||||
const list = useMemo(
|
||||
() =>
|
||||
pageMetas
|
||||
.filter(pageMeta => {
|
||||
if (filterMode === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (filterMode === 'edgeless') {
|
||||
return isPreferredEdgeless(pageMeta.id);
|
||||
}
|
||||
if (filterMode === 'page') {
|
||||
return !isPreferredEdgeless(pageMeta.id);
|
||||
}
|
||||
console.error('unknown filter mode', pageMeta, filterMode);
|
||||
return true;
|
||||
})
|
||||
.filter(pageMeta => {
|
||||
if (!filter[listType](pageMeta, pageMetas)) {
|
||||
return false;
|
||||
}
|
||||
if (!collection) {
|
||||
return true;
|
||||
}
|
||||
return filterPage(collection, pageMeta);
|
||||
}),
|
||||
[pageMetas, filterMode, isPreferredEdgeless, listType, collection]
|
||||
);
|
||||
|
||||
if (listType === 'trash') {
|
||||
const pageList: TrashListData[] = list.map(pageMeta => {
|
||||
return {
|
||||
icon: isPreferredEdgeless(pageMeta.id) ? (
|
||||
<EdgelessIcon />
|
||||
) : (
|
||||
<PageIcon />
|
||||
),
|
||||
pageId: pageMeta.id,
|
||||
title: pageMeta.title,
|
||||
preview: (
|
||||
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||
),
|
||||
createDate: new Date(pageMeta.createDate),
|
||||
trashDate: pageMeta.trashDate
|
||||
? new Date(pageMeta.trashDate)
|
||||
: undefined,
|
||||
onClickPage: () => onOpenPage(pageMeta.id),
|
||||
onClickRestore: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
},
|
||||
onRestorePage: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
toast(
|
||||
t['com.affine.toastMessage.restored']({
|
||||
title: pageMeta.title || 'Untitled',
|
||||
})
|
||||
);
|
||||
},
|
||||
onPermanentlyDeletePage: () => {
|
||||
permanentlyDeletePage(pageMeta.id);
|
||||
toast(t['com.affine.toastMessage.permanentlyDeleted']());
|
||||
},
|
||||
};
|
||||
});
|
||||
return (
|
||||
<PageListTrashView
|
||||
list={pageList}
|
||||
fallback={<PageListEmpty listType={listType} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const pageList: ListData[] = list.map(pageMeta => {
|
||||
const page = blockSuiteWorkspace.getPage(pageMeta.id);
|
||||
return {
|
||||
icon: isPreferredEdgeless(pageMeta.id) ? <EdgelessIcon /> : <PageIcon />,
|
||||
pageId: pageMeta.id,
|
||||
title: pageMeta.title,
|
||||
preview: (
|
||||
<PagePreview workspace={blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||
),
|
||||
tags:
|
||||
page?.meta.tags?.map(id => tagOptionMap[id]).filter(v => v != null) ??
|
||||
[],
|
||||
favorite: !!pageMeta.favorite,
|
||||
isPublicPage: !!pageMeta.isPublic,
|
||||
createDate: new Date(pageMeta.createDate),
|
||||
updatedDate: new Date(pageMeta.updatedDate ?? pageMeta.createDate),
|
||||
onClickPage: () => onOpenPage(pageMeta.id),
|
||||
onOpenPageInNewTab: () => onOpenPage(pageMeta.id, true),
|
||||
onClickRestore: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
},
|
||||
removeToTrash: () =>
|
||||
setTrashModal({
|
||||
open: true,
|
||||
pageId: pageMeta.id,
|
||||
pageTitle: pageMeta.title,
|
||||
}),
|
||||
|
||||
onRestorePage: () => {
|
||||
restoreFromTrash(pageMeta.id);
|
||||
toast(
|
||||
t['com.affine.toastMessage.restored']({
|
||||
title: pageMeta.title || 'Untitled',
|
||||
})
|
||||
);
|
||||
},
|
||||
bookmarkPage: () => {
|
||||
const status = pageMeta.favorite;
|
||||
toggleFavorite(pageMeta.id);
|
||||
toast(
|
||||
status
|
||||
? t['com.affine.toastMessage.removedFavorites']()
|
||||
: t['com.affine.toastMessage.addedFavorites']()
|
||||
);
|
||||
},
|
||||
onDisablePublicSharing: () => {
|
||||
cancelPublicPage(pageMeta.id);
|
||||
toast('Successfully disabled', {
|
||||
portal: document.body,
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<PageList
|
||||
collectionsAtom={currentCollectionsAtom}
|
||||
propertiesMeta={blockSuiteWorkspace.meta.properties}
|
||||
getPageInfo={getPageInfo}
|
||||
onCreateNewPage={createPage}
|
||||
onCreateNewEdgeless={createEdgeless}
|
||||
onImportFile={importFile}
|
||||
isPublicWorkspace={isPublic}
|
||||
list={pageList}
|
||||
fallback={<PageListEmpty createPage={createPage} listType={listType} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
import { toast } from '@affine/component';
|
||||
import { WorkspaceSubPath } from '@affine/env/workspace';
|
||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||
import { initEmptyPage } from '@toeverything/infra/blocksuite';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { pageSettingsAtom, setPageModeAtom } from '../../../atoms';
|
||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||
import type { BlockSuiteWorkspace } from '../../../shared';
|
||||
|
||||
export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
||||
const { openPage, jumpToSubPath } = useNavigateHelper();
|
||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||
const pageSettings = useAtomValue(pageSettingsAtom);
|
||||
const isPreferredEdgeless = useCallback(
|
||||
(pageId: string) => pageSettings[pageId]?.mode === 'edgeless',
|
||||
[pageSettings]
|
||||
);
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
const createPageAndOpen = useCallback(
|
||||
(id?: string, mode?: 'page' | 'edgeless') => {
|
||||
const page = createPage(id);
|
||||
initEmptyPage(page).catch(error => {
|
||||
toast(`Failed to initialize Page: ${error.message}`);
|
||||
});
|
||||
setPageMode(page.id, mode || 'page');
|
||||
openPage(blockSuiteWorkspace.id, page.id);
|
||||
return page;
|
||||
},
|
||||
[blockSuiteWorkspace.id, createPage, openPage, setPageMode]
|
||||
);
|
||||
const createEdgelessAndOpen = useCallback(
|
||||
(id?: string) => {
|
||||
return createPageAndOpen(id, 'edgeless');
|
||||
},
|
||||
[createPageAndOpen]
|
||||
);
|
||||
const importFileAndOpen = useCallback(async () => {
|
||||
const { showImportModal } = await import('@blocksuite/blocks');
|
||||
const onSuccess = (pageIds: string[], isWorkspaceFile: boolean) => {
|
||||
toast(
|
||||
`Successfully imported ${pageIds.length} Page${
|
||||
pageIds.length > 1 ? 's' : ''
|
||||
}.`
|
||||
);
|
||||
if (isWorkspaceFile) {
|
||||
jumpToSubPath(blockSuiteWorkspace.id, WorkspaceSubPath.ALL);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pageIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
const pageId = pageIds[0];
|
||||
openPage(blockSuiteWorkspace.id, pageId);
|
||||
};
|
||||
showImportModal({ workspace: blockSuiteWorkspace, onSuccess });
|
||||
}, [blockSuiteWorkspace, openPage, jumpToSubPath]);
|
||||
return useMemo(() => {
|
||||
return {
|
||||
createPage: createPageAndOpen,
|
||||
createEdgeless: createEdgelessAndOpen,
|
||||
importFile: importFileAndOpen,
|
||||
isPreferredEdgeless: isPreferredEdgeless,
|
||||
};
|
||||
}, [
|
||||
createEdgelessAndOpen,
|
||||
createPageAndOpen,
|
||||
importFileAndOpen,
|
||||
isPreferredEdgeless,
|
||||
]);
|
||||
};
|
||||
244
packages/frontend/core/src/components/bookmark.tsx
Normal file
244
packages/frontend/core/src/components/bookmark.tsx
Normal file
@@ -0,0 +1,244 @@
|
||||
import { MenuItem, PureMenu } from '@affine/component';
|
||||
import { MuiClickAwayListener } from '@affine/component';
|
||||
import type { SerializedBlock } from '@blocksuite/blocks';
|
||||
import type { BaseBlockModel } from '@blocksuite/store';
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import type { VEditor } from '@blocksuite/virgo';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type ShortcutMap = {
|
||||
[key: string]: (e: KeyboardEvent, page: Page) => void;
|
||||
};
|
||||
|
||||
const menuOptions = [
|
||||
{
|
||||
id: 'dismiss',
|
||||
label: 'Dismiss',
|
||||
},
|
||||
{
|
||||
id: 'bookmark',
|
||||
label: 'Create bookmark',
|
||||
},
|
||||
];
|
||||
|
||||
function getCurrentNativeRange(selection = window.getSelection()) {
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
if (selection.rangeCount === 0) {
|
||||
return null;
|
||||
}
|
||||
if (selection.rangeCount > 1) {
|
||||
console.warn('getCurrentRange may be wrong, rangeCount > 1');
|
||||
}
|
||||
return selection.getRangeAt(0);
|
||||
}
|
||||
|
||||
const handleEnter = ({
|
||||
page,
|
||||
selectedOption,
|
||||
callback,
|
||||
}: {
|
||||
page: Page;
|
||||
selectedOption: keyof ShortcutMap;
|
||||
callback: () => void;
|
||||
}) => {
|
||||
if (selectedOption === 'dismiss') {
|
||||
return callback();
|
||||
}
|
||||
const native = getCurrentNativeRange();
|
||||
if (!native) {
|
||||
return callback();
|
||||
}
|
||||
const container = native.startContainer;
|
||||
const element =
|
||||
container instanceof Element ? container : container?.parentElement;
|
||||
const virgo = element?.closest<Element & { virgoEditor: VEditor }>(
|
||||
'[data-virgo-root]'
|
||||
)?.virgoEditor;
|
||||
if (!virgo) {
|
||||
return callback();
|
||||
}
|
||||
const linkInfo = virgo
|
||||
?.getDeltasByVRange({
|
||||
index: native.startOffset,
|
||||
length: 0,
|
||||
})
|
||||
.find(delta => delta[0]?.attributes?.link);
|
||||
if (!linkInfo) {
|
||||
return;
|
||||
}
|
||||
const [, { index, length }] = linkInfo;
|
||||
const link = linkInfo[0]?.attributes?.link as string;
|
||||
|
||||
const model = element?.closest<Element & { model: BaseBlockModel }>(
|
||||
'[data-block-id]'
|
||||
)?.model;
|
||||
if (!model) {
|
||||
return callback();
|
||||
}
|
||||
const parent = page.getParent(model);
|
||||
if (!parent) {
|
||||
return callback();
|
||||
}
|
||||
const currentBlockIndex = parent.children.indexOf(model);
|
||||
page.addBlock(
|
||||
'affine:bookmark',
|
||||
{ url: link },
|
||||
parent,
|
||||
currentBlockIndex + 1
|
||||
);
|
||||
|
||||
virgo?.deleteText({
|
||||
index,
|
||||
length,
|
||||
});
|
||||
|
||||
if (model.isEmpty()) {
|
||||
page.deleteBlock(model);
|
||||
}
|
||||
return callback();
|
||||
};
|
||||
|
||||
const shouldShowBookmarkMenu = (pastedBlocks: Record<string, unknown>[]) => {
|
||||
if (!pastedBlocks.length || pastedBlocks.length > 1) {
|
||||
return;
|
||||
}
|
||||
const [firstBlock] = pastedBlocks as [SerializedBlock];
|
||||
if (
|
||||
!firstBlock.text ||
|
||||
!firstBlock.text.length ||
|
||||
firstBlock.text.length > 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
return !!firstBlock.text[0].attributes?.link;
|
||||
};
|
||||
|
||||
export type BookmarkProps = {
|
||||
page: Page;
|
||||
};
|
||||
|
||||
export const Bookmark = ({ page }: BookmarkProps) => {
|
||||
const [anchor, setAnchor] = useState<Range | null>(null);
|
||||
const [selectedOption, setSelectedOption] = useState<string>(
|
||||
menuOptions[0].id
|
||||
);
|
||||
const shortcutMap = useMemo<ShortcutMap>(
|
||||
() => ({
|
||||
ArrowUp: () => {
|
||||
const curIndex = menuOptions.findIndex(
|
||||
({ id }) => id === selectedOption
|
||||
);
|
||||
if (menuOptions[curIndex - 1]) {
|
||||
setSelectedOption(menuOptions[curIndex - 1].id);
|
||||
} else if (curIndex === -1) {
|
||||
setSelectedOption(menuOptions[0].id);
|
||||
} else {
|
||||
setSelectedOption(menuOptions[menuOptions.length - 1].id);
|
||||
}
|
||||
},
|
||||
ArrowDown: () => {
|
||||
const curIndex = menuOptions.findIndex(
|
||||
({ id }) => id === selectedOption
|
||||
);
|
||||
if (curIndex !== -1 && menuOptions[curIndex + 1]) {
|
||||
setSelectedOption(menuOptions[curIndex + 1].id);
|
||||
} else {
|
||||
setSelectedOption(menuOptions[0].id);
|
||||
}
|
||||
},
|
||||
Enter: () =>
|
||||
handleEnter({
|
||||
page,
|
||||
selectedOption,
|
||||
callback: () => {
|
||||
setAnchor(null);
|
||||
},
|
||||
}),
|
||||
Escape: () => {
|
||||
setAnchor(null);
|
||||
},
|
||||
}),
|
||||
[page, selectedOption]
|
||||
);
|
||||
const onKeydown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
const shortcut = shortcutMap[e.key];
|
||||
if (shortcut) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
shortcut(e, page);
|
||||
} else {
|
||||
setAnchor(null);
|
||||
}
|
||||
},
|
||||
[page, shortcutMap]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const disposer = page.slots.pasted.on(pastedBlocks => {
|
||||
if (!shouldShowBookmarkMenu(pastedBlocks)) {
|
||||
return;
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
setAnchor(getCurrentNativeRange());
|
||||
}, 100);
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposer.dispose();
|
||||
};
|
||||
}, [onKeydown, page, shortcutMap]);
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor) {
|
||||
document.addEventListener('keydown', onKeydown, { capture: true });
|
||||
} else {
|
||||
// reset status and remove event
|
||||
setSelectedOption(menuOptions[0].id);
|
||||
document.removeEventListener('keydown', onKeydown, { capture: true });
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeydown, { capture: true });
|
||||
};
|
||||
}, [anchor, onKeydown]);
|
||||
|
||||
return anchor ? (
|
||||
<MuiClickAwayListener
|
||||
onClickAway={() => {
|
||||
setAnchor(null);
|
||||
setSelectedOption('');
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<PureMenu open={!!anchor} anchorEl={anchor} placement="bottom-start">
|
||||
{menuOptions.map(({ id, label }) => {
|
||||
return (
|
||||
<MenuItem
|
||||
key={id}
|
||||
active={selectedOption === id}
|
||||
onClick={() => {
|
||||
handleEnter({
|
||||
page,
|
||||
selectedOption: id,
|
||||
callback: () => {
|
||||
setAnchor(null);
|
||||
},
|
||||
});
|
||||
}}
|
||||
disableHover={true}
|
||||
onMouseEnter={() => {
|
||||
setSelectedOption(id);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</PureMenu>
|
||||
</div>
|
||||
</MuiClickAwayListener>
|
||||
) : null;
|
||||
};
|
||||
47
packages/frontend/core/src/components/cloud/login-card.tsx
Normal file
47
packages/frontend/core/src/components/cloud/login-card.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloudWorkspaceIcon } from '@blocksuite/icons';
|
||||
import { Avatar } from '@toeverything/components/avatar';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../hooks/affine/use-current-login-status';
|
||||
import { useCurrentUser } from '../../hooks/affine/use-current-user';
|
||||
import { signInCloud } from '../../utils/cloud-utils';
|
||||
import { StyledSignInButton } from '../pure/footer/styles';
|
||||
|
||||
export const LoginCard = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
if (loginStatus === 'authenticated') {
|
||||
return <UserCard />;
|
||||
}
|
||||
return (
|
||||
<StyledSignInButton
|
||||
data-testid="sign-in-button"
|
||||
onClick={async () => {
|
||||
signInCloud().catch(console.error);
|
||||
}}
|
||||
>
|
||||
<div className="circle">
|
||||
<CloudWorkspaceIcon />
|
||||
</div>{' '}
|
||||
{t['Sign in']()}
|
||||
</StyledSignInButton>
|
||||
);
|
||||
};
|
||||
|
||||
const UserCard = () => {
|
||||
const user = useCurrentUser();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<Avatar size={28} name={user.name} url={user.image} />
|
||||
<div style={{ marginLeft: '15px' }}>
|
||||
<div>{user.name}</div>
|
||||
<div>{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
58
packages/frontend/core/src/components/cloud/provider.tsx
Normal file
58
packages/frontend/core/src/components/cloud/provider.tsx
Normal file
@@ -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>;
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const filterContainerStyle = style({
|
||||
padding: '12px',
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
'::after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
margin: '0 1px',
|
||||
},
|
||||
});
|
||||
78
packages/frontend/core/src/components/migration-fallback.tsx
Normal file
78
packages/frontend/core/src/components/migration-fallback.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import type {
|
||||
AffineSocketIOProvider,
|
||||
LocalIndexedDBBackgroundProvider,
|
||||
SQLiteProvider,
|
||||
} from '@affine/env/workspace';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { forceUpgradePages } from '@toeverything/infra/blocksuite';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { syncDataSourceFromDoc, syncDocFromDataSource } from 'y-provider';
|
||||
|
||||
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
|
||||
|
||||
export const MigrationFallback = function MigrationFallback() {
|
||||
const [done, setDone] = useState(false);
|
||||
const [workspace] = useCurrentWorkspace();
|
||||
const providers = workspace.blockSuiteWorkspace.providers;
|
||||
const remoteProvider: AffineSocketIOProvider | undefined = useMemo(() => {
|
||||
return providers.find(
|
||||
(provider): provider is AffineSocketIOProvider =>
|
||||
provider.flavour === 'affine-socket-io'
|
||||
);
|
||||
}, [providers]);
|
||||
const localProvider = useMemo(() => {
|
||||
const sqliteProvider = providers.find(
|
||||
(provider): provider is SQLiteProvider => provider.flavour === 'sqlite'
|
||||
);
|
||||
const indexedDbProvider = providers.find(
|
||||
(provider): provider is LocalIndexedDBBackgroundProvider =>
|
||||
provider.flavour === 'local-indexeddb-background'
|
||||
);
|
||||
const provider = sqliteProvider || indexedDbProvider;
|
||||
assertExists(provider, 'no local provider');
|
||||
return provider;
|
||||
}, [providers]);
|
||||
const handleClick = useCallback(async () => {
|
||||
setDone(false);
|
||||
await syncDocFromDataSource(
|
||||
workspace.blockSuiteWorkspace.doc,
|
||||
localProvider.datasource
|
||||
);
|
||||
if (remoteProvider) {
|
||||
await syncDocFromDataSource(
|
||||
workspace.blockSuiteWorkspace.doc,
|
||||
remoteProvider.datasource
|
||||
);
|
||||
}
|
||||
|
||||
await forceUpgradePages({
|
||||
getCurrentRootDoc: async () => workspace.blockSuiteWorkspace.doc,
|
||||
getSchema: () => workspace.blockSuiteWorkspace.schema,
|
||||
});
|
||||
await syncDataSourceFromDoc(
|
||||
workspace.blockSuiteWorkspace.doc,
|
||||
localProvider.datasource
|
||||
);
|
||||
if (remoteProvider) {
|
||||
await syncDataSourceFromDoc(
|
||||
workspace.blockSuiteWorkspace.doc,
|
||||
remoteProvider.datasource
|
||||
);
|
||||
}
|
||||
setDone(true);
|
||||
}, [
|
||||
localProvider.datasource,
|
||||
remoteProvider,
|
||||
workspace.blockSuiteWorkspace.doc,
|
||||
workspace.blockSuiteWorkspace.schema,
|
||||
]);
|
||||
if (done) {
|
||||
return <div>Done, please refresh the page.</div>;
|
||||
}
|
||||
return (
|
||||
<Button data-testid="upgrade-workspace" onClick={handleClick}>
|
||||
Upgrade Workspace
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
/**
|
||||
* Editor container element layer should be lower than header and after auto
|
||||
* The zIndex of header is 2, defined in packages/frontend/core/src/components/pure/header/style.css.tsx
|
||||
*/
|
||||
export const editorContainer = style({
|
||||
position: 'relative',
|
||||
zIndex: 0, // it will create stacking context to limit layer of child elements and be lower than after auto zIndex
|
||||
});
|
||||
|
||||
export const pluginContainer = style({
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const editor = style({
|
||||
height: '100%',
|
||||
selectors: {
|
||||
'&.full-screen': {
|
||||
vars: {
|
||||
'--affine-editor-width': '100%',
|
||||
'--affine-editor-side-padding': '15px',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${editor} .affine-doc-viewport`, {
|
||||
paddingBottom: '150px',
|
||||
});
|
||||
|
||||
globalStyle('.is-public-page affine-page-meta-data', {
|
||||
display: 'none',
|
||||
});
|
||||
289
packages/frontend/core/src/components/page-detail-editor.tsx
Normal file
289
packages/frontend/core/src/components/page-detail-editor.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
import './page-detail-editor.css';
|
||||
|
||||
import { PageNotFoundError } from '@affine/env/constant';
|
||||
import type { LayoutNode } from '@affine/sdk//entry';
|
||||
import { rootBlockHubAtom } from '@affine/workspace/atom';
|
||||
import type { EditorContainer } from '@blocksuite/editor';
|
||||
import { assertExists, DisposableGroup } from '@blocksuite/global/utils';
|
||||
import type { Page, Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import {
|
||||
addCleanup,
|
||||
pluginEditorAtom,
|
||||
pluginWindowAtom,
|
||||
} from '@toeverything/infra/__internal__/plugin';
|
||||
import { contentLayoutAtom, getCurrentStore } from '@toeverything/infra/atom';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import type { CSSProperties, ReactElement } from 'react';
|
||||
import {
|
||||
memo,
|
||||
startTransition,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
|
||||
|
||||
import { pageSettingFamily } from '../atoms';
|
||||
import { fontStyleOptions, useAppSetting } from '../atoms/settings';
|
||||
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
|
||||
import { Bookmark } from './bookmark';
|
||||
import * as styles from './page-detail-editor.css';
|
||||
import { editorContainer, pluginContainer } from './page-detail-editor.css';
|
||||
import { TrashButtonGroup } from './pure/trash-button-group';
|
||||
|
||||
export type OnLoadEditor = (page: Page, editor: EditorContainer) => () => void;
|
||||
|
||||
export interface PageDetailEditorProps {
|
||||
isPublic?: boolean;
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
onInit: (
|
||||
page: Page,
|
||||
editor: Readonly<EditorContainer>
|
||||
) => Promise<void> | void;
|
||||
onLoad?: OnLoadEditor;
|
||||
}
|
||||
|
||||
const EditorWrapper = memo(function EditorWrapper({
|
||||
workspace,
|
||||
pageId,
|
||||
onInit,
|
||||
onLoad,
|
||||
isPublic,
|
||||
}: PageDetailEditorProps) {
|
||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||
if (!page) {
|
||||
throw new PageNotFoundError(workspace, pageId);
|
||||
}
|
||||
const meta = useBlockSuitePageMeta(workspace).find(
|
||||
meta => meta.id === pageId
|
||||
);
|
||||
const pageSettingAtom = pageSettingFamily(pageId);
|
||||
const pageSetting = useAtomValue(pageSettingAtom);
|
||||
const currentMode = pageSetting?.mode ?? 'page';
|
||||
|
||||
const setBlockHub = useSetAtom(rootBlockHubAtom);
|
||||
const [appSettings] = useAppSetting();
|
||||
|
||||
assertExists(meta);
|
||||
const value = useMemo(() => {
|
||||
const fontStyle = fontStyleOptions.find(
|
||||
option => option.key === appSettings.fontStyle
|
||||
);
|
||||
assertExists(fontStyle);
|
||||
return fontStyle.value;
|
||||
}, [appSettings.fontStyle]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Editor
|
||||
className={clsx(styles.editor, {
|
||||
'full-screen': appSettings.fullWidthLayout,
|
||||
'is-public-page': isPublic,
|
||||
})}
|
||||
style={
|
||||
{
|
||||
'--affine-font-family': value,
|
||||
} as CSSProperties
|
||||
}
|
||||
mode={isPublic ? 'page' : currentMode}
|
||||
page={page}
|
||||
onInit={useCallback(
|
||||
(page: Page, editor: Readonly<EditorContainer>) => {
|
||||
onInit(page, editor);
|
||||
},
|
||||
[onInit]
|
||||
)}
|
||||
setBlockHub={setBlockHub}
|
||||
onLoad={useCallback(
|
||||
(page: Page, editor: EditorContainer) => {
|
||||
const disposableGroup = new DisposableGroup();
|
||||
disposableGroup.add(
|
||||
page.slots.blockUpdated.once(() => {
|
||||
page.workspace.setPageMeta(page.id, {
|
||||
updatedDate: Date.now(),
|
||||
});
|
||||
})
|
||||
);
|
||||
localStorage.setItem('last_page_id', page.id);
|
||||
if (onLoad) {
|
||||
disposableGroup.add(onLoad(page, editor));
|
||||
}
|
||||
const rootStore = getCurrentStore();
|
||||
const editorItems = rootStore.get(pluginEditorAtom);
|
||||
let disposes: (() => void)[] = [];
|
||||
const renderTimeout = window.setTimeout(() => {
|
||||
disposes = Object.entries(editorItems).map(([id, editorItem]) => {
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('plugin-id', id);
|
||||
const cleanup = editorItem(div, editor);
|
||||
assertExists(parent);
|
||||
document.body.appendChild(div);
|
||||
return () => {
|
||||
cleanup();
|
||||
document.body.removeChild(div);
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
disposableGroup.dispose();
|
||||
clearTimeout(renderTimeout);
|
||||
window.setTimeout(() => {
|
||||
disposes.forEach(dispose => dispose());
|
||||
});
|
||||
};
|
||||
},
|
||||
[onLoad]
|
||||
)}
|
||||
/>
|
||||
{meta.trash && <TrashButtonGroup />}
|
||||
<Bookmark page={page} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
interface PluginContentAdapterProps {
|
||||
windowItem: (div: HTMLDivElement) => () => void;
|
||||
pluginName: string;
|
||||
}
|
||||
|
||||
const PluginContentAdapter = memo<PluginContentAdapterProps>(
|
||||
function PluginContentAdapter({ windowItem, pluginName }) {
|
||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const abortController = new AbortController();
|
||||
const root = rootRef.current;
|
||||
if (root) {
|
||||
startTransition(() => {
|
||||
if (abortController.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
const cleanup = windowItem(div);
|
||||
root.appendChild(div);
|
||||
if (abortController.signal.aborted) {
|
||||
cleanup();
|
||||
root.removeChild(div);
|
||||
} else {
|
||||
const cl = () => {
|
||||
cleanup();
|
||||
root.removeChild(div);
|
||||
};
|
||||
const dispose = addCleanup(pluginName, cl);
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
window.setTimeout(() => {
|
||||
dispose();
|
||||
cl();
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [pluginName, windowItem]);
|
||||
return <div className={pluginContainer} ref={rootRef} />;
|
||||
}
|
||||
);
|
||||
|
||||
interface LayoutPanelProps {
|
||||
node: LayoutNode;
|
||||
editorProps: PageDetailEditorProps;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
const LayoutPanel = memo(function LayoutPanel(
|
||||
props: LayoutPanelProps
|
||||
): ReactElement {
|
||||
const { node, depth, editorProps } = props;
|
||||
const windowItems = useAtomValue(pluginWindowAtom);
|
||||
if (typeof node === 'string') {
|
||||
if (node === 'editor') {
|
||||
return <EditorWrapper {...editorProps} />;
|
||||
} else {
|
||||
const windowItem = windowItems[node];
|
||||
return <PluginContentAdapter pluginName={node} windowItem={windowItem} />;
|
||||
}
|
||||
} else {
|
||||
return (
|
||||
<PanelGroup
|
||||
direction={node.direction}
|
||||
style={depth === 0 ? { height: 'calc(100% - 52px)' } : undefined}
|
||||
className={depth === 0 ? editorContainer : undefined}
|
||||
>
|
||||
<Panel
|
||||
defaultSize={node.splitPercentage}
|
||||
style={{
|
||||
maxWidth: node.maxWidth?.[0],
|
||||
}}
|
||||
>
|
||||
<Suspense>
|
||||
<LayoutPanel
|
||||
node={node.first}
|
||||
editorProps={editorProps}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</Suspense>
|
||||
</Panel>
|
||||
<PanelResizeHandle />
|
||||
<Panel
|
||||
defaultSize={100 - node.splitPercentage}
|
||||
style={{
|
||||
overflow: 'scroll',
|
||||
maxWidth: node.maxWidth?.[1],
|
||||
}}
|
||||
>
|
||||
<Suspense>
|
||||
<LayoutPanel
|
||||
node={node.second}
|
||||
editorProps={editorProps}
|
||||
depth={depth + 1}
|
||||
/>
|
||||
</Suspense>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export const PageDetailEditor = (props: PageDetailEditorProps) => {
|
||||
const { workspace, pageId } = props;
|
||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||
if (!page) {
|
||||
throw new PageNotFoundError(workspace, pageId);
|
||||
}
|
||||
|
||||
const layout = useAtomValue(contentLayoutAtom);
|
||||
|
||||
if (layout === 'editor') {
|
||||
return (
|
||||
<Suspense>
|
||||
<PanelGroup
|
||||
style={{ height: 'calc(100% - 52px)' }}
|
||||
direction="horizontal"
|
||||
className={editorContainer}
|
||||
>
|
||||
<Panel>
|
||||
<EditorWrapper {...props} />
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Suspense>
|
||||
<LayoutPanel node={layout} editorProps={props} depth={0} />
|
||||
</Suspense>
|
||||
</>
|
||||
);
|
||||
};
|
||||
413
packages/frontend/core/src/components/pure/cmdk/data.tsx
Normal file
413
packages/frontend/core/src/components/pure/cmdk/data.tsx
Normal file
@@ -0,0 +1,413 @@
|
||||
import { commandScore } from '@affine/cmdk';
|
||||
import { useCollectionManager } from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, PageIcon, ViewLayersIcon } from '@blocksuite/icons';
|
||||
import type { Page, PageMeta } from '@blocksuite/store';
|
||||
import {
|
||||
useBlockSuitePageMeta,
|
||||
usePageMetaHelper,
|
||||
} from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import {
|
||||
getWorkspace,
|
||||
waitForWorkspace,
|
||||
} from '@toeverything/infra/__internal__/workspace';
|
||||
import {
|
||||
currentPageIdAtom,
|
||||
currentWorkspaceIdAtom,
|
||||
getCurrentStore,
|
||||
} from '@toeverything/infra/atom';
|
||||
import {
|
||||
type AffineCommand,
|
||||
AffineCommandRegistry,
|
||||
type CommandCategory,
|
||||
PreconditionStrategy,
|
||||
} from '@toeverything/infra/command';
|
||||
import { atom, useAtomValue } from 'jotai';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import {
|
||||
openQuickSearchModalAtom,
|
||||
pageSettingsAtom,
|
||||
recentPageIdsBaseAtom,
|
||||
} from '../../../atoms';
|
||||
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||
import { WorkspaceSubPath } from '../../../shared';
|
||||
import { currentCollectionsAtom } from '../../../utils/user-setting';
|
||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||
import type { CMDKCommand, CommandContext } from './types';
|
||||
|
||||
export const cmdkQueryAtom = atom('');
|
||||
export const cmdkValueAtom = atom('');
|
||||
|
||||
// like currentWorkspaceAtom, but not throw error
|
||||
const safeCurrentPageAtom = atom<Promise<Page | undefined>>(async get => {
|
||||
const currentWorkspaceId = get(currentWorkspaceIdAtom);
|
||||
if (!currentWorkspaceId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPageId = get(currentPageIdAtom);
|
||||
|
||||
if (!currentPageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const workspace = getWorkspace(currentWorkspaceId);
|
||||
await waitForWorkspace(workspace);
|
||||
const page = workspace.getPage(currentPageId);
|
||||
|
||||
if (!page) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!page.loaded) {
|
||||
await page.waitForLoaded();
|
||||
}
|
||||
return page;
|
||||
});
|
||||
|
||||
export const commandContextAtom = atom<Promise<CommandContext>>(async get => {
|
||||
const currentPage = await get(safeCurrentPageAtom);
|
||||
const pageSettings = get(pageSettingsAtom);
|
||||
|
||||
return {
|
||||
currentPage,
|
||||
pageMode: currentPage ? pageSettings[currentPage.id]?.mode : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
function filterCommandByContext(
|
||||
command: AffineCommand,
|
||||
context: CommandContext
|
||||
) {
|
||||
if (command.preconditionStrategy === PreconditionStrategy.Always) {
|
||||
return true;
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) {
|
||||
return context.pageMode === 'edgeless';
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.InPaper) {
|
||||
return context.pageMode === 'page';
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) {
|
||||
return !!context.currentPage;
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.Never) {
|
||||
return false;
|
||||
}
|
||||
if (typeof command.preconditionStrategy === 'function') {
|
||||
return command.preconditionStrategy();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
let quickSearchOpenCounter = 0;
|
||||
const openCountAtom = atom(get => {
|
||||
if (get(openQuickSearchModalAtom)) {
|
||||
quickSearchOpenCounter++;
|
||||
}
|
||||
return quickSearchOpenCounter;
|
||||
});
|
||||
|
||||
export const filteredAffineCommands = atom(async get => {
|
||||
const context = await get(commandContextAtom);
|
||||
// reset when modal open
|
||||
get(openCountAtom);
|
||||
const commands = AffineCommandRegistry.getAll();
|
||||
return commands.filter(command => {
|
||||
return filterCommandByContext(command, context);
|
||||
});
|
||||
});
|
||||
|
||||
const useWorkspacePages = () => {
|
||||
const [currentWorkspace] = useCurrentWorkspace();
|
||||
const pages = useBlockSuitePageMeta(currentWorkspace.blockSuiteWorkspace);
|
||||
return pages;
|
||||
};
|
||||
|
||||
const useRecentPages = () => {
|
||||
const pages = useWorkspacePages();
|
||||
const recentPageIds = useAtomValue(recentPageIdsBaseAtom);
|
||||
return useMemo(() => {
|
||||
return recentPageIds
|
||||
.map(pageId => {
|
||||
const page = pages.find(page => page.id === pageId);
|
||||
return page;
|
||||
})
|
||||
.filter((p): p is PageMeta => !!p);
|
||||
}, [recentPageIds, pages]);
|
||||
};
|
||||
|
||||
const valueWrapperStart = '__>>>';
|
||||
const valueWrapperEnd = '<<<__';
|
||||
|
||||
export const pageToCommand = (
|
||||
category: CommandCategory,
|
||||
page: PageMeta,
|
||||
store: ReturnType<typeof getCurrentStore>,
|
||||
navigationHelper: ReturnType<typeof useNavigateHelper>,
|
||||
t: ReturnType<typeof useAFFiNEI18N>
|
||||
): CMDKCommand => {
|
||||
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
|
||||
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
|
||||
const label = page.title || t['Untitled']();
|
||||
return {
|
||||
id: page.id,
|
||||
label: label,
|
||||
// hack: when comparing, the part between >>> and <<< will be ignored
|
||||
// adding this patch so that CMDK will not complain about duplicated commands
|
||||
value:
|
||||
label + valueWrapperStart + page.id + '.' + category + valueWrapperEnd,
|
||||
originalValue: label,
|
||||
category: category,
|
||||
run: () => {
|
||||
if (!currentWorkspaceId) {
|
||||
console.error('current workspace not found');
|
||||
return;
|
||||
}
|
||||
navigationHelper.jumpToPage(currentWorkspaceId, page.id);
|
||||
},
|
||||
icon: pageMode === 'edgeless' ? <EdgelessIcon /> : <PageIcon />,
|
||||
timestamp: page.updatedDate,
|
||||
};
|
||||
};
|
||||
|
||||
const contentMatchedMagicString = '__$$content_matched$$__';
|
||||
|
||||
export const usePageCommands = () => {
|
||||
// todo: considering collections for searching pages
|
||||
// const { savedCollections } = useCollectionManager(currentCollectionsAtom);
|
||||
const recentPages = useRecentPages();
|
||||
const pages = useWorkspacePages();
|
||||
const store = getCurrentStore();
|
||||
const [workspace] = useCurrentWorkspace();
|
||||
const pageHelper = usePageHelper(workspace.blockSuiteWorkspace);
|
||||
const pageMetaHelper = usePageMetaHelper(workspace.blockSuiteWorkspace);
|
||||
const query = useAtomValue(cmdkQueryAtom);
|
||||
const navigationHelper = useNavigateHelper();
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return useMemo(() => {
|
||||
let results: CMDKCommand[] = [];
|
||||
if (query.trim() === '') {
|
||||
results = recentPages.map(page => {
|
||||
return pageToCommand('affine:recent', page, store, navigationHelper, t);
|
||||
});
|
||||
} else {
|
||||
// queried pages that has matched contents
|
||||
const searchResults = Array.from(
|
||||
workspace.blockSuiteWorkspace.search({ query }).values()
|
||||
) as unknown as { space: string; content: string }[];
|
||||
|
||||
const pageIds = searchResults.map(id => {
|
||||
if (id.space.startsWith('space:')) {
|
||||
return id.space.slice(6);
|
||||
} else {
|
||||
return id.space;
|
||||
}
|
||||
});
|
||||
|
||||
results = pages.map(page => {
|
||||
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
|
||||
const category =
|
||||
pageMode === 'edgeless' ? 'affine:edgeless' : 'affine:pages';
|
||||
const command = pageToCommand(
|
||||
category,
|
||||
page,
|
||||
store,
|
||||
navigationHelper,
|
||||
t
|
||||
);
|
||||
|
||||
if (pageIds.includes(page.id)) {
|
||||
// hack to make the page always showing in the search result
|
||||
command.value += contentMatchedMagicString;
|
||||
}
|
||||
|
||||
return command;
|
||||
});
|
||||
|
||||
// check if the pages have exact match. if not, we should show the "create page" command
|
||||
if (results.every(command => command.originalValue !== query)) {
|
||||
results.push({
|
||||
id: 'affine:pages:create-page',
|
||||
label: (
|
||||
<Trans
|
||||
i18nKey="com.affine.cmdk.affine.create-new-page-as"
|
||||
values={{ query }}
|
||||
>
|
||||
Create New Page as: <strong>query</strong>
|
||||
</Trans>
|
||||
),
|
||||
value: 'affine::create-page' + query, // hack to make the page always showing in the search result
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const page = pageHelper.createPage();
|
||||
await page.waitForLoaded();
|
||||
pageMetaHelper.setPageTitle(page.id, query);
|
||||
},
|
||||
icon: <PageIcon />,
|
||||
});
|
||||
|
||||
results.push({
|
||||
id: 'affine:pages:create-edgeless',
|
||||
label: (
|
||||
<Trans
|
||||
values={{ query }}
|
||||
i18nKey="com.affine.cmdk.affine.create-new-edgeless-as"
|
||||
>
|
||||
Create New Edgeless as: <strong>query</strong>
|
||||
</Trans>
|
||||
),
|
||||
value: 'affine::create-edgeless' + query, // hack to make the page always showing in the search result
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const page = pageHelper.createEdgeless();
|
||||
await page.waitForLoaded();
|
||||
pageMetaHelper.setPageTitle(page.id, query);
|
||||
},
|
||||
icon: <EdgelessIcon />,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}, [
|
||||
pageHelper,
|
||||
pageMetaHelper,
|
||||
navigationHelper,
|
||||
pages,
|
||||
query,
|
||||
recentPages,
|
||||
store,
|
||||
t,
|
||||
workspace.blockSuiteWorkspace,
|
||||
]);
|
||||
};
|
||||
|
||||
export const collectionToCommand = (
|
||||
collection: Collection,
|
||||
store: ReturnType<typeof getCurrentStore>,
|
||||
navigationHelper: ReturnType<typeof useNavigateHelper>,
|
||||
selectCollection: ReturnType<typeof useCollectionManager>['selectCollection'],
|
||||
t: ReturnType<typeof useAFFiNEI18N>
|
||||
): CMDKCommand => {
|
||||
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
|
||||
const label = collection.name || t['Untitled']();
|
||||
const category = 'affine:collections';
|
||||
return {
|
||||
id: collection.id,
|
||||
label: label,
|
||||
// hack: when comparing, the part between >>> and <<< will be ignored
|
||||
// adding this patch so that CMDK will not complain about duplicated commands
|
||||
value:
|
||||
label +
|
||||
valueWrapperStart +
|
||||
collection.id +
|
||||
'.' +
|
||||
category +
|
||||
valueWrapperEnd,
|
||||
originalValue: label,
|
||||
category: category,
|
||||
run: () => {
|
||||
if (!currentWorkspaceId) {
|
||||
console.error('current workspace not found');
|
||||
return;
|
||||
}
|
||||
navigationHelper.jumpToSubPath(currentWorkspaceId, WorkspaceSubPath.ALL);
|
||||
selectCollection(collection.id);
|
||||
},
|
||||
icon: <ViewLayersIcon />,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollectionsCommands = () => {
|
||||
// todo: considering collections for searching pages
|
||||
const { savedCollections, selectCollection } = useCollectionManager(
|
||||
currentCollectionsAtom
|
||||
);
|
||||
const store = getCurrentStore();
|
||||
const query = useAtomValue(cmdkQueryAtom);
|
||||
const navigationHelper = useNavigateHelper();
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return useMemo(() => {
|
||||
let results: CMDKCommand[] = [];
|
||||
if (query.trim() === '') {
|
||||
return results;
|
||||
} else {
|
||||
results = savedCollections.map(collection => {
|
||||
const command = collectionToCommand(
|
||||
collection,
|
||||
store,
|
||||
navigationHelper,
|
||||
selectCollection,
|
||||
t
|
||||
);
|
||||
return command;
|
||||
});
|
||||
return results;
|
||||
}
|
||||
}, [query, savedCollections, store, navigationHelper, selectCollection, t]);
|
||||
};
|
||||
|
||||
export const useCMDKCommandGroups = () => {
|
||||
const pageCommands = usePageCommands();
|
||||
const collectionCommands = useCollectionsCommands();
|
||||
const affineCommands = useAtomValue(filteredAffineCommands);
|
||||
|
||||
return useMemo(() => {
|
||||
const commands = [
|
||||
...pageCommands,
|
||||
...collectionCommands,
|
||||
...affineCommands,
|
||||
];
|
||||
const groups = groupBy(commands, command => command.category);
|
||||
return Object.entries(groups) as [CommandCategory, CMDKCommand[]][];
|
||||
}, [affineCommands, collectionCommands, pageCommands]);
|
||||
};
|
||||
|
||||
export const customCommandFilter = (value: string, search: string) => {
|
||||
// strip off the part between __>>> and <<<__
|
||||
let label = value.replace(
|
||||
new RegExp(valueWrapperStart + '.*' + valueWrapperEnd, 'g'),
|
||||
''
|
||||
);
|
||||
|
||||
const pageContentMatched = label.includes(contentMatchedMagicString);
|
||||
if (pageContentMatched) {
|
||||
label = label.replace(contentMatchedMagicString, '');
|
||||
}
|
||||
|
||||
const originalScore = commandScore(label, search);
|
||||
|
||||
// if the command has matched the content but not the label,
|
||||
// we should give it a higher score, but not too high
|
||||
if (originalScore < 0.01 && pageContentMatched) {
|
||||
return 0.3;
|
||||
}
|
||||
return originalScore;
|
||||
};
|
||||
|
||||
export const useCommandFilteredStatus = (
|
||||
groups: [CommandCategory, CMDKCommand[]][]
|
||||
) => {
|
||||
// for each of the groups, show the count of commands that has matched the query
|
||||
const query = useAtomValue(cmdkQueryAtom);
|
||||
return useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
groups.map(([category, commands]) => {
|
||||
return [category, getCommandFilteredCount(commands, query)] as const;
|
||||
})
|
||||
) as Record<CommandCategory, number>;
|
||||
}, [groups, query]);
|
||||
};
|
||||
|
||||
function getCommandFilteredCount(commands: CMDKCommand[], query: string) {
|
||||
return commands.filter(command => {
|
||||
return command.value && customCommandFilter(command.value, query) > 0;
|
||||
}).length;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './main';
|
||||
export * from './modal';
|
||||
184
packages/frontend/core/src/components/pure/cmdk/main.css.ts
Normal file
184
packages/frontend/core/src/components/pure/cmdk/main.css.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({});
|
||||
|
||||
export const commandsContainer = style({
|
||||
height: 'calc(100% - 65px)',
|
||||
padding: '8px 6px 18px 6px',
|
||||
});
|
||||
|
||||
export const searchInput = style({
|
||||
height: 66,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
padding: '21px 24px',
|
||||
width: '100%',
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
flexShrink: 0,
|
||||
|
||||
'::placeholder': {
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
},
|
||||
selectors: {
|
||||
'&.inEditor': {
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '18px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const pageTitleWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '18px 24px 0 24px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const pageTitle = style({
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
lineHeight: '20px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
||||
});
|
||||
|
||||
export const panelContainer = style({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const itemIcon = style({
|
||||
fontSize: 20,
|
||||
marginRight: 16,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--affine-icon-secondary)',
|
||||
});
|
||||
|
||||
export const itemLabel = style({
|
||||
fontSize: 14,
|
||||
lineHeight: '1.5',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const timestamp = style({
|
||||
display: 'flex',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const keybinding = style({
|
||||
display: 'flex',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
columnGap: 2,
|
||||
});
|
||||
|
||||
export const keybindingFragment = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
backgroundColor: 'var(--affine-background-tertiary-color)',
|
||||
width: 24,
|
||||
height: 20,
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-root]`, {
|
||||
height: '100%',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-group-heading]`, {
|
||||
padding: '8px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.67',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-group][hidden]`, {
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
globalStyle(
|
||||
`${root} [cmdk-group]:not([hidden]):first-of-type [cmdk-group-heading]`,
|
||||
{
|
||||
paddingTop: 16,
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(`${root} [cmdk-list]`, {
|
||||
maxHeight: 400,
|
||||
minHeight: 120,
|
||||
overflow: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
transition: '.1s ease',
|
||||
transitionProperty: 'height',
|
||||
height: 'min(330px, calc(var(--cmdk-list-height) + 8px))',
|
||||
padding: '0 0 8px 6px',
|
||||
scrollbarGutter: 'stable',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-list]::-webkit-scrollbar`, {
|
||||
width: 6,
|
||||
height: 6,
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-list]::-webkit-scrollbar-thumb`, {
|
||||
borderRadius: 4,
|
||||
backgroundClip: 'padding-box',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-list]:hover::-webkit-scrollbar-thumb`, {
|
||||
backgroundColor: 'var(--affine-divider-color)',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-list]:hover::-webkit-scrollbar-thumb:hover`, {
|
||||
backgroundColor: 'var(--affine-icon-color)',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-item]`, {
|
||||
display: 'flex',
|
||||
height: 44,
|
||||
padding: '0 12px',
|
||||
alignItems: 'center',
|
||||
cursor: 'default',
|
||||
borderRadius: 4,
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-item][data-selected=true]`, {
|
||||
background: 'var(--affine-background-secondary-color)',
|
||||
});
|
||||
globalStyle(`${root} [cmdk-item][data-selected=true][data-is-danger=true]`, {
|
||||
background: 'var(--affine-background-error-color)',
|
||||
color: 'var(--affine-error-color)',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-item][data-selected=true] ${itemIcon}`, {
|
||||
color: 'var(--affine-icon-color)',
|
||||
});
|
||||
globalStyle(
|
||||
`${root} [cmdk-item][data-selected=true][data-is-danger=true] ${itemIcon}`,
|
||||
{
|
||||
color: 'var(--affine-error-color)',
|
||||
}
|
||||
);
|
||||
globalStyle(
|
||||
`${root} [cmdk-item][data-selected=true][data-is-danger=true] ${itemLabel}`,
|
||||
{
|
||||
color: 'var(--affine-error-color)',
|
||||
}
|
||||
);
|
||||
242
packages/frontend/core/src/components/pure/cmdk/main.tsx
Normal file
242
packages/frontend/core/src/components/pure/cmdk/main.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Command } from '@affine/cmdk';
|
||||
import { formatDate } from '@affine/component/page-list';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import type { CommandCategory } from '@toeverything/infra/command';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom, useSetAtom } from 'jotai';
|
||||
import { Suspense, useLayoutEffect, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
cmdkQueryAtom,
|
||||
cmdkValueAtom,
|
||||
customCommandFilter,
|
||||
useCMDKCommandGroups,
|
||||
} from './data';
|
||||
import * as styles from './main.css';
|
||||
import { CMDKModal, type CMDKModalProps } from './modal';
|
||||
import type { CMDKCommand } from './types';
|
||||
|
||||
type NoParametersKeys<T> = {
|
||||
[K in keyof T]: T[K] extends () => any ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type i18nKey = NoParametersKeys<ReturnType<typeof useAFFiNEI18N>>;
|
||||
|
||||
const categoryToI18nKey: Record<CommandCategory, i18nKey> = {
|
||||
'affine:recent': 'com.affine.cmdk.affine.category.affine.recent',
|
||||
'affine:navigation': 'com.affine.cmdk.affine.category.affine.navigation',
|
||||
'affine:creation': 'com.affine.cmdk.affine.category.affine.creation',
|
||||
'affine:general': 'com.affine.cmdk.affine.category.affine.general',
|
||||
'affine:layout': 'com.affine.cmdk.affine.category.affine.layout',
|
||||
'affine:pages': 'com.affine.cmdk.affine.category.affine.pages',
|
||||
'affine:edgeless': 'com.affine.cmdk.affine.category.affine.edgeless',
|
||||
'affine:collections': 'com.affine.cmdk.affine.category.affine.collections',
|
||||
'affine:settings': 'com.affine.cmdk.affine.category.affine.settings',
|
||||
'affine:updates': 'com.affine.cmdk.affine.category.affine.updates',
|
||||
'affine:help': 'com.affine.cmdk.affine.category.affine.help',
|
||||
'editor:edgeless': 'com.affine.cmdk.affine.category.editor.edgeless',
|
||||
'editor:insert-object':
|
||||
'com.affine.cmdk.affine.category.editor.insert-object',
|
||||
'editor:page': 'com.affine.cmdk.affine.category.editor.page',
|
||||
};
|
||||
|
||||
const QuickSearchGroup = ({
|
||||
category,
|
||||
commands,
|
||||
onOpenChange,
|
||||
}: {
|
||||
category: CommandCategory;
|
||||
commands: CMDKCommand[];
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const i18nkey = categoryToI18nKey[category];
|
||||
const setQuery = useSetAtom(cmdkQueryAtom);
|
||||
return (
|
||||
<Command.Group key={category} heading={t[i18nkey]()}>
|
||||
{commands.map(command => {
|
||||
return (
|
||||
<Command.Item
|
||||
key={command.id}
|
||||
onSelect={() => {
|
||||
command.run();
|
||||
setQuery('');
|
||||
onOpenChange?.(false);
|
||||
}}
|
||||
value={command.value}
|
||||
data-is-danger={
|
||||
command.id === 'editor:page-move-to-trash' ||
|
||||
command.id === 'editor:edgeless-move-to-trash'
|
||||
}
|
||||
>
|
||||
<div className={styles.itemIcon}>{command.icon}</div>
|
||||
<div
|
||||
data-testid="cmdk-label"
|
||||
className={styles.itemLabel}
|
||||
data-value={
|
||||
command.originalValue ? command.originalValue : undefined
|
||||
}
|
||||
>
|
||||
{command.label}
|
||||
</div>
|
||||
{command.timestamp ? (
|
||||
<div className={styles.timestamp}>
|
||||
{formatDate(new Date(command.timestamp))}
|
||||
</div>
|
||||
) : null}
|
||||
{command.keyBinding ? (
|
||||
<CMDKKeyBinding
|
||||
keyBinding={
|
||||
typeof command.keyBinding === 'string'
|
||||
? command.keyBinding
|
||||
: command.keyBinding.binding
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.Group>
|
||||
);
|
||||
};
|
||||
|
||||
const QuickSearchCommands = ({
|
||||
onOpenChange,
|
||||
}: {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
const groups = useCMDKCommandGroups();
|
||||
|
||||
return groups.map(([category, commands]) => {
|
||||
return (
|
||||
<QuickSearchGroup
|
||||
key={category}
|
||||
onOpenChange={onOpenChange}
|
||||
category={category}
|
||||
commands={commands}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export const CMDKContainer = ({
|
||||
className,
|
||||
onQueryChange,
|
||||
query,
|
||||
children,
|
||||
pageMeta,
|
||||
...rest
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
query: string;
|
||||
pageMeta?: PageMeta;
|
||||
onQueryChange: (query: string) => void;
|
||||
}>) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [value, setValue] = useAtom(cmdkValueAtom);
|
||||
const isInEditor = pageMeta !== undefined;
|
||||
return (
|
||||
<Command
|
||||
{...rest}
|
||||
data-testid="cmdk-quick-search"
|
||||
filter={customCommandFilter}
|
||||
className={clsx(className, styles.panelContainer)}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
// Handle KeyboardEvent conflicts with blocksuite
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (
|
||||
e.key === 'ArrowDown' ||
|
||||
e.key === 'ArrowUp' ||
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'ArrowRight'
|
||||
) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* todo: add page context here */}
|
||||
{isInEditor ? (
|
||||
<div className={styles.pageTitleWrapper}>
|
||||
<span className={styles.pageTitle}>
|
||||
{pageMeta.title ? pageMeta.title : t['Untitled']()}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<Command.Input
|
||||
placeholder={t['com.affine.cmdk.placeholder']()}
|
||||
autoFocus
|
||||
{...rest}
|
||||
value={query}
|
||||
onValueChange={onQueryChange}
|
||||
className={clsx(className, styles.searchInput, {
|
||||
inEditor: isInEditor,
|
||||
})}
|
||||
/>
|
||||
<Command.List>{children}</Command.List>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
export const CMDKQuickSearchModal = ({
|
||||
pageMeta,
|
||||
...props
|
||||
}: CMDKModalProps & { pageMeta?: PageMeta }) => {
|
||||
const [query, setQuery] = useAtom(cmdkQueryAtom);
|
||||
useLayoutEffect(() => {
|
||||
if (props.open) {
|
||||
setQuery('');
|
||||
}
|
||||
}, [props.open, setQuery]);
|
||||
return (
|
||||
<CMDKModal {...props}>
|
||||
<CMDKContainer
|
||||
className={styles.root}
|
||||
query={query}
|
||||
onQueryChange={setQuery}
|
||||
pageMeta={pageMeta}
|
||||
>
|
||||
<Suspense fallback={<Command.Loading />}>
|
||||
<QuickSearchCommands onOpenChange={props.onOpenChange} />
|
||||
</Suspense>
|
||||
</CMDKContainer>
|
||||
</CMDKModal>
|
||||
);
|
||||
};
|
||||
|
||||
const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => {
|
||||
const isMacOS = environment.isBrowser && environment.isMacOs;
|
||||
const fragments = useMemo(() => {
|
||||
return keyBinding.split('+').map(fragment => {
|
||||
if (fragment === '$mod') {
|
||||
return isMacOS ? '⌘' : 'Ctrl';
|
||||
}
|
||||
if (fragment === 'ArrowUp') {
|
||||
return '↑';
|
||||
}
|
||||
if (fragment === 'ArrowDown') {
|
||||
return '↓';
|
||||
}
|
||||
if (fragment === 'ArrowLeft') {
|
||||
return '←';
|
||||
}
|
||||
if (fragment === 'ArrowRight') {
|
||||
return '→';
|
||||
}
|
||||
return fragment;
|
||||
});
|
||||
}, [isMacOS, keyBinding]);
|
||||
|
||||
return (
|
||||
<div className={styles.keybinding}>
|
||||
{fragments.map((fragment, index) => {
|
||||
return (
|
||||
<div key={index} className={styles.keybindingFragment}>
|
||||
{fragment}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
55
packages/frontend/core/src/components/pure/cmdk/modal.css.ts
Normal file
55
packages/frontend/core/src/components/pure/cmdk/modal.css.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const contentShow = keyframes({
|
||||
from: { opacity: 0, transform: 'translateY(-2%) scale(0.96)' },
|
||||
to: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||
});
|
||||
|
||||
const contentHide = keyframes({
|
||||
to: { opacity: 0, transform: 'translateY(-2%) scale(0.96)' },
|
||||
from: { opacity: 1, transform: 'translateY(0) scale(1)' },
|
||||
});
|
||||
|
||||
export const modalOverlay = style({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: 'var(--affine-z-index-modal)',
|
||||
});
|
||||
|
||||
export const modalContentWrapper = style({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
zIndex: 'var(--affine-z-index-modal)',
|
||||
padding: '13vh 16px 16px',
|
||||
});
|
||||
|
||||
export const modalContent = style({
|
||||
width: 640,
|
||||
// height: 530,
|
||||
backgroundColor: 'var(--affine-background-overlay-panel-color)',
|
||||
boxShadow: 'var(--affine-cmd-shadow)',
|
||||
borderRadius: '12px',
|
||||
maxWidth: 'calc(100vw - 50px)',
|
||||
minWidth: 480,
|
||||
// minHeight: 420,
|
||||
// :focus-visible will set outline
|
||||
outline: 'none',
|
||||
position: 'relative',
|
||||
zIndex: 'var(--affine-z-index-modal)',
|
||||
willChange: 'transform, opacity',
|
||||
|
||||
selectors: {
|
||||
'&[data-state=entered], &[data-state=entering]': {
|
||||
animation: `${contentShow} 120ms cubic-bezier(0.42, 0, 0.58, 1)`,
|
||||
animationFillMode: 'forwards',
|
||||
},
|
||||
'&[data-state=exited], &[data-state=exiting]': {
|
||||
animation: `${contentHide} 120ms cubic-bezier(0.42, 0, 0.58, 1)`,
|
||||
animationFillMode: 'forwards',
|
||||
},
|
||||
},
|
||||
});
|
||||
67
packages/frontend/core/src/components/pure/cmdk/modal.tsx
Normal file
67
packages/frontend/core/src/components/pure/cmdk/modal.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { useEffect, useReducer } from 'react';
|
||||
|
||||
import * as styles from './modal.css';
|
||||
|
||||
// a CMDK modal that can be used to display a CMDK command
|
||||
// it has a smooth animation and can be closed by clicking outside of the modal
|
||||
|
||||
export interface CMDKModalProps {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type ModalAnimationState = 'entering' | 'entered' | 'exiting' | 'exited';
|
||||
|
||||
function reduceAnimationState(
|
||||
state: ModalAnimationState,
|
||||
action: 'open' | 'close' | 'finish'
|
||||
) {
|
||||
switch (action) {
|
||||
case 'open':
|
||||
return state === 'entered' || state === 'entering' ? state : 'entering';
|
||||
case 'close':
|
||||
return state === 'exited' || state === 'exiting' ? state : 'exiting';
|
||||
case 'finish':
|
||||
return state === 'entering' ? 'entered' : 'exited';
|
||||
}
|
||||
}
|
||||
|
||||
export const CMDKModal = ({
|
||||
onOpenChange,
|
||||
open,
|
||||
children,
|
||||
}: React.PropsWithChildren<CMDKModalProps>) => {
|
||||
const [animationState, dispatch] = useReducer(reduceAnimationState, 'exited');
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(open ? 'open' : 'close');
|
||||
const timeout = setTimeout(() => {
|
||||
dispatch('finish');
|
||||
}, 120);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog.Root
|
||||
modal
|
||||
open={animationState !== 'exited'}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className={styles.modalOverlay} />
|
||||
<div className={styles.modalContentWrapper}>
|
||||
<Dialog.Content
|
||||
className={styles.modalContent}
|
||||
data-state={animationState}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
22
packages/frontend/core/src/components/pure/cmdk/types.ts
Normal file
22
packages/frontend/core/src/components/pure/cmdk/types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { Page } from '@blocksuite/store';
|
||||
import type { CommandCategory } from '@toeverything/infra/command';
|
||||
|
||||
export interface CommandContext {
|
||||
currentPage: Page | undefined;
|
||||
pageMode: 'page' | 'edgeless' | undefined;
|
||||
}
|
||||
|
||||
// similar to AffineCommand, but for rendering into the UI
|
||||
// it unifies all possible commands into a single type so that
|
||||
// we can use a single render function to render all different commands
|
||||
export interface CMDKCommand {
|
||||
id: string;
|
||||
label: string | React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
category: CommandCategory;
|
||||
keyBinding?: string | { binding: string };
|
||||
timestamp?: number;
|
||||
value?: string; // this is used for item filtering
|
||||
originalValue?: string; // some values may be transformed, this is the original value
|
||||
run: (e?: Event) => void | Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { styled } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import type { ChangeEvent, PropsWithChildren } from 'react';
|
||||
import { useRef } from 'react';
|
||||
|
||||
export interface UploadProps {
|
||||
uploadType?: string;
|
||||
accept?: string;
|
||||
fileChange: (file: File) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const Upload = ({
|
||||
fileChange,
|
||||
accept,
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}: PropsWithChildren<UploadProps>) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const input_ref = useRef<HTMLInputElement>(null);
|
||||
const _chooseFile = () => {
|
||||
if (input_ref.current) {
|
||||
input_ref.current.click();
|
||||
}
|
||||
};
|
||||
const _handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (!files) {
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
fileChange(file);
|
||||
if (input_ref.current) {
|
||||
input_ref.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
if (disabled) {
|
||||
return children ?? <Button>{t['Upload']()}</Button>;
|
||||
}
|
||||
|
||||
return (
|
||||
<UploadStyle onClick={_chooseFile}>
|
||||
{children ?? <Button>{t['Upload']()}</Button>}
|
||||
<input
|
||||
ref={input_ref}
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
onChange={_handleInputChange}
|
||||
accept={accept}
|
||||
{...props}
|
||||
/>
|
||||
</UploadStyle>
|
||||
);
|
||||
};
|
||||
|
||||
const UploadStyle = styled('div')(() => {
|
||||
return {
|
||||
display: 'inline-block',
|
||||
};
|
||||
});
|
||||
101
packages/frontend/core/src/components/pure/footer/index.tsx
Normal file
101
packages/frontend/core/src/components/pure/footer/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloudWorkspaceIcon } from '@blocksuite/icons';
|
||||
import { type CSSProperties, type FC, forwardRef, useCallback } from 'react';
|
||||
|
||||
import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status';
|
||||
import { stringToColour } from '../../../utils';
|
||||
import { signInCloud } from '../../../utils/cloud-utils';
|
||||
import { StyledFooter, StyledSignInButton } from './styles';
|
||||
|
||||
export const Footer: FC = () => {
|
||||
const loginStatus = useCurrentLoginStatus();
|
||||
|
||||
// const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
|
||||
return (
|
||||
<StyledFooter data-testid="workspace-list-modal-footer">
|
||||
{loginStatus === 'authenticated' ? null : <SignInButton />}
|
||||
</StyledFooter>
|
||||
);
|
||||
};
|
||||
|
||||
const SignInButton = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
return (
|
||||
<StyledSignInButton
|
||||
data-testid="sign-in-button"
|
||||
onClick={useCallback(() => {
|
||||
signInCloud().catch(console.error);
|
||||
}, [])}
|
||||
>
|
||||
<div className="circle">
|
||||
<CloudWorkspaceIcon />
|
||||
</div>
|
||||
|
||||
{t['Sign in']()}
|
||||
</StyledSignInButton>
|
||||
);
|
||||
};
|
||||
|
||||
interface WorkspaceAvatarProps {
|
||||
size: number;
|
||||
name: string | undefined;
|
||||
avatar: string | undefined;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
export const WorkspaceAvatar = forwardRef<HTMLDivElement, WorkspaceAvatarProps>(
|
||||
function WorkspaceAvatar(props, ref) {
|
||||
const size = props.size || 20;
|
||||
const sizeStr = size + 'px';
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.avatar ? (
|
||||
<div
|
||||
style={{
|
||||
...props.style,
|
||||
width: sizeStr,
|
||||
height: sizeStr,
|
||||
color: '#fff',
|
||||
borderRadius: '50%',
|
||||
overflow: 'hidden',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
<picture>
|
||||
<img
|
||||
style={{ width: sizeStr, height: sizeStr }}
|
||||
src={props.avatar}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
...props.style,
|
||||
width: sizeStr,
|
||||
height: sizeStr,
|
||||
border: '1px solid #fff',
|
||||
color: '#fff',
|
||||
fontSize: Math.ceil(0.5 * size) + 'px',
|
||||
background: stringToColour(props.name || 'AFFiNE'),
|
||||
borderRadius: '50%',
|
||||
textAlign: 'center',
|
||||
lineHeight: size + 'px',
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'middle',
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
{(props.name || 'AFFiNE').substring(0, 1)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
152
packages/frontend/core/src/components/pure/footer/styles.ts
Normal file
152
packages/frontend/core/src/components/pure/footer/styles.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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',
|
||||
width: '202px',
|
||||
p: {
|
||||
height: '20px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
...displayFlex('flex-start', 'center'),
|
||||
},
|
||||
svg: {
|
||||
marginRight: '10px',
|
||||
fontSize: '16px',
|
||||
flexShrink: 0,
|
||||
},
|
||||
span: {
|
||||
flexGrow: 1,
|
||||
...textEllipsis(1),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyleWorkspaceTitle = styled('div')(() => {
|
||||
return {
|
||||
fontSize: 'var(--affine-font-base)',
|
||||
fontWeight: 600,
|
||||
lineHeight: '24px',
|
||||
marginBottom: '10px',
|
||||
maxWidth: '200px',
|
||||
...textEllipsis(1),
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledFooter = styled('div')({
|
||||
padding: '20px 40px',
|
||||
flexShrink: 0,
|
||||
...displayFlex('space-between', 'center'),
|
||||
});
|
||||
|
||||
export const StyleUserInfo = styled('div')(() => {
|
||||
return {
|
||||
textAlign: 'left',
|
||||
marginLeft: '16px',
|
||||
flex: 1,
|
||||
p: {
|
||||
lineHeight: '24px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
},
|
||||
'p:first-of-type': {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontWeight: 600,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledModalHeaderLeft = styled('div')(() => {
|
||||
return { ...displayFlex('flex-start', 'center') };
|
||||
});
|
||||
export const StyledModalTitle = styled('div')(() => {
|
||||
return {
|
||||
fontWeight: 600,
|
||||
fontSize: 'var(--affine-font-h6)',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledHelperContainer = styled('div')(() => {
|
||||
return {
|
||||
color: 'var(--affine-icon-color)',
|
||||
marginLeft: '15px',
|
||||
fontWeight: 400,
|
||||
fontSize: 'var(--affine-font-h6)',
|
||||
...displayFlex('center', 'center'),
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledModalContent = styled('div')({
|
||||
height: '534px',
|
||||
padding: '8px 40px',
|
||||
marginTop: '72px',
|
||||
overflow: 'auto',
|
||||
...displayFlex('space-between', 'flex-start', 'flex-start'),
|
||||
flexWrap: 'wrap',
|
||||
});
|
||||
export const StyledOperationWrapper = styled('div')(() => {
|
||||
return {
|
||||
...displayFlex('flex-end', 'center'),
|
||||
};
|
||||
});
|
||||
|
||||
export const StyleWorkspaceAdd = styled('div')(() => {
|
||||
return {
|
||||
width: '58px',
|
||||
height: '58px',
|
||||
borderRadius: '100%',
|
||||
background: '#f4f5fa',
|
||||
border: '1.5px dashed #f4f5fa',
|
||||
transition: 'background .2s',
|
||||
...displayFlex('center', 'center'),
|
||||
};
|
||||
});
|
||||
export const StyledModalHeader = styled('div')(() => {
|
||||
return {
|
||||
width: '100%',
|
||||
height: '72px',
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
borderRadius: '24px 24px 0 0',
|
||||
padding: '0 40px',
|
||||
...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'),
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons';
|
||||
import {
|
||||
IconButton,
|
||||
type IconButtonProps,
|
||||
} from '@toeverything/components/button';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import { headerMenuTrigger } from './styles.css';
|
||||
|
||||
export const HeaderDropDownButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
Omit<IconButtonProps, 'children'>
|
||||
>((props, ref) => {
|
||||
return (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
{...props}
|
||||
data-testid="header-dropDownButton"
|
||||
className={headerMenuTrigger}
|
||||
withoutHoverStyle={true}
|
||||
type="plain"
|
||||
>
|
||||
<ArrowDownSmallIcon />
|
||||
</IconButton>
|
||||
);
|
||||
});
|
||||
|
||||
HeaderDropDownButton.displayName = 'HeaderDropDownButton';
|
||||
@@ -0,0 +1,10 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const headerMenuTrigger = style({});
|
||||
|
||||
globalStyle(`${headerMenuTrigger} svg`, {
|
||||
transition: 'transform 0.15s ease-in-out',
|
||||
});
|
||||
globalStyle(`${headerMenuTrigger}:hover svg`, {
|
||||
transform: 'translateY(3px)',
|
||||
});
|
||||
109
packages/frontend/core/src/components/pure/header/index.tsx
Normal file
109
packages/frontend/core/src/components/pure/header/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
appSidebarFloatingAtom,
|
||||
appSidebarOpenAtom,
|
||||
SidebarSwitch,
|
||||
} from '@affine/component/app-sidebar';
|
||||
import { useIsTinyScreen } from '@toeverything/hooks/use-is-tiny-screen';
|
||||
import clsx from 'clsx';
|
||||
import { type Atom, useAtomValue } from 'jotai';
|
||||
import type { ReactElement } from 'react';
|
||||
import { forwardRef, useRef } from 'react';
|
||||
|
||||
import * as style from './style.css';
|
||||
import { TopTip } from './top-tip';
|
||||
import { WindowsAppControls } from './windows-app-controls';
|
||||
|
||||
interface HeaderPros {
|
||||
left?: ReactElement;
|
||||
right?: ReactElement;
|
||||
center?: ReactElement;
|
||||
mainContainerAtom: Atom<HTMLDivElement | null>;
|
||||
}
|
||||
|
||||
// The Header component is used to solve the following problems
|
||||
// 1. Manage layout issues independently of page or business logic
|
||||
// 2. Dynamic centered middle element (relative to the main-container), when the middle element is detected to collide with the two elements, the line wrapping process is performed
|
||||
export const Header = forwardRef<HTMLDivElement, HeaderPros>(function Header(
|
||||
{ left, center, right, mainContainerAtom },
|
||||
ref
|
||||
) {
|
||||
const sidebarSwitchRef = useRef<HTMLDivElement | null>(null);
|
||||
const leftSlotRef = useRef<HTMLDivElement | null>(null);
|
||||
const centerSlotRef = useRef<HTMLDivElement | null>(null);
|
||||
const rightSlotRef = useRef<HTMLDivElement | null>(null);
|
||||
const windowControlsRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const mainContainer = useAtomValue(mainContainerAtom);
|
||||
|
||||
const isTinyScreen = useIsTinyScreen({
|
||||
mainContainer,
|
||||
leftStatic: sidebarSwitchRef,
|
||||
leftSlot: [leftSlotRef],
|
||||
centerDom: centerSlotRef,
|
||||
rightSlot: [rightSlotRef],
|
||||
rightStatic: windowControlsRef,
|
||||
});
|
||||
|
||||
const isWindowsDesktop = environment.isDesktop && environment.isWindows;
|
||||
const open = useAtomValue(appSidebarOpenAtom);
|
||||
const appSidebarFloating = useAtomValue(appSidebarFloatingAtom);
|
||||
return (
|
||||
<>
|
||||
<TopTip />
|
||||
<div
|
||||
className={style.header}
|
||||
// data-has-warning={showWarning}
|
||||
data-open={open}
|
||||
data-sidebar-floating={appSidebarFloating}
|
||||
data-testid="header"
|
||||
ref={ref}
|
||||
>
|
||||
<div
|
||||
className={clsx(style.headerSideContainer, {
|
||||
block: isTinyScreen,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
style.headerItem,
|
||||
'top-item',
|
||||
!open ? 'top-item-visible' : ''
|
||||
)}
|
||||
>
|
||||
<div ref={sidebarSwitchRef}>
|
||||
<SidebarSwitch show={!open} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx(style.headerItem, 'left')}>
|
||||
<div ref={leftSlotRef}>{left}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={clsx({
|
||||
[style.headerCenter]: center,
|
||||
'is-window': isWindowsDesktop,
|
||||
})}
|
||||
ref={centerSlotRef}
|
||||
>
|
||||
{center}
|
||||
</div>
|
||||
<div
|
||||
className={clsx(style.headerSideContainer, 'right', {
|
||||
block: isTinyScreen,
|
||||
})}
|
||||
>
|
||||
<div className={clsx(style.headerItem, 'top-item')}>
|
||||
<div ref={windowControlsRef}>
|
||||
{isWindowsDesktop ? <WindowsAppControls /> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={clsx(style.headerItem, 'right')}>
|
||||
<div ref={rightSlotRef}>{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
Header.displayName = 'Header';
|
||||
117
packages/frontend/core/src/components/pure/header/style.css.tsx
Normal file
117
packages/frontend/core/src/components/pure/header/style.css.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { ComplexStyleRule } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
position: 'relative',
|
||||
padding: '0 16px',
|
||||
minHeight: '52px',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
zIndex: 2,
|
||||
selectors: {
|
||||
'&[data-sidebar-floating="false"]': {
|
||||
WebkitAppRegion: 'drag',
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
print: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
':has([data-popper-placement])': {
|
||||
WebkitAppRegion: 'no-drag',
|
||||
},
|
||||
} as ComplexStyleRule);
|
||||
|
||||
export const headerItem = style({
|
||||
minHeight: '32px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
selectors: {
|
||||
'&.top-item': {
|
||||
height: '52px',
|
||||
},
|
||||
'&.top-item-visible': {
|
||||
marginRight: '20px',
|
||||
},
|
||||
'&.left': {
|
||||
justifyContent: 'left',
|
||||
},
|
||||
'&.right': {
|
||||
justifyContent: 'right',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const headerCenter = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '52px',
|
||||
flexShrink: 0,
|
||||
maxWidth: '60%',
|
||||
minWidth: '300px',
|
||||
position: 'absolute',
|
||||
transform: 'translateX(-50%)',
|
||||
left: '50%',
|
||||
zIndex: 1,
|
||||
selectors: {
|
||||
'&.is-window': {
|
||||
maxWidth: '50%',
|
||||
},
|
||||
'&.shadow': {
|
||||
position: 'static',
|
||||
visibility: 'hidden',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const headerSideContainer = style({
|
||||
display: 'flex',
|
||||
flexShrink: 0,
|
||||
alignItems: 'center',
|
||||
selectors: {
|
||||
'&.right': {
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
'&.block': {
|
||||
display: 'block',
|
||||
paddingBottom: '10px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const windowAppControlsWrapper = style({
|
||||
display: 'flex',
|
||||
marginLeft: '20px',
|
||||
// header padding right
|
||||
transform: 'translateX(16px)',
|
||||
});
|
||||
|
||||
export const windowAppControl = style({
|
||||
WebkitAppRegion: 'no-drag',
|
||||
cursor: 'pointer',
|
||||
display: 'inline-flex',
|
||||
width: '52px',
|
||||
height: '52px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '0',
|
||||
color: 'var(--affine-icon-color)',
|
||||
selectors: {
|
||||
'&[data-type="close"]': {
|
||||
width: '56px',
|
||||
paddingRight: '5px',
|
||||
},
|
||||
'&[data-type="close"]:hover': {
|
||||
background: 'var(--affine-windows-close-button)',
|
||||
color: 'var(--affine-pure-white)',
|
||||
},
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
} as ComplexStyleRule);
|
||||
@@ -0,0 +1,86 @@
|
||||
import { BrowserWarning } from '@affine/component/affine-banner';
|
||||
import { DownloadTips } from '@affine/component/affine-banner';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { guideDownloadClientTipAtom } from '../../../atoms/guide';
|
||||
|
||||
const minimumChromeVersion = 102;
|
||||
|
||||
const shouldShowWarning = () => {
|
||||
if (environment.isDesktop) {
|
||||
// even though desktop has compatibility issues,
|
||||
// we don't want to show the warning
|
||||
return false;
|
||||
}
|
||||
if (!environment.isBrowser) {
|
||||
// disable in SSR
|
||||
return false;
|
||||
}
|
||||
if (environment.isChrome) {
|
||||
return environment.chromeVersion < minimumChromeVersion;
|
||||
} else {
|
||||
return !environment.isMobile;
|
||||
}
|
||||
};
|
||||
|
||||
const OSWarningMessage = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [notChrome, setNotChrome] = useState(false);
|
||||
const [notGoodVersion, setNotGoodVersion] = useState(false);
|
||||
useEffect(() => {
|
||||
setNotChrome(environment.isBrowser && !environment.isChrome);
|
||||
setNotGoodVersion(
|
||||
environment.isBrowser &&
|
||||
environment.isChrome &&
|
||||
environment.chromeVersion < minimumChromeVersion
|
||||
);
|
||||
}, []);
|
||||
|
||||
if (notChrome) {
|
||||
return (
|
||||
<span>
|
||||
<Trans i18nKey="recommendBrowser">
|
||||
We recommend the <strong>Chrome</strong> browser for an optimal
|
||||
experience.
|
||||
</Trans>
|
||||
</span>
|
||||
);
|
||||
} else if (notGoodVersion) {
|
||||
return <span>{t['upgradeBrowser']()}</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
export const TopTip = () => {
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [showDownloadTip, setShowDownloadTip] = useAtom(
|
||||
guideDownloadClientTipAtom
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setShowWarning(shouldShowWarning());
|
||||
}, []);
|
||||
|
||||
if (showDownloadTip && environment.isDesktop) {
|
||||
return (
|
||||
<DownloadTips
|
||||
onClose={() => {
|
||||
setShowDownloadTip(false);
|
||||
localStorage.setItem('affine-is-dt-hide', '1');
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<BrowserWarning
|
||||
show={showWarning}
|
||||
message={<OSWarningMessage />}
|
||||
onClose={() => {
|
||||
setShowWarning(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,124 @@
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { atomWithObservable } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import * as style from './style.css';
|
||||
|
||||
const maximizedAtom = atomWithObservable(() => {
|
||||
return new Observable<boolean>(subscriber => {
|
||||
subscriber.next(false);
|
||||
return window.events?.ui.onMaximized(maximized => {
|
||||
return subscriber.next(maximized);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const minimizeSVG = (
|
||||
<svg
|
||||
width="10"
|
||||
height="1"
|
||||
viewBox="0 0 10 1"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M0.498047 1C0.429688 1 0.364583 0.986979 0.302734 0.960938C0.244141 0.934896 0.192057 0.899089 0.146484 0.853516C0.100911 0.807943 0.0651042 0.755859 0.0390625 0.697266C0.0130208 0.635417 0 0.570312 0 0.501953C0 0.433594 0.0130208 0.370117 0.0390625 0.311523C0.0651042 0.249674 0.100911 0.195964 0.146484 0.150391C0.192057 0.101562 0.244141 0.0641276 0.302734 0.0380859C0.364583 0.0120443 0.429688 -0.000976562 0.498047 -0.000976562H9.50195C9.57031 -0.000976562 9.63379 0.0120443 9.69238 0.0380859C9.75423 0.0641276 9.80794 0.101562 9.85352 0.150391C9.89909 0.195964 9.9349 0.249674 9.96094 0.311523C9.98698 0.370117 10 0.433594 10 0.501953C10 0.570312 9.98698 0.635417 9.96094 0.697266C9.9349 0.755859 9.89909 0.807943 9.85352 0.853516C9.80794 0.899089 9.75423 0.934896 9.69238 0.960938C9.63379 0.986979 9.57031 1 9.50195 1H0.498047Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const closeSVG = (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M5 5.70801L0.854492 9.85352C0.756836 9.95117 0.639648 10 0.50293 10C0.359701 10 0.239258 9.9528 0.141602 9.8584C0.0472005 9.76074 0 9.6403 0 9.49707C0 9.36035 0.0488281 9.24316 0.146484 9.14551L4.29199 5L0.146484 0.854492C0.0488281 0.756836 0 0.638021 0 0.498047C0 0.429688 0.0130208 0.364583 0.0390625 0.302734C0.0651042 0.240885 0.100911 0.188802 0.146484 0.146484C0.192057 0.100911 0.245768 0.0651042 0.307617 0.0390625C0.369466 0.0130208 0.43457 0 0.50293 0C0.639648 0 0.756836 0.0488281 0.854492 0.146484L5 4.29199L9.14551 0.146484C9.24316 0.0488281 9.36198 0 9.50195 0C9.57031 0 9.63379 0.0130208 9.69238 0.0390625C9.75423 0.0651042 9.80794 0.100911 9.85352 0.146484C9.89909 0.192057 9.9349 0.245768 9.96094 0.307617C9.98698 0.366211 10 0.429688 10 0.498047C10 0.638021 9.95117 0.756836 9.85352 0.854492L5.70801 5L9.85352 9.14551C9.95117 9.24316 10 9.36035 10 9.49707C10 9.56543 9.98698 9.63053 9.96094 9.69238C9.9349 9.75423 9.89909 9.80794 9.85352 9.85352C9.8112 9.89909 9.75911 9.9349 9.69727 9.96094C9.63542 9.98698 9.57031 10 9.50195 10C9.36198 10 9.24316 9.95117 9.14551 9.85352L5 5.70801Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const maximizeSVG = (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M1.47461 10C1.2793 10 1.09212 9.96094 0.913086 9.88281C0.734049 9.80143 0.576172 9.69401 0.439453 9.56055C0.30599 9.42383 0.198568 9.26595 0.117188 9.08691C0.0390625 8.90788 0 8.7207 0 8.52539V1.47461C0 1.2793 0.0390625 1.09212 0.117188 0.913086C0.198568 0.734049 0.30599 0.577799 0.439453 0.444336C0.576172 0.307617 0.734049 0.200195 0.913086 0.12207C1.09212 0.0406901 1.2793 0 1.47461 0H8.52539C8.7207 0 8.90788 0.0406901 9.08691 0.12207C9.26595 0.200195 9.4222 0.307617 9.55566 0.444336C9.69238 0.577799 9.7998 0.734049 9.87793 0.913086C9.95931 1.09212 10 1.2793 10 1.47461V8.52539C10 8.7207 9.95931 8.90788 9.87793 9.08691C9.7998 9.26595 9.69238 9.42383 9.55566 9.56055C9.4222 9.69401 9.26595 9.80143 9.08691 9.88281C8.90788 9.96094 8.7207 10 8.52539 10H1.47461ZM8.50098 8.99902C8.56934 8.99902 8.63281 8.986 8.69141 8.95996C8.75326 8.93392 8.80697 8.89811 8.85254 8.85254C8.89811 8.80697 8.93392 8.75488 8.95996 8.69629C8.986 8.63444 8.99902 8.56934 8.99902 8.50098V1.49902C8.99902 1.43066 8.986 1.36719 8.95996 1.30859C8.93392 1.24674 8.89811 1.19303 8.85254 1.14746C8.80697 1.10189 8.75326 1.06608 8.69141 1.04004C8.63281 1.014 8.56934 1.00098 8.50098 1.00098H1.49902C1.43066 1.00098 1.36556 1.014 1.30371 1.04004C1.24512 1.06608 1.19303 1.10189 1.14746 1.14746C1.10189 1.19303 1.06608 1.24674 1.04004 1.30859C1.014 1.36719 1.00098 1.43066 1.00098 1.49902V8.50098C1.00098 8.56934 1.014 8.63444 1.04004 8.69629C1.06608 8.75488 1.10189 8.80697 1.14746 8.85254C1.19303 8.89811 1.24512 8.93392 1.30371 8.95996C1.36556 8.986 1.43066 8.99902 1.49902 8.99902H8.50098Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
const unmaximizedSVG = (
|
||||
<svg
|
||||
width="10"
|
||||
height="10"
|
||||
viewBox="0 0 10 10"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8.99902 2.96387C8.99902 2.69368 8.94531 2.43978 8.83789 2.20215C8.73047 1.96126 8.58398 1.75293 8.39844 1.57715C8.21615 1.39811 8.00293 1.25814 7.75879 1.15723C7.5179 1.05306 7.264 1.00098 6.99707 1.00098H2.08496C2.13704 0.851237 2.21029 0.714518 2.30469 0.59082C2.39909 0.467122 2.50814 0.361328 2.63184 0.273438C2.75553 0.185547 2.89062 0.118815 3.03711 0.0732422C3.18685 0.0244141 3.34147 0 3.50098 0H6.99707C7.41048 0 7.79948 0.0797526 8.16406 0.239258C8.52865 0.395508 8.84603 0.608724 9.11621 0.878906C9.38965 1.14909 9.60449 1.46647 9.76074 1.83105C9.92025 2.19564 10 2.58464 10 2.99805V6.49902C10 6.65853 9.97559 6.81315 9.92676 6.96289C9.88118 7.10938 9.81445 7.24447 9.72656 7.36816C9.63867 7.49186 9.53288 7.60091 9.40918 7.69531C9.28548 7.78971 9.14876 7.86296 8.99902 7.91504V2.96387ZM1.47461 10C1.2793 10 1.09212 9.96094 0.913086 9.88281C0.734049 9.80143 0.576172 9.69401 0.439453 9.56055C0.30599 9.42383 0.198568 9.26595 0.117188 9.08691C0.0390625 8.90788 0 8.7207 0 8.52539V3.47656C0 3.27799 0.0390625 3.09082 0.117188 2.91504C0.198568 2.736 0.30599 2.57975 0.439453 2.44629C0.576172 2.30957 0.732422 2.20215 0.908203 2.12402C1.08724 2.04264 1.27604 2.00195 1.47461 2.00195H6.52344C6.72201 2.00195 6.91081 2.04264 7.08984 2.12402C7.26888 2.20215 7.42513 2.30794 7.55859 2.44141C7.69206 2.57487 7.79785 2.73112 7.87598 2.91016C7.95736 3.08919 7.99805 3.27799 7.99805 3.47656V8.52539C7.99805 8.72396 7.95736 8.91276 7.87598 9.0918C7.79785 9.26758 7.69043 9.42383 7.55371 9.56055C7.42025 9.69401 7.264 9.80143 7.08496 9.88281C6.90918 9.96094 6.72201 10 6.52344 10H1.47461ZM6.49902 8.99902C6.56738 8.99902 6.63086 8.986 6.68945 8.95996C6.7513 8.93392 6.80501 8.89811 6.85059 8.85254C6.89941 8.80697 6.93685 8.75488 6.96289 8.69629C6.98893 8.63444 7.00195 8.56934 7.00195 8.50098V3.50098C7.00195 3.43262 6.98893 3.36751 6.96289 3.30566C6.93685 3.24382 6.90104 3.1901 6.85547 3.14453C6.8099 3.09896 6.75619 3.06315 6.69434 3.03711C6.63249 3.01107 6.56738 2.99805 6.49902 2.99805H1.49902C1.43066 2.99805 1.36556 3.01107 1.30371 3.03711C1.24512 3.06315 1.19303 3.10059 1.14746 3.14941C1.10189 3.19499 1.06608 3.2487 1.04004 3.31055C1.014 3.36914 1.00098 3.43262 1.00098 3.50098V8.50098C1.00098 8.56934 1.014 8.63444 1.04004 8.69629C1.06608 8.75488 1.10189 8.80697 1.14746 8.85254C1.19303 8.89811 1.24512 8.93392 1.30371 8.95996C1.36556 8.986 1.43066 8.99902 1.49902 8.99902H6.49902Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export const WindowsAppControls = () => {
|
||||
const handleMinimizeApp = useCallback(() => {
|
||||
window.apis?.ui.handleMinimizeApp().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, []);
|
||||
const handleMaximizeApp = useCallback(() => {
|
||||
window.apis?.ui.handleMaximizeApp().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, []);
|
||||
const handleCloseApp = useCallback(() => {
|
||||
window.apis?.ui.handleCloseApp().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const maximized = useAtomValue(maximizedAtom);
|
||||
|
||||
return (
|
||||
<div
|
||||
data-platform-target="win32"
|
||||
className={style.windowAppControlsWrapper}
|
||||
>
|
||||
<button
|
||||
data-type="minimize"
|
||||
className={style.windowAppControl}
|
||||
onClick={handleMinimizeApp}
|
||||
>
|
||||
{minimizeSVG}
|
||||
</button>
|
||||
<button
|
||||
data-type="maximize"
|
||||
className={style.windowAppControl}
|
||||
onClick={handleMaximizeApp}
|
||||
>
|
||||
{maximized ? unmaximizedSVG : maximizeSVG}
|
||||
</button>
|
||||
<button
|
||||
data-type="close"
|
||||
className={style.windowAppControl}
|
||||
onClick={handleCloseApp}
|
||||
>
|
||||
{closeSVG}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
export const HelpIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.0041 3.8002C7.47536 3.8002 3.8041 7.47146 3.8041 12.0002C3.8041 13.311 4.1111 14.5479 4.65639 15.6449C4.92133 16.1779 4.91348 16.8471 4.85228 17.3998C4.7869 17.9901 4.63749 18.6238 4.47572 19.1908C4.3722 19.5537 4.26086 19.8987 4.1573 20.2002H12.0041C16.5328 20.2002 20.2041 16.5289 20.2041 12.0002C20.2041 7.47146 16.5328 3.8002 12.0041 3.8002ZM2.26631 20.6926L2.2668 20.6914L2.26948 20.685L2.28104 20.6567C2.29139 20.6312 2.30687 20.5928 2.32657 20.5429C2.36599 20.4431 2.42218 20.2979 2.48795 20.1192C2.61992 19.7607 2.78833 19.2734 2.93712 18.7519C3.08715 18.226 3.21054 17.6884 3.262 17.2236C3.31765 16.7212 3.2707 16.4518 3.22364 16.3571C2.57081 15.0438 2.2041 13.5637 2.2041 12.0002C2.2041 6.58781 6.59171 2.2002 12.0041 2.2002C17.4165 2.2002 21.8041 6.5878 21.8041 12.0002C21.8041 17.4126 17.4165 21.8002 12.0041 21.8002H3.0049C2.73745 21.8002 2.4876 21.6665 2.33922 21.444C2.19087 21.2215 2.16356 20.9395 2.26631 20.6926ZM11.9672 9.0502C11.4091 9.0502 10.9382 9.43186 10.8049 9.9496C10.6948 10.3775 10.2587 10.6351 9.83079 10.5249C9.40291 10.4148 9.14532 9.97867 9.25545 9.55079C9.56623 8.3433 10.6614 7.4502 11.9672 7.4502C13.5136 7.4502 14.7672 8.7038 14.7672 10.2502C14.7672 11.1058 14.3536 11.6751 13.8978 12.115C13.7108 12.2955 13.4978 12.4721 13.2997 12.6362C13.2705 12.6604 13.2416 12.6844 13.2132 12.7081C12.982 12.9004 12.7556 13.0932 12.5329 13.3159C12.2205 13.6283 11.7139 13.6283 11.4015 13.3159C11.0891 13.0035 11.0891 12.4969 11.4015 12.1845C11.6788 11.9072 11.9525 11.6756 12.19 11.478C12.2213 11.4519 12.2517 11.4267 12.2812 11.4022C12.4849 11.2332 12.6465 11.0991 12.7866 10.9638C13.0808 10.6799 13.1672 10.4992 13.1672 10.2502C13.1672 9.58745 12.6299 9.0502 11.9672 9.0502ZM11.9772 16.5502H11.9672C11.5254 16.5502 11.1672 16.192 11.1672 15.7502C11.1672 15.3084 11.5254 14.9502 11.9672 14.9502H11.9772C12.419 14.9502 12.7772 15.3084 12.7772 15.7502C12.7772 16.192 12.419 16.5502 11.9772 16.5502Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const ContactIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.16957 3.25H14.8304C15.3646 3.24999 15.8104 3.24998 16.1747 3.27974C16.5546 3.31078 16.9112 3.37789 17.2485 3.54973C17.7659 3.81338 18.1866 4.23408 18.4503 4.75153C18.6221 5.08879 18.6892 5.44545 18.7203 5.82533C18.75 6.18956 18.75 6.6354 18.75 7.16955V8.36732C18.9209 8.40912 19.0869 8.46741 19.2485 8.54973C19.7659 8.81339 20.1866 9.23408 20.4503 9.75153C20.6221 10.0888 20.6892 10.4454 20.7203 10.8253C20.75 11.1896 20.75 11.6354 20.75 12.1695V20C20.75 20.2766 20.5978 20.5307 20.3539 20.6613C20.11 20.7918 19.8141 20.7775 19.584 20.624L17.3901 19.1615C17.069 18.9474 16.9819 18.8925 16.8945 18.8544C16.8046 18.8151 16.7104 18.7866 16.6138 18.7694C16.52 18.7527 16.4171 18.75 16.0311 18.75H11.1695C10.6354 18.75 10.1896 18.75 9.82533 18.7203C9.44545 18.6892 9.08879 18.6221 8.75153 18.4503C8.23408 18.1866 7.81339 17.7659 7.54973 17.2485C7.52446 17.1989 7.50144 17.1488 7.48046 17.0983L4.33541 18.6708C4.10292 18.7871 3.82681 18.7746 3.6057 18.638C3.38459 18.5013 3.25 18.2599 3.25 18L3.25 7.16957C3.24999 6.63541 3.24998 6.18956 3.27974 5.82533C3.31078 5.44545 3.37789 5.08879 3.54973 4.75153C3.81338 4.23408 4.23408 3.81338 4.75153 3.54973C5.08879 3.37789 5.44545 3.31078 5.82533 3.27974C6.18956 3.24998 6.63541 3.24999 7.16957 3.25ZM7.25322 15.5349C7.25 15.318 7.25 15.0803 7.25 14.8211L7.25 12.1696C7.24999 11.6354 7.24998 11.1896 7.27974 10.8253C7.31078 10.4454 7.37789 10.0888 7.54973 9.75153C7.81339 9.23408 8.23408 8.81339 8.75153 8.54973C9.08879 8.37789 9.44545 8.31078 9.82533 8.27974C10.1896 8.24998 10.6354 8.24999 11.1696 8.25H16.8304C16.9769 8.25 17.1167 8.24999 17.25 8.25061V7.2C17.25 6.62757 17.2494 6.24336 17.2252 5.94748C17.2018 5.66036 17.1599 5.52307 17.1138 5.43251C16.9939 5.19731 16.8027 5.00608 16.5675 4.88624C16.4769 4.8401 16.3396 4.79822 16.0525 4.77476C15.7566 4.75058 15.3724 4.75 14.8 4.75H7.2C6.62757 4.75 6.24336 4.75058 5.94748 4.77476C5.66036 4.79822 5.52307 4.8401 5.43251 4.88624C5.19731 5.00608 5.00608 5.19731 4.88624 5.43251C4.8401 5.52307 4.79822 5.66036 4.77476 5.94748C4.75058 6.24336 4.75 6.62757 4.75 7.2V16.7865L7.25322 15.5349ZM9.94748 9.77476C9.66036 9.79822 9.52307 9.8401 9.43251 9.88624C9.19731 10.0061 9.00608 10.1973 8.88624 10.4325C8.8401 10.5231 8.79822 10.6604 8.77476 10.9475C8.75058 11.2434 8.75 11.6276 8.75 12.2V14.8C8.75 15.3018 8.7503 15.6608 8.7673 15.9461C8.78877 16.3064 8.83405 16.465 8.88624 16.5675C9.00608 16.8027 9.19731 16.9939 9.43251 17.1138C9.52307 17.1599 9.66036 17.2018 9.94748 17.2252C10.2434 17.2494 10.6276 17.25 11.2 17.25H16.0311C16.049 17.25 16.0666 17.25 16.084 17.25C16.3929 17.2499 16.6362 17.2497 16.877 17.2927C17.0895 17.3306 17.2968 17.3933 17.4947 17.4797C17.7189 17.5775 17.9213 17.7126 18.1782 17.884C18.1927 17.8937 18.2073 17.9035 18.2222 17.9134L19.25 18.5986V12.2C19.25 11.6276 19.2494 11.2434 19.2252 10.9475C19.2018 10.6604 19.1599 10.5231 19.1138 10.4325C18.9939 10.1973 18.8027 10.0061 18.5675 9.88624C18.4769 9.8401 18.3396 9.79822 18.0525 9.77476C17.7566 9.75058 17.3724 9.75 16.8 9.75H11.2C10.6276 9.75 10.2434 9.75058 9.94748 9.77476Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeyboardIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="25"
|
||||
viewBox="0 0 24 25"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M19.745 5C20.3417 5 20.914 5.23705 21.336 5.65901C21.7579 6.08097 21.995 6.65326 21.995 7.25V16.755C21.995 17.3517 21.7579 17.924 21.336 18.346C20.914 18.7679 20.3417 19.005 19.745 19.005H4.25C3.95453 19.005 3.66194 18.9468 3.38896 18.8337C3.11598 18.7207 2.86794 18.5549 2.65901 18.346C2.45008 18.1371 2.28434 17.889 2.17127 17.616C2.0582 17.3431 2 17.0505 2 16.755V7.25C2 6.65326 2.23705 6.08097 2.65901 5.65901C3.08097 5.23705 3.65326 5 4.25 5H19.745ZM19.745 6.5H4.25C4.05109 6.5 3.86032 6.57902 3.71967 6.71967C3.57902 6.86032 3.5 7.05109 3.5 7.25V16.755C3.5 17.169 3.836 17.505 4.25 17.505H19.745C19.9439 17.505 20.1347 17.426 20.2753 17.2853C20.416 17.1447 20.495 16.9539 20.495 16.755V7.25C20.495 7.05109 20.416 6.86032 20.2753 6.71967C20.1347 6.57902 19.9439 6.5 19.745 6.5ZM6.75 14.5H17.25C17.44 14.5001 17.6229 14.5722 17.7618 14.702C17.9006 14.8317 17.9851 15.0093 17.998 15.1989C18.011 15.3885 17.9515 15.5759 17.8316 15.7233C17.7117 15.8707 17.5402 15.9671 17.352 15.993L17.25 16H6.75C6.55998 15.9999 6.37706 15.9278 6.23821 15.798C6.09936 15.6683 6.01493 15.4907 6.00197 15.3011C5.98902 15.1115 6.04852 14.9241 6.16843 14.7767C6.28835 14.6293 6.45975 14.5329 6.648 14.507L6.75 14.5H17.25H6.75ZM16.5 11C16.7652 11 17.0196 11.1054 17.2071 11.2929C17.3946 11.4804 17.5 11.7348 17.5 12C17.5 12.2652 17.3946 12.5196 17.2071 12.7071C17.0196 12.8946 16.7652 13 16.5 13C16.2348 13 15.9804 12.8946 15.7929 12.7071C15.6054 12.5196 15.5 12.2652 15.5 12C15.5 11.7348 15.6054 11.4804 15.7929 11.2929C15.9804 11.1054 16.2348 11 16.5 11ZM10.505 11C10.7702 11 11.0246 11.1054 11.2121 11.2929C11.3996 11.4804 11.505 11.7348 11.505 12C11.505 12.2652 11.3996 12.5196 11.2121 12.7071C11.0246 12.8946 10.7702 13 10.505 13C10.2398 13 9.98543 12.8946 9.79789 12.7071C9.61036 12.5196 9.505 12.2652 9.505 12C9.505 11.7348 9.61036 11.4804 9.79789 11.2929C9.98543 11.1054 10.2398 11 10.505 11ZM7.505 11C7.77022 11 8.02457 11.1054 8.21211 11.2929C8.39964 11.4804 8.505 11.7348 8.505 12C8.505 12.2652 8.39964 12.5196 8.21211 12.7071C8.02457 12.8946 7.77022 13 7.505 13C7.23978 13 6.98543 12.8946 6.79789 12.7071C6.61036 12.5196 6.505 12.2652 6.505 12C6.505 11.7348 6.61036 11.4804 6.79789 11.2929C6.98543 11.1054 7.23978 11 7.505 11ZM13.505 11C13.7702 11 14.0246 11.1054 14.2121 11.2929C14.3996 11.4804 14.505 11.7348 14.505 12C14.505 12.2652 14.3996 12.5196 14.2121 12.7071C14.0246 12.8946 13.7702 13 13.505 13C13.2398 13 12.9854 12.8946 12.7979 12.7071C12.6104 12.5196 12.505 12.2652 12.505 12C12.505 11.7348 12.6104 11.4804 12.7979 11.2929C12.9854 11.1054 13.2398 11 13.505 11ZM6 8C6.26522 8 6.51957 8.10536 6.70711 8.29289C6.89464 8.48043 7 8.73478 7 9C7 9.26522 6.89464 9.51957 6.70711 9.70711C6.51957 9.89464 6.26522 10 6 10C5.73478 10 5.48043 9.89464 5.29289 9.70711C5.10536 9.51957 5 9.26522 5 9C5 8.73478 5.10536 8.48043 5.29289 8.29289C5.48043 8.10536 5.73478 8 6 8ZM8.995 8C9.26022 8 9.51457 8.10536 9.70211 8.29289C9.88964 8.48043 9.995 8.73478 9.995 9C9.995 9.26522 9.88964 9.51957 9.70211 9.70711C9.51457 9.89464 9.26022 10 8.995 10C8.72978 10 8.47543 9.89464 8.28789 9.70711C8.10036 9.51957 7.995 9.26522 7.995 9C7.995 8.73478 8.10036 8.48043 8.28789 8.29289C8.47543 8.10536 8.72978 8 8.995 8ZM11.995 8C12.2602 8 12.5146 8.10536 12.7021 8.29289C12.8896 8.48043 12.995 8.73478 12.995 9C12.995 9.26522 12.8896 9.51957 12.7021 9.70711C12.5146 9.89464 12.2602 10 11.995 10C11.7298 10 11.4754 9.89464 11.2879 9.70711C11.1004 9.51957 10.995 9.26522 10.995 9C10.995 8.73478 11.1004 8.48043 11.2879 8.29289C11.4754 8.10536 11.7298 8 11.995 8ZM14.995 8C15.2602 8 15.5146 8.10536 15.7021 8.29289C15.8896 8.48043 15.995 8.73478 15.995 9C15.995 9.26522 15.8896 9.51957 15.7021 9.70711C15.5146 9.89464 15.2602 10 14.995 10C14.7298 10 14.4754 9.89464 14.2879 9.70711C14.1004 9.51957 13.995 9.26522 13.995 9C13.995 8.73478 14.1004 8.48043 14.2879 8.29289C14.4754 8.10536 14.7298 8 14.995 8ZM17.995 8C18.2602 8 18.5146 8.10536 18.7021 8.29289C18.8896 8.48043 18.995 8.73478 18.995 9C18.995 9.26522 18.8896 9.51957 18.7021 9.70711C18.5146 9.89464 18.2602 10 17.995 10C17.7298 10 17.4754 9.89464 17.2879 9.70711C17.1004 9.51957 16.995 9.26522 16.995 9C16.995 8.73478 17.1004 8.48043 17.2879 8.29289C17.4754 8.10536 17.7298 8 17.995 8Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
148
packages/frontend/core/src/components/pure/help-island/index.tsx
Normal file
148
packages/frontend/core/src/components/pure/help-island/index.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
import { MuiFade } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon, NewIcon, UserGuideIcon } from '@blocksuite/icons';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import { useSetAtom } from 'jotai/react';
|
||||
import { useAtomValue } from 'jotai/react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { openOnboardingModalAtom, openSettingModalAtom } from '../../../atoms';
|
||||
import { currentModeAtom } from '../../../atoms/mode';
|
||||
import { ShortcutsModal } from '../shortcuts-modal';
|
||||
import { ContactIcon, HelpIcon, KeyboardIcon } from './icons';
|
||||
import {
|
||||
StyledAnimateWrapper,
|
||||
StyledIconWrapper,
|
||||
StyledIsland,
|
||||
StyledTriggerWrapper,
|
||||
} from './style';
|
||||
|
||||
const DEFAULT_SHOW_LIST: IslandItemNames[] = [
|
||||
'whatNew',
|
||||
'contact',
|
||||
'shortcuts',
|
||||
];
|
||||
const DESKTOP_SHOW_LIST: IslandItemNames[] = [...DEFAULT_SHOW_LIST, 'guide'];
|
||||
export type IslandItemNames = 'whatNew' | 'contact' | 'shortcuts' | 'guide';
|
||||
|
||||
export const HelpIsland = ({
|
||||
showList = environment.isDesktop ? DESKTOP_SHOW_LIST : DEFAULT_SHOW_LIST,
|
||||
}: {
|
||||
showList?: IslandItemNames[];
|
||||
}) => {
|
||||
const mode = useAtomValue(currentModeAtom);
|
||||
const setOpenOnboarding = useSetAtom(openOnboardingModalAtom);
|
||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||
const [spread, setShowSpread] = useState(false);
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const [openShortCut, setOpenShortCut] = useState(false);
|
||||
|
||||
const openAbout = useCallback(() => {
|
||||
setShowSpread(false);
|
||||
|
||||
setOpenSettingModalAtom({
|
||||
open: true,
|
||||
activeTab: 'about',
|
||||
workspaceId: null,
|
||||
});
|
||||
}, [setOpenSettingModalAtom]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StyledIsland
|
||||
spread={spread}
|
||||
data-testid="help-island"
|
||||
onClick={() => {
|
||||
setShowSpread(!spread);
|
||||
}}
|
||||
inEdgelessPage={mode === 'edgeless'}
|
||||
>
|
||||
<StyledAnimateWrapper
|
||||
style={{ height: spread ? `${showList.length * 40 + 4}px` : 0 }}
|
||||
>
|
||||
{showList.includes('whatNew') && (
|
||||
<Tooltip
|
||||
content={t['com.affine.appUpdater.whatsNew']()}
|
||||
side="left"
|
||||
>
|
||||
<StyledIconWrapper
|
||||
data-testid="right-bottom-change-log-icon"
|
||||
onClick={() => {
|
||||
window.open(runtimeConfig.changelogUrl, '_blank');
|
||||
}}
|
||||
>
|
||||
<NewIcon />
|
||||
</StyledIconWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showList.includes('contact') && (
|
||||
<Tooltip
|
||||
content={t['com.affine.helpIsland.contactUs']()}
|
||||
side="left"
|
||||
>
|
||||
<StyledIconWrapper
|
||||
data-testid="right-bottom-contact-us-icon"
|
||||
onClick={openAbout}
|
||||
>
|
||||
<ContactIcon />
|
||||
</StyledIconWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showList.includes('shortcuts') && (
|
||||
<Tooltip
|
||||
content={t['com.affine.keyboardShortcuts.title']()}
|
||||
side="left"
|
||||
>
|
||||
<StyledIconWrapper
|
||||
data-testid="shortcuts-icon"
|
||||
onClick={() => {
|
||||
setShowSpread(false);
|
||||
setOpenShortCut(true);
|
||||
}}
|
||||
>
|
||||
<KeyboardIcon />
|
||||
</StyledIconWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showList.includes('guide') && (
|
||||
<Tooltip
|
||||
content={t['com.affine.helpIsland.gettingStarted']()}
|
||||
side="left"
|
||||
>
|
||||
<StyledIconWrapper
|
||||
data-testid="easy-guide"
|
||||
onClick={() => {
|
||||
setShowSpread(false);
|
||||
setOpenOnboarding(true);
|
||||
}}
|
||||
>
|
||||
<UserGuideIcon />
|
||||
</StyledIconWrapper>
|
||||
</Tooltip>
|
||||
)}
|
||||
</StyledAnimateWrapper>
|
||||
|
||||
<Tooltip
|
||||
content={t['com.affine.helpIsland.helpAndFeedback']()}
|
||||
side="left"
|
||||
>
|
||||
<MuiFade in={!spread} data-testid="faq-icon">
|
||||
<StyledTriggerWrapper>
|
||||
<HelpIcon />
|
||||
</StyledTriggerWrapper>
|
||||
</MuiFade>
|
||||
</Tooltip>
|
||||
<MuiFade in={spread}>
|
||||
<StyledTriggerWrapper spread>
|
||||
<CloseIcon />
|
||||
</StyledTriggerWrapper>
|
||||
</MuiFade>
|
||||
</StyledIsland>
|
||||
<ShortcutsModal
|
||||
open={openShortCut}
|
||||
onClose={() => setOpenShortCut(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,79 @@
|
||||
import { displayFlex, positionAbsolute, styled } from '@affine/component';
|
||||
|
||||
export const StyledIsland = styled('div')<{
|
||||
spread: boolean;
|
||||
inEdgelessPage?: boolean;
|
||||
}>(({ spread, inEdgelessPage }) => {
|
||||
return {
|
||||
transition: 'box-shadow 0.2s',
|
||||
width: '44px',
|
||||
position: 'relative',
|
||||
boxShadow: spread
|
||||
? 'var(--affine-menu-shadow)'
|
||||
: inEdgelessPage
|
||||
? 'var(--affine-menu-shadow)'
|
||||
: 'unset',
|
||||
padding: '0 4px 44px',
|
||||
borderRadius: '10px',
|
||||
background: spread
|
||||
? 'var(--affine-background-overlay-panel-color)'
|
||||
: 'var(--affine-background-primary-color)',
|
||||
':hover': {
|
||||
background: spread ? null : 'var(--affine-white)',
|
||||
boxShadow: spread ? null : 'var(--affine-menu-shadow)',
|
||||
},
|
||||
'::after': {
|
||||
content: '""',
|
||||
width: '36px',
|
||||
height: '1px',
|
||||
background: spread ? 'var(--affine-border-color)' : 'transparent',
|
||||
...positionAbsolute({
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: '44px',
|
||||
}),
|
||||
margin: 'auto',
|
||||
transition: 'background 0.15s',
|
||||
},
|
||||
};
|
||||
});
|
||||
export const StyledIconWrapper = styled('div')(() => {
|
||||
return {
|
||||
color: 'var(--affine-icon-color)',
|
||||
...displayFlex('center', 'center'),
|
||||
cursor: 'pointer',
|
||||
fontSize: '24px',
|
||||
borderRadius: '5px',
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
margin: '4px auto 4px',
|
||||
transition: 'background-color 0.2s',
|
||||
position: 'relative',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledAnimateWrapper = styled('div')(() => ({
|
||||
transition: 'height 0.2s cubic-bezier(0, 0, 0.55, 1.6)',
|
||||
overflow: 'hidden',
|
||||
}));
|
||||
|
||||
export const StyledTriggerWrapper = styled('div')<{
|
||||
spread?: boolean;
|
||||
}>(({ spread }) => {
|
||||
return {
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
cursor: 'pointer',
|
||||
color: 'var(--affine-icon-color)',
|
||||
borderRadius: '5px',
|
||||
fontSize: '24px',
|
||||
...displayFlex('center', 'center'),
|
||||
...positionAbsolute({ left: '4px', bottom: '4px' }),
|
||||
':hover': {
|
||||
backgroundColor: spread ? 'var(--affine-hover-color)' : null,
|
||||
},
|
||||
};
|
||||
});
|
||||
28
packages/frontend/core/src/components/pure/icons/index.tsx
Normal file
28
packages/frontend/core/src/components/pure/icons/index.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
CloudWorkspaceIcon as DefaultCloudWorkspaceIcon,
|
||||
CollaborationIcon as DefaultJoinedWorkspaceIcon,
|
||||
LocalDataIcon as DefaultLocalDataIcon,
|
||||
LocalWorkspaceIcon as DefaultLocalWorkspaceIcon,
|
||||
PublishIcon as DefaultPublishIcon,
|
||||
} from '@blocksuite/icons';
|
||||
|
||||
// Here are some icons with special color or size
|
||||
|
||||
export const JoinedWorkspaceIcon = () => {
|
||||
return <DefaultJoinedWorkspaceIcon style={{ color: '#FF646B' }} />;
|
||||
};
|
||||
export const LocalWorkspaceIcon = () => {
|
||||
return <DefaultLocalWorkspaceIcon style={{ color: '#FDBD32' }} />;
|
||||
};
|
||||
|
||||
export const CloudWorkspaceIcon = () => {
|
||||
return <DefaultCloudWorkspaceIcon style={{ color: '#60A5FA' }} />;
|
||||
};
|
||||
|
||||
export const LocalDataIcon = () => {
|
||||
return <DefaultLocalDataIcon style={{ color: '#62CD80' }} />;
|
||||
};
|
||||
|
||||
export const PublishIcon = () => {
|
||||
return <DefaultPublishIcon style={{ color: '#8699FF' }} />;
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
addCleanup,
|
||||
pluginHeaderItemAtom,
|
||||
} from '@toeverything/infra/__internal__/plugin';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { startTransition, useCallback, useRef } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
export const PluginHeader = () => {
|
||||
const headerItem = useAtomValue(pluginHeaderItemAtom);
|
||||
const pluginsRef = useRef<string[]>([]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.pluginHeaderItems}
|
||||
ref={useCallback(
|
||||
(root: HTMLDivElement | null) => {
|
||||
if (root) {
|
||||
Object.entries(headerItem).forEach(([pluginName, create]) => {
|
||||
if (pluginsRef.current.includes(pluginName)) {
|
||||
return;
|
||||
}
|
||||
pluginsRef.current.push(pluginName);
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('plugin-id', pluginName);
|
||||
startTransition(() => {
|
||||
const cleanup = create(div);
|
||||
root.appendChild(div);
|
||||
addCleanup(pluginName, () => {
|
||||
pluginsRef.current = pluginsRef.current.filter(
|
||||
name => name !== pluginName
|
||||
);
|
||||
root.removeChild(div);
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
[headerItem]
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const pluginHeaderItems = style({
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'center',
|
||||
height: '100%',
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
export const CloseIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 14 14"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M7.94 7.00014L13.4667 1.47348C13.5759 1.34594 13.633 1.18189 13.6265 1.01411C13.62 0.846324 13.5504 0.687165 13.4317 0.568435C13.313 0.449706 13.1538 0.38015 12.986 0.37367C12.8183 0.367189 12.6542 0.42426 12.5267 0.533477L7 6.06014L1.47334 0.526811C1.3478 0.401275 1.17754 0.33075 1 0.33075C0.822468 0.33075 0.652205 0.401275 0.526669 0.526811C0.401133 0.652346 0.330608 0.82261 0.330608 1.00014C0.330608 1.17768 0.401133 1.34794 0.526669 1.47348L6.06 7.00014L0.526669 12.5268C0.456881 12.5866 0.400201 12.6601 0.360186 12.7428C0.32017 12.8255 0.297683 12.9156 0.294137 13.0074C0.290591 13.0993 0.306061 13.1908 0.339577 13.2764C0.373094 13.3619 0.423932 13.4396 0.488902 13.5046C0.553872 13.5695 0.63157 13.6204 0.71712 13.6539C0.80267 13.6874 0.894225 13.7029 0.986038 13.6993C1.07785 13.6958 1.16794 13.6733 1.25065 13.6333C1.33336 13.5933 1.4069 13.5366 1.46667 13.4668L7 7.94014L12.5267 13.4668C12.6542 13.576 12.8183 13.6331 12.986 13.6266C13.1538 13.6201 13.313 13.5506 13.4317 13.4319C13.5504 13.3131 13.62 13.154 13.6265 12.9862C13.633 12.8184 13.5759 12.6543 13.4667 12.5268L7.94 7.00014Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export const KeyboardIcon = () => {
|
||||
return (
|
||||
<svg
|
||||
width="20"
|
||||
height="15"
|
||||
viewBox="0 0 20 15"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path d="M17.745 0C18.3417 0 18.914 0.237053 19.336 0.65901C19.7579 1.08097 19.995 1.65326 19.995 2.25V11.755C19.995 12.3517 19.7579 12.924 19.336 13.346C18.914 13.7679 18.3417 14.005 17.745 14.005H2.25C1.95453 14.005 1.66194 13.9468 1.38896 13.8337C1.11598 13.7207 0.867941 13.5549 0.65901 13.346C0.450078 13.1371 0.284344 12.889 0.171271 12.616C0.058198 12.3431 0 12.0505 0 11.755V2.25C0 1.65326 0.237053 1.08097 0.65901 0.65901C1.08097 0.237053 1.65326 0 2.25 0H17.745ZM17.745 1.5H2.25C2.05109 1.5 1.86032 1.57902 1.71967 1.71967C1.57902 1.86032 1.5 2.05109 1.5 2.25V11.755C1.5 12.169 1.836 12.505 2.25 12.505H17.745C17.9439 12.505 18.1347 12.426 18.2753 12.2853C18.416 12.1447 18.495 11.9539 18.495 11.755V2.25C18.495 2.05109 18.416 1.86032 18.2753 1.71967C18.1347 1.57902 17.9439 1.5 17.745 1.5ZM4.75 9.5H15.25C15.44 9.50006 15.6229 9.57224 15.7618 9.70197C15.9006 9.8317 15.9851 10.0093 15.998 10.1989C16.011 10.3885 15.9515 10.5759 15.8316 10.7233C15.7117 10.8707 15.5402 10.9671 15.352 10.993L15.25 11H4.75C4.55998 10.9999 4.37706 10.9278 4.23821 10.798C4.09936 10.6683 4.01493 10.4907 4.00197 10.3011C3.98902 10.1115 4.04852 9.92411 4.16843 9.7767C4.28835 9.62929 4.45975 9.5329 4.648 9.507L4.75 9.5H15.25H4.75ZM14.5 6C14.7652 6 15.0196 6.10536 15.2071 6.29289C15.3946 6.48043 15.5 6.73478 15.5 7C15.5 7.26522 15.3946 7.51957 15.2071 7.70711C15.0196 7.89464 14.7652 8 14.5 8C14.2348 8 13.9804 7.89464 13.7929 7.70711C13.6054 7.51957 13.5 7.26522 13.5 7C13.5 6.73478 13.6054 6.48043 13.7929 6.29289C13.9804 6.10536 14.2348 6 14.5 6ZM8.505 6C8.77022 6 9.02457 6.10536 9.21211 6.29289C9.39964 6.48043 9.505 6.73478 9.505 7C9.505 7.26522 9.39964 7.51957 9.21211 7.70711C9.02457 7.89464 8.77022 8 8.505 8C8.23978 8 7.98543 7.89464 7.79789 7.70711C7.61036 7.51957 7.505 7.26522 7.505 7C7.505 6.73478 7.61036 6.48043 7.79789 6.29289C7.98543 6.10536 8.23978 6 8.505 6ZM5.505 6C5.77022 6 6.02457 6.10536 6.21211 6.29289C6.39964 6.48043 6.505 6.73478 6.505 7C6.505 7.26522 6.39964 7.51957 6.21211 7.70711C6.02457 7.89464 5.77022 8 5.505 8C5.23978 8 4.98543 7.89464 4.79789 7.70711C4.61036 7.51957 4.505 7.26522 4.505 7C4.505 6.73478 4.61036 6.48043 4.79789 6.29289C4.98543 6.10536 5.23978 6 5.505 6ZM11.505 6C11.7702 6 12.0246 6.10536 12.2121 6.29289C12.3996 6.48043 12.505 6.73478 12.505 7C12.505 7.26522 12.3996 7.51957 12.2121 7.70711C12.0246 7.89464 11.7702 8 11.505 8C11.2398 8 10.9854 7.89464 10.7979 7.70711C10.6104 7.51957 10.505 7.26522 10.505 7C10.505 6.73478 10.6104 6.48043 10.7979 6.29289C10.9854 6.10536 11.2398 6 11.505 6ZM4 3C4.26522 3 4.51957 3.10536 4.70711 3.29289C4.89464 3.48043 5 3.73478 5 4C5 4.26522 4.89464 4.51957 4.70711 4.70711C4.51957 4.89464 4.26522 5 4 5C3.73478 5 3.48043 4.89464 3.29289 4.70711C3.10536 4.51957 3 4.26522 3 4C3 3.73478 3.10536 3.48043 3.29289 3.29289C3.48043 3.10536 3.73478 3 4 3ZM6.995 3C7.26022 3 7.51457 3.10536 7.70211 3.29289C7.88964 3.48043 7.995 3.73478 7.995 4C7.995 4.26522 7.88964 4.51957 7.70211 4.70711C7.51457 4.89464 7.26022 5 6.995 5C6.72978 5 6.47543 4.89464 6.28789 4.70711C6.10036 4.51957 5.995 4.26522 5.995 4C5.995 3.73478 6.10036 3.48043 6.28789 3.29289C6.47543 3.10536 6.72978 3 6.995 3ZM9.995 3C10.2602 3 10.5146 3.10536 10.7021 3.29289C10.8896 3.48043 10.995 3.73478 10.995 4C10.995 4.26522 10.8896 4.51957 10.7021 4.70711C10.5146 4.89464 10.2602 5 9.995 5C9.72978 5 9.47543 4.89464 9.28789 4.70711C9.10036 4.51957 8.995 4.26522 8.995 4C8.995 3.73478 9.10036 3.48043 9.28789 3.29289C9.47543 3.10536 9.72978 3 9.995 3ZM12.995 3C13.2602 3 13.5146 3.10536 13.7021 3.29289C13.8896 3.48043 13.995 3.73478 13.995 4C13.995 4.26522 13.8896 4.51957 13.7021 4.70711C13.5146 4.89464 13.2602 5 12.995 5C12.7298 5 12.4754 4.89464 12.2879 4.70711C12.1004 4.51957 11.995 4.26522 11.995 4C11.995 3.73478 12.1004 3.48043 12.2879 3.29289C12.4754 3.10536 12.7298 3 12.995 3ZM15.995 3C16.2602 3 16.5146 3.10536 16.7021 3.29289C16.8896 3.48043 16.995 3.73478 16.995 4C16.995 4.26522 16.8896 4.51957 16.7021 4.70711C16.5146 4.89464 16.2602 5 15.995 5C15.7298 5 15.4754 4.89464 15.2879 4.70711C15.1004 4.51957 14.995 4.26522 14.995 4C14.995 3.73478 15.1004 3.48043 15.2879 3.29289C15.4754 3.10536 15.7298 3 15.995 3Z" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
import { MuiClickAwayListener, MuiSlide } from '@affine/component';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon } from '@blocksuite/icons';
|
||||
import { IconButton } from '@toeverything/components/button';
|
||||
|
||||
import {
|
||||
type ShortcutsInfo,
|
||||
useEdgelessShortcuts,
|
||||
useGeneralShortcuts,
|
||||
useMarkdownShortcuts,
|
||||
usePageShortcuts,
|
||||
} from '../../../hooks/affine/use-shortcuts';
|
||||
import { KeyboardIcon } from './icons';
|
||||
import * as styles from './style.css';
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const ShortcutsPanel = ({
|
||||
shortcutsInfo,
|
||||
}: {
|
||||
shortcutsInfo: ShortcutsInfo;
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.subtitle}>{shortcutsInfo.title}</div>
|
||||
|
||||
{Object.entries(shortcutsInfo.shortcuts).map(([title, shortcuts]) => {
|
||||
return (
|
||||
<div className={styles.listItem} key={title}>
|
||||
<span>{title}</span>
|
||||
<div className={styles.keyContainer}>
|
||||
{shortcuts.map(key => {
|
||||
return (
|
||||
<span className={styles.key} key={key}>
|
||||
{key}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShortcutsModal = ({ open, onClose }: ModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const markdownShortcutsInfo = useMarkdownShortcuts();
|
||||
const pageShortcutsInfo = usePageShortcuts();
|
||||
const edgelessShortcutsInfo = useEdgelessShortcuts();
|
||||
const generalShortcutsInfo = useGeneralShortcuts();
|
||||
|
||||
return (
|
||||
<MuiSlide direction="left" in={open} mountOnEnter unmountOnExit>
|
||||
<div className={styles.shortcutsModal} data-testid="shortcuts-modal">
|
||||
<MuiClickAwayListener
|
||||
onClickAway={() => {
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className={styles.modalHeader}
|
||||
style={{ marginBottom: '-28px' }}
|
||||
>
|
||||
<div className={styles.title}>
|
||||
<KeyboardIcon />
|
||||
{t['Shortcuts']()}
|
||||
</div>
|
||||
|
||||
<IconButton
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 6,
|
||||
top: 6,
|
||||
}}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
}}
|
||||
icon={<CloseIcon />}
|
||||
/>
|
||||
</div>
|
||||
<ShortcutsPanel shortcutsInfo={generalShortcutsInfo} />
|
||||
<ShortcutsPanel shortcutsInfo={pageShortcutsInfo} />
|
||||
<ShortcutsPanel shortcutsInfo={edgelessShortcutsInfo} />
|
||||
<ShortcutsPanel shortcutsInfo={markdownShortcutsInfo} />
|
||||
</div>
|
||||
</MuiClickAwayListener>
|
||||
</div>
|
||||
</MuiSlide>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,89 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const shortcutsModal = style({
|
||||
width: '288px',
|
||||
height: '74vh',
|
||||
paddingBottom: '28px',
|
||||
backgroundColor: 'var(--affine-white)',
|
||||
boxShadow: 'var(--affine-popover-shadow)',
|
||||
borderRadius: `var(--affine-popover-radius)`,
|
||||
overflow: 'auto',
|
||||
position: 'fixed',
|
||||
right: '12px',
|
||||
top: '0',
|
||||
bottom: '0',
|
||||
margin: 'auto',
|
||||
zIndex: 'var(--affine-z-index-modal)',
|
||||
});
|
||||
// export const shortcutsModal = style({
|
||||
// color: 'var(--affine-text-primary-color)',
|
||||
// fontWeight: '500',
|
||||
// fontSize: 'var(--affine-font-sm)',
|
||||
// height: '44px',
|
||||
// display: 'flex',
|
||||
// justifyContent: 'center',
|
||||
// alignItems: 'center',
|
||||
// svg: {
|
||||
// width: '20px',
|
||||
// marginRight: '14px',
|
||||
// color: 'var(--affine-primary-color)',
|
||||
// },
|
||||
// });
|
||||
export const title = style({
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontWeight: '500',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
globalStyle(`${title} svg`, {
|
||||
width: '20px',
|
||||
marginRight: '14px',
|
||||
color: 'var(--affine-primary-color)',
|
||||
});
|
||||
|
||||
export const subtitle = style({
|
||||
fontWeight: '500',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
height: '34px',
|
||||
lineHeight: '36px',
|
||||
marginTop: '28px',
|
||||
padding: '0 16px',
|
||||
});
|
||||
export const modalHeader = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingTop: '8px 4px 0 4px',
|
||||
width: '100%',
|
||||
padding: '8px 16px 0 16px',
|
||||
position: 'sticky',
|
||||
left: '0',
|
||||
top: '0',
|
||||
background: 'var(--affine-white)',
|
||||
transition: 'background-color 0.5s',
|
||||
});
|
||||
|
||||
export const listItem = style({
|
||||
height: '34px',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
padding: '0 16px',
|
||||
});
|
||||
export const keyContainer = style({
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const key = style({
|
||||
selectors: {
|
||||
'&:not(:last-child)::after': {
|
||||
content: '+',
|
||||
margin: '0 4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user