mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
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:
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 |
@@ -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',
|
||||
|
||||
@@ -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} />
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -20,3 +20,8 @@ export const wrapper = style({
|
||||
export const largeButtonEffect = style({
|
||||
boxShadow: `${cssVar('largeButtonEffect')} !important`,
|
||||
});
|
||||
|
||||
export const illustration = style({
|
||||
maxWidth: '100%',
|
||||
width: '670px',
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user