From 5ae5fd88f191a8c98b4126eb03259d78a4c1df49 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Fri, 7 Feb 2025 13:05:58 +0000 Subject: [PATCH] feat(core): add doc grant feature to share menu (#9672) --- .../common/infra/src/livedata/effect/index.ts | 62 +-- .../component/src/ui/menu/styles.css.ts | 1 - .../share-page-modal/share-menu/index.css.ts | 271 ------------ .../share-menu/share-menu.tsx | 124 ------ .../share-menu/share-page.tsx | 253 ----------- .../block-suite-header/menu/index.tsx | 2 +- .../dialogs/create-workspace/index.tsx | 2 +- .../detail-page/detail-page-header.tsx | 2 +- .../detail/page-header-share-button.tsx | 4 +- .../core/src/modules/permissions/index.ts | 24 +- .../permissions/services/doc-granted-users.ts | 142 +++++++ .../permissions/services/member-search.ts | 85 ++++ .../permissions/stores/doc-granted-users.ts | 95 +++++ .../permissions/stores/member-search.ts | 36 ++ .../core/src/modules/share-menu/index.ts | 1 + .../share-menu/view}/cloud-svg.tsx | 0 .../share-menu/view}/index.tsx | 2 + .../view/share-menu/copy-link-button.css.ts | 111 +++++ .../view}/share-menu/copy-link-button.tsx | 2 +- .../view/share-menu/general-access/index.ts | 2 + .../general-access/members-permission.tsx | 112 +++++ .../general-access/public-page-button.tsx | 139 ++++++ .../share-menu/general-access/styles.css.ts | 38 ++ .../share-menu/view/share-menu/index.css.ts | 135 ++++++ .../view}/share-menu/index.jotai.ts | 0 .../share-menu/view}/share-menu/index.tsx | 0 .../share-menu/invite-member-editor/index.tsx | 21 + .../invite-member-editor.css.ts | 148 +++++++ .../invite-member-editor.tsx | 398 ++++++++++++++++++ .../invite-member-editor/member-item.css.ts | 68 +++ .../invite-member-editor/member-item.tsx | 39 ++ .../selected-member-item.css.ts | 54 +++ .../selected-member-item.tsx | 45 ++ .../invite-member-editor/styles.css.ts | 7 + .../share-menu/member-management/index.tsx | 138 ++++++ .../member-management/member-item.css.ts | 80 ++++ .../member-management/member-item.tsx | 236 +++++++++++ .../member-management.css.ts | 60 +++ .../member-management/member-management.tsx | 112 +++++ .../member-management/styles.css.ts | 77 ++++ .../view/share-menu/plan-tag.css.ts | 15 + .../share-menu/view/share-menu/plan-tag.tsx | 5 + .../view/share-menu/scroller.css.ts | 5 + .../share-menu/view/share-menu/scroller.tsx | 20 + .../view}/share-menu/share-export.tsx | 0 .../share-menu/view/share-menu/share-menu.tsx | 275 ++++++++++++ .../share-menu/view/share-menu/share-page.tsx | 122 ++++++ packages/frontend/graphql/src/error.ts | 4 +- .../graphql/get-members-by-workspace-id.gql | 4 +- .../graphql/get-page-granted-users-list.gql | 24 ++ .../src/graphql/grant-doc-user-roles.gql | 3 + .../frontend/graphql/src/graphql/index.ts | 69 ++- .../src/graphql/revoke-doc-user-roles.gql | 3 + .../src/graphql/update-doc-user-role.gql | 3 + packages/frontend/graphql/src/schema.ts | 98 ++++- packages/frontend/i18n/src/i18n.gen.ts | 79 +++- packages/frontend/i18n/src/resources/en.json | 28 +- 57 files changed, 3188 insertions(+), 697 deletions(-) delete mode 100644 packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts delete mode 100644 packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx delete mode 100644 packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx create mode 100644 packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts create mode 100644 packages/frontend/core/src/modules/permissions/services/member-search.ts create mode 100644 packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts create mode 100644 packages/frontend/core/src/modules/permissions/stores/member-search.ts create mode 100644 packages/frontend/core/src/modules/share-menu/index.ts rename packages/frontend/core/src/{components/affine/share-page-modal => modules/share-menu/view}/cloud-svg.tsx (100%) rename packages/frontend/core/src/{components/affine/share-page-modal => modules/share-menu/view}/index.tsx (91%) create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.css.ts rename packages/frontend/core/src/{components/affine/share-page-modal => modules/share-menu/view}/share-menu/copy-link-button.tsx (98%) create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/index.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/styles.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/index.css.ts rename packages/frontend/core/src/{components/affine/share-page-modal => modules/share-menu/view}/share-menu/index.jotai.ts (100%) rename packages/frontend/core/src/{components/affine/share-page-modal => modules/share-menu/view}/share-menu/index.tsx (100%) create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/index.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/styles.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/index.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/styles.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.css.ts create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.tsx rename packages/frontend/core/src/{components/affine/share-page-modal => modules/share-menu/view}/share-menu/share-export.tsx (100%) create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx create mode 100644 packages/frontend/core/src/modules/share-menu/view/share-menu/share-page.tsx create mode 100644 packages/frontend/graphql/src/graphql/get-page-granted-users-list.gql create mode 100644 packages/frontend/graphql/src/graphql/grant-doc-user-roles.gql create mode 100644 packages/frontend/graphql/src/graphql/revoke-doc-user-roles.gql create mode 100644 packages/frontend/graphql/src/graphql/update-doc-user-role.gql diff --git a/packages/common/infra/src/livedata/effect/index.ts b/packages/common/infra/src/livedata/effect/index.ts index 19c93f18c0..48df960bb9 100644 --- a/packages/common/infra/src/livedata/effect/index.ts +++ b/packages/common/infra/src/livedata/effect/index.ts @@ -1,6 +1,6 @@ import { DebugLogger } from '@affine/debug'; import { Unreachable } from '@affine/env/constant'; -import { type OperatorFunction, Subject } from 'rxjs'; +import { type OperatorFunction, Subject, type Subscription } from 'rxjs'; const logger = new DebugLogger('effect'); @@ -9,6 +9,8 @@ export type Effect = (T | undefined extends T // hack to detect if T is unkno : (value: T) => void) & { // unsubscribe effect, all ongoing effects will be cancelled. unsubscribe: () => void; + // reset internal state, all ongoing effects will be cancelled. + reset: () => void; }; /** @@ -89,36 +91,44 @@ export function effect(...args: any[]) { } } - // eslint-disable-next-line prefer-spread - const subscription = subject$.pipe.apply(subject$, args as any).subscribe({ - next(value) { - const error = new EffectError('should not emit value', value); - // make a uncaught exception - setTimeout(() => { - throw error; - }, 0); - }, - complete() { - const error = new EffectError('effect unexpected complete'); - // make a uncaught exception - setTimeout(() => { - throw error; - }, 0); - }, - error(error) { - const effectError = new EffectError('effect uncaught error', error); - // make a uncaught exception - setTimeout(() => { - throw effectError; - }, 0); - }, - }); + let subscription: Subscription | null = null; + + function subscribe() { + subscription = subject$.pipe.apply(subject$, args as any).subscribe({ + next(value) { + const error = new EffectError('should not emit value', value); + // make a uncaught exception + setTimeout(() => { + throw error; + }, 0); + }, + complete() { + const error = new EffectError('effect unexpected complete'); + // make a uncaught exception + setTimeout(() => { + throw error; + }, 0); + }, + error(error) { + const effectError = new EffectError('effect uncaught error', error); + // make a uncaught exception + setTimeout(() => { + throw effectError; + }, 0); + }, + }); + } + subscribe(); const fn = (value: unknown) => { subject$.next(value); }; - fn.unsubscribe = () => subscription.unsubscribe(); + fn.unsubscribe = () => subscription?.unsubscribe(); + fn.reset = () => { + subscription?.unsubscribe(); + subscribe(); + }; return fn as never; } diff --git a/packages/frontend/component/src/ui/menu/styles.css.ts b/packages/frontend/component/src/ui/menu/styles.css.ts index 4ad3ad462c..668aa2805f 100644 --- a/packages/frontend/component/src/ui/menu/styles.css.ts +++ b/packages/frontend/component/src/ui/menu/styles.css.ts @@ -85,7 +85,6 @@ export const menuItem = style({ '&.checked, &.selected': { vars: { [iconColor]: cssVar('primaryColor'), - [labelColor]: cssVar('primaryColor'), }, }, }, diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts deleted file mode 100644 index 4771c68717..0000000000 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.css.ts +++ /dev/null @@ -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'), -}); diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx deleted file mode 100644 index 3d4f616a21..0000000000 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx +++ /dev/null @@ -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 ( -
- - - - {t['com.affine.share-menu.shareButton']()} - - {t['Export']()} - - - - - - - - -
- ); -}; - -const DefaultShareButton = forwardRef(function DefaultShareButton( - _, - ref: Ref -) { - const t = useI18n(); - const shareInfoService = useService(ShareInfoService); - const shared = useLiveData(shareInfoService.shareInfo.isShared$); - - useEffect(() => { - shareInfoService.shareInfo.revalidate(); - }, [shareInfoService]); - - return ( - - - - ); -}); - -const LocalShareMenu = (props: ShareMenuProps) => { - return ( - } - contentOptions={{ - className: styles.menuStyle, - ['data-testid' as string]: 'local-share-menu', - align: 'end', - }} - rootOptions={{ - modal: false, - onOpenChange: props.onOpenShareModal, - }} - > -
- {props.children || } -
-
- ); -}; - -const CloudShareMenu = (props: ShareMenuProps) => { - return ( - } - contentOptions={{ - className: styles.menuStyle, - ['data-testid' as string]: 'cloud-share-menu', - align: 'end', - }} - rootOptions={{ - modal: false, - onOpenChange: props.onOpenShareModal, - }} - > -
- {props.children || } -
-
- ); -}; - -export const ShareMenu = (props: ShareMenuProps) => { - const { workspaceMetadata } = props; - - if (workspaceMetadata.flavour === 'local') { - return ; - } - return ; -}; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx deleted file mode 100644 index 9f032eb5fb..0000000000 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-page.tsx +++ /dev/null @@ -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 ( - <> -
-
-
- {t['com.affine.share-menu.EnableCloudDescription']()} -
-
- -
-
-
- -
-
- - - ); -}; - -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: , - }); - } 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 ( - <> - - - - ); - } - - return ( -
-
- {isSharedPage - ? t['com.affine.share-menu.option.link.readonly.description']() - : t['com.affine.share-menu.option.link.no-access.description']()} -
-
-
-
- {t['com.affine.share-menu.option.link.label']()} -
- - } onSelect={onDisablePublic}> -
-
- {t['com.affine.share-menu.option.link.no-access']()} -
- {!isSharedPage && ( - - )} -
-
- } - onSelect={onClickAnyoneReadOnlyShare} - data-testid="share-link-menu-enable-share" - > -
-
- {t['com.affine.share-menu.option.link.readonly']()} -
- {isSharedPage && ( - - )} -
-
- - } - > - - {isSharedPage - ? t['com.affine.share-menu.option.link.readonly']() - : t['com.affine.share-menu.option.link.no-access']()} - -
-
-
-
- {t['com.affine.share-menu.option.permission.label']()} -
- -
-
- {isOwner && ( -
- - {t['com.affine.share-menu.navigate.workspace']()} -
- )} - -
- ); -}; - -export const SharePage = (props: ShareMenuProps) => { - if (props.workspaceMetadata.flavour === 'local') { - return ; - } else { - return ( - // TODO(@eyhn): refactor this part - - - - - - ); - } -}; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx index 6e1d1bc54c..e4a1284242 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header/menu/index.tsx @@ -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'; diff --git a/packages/frontend/core/src/desktop/dialogs/create-workspace/index.tsx b/packages/frontend/core/src/desktop/dialogs/create-workspace/index.tsx index 2aba7fdf03..6576ae65e2 100644 --- a/packages/frontend/core/src/desktop/dialogs/create-workspace/index.tsx +++ b/packages/frontend/core/src/desktop/dialogs/create-workspace/index.tsx @@ -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'; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx index b89b4980d4..ce83dba275 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/detail-page-header.tsx @@ -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'; diff --git a/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-share-button.tsx b/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-share-button.tsx index dbd7daf545..97d221ba92 100644 --- a/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-share-button.tsx +++ b/packages/frontend/core/src/mobile/pages/workspace/detail/page-header-share-button.tsx @@ -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 = () => { - diff --git a/packages/frontend/core/src/modules/permissions/index.ts b/packages/frontend/core/src/modules/permissions/index.ts index 9e855fca3b..c950efb166 100644 --- a/packages/frontend/core/src/modules/permissions/index.ts +++ b/packages/frontend/core/src/modules/permissions/index.ts @@ -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]); } diff --git a/packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts b/packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts new file mode 100644 index 0000000000..04bce19aad --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/services/doc-granted-users.ts @@ -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(undefined); + hasMore$ = new LiveData(true); + grantedUserCount$ = new LiveData(0); + grantedUsers$ = new LiveData([]); + isLoading$ = new LiveData(false); + error$ = new LiveData(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(); + } +} diff --git a/packages/frontend/core/src/modules/permissions/services/member-search.ts b/packages/frontend/core/src/modules/permissions/services/member-search.ts new file mode 100644 index 0000000000..0ea530b23f --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/services/member-search.ts @@ -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(''); + readonly isLoading$ = new LiveData(false); + readonly error$ = new LiveData(null); + readonly result$ = new LiveData([]); + 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(); + } +} diff --git a/packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts b/packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts new file mode 100644 index 0000000000..e1cfb33908 --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/stores/doc-granted-users.ts @@ -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; + } +} diff --git a/packages/frontend/core/src/modules/permissions/stores/member-search.ts b/packages/frontend/core/src/modules/permissions/stores/member-search.ts new file mode 100644 index 0000000000..81480b6751 --- /dev/null +++ b/packages/frontend/core/src/modules/permissions/stores/member-search.ts @@ -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; + } +} diff --git a/packages/frontend/core/src/modules/share-menu/index.ts b/packages/frontend/core/src/modules/share-menu/index.ts new file mode 100644 index 0000000000..e2d1930452 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/index.ts @@ -0,0 +1 @@ +export { CloudSvg, ShareMenuContent, SharePageButton } from './view'; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/cloud-svg.tsx b/packages/frontend/core/src/modules/share-menu/view/cloud-svg.tsx similarity index 100% rename from packages/frontend/core/src/components/affine/share-page-modal/cloud-svg.tsx rename to packages/frontend/core/src/modules/share-menu/view/cloud-svg.tsx diff --git a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx b/packages/frontend/core/src/modules/share-menu/view/index.tsx similarity index 91% rename from packages/frontend/core/src/components/affine/share-page-modal/index.tsx rename to packages/frontend/core/src/modules/share-menu/view/index.tsx index 7b4cec49e7..40af8cfcc9 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx +++ b/packages/frontend/core/src/modules/share-menu/view/index.tsx @@ -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; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.css.ts new file mode 100644 index 0000000000..740d0c2b22 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/copy-link-button.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.tsx similarity index 98% rename from packages/frontend/core/src/components/affine/share-page-modal/share-menu/copy-link-button.tsx rename to packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.tsx index 787dd1e917..ecdec749f6 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/copy-link-button.tsx +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/copy-link-button.tsx @@ -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, diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/index.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/index.ts new file mode 100644 index 0000000000..76124f4ff0 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/index.ts @@ -0,0 +1,2 @@ +export * from './members-permission'; +export * from './public-page-button'; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx new file mode 100644 index 0000000000..868a9dd7ba --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/members-permission.tsx @@ -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) => { + 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.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 ( +
+
+ {t['com.affine.share-menu.option.permission.label']()} +
+ + +
+ {t['com.affine.share-menu.option.permission.can-manage']()} +
+
+ +
+
+ {t['com.affine.share-menu.option.permission.can-edit']()} + +
+
+
+ +
+
+ {t['com.affine.share-menu.option.permission.can-read']()} + +
+
+
+ + } + > + + {currentRoleName} + +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx new file mode 100644 index 0000000000..83d43b5e76 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/public-page-button.tsx @@ -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: , + }); + } 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 ( +
+
+ {t['com.affine.share-menu.option.link.label']()} +
+ + + } + onSelect={onDisablePublic} + selected={!isSharedPage} + > +
+
{t['com.affine.share-menu.option.link.no-access']()}
+
+
+ } + onSelect={onClickAnyoneReadOnlyShare} + data-testid="share-link-menu-enable-share" + selected={!!isSharedPage} + > +
+
{t['com.affine.share-menu.option.link.readonly']()}
+
+
+ + } + > + + {isSharedPage + ? t['com.affine.share-menu.option.link.readonly']() + : t['com.affine.share-menu.option.link.no-access']()} + +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/styles.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/styles.css.ts new file mode 100644 index 0000000000..a413febeab --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/general-access/styles.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/index.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/index.css.ts new file mode 100644 index 0000000000..53c17bda8f --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/index.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.jotai.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/index.jotai.ts similarity index 100% rename from packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.jotai.ts rename to packages/frontend/core/src/modules/share-menu/view/share-menu/index.jotai.ts diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/index.tsx similarity index 100% rename from packages/frontend/core/src/components/affine/share-page-modal/share-menu/index.tsx rename to packages/frontend/core/src/modules/share-menu/view/share-menu/index.tsx diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/index.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/index.tsx new file mode 100644 index 0000000000..02cd70db79 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/index.tsx @@ -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 ( + } + className={styles.inputStyle} + onFocus={onFocus} + inputStyle={{ + paddingLeft: '0', + }} + placeholder={t['com.affine.share-menu.invite-editor.placeholder']()} + /> + ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.css.ts new file mode 100644 index 0000000000..b89607fe53 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.tsx new file mode 100644 index 0000000000..4a82e83d25 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/invite-member-editor.tsx @@ -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) => { + 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([]); + const docGrantedUsersService = useService(DocGrantedUsersService); + const [inviteDocRoleType, setInviteDocRoleType] = useState( + 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(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 = + useCallback(() => { + setComposing(true); + }, []); + + const handleCompositionEnd: CompositionEventHandler = + 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 ( +
+
+ + {t['com.affine.share-menu.invite-editor.header']()} +
+
+
+
+ {selectedMembers.map((member, idx) => { + if (!member) { + return null; + } + const onRemoved = () => handleRemoved(member.id); + return ( + + ); + })} + +
+ {!selectedMembers.length ? null : ( + + )} +
+
+ + {t['com.affine.share-menu.invite-editor.sent-email']()} +
+ +
+
+ + {t['com.affine.share-menu.invite-editor.manage-members']()} + +
+ + +
+
+
+ ); +}; + +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 ( +
+ +
+ ); + }, + [onClickMember] + ); + + const t = useI18n(); + + const loadMore = useCallback(() => { + memberSearchService.loadMore(); + }, [memberSearchService]); + + if (!activeMembers || activeMembers.length === 0) { + if (isLoading) { + return ; + } + return ( +
+ {t['com.affine.share-menu.invite-editor.no-found']()} +
+ ); + } + + return ( + + ); +}; + +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 ( +
+ + + {t['com.affine.share-menu.option.permission.can-manage']()} + + +
+ {t['com.affine.share-menu.option.permission.can-edit']()} + +
+
+ +
+ {t['com.affine.share-menu.option.permission.can-read']()} + +
+
+ + } + > + + {currentRoleName} + +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.css.ts new file mode 100644 index 0000000000..0db774b9ab --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.tsx new file mode 100644 index 0000000000..2dc665ec95 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/member-item.tsx @@ -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 ( +
+
+ +
+ +
{member.name}
+
+ +
{member.email}
+
+
+
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.css.ts new file mode 100644 index 0000000000..380c9ea380 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.css.ts @@ -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)', + }, +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.tsx new file mode 100644 index 0000000000..bc61f5342c --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/selected-member-item.tsx @@ -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 = useCallback( + e => { + e.stopPropagation(); + onRemoved?.(); + }, + [onRemoved] + ); + return ( +
+
+
{member.name}
+ {onRemoved ? ( +
+ +
+ ) : null} +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/styles.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/styles.css.ts new file mode 100644 index 0000000000..882ad1ba61 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/invite-member-editor/styles.css.ts @@ -0,0 +1,7 @@ +import { style } from '@vanilla-extract/css'; + +export const inputStyle = style({ + marginTop: '6px', + padding: '4px', + gap: '4px', +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/index.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/index.tsx new file mode 100644 index 0000000000..51bf9540d5 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/index.tsx @@ -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 ( + +
+
+
+ {topThreeMembers.map((member, index) => ( + + ))} +
+ {description} +
+
+ +
+
+
+ ); + } + + if (!docOwner && loading) { + // is loading + return ( +
+ +
+ ); + } + + // TODO(@JimmFly): handle the case when there is only one member + return ( +
+
+ + {docOwner?.user.name} +
+
{t['Owner']()}
+
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.css.ts new file mode 100644 index 0000000000..65f3be4af5 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.tsx new file mode 100644 index 0000000000..3b52085a0d --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-item.tsx @@ -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 ( +
+
+ +
+ +
{user.name}
+
+ +
{user.email}
+
+
+
+ + {/* TODO(@eyhn): add guard here */} + + } + contentOptions={{ + align: 'start', + }} + > + + {role} + + +
+ ); +}; + +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 ? ( + +
+ {item.label} {item.showPlanTag ? : null} +
+
+ ) : null + )} + {/* TODO(@eyhn): add guard here */} + + {t['com.affine.share-menu.member-management.set-as-owner']()} + + {/* TODO(@eyhn): add guard here */} + + + {t['com.affine.share-menu.member-management.remove']()} + + + ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.css.ts new file mode 100644 index 0000000000..13f16ea6c7 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.css.ts @@ -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, +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.tsx new file mode 100644 index 0000000000..16e56f916c --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/member-management.tsx @@ -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 ( +
+
+ + {t['com.affine.share-menu.member-management.header']({ + memberCount: grantedUserCount?.toString() || '??', + })} +
+ {grantedUserList ? ( + + ) : ( + + )} + {/* TODO(@eyhn): add guard here */} +
+ + {t['com.affine.share-menu.member-management.add-collaborators']()} + +
+
+ ); +}; + +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 ( + + ); + }, + [hittingPaywall, openPaywallModal] + ); + return ( + + ); +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/styles.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/styles.css.ts new file mode 100644 index 0000000000..4bd87acb1b --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/member-management/styles.css.ts @@ -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'), +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.css.ts new file mode 100644 index 0000000000..4abcac59c2 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.css.ts @@ -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', +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.tsx new file mode 100644 index 0000000000..0fd3b20ef9 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/plan-tag.tsx @@ -0,0 +1,5 @@ +import { containerStyle } from './plan-tag.css'; + +export const PlanTag = () => { + return
Pro
; +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.css.ts b/packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.css.ts new file mode 100644 index 0000000000..e2d7357879 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.css.ts @@ -0,0 +1,5 @@ +import { style } from '@vanilla-extract/css'; +export const result = style({ + minHeight: '164px', + maxHeight: '342px', +}); diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.tsx new file mode 100644 index 0000000000..2f3fdcf26e --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/scroller.tsx @@ -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> +>(({ children, ...props }, ref) => { + return ( + + + {children} + + + + ); +}); + +Scroller.displayName = 'Scroller'; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/share-export.tsx similarity index 100% rename from packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-export.tsx rename to packages/frontend/core/src/modules/share-menu/view/share-menu/share-export.tsx diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx new file mode 100644 index 0000000000..eaedcab2f2 --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/share-menu.tsx @@ -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 ( + { + setCurrentTab(ShareMenuTab.Share); + }} + onClickInvite={() => { + setCurrentTab(ShareMenuTab.Invite); + }} + /> + ); + } + if (currentTab === ShareMenuTab.Invite) { + return ( + { + setCurrentTab(ShareMenuTab.Share); + }} + /> + ); + } + return ( +
+ + + + {t['com.affine.share-menu.shareButton']()} + + + {t['Export']()} + + + invite + + + members + + + + { + setCurrentTab(ShareMenuTab.Invite); + }} + onClickMembers={() => { + setCurrentTab(ShareMenuTab.Members); + }} + {...props} + /> + + + + + +
null
+
+ +
null
+
+
+
+ ); +}; + +const DefaultShareButton = forwardRef(function DefaultShareButton( + _, + ref: Ref +) { + const t = useI18n(); + const shareInfoService = useService(ShareInfoService); + const shared = useLiveData(shareInfoService.shareInfo.isShared$); + + useEffect(() => { + shareInfoService.shareInfo.revalidate(); + }, [shareInfoService]); + + return ( + + + + ); +}); + +const LocalShareMenu = (props: ShareMenuProps) => { + return ( + } + contentOptions={{ + className: styles.localMenuStyle, + ['data-testid' as string]: 'local-share-menu', + align: 'end', + }} + rootOptions={{ + modal: false, + onOpenChange: props.onOpenShareModal, + }} + > +
+ {props.children || } +
+
+ ); +}; + +const CloudShareMenu = (props: ShareMenuProps) => { + return ( + } + contentOptions={{ + className: styles.menuStyle, + ['data-testid' as string]: 'cloud-share-menu', + align: 'end', + }} + rootOptions={{ + modal: false, + onOpenChange: props.onOpenShareModal, + }} + > +
+ {props.children || } +
+
+ ); +}; + +export const ShareMenu = (props: ShareMenuProps) => { + const { workspaceMetadata } = props; + + if (workspaceMetadata.flavour === 'local') { + return ; + } + return ; +}; diff --git a/packages/frontend/core/src/modules/share-menu/view/share-menu/share-page.tsx b/packages/frontend/core/src/modules/share-menu/view/share-menu/share-page.tsx new file mode 100644 index 0000000000..aac2dd267c --- /dev/null +++ b/packages/frontend/core/src/modules/share-menu/view/share-menu/share-page.tsx @@ -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 ( + <> +
+
+
+ {t['com.affine.share-menu.EnableCloudDescription']()} +
+
+ +
+
+
+ +
+
+ + + ); +}; + +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 ( + <> + + + + ); + } + + return ( +
+
+ + +
+ {t['com.affine.share-menu.generalAccess']()} +
+ + +
+ +
+ ); +}; + +export const SharePage = ( + props: ShareMenuProps & { + onClickInvite: () => void; + onClickMembers: () => void; + } +) => { + if (props.workspaceMetadata.flavour === 'local') { + return ; + } else { + return ( + // TODO(@eyhn): refactor this part + + + + + + ); + } +}; diff --git a/packages/frontend/graphql/src/error.ts b/packages/frontend/graphql/src/error.ts index c10623454a..39095af7ea 100644 --- a/packages/frontend/graphql/src/error.ts +++ b/packages/frontend/graphql/src/error.ts @@ -8,7 +8,7 @@ export interface UserFriendlyErrorResponse { type: string; name: ErrorNames; message: string; - args?: any; + data?: any; stacktrace?: string; } @@ -21,7 +21,7 @@ export class UserFriendlyError readonly type = this.response.type; override readonly name = this.response.name; override readonly message = this.response.message; - readonly args = this.response.args; + readonly data = this.response.data; readonly stacktrace = this.response.stacktrace; static fromAnyError(response: any) { diff --git a/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql b/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql index 29a77b7e55..e2e2ff8858 100644 --- a/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql +++ b/packages/frontend/graphql/src/graphql/get-members-by-workspace-id.gql @@ -1,7 +1,7 @@ -query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { +query getMembersByWorkspaceId($workspaceId: String!, $skip: Int, $take: Int, $query: String) { workspace(id: $workspaceId) { memberCount - members(skip: $skip, take: $take) { + members(skip: $skip, take: $take, query: $query) { id name email diff --git a/packages/frontend/graphql/src/graphql/get-page-granted-users-list.gql b/packages/frontend/graphql/src/graphql/get-page-granted-users-list.gql new file mode 100644 index 0000000000..b75d430f26 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-page-granted-users-list.gql @@ -0,0 +1,24 @@ +query getPageGrantedUsersList($pagination: PaginationInput!, $docId: String!, $workspaceId: String!) { + workspace(id: $workspaceId) { + doc(docId: $docId) { + grantedUsersList(pagination: $pagination) { + totalCount + pageInfo { + endCursor + hasNextPage + } + edges { + node { + role + user { + id + name + email + avatarUrl + } + } + } + } + } + } +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/grant-doc-user-roles.gql b/packages/frontend/graphql/src/graphql/grant-doc-user-roles.gql new file mode 100644 index 0000000000..2b7b85b786 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/grant-doc-user-roles.gql @@ -0,0 +1,3 @@ +mutation grantDocUserRoles($input: GrantDocUserRolesInput!) { + grantDocUserRoles(input: $input) +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 265c13c75f..87d024ae34 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -525,10 +525,10 @@ export const getMembersByWorkspaceIdQuery = { definitionName: 'workspace', containsFile: false, query: ` -query getMembersByWorkspaceId($workspaceId: String!, $skip: Int!, $take: Int!) { +query getMembersByWorkspaceId($workspaceId: String!, $skip: Int, $take: Int, $query: String) { workspace(id: $workspaceId) { memberCount - members(skip: $skip, take: $take) { + members(skip: $skip, take: $take, query: $query) { id name email @@ -555,6 +555,38 @@ query oauthProviders { }`, }; +export const getPageGrantedUsersListQuery = { + id: 'getPageGrantedUsersListQuery' as const, + operationName: 'getPageGrantedUsersList', + definitionName: 'workspace', + containsFile: false, + query: ` +query getPageGrantedUsersList($pagination: PaginationInput!, $docId: String!, $workspaceId: String!) { + workspace(id: $workspaceId) { + doc(docId: $docId) { + grantedUsersList(pagination: $pagination) { + totalCount + pageInfo { + endCursor + hasNextPage + } + edges { + node { + role + user { + id + name + email + avatarUrl + } + } + } + } + } + } +}`, +}; + export const getPromptsQuery = { id: 'getPromptsQuery' as const, operationName: 'getPrompts', @@ -830,6 +862,17 @@ query getWorkspaces { }`, }; +export const grantDocUserRolesMutation = { + id: 'grantDocUserRolesMutation' as const, + operationName: 'grantDocUserRoles', + definitionName: 'grantDocUserRoles', + containsFile: false, + query: ` +mutation grantDocUserRoles($input: GrantDocUserRolesInput!) { + grantDocUserRoles(input: $input) +}`, +}; + export const listHistoryQuery = { id: 'listHistoryQuery' as const, operationName: 'listHistory', @@ -1019,6 +1062,17 @@ mutation resumeSubscription($plan: SubscriptionPlan = Pro, $workspaceId: String) }`, }; +export const revokeDocUserRolesMutation = { + id: 'revokeDocUserRolesMutation' as const, + operationName: 'revokeDocUserRoles', + definitionName: 'revokeDocUserRoles', + containsFile: false, + query: ` +mutation revokeDocUserRoles($input: RevokeDocUserRoleInput!) { + revokeDocUserRoles(input: $input) +}`, +}; + export const revokeMemberPermissionMutation = { id: 'revokeMemberPermissionMutation' as const, operationName: 'revokeMemberPermission', @@ -1196,6 +1250,17 @@ mutation updateCopilotSession($options: UpdateChatSessionInput!) { }`, }; +export const updateDocUserRoleMutation = { + id: 'updateDocUserRoleMutation' as const, + operationName: 'updateDocUserRole', + definitionName: 'updateDocUserRole', + containsFile: false, + query: ` +mutation updateDocUserRole($input: UpdateDocUserRoleInput!) { + updateDocUserRole(input: $input) +}`, +}; + export const updatePromptMutation = { id: 'updatePromptMutation' as const, operationName: 'updatePrompt', diff --git a/packages/frontend/graphql/src/graphql/revoke-doc-user-roles.gql b/packages/frontend/graphql/src/graphql/revoke-doc-user-roles.gql new file mode 100644 index 0000000000..cf43d05058 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/revoke-doc-user-roles.gql @@ -0,0 +1,3 @@ +mutation revokeDocUserRoles($input: RevokeDocUserRoleInput!) { + revokeDocUserRoles(input: $input) +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/graphql/update-doc-user-role.gql b/packages/frontend/graphql/src/graphql/update-doc-user-role.gql new file mode 100644 index 0000000000..8e68b5f863 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/update-doc-user-role.gql @@ -0,0 +1,3 @@ +mutation updateDocUserRole($input: UpdateDocUserRoleInput!) { + updateDocUserRole(input: $input) +} \ No newline at end of file diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index dbbde1a9ca..53219205c5 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -348,6 +348,7 @@ export enum ErrorNames { CANNOT_DELETE_ALL_ADMIN_ACCOUNT = 'CANNOT_DELETE_ALL_ADMIN_ACCOUNT', CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT', CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION = 'CANT_UPDATE_ONETIME_PAYMENT_SUBSCRIPTION', + CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS = 'CAN_NOT_BATCH_GRANT_DOC_OWNER_PERMISSIONS', CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE', @@ -925,7 +926,7 @@ export interface MutationRevokeArgs { } export interface MutationRevokeDocUserRolesArgs { - input: RevokeDocUserRolesInput; + input: RevokeDocUserRoleInput; } export interface MutationRevokeInviteLinkArgs { @@ -1202,9 +1203,9 @@ export interface RemoveAvatar { success: Scalars['Boolean']['output']; } -export interface RevokeDocUserRolesInput { +export interface RevokeDocUserRoleInput { docId: Scalars['String']['input']; - userIds: Array; + userId: Scalars['String']['input']; workspaceId: Scalars['String']['input']; } @@ -2160,8 +2161,9 @@ export type GetMemberCountByWorkspaceIdQuery = { export type GetMembersByWorkspaceIdQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; - skip: Scalars['Int']['input']; - take: Scalars['Int']['input']; + skip?: InputMaybe; + take?: InputMaybe; + query?: InputMaybe; }>; export type GetMembersByWorkspaceIdQuery = { @@ -2193,6 +2195,45 @@ export type OauthProvidersQuery = { }; }; +export type GetPageGrantedUsersListQueryVariables = Exact<{ + pagination: PaginationInput; + docId: Scalars['String']['input']; + workspaceId: Scalars['String']['input']; +}>; + +export type GetPageGrantedUsersListQuery = { + __typename?: 'Query'; + workspace: { + __typename?: 'WorkspaceType'; + doc: { + __typename?: 'DocType'; + grantedUsersList: { + __typename?: 'PaginatedGrantedDocUserType'; + totalCount: number; + pageInfo: { + __typename?: 'PageInfo'; + endCursor: string | null; + hasNextPage: boolean; + }; + edges: Array<{ + __typename?: 'GrantedDocUserTypeEdge'; + node: { + __typename?: 'GrantedDocUserType'; + role: DocRole; + user: { + __typename?: 'PublicUserType'; + id: string; + name: string; + email: string; + avatarUrl: string | null; + }; + }; + }>; + }; + }; + }; +}; + export type GetPromptsQueryVariables = Exact<{ [key: string]: never }>; export type GetPromptsQuery = { @@ -2442,6 +2483,15 @@ export type GetWorkspacesQuery = { }>; }; +export type GrantDocUserRolesMutationVariables = Exact<{ + input: GrantDocUserRolesInput; +}>; + +export type GrantDocUserRolesMutation = { + __typename?: 'Mutation'; + grantDocUserRoles: boolean; +}; + export type ListHistoryQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; pageDocId: Scalars['String']['input']; @@ -2613,6 +2663,15 @@ export type ResumeSubscriptionMutation = { }; }; +export type RevokeDocUserRolesMutationVariables = Exact<{ + input: RevokeDocUserRoleInput; +}>; + +export type RevokeDocUserRolesMutation = { + __typename?: 'Mutation'; + revokeDocUserRoles: boolean; +}; + export type RevokeMemberPermissionMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; userId: Scalars['String']['input']; @@ -2774,6 +2833,15 @@ export type UpdateCopilotSessionMutation = { updateCopilotSession: string; }; +export type UpdateDocUserRoleMutationVariables = Exact<{ + input: UpdateDocUserRoleInput; +}>; + +export type UpdateDocUserRoleMutation = { + __typename?: 'Mutation'; + updateDocUserRole: boolean; +}; + export type UpdatePromptMutationVariables = Exact<{ name: Scalars['String']['input']; messages: Array | CopilotPromptMessageInput; @@ -3129,6 +3197,11 @@ export type Queries = variables: OauthProvidersQueryVariables; response: OauthProvidersQuery; } + | { + name: 'getPageGrantedUsersListQuery'; + variables: GetPageGrantedUsersListQueryVariables; + response: GetPageGrantedUsersListQuery; + } | { name: 'getPromptsQuery'; variables: GetPromptsQueryVariables; @@ -3371,6 +3444,11 @@ export type Mutations = variables: GenerateLicenseKeyMutationVariables; response: GenerateLicenseKeyMutation; } + | { + name: 'grantDocUserRolesMutation'; + variables: GrantDocUserRolesMutationVariables; + response: GrantDocUserRolesMutation; + } | { name: 'leaveWorkspaceMutation'; variables: LeaveWorkspaceMutationVariables; @@ -3396,6 +3474,11 @@ export type Mutations = variables: ResumeSubscriptionMutationVariables; response: ResumeSubscriptionMutation; } + | { + name: 'revokeDocUserRolesMutation'; + variables: RevokeDocUserRolesMutationVariables; + response: RevokeDocUserRolesMutation; + } | { name: 'revokeMemberPermissionMutation'; variables: RevokeMemberPermissionMutationVariables; @@ -3451,6 +3534,11 @@ export type Mutations = variables: UpdateCopilotSessionMutationVariables; response: UpdateCopilotSessionMutation; } + | { + name: 'updateDocUserRoleMutation'; + variables: UpdateDocUserRoleMutationVariables; + response: UpdateDocUserRoleMutation; + } | { name: 'updatePromptMutation'; variables: UpdatePromptMutationVariables; diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index d8f737cb8c..dfde0c3889 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -5754,6 +5754,10 @@ export function useAFFiNEI18N(): { * `Share doc` */ ["com.affine.share-menu.SharePage"](): string; + /** + * `General access` + */ + ["com.affine.share-menu.generalAccess"](): string; /** * `Share via export` */ @@ -5859,9 +5863,17 @@ export function useAFFiNEI18N(): { */ ["com.affine.share-menu.option.link.readonly.description"](): string; /** - * `Can Edit` + * `Can manage` + */ + ["com.affine.share-menu.option.permission.can-manage"](): string; + /** + * `Can edit` */ ["com.affine.share-menu.option.permission.can-edit"](): string; + /** + * `Can read` + */ + ["com.affine.share-menu.option.permission.can-read"](): string; /** * `Members in workspace` */ @@ -5882,6 +5894,71 @@ export function useAFFiNEI18N(): { * `Shared` */ ["com.affine.share-menu.sharedButton"](): string; + /** + * `{{member1}} and {{member2}} are in this doc` + */ + ["com.affine.share-menu.member-management.member-count-2"](options: Readonly<{ + member1: string; + member2: string; + }>): string; + /** + * `{{member1}}, {{member2}} and {{member3}} are in this doc` + */ + ["com.affine.share-menu.member-management.member-count-3"](options: Readonly<{ + member1: string; + member2: string; + member3: string; + }>): string; + /** + * `{{member1}}, {{member2}} and {{memberCount}} others` + */ + ["com.affine.share-menu.member-management.member-count-more"](options: Readonly<{ + member1: string; + member2: string; + memberCount: string; + }>): string; + /** + * `Remove` + */ + ["com.affine.share-menu.member-management.remove"](): string; + /** + * `Set as owner` + */ + ["com.affine.share-menu.member-management.set-as-owner"](): string; + /** + * `{{memberCount}} collaborators in the doc` + */ + ["com.affine.share-menu.member-management.header"](options: { + readonly memberCount: string; + }): string; + /** + * `Add collaborators` + */ + ["com.affine.share-menu.member-management.add-collaborators"](): string; + /** + * `Send invite` + */ + ["com.affine.share-menu.invite-editor.header"](): string; + /** + * `Manage members` + */ + ["com.affine.share-menu.invite-editor.manage-members"](): string; + /** + * `Invite` + */ + ["com.affine.share-menu.invite-editor.invite"](): string; + /** + * `No results found` + */ + ["com.affine.share-menu.invite-editor.no-found"](): string; + /** + * `Invite other members` + */ + ["com.affine.share-menu.invite-editor.placeholder"](): string; + /** + * `Notify via Email` + */ + ["com.affine.share-menu.invite-editor.sent-email"](): string; /** * `Built with` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index f33ad7d206..2dfa669a79 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1439,6 +1439,7 @@ "com.affine.share-menu.EnableCloudDescription": "Sharing doc requires AFFiNE Cloud.", "com.affine.share-menu.ShareMode": "Share mode", "com.affine.share-menu.SharePage": "Share doc", + "com.affine.share-menu.generalAccess": "General access", "com.affine.share-menu.ShareViaExport": "Share via export", "com.affine.share-menu.ShareViaExportDescription": "Download a static copy of your doc to share with others", "com.affine.share-menu.ShareViaPrintDescription": "Print a paper copy", @@ -1461,16 +1462,37 @@ "com.affine.share-menu.disable-publish-link.notification.success.title": "Public link disabled", "com.affine.share-menu.navigate.workspace": "Manage workspace members", "com.affine.share-menu.option.link.label": "Anyone with the link", - "com.affine.share-menu.option.link.no-access": "No Access", + "com.affine.share-menu.option.link.no-access": "No access", "com.affine.share-menu.option.link.no-access.description": "Only workspace members can access this link", - "com.affine.share-menu.option.link.readonly": "Read Only", + "com.affine.share-menu.option.link.readonly": "Read only", "com.affine.share-menu.option.link.readonly.description": "Anyone can access this link", - "com.affine.share-menu.option.permission.can-edit": "Can Edit", + "com.affine.share-menu.option.permission.can-manage": "Can manage", + "com.affine.share-menu.option.permission.can-edit": "Can edit", + "com.affine.share-menu.option.permission.can-read": "Can read", "com.affine.share-menu.option.permission.label": "Members in workspace", "com.affine.share-menu.publish-to-web": "Publish to web", "com.affine.share-menu.share-privately": "Share privately", "com.affine.share-menu.shareButton": "Share", "com.affine.share-menu.sharedButton": "Shared", + "com.affine.share-menu.member-management.member-count-2": "{{member1}} and {{member2}} are in this doc", + "com.affine.share-menu.member-management.member-count-3": "{{member1}}, {{member2}} and {{member3}} are in this doc", + "com.affine.share-menu.member-management.member-count-more": "{{member1}}, {{member2}} and {{memberCount}} others", + "com.affine.share-menu.member-management.remove": "Remove", + "com.affine.share-menu.member-management.set-as-owner": "Set as owner", + "com.affine.share-menu.member-management.header": "{{memberCount}} collaborators in the doc", + "com.affine.share-menu.member-management.add-collaborators": "Add collaborators", + "com.affine.share-menu.invite-editor.header": "Send invite", + "com.affine.share-menu.invite-editor.manage-members": "Manage members", + "com.affine.share-menu.invite-editor.invite": "Invite", + "com.affine.share-menu.invite-editor.no-found": "No results found", + "com.affine.share-menu.invite-editor.placeholder": "Invite other members", + "com.affine.share-menu.invite-editor.sent-email": "Notify via Email", + "com.affine.share-menu.paywall.owner.title": "Permission not available in Free plan", + "com.affine.share-menu.paywall.owner.description": "Upgrade to Pro or higher to unlock permission settings for this doc.", + "com.affine.share-menu.paywall.owner.confirm": "Upgrade", + "com.affine.share-menu.paywall.member.title": "Permission requires a workspace upgrade", + "com.affine.share-menu.paywall.member.description": "Ask your workspace owner to upgrade to Pro or higher to enable permissions.", + "com.affine.share-menu.paywall.member.confirm": "Got it", "com.affine.share-page.footer.built-with": "Built with", "com.affine.share-page.footer.create-with": "Create with", "com.affine.share-page.footer.description": "Empower your sharing with AFFiNE Cloud: One-click doc sharing",