feat(core): add team badge to user info menu (#12144)
close AF-2545 fix(core): go to workspace command does not work using CMDK feat(core): add team badge to user info  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a redesigned user info section in the sidebar, including enhanced account details, cloud and AI usage indicators, and team workspace listing with improved navigation. - Workspace selector now supports controlled open/close state for smoother sidebar integration. - Team workspaces are grouped and displayed with role-based badges and tooltips. - Added new localization for team roles and workspace tooltips. - **Improvements** - Updated theming and styling for user plan buttons and usage indicators for a more consistent look. - Sidebar user info UI is now more modular and responsive. - **Bug Fixes** - Improved error handling and loading states for quota and usage displays. - **Chores** - Refactored internal state management and code structure for better maintainability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -2,25 +2,24 @@ import type { useI18n } from '@affine/i18n';
|
|||||||
import { track } from '@affine/track';
|
import { track } from '@affine/track';
|
||||||
import type { Workspace } from '@blocksuite/affine/store';
|
import type { Workspace } from '@blocksuite/affine/store';
|
||||||
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
|
import { ArrowRightBigIcon } from '@blocksuite/icons/rc';
|
||||||
import type { createStore } from 'jotai';
|
|
||||||
|
|
||||||
import { openWorkspaceListModalAtom } from '../components/atoms';
|
|
||||||
import type { useNavigateHelper } from '../components/hooks/use-navigate-helper';
|
import type { useNavigateHelper } from '../components/hooks/use-navigate-helper';
|
||||||
import type { WorkspaceDialogService } from '../modules/dialogs';
|
import type { WorkspaceDialogService } from '../modules/dialogs';
|
||||||
|
import type { WorkbenchService } from '../modules/workbench';
|
||||||
import { registerAffineCommand } from './registry';
|
import { registerAffineCommand } from './registry';
|
||||||
|
|
||||||
export function registerAffineNavigationCommands({
|
export function registerAffineNavigationCommands({
|
||||||
t,
|
t,
|
||||||
store,
|
|
||||||
docCollection,
|
docCollection,
|
||||||
navigationHelper,
|
navigationHelper,
|
||||||
workspaceDialogService,
|
workspaceDialogService,
|
||||||
|
workbenchService,
|
||||||
}: {
|
}: {
|
||||||
t: ReturnType<typeof useI18n>;
|
t: ReturnType<typeof useI18n>;
|
||||||
store: ReturnType<typeof createStore>;
|
|
||||||
navigationHelper: ReturnType<typeof useNavigateHelper>;
|
navigationHelper: ReturnType<typeof useNavigateHelper>;
|
||||||
docCollection: Workspace;
|
docCollection: Workspace;
|
||||||
workspaceDialogService: WorkspaceDialogService;
|
workspaceDialogService: WorkspaceDialogService;
|
||||||
|
workbenchService?: WorkbenchService;
|
||||||
}) {
|
}) {
|
||||||
const unsubs: Array<() => void> = [];
|
const unsubs: Array<() => void> = [];
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
@@ -82,7 +81,7 @@ export function registerAffineNavigationCommands({
|
|||||||
to: 'workspace',
|
to: 'workspace',
|
||||||
});
|
});
|
||||||
|
|
||||||
store.set(openWorkspaceListModalAtom, true);
|
workbenchService?.workbench.openWorkspaceSelector();
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const userPlanButton = style({
|
export const userPlanButton = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
fontSize: cssVar('fontXs'),
|
fontSize: '10px',
|
||||||
height: 20,
|
height: 16,
|
||||||
fontWeight: 500,
|
fontWeight: 400,
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
color: cssVar('pureWhite'),
|
color: cssVarV2('button/pureWhiteText'),
|
||||||
backgroundColor: cssVar('brandColor'),
|
backgroundColor: cssVarV2('badge/free'),
|
||||||
padding: '0 4px',
|
padding: '0 4px',
|
||||||
borderRadius: 4,
|
borderRadius: 2,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
|
|
||||||
selectors: {
|
selectors: {
|
||||||
'&[data-is-believer="true"]': {
|
'&[data-is-believer="true"]': {
|
||||||
// TODO(@CatsJuice): this color is new `Figma token` value without dark mode support.
|
backgroundColor: cssVarV2('badge/believer'),
|
||||||
backgroundColor: '#374151',
|
},
|
||||||
|
'&[data-is-pro="true"]': {
|
||||||
|
backgroundColor: cssVarV2('badge/pro'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const UserPlanButton = ({
|
|||||||
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
|
<Tooltip content={t['com.affine.payment.tag-tooltips']()} side="top">
|
||||||
<div
|
<div
|
||||||
data-is-believer={isBeliever ? 'true' : undefined}
|
data-is-believer={isBeliever ? 'true' : undefined}
|
||||||
|
data-is-pro={plan === SubscriptionPlan.Pro ? 'true' : undefined}
|
||||||
className={styles.userPlanButton}
|
className={styles.userPlanButton}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
data-event-props="$.settingsPanel.profileAndBadge.viewPlans"
|
data-event-props="$.settingsPanel.profileAndBadge.viewPlans"
|
||||||
|
|||||||
@@ -7,5 +7,3 @@ export const openQuotaModalAtom = atom(false);
|
|||||||
|
|
||||||
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
|
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
|
||||||
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
|
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
|
||||||
|
|
||||||
export const openWorkspaceListModalAtom = atom(false);
|
|
||||||
|
|||||||
@@ -6,9 +6,14 @@ import {
|
|||||||
} from '@affine/core/modules/dialogs';
|
} from '@affine/core/modules/dialogs';
|
||||||
import { I18nService } from '@affine/core/modules/i18n';
|
import { I18nService } from '@affine/core/modules/i18n';
|
||||||
import { UrlService } from '@affine/core/modules/url';
|
import { UrlService } from '@affine/core/modules/url';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||||
import { useI18n } from '@affine/i18n';
|
import { useI18n } from '@affine/i18n';
|
||||||
import { useService, useServiceOptional } from '@toeverything/infra';
|
import {
|
||||||
|
useService,
|
||||||
|
useServiceOptional,
|
||||||
|
useServices,
|
||||||
|
} from '@toeverything/infra';
|
||||||
import { useStore } from 'jotai';
|
import { useStore } from 'jotai';
|
||||||
import { useTheme } from 'next-themes';
|
import { useTheme } from 'next-themes';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
@@ -53,24 +58,36 @@ export function useRegisterWorkspaceCommands() {
|
|||||||
const urlService = useService(UrlService);
|
const urlService = useService(UrlService);
|
||||||
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
||||||
const navigationHelper = useNavigateHelper();
|
const navigationHelper = useNavigateHelper();
|
||||||
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
const {
|
||||||
const editorSettingService = useService(EditorSettingService);
|
cMDKQuickSearchService,
|
||||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
editorSettingService,
|
||||||
const globalDialogService = useService(GlobalDialogService);
|
workspaceDialogService,
|
||||||
const appSidebarService = useService(AppSidebarService);
|
globalDialogService,
|
||||||
const i18n = useService(I18nService).i18n;
|
appSidebarService,
|
||||||
|
i18nService,
|
||||||
|
} = useServices({
|
||||||
|
CMDKQuickSearchService,
|
||||||
|
EditorSettingService,
|
||||||
|
WorkspaceDialogService,
|
||||||
|
GlobalDialogService,
|
||||||
|
AppSidebarService,
|
||||||
|
I18nService,
|
||||||
|
});
|
||||||
|
|
||||||
|
const i18n = i18nService.i18n;
|
||||||
|
|
||||||
const desktopApiService = useServiceOptional(DesktopApiService);
|
const desktopApiService = useServiceOptional(DesktopApiService);
|
||||||
|
const workbenchService = useServiceOptional(WorkbenchService);
|
||||||
|
|
||||||
const quitAndInstall = desktopApiService?.handler.updater.quitAndInstall;
|
const quitAndInstall = desktopApiService?.handler.updater.quitAndInstall;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = registerCMDKCommand(cmdkQuickSearchService);
|
const unsub = registerCMDKCommand(cMDKQuickSearchService);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
};
|
};
|
||||||
}, [cmdkQuickSearchService]);
|
}, [cMDKQuickSearchService]);
|
||||||
|
|
||||||
// register AffineUpdatesCommands
|
// register AffineUpdatesCommands
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -92,11 +109,11 @@ export function useRegisterWorkspaceCommands() {
|
|||||||
// register AffineNavigationCommands
|
// register AffineNavigationCommands
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = registerAffineNavigationCommands({
|
const unsub = registerAffineNavigationCommands({
|
||||||
store,
|
|
||||||
t,
|
t,
|
||||||
docCollection: currentWorkspace.docCollection,
|
docCollection: currentWorkspace.docCollection,
|
||||||
navigationHelper,
|
navigationHelper,
|
||||||
workspaceDialogService,
|
workspaceDialogService,
|
||||||
|
workbenchService,
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -109,6 +126,7 @@ export function useRegisterWorkspaceCommands() {
|
|||||||
navigationHelper,
|
navigationHelper,
|
||||||
globalDialogService,
|
globalDialogService,
|
||||||
workspaceDialogService,
|
workspaceDialogService,
|
||||||
|
workbenchService,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// register AffineSettingsCommands
|
// register AffineSettingsCommands
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { style } from '@vanilla-extract/css';
|
||||||
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const progressColorVar = createVar();
|
|
||||||
|
|
||||||
export const workspaceAndUserWrapper = style({
|
export const workspaceAndUserWrapper = style({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -25,81 +22,6 @@ export const workspaceWrapper = style({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const operationMenu = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 4,
|
|
||||||
});
|
|
||||||
// TODO: refactor menu, use `gap` to replace `margin`
|
|
||||||
globalStyle(`.${operationMenu} > div:not([data-divider])`, {
|
|
||||||
marginBottom: '0 !important',
|
|
||||||
marginTop: '0 !important',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const usageBlock = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
gap: 4,
|
|
||||||
borderRadius: 4,
|
|
||||||
});
|
|
||||||
export const aiUsageBlock = style({
|
|
||||||
padding: 12,
|
|
||||||
cursor: 'pointer',
|
|
||||||
selectors: {
|
|
||||||
'&[data-pro]': {
|
|
||||||
padding: '12px 12px 2px 12px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const cloudUsageBlock = style({
|
|
||||||
padding: '4px 12px',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const usageLabel = style({
|
|
||||||
fontWeight: 400,
|
|
||||||
lineHeight: '20px',
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
color: cssVar('textSecondaryColor'),
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
});
|
|
||||||
export const usageLabelTitle = style({
|
|
||||||
color: cssVar('textPrimaryColor'),
|
|
||||||
marginRight: '0.5em',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const cloudUsageBar = style({
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 5,
|
|
||||||
overflow: 'hidden',
|
|
||||||
position: 'relative',
|
|
||||||
minWidth: 260,
|
|
||||||
|
|
||||||
'::before': {
|
|
||||||
position: 'absolute',
|
|
||||||
inset: 0,
|
|
||||||
content: '""',
|
|
||||||
backgroundColor: cssVar('black'),
|
|
||||||
opacity: 0.04,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const cloudUsageBarInner = style({
|
|
||||||
height: '100%',
|
|
||||||
borderRadius: 'inherit',
|
|
||||||
backgroundColor: progressColorVar,
|
|
||||||
});
|
|
||||||
export const freeTag = style({
|
|
||||||
height: 20,
|
|
||||||
padding: '0px 4px',
|
|
||||||
borderRadius: 4,
|
|
||||||
fontWeight: 500,
|
|
||||||
fontSize: cssVar('fontXs'),
|
|
||||||
lineHeight: '20px',
|
|
||||||
color: cssVar('pureWhite'),
|
|
||||||
background: cssVar('primaryColor'),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const bottomContainer = style({
|
export const bottomContainer = style({
|
||||||
gap: 8,
|
gap: 8,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ import { SidebarAudioPlayer } from './sidebar-audio-player';
|
|||||||
import { TemplateDocEntrance } from './template-doc-entrance';
|
import { TemplateDocEntrance } from './template-doc-entrance';
|
||||||
import { TrashButton } from './trash-button';
|
import { TrashButton } from './trash-button';
|
||||||
import { UpdaterButton } from './updater-button';
|
import { UpdaterButton } from './updater-button';
|
||||||
import { UserInfo } from './user-info';
|
import UserInfo from './user-info';
|
||||||
|
|
||||||
export type RootAppSidebarProps = {
|
export type RootAppSidebarProps = {
|
||||||
isPublicWorkspace: boolean;
|
isPublicWorkspace: boolean;
|
||||||
@@ -103,10 +103,18 @@ export const RootAppSidebar = memo((): ReactElement => {
|
|||||||
const t = useI18n();
|
const t = useI18n();
|
||||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||||
const workbench = workbenchService.workbench;
|
const workbench = workbenchService.workbench;
|
||||||
|
const workspaceSelectorOpen = useLiveData(workbench.workspaceSelectorOpen$);
|
||||||
const onOpenQuickSearchModal = useCallback(() => {
|
const onOpenQuickSearchModal = useCallback(() => {
|
||||||
cMDKQuickSearchService.toggle();
|
cMDKQuickSearchService.toggle();
|
||||||
}, [cMDKQuickSearchService]);
|
}, [cMDKQuickSearchService]);
|
||||||
|
|
||||||
|
const onWorkspaceSelectorOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
workbench.setWorkspaceSelectorOpen(open);
|
||||||
|
},
|
||||||
|
[workbench]
|
||||||
|
);
|
||||||
|
|
||||||
const onOpenSettingModal = useCallback(() => {
|
const onOpenSettingModal = useCallback(() => {
|
||||||
workspaceDialogService.open('setting', {
|
workspaceDialogService.open('setting', {
|
||||||
activeTab: 'appearance',
|
activeTab: 'appearance',
|
||||||
@@ -153,7 +161,12 @@ export const RootAppSidebar = memo((): ReactElement => {
|
|||||||
<SidebarContainer>
|
<SidebarContainer>
|
||||||
<div className={workspaceAndUserWrapper}>
|
<div className={workspaceAndUserWrapper}>
|
||||||
<div className={workspaceWrapper}>
|
<div className={workspaceWrapper}>
|
||||||
<WorkspaceNavigator showEnableCloudButton showSyncStatus />
|
<WorkspaceNavigator
|
||||||
|
showEnableCloudButton
|
||||||
|
showSyncStatus
|
||||||
|
open={workspaceSelectorOpen}
|
||||||
|
onOpenChange={onWorkspaceSelectorOpenChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<UserInfo />
|
<UserInfo />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,312 +0,0 @@
|
|||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Divider,
|
|
||||||
ErrorMessage,
|
|
||||||
IconButton,
|
|
||||||
Menu,
|
|
||||||
MenuItem,
|
|
||||||
type MenuProps,
|
|
||||||
Skeleton,
|
|
||||||
} from '@affine/component';
|
|
||||||
import {
|
|
||||||
GlobalDialogService,
|
|
||||||
WorkspaceDialogService,
|
|
||||||
} from '@affine/core/modules/dialogs';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { track } from '@affine/track';
|
|
||||||
import { AccountIcon, AdminIcon, SignOutIcon } from '@blocksuite/icons/rc';
|
|
||||||
import { useLiveData, useService } from '@toeverything/infra';
|
|
||||||
import { cssVar } from '@toeverything/theme';
|
|
||||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useCallback, useEffect } from 'react';
|
|
||||||
|
|
||||||
import {
|
|
||||||
type AuthAccountInfo,
|
|
||||||
AuthService,
|
|
||||||
ServerService,
|
|
||||||
SubscriptionService,
|
|
||||||
UserCopilotQuotaService,
|
|
||||||
UserFeatureService,
|
|
||||||
UserQuotaService,
|
|
||||||
} from '../../modules/cloud';
|
|
||||||
import { UserPlanButton } from '../affine/auth/user-plan-button';
|
|
||||||
import { useSignOut } from '../hooks/affine/use-sign-out';
|
|
||||||
import { useCatchEventCallback } from '../hooks/use-catch-event-hook';
|
|
||||||
import * as styles from './index.css';
|
|
||||||
import { UnknownUserIcon } from './unknow-user';
|
|
||||||
|
|
||||||
export const UserInfo = () => {
|
|
||||||
const session = useService(AuthService).session;
|
|
||||||
const account = useLiveData(session.account$);
|
|
||||||
return account ? (
|
|
||||||
<AuthorizedUserInfo account={account} />
|
|
||||||
) : (
|
|
||||||
<UnauthorizedUserInfo />
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const menuContentOptions: MenuProps['contentOptions'] = {
|
|
||||||
className: styles.operationMenu,
|
|
||||||
};
|
|
||||||
const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => {
|
|
||||||
return (
|
|
||||||
<Menu items={<OperationMenu />} contentOptions={menuContentOptions}>
|
|
||||||
<IconButton data-testid="sidebar-user-avatar" variant="plain" size="24">
|
|
||||||
<Avatar size={24} name={account.label} url={account.avatar} />
|
|
||||||
</IconButton>
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const UnauthorizedUserInfo = () => {
|
|
||||||
const globalDialogService = useService(GlobalDialogService);
|
|
||||||
|
|
||||||
const openSignInModal = useCallback(() => {
|
|
||||||
globalDialogService.open('sign-in', {});
|
|
||||||
}, [globalDialogService]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<IconButton
|
|
||||||
onClick={openSignInModal}
|
|
||||||
data-testid="sidebar-user-avatar"
|
|
||||||
variant="plain"
|
|
||||||
size="24"
|
|
||||||
>
|
|
||||||
<UnknownUserIcon />
|
|
||||||
</IconButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccountMenu = () => {
|
|
||||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
|
||||||
const openSignOutModal = useSignOut();
|
|
||||||
const serverService = useService(ServerService);
|
|
||||||
const userFeatureService = useService(UserFeatureService);
|
|
||||||
const isAFFiNEAdmin = useLiveData(userFeatureService.userFeature.isAdmin$);
|
|
||||||
|
|
||||||
const onOpenAccountSetting = useCallback(() => {
|
|
||||||
track.$.navigationPanel.profileAndBadge.openSettings({ to: 'account' });
|
|
||||||
workspaceDialogService.open('setting', {
|
|
||||||
activeTab: 'account',
|
|
||||||
});
|
|
||||||
}, [workspaceDialogService]);
|
|
||||||
|
|
||||||
const onOpenAdminPanel = useCallback(() => {
|
|
||||||
window.open(`${serverService.server.baseUrl}/admin`, '_blank');
|
|
||||||
}, [serverService.server.baseUrl]);
|
|
||||||
|
|
||||||
const t = useI18n();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
userFeatureService.userFeature.revalidate();
|
|
||||||
}, [userFeatureService]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<MenuItem
|
|
||||||
prefixIcon={<AccountIcon />}
|
|
||||||
data-testid="workspace-modal-account-settings-option"
|
|
||||||
onClick={onOpenAccountSetting}
|
|
||||||
>
|
|
||||||
{t['com.affine.workspace.cloud.account.settings']()}
|
|
||||||
</MenuItem>
|
|
||||||
{isAFFiNEAdmin ? (
|
|
||||||
<MenuItem
|
|
||||||
prefixIcon={<AdminIcon />}
|
|
||||||
data-testid="workspace-modal-account-admin-option"
|
|
||||||
onClick={onOpenAdminPanel}
|
|
||||||
>
|
|
||||||
{t['com.affine.workspace.cloud.account.admin']()}
|
|
||||||
</MenuItem>
|
|
||||||
) : null}
|
|
||||||
<MenuItem
|
|
||||||
prefixIcon={<SignOutIcon />}
|
|
||||||
data-testid="workspace-modal-sign-out-option"
|
|
||||||
onClick={openSignOutModal}
|
|
||||||
>
|
|
||||||
{t['com.affine.workspace.cloud.account.logout']()}
|
|
||||||
</MenuItem>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CloudUsage = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
const quota = useService(UserQuotaService).quota;
|
|
||||||
const quotaError = useLiveData(quota.error$);
|
|
||||||
|
|
||||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
|
||||||
const handleClick = useCatchEventCallback(() => {
|
|
||||||
workspaceDialogService.open('setting', {
|
|
||||||
activeTab: 'plans',
|
|
||||||
scrollAnchor: 'cloudPricingPlan',
|
|
||||||
});
|
|
||||||
}, [workspaceDialogService]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// revalidate quota to get the latest status
|
|
||||||
quota.revalidate();
|
|
||||||
}, [quota]);
|
|
||||||
const color = useLiveData(quota.color$);
|
|
||||||
const usedFormatted = useLiveData(quota.usedFormatted$);
|
|
||||||
const maxFormatted = useLiveData(quota.maxFormatted$);
|
|
||||||
const percent = useLiveData(quota.percent$);
|
|
||||||
|
|
||||||
if (percent === null) {
|
|
||||||
if (quotaError) {
|
|
||||||
return <ErrorMessage>Failed to load quota</ErrorMessage>;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Skeleton height={15} width={50} />
|
|
||||||
<Skeleton height={10} style={{ marginTop: 4 }} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={clsx(styles.usageBlock, styles.cloudUsageBlock)}
|
|
||||||
style={assignInlineVars({
|
|
||||||
[styles.progressColorVar]: color,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className={styles.usageLabel}>
|
|
||||||
<div>
|
|
||||||
<span className={styles.usageLabelTitle}>
|
|
||||||
{t['com.affine.user-info.usage.cloud']()}
|
|
||||||
</span>
|
|
||||||
<span>{usedFormatted}</span>
|
|
||||||
<span> / </span>
|
|
||||||
<span>{maxFormatted}</span>
|
|
||||||
</div>
|
|
||||||
<UserPlanButton onClick={handleClick} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.cloudUsageBar}>
|
|
||||||
<div
|
|
||||||
className={styles.cloudUsageBarInner}
|
|
||||||
style={{ width: `${percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const AIUsage = () => {
|
|
||||||
const t = useI18n();
|
|
||||||
const copilotQuotaService = useService(UserCopilotQuotaService);
|
|
||||||
const subscriptionService = useService(SubscriptionService);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// revalidate latest subscription status
|
|
||||||
subscriptionService.subscription.revalidate();
|
|
||||||
}, [subscriptionService]);
|
|
||||||
useEffect(() => {
|
|
||||||
copilotQuotaService.copilotQuota.revalidate();
|
|
||||||
}, [copilotQuotaService]);
|
|
||||||
|
|
||||||
const copilotActionLimit = useLiveData(
|
|
||||||
copilotQuotaService.copilotQuota.copilotActionLimit$
|
|
||||||
);
|
|
||||||
const copilotActionUsed = useLiveData(
|
|
||||||
copilotQuotaService.copilotQuota.copilotActionUsed$
|
|
||||||
);
|
|
||||||
const loading = copilotActionLimit === null || copilotActionUsed === null;
|
|
||||||
const loadError = useLiveData(copilotQuotaService.copilotQuota.error$);
|
|
||||||
|
|
||||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
|
||||||
|
|
||||||
const goToAIPlanPage = useCallback(() => {
|
|
||||||
workspaceDialogService.open('setting', {
|
|
||||||
activeTab: 'plans',
|
|
||||||
scrollAnchor: 'aiPricingPlan',
|
|
||||||
});
|
|
||||||
}, [workspaceDialogService]);
|
|
||||||
|
|
||||||
const goToAccountSetting = useCallback(() => {
|
|
||||||
workspaceDialogService.open('setting', {
|
|
||||||
activeTab: 'account',
|
|
||||||
});
|
|
||||||
}, [workspaceDialogService]);
|
|
||||||
|
|
||||||
if (loading) {
|
|
||||||
if (loadError) console.error(loadError);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// unlimited
|
|
||||||
if (copilotActionLimit === 'unlimited') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={goToAccountSetting}
|
|
||||||
data-pro
|
|
||||||
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
|
|
||||||
>
|
|
||||||
<div className={styles.usageLabel}>
|
|
||||||
<div className={styles.usageLabelTitle}>
|
|
||||||
{t['com.affine.user-info.usage.ai']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.usageLabel}>
|
|
||||||
{t['com.affine.payment.ai.usage-description-purchased']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const percent = Math.min(
|
|
||||||
100,
|
|
||||||
Math.max(
|
|
||||||
0.5,
|
|
||||||
Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={goToAIPlanPage}
|
|
||||||
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
|
|
||||||
style={assignInlineVars({
|
|
||||||
[styles.progressColorVar]: color,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className={styles.usageLabel}>
|
|
||||||
<div>
|
|
||||||
<span className={styles.usageLabelTitle}>
|
|
||||||
{t['com.affine.user-info.usage.ai']()}
|
|
||||||
</span>
|
|
||||||
<span>{copilotActionUsed}</span>
|
|
||||||
<span> / </span>
|
|
||||||
<span>{copilotActionLimit}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.freeTag}>Free</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.cloudUsageBar}>
|
|
||||||
<div
|
|
||||||
className={styles.cloudUsageBarInner}
|
|
||||||
style={{ width: `${percent}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const OperationMenu = () => {
|
|
||||||
const serverService = useService(ServerService);
|
|
||||||
const serverFeatures = useLiveData(serverService.server.features$);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{serverFeatures?.copilot ? <AIUsage /> : null}
|
|
||||||
<CloudUsage />
|
|
||||||
<Divider />
|
|
||||||
<AccountMenu />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { MenuItem } from '@affine/component';
|
||||||
|
import { ServerService, UserFeatureService } from '@affine/core/modules/cloud';
|
||||||
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { track } from '@affine/track';
|
||||||
|
import { AccountIcon, AdminIcon, SignOutIcon } from '@blocksuite/icons/rc';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { useSignOut } from '../../hooks/affine/use-sign-out';
|
||||||
|
|
||||||
|
export const AccountMenu = () => {
|
||||||
|
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||||
|
const openSignOutModal = useSignOut();
|
||||||
|
const serverService = useService(ServerService);
|
||||||
|
const userFeatureService = useService(UserFeatureService);
|
||||||
|
const isAFFiNEAdmin = useLiveData(userFeatureService.userFeature.isAdmin$);
|
||||||
|
|
||||||
|
const onOpenAccountSetting = useCallback(() => {
|
||||||
|
track.$.navigationPanel.profileAndBadge.openSettings({ to: 'account' });
|
||||||
|
workspaceDialogService.open('setting', {
|
||||||
|
activeTab: 'account',
|
||||||
|
});
|
||||||
|
}, [workspaceDialogService]);
|
||||||
|
|
||||||
|
const onOpenAdminPanel = useCallback(() => {
|
||||||
|
window.open(`${serverService.server.baseUrl}/admin`, '_blank');
|
||||||
|
}, [serverService.server.baseUrl]);
|
||||||
|
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
userFeatureService.userFeature.revalidate();
|
||||||
|
}, [userFeatureService]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<AccountIcon />}
|
||||||
|
data-testid="workspace-modal-account-settings-option"
|
||||||
|
onClick={onOpenAccountSetting}
|
||||||
|
>
|
||||||
|
{t['com.affine.workspace.cloud.account.settings']()}
|
||||||
|
</MenuItem>
|
||||||
|
{isAFFiNEAdmin ? (
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<AdminIcon />}
|
||||||
|
data-testid="workspace-modal-account-admin-option"
|
||||||
|
onClick={onOpenAdminPanel}
|
||||||
|
>
|
||||||
|
{t['com.affine.workspace.cloud.account.admin']()}
|
||||||
|
</MenuItem>
|
||||||
|
) : null}
|
||||||
|
<MenuItem
|
||||||
|
prefixIcon={<SignOutIcon />}
|
||||||
|
data-testid="workspace-modal-sign-out-option"
|
||||||
|
onClick={openSignOutModal}
|
||||||
|
>
|
||||||
|
{t['com.affine.workspace.cloud.account.logout']()}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Avatar } from '@affine/component';
|
||||||
|
import { AuthService } from '@affine/core/modules/cloud';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import * as styles from './index.css';
|
||||||
|
|
||||||
|
export const Account = () => {
|
||||||
|
const account = useLiveData(useService(AuthService).session.account$);
|
||||||
|
if (!account) {
|
||||||
|
// TODO(@JimmFly): loading ui
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div data-testid="user-info-card" className={styles.account}>
|
||||||
|
<Avatar
|
||||||
|
size={28}
|
||||||
|
rounded={50}
|
||||||
|
name={account.label}
|
||||||
|
url={account.avatar}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className={styles.content}>
|
||||||
|
<div
|
||||||
|
className={styles.name}
|
||||||
|
title={account.label}
|
||||||
|
content={account.label}
|
||||||
|
>
|
||||||
|
{account.label}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={styles.email}
|
||||||
|
title={account.email}
|
||||||
|
content={account.email}
|
||||||
|
>
|
||||||
|
{account.email}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import {
|
||||||
|
SubscriptionService,
|
||||||
|
UserCopilotQuotaService,
|
||||||
|
} from '@affine/core/modules/cloud';
|
||||||
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
|
||||||
|
import * as styles from './index.css';
|
||||||
|
|
||||||
|
export const AIUsage = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const copilotQuotaService = useService(UserCopilotQuotaService);
|
||||||
|
const subscriptionService = useService(SubscriptionService);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// revalidate latest subscription status
|
||||||
|
subscriptionService.subscription.revalidate();
|
||||||
|
}, [subscriptionService]);
|
||||||
|
useEffect(() => {
|
||||||
|
copilotQuotaService.copilotQuota.revalidate();
|
||||||
|
}, [copilotQuotaService]);
|
||||||
|
|
||||||
|
const copilotActionLimit = useLiveData(
|
||||||
|
copilotQuotaService.copilotQuota.copilotActionLimit$
|
||||||
|
);
|
||||||
|
const copilotActionUsed = useLiveData(
|
||||||
|
copilotQuotaService.copilotQuota.copilotActionUsed$
|
||||||
|
);
|
||||||
|
const loading = copilotActionLimit === null || copilotActionUsed === null;
|
||||||
|
const loadError = useLiveData(copilotQuotaService.copilotQuota.error$);
|
||||||
|
|
||||||
|
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||||
|
|
||||||
|
const goToAIPlanPage = useCallback(() => {
|
||||||
|
workspaceDialogService.open('setting', {
|
||||||
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'aiPricingPlan',
|
||||||
|
});
|
||||||
|
}, [workspaceDialogService]);
|
||||||
|
|
||||||
|
const goToAccountSetting = useCallback(() => {
|
||||||
|
workspaceDialogService.open('setting', {
|
||||||
|
activeTab: 'account',
|
||||||
|
});
|
||||||
|
}, [workspaceDialogService]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
if (loadError) console.error(loadError);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// unlimited
|
||||||
|
if (copilotActionLimit === 'unlimited') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={goToAccountSetting}
|
||||||
|
data-pro
|
||||||
|
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
|
||||||
|
>
|
||||||
|
<div className={styles.usageLabel}>
|
||||||
|
<div className={styles.usageLabelTitle}>
|
||||||
|
{t['com.affine.user-info.usage.ai']()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.usageLabel}>
|
||||||
|
{t['com.affine.payment.ai.usage-description-purchased']()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const percent = Math.min(
|
||||||
|
100,
|
||||||
|
Math.max(
|
||||||
|
0.5,
|
||||||
|
Number(((copilotActionUsed / copilotActionLimit) * 100).toFixed(4))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const color = percent > 80 ? cssVar('errorColor') : cssVar('processingColor');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={goToAIPlanPage}
|
||||||
|
className={clsx(styles.usageBlock, styles.aiUsageBlock)}
|
||||||
|
style={assignInlineVars({
|
||||||
|
[styles.progressColorVar]: color,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={styles.usageLabel}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.usageLabelTitle}>
|
||||||
|
{t['com.affine.user-info.usage.ai']()}
|
||||||
|
</span>
|
||||||
|
<span>{copilotActionUsed}</span>
|
||||||
|
<span> / </span>
|
||||||
|
<span>{copilotActionLimit}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.freeTag}>Free</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cloudUsageBar}>
|
||||||
|
<div
|
||||||
|
className={styles.cloudUsageBarInner}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { ErrorMessage, Skeleton } from '@affine/component';
|
||||||
|
import { UserQuotaService } from '@affine/core/modules/cloud';
|
||||||
|
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
import { UserPlanButton } from '../../affine/auth/user-plan-button';
|
||||||
|
import { useCatchEventCallback } from '../../hooks/use-catch-event-hook';
|
||||||
|
import * as styles from './index.css';
|
||||||
|
|
||||||
|
export const CloudUsage = () => {
|
||||||
|
const t = useI18n();
|
||||||
|
const quota = useService(UserQuotaService).quota;
|
||||||
|
const quotaError = useLiveData(quota.error$);
|
||||||
|
|
||||||
|
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||||
|
const handleClick = useCatchEventCallback(() => {
|
||||||
|
workspaceDialogService.open('setting', {
|
||||||
|
activeTab: 'plans',
|
||||||
|
scrollAnchor: 'cloudPricingPlan',
|
||||||
|
});
|
||||||
|
}, [workspaceDialogService]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// revalidate quota to get the latest status
|
||||||
|
quota.revalidate();
|
||||||
|
}, [quota]);
|
||||||
|
const color = useLiveData(quota.color$);
|
||||||
|
const usedFormatted = useLiveData(quota.usedFormatted$);
|
||||||
|
const maxFormatted = useLiveData(quota.maxFormatted$);
|
||||||
|
const percent = useLiveData(quota.percent$);
|
||||||
|
|
||||||
|
if (percent === null) {
|
||||||
|
if (quotaError) {
|
||||||
|
return <ErrorMessage>Failed to load quota</ErrorMessage>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Skeleton height={15} width={50} />
|
||||||
|
<Skeleton height={10} style={{ marginTop: 4 }} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={clsx(styles.usageBlock, styles.cloudUsageBlock)}
|
||||||
|
style={assignInlineVars({
|
||||||
|
[styles.progressColorVar]: color,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<div className={styles.usageLabel}>
|
||||||
|
<div>
|
||||||
|
<span className={styles.usageLabelTitle}>
|
||||||
|
{t['com.affine.user-info.usage.cloud']()}
|
||||||
|
</span>
|
||||||
|
<span>{usedFormatted}</span>
|
||||||
|
<span> / </span>
|
||||||
|
<span>{maxFormatted}</span>
|
||||||
|
</div>
|
||||||
|
<UserPlanButton onClick={handleClick} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.cloudUsageBar}>
|
||||||
|
<div
|
||||||
|
className={styles.cloudUsageBarInner}
|
||||||
|
style={{ width: `${percent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
|
import { createVar, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const progressColorVar = createVar();
|
||||||
|
|
||||||
|
export const operationMenu = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const account = style({
|
||||||
|
padding: '4px 12px',
|
||||||
|
userSelect: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '12px',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
export const content = style({
|
||||||
|
flexGrow: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
maxWidth: '220px',
|
||||||
|
});
|
||||||
|
export const name = style({
|
||||||
|
fontSize: cssVar('fontSm'),
|
||||||
|
fontWeight: 500,
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
height: '22px',
|
||||||
|
});
|
||||||
|
export const email = style({
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
overflow: 'hidden',
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
flexGrow: 1,
|
||||||
|
height: '20px',
|
||||||
|
});
|
||||||
|
export const usageBlock = style({
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 4,
|
||||||
|
borderRadius: 4,
|
||||||
|
});
|
||||||
|
export const aiUsageBlock = style({
|
||||||
|
padding: '0px 6px 12px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
});
|
||||||
|
export const cloudUsageBlock = style({
|
||||||
|
padding: '0px 6px 12px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const usageLabel = style({
|
||||||
|
fontWeight: 400,
|
||||||
|
lineHeight: '20px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
color: cssVarV2('text/secondary'),
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
export const usageLabelTitle = style({
|
||||||
|
color: cssVarV2('text/primary'),
|
||||||
|
marginRight: '0.5em',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const cloudUsageBar = style({
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
overflow: 'hidden',
|
||||||
|
position: 'relative',
|
||||||
|
minWidth: 260,
|
||||||
|
|
||||||
|
'::before': {
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
content: '""',
|
||||||
|
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
export const cloudUsageBarInner = style({
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 'inherit',
|
||||||
|
backgroundColor: progressColorVar,
|
||||||
|
});
|
||||||
|
export const freeTag = style({
|
||||||
|
height: 16,
|
||||||
|
padding: '0px 4px',
|
||||||
|
borderRadius: 2,
|
||||||
|
fontWeight: 400,
|
||||||
|
fontSize: '10px',
|
||||||
|
lineHeight: '16px',
|
||||||
|
color: cssVarV2('button/pureWhiteText'),
|
||||||
|
background: cssVarV2('badge/free'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamWorkspace = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '4px',
|
||||||
|
borderRadius: 4,
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: '2px 6px',
|
||||||
|
':hover': {
|
||||||
|
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamAvatarStack = style({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const workspaceAvatar = style({
|
||||||
|
borderRadius: 4,
|
||||||
|
border: `1px solid ${cssVarV2('layer/white')}`,
|
||||||
|
selectors: {
|
||||||
|
'&.multi-avatar': {
|
||||||
|
marginLeft: -4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamName = style({
|
||||||
|
flex: 1,
|
||||||
|
overflow: 'hidden',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
textOverflow: 'ellipsis',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
maxWidth: '170px',
|
||||||
|
lineHeight: '20px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const teamBadge = style({
|
||||||
|
marginLeft: 'auto',
|
||||||
|
marginRight: '0px',
|
||||||
|
padding: '0px 4px',
|
||||||
|
borderRadius: '2px',
|
||||||
|
fontSize: '10px',
|
||||||
|
color: cssVarV2('button/pureWhiteText'),
|
||||||
|
background: cssVarV2('badge/believer'),
|
||||||
|
height: '16px',
|
||||||
|
lineHeight: '16px',
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Divider,
|
||||||
|
IconButton,
|
||||||
|
Menu,
|
||||||
|
type MenuProps,
|
||||||
|
} from '@affine/component';
|
||||||
|
import {
|
||||||
|
type AuthAccountInfo,
|
||||||
|
AuthService,
|
||||||
|
ServerService,
|
||||||
|
} from '@affine/core/modules/cloud';
|
||||||
|
import { GlobalDialogService } from '@affine/core/modules/dialogs';
|
||||||
|
import { useLiveData, useService } from '@toeverything/infra';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { Account } from './account';
|
||||||
|
import { AccountMenu } from './account-menu';
|
||||||
|
import { AIUsage } from './ai-usage';
|
||||||
|
import { CloudUsage } from './cloud-usage';
|
||||||
|
import * as styles from './index.css';
|
||||||
|
import { TeamList } from './team-list';
|
||||||
|
import { UnknownUserIcon } from './unknow-user';
|
||||||
|
|
||||||
|
export default function UserInfo() {
|
||||||
|
const session = useService(AuthService).session;
|
||||||
|
const account = useLiveData(session.account$);
|
||||||
|
return account ? (
|
||||||
|
<AuthorizedUserInfo account={account} />
|
||||||
|
) : (
|
||||||
|
<UnauthorizedUserInfo />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuContentOptions: MenuProps['contentOptions'] = {
|
||||||
|
className: styles.operationMenu,
|
||||||
|
};
|
||||||
|
const AuthorizedUserInfo = ({ account }: { account: AuthAccountInfo }) => {
|
||||||
|
return (
|
||||||
|
<Menu items={<OperationMenu />} contentOptions={menuContentOptions}>
|
||||||
|
<IconButton data-testid="sidebar-user-avatar" variant="plain" size="24">
|
||||||
|
<Avatar size={24} name={account.label} url={account.avatar} />
|
||||||
|
</IconButton>
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const UnauthorizedUserInfo = () => {
|
||||||
|
const globalDialogService = useService(GlobalDialogService);
|
||||||
|
|
||||||
|
const openSignInModal = useCallback(() => {
|
||||||
|
globalDialogService.open('sign-in', {});
|
||||||
|
}, [globalDialogService]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
onClick={openSignInModal}
|
||||||
|
data-testid="sidebar-user-avatar"
|
||||||
|
variant="plain"
|
||||||
|
size="24"
|
||||||
|
>
|
||||||
|
<UnknownUserIcon />
|
||||||
|
</IconButton>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const OperationMenu = () => {
|
||||||
|
const serverService = useService(ServerService);
|
||||||
|
const serverFeatures = useLiveData(serverService.server.features$);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Account />
|
||||||
|
<Divider />
|
||||||
|
<CloudUsage />
|
||||||
|
{serverFeatures?.copilot ? <AIUsage /> : null}
|
||||||
|
<Divider />
|
||||||
|
<TeamList />
|
||||||
|
<AccountMenu />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { Divider, Tooltip } from '@affine/component';
|
||||||
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
|
import {
|
||||||
|
type WorkspaceMetadata,
|
||||||
|
WorkspacesService,
|
||||||
|
} from '@affine/core/modules/workspace';
|
||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import {
|
||||||
|
useLiveData,
|
||||||
|
useService,
|
||||||
|
useServiceOptional,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { memo, useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { useNavigateHelper } from '../../hooks/use-navigate-helper';
|
||||||
|
import { WorkspaceAvatar } from '../../workspace-avatar';
|
||||||
|
import * as styles from './index.css';
|
||||||
|
|
||||||
|
type WorkspaceProfile = ReturnType<WorkspacesService['getProfile']>;
|
||||||
|
interface WorkspaceInfo {
|
||||||
|
profile: WorkspaceProfile;
|
||||||
|
meta: WorkspaceMetadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TeamItemProps {
|
||||||
|
workspaces: WorkspaceInfo[];
|
||||||
|
badgeText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TeamItem = memo(({ workspaces, badgeText }: TeamItemProps) => {
|
||||||
|
const t = useI18n();
|
||||||
|
|
||||||
|
const { jumpToPage } = useNavigateHelper();
|
||||||
|
const workbench = useServiceOptional(WorkbenchService)?.workbench;
|
||||||
|
|
||||||
|
const handleJumpToWorkspace = useCallback(() => {
|
||||||
|
const closeInactiveViews = () =>
|
||||||
|
workbench?.views$.value.forEach(view => {
|
||||||
|
if (workbench?.activeView$.value !== view) {
|
||||||
|
workbench?.close(view);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (workspaces.length !== 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.startViewTransition) {
|
||||||
|
document.startViewTransition(() => {
|
||||||
|
closeInactiveViews();
|
||||||
|
jumpToPage(workspaces[0].profile.id, 'all');
|
||||||
|
return new Promise(resolve =>
|
||||||
|
setTimeout(resolve, 150)
|
||||||
|
); /* start transition after 150ms */
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
closeInactiveViews();
|
||||||
|
jumpToPage(workspaces[0].profile.id, 'all');
|
||||||
|
}
|
||||||
|
}, [jumpToPage, workbench, workspaces]);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (workspaces.length === 1) {
|
||||||
|
handleJumpToWorkspace();
|
||||||
|
} else {
|
||||||
|
workbench?.setWorkspaceSelectorOpen(true);
|
||||||
|
}
|
||||||
|
}, [handleJumpToWorkspace, workbench, workspaces.length]);
|
||||||
|
|
||||||
|
if (workspaces.length === 0) return null;
|
||||||
|
|
||||||
|
const displayName =
|
||||||
|
workspaces.length > 1
|
||||||
|
? t['com.affine.workspace.cloud.account.team.multi']()
|
||||||
|
: workspaces[0].profile.profile$.value?.name || 'Team';
|
||||||
|
|
||||||
|
const tooltipContent =
|
||||||
|
workspaces.length > 1
|
||||||
|
? t['com.affine.workspace.cloud.account.team.tips-2']()
|
||||||
|
: t['com.affine.workspace.cloud.account.team.tips-1']();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={tooltipContent}>
|
||||||
|
<div className={styles.teamWorkspace} onClick={handleClick}>
|
||||||
|
<div className={styles.teamAvatarStack}>
|
||||||
|
{workspaces.map((workspace, index) => (
|
||||||
|
<WorkspaceAvatar
|
||||||
|
key={workspace.profile.id}
|
||||||
|
meta={workspace.meta}
|
||||||
|
rounded={3}
|
||||||
|
size={16}
|
||||||
|
name={workspace.profile.profile$.value?.name}
|
||||||
|
colorfulFallback
|
||||||
|
className={clsx(styles.workspaceAvatar, {
|
||||||
|
['multi-avatar']: index !== 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<span className={styles.teamName}>{displayName}</span>
|
||||||
|
<span className={styles.teamBadge}>{badgeText}</span>
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TeamItem.displayName = 'TeamItem';
|
||||||
|
|
||||||
|
export const TeamList = memo(() => {
|
||||||
|
const t = useI18n();
|
||||||
|
const workspacesService = useService(WorkspacesService);
|
||||||
|
const workspaceMetas = useLiveData(workspacesService.list.workspaces$);
|
||||||
|
const workspaceProfiles = workspacesService.getAllWorkspaceProfile();
|
||||||
|
|
||||||
|
const teamWorkspaces = useMemo(() => {
|
||||||
|
const ownerWorkspaces: WorkspaceInfo[] = [];
|
||||||
|
const memberWorkspaces: WorkspaceInfo[] = [];
|
||||||
|
|
||||||
|
const metaMap = new Map(workspaceMetas.map(meta => [meta.id, meta]));
|
||||||
|
|
||||||
|
for (const profile of workspaceProfiles) {
|
||||||
|
if (!profile.profile$.value?.isTeam) continue;
|
||||||
|
|
||||||
|
const meta = metaMap.get(profile.id);
|
||||||
|
if (!meta) continue;
|
||||||
|
|
||||||
|
if (profile.profile$.value.isOwner) {
|
||||||
|
ownerWorkspaces.push({ profile, meta });
|
||||||
|
} else {
|
||||||
|
memberWorkspaces.push({ profile, meta });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// only show avatar stack when multiple workspaces
|
||||||
|
const slicedOwnerWorkspaces = ownerWorkspaces.slice(0, 3);
|
||||||
|
const slicedMemberWorkspaces = memberWorkspaces.slice(0, 3);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerWorkspaces: slicedOwnerWorkspaces,
|
||||||
|
memberWorkspaces: slicedMemberWorkspaces,
|
||||||
|
};
|
||||||
|
}, [workspaceProfiles, workspaceMetas]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
teamWorkspaces.ownerWorkspaces.length === 0 &&
|
||||||
|
teamWorkspaces.memberWorkspaces.length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{teamWorkspaces.ownerWorkspaces.length > 0 && (
|
||||||
|
<TeamItem
|
||||||
|
workspaces={teamWorkspaces.ownerWorkspaces}
|
||||||
|
badgeText={t['com.affine.workspace.cloud.account.team.owner']()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{teamWorkspaces.memberWorkspaces.length > 0 && (
|
||||||
|
<TeamItem
|
||||||
|
workspaces={teamWorkspaces.memberWorkspaces}
|
||||||
|
badgeText={t['com.affine.workspace.cloud.account.team.member']()}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TeamList.displayName = 'TeamList';
|
||||||
@@ -19,6 +19,7 @@ import { WorkspaceCard } from './workspace-card';
|
|||||||
|
|
||||||
interface WorkspaceSelectorProps {
|
interface WorkspaceSelectorProps {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
|
onOpenChange?: (open: boolean) => void;
|
||||||
workspaceMetadata?: WorkspaceMetadata;
|
workspaceMetadata?: WorkspaceMetadata;
|
||||||
onSelectWorkspace?: (workspaceMetadata: WorkspaceMetadata) => void;
|
onSelectWorkspace?: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||||
onCreatedWorkspace?: (payload: {
|
onCreatedWorkspace?: (payload: {
|
||||||
@@ -40,6 +41,7 @@ export const WorkspaceSelector = ({
|
|||||||
showArrowDownIcon,
|
showArrowDownIcon,
|
||||||
disable,
|
disable,
|
||||||
open: outerOpen,
|
open: outerOpen,
|
||||||
|
onOpenChange: outerOnOpenChange,
|
||||||
showEnableCloudButton,
|
showEnableCloudButton,
|
||||||
showSyncStatus,
|
showSyncStatus,
|
||||||
className,
|
className,
|
||||||
@@ -51,13 +53,29 @@ export const WorkspaceSelector = ({
|
|||||||
});
|
});
|
||||||
const [innerOpen, setOpened] = useState(false);
|
const [innerOpen, setOpened] = useState(false);
|
||||||
const open = outerOpen ?? innerOpen;
|
const open = outerOpen ?? innerOpen;
|
||||||
|
const onOpenChange = useCallback(
|
||||||
|
(open: boolean) => {
|
||||||
|
outerOnOpenChange !== undefined
|
||||||
|
? outerOnOpenChange?.(open)
|
||||||
|
: setOpened(open);
|
||||||
|
},
|
||||||
|
[outerOnOpenChange]
|
||||||
|
);
|
||||||
const closeUserWorkspaceList = useCallback(() => {
|
const closeUserWorkspaceList = useCallback(() => {
|
||||||
setOpened(false);
|
if (outerOnOpenChange) {
|
||||||
}, []);
|
outerOnOpenChange(false);
|
||||||
|
} else {
|
||||||
|
setOpened(false);
|
||||||
|
}
|
||||||
|
}, [outerOnOpenChange]);
|
||||||
const openUserWorkspaceList = useCallback(() => {
|
const openUserWorkspaceList = useCallback(() => {
|
||||||
track.$.navigationPanel.workspaceList.open();
|
track.$.navigationPanel.workspaceList.open();
|
||||||
setOpened(true);
|
if (outerOnOpenChange) {
|
||||||
}, []);
|
outerOnOpenChange(true);
|
||||||
|
} else {
|
||||||
|
setOpened(true);
|
||||||
|
}
|
||||||
|
}, [outerOnOpenChange]);
|
||||||
|
|
||||||
const currentWorkspaceId = useLiveData(
|
const currentWorkspaceId = useLiveData(
|
||||||
globalContextService.globalContext.workspaceId.$
|
globalContextService.globalContext.workspaceId.$
|
||||||
@@ -80,6 +98,7 @@ export const WorkspaceSelector = ({
|
|||||||
<Menu
|
<Menu
|
||||||
rootOptions={{
|
rootOptions={{
|
||||||
open,
|
open,
|
||||||
|
onOpenChange,
|
||||||
}}
|
}}
|
||||||
items={
|
items={
|
||||||
<UserWithWorkspaceList
|
<UserWithWorkspaceList
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
onStart,
|
onStart,
|
||||||
smartRetry,
|
smartRetry,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import { map, tap } from 'rxjs';
|
import { map, tap } from 'rxjs';
|
||||||
|
|
||||||
@@ -48,8 +48,8 @@ export class UserQuota extends Entity {
|
|||||||
color$ = this.percent$.map(percent =>
|
color$ = this.percent$.map(percent =>
|
||||||
percent !== null
|
percent !== null
|
||||||
? percent > 80
|
? percent > 80
|
||||||
? cssVar('errorColor')
|
? cssVarV2('toast/iconState/error')
|
||||||
: cssVar('processingColor')
|
: cssVarV2('toast/iconState/regular')
|
||||||
: null
|
: null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export type WorkbenchOpenOptions = {
|
|||||||
|
|
||||||
const sidebarOpenKey = 'workbenchSidebarOpen';
|
const sidebarOpenKey = 'workbenchSidebarOpen';
|
||||||
const sidebarWidthKey = 'workbenchSidebarWidth';
|
const sidebarWidthKey = 'workbenchSidebarWidth';
|
||||||
|
const workspaceSelectorOpenKey = 'workspaceSelectorOpen';
|
||||||
|
|
||||||
export class Workbench extends Entity {
|
export class Workbench extends Entity {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -70,6 +71,14 @@ export class Workbench extends Entity {
|
|||||||
this.globalState.set(sidebarWidthKey, width);
|
this.globalState.set(sidebarWidthKey, width);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
workspaceSelectorOpen$ = LiveData.from(
|
||||||
|
this.globalState.watch<boolean>(workspaceSelectorOpenKey),
|
||||||
|
false
|
||||||
|
);
|
||||||
|
setWorkspaceSelectorOpen(open: boolean) {
|
||||||
|
this.globalState.set(workspaceSelectorOpenKey, open);
|
||||||
|
}
|
||||||
|
|
||||||
active(index: number | View) {
|
active(index: number | View) {
|
||||||
if (typeof index === 'number') {
|
if (typeof index === 'number') {
|
||||||
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
|
index = Math.max(0, Math.min(index, this.views$.value.length - 1));
|
||||||
@@ -114,6 +123,18 @@ export class Workbench extends Entity {
|
|||||||
this.setSidebarOpen(!this.sidebarOpen$.value);
|
this.setSidebarOpen(!this.sidebarOpen$.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openWorkspaceSelector() {
|
||||||
|
this.setWorkspaceSelectorOpen(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeWorkspaceSelector() {
|
||||||
|
this.setWorkspaceSelectorOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleWorkspaceSelector() {
|
||||||
|
this.setWorkspaceSelectorOpen(!this.workspaceSelectorOpen$.value);
|
||||||
|
}
|
||||||
|
|
||||||
open(to: To, option: WorkbenchOpenOptions = {}) {
|
open(to: To, option: WorkbenchOpenOptions = {}) {
|
||||||
if (option.at === 'new-tab') {
|
if (option.at === 'new-tab') {
|
||||||
this.newTab(to, {
|
this.newTab(to, {
|
||||||
|
|||||||
@@ -61,4 +61,10 @@ export class WorkspacesService extends Service {
|
|||||||
x => x.flavour === meta.flavour
|
x => x.flavour === meta.flavour
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAllWorkspaceProfile() {
|
||||||
|
const list = this.listService.list.workspaces$.value;
|
||||||
|
const profiles = list.map(meta => this.getProfile(meta));
|
||||||
|
return profiles;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"el-GR": 95,
|
"el-GR": 95,
|
||||||
"en": 100,
|
"en": 100,
|
||||||
"es-AR": 95,
|
"es-AR": 95,
|
||||||
"es-CL": 97,
|
"es-CL": 96,
|
||||||
"es": 95,
|
"es": 95,
|
||||||
"fa": 95,
|
"fa": 95,
|
||||||
"fr": 95,
|
"fr": 95,
|
||||||
|
|||||||
@@ -6892,6 +6892,26 @@ export function useAFFiNEI18N(): {
|
|||||||
* `Admin panel`
|
* `Admin panel`
|
||||||
*/
|
*/
|
||||||
["com.affine.workspace.cloud.account.admin"](): string;
|
["com.affine.workspace.cloud.account.admin"](): string;
|
||||||
|
/**
|
||||||
|
* `Team owner`
|
||||||
|
*/
|
||||||
|
["com.affine.workspace.cloud.account.team.owner"](): string;
|
||||||
|
/**
|
||||||
|
* `Team member`
|
||||||
|
*/
|
||||||
|
["com.affine.workspace.cloud.account.team.member"](): string;
|
||||||
|
/**
|
||||||
|
* `Multiple teams`
|
||||||
|
*/
|
||||||
|
["com.affine.workspace.cloud.account.team.multi"](): string;
|
||||||
|
/**
|
||||||
|
* `Click to open workspace`
|
||||||
|
*/
|
||||||
|
["com.affine.workspace.cloud.account.team.tips-1"](): string;
|
||||||
|
/**
|
||||||
|
* `Click to open workspace list`
|
||||||
|
*/
|
||||||
|
["com.affine.workspace.cloud.account.team.tips-2"](): string;
|
||||||
/**
|
/**
|
||||||
* `Sign up/ Sign in`
|
* `Sign up/ Sign in`
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1718,6 +1718,11 @@
|
|||||||
"com.affine.workspace.cloud.account.logout": "Sign out",
|
"com.affine.workspace.cloud.account.logout": "Sign out",
|
||||||
"com.affine.workspace.cloud.account.settings": "Account settings",
|
"com.affine.workspace.cloud.account.settings": "Account settings",
|
||||||
"com.affine.workspace.cloud.account.admin": "Admin panel",
|
"com.affine.workspace.cloud.account.admin": "Admin panel",
|
||||||
|
"com.affine.workspace.cloud.account.team.owner": "Team owner",
|
||||||
|
"com.affine.workspace.cloud.account.team.member": "Team member",
|
||||||
|
"com.affine.workspace.cloud.account.team.multi": "Multiple teams",
|
||||||
|
"com.affine.workspace.cloud.account.team.tips-1": "Click to open workspace",
|
||||||
|
"com.affine.workspace.cloud.account.team.tips-2": "Click to open workspace list",
|
||||||
"com.affine.workspace.cloud.auth": "Sign up/ Sign in",
|
"com.affine.workspace.cloud.auth": "Sign up/ Sign in",
|
||||||
"com.affine.workspace.cloud.description": "Sync with AFFiNE Cloud",
|
"com.affine.workspace.cloud.description": "Sync with AFFiNE Cloud",
|
||||||
"com.affine.workspace.cloud.join": "Join workspace",
|
"com.affine.workspace.cloud.join": "Join workspace",
|
||||||
|
|||||||
Reference in New Issue
Block a user