mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-02 18:09:58 +08: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:
+27
@@ -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',
|
||||
});
|
||||
+32
-24
@@ -4,13 +4,15 @@ import { UserQuotaService } from '@affine/core/modules/cloud';
|
||||
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { WorkspaceQuotaService } from '@affine/core/modules/quota';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { type I18nString, useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||
import bytes from 'bytes';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useCallback, useEffect, useMemo } from 'react';
|
||||
|
||||
import * as styles from './cloud-quota-modal.css';
|
||||
|
||||
export const CloudQuotaModal = () => {
|
||||
const t = useI18n();
|
||||
const currentWorkspace = useService(WorkspaceService).workspace;
|
||||
@@ -39,10 +41,6 @@ export const CloudQuotaModal = () => {
|
||||
)
|
||||
);
|
||||
|
||||
const isFreePlanOwner = useMemo(() => {
|
||||
return isOwner && userQuota?.name === 'free';
|
||||
}, [isOwner, userQuota]);
|
||||
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const handleUpgradeConfirm = useCallback(() => {
|
||||
globalDialogService.open('setting', {
|
||||
@@ -55,18 +53,8 @@ export const CloudQuotaModal = () => {
|
||||
}, [globalDialogService, setOpen]);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (userQuota && isFreePlanOwner) {
|
||||
return t['com.affine.payment.blob-limit.description.owner.free']({
|
||||
planName: userQuota.name,
|
||||
currentQuota: userQuota.blobLimit,
|
||||
upgradeQuota: '100MB',
|
||||
});
|
||||
}
|
||||
if (isOwner && userQuota && userQuota.name.toLowerCase() === 'pro') {
|
||||
return t['com.affine.payment.blob-limit.description.owner.pro']({
|
||||
planName: userQuota.name,
|
||||
quota: userQuota.blobLimit,
|
||||
});
|
||||
if (userQuota && isOwner) {
|
||||
return <OwnerDescription quota={userQuota.blobLimit} />;
|
||||
}
|
||||
if (workspaceQuota) {
|
||||
return t['com.affine.payment.blob-limit.description.member']({
|
||||
@@ -76,7 +64,7 @@ export const CloudQuotaModal = () => {
|
||||
// loading
|
||||
return null;
|
||||
}
|
||||
}, [userQuota, isFreePlanOwner, isOwner, workspaceQuota, t]);
|
||||
}, [userQuota, isOwner, workspaceQuota, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!workspaceQuota) {
|
||||
@@ -100,16 +88,36 @@ export const CloudQuotaModal = () => {
|
||||
title={t['com.affine.payment.blob-limit.title']()}
|
||||
onOpenChange={setOpen}
|
||||
description={description}
|
||||
cancelButtonOptions={{
|
||||
hidden: !isFreePlanOwner,
|
||||
}}
|
||||
onConfirm={handleUpgradeConfirm}
|
||||
confirmText={
|
||||
isFreePlanOwner ? t['com.affine.payment.upgrade']() : t['Got it']()
|
||||
}
|
||||
confirmText={t['com.affine.payment.upgrade']()}
|
||||
confirmButtonOptions={{
|
||||
variant: 'primary',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const tips: I18nString[] = [
|
||||
'com.affine.payment.blob-limit.description.owner.tips-1',
|
||||
'com.affine.payment.blob-limit.description.owner.tips-2',
|
||||
'com.affine.payment.blob-limit.description.owner.tips-3',
|
||||
];
|
||||
|
||||
const OwnerDescription = ({ quota }: { quota: string }) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div>
|
||||
{t['com.affine.payment.blob-limit.description.owner']({
|
||||
quota: quota,
|
||||
})}
|
||||
<ul className={styles.ulStyle}>
|
||||
{tips.map((tip, index) => (
|
||||
<li className={styles.liStyle} key={index}>
|
||||
<div className={styles.prefixDot} />
|
||||
{t.t(tip)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import type { Permission } from '@affine/graphql';
|
||||
import { inviteByEmailMutation } from '@affine/graphql';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useMutation } from '../use-mutation';
|
||||
import { useMutateCloud } from './use-mutate-cloud';
|
||||
|
||||
export function useInviteMember(workspaceId: string) {
|
||||
const { trigger, isMutating } = useMutation({
|
||||
mutation: inviteByEmailMutation,
|
||||
});
|
||||
const mutate = useMutateCloud();
|
||||
return {
|
||||
invite: useCallback(
|
||||
async (email: string, permission: Permission, sendInviteMail = false) => {
|
||||
const res = await trigger({
|
||||
workspaceId,
|
||||
email,
|
||||
permission,
|
||||
sendInviteMail,
|
||||
});
|
||||
await mutate();
|
||||
// return is successful
|
||||
return res?.invite;
|
||||
},
|
||||
[mutate, trigger, workspaceId]
|
||||
),
|
||||
isMutating,
|
||||
};
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { revokeMemberPermissionMutation } from '@affine/graphql';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useMutation } from '../use-mutation';
|
||||
import { useMutateCloud } from './use-mutate-cloud';
|
||||
|
||||
export function useRevokeMemberPermission(workspaceId: string) {
|
||||
const mutate = useMutateCloud();
|
||||
const { trigger } = useMutation({
|
||||
mutation: revokeMemberPermissionMutation,
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
async (userId: string) => {
|
||||
const res = await trigger({
|
||||
workspaceId,
|
||||
userId,
|
||||
});
|
||||
await mutate();
|
||||
return res;
|
||||
},
|
||||
[mutate, trigger, workspaceId]
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,13 @@ type TypeFormInfo = {
|
||||
const getTypeFormLink = (id: string, info: TypeFormInfo) => {
|
||||
const plans = Array.isArray(info.plan) ? info.plan : [info.plan];
|
||||
const product_id = plans
|
||||
.map(plan => (plan === SubscriptionPlan.AI ? 'ai' : 'cloud'))
|
||||
.map(plan =>
|
||||
plan === SubscriptionPlan.AI
|
||||
? 'ai'
|
||||
: plan === SubscriptionPlan.Team
|
||||
? 'team'
|
||||
: 'cloud'
|
||||
)
|
||||
.join('-');
|
||||
const product_price =
|
||||
info.recurring === SubscriptionRecurring.Monthly
|
||||
@@ -46,7 +52,11 @@ export const generateSubscriptionCallbackLink = (
|
||||
throw new Error('Account is required');
|
||||
}
|
||||
const baseUrl =
|
||||
plan === SubscriptionPlan.AI ? '/ai-upgrade-success' : '/upgrade-success';
|
||||
plan === SubscriptionPlan.AI
|
||||
? '/ai-upgrade-success'
|
||||
: plan === SubscriptionPlan.Team
|
||||
? '/upgrade-success/team'
|
||||
: '/upgrade-success';
|
||||
|
||||
let name = account?.info?.name ?? '';
|
||||
if (name.includes(separator)) {
|
||||
|
||||
@@ -38,17 +38,17 @@ export const OverCapacityNotification = () => {
|
||||
}
|
||||
if (isOwner) {
|
||||
notify.warning({
|
||||
title: t['com.affine.payment.storage-limit.title'](),
|
||||
title: t['com.affine.payment.storage-limit.new-title'](),
|
||||
message:
|
||||
t['com.affine.payment.storage-limit.description.owner'](),
|
||||
t['com.affine.payment.storage-limit.new-description.owner'](),
|
||||
action: {
|
||||
label: t['com.affine.payment.storage-limit.view'](),
|
||||
label: t['com.affine.payment.upgrade'](),
|
||||
onClick: jumpToPricePlan,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
notify.warning({
|
||||
title: t['com.affine.payment.storage-limit.title'](),
|
||||
title: t['com.affine.payment.storage-limit.new-title'](),
|
||||
message:
|
||||
t['com.affine.payment.storage-limit.description.member'](),
|
||||
});
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const iconWrapper = style({
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '16px',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
selectors: {
|
||||
'&:visited': {
|
||||
color: cssVar('textPrimaryColor'),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { Empty } from '@affine/component';
|
||||
import { Logo1Icon } from '@blocksuite/icons/rc';
|
||||
|
||||
export const SharePageNotFoundError = () => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<a
|
||||
href="https://affine.pro/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '16px',
|
||||
left: '16px',
|
||||
fontSize: '24px',
|
||||
cursor: 'pointer',
|
||||
color: 'inherit',
|
||||
}}
|
||||
>
|
||||
<Logo1Icon />
|
||||
</a>
|
||||
<Empty
|
||||
description={'You do not have access or this content does not exist.'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
LocalWorkspaceIcon,
|
||||
NoNetworkIcon,
|
||||
SettingsIcon,
|
||||
TeamWorkspaceIcon,
|
||||
UnsyncIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import {
|
||||
@@ -28,6 +29,7 @@ import { forwardRef, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
|
||||
import * as styles from './styles.css';
|
||||
export { PureWorkspaceCard } from './pure-workspace-card';
|
||||
|
||||
const CloudWorkspaceStatus = () => {
|
||||
return (
|
||||
@@ -240,6 +242,7 @@ export const WorkspaceCard = forwardRef<
|
||||
avatarSize?: number;
|
||||
disable?: boolean;
|
||||
hideCollaborationIcon?: boolean;
|
||||
hideTeamWorkspaceIcon?: boolean;
|
||||
active?: boolean;
|
||||
onClickOpenSettings?: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onClickEnableCloud?: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
@@ -256,6 +259,7 @@ export const WorkspaceCard = forwardRef<
|
||||
className,
|
||||
disable,
|
||||
hideCollaborationIcon,
|
||||
hideTeamWorkspaceIcon,
|
||||
active,
|
||||
...props
|
||||
},
|
||||
@@ -325,6 +329,9 @@ export const WorkspaceCard = forwardRef<
|
||||
{hideCollaborationIcon || information?.isOwner ? null : (
|
||||
<CollaborationIcon className={styles.collaborationIcon} />
|
||||
)}
|
||||
{hideTeamWorkspaceIcon || !information?.isTeam ? null : (
|
||||
<TeamWorkspaceIcon className={styles.collaborationIcon} />
|
||||
)}
|
||||
{onClickOpenSettings && (
|
||||
<div className={styles.settingButton} onClick={onOpenSettings}>
|
||||
<SettingsIcon width={16} height={16} />
|
||||
@@ -336,7 +343,7 @@ export const WorkspaceCard = forwardRef<
|
||||
|
||||
{active && (
|
||||
<div className={styles.activeContainer}>
|
||||
<DoneIcon className={styles.activeIcon} />{' '}
|
||||
<DoneIcon className={styles.activeIcon} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
|
||||
import { useWorkspaceInfo } from '@affine/core/components/hooks/use-workspace-info';
|
||||
import { UNTITLED_WORKSPACE_NAME } from '@affine/env/constant';
|
||||
import { DoneIcon } from '@blocksuite/icons/rc';
|
||||
import { type WorkspaceMetadata } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { forwardRef } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const PureWorkspaceCard = forwardRef<
|
||||
HTMLDivElement,
|
||||
HTMLAttributes<HTMLDivElement> & {
|
||||
workspaceMetadata: WorkspaceMetadata;
|
||||
avatarSize?: number;
|
||||
disable?: boolean;
|
||||
active?: boolean;
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
workspaceMetadata,
|
||||
avatarSize = 32,
|
||||
className,
|
||||
disable,
|
||||
active,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const information = useWorkspaceInfo(workspaceMetadata);
|
||||
|
||||
const name = information?.name ?? UNTITLED_WORKSPACE_NAME;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
styles.container,
|
||||
disable ? styles.disable : null,
|
||||
className
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
data-testid="workspace-card"
|
||||
ref={ref}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.infoContainer}>
|
||||
{information ? (
|
||||
<WorkspaceAvatar
|
||||
meta={workspaceMetadata}
|
||||
rounded={3}
|
||||
data-testid="workspace-avatar"
|
||||
size={avatarSize}
|
||||
name={name}
|
||||
colorfulFallback
|
||||
/>
|
||||
) : (
|
||||
<Skeleton width={avatarSize} height={avatarSize} />
|
||||
)}
|
||||
<div className={styles.workspaceTitleContainer}>
|
||||
{information ? (
|
||||
<span className={styles.workspaceName}>{information.name}</span>
|
||||
) : (
|
||||
<Skeleton width={100} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{active && (
|
||||
<div className={styles.activeContainer}>
|
||||
<DoneIcon className={styles.activeIcon} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
PureWorkspaceCard.displayName = 'PureWorkspaceCard';
|
||||
Reference in New Issue
Block a user