feat(core): impl team workspace (#8920)

AF-1738 AF-1735 AF-1731 AF-1721 AF-1717 AF-1736 AF-1727 AF-1719 AF-1877
UI for team workspaces :
- add upgrade to team & successful upgrade page ( `/upgrade-to-team` & `/upgrade-success/team`)
- update team plans on pricing page ( settings —> pricing plans )
- update reaching the usage/member limit modal
- update invite member modal
- update member CRUD options
This commit is contained in:
JimmFly
2024-12-10 06:31:35 +00:00
parent 5d25580eff
commit 612310bc26
77 changed files with 3788 additions and 1044 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 374 KiB

View File

@@ -7,7 +7,8 @@ export const root = style({
flexDirection: 'column',
fontSize: cssVar('fontBase'),
position: 'relative',
background: cssVar('backgroundPrimaryColor'),
backgroundColor: cssVar('backgroundPrimaryColor'),
backgroundSize: 'cover',
});
export const affineLogo = style({
color: 'inherit',

View File

@@ -1,8 +1,11 @@
import { Button } from '@affine/component/ui/button';
import { useI18n } from '@affine/i18n';
import { Logo1Icon } from '@blocksuite/icons/rc';
import { useTheme } from 'next-themes';
import { type ReactNode, useCallback } from 'react';
import dotBgDark from './assets/dot-bg.dark.png';
import dotBgLight from './assets/dot-bg.light.png';
import { DesktopNavbar } from './desktop-navbar';
import * as styles from './index.css';
import { MobileNavbar } from './mobile-navbar';
@@ -18,8 +21,15 @@ export const AffineOtherPageLayout = ({
open(BUILD_CONFIG.downloadUrl, '_blank');
}, []);
const { resolvedTheme } = useTheme();
const backgroundImage =
resolvedTheme === 'dark' && dotBgDark ? dotBgDark : dotBgLight;
return (
<div className={styles.root}>
<div
className={styles.root}
style={{ backgroundImage: `url(${backgroundImage})` }}
>
{BUILD_CONFIG.isElectron ? (
<div className={styles.draggableHeader} />
) : (

View File

@@ -1,8 +1,14 @@
import type { FC, PropsWithChildren, ReactNode } from 'react';
import { Empty } from '../../ui/empty';
import { ThemedImg } from '../../ui/themed-img';
import { AffineOtherPageLayout } from '../affine-other-page-layout';
import { authPageContainer, hideInSmallScreen } from './share.css';
import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png';
import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png';
import {
authPageContainer,
hideInSmallScreen,
illustration,
} from './share.css';
export const AuthPageContainer: FC<
PropsWithChildren<{
@@ -20,7 +26,12 @@ export const AuthPageContainer: FC<
{children}
</div>
<div className={hideInSmallScreen}>
<Empty />
<ThemedImg
draggable={false}
className={illustration}
lightSrc={illustrationLight}
darkSrc={illustrationDark}
/>
</div>
</div>
</div>

View File

@@ -159,14 +159,33 @@ export const authPageContainer = style({
globalStyle(`${authPageContainer} .wrapper`, {
display: 'flex',
alignItems: 'center',
width: '100%',
justifyContent: 'center',
overflow: 'hidden',
'@media': {
'screen and (max-width: 1024px)': {
flexDirection: 'column',
justifyContent: 'flex-start',
},
},
});
globalStyle(`${authPageContainer} .content`, {
maxWidth: '700px',
maxWidth: '810px',
'@media': {
'screen and (min-width: 1024px)': {
marginLeft: '200px',
minWidth: '500px',
marginRight: '60px',
flexGrow: 1,
flexShrink: 0,
flexBasis: 0,
},
'screen and (max-width: 1024px)': {
maxWidth: '600px',
width: '100%',
margin: 'auto',
},
},
});
globalStyle(`${authPageContainer} .title`, {
fontSize: cssVar('fontTitle'),
@@ -203,3 +222,8 @@ export const hideInSmallScreen = style({
},
},
});
export const illustration = style({
flexShrink: 0,
width: '670px',
});

View File

@@ -4,7 +4,6 @@ import { useI18n } from '@affine/i18n';
import { Avatar } from '../../ui/avatar';
import { Button } from '../../ui/button';
import { FlexWrapper } from '../../ui/layout';
import * as styles from './styles.css';
export const AcceptInvitePage = ({
onOpenWorkspace,
@@ -18,23 +17,29 @@ export const AcceptInvitePage = ({
<AuthPageContainer
title={t['Successfully joined!']()}
subtitle={
<FlexWrapper alignItems="center">
<Avatar
url={inviteInfo.user.avatarUrl || ''}
name={inviteInfo.user.name}
size={20}
/>
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
{t['invited you to join']()}
<Avatar
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
name={inviteInfo.workspace.name}
size={20}
style={{ marginLeft: 4 }}
colorfulFallback
/>
<span className={styles.inviteName}>{inviteInfo.workspace.name}</span>
</FlexWrapper>
<div className={styles.content}>
<div className={styles.userWrapper}>
<Avatar
url={inviteInfo.user.avatarUrl || ''}
name={inviteInfo.user.name}
size={20}
/>
<span className={styles.inviteName}>{inviteInfo.user.name}</span>
</div>
<div>{t['invited you to join']()}</div>
<div className={styles.userWrapper}>
<Avatar
url={`data:image/png;base64,${inviteInfo.workspace.avatar}`}
name={inviteInfo.workspace.name}
size={20}
style={{ marginLeft: 4 }}
colorfulFallback
/>
<span className={styles.inviteName}>
{inviteInfo.workspace.name}
</span>
</div>
</div>
}
>
<Button variant="primary" size="large" onClick={onOpenWorkspace}>

View File

@@ -1,4 +1,5 @@
export * from './accept-invite-page';
export * from './invite-modal';
export * from './invite-team-modal';
export * from './member-limit-modal';
export * from './pagination';

View File

@@ -0,0 +1,49 @@
import { useI18n } from '@affine/i18n';
import { cssVar } from '@toeverything/theme';
import Input from '../../../ui/input';
import * as styles from './styles.css';
export const EmailInvite = ({
inviteEmail,
setInviteEmail,
handleConfirm,
importCSV,
isMutating,
isValidEmail,
}: {
inviteEmail: string;
setInviteEmail: (value: string) => void;
handleConfirm: () => void;
isMutating: boolean;
isValidEmail: boolean;
importCSV: React.ReactNode;
}) => {
const t = useI18n();
return (
<>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.email-invite']()}
</div>
<div>
<Input
inputStyle={{ fontSize: cssVar('fontXs') }}
disabled={isMutating}
placeholder={t[
'com.affine.payment.member.team.invite.email-placeholder'
]()}
value={inviteEmail}
onChange={setInviteEmail}
onEnter={handleConfirm}
size="large"
/>
{!isValidEmail ? (
<div className={styles.errorHint}>
{t['com.affine.auth.sign.email.error']()}
</div>
) : null}
</div>
<div>{importCSV}</div>
</>
);
};

View File

@@ -0,0 +1,117 @@
import { emailRegex } from '@affine/component/auth-components';
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { useCallback, useEffect, useState } from 'react';
import { ConfirmModal } from '../../../ui/modal';
import { notify } from '../../../ui/notification';
import { type InviteMethodType, ModalContent } from './modal-content';
import * as styles from './styles.css';
export interface InviteTeamMemberModalProps {
open: boolean;
setOpen: (value: boolean) => void;
onConfirm: (params: { emails: string[] }) => void;
isMutating: boolean;
copyTextToClipboard: (text: string) => Promise<boolean>;
onGenerateInviteLink: (
expireTime: WorkspaceInviteLinkExpireTime
) => Promise<string>;
onRevokeInviteLink: () => Promise<boolean>;
importCSV: React.ReactNode;
}
const parseEmailString = (emailString: string): string[] => {
return emailString
.split(',')
.map(email => email.trim())
.filter(email => email.length > 0);
};
export const InviteTeamMemberModal = ({
open,
setOpen,
onConfirm,
isMutating,
copyTextToClipboard,
onGenerateInviteLink,
onRevokeInviteLink,
importCSV,
}: InviteTeamMemberModalProps) => {
const t = useI18n();
const [inviteEmails, setInviteEmails] = useState('');
const [isValidEmail, setIsValidEmail] = useState(true);
const [inviteMethod, setInviteMethod] = useState<InviteMethodType>('email');
const handleConfirm = useCallback(() => {
if (inviteMethod === 'link') {
setOpen(false);
return;
}
const inviteEmailsArray = parseEmailString(inviteEmails);
const invalidEmail = inviteEmailsArray.find(
email => !emailRegex.test(email)
);
if (invalidEmail) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
onConfirm({
emails: inviteEmailsArray,
});
notify.success({
title: t['com.affine.payment.member.team.invite.notify.title'](),
message: t['com.affine.payment.member.team.invite.notify.message'](),
});
}, [inviteEmails, inviteMethod, onConfirm, setOpen, t]);
useEffect(() => {
if (!open) {
setInviteEmails('');
setIsValidEmail(true);
}
}, [open]);
return (
<ConfirmModal
width={480}
open={open}
onOpenChange={setOpen}
title={t['com.affine.payment.member.team.invite.title']()}
cancelText={t['com.affine.inviteModal.button.cancel']()}
contentOptions={{
['data-testid' as string]: 'invite-modal',
style: {
padding: '20px 24px',
},
}}
confirmText={
inviteMethod === 'email'
? t['com.affine.payment.member.team.invite.send-invites']()
: t['com.affine.payment.member.team.invite.done']()
}
confirmButtonOptions={{
loading: isMutating,
variant: 'primary',
}}
onConfirm={handleConfirm}
childrenContentClassName={styles.contentStyle}
>
<ModalContent
inviteEmail={inviteEmails}
setInviteEmail={setInviteEmails}
handleConfirm={handleConfirm}
isMutating={isMutating}
isValidEmail={isValidEmail}
inviteMethod={inviteMethod}
importCSV={importCSV}
onInviteMethodChange={setInviteMethod}
copyTextToClipboard={copyTextToClipboard}
onGenerateInviteLink={onGenerateInviteLink}
onRevokeInviteLink={onRevokeInviteLink}
/>
</ConfirmModal>
);
};

View File

@@ -0,0 +1,167 @@
import { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { CloseIcon } from '@blocksuite/icons/rc';
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { useCallback, useMemo, useState } from 'react';
import { Button, IconButton } from '../../../ui/button';
import Input from '../../../ui/input';
import { Menu, MenuItem, MenuTrigger } from '../../../ui/menu';
import { notify } from '../../../ui/notification';
import * as styles from './styles.css';
const getMenuItems = (t: ReturnType<typeof useI18n>) => [
{
label: t['com.affine.payment.member.team.invite.expiration-date']({
number: '1',
}),
value: WorkspaceInviteLinkExpireTime.OneDay,
},
{
label: t['com.affine.payment.member.team.invite.expiration-date']({
number: '3',
}),
value: WorkspaceInviteLinkExpireTime.ThreeDays,
},
{
label: t['com.affine.payment.member.team.invite.expiration-date']({
number: '7',
}),
value: WorkspaceInviteLinkExpireTime.OneWeek,
},
{
label: t['com.affine.payment.member.team.invite.expiration-date']({
number: '30',
}),
value: WorkspaceInviteLinkExpireTime.OneMonth,
},
];
export const LinkInvite = ({
copyTextToClipboard,
generateInvitationLink,
revokeInvitationLink,
}: {
generateInvitationLink: (
expireTime: WorkspaceInviteLinkExpireTime
) => Promise<string>;
revokeInvitationLink: () => Promise<boolean>;
copyTextToClipboard: (text: string) => Promise<boolean>;
}) => {
const t = useI18n();
const [selectedValue, setSelectedValue] = useState(
WorkspaceInviteLinkExpireTime.OneWeek
);
const [invitationLink, setInvitationLink] = useState('');
const menuItems = getMenuItems(t);
const items = useMemo(() => {
return menuItems.map(item => (
<MenuItem key={item.value} onSelect={() => setSelectedValue(item.value)}>
{item.label}
</MenuItem>
));
}, [menuItems]);
const currentSelectedLabel = useMemo(
() => menuItems.find(item => item.value === selectedValue)?.label,
[menuItems, selectedValue]
);
const onGenerate = useCallback(() => {
generateInvitationLink(selectedValue)
.then(link => {
setInvitationLink(link);
})
.catch(err => {
console.error('Failed to generate invitation link: ', err);
notify.error({
title: 'Failed to generate invitation link',
message: err.message,
});
});
}, [generateInvitationLink, selectedValue]);
const onCopy = useCallback(() => {
copyTextToClipboard(invitationLink)
.then(() =>
notify.success({
title: t['Copied link to clipboard'](),
})
)
.catch(err => {
console.error('Failed to copy text: ', err);
notify.error({
title: 'Failed to copy link to clipboard',
message: err.message,
});
});
}, [copyTextToClipboard, invitationLink, t]);
const onReset = useCallback(() => {
revokeInvitationLink()
.then(() => {
setInvitationLink('');
})
.catch(err => {
console.error('Failed to revoke invitation link: ', err);
notify.error({
title: 'Failed to revoke invitation link',
message: err.message,
});
});
}, [revokeInvitationLink]);
return (
<>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.link-expiration']()}
</div>
<Menu
items={items}
contentOptions={{
style: {
width: 'var(--radix-dropdown-menu-trigger-width)',
},
}}
>
<MenuTrigger style={{ width: '100%' }}>
{currentSelectedLabel}
</MenuTrigger>
</Menu>
<div className={styles.modalSubTitle}>
{t['com.affine.payment.member.team.invite.invitation-link']()}
</div>
<div className={styles.invitationLinkContent}>
<Input
value={
invitationLink
? invitationLink
: 'https://your-app.com/invite/xxxxxxxx'
}
inputMode="none"
disabled
inputStyle={{
fontSize: cssVar('fontXs'),
color: cssVarV2(
invitationLink ? 'text/primary' : 'text/placeholder'
),
backgroundColor: cssVarV2('layer/background/primary'),
}}
/>
{invitationLink ? (
<>
<Button onClick={onCopy}>
{t['com.affine.payment.member.team.invite.copy']()}
</Button>
<IconButton icon={<CloseIcon />} onClick={onReset} />
</>
) : (
<Button onClick={onGenerate}>
{t['com.affine.payment.member.team.invite.generate']()}
</Button>
)}
</div>
</>
);
};

View File

@@ -0,0 +1,103 @@
import type { WorkspaceInviteLinkExpireTime } from '@affine/graphql';
import { useI18n } from '@affine/i18n';
import { EmailIcon, LinkIcon } from '@blocksuite/icons/rc';
import { RadioGroup } from '../../../ui/radio';
import { EmailInvite } from './email-invite';
import { LinkInvite } from './link-invite';
import * as styles from './styles.css';
export type InviteMethodType = 'email' | 'link';
export const ModalContent = ({
inviteEmail,
setInviteEmail,
inviteMethod,
onInviteMethodChange,
handleConfirm,
isMutating,
isValidEmail,
copyTextToClipboard,
onGenerateInviteLink,
onRevokeInviteLink,
importCSV,
}: {
inviteEmail: string;
importCSV: React.ReactNode;
setInviteEmail: (value: string) => void;
inviteMethod: InviteMethodType;
onInviteMethodChange: (value: InviteMethodType) => void;
handleConfirm: () => void;
isMutating: boolean;
isValidEmail: boolean;
copyTextToClipboard: (text: string) => Promise<boolean>;
onGenerateInviteLink: (
expireTime: WorkspaceInviteLinkExpireTime
) => Promise<string>;
onRevokeInviteLink: () => Promise<boolean>;
}) => {
const t = useI18n();
return (
<div className={styles.modalContent}>
<div>{t['com.affine.payment.member.team.invite.description']()}</div>
<RadioGroup
width={'100%'}
value={inviteMethod}
onChange={onInviteMethodChange}
items={[
{
label: (
<RadioItem
icon={<EmailIcon className={styles.iconStyle} />}
label={t[
'com.affine.payment.member.team.invite.email-invite'
]()}
/>
),
value: 'email',
},
{
label: (
<RadioItem
icon={<LinkIcon className={styles.iconStyle} />}
label={t['com.affine.payment.member.team.invite.invite-link']()}
/>
),
value: 'link',
},
]}
/>
{inviteMethod === 'email' ? (
<EmailInvite
inviteEmail={inviteEmail}
setInviteEmail={setInviteEmail}
handleConfirm={handleConfirm}
isMutating={isMutating}
isValidEmail={isValidEmail}
importCSV={importCSV}
/>
) : (
<LinkInvite
copyTextToClipboard={copyTextToClipboard}
generateInvitationLink={onGenerateInviteLink}
revokeInvitationLink={onRevokeInviteLink}
/>
)}
</div>
);
};
const RadioItem = ({
icon,
label,
}: {
icon: React.ReactNode;
label: string;
}) => {
return (
<div className={styles.radioItem}>
{icon}
<div>{label}</div>
</div>
);
};

View File

@@ -1,8 +1,10 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const inviteModalTitle = style({
fontWeight: '600',
fontSize: 'var(--affine-font-h-6)',
fontSize: cssVar('fontH6'),
marginBottom: '20px',
});
@@ -19,7 +21,7 @@ export const inviteModalButtonContainer = style({
export const inviteName = style({
marginLeft: '4px',
marginRight: '10px',
color: 'var(--affine-black)',
color: cssVarV2('text/primary'),
});
export const pagination = style({
@@ -36,27 +38,27 @@ export const pageItem = style({
alignItems: 'center',
width: '20px',
height: '20px',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-primary-color)',
fontSize: cssVar('fontXs'),
color: cssVarV2('text/primary'),
borderRadius: '4px',
selectors: {
'&:hover': {
background: 'var(--affine-hover-color)',
background: cssVarV2('layer/background/hoverOverlay'),
},
'&.active': {
color: 'var(--affine-primary-color)',
color: cssVarV2('text/emphasis'),
cursor: 'default',
pointerEvents: 'none',
},
'&.label': {
color: 'var(--affine-icon-color)',
color: cssVarV2('icon/primary'),
fontSize: '16px',
},
'&.disabled': {
opacity: '.4',
cursor: 'default',
color: 'var(--affine-disable-color)',
color: cssVarV2('text/disable'),
pointerEvents: 'none',
},
},
@@ -67,3 +69,40 @@ globalStyle(`${pageItem} a`, {
justifyContent: 'center',
alignItems: 'center',
});
export const modalContent = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
});
export const modalSubTitle = style({
fontSize: cssVar('fontSm'),
fontWeight: '500',
});
export const radioItem = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
});
export const iconStyle = style({
color: cssVarV2('icon/primary'),
fontSize: '16px',
});
export const errorHint = style({
color: cssVarV2('status/error'),
fontSize: cssVar('fontXs'),
});
export const contentStyle = style({
paddingLeft: '0',
paddingRight: '0',
});
export const invitationLinkContent = style({
display: 'flex',
gap: '8px',
});

View File

@@ -0,0 +1,27 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const ulStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '8px',
marginTop: '12px',
});
export const liStyle = style({
display: 'flex',
flexDirection: 'row',
alignItems: 'start',
fontSize: cssVar('fontBase'),
});
export const prefixDot = style({
background: cssVarV2('icon/activated'),
width: '5px',
height: '5px',
borderRadius: '50%',
marginRight: '12px',
marginTop: '10px',
});

View File

@@ -2,6 +2,8 @@ import { ConfirmModal } from '@affine/component/ui/modal';
import { useI18n } from '@affine/i18n';
import { useCallback } from 'react';
import * as styles from './member-limit-modal.css';
export interface MemberLimitModalProps {
isFreePlan: boolean;
open: boolean;
@@ -22,27 +24,18 @@ export const MemberLimitModal = ({
const t = useI18n();
const handleConfirm = useCallback(() => {
setOpen(false);
if (isFreePlan) {
onConfirm();
}
}, [onConfirm, setOpen, isFreePlan]);
onConfirm();
}, [onConfirm, setOpen]);
return (
<ConfirmModal
open={open}
onOpenChange={setOpen}
title={t['com.affine.payment.member-limit.title']()}
description={t[
isFreePlan
? 'com.affine.payment.member-limit.free.description'
: 'com.affine.payment.member-limit.pro.description'
]({ planName: plan, quota: quota })}
cancelButtonOptions={{ style: { display: isFreePlan ? '' : 'none' } }}
confirmText={t[
isFreePlan
? 'com.affine.payment.member-limit.free.confirm'
: 'com.affine.payment.member-limit.pro.confirm'
]()}
description={
<ConfirmDescription plan={plan} quota={quota} isFreePlan={isFreePlan} />
}
confirmText={t['com.affine.payment.upgrade']()}
confirmButtonOptions={{
variant: 'primary',
}}
@@ -50,3 +43,41 @@ export const MemberLimitModal = ({
></ConfirmModal>
);
};
export const ConfirmDescription = ({
isFreePlan,
plan,
quota,
}: {
isFreePlan: boolean;
plan: string;
quota: string;
}) => {
const t = useI18n();
return (
<div>
{t['com.affine.payment.member-limit.description']({
planName: plan,
quota: quota,
})}
<ul className={styles.ulStyle}>
{isFreePlan && (
<li className={styles.liStyle}>
<div className={styles.prefixDot} />
{t[
'com.affine.payment.member-limit.description.tips-for-free-plan'
]()}
</li>
)}
<li className={styles.liStyle}>
<div className={styles.prefixDot} />
{t['com.affine.payment.member-limit.description.tips-1']()}
</li>
<li className={styles.liStyle}>
<div className={styles.prefixDot} />
{t['com.affine.payment.member-limit.description.tips-2']()}
</li>
</ul>
</div>
);
};

View File

@@ -0,0 +1,113 @@
import { cssVar } from '@toeverything/theme';
import { cssVarV2 } from '@toeverything/theme/v2';
import { globalStyle, style } from '@vanilla-extract/css';
export const inviteModalTitle = style({
fontWeight: '600',
fontSize: cssVar('fontH6'),
marginBottom: '20px',
});
export const inviteModalContent = style({
marginBottom: '10px',
});
export const inviteModalButtonContainer = style({
display: 'flex',
justifyContent: 'flex-end',
// marginTop: 10,
});
export const inviteName = style({
color: cssVarV2('text/primary'),
});
export const content = style({
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '8px',
});
export const userWrapper = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
});
export const pagination = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '6px',
marginTop: 5,
});
export const pageItem = style({
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
width: '20px',
height: '20px',
fontSize: cssVar('fontXs'),
color: cssVarV2('text/primary'),
borderRadius: '4px',
selectors: {
'&:hover': {
background: cssVarV2('layer/background/hoverOverlay'),
},
'&.active': {
color: cssVarV2('text/emphasis'),
cursor: 'default',
pointerEvents: 'none',
},
'&.label': {
color: cssVarV2('icon/primary'),
fontSize: '16px',
},
'&.disabled': {
opacity: '.4',
cursor: 'default',
color: cssVarV2('text/disable'),
pointerEvents: 'none',
},
},
});
globalStyle(`${pageItem} a`, {
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
});
export const modalContent = style({
display: 'flex',
flexDirection: 'column',
gap: '12px',
});
export const modalSubTitle = style({
fontSize: cssVar('fontSm'),
fontWeight: '500',
});
export const radioItem = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
});
export const iconStyle = style({
color: cssVarV2('icon/primary'),
fontSize: '16px',
});
export const errorHint = style({
color: cssVarV2('status/error'),
fontSize: cssVar('fontXs'),
});
export const importButton = style({
padding: '4px 8px',
});

View File

@@ -3,10 +3,13 @@ import { SignOutIcon } from '@blocksuite/icons/rc';
import { Avatar } from '../../ui/avatar';
import { Button, IconButton } from '../../ui/button';
import { ThemedImg } from '../../ui/themed-img';
import { AffineOtherPageLayout } from '../affine-other-page-layout';
import illustrationDark from '../affine-other-page-layout/assets/other-page.dark.png';
import illustrationLight from '../affine-other-page-layout/assets/other-page.light.png';
import type { User } from '../auth-components';
import { NotFoundPattern } from './not-found-pattern';
import {
illustration,
largeButtonEffect,
notFoundPageContainer,
wrapper,
@@ -32,7 +35,12 @@ export const NoPermissionOrNotFound = ({
{user ? (
<>
<div className={wrapper}>
<NotFoundPattern />
<ThemedImg
draggable={false}
className={illustration}
lightSrc={illustrationLight}
darkSrc={illustrationDark}
/>
</div>
<p className={wrapper}>{t['404.hint']()}</p>
<div className={wrapper}>
@@ -76,7 +84,12 @@ export const NotFoundPage = ({
<AffineOtherPageLayout>
<div className={notFoundPageContainer} data-testid="not-found">
<div className={wrapper}>
<NotFoundPattern />
<ThemedImg
draggable={false}
className={illustration}
lightSrc={illustrationLight}
darkSrc={illustrationDark}
/>
</div>
<p className={wrapper}>{t['404.hint']()}</p>
<div className={wrapper}>

View File

@@ -1,121 +0,0 @@
export const NotFoundPattern = () => {
return (
<svg
width="240"
height="209"
viewBox="0 0 240 209"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M24.4197 172.91L119.045 8.64233L213.671 172.91H24.4197Z"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M165.921 91.5342L119.045 172.161L72.1684 91.5342L165.921 91.5342Z"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M179.022 68.1181C179.022 101.243 152.169 128.096 119.045 128.096C85.9202 128.096 59.0674 101.243 59.0674 68.1181C59.0674 34.9934 85.9202 8.14062 119.045 8.14062C152.169 8.14062 179.022 34.9934 179.022 68.1181Z"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<circle
cx="162.485"
cy="142.984"
r="59.9775"
transform="rotate(120 162.485 142.984)"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<circle
cx="75.2925"
cy="142.984"
r="59.9775"
transform="rotate(-120 75.2925 142.984)"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M119.045 7.64062V173.158"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M214.536 173.475L71.2998 91.0352"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<path
d="M23.5547 173.475L166.791 91.0352"
stroke="var(--affine-text-disable-color)"
strokeOpacity="0.6"
/>
<ellipse
cx="119.045"
cy="7.63971"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="214.536"
cy="173.155"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="166.79"
cy="91.0342"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="119.045"
cy="173.155"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="71.2999"
cy="91.0342"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="119.045"
cy="91.0342"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="95.4903"
cy="131.776"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="143.236"
cy="131.776"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
<ellipse
cx="23.5548"
cy="173.155"
rx="5.09284"
ry="5.09284"
fill="var(--affine-text-primary-color)"
/>
</svg>
);
};

View File

@@ -20,3 +20,8 @@ export const wrapper = style({
export const largeButtonEffect = style({
boxShadow: `${cssVar('largeButtonEffect')} !important`,
});
export const illustration = style({
maxWidth: '100%',
width: '670px',
});