mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-27 02:42:25 +08:00
feat(core): add doc grant feature to share menu (#9672)
This commit is contained in:
@@ -1,271 +0,0 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const headerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
padding: '0 4px',
|
||||
gap: '4px',
|
||||
});
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const menuStyle = style({
|
||||
width: '390px',
|
||||
height: 'auto',
|
||||
padding: '12px',
|
||||
});
|
||||
export const menuTriggerStyle = style({
|
||||
width: '150px',
|
||||
padding: '4px 10px',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const publicItemRowStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const DoneIconStyle = style({
|
||||
color: cssVarV2('button/primary'),
|
||||
fontSize: cssVar('fontH5'),
|
||||
marginLeft: '8px',
|
||||
});
|
||||
export const exportItemStyle = style({
|
||||
padding: '4px',
|
||||
transition: 'all 0.3s',
|
||||
gap: '0px',
|
||||
});
|
||||
globalStyle(`${exportItemStyle} > div:first-child`, {
|
||||
alignItems: 'center',
|
||||
});
|
||||
globalStyle(`${exportItemStyle} svg`, {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
});
|
||||
|
||||
export const copyLinkContainerStyle = style({
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
padding: 0,
|
||||
marginTop: '12px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkButtonStyle = style({
|
||||
flex: 1,
|
||||
padding: '4px 12px',
|
||||
paddingRight: '6px',
|
||||
borderRight: 'none',
|
||||
borderTopRightRadius: '0',
|
||||
borderBottomRightRadius: '0',
|
||||
color: 'transparent',
|
||||
position: 'initial',
|
||||
selectors: {
|
||||
'&.dark': {
|
||||
backgroundColor: cssVarV2('layer/pureBlack'),
|
||||
},
|
||||
'&.dark::hover': {
|
||||
backgroundColor: cssVarV2('layer/pureBlack'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkLabelContainerStyle = style({
|
||||
width: '100%',
|
||||
borderRight: 'none',
|
||||
borderTopRightRadius: '0',
|
||||
borderBottomRightRadius: '0',
|
||||
position: 'relative',
|
||||
});
|
||||
export const copyLinkLabelStyle = style({
|
||||
position: 'absolute',
|
||||
textAlign: 'end',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%) translateY(-50%)',
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/pureWhite'),
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
color: cssVarV2('text/primary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkShortcutStyle = style({
|
||||
position: 'absolute',
|
||||
textAlign: 'end',
|
||||
top: '50%',
|
||||
right: '52px',
|
||||
transform: 'translateY(-50%)',
|
||||
opacity: 0.5,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/pureWhite'),
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkTriggerStyle = style({
|
||||
padding: '4px 12px 4px 8px',
|
||||
borderLeft: 'none',
|
||||
borderTopLeftRadius: '0',
|
||||
borderBottomLeftRadius: '0',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('button/primary'),
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
},
|
||||
'::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
backgroundColor: cssVarV2('button/innerBlackBorder'),
|
||||
},
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
backgroundColor: cssVarV2('button/secondary'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
'&.secondary:hover': {
|
||||
backgroundColor: cssVarV2('button/secondary'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${copyLinkTriggerStyle} svg`, {
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
transform: 'translateX(2px)',
|
||||
});
|
||||
globalStyle(`${copyLinkTriggerStyle}.secondary svg`, {
|
||||
color: cssVarV2('text/secondary'),
|
||||
transform: 'translateX(2px)',
|
||||
});
|
||||
export const copyLinkMenuItemStyle = style({
|
||||
padding: '4px',
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
export const descriptionStyle = style({
|
||||
wordWrap: 'break-word',
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
textAlign: 'left',
|
||||
padding: '0 6px',
|
||||
});
|
||||
export const containerStyle = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const indicatorContainerStyle = style({
|
||||
position: 'relative',
|
||||
});
|
||||
export const titleContainerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontWeight: 400,
|
||||
padding: '8px 4px 0 4px',
|
||||
});
|
||||
export const columnContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
gap: '8px',
|
||||
});
|
||||
export const rowContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
});
|
||||
export const exportContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const labelStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const disableSharePage = style({
|
||||
color: cssVarV2('button/error'),
|
||||
});
|
||||
export const localSharePage = style({
|
||||
padding: '12px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
minHeight: '84px',
|
||||
position: 'relative',
|
||||
});
|
||||
export const cloudSvgContainer = style({
|
||||
width: '146px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
right: '0',
|
||||
});
|
||||
export const shareLinkStyle = style({
|
||||
padding: '4px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
transform: 'translateX(-4px)',
|
||||
gap: '4px',
|
||||
});
|
||||
globalStyle(`${shareLinkStyle} > span`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
globalStyle(`${shareLinkStyle} > div > svg`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
export const buttonContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const button = style({
|
||||
padding: '6px 8px',
|
||||
height: 32,
|
||||
});
|
||||
export const shortcutStyle = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontWeight: 400,
|
||||
});
|
||||
export const openWorkspaceSettingsStyle = style({
|
||||
color: cssVarV2('text/link'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
globalStyle(`${openWorkspaceSettingsStyle} svg`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Tabs, Tooltip } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { Menu } from '@affine/component/ui/menu';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
import { LockIcon, PublishIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { forwardRef, type PropsWithChildren, type Ref, useEffect } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import { ShareExport } from './share-export';
|
||||
import { SharePage } from './share-page';
|
||||
|
||||
export interface ShareMenuProps extends PropsWithChildren {
|
||||
workspaceMetadata: WorkspaceMetadata;
|
||||
currentPage: Store;
|
||||
onEnableAffineCloud: () => void;
|
||||
onOpenShareModal?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export const ShareMenuContent = (props: ShareMenuProps) => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<div className={styles.containerStyle}>
|
||||
<Tabs.Root defaultValue="share">
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value="share">
|
||||
{t['com.affine.share-menu.shareButton']()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="export">{t['Export']()}</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value="share">
|
||||
<SharePage {...props} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="export">
|
||||
<ShareExport />
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultShareButton = forwardRef(function DefaultShareButton(
|
||||
_,
|
||||
ref: Ref<HTMLButtonElement>
|
||||
) {
|
||||
const t = useI18n();
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const shared = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
shared
|
||||
? t['com.affine.share-menu.option.link.readonly.description']()
|
||||
: t['com.affine.share-menu.option.link.no-access.description']()
|
||||
}
|
||||
>
|
||||
<Button ref={ref} className={styles.button}>
|
||||
<div className={styles.buttonContainer}>
|
||||
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
|
||||
{t['com.affine.share-menu.shareButton']()}
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const LocalShareMenu = (props: ShareMenuProps) => {
|
||||
return (
|
||||
<Menu
|
||||
items={<ShareMenuContent {...props} />}
|
||||
contentOptions={{
|
||||
className: styles.menuStyle,
|
||||
['data-testid' as string]: 'local-share-menu',
|
||||
align: 'end',
|
||||
}}
|
||||
rootOptions={{
|
||||
modal: false,
|
||||
onOpenChange: props.onOpenShareModal,
|
||||
}}
|
||||
>
|
||||
<div data-testid="local-share-menu-button">
|
||||
{props.children || <DefaultShareButton />}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const CloudShareMenu = (props: ShareMenuProps) => {
|
||||
return (
|
||||
<Menu
|
||||
items={<ShareMenuContent {...props} />}
|
||||
contentOptions={{
|
||||
className: styles.menuStyle,
|
||||
['data-testid' as string]: 'cloud-share-menu',
|
||||
align: 'end',
|
||||
}}
|
||||
rootOptions={{
|
||||
modal: false,
|
||||
onOpenChange: props.onOpenShareModal,
|
||||
}}
|
||||
>
|
||||
<div data-testid="cloud-share-menu-button">
|
||||
{props.children || <DefaultShareButton />}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShareMenu = (props: ShareMenuProps) => {
|
||||
const { workspaceMetadata } = props;
|
||||
|
||||
if (workspaceMetadata.flavour === 'local') {
|
||||
return <LocalShareMenu {...props} />;
|
||||
}
|
||||
return <CloudShareMenu {...props} />;
|
||||
};
|
||||
@@ -1,253 +0,0 @@
|
||||
import { notify, Skeleton } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { Menu, MenuItem, MenuTrigger } from '@affine/component/ui/menu';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { ServerService } from '@affine/core/modules/cloud';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import { PublicDocMode } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
import {
|
||||
CollaborationIcon,
|
||||
DoneIcon,
|
||||
LockIcon,
|
||||
SingleSelectCheckSolidIcon,
|
||||
ViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { Suspense, useCallback, useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { CloudSvg } from '../cloud-svg';
|
||||
import { CopyLinkButton } from './copy-link-button';
|
||||
import * as styles from './index.css';
|
||||
import type { ShareMenuProps } from './share-menu';
|
||||
|
||||
export const LocalSharePage = (props: ShareMenuProps) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workspaceMetadata: { id: workspaceId },
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.localSharePage}>
|
||||
<div className={styles.columnContainerStyle} style={{ gap: '12px' }}>
|
||||
<div
|
||||
className={styles.descriptionStyle}
|
||||
style={{ maxWidth: '230px' }}
|
||||
>
|
||||
{t['com.affine.share-menu.EnableCloudDescription']()}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={props.onEnableAffineCloud}
|
||||
variant="primary"
|
||||
data-testid="share-menu-enable-affine-cloud-button"
|
||||
>
|
||||
{t['Enable AFFiNE Cloud']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cloudSvgContainer}>
|
||||
<CloudSvg />
|
||||
</div>
|
||||
</div>
|
||||
<CopyLinkButton workspaceId={workspaceId} secondary />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AFFiNESharePage = (props: ShareMenuProps) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workspaceMetadata: { id: workspaceId },
|
||||
} = props;
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const serverService = useService(ServerService);
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
const sharedMode = useLiveData(shareInfoService.shareInfo.sharedMode$);
|
||||
const baseUrl = serverService.server.baseUrl;
|
||||
const isLoading =
|
||||
isSharedPage === null || sharedMode === null || baseUrl === null;
|
||||
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
|
||||
const onOpenWorkspaceSettings = useCallback(() => {
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: 'workspace:preference',
|
||||
});
|
||||
}, [workspaceDialogService]);
|
||||
|
||||
const onClickAnyoneReadOnlyShare = useAsyncCallback(async () => {
|
||||
if (isSharedPage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// TODO(@JimmFly): remove mode when we have a better way to handle it
|
||||
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
|
||||
track.$.sharePanel.$.createShareLink();
|
||||
notify.success({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.create-public-link.notification.success.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.create-public-link.notification.success.message'
|
||||
](),
|
||||
style: 'normal',
|
||||
icon: <SingleSelectCheckSolidIcon color={cssVar('primaryColor')} />,
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.message'
|
||||
](),
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
}, [isSharedPage, shareInfoService.shareInfo, t]);
|
||||
|
||||
const onDisablePublic = useAsyncCallback(async () => {
|
||||
try {
|
||||
await shareInfoService.shareInfo.disableShare();
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.success.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.success.message'
|
||||
](),
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.fail.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.fail.message'
|
||||
](),
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
}, [shareInfoService, t]);
|
||||
|
||||
if (isLoading) {
|
||||
// TODO(@eyhn): loading and error UI
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={100} />
|
||||
<Skeleton height={40} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.titleContainerStyle}>
|
||||
{isSharedPage
|
||||
? t['com.affine.share-menu.option.link.readonly.description']()
|
||||
: t['com.affine.share-menu.option.link.no-access.description']()}
|
||||
</div>
|
||||
<div className={styles.columnContainerStyle}>
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<div className={styles.labelStyle}>
|
||||
{t['com.affine.share-menu.option.link.label']()}
|
||||
</div>
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem prefixIcon={<LockIcon />} onSelect={onDisablePublic}>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>
|
||||
{t['com.affine.share-menu.option.link.no-access']()}
|
||||
</div>
|
||||
{!isSharedPage && (
|
||||
<DoneIcon className={styles.DoneIconStyle} />
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<ViewIcon />}
|
||||
onSelect={onClickAnyoneReadOnlyShare}
|
||||
data-testid="share-link-menu-enable-share"
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>
|
||||
{t['com.affine.share-menu.option.link.readonly']()}
|
||||
</div>
|
||||
{isSharedPage && (
|
||||
<DoneIcon className={styles.DoneIconStyle} />
|
||||
)}
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTriggerStyle}
|
||||
data-testid="share-link-menu-trigger"
|
||||
>
|
||||
{isSharedPage
|
||||
? t['com.affine.share-menu.option.link.readonly']()
|
||||
: t['com.affine.share-menu.option.link.no-access']()}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<div className={styles.labelStyle}>
|
||||
{t['com.affine.share-menu.option.permission.label']()}
|
||||
</div>
|
||||
<Button className={styles.menuTriggerStyle} disabled>
|
||||
{t['com.affine.share-menu.option.permission.can-edit']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isOwner && (
|
||||
<div
|
||||
className={styles.openWorkspaceSettingsStyle}
|
||||
onClick={onOpenWorkspaceSettings}
|
||||
>
|
||||
<CollaborationIcon fontSize={16} />
|
||||
{t['com.affine.share-menu.navigate.workspace']()}
|
||||
</div>
|
||||
)}
|
||||
<CopyLinkButton workspaceId={workspaceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SharePage = (props: ShareMenuProps) => {
|
||||
if (props.workspaceMetadata.flavour === 'local') {
|
||||
return <LocalSharePage {...props} />;
|
||||
} else {
|
||||
return (
|
||||
// TODO(@eyhn): refactor this part
|
||||
<ErrorBoundary fallback={null}>
|
||||
<Suspense>
|
||||
<AFFiNESharePage {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
MenuSub,
|
||||
} from '@affine/component/ui/menu';
|
||||
import { PageHistoryModal } from '@affine/core/components/affine/page-history-modal';
|
||||
import { ShareMenuContent } from '@affine/core/components/affine/share-page-modal/share-menu';
|
||||
import { useBlockSuiteMetaHelper } from '@affine/core/components/hooks/affine/use-block-suite-meta-helper';
|
||||
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
|
||||
import { useExportPage } from '@affine/core/components/hooks/affine/use-export-page';
|
||||
@@ -17,6 +16,7 @@ import { useDetailPageHeaderResponsive } from '@affine/core/desktop/pages/worksp
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { OpenInAppService } from '@affine/core/modules/open-in-app/services';
|
||||
import { ShareMenuContent } from '@affine/core/modules/share-menu';
|
||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||
import { ViewService } from '@affine/core/modules/workbench/services/view';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Avatar, ConfirmModal, Input, notify, Switch } from '@affine/component';
|
||||
import type { ConfirmModalProps } from '@affine/component/ui/modal';
|
||||
import { CloudSvg } from '@affine/core/components/affine/share-page-modal/cloud-svg';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { AuthService, ServersService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
@@ -9,6 +8,7 @@ import {
|
||||
GlobalDialogService,
|
||||
} from '@affine/core/modules/dialogs';
|
||||
import { FeatureFlagService } from '@affine/core/modules/feature-flag';
|
||||
import { CloudSvg } from '@affine/core/modules/share-menu';
|
||||
import { WorkspacesService } from '@affine/core/modules/workspace';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { track } from '@affine/track';
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
observeResize,
|
||||
useDraggable,
|
||||
} from '@affine/component';
|
||||
import { SharePageButton } from '@affine/core/components/affine/share-page-modal';
|
||||
import { FavoriteButton } from '@affine/core/components/blocksuite/block-suite-header/favorite';
|
||||
import { InfoButton } from '@affine/core/components/blocksuite/block-suite-header/info';
|
||||
import { JournalWeekDatePicker } from '@affine/core/components/blocksuite/block-suite-header/journal/date-picker';
|
||||
@@ -20,6 +19,7 @@ import { DocService } from '@affine/core/modules/doc';
|
||||
import { DocDisplayMetaService } from '@affine/core/modules/doc-display-meta';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { JournalService } from '@affine/core/modules/journal';
|
||||
import { SharePageButton } from '@affine/core/modules/share-menu';
|
||||
import { TemplateDocService } from '@affine/core/modules/template-doc';
|
||||
import { ViewIcon, ViewTitle } from '@affine/core/modules/workbench';
|
||||
import type { Workspace } from '@affine/core/modules/workspace';
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { IconButton, MobileMenu } from '@affine/component';
|
||||
import { SharePage } from '@affine/core/components/affine/share-page-modal/share-menu/share-page';
|
||||
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
|
||||
import { DocService } from '@affine/core/modules/doc';
|
||||
import { ShareMenuContent } from '@affine/core/modules/share-menu';
|
||||
import { WorkspaceService } from '@affine/core/modules/workspace';
|
||||
import { ShareiOsIcon } from '@blocksuite/icons/rc';
|
||||
import { useServices } from '@toeverything/infra';
|
||||
@@ -25,7 +25,7 @@ export const PageHeaderShareButton = () => {
|
||||
<MobileMenu
|
||||
items={
|
||||
<div className={styles.content}>
|
||||
<SharePage
|
||||
<ShareMenuContent
|
||||
workspaceMetadata={workspace.meta}
|
||||
currentPage={doc}
|
||||
onEnableAffineCloud={() =>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
export type { Member } from './entities/members';
|
||||
export {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from './services/doc-granted-users';
|
||||
export { MemberSearchService } from './services/member-search';
|
||||
export { WorkspaceMembersService } from './services/members';
|
||||
export { WorkspacePermissionService } from './services/permission';
|
||||
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceServerService } from '../cloud';
|
||||
import { DocScope, DocService } from '../doc';
|
||||
import {
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
@@ -12,8 +18,12 @@ import {
|
||||
} from '../workspace';
|
||||
import { WorkspaceMembers } from './entities/members';
|
||||
import { WorkspacePermission } from './entities/permission';
|
||||
import { DocGrantedUsersService } from './services/doc-granted-users';
|
||||
import { MemberSearchService } from './services/member-search';
|
||||
import { WorkspaceMembersService } from './services/members';
|
||||
import { WorkspacePermissionService } from './services/permission';
|
||||
import { DocGrantedUsersStore } from './stores/doc-granted-users';
|
||||
import { MemberSearchStore } from './stores/member-search';
|
||||
import { WorkspaceMembersStore } from './stores/members';
|
||||
import { WorkspacePermissionStore } from './stores/permission';
|
||||
|
||||
@@ -29,5 +39,17 @@ export function configurePermissionsModule(framework: Framework) {
|
||||
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
|
||||
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
|
||||
.store(WorkspaceMembersStore, [WorkspaceServerService])
|
||||
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService]);
|
||||
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
|
||||
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
|
||||
.store(MemberSearchStore, [WorkspaceServerService]);
|
||||
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.scope(DocScope)
|
||||
.service(DocGrantedUsersService, [
|
||||
DocGrantedUsersStore,
|
||||
WorkspaceService,
|
||||
DocService,
|
||||
])
|
||||
.store(DocGrantedUsersStore, [WorkspaceServerService]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { DocRole, GetPageGrantedUsersListQuery } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { DocService } from '../../doc';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { DocGrantedUsersStore } from '../stores/doc-granted-users';
|
||||
|
||||
export type GrantedUser =
|
||||
GetPageGrantedUsersListQuery['workspace']['doc']['grantedUsersList']['edges'][number]['node'];
|
||||
|
||||
export class DocGrantedUsersService extends Service {
|
||||
constructor(
|
||||
private readonly store: DocGrantedUsersStore,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly docService: DocService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly PAGE_SIZE = 8;
|
||||
|
||||
nextCursor$ = new LiveData<string | undefined>(undefined);
|
||||
hasMore$ = new LiveData(true);
|
||||
grantedUserCount$ = new LiveData(0);
|
||||
grantedUsers$ = new LiveData<GrantedUser[]>([]);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
readonly loadMore = effect(
|
||||
exhaustMap(() => {
|
||||
if (!this.hasMore$.value) {
|
||||
return EMPTY;
|
||||
}
|
||||
return fromPromise(async signal => {
|
||||
return await this.store.fetchDocGrantedUsersList(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
{
|
||||
first: this.PAGE_SIZE,
|
||||
after: this.nextCursor$.value,
|
||||
},
|
||||
signal
|
||||
);
|
||||
}).pipe(
|
||||
mergeMap(({ edges, pageInfo, totalCount }) => {
|
||||
this.grantedUsers$.next([
|
||||
...this.grantedUsers$.value,
|
||||
...edges.map(edge => edge.node),
|
||||
]);
|
||||
|
||||
this.grantedUserCount$.next(totalCount);
|
||||
this.hasMore$.next(pageInfo.hasNextPage);
|
||||
this.nextCursor$.next(pageInfo.endCursor ?? undefined);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => {
|
||||
this.isLoading$.setValue(true);
|
||||
}),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.grantedUsers$.setValue([]);
|
||||
this.grantedUserCount$.setValue(0);
|
||||
this.hasMore$.setValue(true);
|
||||
this.nextCursor$.setValue(undefined);
|
||||
this.isLoading$.setValue(false);
|
||||
this.error$.setValue(null);
|
||||
this.loadMore.reset();
|
||||
}
|
||||
|
||||
async grantUsersRole(userIds: string[], role: DocRole) {
|
||||
await this.store.grantDocUserRoles({
|
||||
docId: this.docService.doc.id,
|
||||
workspaceId: this.workspaceService.workspace.id,
|
||||
userIds,
|
||||
role,
|
||||
});
|
||||
this.grantedUsers$.next(
|
||||
this.grantedUsers$.value.map(user => {
|
||||
if (userIds.includes(user.user.id)) {
|
||||
return { ...user, role };
|
||||
}
|
||||
return user;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async revokeUsersRole(userId: string) {
|
||||
await this.store.revokeDocUserRoles(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
userId
|
||||
);
|
||||
this.grantedUsers$.next(
|
||||
this.grantedUsers$.value.filter(user => user.user.id !== userId)
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserRole(userId: string, role: DocRole) {
|
||||
await this.store.updateDocUserRole(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
userId,
|
||||
role
|
||||
);
|
||||
this.grantedUsers$.next(
|
||||
this.grantedUsers$.value.map(user => {
|
||||
if (user.user.id === userId) {
|
||||
return { ...user, role };
|
||||
}
|
||||
return user;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.loadMore.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { Member } from '../entities/members';
|
||||
import type { MemberSearchStore } from '../stores/member-search';
|
||||
|
||||
export class MemberSearchService extends Service {
|
||||
constructor(
|
||||
private readonly store: MemberSearchStore,
|
||||
private readonly workspaceService: WorkspaceService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly PAGE_SIZE = 8;
|
||||
readonly searchText$ = new LiveData<string>('');
|
||||
readonly isLoading$ = new LiveData(false);
|
||||
readonly error$ = new LiveData<any>(null);
|
||||
readonly result$ = new LiveData<Member[]>([]);
|
||||
readonly hasMore$ = new LiveData(true);
|
||||
|
||||
readonly loadMore = effect(
|
||||
exhaustMap(() => {
|
||||
if (!this.hasMore$.value) {
|
||||
return EMPTY;
|
||||
}
|
||||
return fromPromise(async signal => {
|
||||
return this.store.getMembersByEmailOrName(
|
||||
this.workspaceService.workspace.id,
|
||||
this.searchText$.value || undefined,
|
||||
this.result$.value.length,
|
||||
this.PAGE_SIZE,
|
||||
signal
|
||||
);
|
||||
}).pipe(
|
||||
mergeMap(data => {
|
||||
this.result$.setValue([...this.result$.value, ...data.members]);
|
||||
this.hasMore$.setValue(data.members.length === this.PAGE_SIZE);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => {
|
||||
this.isLoading$.setValue(true);
|
||||
}),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.result$.setValue([]);
|
||||
this.hasMore$.setValue(true);
|
||||
this.searchText$.setValue('');
|
||||
this.error$.setValue(null);
|
||||
this.loadMore.reset();
|
||||
}
|
||||
|
||||
search(searchText?: string) {
|
||||
if (this.searchText$.value === searchText) {
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
this.searchText$.setValue(searchText ?? '');
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
type DocRole,
|
||||
getPageGrantedUsersListQuery,
|
||||
type GrantDocUserRolesInput,
|
||||
grantDocUserRolesMutation,
|
||||
type PaginationInput,
|
||||
revokeDocUserRolesMutation,
|
||||
updateDocUserRoleMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class DocGrantedUsersStore extends Store {
|
||||
constructor(private readonly workspaceServerService: WorkspaceServerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchDocGrantedUsersList(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
pagination: PaginationInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: getPageGrantedUsersListQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
docId,
|
||||
pagination,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return res.workspace.doc.grantedUsersList;
|
||||
}
|
||||
|
||||
async grantDocUserRoles(input: GrantDocUserRolesInput) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: grantDocUserRolesMutation,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
|
||||
return res.grantDocUserRoles;
|
||||
}
|
||||
|
||||
async revokeDocUserRoles(workspaceId: string, docId: string, userId: string) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: revokeDocUserRolesMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId,
|
||||
docId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.revokeDocUserRoles;
|
||||
}
|
||||
|
||||
async updateDocUserRole(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
userId: string,
|
||||
role: DocRole
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: updateDocUserRoleMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId,
|
||||
docId,
|
||||
userId,
|
||||
role,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.updateDocUserRole;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceServerService } from '../../cloud';
|
||||
|
||||
export class MemberSearchStore extends Store {
|
||||
constructor(private readonly workspaceServerService: WorkspaceServerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getMembersByEmailOrName(
|
||||
workspaceId: string,
|
||||
query?: string,
|
||||
skip?: number,
|
||||
take?: number,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
skip,
|
||||
take,
|
||||
query,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
|
||||
return data.workspace;
|
||||
}
|
||||
}
|
||||
1
packages/frontend/core/src/modules/share-menu/index.ts
Normal file
1
packages/frontend/core/src/modules/share-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CloudSvg, ShareMenuContent, SharePageButton } from './view';
|
||||
@@ -5,6 +5,8 @@ import type { Store } from '@blocksuite/affine/store';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { ShareMenu } from './share-menu';
|
||||
export { CloudSvg } from './cloud-svg';
|
||||
export { ShareMenuContent } from './share-menu';
|
||||
|
||||
type SharePageModalProps = {
|
||||
workspace: Workspace;
|
||||
@@ -0,0 +1,111 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const copyLinkContainerStyle = style({
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
padding: 0,
|
||||
marginTop: '12px',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkButtonStyle = style({
|
||||
flex: 1,
|
||||
padding: '4px 12px',
|
||||
paddingRight: '6px',
|
||||
borderRight: 'none',
|
||||
borderTopRightRadius: '0',
|
||||
borderBottomRightRadius: '0',
|
||||
color: 'transparent',
|
||||
position: 'initial',
|
||||
selectors: {
|
||||
'&.dark': {
|
||||
backgroundColor: cssVarV2('layer/pureBlack'),
|
||||
},
|
||||
'&.dark::hover': {
|
||||
backgroundColor: cssVarV2('layer/pureBlack'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkLabelContainerStyle = style({
|
||||
width: '100%',
|
||||
borderRight: 'none',
|
||||
borderTopRightRadius: '0',
|
||||
borderBottomRightRadius: '0',
|
||||
position: 'relative',
|
||||
});
|
||||
export const copyLinkLabelStyle = style({
|
||||
position: 'absolute',
|
||||
textAlign: 'end',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%) translateY(-50%)',
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/pureWhite'),
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
color: cssVarV2('text/primary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkShortcutStyle = style({
|
||||
position: 'absolute',
|
||||
textAlign: 'end',
|
||||
top: '50%',
|
||||
right: '52px',
|
||||
transform: 'translateY(-50%)',
|
||||
opacity: 0.5,
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/pureWhite'),
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const copyLinkTriggerStyle = style({
|
||||
padding: '4px 12px 4px 8px',
|
||||
borderLeft: 'none',
|
||||
borderTopLeftRadius: '0',
|
||||
borderBottomLeftRadius: '0',
|
||||
':hover': {
|
||||
backgroundColor: cssVarV2('button/primary'),
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
},
|
||||
'::after': {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
height: '100%',
|
||||
width: '1px',
|
||||
backgroundColor: cssVarV2('button/innerBlackBorder'),
|
||||
},
|
||||
selectors: {
|
||||
'&.secondary': {
|
||||
backgroundColor: cssVarV2('button/secondary'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
'&.secondary:hover': {
|
||||
backgroundColor: cssVarV2('button/secondary'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
},
|
||||
});
|
||||
globalStyle(`${copyLinkTriggerStyle} svg`, {
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
transform: 'translateX(2px)',
|
||||
});
|
||||
globalStyle(`${copyLinkTriggerStyle}.secondary svg`, {
|
||||
color: cssVarV2('text/secondary'),
|
||||
transform: 'translateX(2px)',
|
||||
});
|
||||
export const copyLinkMenuItemStyle = style({
|
||||
padding: '4px',
|
||||
transition: 'all 0.3s',
|
||||
});
|
||||
@@ -11,7 +11,7 @@ import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import * as styles from './copy-link-button.css';
|
||||
|
||||
export const CopyLinkButton = ({
|
||||
workspaceId,
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './members-permission';
|
||||
export * from './public-page-button';
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Menu, MenuItem, MenuTrigger } from '@affine/component';
|
||||
import { DocRole } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { PlanTag } from '../plan-tag';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
|
||||
switch (role) {
|
||||
case DocRole.Manager:
|
||||
return t['com.affine.share-menu.option.permission.can-manage']();
|
||||
case DocRole.Editor:
|
||||
return t['com.affine.share-menu.option.permission.can-edit']();
|
||||
case DocRole.Reader:
|
||||
return t['com.affine.share-menu.option.permission.can-read']();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
// TODO(@JimmFly): impl the real permission
|
||||
export const MembersPermission = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [docRole, setDocRole] = useState<DocRole>(DocRole.Manager);
|
||||
const currentRoleName = useMemo(() => getRoleName(docRole, t), [docRole, t]);
|
||||
|
||||
const changePermission = useCallback((newPermission: DocRole) => {
|
||||
setDocRole(newPermission);
|
||||
}, []);
|
||||
|
||||
const selectManage = useCallback(() => {
|
||||
changePermission(DocRole.Manager);
|
||||
}, [changePermission]);
|
||||
|
||||
const selectEdit = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal?.();
|
||||
return;
|
||||
}
|
||||
changePermission(DocRole.Editor);
|
||||
}, [changePermission, hittingPaywall, openPaywallModal]);
|
||||
|
||||
const selectRead = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal?.();
|
||||
return;
|
||||
}
|
||||
changePermission(DocRole.Reader);
|
||||
}, [changePermission, hittingPaywall, openPaywallModal]);
|
||||
return (
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<div className={styles.labelStyle}>
|
||||
{t['com.affine.share-menu.option.permission.label']()}
|
||||
</div>
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
onSelect={selectManage}
|
||||
selected={docRole === DocRole.Manager}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-manage']()}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectEdit}
|
||||
selected={docRole === DocRole.Editor}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-edit']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectRead}
|
||||
selected={docRole === DocRole.Reader}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-read']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTriggerStyle}
|
||||
variant="plain"
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentRoleName}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,139 @@
|
||||
import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import { PublicDocMode } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import {
|
||||
LockIcon,
|
||||
SingleSelectCheckSolidIcon,
|
||||
ViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const PublicDoc = () => {
|
||||
const t = useI18n();
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
|
||||
const onDisablePublic = useAsyncCallback(async () => {
|
||||
try {
|
||||
await shareInfoService.shareInfo.disableShare();
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.success.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.success.message'
|
||||
](),
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.fail.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.fail.message'
|
||||
](),
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
}, [shareInfoService, t]);
|
||||
|
||||
const onClickAnyoneReadOnlyShare = useAsyncCallback(async () => {
|
||||
if (isSharedPage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// TODO(@JimmFly): remove mode when we have a better way to handle it
|
||||
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
|
||||
track.$.sharePanel.$.createShareLink();
|
||||
notify.success({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.create-public-link.notification.success.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.create-public-link.notification.success.message'
|
||||
](),
|
||||
style: 'normal',
|
||||
icon: <SingleSelectCheckSolidIcon color={cssVar('primaryColor')} />,
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.message'
|
||||
](),
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
}, [isSharedPage, shareInfoService.shareInfo, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<div className={styles.labelStyle}>
|
||||
{t['com.affine.share-menu.option.link.label']()}
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<LockIcon />}
|
||||
onSelect={onDisablePublic}
|
||||
selected={!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>{t['com.affine.share-menu.option.link.no-access']()}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<ViewIcon />}
|
||||
onSelect={onClickAnyoneReadOnlyShare}
|
||||
data-testid="share-link-menu-enable-share"
|
||||
selected={!!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>{t['com.affine.share-menu.option.link.readonly']()}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTriggerStyle}
|
||||
data-testid="share-link-menu-trigger"
|
||||
variant="plain"
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{isSharedPage
|
||||
? t['com.affine.share-menu.option.link.readonly']()
|
||||
: t['com.affine.share-menu.option.link.no-access']()}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,38 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuTriggerStyle = style({
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
justifyContent: 'space-between',
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const rowContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
});
|
||||
export const exportContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const labelStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const publicItemRowStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const tagContainerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const headerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
padding: '0 4px',
|
||||
gap: '4px',
|
||||
});
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const menuStyle = style({
|
||||
width: '390px',
|
||||
minHeight: '310px',
|
||||
maxHeight: '562px',
|
||||
padding: '12px',
|
||||
});
|
||||
export const localMenuStyle = style({
|
||||
width: '390px',
|
||||
padding: '12px',
|
||||
});
|
||||
export const menuTriggerStyle = style({
|
||||
width: '150px',
|
||||
padding: '4px 10px',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const exportItemStyle = style({
|
||||
padding: '4px',
|
||||
transition: 'all 0.3s',
|
||||
gap: '0px',
|
||||
});
|
||||
globalStyle(`${exportItemStyle} > div:first-child`, {
|
||||
alignItems: 'center',
|
||||
});
|
||||
globalStyle(`${exportItemStyle} svg`, {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
});
|
||||
|
||||
export const descriptionStyle = style({
|
||||
wordWrap: 'break-word',
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
textAlign: 'left',
|
||||
padding: '0 6px',
|
||||
});
|
||||
export const containerStyle = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const indicatorContainerStyle = style({
|
||||
position: 'relative',
|
||||
});
|
||||
export const columnContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
gap: '8px',
|
||||
});
|
||||
export const exportContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const labelStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const disableSharePage = style({
|
||||
color: cssVarV2('button/error'),
|
||||
});
|
||||
export const localSharePage = style({
|
||||
padding: '12px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
minHeight: '84px',
|
||||
position: 'relative',
|
||||
});
|
||||
export const cloudSvgContainer = style({
|
||||
width: '146px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
right: '0',
|
||||
});
|
||||
export const shareLinkStyle = style({
|
||||
padding: '4px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
transform: 'translateX(-4px)',
|
||||
gap: '4px',
|
||||
});
|
||||
globalStyle(`${shareLinkStyle} > span`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
globalStyle(`${shareLinkStyle} > div > svg`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
export const buttonContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const button = style({
|
||||
padding: '6px 8px',
|
||||
height: 32,
|
||||
});
|
||||
export const shortcutStyle = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const generalAccessStyle = style({
|
||||
padding: '4px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Input } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const InviteInput = ({ onFocus }: { onFocus: () => void }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Input
|
||||
preFix={<SearchIcon fontSize={20} />}
|
||||
className={styles.inputStyle}
|
||||
onFocus={onFocus}
|
||||
inputStyle={{
|
||||
paddingLeft: '0',
|
||||
}}
|
||||
placeholder={t['com.affine.share-menu.invite-editor.placeholder']()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const containerStyle = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const headerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottom: `1px solid ${cssVarV2('tab/divider/divider')}`,
|
||||
cursor: 'pointer',
|
||||
gap: '4px',
|
||||
padding: '4px 4px 6px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
export const iconStyle = style({
|
||||
fontSize: '20px',
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const memberListStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
overflowY: 'auto',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const footerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
borderTop: `1px solid ${cssVarV2('tab/divider/divider')}`,
|
||||
paddingTop: '8px',
|
||||
});
|
||||
export const manageMemberStyle = style({
|
||||
color: cssVarV2('text/link'),
|
||||
cursor: 'pointer',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
padding: '5px 4px',
|
||||
});
|
||||
|
||||
export const searchInput = style({
|
||||
flexGrow: 1,
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
color: 'inherit',
|
||||
backgroundColor: 'transparent',
|
||||
'::placeholder': {
|
||||
color: cssVarV2('text/placeholder'),
|
||||
},
|
||||
});
|
||||
|
||||
export const InputContainer = style({
|
||||
display: 'flex',
|
||||
gap: '4px',
|
||||
borderRadius: '4px',
|
||||
padding: '4px',
|
||||
flexWrap: 'wrap',
|
||||
width: '100%',
|
||||
border: `1px solid ${cssVarV2('input/border/default')}`,
|
||||
|
||||
selectors: {
|
||||
'&.focus': {
|
||||
borderColor: cssVarV2('input/border/active'),
|
||||
},
|
||||
},
|
||||
});
|
||||
export const inlineMembersContainer = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
width: '100%',
|
||||
flex: 1,
|
||||
gap: '4px',
|
||||
maxHeight: '60px',
|
||||
overflowY: 'auto',
|
||||
});
|
||||
export const roleSelectorContainer = style({
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const menuTriggerStyle = style({
|
||||
padding: '1px 2px',
|
||||
gap: '4px',
|
||||
height: '22px',
|
||||
borderRadius: '2px',
|
||||
justifyContent: 'space-between',
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const buttonsContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '12px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const button = style({
|
||||
padding: '4px 12px',
|
||||
borderRadius: '4px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const sentEmail = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontSm'),
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const checkbox = style({
|
||||
fontSize: 20,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const noFound = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
|
||||
export const scrollbar = style({
|
||||
width: 6,
|
||||
});
|
||||
|
||||
export const planTagContainer = style({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
});
|
||||
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Loading,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
notify,
|
||||
RowInput,
|
||||
} from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type Member,
|
||||
MemberSearchService,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import {
|
||||
DocRole,
|
||||
UserFriendlyError,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type CompositionEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { PlanTag } from '../plan-tag';
|
||||
import { Scroller } from '../scroller';
|
||||
import * as styles from './invite-member-editor.css';
|
||||
import { MemberItem } from './member-item';
|
||||
import { SelectedMemberItem } from './selected-member-item';
|
||||
|
||||
const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
|
||||
switch (role) {
|
||||
case DocRole.Manager:
|
||||
return t['com.affine.share-menu.option.permission.can-manage']();
|
||||
case DocRole.Editor:
|
||||
return t['com.affine.share-menu.option.permission.can-edit']();
|
||||
case DocRole.Reader:
|
||||
return t['com.affine.share-menu.option.permission.can-read']();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const InviteMemberEditor = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
onClickCancel,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
onClickCancel: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [selectedMembers, setSelectedMembers] = useState<Member[]>([]);
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
const [inviteDocRoleType, setInviteDocRoleType] = useState<DocRole>(
|
||||
DocRole.Manager
|
||||
);
|
||||
|
||||
const memberSearchService = useService(MemberSearchService);
|
||||
const searchText = useLiveData(memberSearchService.searchText$);
|
||||
|
||||
useEffect(() => {
|
||||
// reset the search text when the component is mounted
|
||||
memberSearchService.reset();
|
||||
memberSearchService.loadMore();
|
||||
}, [memberSearchService]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!composing) {
|
||||
memberSearchService.search(value);
|
||||
}
|
||||
},
|
||||
[composing, memberSearchService]
|
||||
);
|
||||
|
||||
const [shouldSendEmail, setShouldSendEmail] = useState(false);
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
|
||||
const onInvite = useAsyncCallback(async () => {
|
||||
const selectedMemberIds = selectedMembers.map(member => member.id);
|
||||
try {
|
||||
await docGrantedUsersService.grantUsersRole(
|
||||
selectedMemberIds,
|
||||
inviteDocRoleType
|
||||
);
|
||||
|
||||
notify.success({
|
||||
title: 'Invite successful',
|
||||
});
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, inviteDocRoleType, selectedMembers, t]);
|
||||
|
||||
const handleCompositionStart: CompositionEventHandler<HTMLInputElement> =
|
||||
useCallback(() => {
|
||||
setComposing(true);
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd: CompositionEventHandler<HTMLInputElement> =
|
||||
useCallback(
|
||||
e => {
|
||||
setComposing(false);
|
||||
memberSearchService.search(e.currentTarget.value);
|
||||
},
|
||||
[memberSearchService]
|
||||
);
|
||||
|
||||
const onCheckboxChange = useCallback(() => {
|
||||
setShouldSendEmail(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
const onFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, []);
|
||||
const onBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
}, []);
|
||||
|
||||
const handleRemoved = useCallback(
|
||||
(memberId: string) => {
|
||||
setSelectedMembers(prev => prev.filter(member => member.id !== memberId));
|
||||
focusInput();
|
||||
},
|
||||
[focusInput]
|
||||
);
|
||||
|
||||
const switchToMemberManagementTab = useCallback(() => {
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: 'workspace:preference',
|
||||
});
|
||||
}, [workspaceDialogService]);
|
||||
|
||||
const handleClickMember = useCallback((member: Member) => {
|
||||
setSelectedMembers(prev => {
|
||||
if (prev.some(m => m.id === member.id)) {
|
||||
// if the member is already in the list, just return
|
||||
return prev;
|
||||
}
|
||||
return [...prev, member];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRoleChange = useCallback((role: DocRole) => {
|
||||
setInviteDocRoleType(role);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.containerStyle}>
|
||||
<div className={styles.headerStyle} onClick={onClickCancel}>
|
||||
<ArrowLeftBigIcon className={styles.iconStyle} />
|
||||
{t['com.affine.share-menu.invite-editor.header']()}
|
||||
</div>
|
||||
<div className={styles.memberListStyle}>
|
||||
<div
|
||||
className={clsx(styles.InputContainer, {
|
||||
focus: focused,
|
||||
})}
|
||||
>
|
||||
<div className={styles.inlineMembersContainer}>
|
||||
{selectedMembers.map((member, idx) => {
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
const onRemoved = () => handleRemoved(member.id);
|
||||
return (
|
||||
<SelectedMemberItem
|
||||
key={member.id}
|
||||
idx={idx}
|
||||
onRemoved={onRemoved}
|
||||
member={member}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<RowInput
|
||||
ref={inputRef}
|
||||
value={searchText}
|
||||
onChange={handleValueChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
autoFocus
|
||||
className={styles.searchInput}
|
||||
placeholder={t[
|
||||
'com.affine.share-menu.invite-editor.placeholder'
|
||||
]()}
|
||||
/>
|
||||
</div>
|
||||
{!selectedMembers.length ? null : (
|
||||
<RoleSelector
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={hittingPaywall}
|
||||
inviteDocRoleType={inviteDocRoleType}
|
||||
onRoleChange={handleRoleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sentEmail} onClick={onCheckboxChange}>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
checked={shouldSendEmail}
|
||||
disabled // not supported yet
|
||||
/>
|
||||
{t['com.affine.share-menu.invite-editor.sent-email']()}
|
||||
</div>
|
||||
<Result onClickMember={handleClickMember} />
|
||||
</div>
|
||||
<div className={styles.footerStyle}>
|
||||
<span
|
||||
className={styles.manageMemberStyle}
|
||||
onClick={switchToMemberManagementTab}
|
||||
>
|
||||
{t['com.affine.share-menu.invite-editor.manage-members']()}
|
||||
</span>
|
||||
<div className={styles.buttonsContainer}>
|
||||
<Button className={styles.button} onClick={onClickCancel}>
|
||||
{t['Cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant="primary"
|
||||
disabled={!selectedMembers.length}
|
||||
onClick={onInvite}
|
||||
>
|
||||
{t['com.affine.share-menu.invite-editor.invite']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Result = ({
|
||||
onClickMember,
|
||||
}: {
|
||||
onClickMember: (member: Member) => void;
|
||||
}) => {
|
||||
const memberSearchService = useService(MemberSearchService);
|
||||
const result = useLiveData(memberSearchService.result$);
|
||||
const isLoading = useLiveData(memberSearchService.isLoading$);
|
||||
|
||||
const activeMembers = useMemo(() => {
|
||||
return result.filter(
|
||||
member => member.status === WorkspaceMemberStatus.Accepted
|
||||
);
|
||||
}, [result]);
|
||||
|
||||
const itemContentRenderer = useCallback(
|
||||
(_index: number, data: Member) => {
|
||||
const handleSelect = () => {
|
||||
onClickMember(data);
|
||||
};
|
||||
return (
|
||||
<div onClick={handleSelect}>
|
||||
<MemberItem member={data} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[onClickMember]
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
memberSearchService.loadMore();
|
||||
}, [memberSearchService]);
|
||||
|
||||
if (!activeMembers || activeMembers.length === 0) {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.noFound}>
|
||||
{t['com.affine.share-menu.invite-editor.no-found']()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
components={{
|
||||
Scroller,
|
||||
}}
|
||||
data={activeMembers}
|
||||
itemContent={itemContentRenderer}
|
||||
endReached={loadMore}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RoleSelector = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
inviteDocRoleType,
|
||||
onRoleChange,
|
||||
}: {
|
||||
openPaywallModal: () => void;
|
||||
inviteDocRoleType: DocRole;
|
||||
onRoleChange: (role: DocRole) => void;
|
||||
hittingPaywall: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const currentRoleName = useMemo(
|
||||
() => getRoleName(inviteDocRoleType, t),
|
||||
[inviteDocRoleType, t]
|
||||
);
|
||||
|
||||
const changeToAdmin = useCallback(
|
||||
() => onRoleChange(DocRole.Manager),
|
||||
[onRoleChange]
|
||||
);
|
||||
const changeToWrite = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
onRoleChange(DocRole.Editor);
|
||||
}, [hittingPaywall, onRoleChange, openPaywallModal]);
|
||||
const changeToRead = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
onRoleChange(DocRole.Reader);
|
||||
}, [hittingPaywall, onRoleChange, openPaywallModal]);
|
||||
return (
|
||||
<div className={styles.roleSelectorContainer}>
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
onSelect={changeToAdmin}
|
||||
selected={inviteDocRoleType === DocRole.Manager}
|
||||
>
|
||||
{t['com.affine.share-menu.option.permission.can-manage']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={changeToWrite}
|
||||
selected={inviteDocRoleType === DocRole.Editor}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{t['com.affine.share-menu.option.permission.can-edit']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={changeToRead}
|
||||
selected={inviteDocRoleType === DocRole.Reader}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{t['com.affine.share-menu.option.permission.can-read']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTriggerStyle}
|
||||
variant="plain"
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentRoleName}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const memberItemStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px',
|
||||
width: '100%',
|
||||
gap: '12px',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const memberContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const memberInfoStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const memberNameStyle = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
export const memberEmailStyle = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
export const memberRoleStyle = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
flexShrink: 0,
|
||||
selectors: {
|
||||
'&.disable': {
|
||||
color: cssVarV2('text/disable'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tooltipContentStyle = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Avatar, Tooltip } from '@affine/component';
|
||||
import type { Member } from '@affine/core/modules/permissions';
|
||||
|
||||
import * as styles from './member-item.css';
|
||||
|
||||
export const MemberItem = ({ member }: { member: Member }) => {
|
||||
return (
|
||||
<div className={styles.memberItemStyle}>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<Avatar
|
||||
key={member.id}
|
||||
url={member.avatarUrl || ''}
|
||||
name={member.name || ''}
|
||||
size={36}
|
||||
/>
|
||||
<div className={styles.memberInfoStyle}>
|
||||
<Tooltip
|
||||
content={member.name}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberNameStyle}>{member.name}</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={member.email}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberEmailStyle}>{member.email}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const member = style({
|
||||
height: '22px',
|
||||
display: 'flex',
|
||||
minWidth: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
':last-child': {
|
||||
minWidth: 'max-content',
|
||||
},
|
||||
});
|
||||
|
||||
export const memberInnerWrapper = style({
|
||||
fontSize: 'inherit',
|
||||
borderRadius: '2px',
|
||||
columnGap: '4px',
|
||||
borderWidth: '1px',
|
||||
borderStyle: 'solid',
|
||||
background: cssVar('backgroundPrimaryColor'),
|
||||
maxWidth: '128px',
|
||||
height: '100%',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '1px 2px',
|
||||
color: cssVarV2('text/primary'),
|
||||
borderColor: cssVarV2('layer/insideBorder/blackBorder'),
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const remove = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 14,
|
||||
height: 14,
|
||||
borderRadius: '2px',
|
||||
flexShrink: 0,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Member } from '@affine/core/modules/permissions';
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import { type MouseEventHandler, useCallback } from 'react';
|
||||
|
||||
import * as styles from './selected-member-item.css';
|
||||
|
||||
export interface TagItemProps {
|
||||
member: Member;
|
||||
idx?: number;
|
||||
onRemoved?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SelectedMemberItem = ({
|
||||
member,
|
||||
idx,
|
||||
onRemoved,
|
||||
style,
|
||||
}: TagItemProps) => {
|
||||
const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
e => {
|
||||
e.stopPropagation();
|
||||
onRemoved?.();
|
||||
},
|
||||
[onRemoved]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.member}
|
||||
data-idx={idx}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberInnerWrapper}>
|
||||
<div className={styles.label}>{member.name}</div>
|
||||
{onRemoved ? (
|
||||
<div className={styles.remove} onClick={handleRemove}>
|
||||
<CloseIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const inputStyle = style({
|
||||
marginTop: '6px',
|
||||
padding: '4px',
|
||||
gap: '4px',
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Avatar, Skeleton, Tooltip } from '@affine/component';
|
||||
import { DocGrantedUsersService } from '@affine/core/modules/permissions';
|
||||
import { DocRole } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export { MemberManagement } from './member-management';
|
||||
|
||||
export const MembersRow = ({ onClick }: { onClick: () => void }) => {
|
||||
const t = useI18n();
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
|
||||
const grantedUserList = useLiveData(docGrantedUsersService.grantedUsers$);
|
||||
const grantedUserCount = useLiveData(
|
||||
docGrantedUsersService.grantedUserCount$
|
||||
);
|
||||
const loading = useLiveData(docGrantedUsersService.isLoading$);
|
||||
const docOwner = useLiveData(
|
||||
docGrantedUsersService.grantedUsers$.map(users =>
|
||||
users.find(user => user.role === DocRole.Owner)
|
||||
)
|
||||
);
|
||||
|
||||
const topThreeMembers = useMemo(
|
||||
() =>
|
||||
grantedUserList
|
||||
?.slice(0, Math.min(3, grantedUserList.length))
|
||||
.map(grantedUser => ({
|
||||
name: grantedUser.user.name,
|
||||
avatarUrl: grantedUser.user.avatarUrl,
|
||||
id: grantedUser.user.id,
|
||||
})),
|
||||
[grantedUserList]
|
||||
);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!grantedUserCount || !topThreeMembers || topThreeMembers.length <= 1) {
|
||||
return '';
|
||||
}
|
||||
switch (grantedUserCount) {
|
||||
case 2:
|
||||
return t['com.affine.share-menu.member-management.member-count-2']({
|
||||
member1: topThreeMembers[0].name,
|
||||
member2: topThreeMembers[1].name,
|
||||
});
|
||||
case 3:
|
||||
return t['com.affine.share-menu.member-management.member-count-3']({
|
||||
member1: topThreeMembers[0].name,
|
||||
member2: topThreeMembers[1].name,
|
||||
member3: topThreeMembers[2].name,
|
||||
});
|
||||
default:
|
||||
return t['com.affine.share-menu.member-management.member-count-more']({
|
||||
member1: topThreeMembers[0].name,
|
||||
member2: topThreeMembers[1].name,
|
||||
memberCount: (grantedUserCount - 2).toString(),
|
||||
});
|
||||
}
|
||||
}, [grantedUserCount, t, topThreeMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
docGrantedUsersService.reset();
|
||||
docGrantedUsersService.loadMore();
|
||||
}, [docGrantedUsersService]);
|
||||
|
||||
if (
|
||||
grantedUserCount &&
|
||||
topThreeMembers &&
|
||||
topThreeMembers.length > 1 &&
|
||||
grantedUserCount > 1
|
||||
) {
|
||||
return (
|
||||
<Tooltip content={description}>
|
||||
<div
|
||||
className={clsx(styles.rowContainerStyle, 'clickable')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<div className={styles.avatarsContainerStyle}>
|
||||
{topThreeMembers.map((member, index) => (
|
||||
<Avatar
|
||||
key={member.id}
|
||||
url={member.avatarUrl || ''}
|
||||
name={member.name || ''}
|
||||
size={24}
|
||||
style={{
|
||||
marginLeft: index === 0 ? 0 : -8,
|
||||
border: `1px solid ${cssVarV2('layer/white')}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={styles.descriptionStyle}>{description}</span>
|
||||
</div>
|
||||
<div className={styles.IconButtonStyle}>
|
||||
<ArrowRightSmallIcon />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!docOwner && loading) {
|
||||
// is loading
|
||||
return (
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<Skeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(@JimmFly): handle the case when there is only one member
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.rowContainerStyle, 'clickable')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<Avatar
|
||||
url={docOwner?.user.avatarUrl || ''}
|
||||
name={docOwner?.user.name}
|
||||
size={24}
|
||||
/>
|
||||
<span>{docOwner?.user.name}</span>
|
||||
</div>
|
||||
<div className={styles.OwnerStyle}>{t['Owner']()}</div>
|
||||
<div className={styles.IconButtonStyle}>
|
||||
<ArrowRightSmallIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const memberItemStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px',
|
||||
width: '100%',
|
||||
gap: '12px',
|
||||
});
|
||||
|
||||
export const memberContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const memberInfoStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const memberNameStyle = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
export const memberEmailStyle = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
|
||||
export const memberRoleStyle = style({
|
||||
color: cssVarV2('text/primary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
flexShrink: 0,
|
||||
selectors: {
|
||||
'&.disable': {
|
||||
color: cssVarV2('text/disable'),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const tooltipContentStyle = style({
|
||||
wordBreak: 'break-word',
|
||||
});
|
||||
|
||||
export const menuTriggerStyle = style({
|
||||
padding: '4px',
|
||||
paddingRight: '0',
|
||||
borderRadius: '4px',
|
||||
gap: '4px',
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const remove = style({
|
||||
color: cssVarV2('status/error'),
|
||||
});
|
||||
export const planTagContainer = style({
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
Avatar,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuTrigger,
|
||||
notify,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { DocRole, UserFriendlyError } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PlanTag } from '../plan-tag';
|
||||
import * as styles from './member-item.css';
|
||||
|
||||
export const MemberItem = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
grantedUser,
|
||||
}: {
|
||||
grantedUser: GrantedUser;
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
}) => {
|
||||
const user = grantedUser.user;
|
||||
|
||||
const role = useMemo(() => {
|
||||
switch (grantedUser.role) {
|
||||
case DocRole.Owner:
|
||||
return 'Owner';
|
||||
case DocRole.Manager:
|
||||
return 'Can manage';
|
||||
case DocRole.Editor:
|
||||
return 'Can edit';
|
||||
case DocRole.Reader:
|
||||
return 'Can read';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [grantedUser.role]);
|
||||
|
||||
return (
|
||||
<div className={styles.memberItemStyle}>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<Avatar
|
||||
key={user.id}
|
||||
url={user.avatarUrl || ''}
|
||||
name={user.name}
|
||||
size={36}
|
||||
/>
|
||||
<div className={styles.memberInfoStyle}>
|
||||
<Tooltip
|
||||
content={user.name}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberNameStyle}>{user.name}</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={user.email}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberEmailStyle}>{user.email}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<Menu
|
||||
items={
|
||||
<Options
|
||||
userId={user.id}
|
||||
memberRole={grantedUser.role}
|
||||
hittingPaywall={hittingPaywall}
|
||||
openPaywallModal={openPaywallModal}
|
||||
/>
|
||||
}
|
||||
contentOptions={{
|
||||
align: 'start',
|
||||
}}
|
||||
>
|
||||
<MenuTrigger
|
||||
variant="plain"
|
||||
className={styles.menuTriggerStyle}
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Options = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
memberRole,
|
||||
userId,
|
||||
}: {
|
||||
userId: string;
|
||||
memberRole: DocRole;
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
|
||||
const changeToManager = useAsyncCallback(async () => {
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Manager);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, userId, t]);
|
||||
|
||||
const changeToEditor = useAsyncCallback(async () => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Editor);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
|
||||
|
||||
const changeToReader = useAsyncCallback(async () => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Reader);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
|
||||
|
||||
const changeToOwner = useAsyncCallback(async () => {
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Owner);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, userId, t]);
|
||||
|
||||
const removeMember = useAsyncCallback(async () => {
|
||||
try {
|
||||
await docGrantedUsersService.revokeUsersRole(userId);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, userId, t]);
|
||||
|
||||
const operationButtonInfo = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-manage'](),
|
||||
onClick: changeToManager,
|
||||
role: DocRole.Manager,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
},
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-edit'](),
|
||||
onClick: changeToEditor,
|
||||
role: DocRole.Editor,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
showPlanTag: true,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-read'](),
|
||||
onClick: changeToReader,
|
||||
role: DocRole.Reader,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
showPlanTag: true,
|
||||
},
|
||||
];
|
||||
}, [changeToEditor, changeToManager, changeToReader, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{operationButtonInfo.map(item =>
|
||||
item.show ? (
|
||||
<MenuItem
|
||||
key={item.label}
|
||||
onSelect={item.onClick}
|
||||
selected={memberRole === item.role}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{item.label} {item.showPlanTag ? <PlanTag /> : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
) : null
|
||||
)}
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<MenuItem onSelect={changeToOwner}>
|
||||
{t['com.affine.share-menu.member-management.set-as-owner']()}
|
||||
</MenuItem>
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={removeMember} type="danger" className={styles.remove}>
|
||||
{t['com.affine.share-menu.member-management.remove']()}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const containerStyle = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
height: '100%',
|
||||
flex: 1,
|
||||
overflowY: 'hidden',
|
||||
});
|
||||
|
||||
export const headerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
borderBottom: `1px solid ${cssVarV2('tab/divider/divider')}`,
|
||||
cursor: 'pointer',
|
||||
gap: '4px',
|
||||
padding: '4px 4px 6px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
});
|
||||
export const iconStyle = style({
|
||||
fontSize: '20px',
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const footerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
borderTop: `1px solid ${cssVarV2('tab/divider/divider')}`,
|
||||
paddingTop: '8px',
|
||||
});
|
||||
export const addCollaboratorsStyle = style({
|
||||
color: cssVarV2('text/link'),
|
||||
cursor: 'pointer',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
padding: '5px 4px',
|
||||
});
|
||||
|
||||
export const memberListStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
paddingTop: '6px',
|
||||
maxHeight: '455px',
|
||||
});
|
||||
|
||||
export const scrollableRootStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const scrollbar = style({
|
||||
width: 6,
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { Scroller } from '../scroller';
|
||||
import { MemberItem } from './member-item';
|
||||
import * as styles from './member-management.css';
|
||||
|
||||
export const MemberManagement = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
onClickBack,
|
||||
onClickInvite,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
onClickBack: () => void;
|
||||
onClickInvite: () => void;
|
||||
}) => {
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
|
||||
const grantedUserList = useLiveData(docGrantedUsersService.grantedUsers$);
|
||||
const grantedUserCount = useLiveData(
|
||||
docGrantedUsersService.grantedUserCount$
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
// reset the list when mounted
|
||||
docGrantedUsersService.reset();
|
||||
docGrantedUsersService.loadMore();
|
||||
}, [docGrantedUsersService]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
docGrantedUsersService.loadMore();
|
||||
}, [docGrantedUsersService]);
|
||||
|
||||
return (
|
||||
<div className={styles.containerStyle}>
|
||||
<div className={styles.headerStyle} onClick={onClickBack}>
|
||||
<ArrowLeftBigIcon className={styles.iconStyle} />
|
||||
{t['com.affine.share-menu.member-management.header']({
|
||||
memberCount: grantedUserCount?.toString() || '??',
|
||||
})}
|
||||
</div>
|
||||
{grantedUserList ? (
|
||||
<MemberList
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={hittingPaywall}
|
||||
grantedUserList={grantedUserList}
|
||||
grantedUserCount={grantedUserCount}
|
||||
loadMore={loadMore}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className={styles.scrollableRootStyle} />
|
||||
)}
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<div className={styles.footerStyle}>
|
||||
<span className={styles.addCollaboratorsStyle} onClick={onClickInvite}>
|
||||
{t['com.affine.share-menu.member-management.add-collaborators']()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberList = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
grantedUserList,
|
||||
grantedUserCount,
|
||||
loadMore,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
grantedUserList: GrantedUser[];
|
||||
grantedUserCount?: number;
|
||||
openPaywallModal: () => void;
|
||||
loadMore: () => void;
|
||||
}) => {
|
||||
const itemContentRenderer = useCallback(
|
||||
(_index: number, data: GrantedUser) => {
|
||||
return (
|
||||
<MemberItem
|
||||
key={data.user.id}
|
||||
grantedUser={data}
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={hittingPaywall}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[hittingPaywall, openPaywallModal]
|
||||
);
|
||||
return (
|
||||
<Virtuoso
|
||||
components={{
|
||||
Scroller,
|
||||
}}
|
||||
data={grantedUserList}
|
||||
itemContent={itemContentRenderer}
|
||||
totalCount={grantedUserCount}
|
||||
endReached={loadMore}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuTriggerStyle = style({
|
||||
width: '150px',
|
||||
padding: '4px 10px',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const rowContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
selectors: {
|
||||
'&.clickable:hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const memberContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const descriptionStyle = style({
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const IconButtonStyle = style({
|
||||
flexShrink: 0,
|
||||
marginLeft: '8px',
|
||||
fontSize: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const OwnerStyle = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const avatarsContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
});
|
||||
|
||||
export const openWorkspaceSettingsStyle = style({
|
||||
color: cssVarV2('text/link'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
globalStyle(`${openWorkspaceSettingsStyle} svg`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const containerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
padding: '0 4px',
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
backgroundColor: cssVarV2('button/primary'),
|
||||
borderRadius: '4px',
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { containerStyle } from './plan-tag.css';
|
||||
|
||||
export const PlanTag = () => {
|
||||
return <div className={containerStyle}>Pro</div>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const result = style({
|
||||
minHeight: '164px',
|
||||
maxHeight: '342px',
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import { forwardRef, type HTMLAttributes, type PropsWithChildren } from 'react';
|
||||
|
||||
import * as styles from './scroller.css';
|
||||
|
||||
export const Scroller = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
||||
>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport {...props} className={styles.result} ref={ref}>
|
||||
{children}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
|
||||
Scroller.displayName = 'Scroller';
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Tabs, Tooltip, useConfirmModal } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { Menu } from '@affine/component/ui/menu';
|
||||
import {
|
||||
ServerService,
|
||||
WorkspaceSubscriptionService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
|
||||
import { ServerDeploymentType, SubscriptionPlan } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
import { LockIcon, PublishIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import {
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import { InviteMemberEditor } from './invite-member-editor/invite-member-editor';
|
||||
import { MemberManagement } from './member-management';
|
||||
import { ShareExport } from './share-export';
|
||||
import { SharePage } from './share-page';
|
||||
|
||||
export interface ShareMenuProps extends PropsWithChildren {
|
||||
workspaceMetadata: WorkspaceMetadata;
|
||||
currentPage: Store;
|
||||
onEnableAffineCloud: () => void;
|
||||
onOpenShareModal?: (open: boolean) => void;
|
||||
openPaywallModal?: () => void;
|
||||
hittingPaywall?: boolean;
|
||||
}
|
||||
|
||||
export enum ShareMenuTab {
|
||||
Share = 'share',
|
||||
Export = 'export',
|
||||
Invite = 'invite',
|
||||
Members = 'members',
|
||||
}
|
||||
|
||||
export const ShareMenuContent = (props: ShareMenuProps) => {
|
||||
const t = useI18n();
|
||||
const [currentTab, setCurrentTab] = useState(ShareMenuTab.Share);
|
||||
|
||||
const serverService = useService(ServerService);
|
||||
const isSelfhosted = useLiveData(
|
||||
serverService.server.config$.selector(
|
||||
c => c.type === ServerDeploymentType.Selfhosted
|
||||
)
|
||||
);
|
||||
|
||||
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
|
||||
const subscription = useLiveData(
|
||||
workspaceSubscriptionService.subscription.subscription$
|
||||
);
|
||||
const hittingPaywall =
|
||||
(!subscription && !isSelfhosted) ||
|
||||
(subscription && subscription.plan === SubscriptionPlan.Free);
|
||||
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
|
||||
const onValueChange = useCallback((value: string) => {
|
||||
setCurrentTab(value as ShareMenuTab);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
workspaceSubscriptionService.subscription.revalidate();
|
||||
}, [workspaceSubscriptionService]);
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
if (!isOwner) {
|
||||
return;
|
||||
}
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: 'plans',
|
||||
scrollAnchor: 'cloudPricingPlan',
|
||||
});
|
||||
return;
|
||||
}, [isOwner, workspaceDialogService]);
|
||||
|
||||
const openPaywallModal = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title:
|
||||
t[
|
||||
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.title`
|
||||
](),
|
||||
description:
|
||||
t[
|
||||
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.description`
|
||||
](),
|
||||
confirmText:
|
||||
t[
|
||||
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.confirm`
|
||||
](),
|
||||
onConfirm: onConfirm,
|
||||
cancelText: t['Cancel'](),
|
||||
cancelButtonOptions: {
|
||||
style: {
|
||||
visibility: isOwner ? 'visible' : 'hidden',
|
||||
},
|
||||
},
|
||||
confirmButtonOptions: {
|
||||
variant: isOwner ? 'primary' : 'custom',
|
||||
},
|
||||
});
|
||||
}, [isOwner, onConfirm, openConfirmModal, t]);
|
||||
|
||||
if (currentTab === ShareMenuTab.Members) {
|
||||
return (
|
||||
<MemberManagement
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={!!hittingPaywall}
|
||||
onClickBack={() => {
|
||||
setCurrentTab(ShareMenuTab.Share);
|
||||
}}
|
||||
onClickInvite={() => {
|
||||
setCurrentTab(ShareMenuTab.Invite);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (currentTab === ShareMenuTab.Invite) {
|
||||
return (
|
||||
<InviteMemberEditor
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={!!hittingPaywall}
|
||||
onClickCancel={() => {
|
||||
setCurrentTab(ShareMenuTab.Share);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.containerStyle}>
|
||||
<Tabs.Root
|
||||
defaultValue={ShareMenuTab.Share}
|
||||
value={currentTab}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value={ShareMenuTab.Share}>
|
||||
{t['com.affine.share-menu.shareButton']()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value={ShareMenuTab.Export}>
|
||||
{t['Export']()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value={ShareMenuTab.Invite} style={{ display: 'none' }}>
|
||||
invite
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value={ShareMenuTab.Members}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
members
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value={ShareMenuTab.Share}>
|
||||
<SharePage
|
||||
hittingPaywall={!!hittingPaywall}
|
||||
openPaywallModal={openPaywallModal}
|
||||
onClickInvite={() => {
|
||||
setCurrentTab(ShareMenuTab.Invite);
|
||||
}}
|
||||
onClickMembers={() => {
|
||||
setCurrentTab(ShareMenuTab.Members);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={ShareMenuTab.Export}>
|
||||
<ShareExport />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={ShareMenuTab.Invite}>
|
||||
<div>null</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={ShareMenuTab.Members}>
|
||||
<div>null</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultShareButton = forwardRef(function DefaultShareButton(
|
||||
_,
|
||||
ref: Ref<HTMLButtonElement>
|
||||
) {
|
||||
const t = useI18n();
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const shared = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
shared
|
||||
? t['com.affine.share-menu.option.link.readonly.description']()
|
||||
: t['com.affine.share-menu.option.link.no-access.description']()
|
||||
}
|
||||
>
|
||||
<Button ref={ref} className={styles.button}>
|
||||
<div className={styles.buttonContainer}>
|
||||
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
|
||||
{t['com.affine.share-menu.shareButton']()}
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const LocalShareMenu = (props: ShareMenuProps) => {
|
||||
return (
|
||||
<Menu
|
||||
items={<ShareMenuContent {...props} />}
|
||||
contentOptions={{
|
||||
className: styles.localMenuStyle,
|
||||
['data-testid' as string]: 'local-share-menu',
|
||||
align: 'end',
|
||||
}}
|
||||
rootOptions={{
|
||||
modal: false,
|
||||
onOpenChange: props.onOpenShareModal,
|
||||
}}
|
||||
>
|
||||
<div data-testid="local-share-menu-button">
|
||||
{props.children || <DefaultShareButton />}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const CloudShareMenu = (props: ShareMenuProps) => {
|
||||
return (
|
||||
<Menu
|
||||
items={<ShareMenuContent {...props} />}
|
||||
contentOptions={{
|
||||
className: styles.menuStyle,
|
||||
['data-testid' as string]: 'cloud-share-menu',
|
||||
align: 'end',
|
||||
}}
|
||||
rootOptions={{
|
||||
modal: false,
|
||||
onOpenChange: props.onOpenShareModal,
|
||||
}}
|
||||
>
|
||||
<div data-testid="cloud-share-menu-button">
|
||||
{props.children || <DefaultShareButton />}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShareMenu = (props: ShareMenuProps) => {
|
||||
const { workspaceMetadata } = props;
|
||||
|
||||
if (workspaceMetadata.flavour === 'local') {
|
||||
return <LocalShareMenu {...props} />;
|
||||
}
|
||||
return <CloudShareMenu {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { ServerService } from '@affine/core/modules/cloud';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { CloudSvg } from '../cloud-svg';
|
||||
import { CopyLinkButton } from './copy-link-button';
|
||||
import { MembersPermission, PublicDoc } from './general-access';
|
||||
import * as styles from './index.css';
|
||||
import { InviteInput } from './invite-member-editor';
|
||||
import { MembersRow } from './member-management';
|
||||
import type { ShareMenuProps } from './share-menu';
|
||||
|
||||
export const LocalSharePage = (props: ShareMenuProps) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workspaceMetadata: { id: workspaceId },
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.localSharePage}>
|
||||
<div className={styles.columnContainerStyle} style={{ gap: '12px' }}>
|
||||
<div
|
||||
className={styles.descriptionStyle}
|
||||
style={{ maxWidth: '230px' }}
|
||||
>
|
||||
{t['com.affine.share-menu.EnableCloudDescription']()}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={props.onEnableAffineCloud}
|
||||
variant="primary"
|
||||
data-testid="share-menu-enable-affine-cloud-button"
|
||||
>
|
||||
{t['Enable AFFiNE Cloud']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cloudSvgContainer}>
|
||||
<CloudSvg />
|
||||
</div>
|
||||
</div>
|
||||
<CopyLinkButton workspaceId={workspaceId} secondary />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AFFiNESharePage = (
|
||||
props: ShareMenuProps & {
|
||||
onClickInvite: () => void;
|
||||
onClickMembers: () => void;
|
||||
}
|
||||
) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workspaceMetadata: { id: workspaceId },
|
||||
} = props;
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const serverService = useService(ServerService);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
|
||||
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
const sharedMode = useLiveData(shareInfoService.shareInfo.sharedMode$);
|
||||
const baseUrl = serverService.server.baseUrl;
|
||||
const isLoading =
|
||||
isSharedPage === null || sharedMode === null || baseUrl === null;
|
||||
|
||||
if (isLoading) {
|
||||
// TODO(@eyhn): loading and error UI
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={100} />
|
||||
<Skeleton height={40} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.columnContainerStyle}>
|
||||
<InviteInput onFocus={props.onClickInvite} />
|
||||
<MembersRow onClick={props.onClickMembers} />
|
||||
<div className={styles.generalAccessStyle}>
|
||||
{t['com.affine.share-menu.generalAccess']()}
|
||||
</div>
|
||||
<MembersPermission
|
||||
openPaywallModal={props.openPaywallModal}
|
||||
hittingPaywall={!!props.hittingPaywall}
|
||||
/>
|
||||
<PublicDoc />
|
||||
</div>
|
||||
<CopyLinkButton workspaceId={workspaceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SharePage = (
|
||||
props: ShareMenuProps & {
|
||||
onClickInvite: () => void;
|
||||
onClickMembers: () => void;
|
||||
}
|
||||
) => {
|
||||
if (props.workspaceMetadata.flavour === 'local') {
|
||||
return <LocalSharePage {...props} />;
|
||||
} else {
|
||||
return (
|
||||
// TODO(@eyhn): refactor this part
|
||||
<ErrorBoundary fallback={null}>
|
||||
<Suspense>
|
||||
<AFFiNESharePage {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user