mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 09:06:19 +08:00
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 type { Workspace } from '@blocksuite/affine/store';
|
||||
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 { WorkspaceDialogService } from '../modules/dialogs';
|
||||
import type { WorkbenchService } from '../modules/workbench';
|
||||
import { registerAffineCommand } from './registry';
|
||||
|
||||
export function registerAffineNavigationCommands({
|
||||
t,
|
||||
store,
|
||||
docCollection,
|
||||
navigationHelper,
|
||||
workspaceDialogService,
|
||||
workbenchService,
|
||||
}: {
|
||||
t: ReturnType<typeof useI18n>;
|
||||
store: ReturnType<typeof createStore>;
|
||||
navigationHelper: ReturnType<typeof useNavigateHelper>;
|
||||
docCollection: Workspace;
|
||||
workspaceDialogService: WorkspaceDialogService;
|
||||
workbenchService?: WorkbenchService;
|
||||
}) {
|
||||
const unsubs: Array<() => void> = [];
|
||||
unsubs.push(
|
||||
@@ -82,7 +81,7 @@ export function registerAffineNavigationCommands({
|
||||
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';
|
||||
|
||||
export const userPlanButton = style({
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontXs'),
|
||||
height: 20,
|
||||
fontWeight: 500,
|
||||
fontSize: '10px',
|
||||
height: 16,
|
||||
fontWeight: 400,
|
||||
cursor: 'pointer',
|
||||
color: cssVar('pureWhite'),
|
||||
backgroundColor: cssVar('brandColor'),
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
backgroundColor: cssVarV2('badge/free'),
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
borderRadius: 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
|
||||
selectors: {
|
||||
'&[data-is-believer="true"]': {
|
||||
// TODO(@CatsJuice): this color is new `Figma token` value without dark mode support.
|
||||
backgroundColor: '#374151',
|
||||
backgroundColor: cssVarV2('badge/believer'),
|
||||
},
|
||||
'&[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">
|
||||
<div
|
||||
data-is-believer={isBeliever ? 'true' : undefined}
|
||||
data-is-pro={plan === SubscriptionPlan.Pro ? 'true' : undefined}
|
||||
className={styles.userPlanButton}
|
||||
onClick={onClick}
|
||||
data-event-props="$.settingsPanel.profileAndBadge.viewPlans"
|
||||
|
||||
@@ -7,5 +7,3 @@ export const openQuotaModalAtom = atom(false);
|
||||
|
||||
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
|
||||
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');
|
||||
|
||||
export const openWorkspaceListModalAtom = atom(false);
|
||||
|
||||
@@ -6,9 +6,14 @@ import {
|
||||
} from '@affine/core/modules/dialogs';
|
||||
import { I18nService } from '@affine/core/modules/i18n';
|
||||
import { UrlService } from '@affine/core/modules/url';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService, useServiceOptional } from '@toeverything/infra';
|
||||
import {
|
||||
useService,
|
||||
useServiceOptional,
|
||||
useServices,
|
||||
} from '@toeverything/infra';
|
||||
import { useStore } from 'jotai';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useEffect } from 'react';
|
||||
@@ -53,24 +58,36 @@ export function useRegisterWorkspaceCommands() {
|
||||
const urlService = useService(UrlService);
|
||||
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
||||
const navigationHelper = useNavigateHelper();
|
||||
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
||||
const editorSettingService = useService(EditorSettingService);
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
const globalDialogService = useService(GlobalDialogService);
|
||||
const appSidebarService = useService(AppSidebarService);
|
||||
const i18n = useService(I18nService).i18n;
|
||||
const {
|
||||
cMDKQuickSearchService,
|
||||
editorSettingService,
|
||||
workspaceDialogService,
|
||||
globalDialogService,
|
||||
appSidebarService,
|
||||
i18nService,
|
||||
} = useServices({
|
||||
CMDKQuickSearchService,
|
||||
EditorSettingService,
|
||||
WorkspaceDialogService,
|
||||
GlobalDialogService,
|
||||
AppSidebarService,
|
||||
I18nService,
|
||||
});
|
||||
|
||||
const i18n = i18nService.i18n;
|
||||
|
||||
const desktopApiService = useServiceOptional(DesktopApiService);
|
||||
const workbenchService = useServiceOptional(WorkbenchService);
|
||||
|
||||
const quitAndInstall = desktopApiService?.handler.updater.quitAndInstall;
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = registerCMDKCommand(cmdkQuickSearchService);
|
||||
const unsub = registerCMDKCommand(cMDKQuickSearchService);
|
||||
|
||||
return () => {
|
||||
unsub();
|
||||
};
|
||||
}, [cmdkQuickSearchService]);
|
||||
}, [cMDKQuickSearchService]);
|
||||
|
||||
// register AffineUpdatesCommands
|
||||
useEffect(() => {
|
||||
@@ -92,11 +109,11 @@ export function useRegisterWorkspaceCommands() {
|
||||
// register AffineNavigationCommands
|
||||
useEffect(() => {
|
||||
const unsub = registerAffineNavigationCommands({
|
||||
store,
|
||||
t,
|
||||
docCollection: currentWorkspace.docCollection,
|
||||
navigationHelper,
|
||||
workspaceDialogService,
|
||||
workbenchService,
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -109,6 +126,7 @@ export function useRegisterWorkspaceCommands() {
|
||||
navigationHelper,
|
||||
globalDialogService,
|
||||
workspaceDialogService,
|
||||
workbenchService,
|
||||
]);
|
||||
|
||||
// register AffineSettingsCommands
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { createVar, globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const progressColorVar = createVar();
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const workspaceAndUserWrapper = style({
|
||||
display: 'flex',
|
||||
@@ -25,81 +22,6 @@ export const workspaceWrapper = style({
|
||||
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({
|
||||
gap: 8,
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ import { SidebarAudioPlayer } from './sidebar-audio-player';
|
||||
import { TemplateDocEntrance } from './template-doc-entrance';
|
||||
import { TrashButton } from './trash-button';
|
||||
import { UpdaterButton } from './updater-button';
|
||||
import { UserInfo } from './user-info';
|
||||
import UserInfo from './user-info';
|
||||
|
||||
export type RootAppSidebarProps = {
|
||||
isPublicWorkspace: boolean;
|
||||
@@ -103,10 +103,18 @@ export const RootAppSidebar = memo((): ReactElement => {
|
||||
const t = useI18n();
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
const workbench = workbenchService.workbench;
|
||||
const workspaceSelectorOpen = useLiveData(workbench.workspaceSelectorOpen$);
|
||||
const onOpenQuickSearchModal = useCallback(() => {
|
||||
cMDKQuickSearchService.toggle();
|
||||
}, [cMDKQuickSearchService]);
|
||||
|
||||
const onWorkspaceSelectorOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
workbench.setWorkspaceSelectorOpen(open);
|
||||
},
|
||||
[workbench]
|
||||
);
|
||||
|
||||
const onOpenSettingModal = useCallback(() => {
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: 'appearance',
|
||||
@@ -153,7 +161,12 @@ export const RootAppSidebar = memo((): ReactElement => {
|
||||
<SidebarContainer>
|
||||
<div className={workspaceAndUserWrapper}>
|
||||
<div className={workspaceWrapper}>
|
||||
<WorkspaceNavigator showEnableCloudButton showSyncStatus />
|
||||
<WorkspaceNavigator
|
||||
showEnableCloudButton
|
||||
showSyncStatus
|
||||
open={workspaceSelectorOpen}
|
||||
onOpenChange={onWorkspaceSelectorOpenChange}
|
||||
/>
|
||||
</div>
|
||||
<UserInfo />
|
||||
</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 {
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
workspaceMetadata?: WorkspaceMetadata;
|
||||
onSelectWorkspace?: (workspaceMetadata: WorkspaceMetadata) => void;
|
||||
onCreatedWorkspace?: (payload: {
|
||||
@@ -40,6 +41,7 @@ export const WorkspaceSelector = ({
|
||||
showArrowDownIcon,
|
||||
disable,
|
||||
open: outerOpen,
|
||||
onOpenChange: outerOnOpenChange,
|
||||
showEnableCloudButton,
|
||||
showSyncStatus,
|
||||
className,
|
||||
@@ -51,13 +53,29 @@ export const WorkspaceSelector = ({
|
||||
});
|
||||
const [innerOpen, setOpened] = useState(false);
|
||||
const open = outerOpen ?? innerOpen;
|
||||
const onOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
outerOnOpenChange !== undefined
|
||||
? outerOnOpenChange?.(open)
|
||||
: setOpened(open);
|
||||
},
|
||||
[outerOnOpenChange]
|
||||
);
|
||||
const closeUserWorkspaceList = useCallback(() => {
|
||||
setOpened(false);
|
||||
}, []);
|
||||
if (outerOnOpenChange) {
|
||||
outerOnOpenChange(false);
|
||||
} else {
|
||||
setOpened(false);
|
||||
}
|
||||
}, [outerOnOpenChange]);
|
||||
const openUserWorkspaceList = useCallback(() => {
|
||||
track.$.navigationPanel.workspaceList.open();
|
||||
setOpened(true);
|
||||
}, []);
|
||||
if (outerOnOpenChange) {
|
||||
outerOnOpenChange(true);
|
||||
} else {
|
||||
setOpened(true);
|
||||
}
|
||||
}, [outerOnOpenChange]);
|
||||
|
||||
const currentWorkspaceId = useLiveData(
|
||||
globalContextService.globalContext.workspaceId.$
|
||||
@@ -80,6 +98,7 @@ export const WorkspaceSelector = ({
|
||||
<Menu
|
||||
rootOptions={{
|
||||
open,
|
||||
onOpenChange,
|
||||
}}
|
||||
items={
|
||||
<UserWithWorkspaceList
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
onStart,
|
||||
smartRetry,
|
||||
} from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import bytes from 'bytes';
|
||||
import { map, tap } from 'rxjs';
|
||||
|
||||
@@ -48,8 +48,8 @@ export class UserQuota extends Entity {
|
||||
color$ = this.percent$.map(percent =>
|
||||
percent !== null
|
||||
? percent > 80
|
||||
? cssVar('errorColor')
|
||||
: cssVar('processingColor')
|
||||
? cssVarV2('toast/iconState/error')
|
||||
: cssVarV2('toast/iconState/regular')
|
||||
: null
|
||||
);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export type WorkbenchOpenOptions = {
|
||||
|
||||
const sidebarOpenKey = 'workbenchSidebarOpen';
|
||||
const sidebarWidthKey = 'workbenchSidebarWidth';
|
||||
const workspaceSelectorOpenKey = 'workspaceSelectorOpen';
|
||||
|
||||
export class Workbench extends Entity {
|
||||
constructor(
|
||||
@@ -70,6 +71,14 @@ export class Workbench extends Entity {
|
||||
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) {
|
||||
if (typeof index === 'number') {
|
||||
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);
|
||||
}
|
||||
|
||||
openWorkspaceSelector() {
|
||||
this.setWorkspaceSelectorOpen(true);
|
||||
}
|
||||
|
||||
closeWorkspaceSelector() {
|
||||
this.setWorkspaceSelectorOpen(false);
|
||||
}
|
||||
|
||||
toggleWorkspaceSelector() {
|
||||
this.setWorkspaceSelectorOpen(!this.workspaceSelectorOpen$.value);
|
||||
}
|
||||
|
||||
open(to: To, option: WorkbenchOpenOptions = {}) {
|
||||
if (option.at === 'new-tab') {
|
||||
this.newTab(to, {
|
||||
|
||||
@@ -61,4 +61,10 @@ export class WorkspacesService extends Service {
|
||||
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,
|
||||
"en": 100,
|
||||
"es-AR": 95,
|
||||
"es-CL": 97,
|
||||
"es-CL": 96,
|
||||
"es": 95,
|
||||
"fa": 95,
|
||||
"fr": 95,
|
||||
|
||||
@@ -6892,6 +6892,26 @@ export function useAFFiNEI18N(): {
|
||||
* `Admin panel`
|
||||
*/
|
||||
["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`
|
||||
*/
|
||||
|
||||
@@ -1718,6 +1718,11 @@
|
||||
"com.affine.workspace.cloud.account.logout": "Sign out",
|
||||
"com.affine.workspace.cloud.account.settings": "Account settings",
|
||||
"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.description": "Sync with AFFiNE Cloud",
|
||||
"com.affine.workspace.cloud.join": "Join workspace",
|
||||
|
||||
Reference in New Issue
Block a user