mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 11:36:25 +08:00
feat(core): add doc grant feature to share menu (#9672)
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
export type { Member } from './entities/members';
|
||||
export {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from './services/doc-granted-users';
|
||||
export { MemberSearchService } from './services/member-search';
|
||||
export { WorkspaceMembersService } from './services/members';
|
||||
export { WorkspacePermissionService } from './services/permission';
|
||||
|
||||
import { type Framework } from '@toeverything/infra';
|
||||
|
||||
import { WorkspaceServerService } from '../cloud';
|
||||
import { DocScope, DocService } from '../doc';
|
||||
import {
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
@@ -12,8 +18,12 @@ import {
|
||||
} from '../workspace';
|
||||
import { WorkspaceMembers } from './entities/members';
|
||||
import { WorkspacePermission } from './entities/permission';
|
||||
import { DocGrantedUsersService } from './services/doc-granted-users';
|
||||
import { MemberSearchService } from './services/member-search';
|
||||
import { WorkspaceMembersService } from './services/members';
|
||||
import { WorkspacePermissionService } from './services/permission';
|
||||
import { DocGrantedUsersStore } from './stores/doc-granted-users';
|
||||
import { MemberSearchStore } from './stores/member-search';
|
||||
import { WorkspaceMembersStore } from './stores/members';
|
||||
import { WorkspacePermissionStore } from './stores/permission';
|
||||
|
||||
@@ -29,5 +39,17 @@ export function configurePermissionsModule(framework: Framework) {
|
||||
.entity(WorkspacePermission, [WorkspaceService, WorkspacePermissionStore])
|
||||
.service(WorkspaceMembersService, [WorkspaceMembersStore, WorkspaceService])
|
||||
.store(WorkspaceMembersStore, [WorkspaceServerService])
|
||||
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService]);
|
||||
.entity(WorkspaceMembers, [WorkspaceMembersStore, WorkspaceService])
|
||||
.service(MemberSearchService, [MemberSearchStore, WorkspaceService])
|
||||
.store(MemberSearchStore, [WorkspaceServerService]);
|
||||
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.scope(DocScope)
|
||||
.service(DocGrantedUsersService, [
|
||||
DocGrantedUsersStore,
|
||||
WorkspaceService,
|
||||
DocService,
|
||||
])
|
||||
.store(DocGrantedUsersStore, [WorkspaceServerService]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { DocRole, GetPageGrantedUsersListQuery } from '@affine/graphql';
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { DocService } from '../../doc';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { DocGrantedUsersStore } from '../stores/doc-granted-users';
|
||||
|
||||
export type GrantedUser =
|
||||
GetPageGrantedUsersListQuery['workspace']['doc']['grantedUsersList']['edges'][number]['node'];
|
||||
|
||||
export class DocGrantedUsersService extends Service {
|
||||
constructor(
|
||||
private readonly store: DocGrantedUsersStore,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
private readonly docService: DocService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly PAGE_SIZE = 8;
|
||||
|
||||
nextCursor$ = new LiveData<string | undefined>(undefined);
|
||||
hasMore$ = new LiveData(true);
|
||||
grantedUserCount$ = new LiveData(0);
|
||||
grantedUsers$ = new LiveData<GrantedUser[]>([]);
|
||||
isLoading$ = new LiveData(false);
|
||||
error$ = new LiveData<any>(null);
|
||||
|
||||
readonly loadMore = effect(
|
||||
exhaustMap(() => {
|
||||
if (!this.hasMore$.value) {
|
||||
return EMPTY;
|
||||
}
|
||||
return fromPromise(async signal => {
|
||||
return await this.store.fetchDocGrantedUsersList(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
{
|
||||
first: this.PAGE_SIZE,
|
||||
after: this.nextCursor$.value,
|
||||
},
|
||||
signal
|
||||
);
|
||||
}).pipe(
|
||||
mergeMap(({ edges, pageInfo, totalCount }) => {
|
||||
this.grantedUsers$.next([
|
||||
...this.grantedUsers$.value,
|
||||
...edges.map(edge => edge.node),
|
||||
]);
|
||||
|
||||
this.grantedUserCount$.next(totalCount);
|
||||
this.hasMore$.next(pageInfo.hasNextPage);
|
||||
this.nextCursor$.next(pageInfo.endCursor ?? undefined);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => {
|
||||
this.isLoading$.setValue(true);
|
||||
}),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.grantedUsers$.setValue([]);
|
||||
this.grantedUserCount$.setValue(0);
|
||||
this.hasMore$.setValue(true);
|
||||
this.nextCursor$.setValue(undefined);
|
||||
this.isLoading$.setValue(false);
|
||||
this.error$.setValue(null);
|
||||
this.loadMore.reset();
|
||||
}
|
||||
|
||||
async grantUsersRole(userIds: string[], role: DocRole) {
|
||||
await this.store.grantDocUserRoles({
|
||||
docId: this.docService.doc.id,
|
||||
workspaceId: this.workspaceService.workspace.id,
|
||||
userIds,
|
||||
role,
|
||||
});
|
||||
this.grantedUsers$.next(
|
||||
this.grantedUsers$.value.map(user => {
|
||||
if (userIds.includes(user.user.id)) {
|
||||
return { ...user, role };
|
||||
}
|
||||
return user;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async revokeUsersRole(userId: string) {
|
||||
await this.store.revokeDocUserRoles(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
userId
|
||||
);
|
||||
this.grantedUsers$.next(
|
||||
this.grantedUsers$.value.filter(user => user.user.id !== userId)
|
||||
);
|
||||
}
|
||||
|
||||
async updateUserRole(userId: string, role: DocRole) {
|
||||
await this.store.updateDocUserRole(
|
||||
this.workspaceService.workspace.id,
|
||||
this.docService.doc.id,
|
||||
userId,
|
||||
role
|
||||
);
|
||||
this.grantedUsers$.next(
|
||||
this.grantedUsers$.value.map(user => {
|
||||
if (user.user.id === userId) {
|
||||
return { ...user, role };
|
||||
}
|
||||
return user;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this.loadMore.unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import {
|
||||
backoffRetry,
|
||||
catchErrorInto,
|
||||
effect,
|
||||
fromPromise,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
Service,
|
||||
} from '@toeverything/infra';
|
||||
import { EMPTY, exhaustMap, mergeMap } from 'rxjs';
|
||||
|
||||
import { isBackendError, isNetworkError } from '../../cloud';
|
||||
import type { WorkspaceService } from '../../workspace';
|
||||
import type { Member } from '../entities/members';
|
||||
import type { MemberSearchStore } from '../stores/member-search';
|
||||
|
||||
export class MemberSearchService extends Service {
|
||||
constructor(
|
||||
private readonly store: MemberSearchStore,
|
||||
private readonly workspaceService: WorkspaceService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
readonly PAGE_SIZE = 8;
|
||||
readonly searchText$ = new LiveData<string>('');
|
||||
readonly isLoading$ = new LiveData(false);
|
||||
readonly error$ = new LiveData<any>(null);
|
||||
readonly result$ = new LiveData<Member[]>([]);
|
||||
readonly hasMore$ = new LiveData(true);
|
||||
|
||||
readonly loadMore = effect(
|
||||
exhaustMap(() => {
|
||||
if (!this.hasMore$.value) {
|
||||
return EMPTY;
|
||||
}
|
||||
return fromPromise(async signal => {
|
||||
return this.store.getMembersByEmailOrName(
|
||||
this.workspaceService.workspace.id,
|
||||
this.searchText$.value || undefined,
|
||||
this.result$.value.length,
|
||||
this.PAGE_SIZE,
|
||||
signal
|
||||
);
|
||||
}).pipe(
|
||||
mergeMap(data => {
|
||||
this.result$.setValue([...this.result$.value, ...data.members]);
|
||||
this.hasMore$.setValue(data.members.length === this.PAGE_SIZE);
|
||||
|
||||
return EMPTY;
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isNetworkError,
|
||||
count: Infinity,
|
||||
}),
|
||||
backoffRetry({
|
||||
when: isBackendError,
|
||||
}),
|
||||
catchErrorInto(this.error$),
|
||||
onStart(() => {
|
||||
this.isLoading$.setValue(true);
|
||||
}),
|
||||
onComplete(() => this.isLoading$.setValue(false))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
reset() {
|
||||
this.result$.setValue([]);
|
||||
this.hasMore$.setValue(true);
|
||||
this.searchText$.setValue('');
|
||||
this.error$.setValue(null);
|
||||
this.loadMore.reset();
|
||||
}
|
||||
|
||||
search(searchText?: string) {
|
||||
if (this.searchText$.value === searchText) {
|
||||
return;
|
||||
}
|
||||
this.reset();
|
||||
this.searchText$.setValue(searchText ?? '');
|
||||
this.loadMore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { WorkspaceServerService } from '@affine/core/modules/cloud';
|
||||
import {
|
||||
type DocRole,
|
||||
getPageGrantedUsersListQuery,
|
||||
type GrantDocUserRolesInput,
|
||||
grantDocUserRolesMutation,
|
||||
type PaginationInput,
|
||||
revokeDocUserRolesMutation,
|
||||
updateDocUserRoleMutation,
|
||||
} from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
export class DocGrantedUsersStore extends Store {
|
||||
constructor(private readonly workspaceServerService: WorkspaceServerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async fetchDocGrantedUsersList(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
pagination: PaginationInput,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: getPageGrantedUsersListQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
docId,
|
||||
pagination,
|
||||
},
|
||||
context: { signal },
|
||||
});
|
||||
|
||||
return res.workspace.doc.grantedUsersList;
|
||||
}
|
||||
|
||||
async grantDocUserRoles(input: GrantDocUserRolesInput) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: grantDocUserRolesMutation,
|
||||
variables: {
|
||||
input,
|
||||
},
|
||||
});
|
||||
|
||||
return res.grantDocUserRoles;
|
||||
}
|
||||
|
||||
async revokeDocUserRoles(workspaceId: string, docId: string, userId: string) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: revokeDocUserRolesMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId,
|
||||
docId,
|
||||
userId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.revokeDocUserRoles;
|
||||
}
|
||||
|
||||
async updateDocUserRole(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
userId: string,
|
||||
role: DocRole
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const res = await this.workspaceServerService.server.gql({
|
||||
query: updateDocUserRoleMutation,
|
||||
variables: {
|
||||
input: {
|
||||
workspaceId,
|
||||
docId,
|
||||
userId,
|
||||
role,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return res.updateDocUserRole;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getMembersByWorkspaceIdQuery } from '@affine/graphql';
|
||||
import { Store } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspaceServerService } from '../../cloud';
|
||||
|
||||
export class MemberSearchStore extends Store {
|
||||
constructor(private readonly workspaceServerService: WorkspaceServerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
async getMembersByEmailOrName(
|
||||
workspaceId: string,
|
||||
query?: string,
|
||||
skip?: number,
|
||||
take?: number,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
if (!this.workspaceServerService.server) {
|
||||
throw new Error('No Server');
|
||||
}
|
||||
const data = await this.workspaceServerService.server.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId,
|
||||
skip,
|
||||
take,
|
||||
query,
|
||||
},
|
||||
context: {
|
||||
signal,
|
||||
},
|
||||
});
|
||||
|
||||
return data.workspace;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CloudSvg, ShareMenuContent, SharePageButton } from './view';
|
||||
@@ -0,0 +1,20 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
|
||||
export const CloudSvg = () => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="146"
|
||||
height="84"
|
||||
viewBox="0 0 146 84"
|
||||
fill="none"
|
||||
>
|
||||
<g opacity="0.1">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M66.9181 15.9788C52.6393 15.9788 41.064 27.5541 41.064 41.8329C41.064 43.7879 41.2801 45.687 41.6881 47.5094C42.2383 49.9676 40.6923 52.4066 38.2344 52.9579C29.4068 54.938 22.814 62.8293 22.814 72.2496C22.814 83.1687 31.6657 92.0204 42.5848 92.0204H97.3348C111.614 92.0204 123.189 80.4451 123.189 66.1663C123.189 51.8874 111.614 40.3121 97.3348 40.3121C97.1618 40.3121 96.9892 40.3138 96.8169 40.3172C94.6134 40.3603 92.6941 38.8222 92.2561 36.6623C89.8629 24.8606 79.4226 15.9788 66.9181 15.9788ZM31.939 41.8329C31.939 22.5145 47.5997 6.85376 66.9181 6.85376C82.573 6.85376 95.8181 17.1339 100.285 31.3098C118.223 32.808 132.314 47.8415 132.314 66.1663C132.314 85.4847 116.653 101.145 97.3348 101.145H42.5848C26.6261 101.145 13.689 88.2083 13.689 72.2496C13.689 59.9818 21.3304 49.5073 32.1102 45.3122C31.9969 44.1668 31.939 43.0061 31.939 41.8329Z"
|
||||
fill={cssVar('iconColor')}
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEnableCloud } from '@affine/core/components/hooks/affine/use-enable-cloud';
|
||||
import type { Workspace } from '@affine/core/modules/workspace';
|
||||
import { track } from '@affine/track';
|
||||
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;
|
||||
page: Store;
|
||||
};
|
||||
|
||||
export const SharePageButton = ({ workspace, page }: SharePageModalProps) => {
|
||||
const confirmEnableCloud = useEnableCloud();
|
||||
const handleOpenShareModal = useCallback((open: boolean) => {
|
||||
if (open) {
|
||||
track.$.sharePanel.$.open();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ShareMenu
|
||||
workspaceMetadata={workspace.meta}
|
||||
currentPage={page}
|
||||
onEnableAffineCloud={() =>
|
||||
confirmEnableCloud(workspace, {
|
||||
openPageId: page.id,
|
||||
})
|
||||
}
|
||||
onOpenShareModal={handleOpenShareModal}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+111
@@ -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',
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Button, Menu, MenuItem, MenuTrigger } from '@affine/component';
|
||||
import {
|
||||
getSelectedNodes,
|
||||
useSharingUrl,
|
||||
} from '@affine/core/components/hooks/affine/use-share-url';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { DocMode } from '@blocksuite/affine/blocks';
|
||||
import { BlockIcon, EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import * as styles from './copy-link-button.css';
|
||||
|
||||
export const CopyLinkButton = ({
|
||||
workspaceId,
|
||||
secondary,
|
||||
}: {
|
||||
secondary?: boolean;
|
||||
workspaceId: string;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
|
||||
const editor = useService(EditorService).editor;
|
||||
const currentMode = useLiveData(editor.mode$);
|
||||
const editorContainer = useLiveData(editor.editorContainer$);
|
||||
|
||||
const { blockIds, elementIds } = useMemo(
|
||||
() => getSelectedNodes(editorContainer?.host || null, currentMode),
|
||||
[editorContainer, currentMode]
|
||||
);
|
||||
const { onClickCopyLink } = useSharingUrl({
|
||||
workspaceId,
|
||||
pageId: editor.doc.id,
|
||||
});
|
||||
|
||||
const onCopyPageLink = useCallback(() => {
|
||||
onClickCopyLink('page' as DocMode);
|
||||
}, [onClickCopyLink]);
|
||||
const onCopyEdgelessLink = useCallback(() => {
|
||||
onClickCopyLink('edgeless' as DocMode);
|
||||
}, [onClickCopyLink]);
|
||||
const onCopyBlockLink = useCallback(() => {
|
||||
onClickCopyLink(currentMode, blockIds, elementIds);
|
||||
}, [onClickCopyLink, currentMode, blockIds, elementIds]);
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.copyLinkContainerStyle, { secondary: secondary })}
|
||||
>
|
||||
<Button
|
||||
className={styles.copyLinkButtonStyle}
|
||||
onClick={onCopyBlockLink}
|
||||
withoutHover
|
||||
variant={secondary ? 'secondary' : 'primary'}
|
||||
>
|
||||
<span
|
||||
className={clsx(styles.copyLinkLabelStyle, {
|
||||
secondary: secondary,
|
||||
})}
|
||||
>
|
||||
{t['com.affine.share-menu.copy']()}
|
||||
</span>
|
||||
{BUILD_CONFIG.isDesktopEdition && (
|
||||
<span
|
||||
className={clsx(styles.copyLinkShortcutStyle, {
|
||||
secondary: secondary,
|
||||
})}
|
||||
>
|
||||
{environment.isMacOs ? '⌘ + ⌥ + C' : 'Ctrl + Shift + C'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<PageIcon />}
|
||||
onSelect={onCopyPageLink}
|
||||
data-testid="share-link-menu-copy-page"
|
||||
>
|
||||
{t['com.affine.share-menu.copy.page']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<EdgelessIcon />}
|
||||
onSelect={onCopyEdgelessLink}
|
||||
data-testid="share-link-menu-copy-edgeless"
|
||||
>
|
||||
{t['com.affine.share-menu.copy.edgeless']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<BlockIcon />}
|
||||
onSelect={onCopyBlockLink}
|
||||
disabled={blockIds.length + elementIds.length === 0}
|
||||
>
|
||||
{t['com.affine.share-menu.copy.block']()}
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
variant={secondary ? 'secondary' : 'primary'}
|
||||
className={clsx(styles.copyLinkTriggerStyle, {
|
||||
secondary: secondary,
|
||||
})}
|
||||
data-testid="share-menu-copy-link-button"
|
||||
suffixStyle={{ width: 20, height: 20 }}
|
||||
withoutHover
|
||||
/>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './members-permission';
|
||||
export * from './public-page-button';
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { Menu, MenuItem, MenuTrigger } from '@affine/component';
|
||||
import { DocRole } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { PlanTag } from '../plan-tag';
|
||||
import * as styles from './styles.css';
|
||||
|
||||
const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
|
||||
switch (role) {
|
||||
case DocRole.Manager:
|
||||
return t['com.affine.share-menu.option.permission.can-manage']();
|
||||
case DocRole.Editor:
|
||||
return t['com.affine.share-menu.option.permission.can-edit']();
|
||||
case DocRole.Reader:
|
||||
return t['com.affine.share-menu.option.permission.can-read']();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
// TODO(@JimmFly): impl the real permission
|
||||
export const MembersPermission = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal?: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [docRole, setDocRole] = useState<DocRole>(DocRole.Manager);
|
||||
const currentRoleName = useMemo(() => getRoleName(docRole, t), [docRole, t]);
|
||||
|
||||
const changePermission = useCallback((newPermission: DocRole) => {
|
||||
setDocRole(newPermission);
|
||||
}, []);
|
||||
|
||||
const selectManage = useCallback(() => {
|
||||
changePermission(DocRole.Manager);
|
||||
}, [changePermission]);
|
||||
|
||||
const selectEdit = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal?.();
|
||||
return;
|
||||
}
|
||||
changePermission(DocRole.Editor);
|
||||
}, [changePermission, hittingPaywall, openPaywallModal]);
|
||||
|
||||
const selectRead = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal?.();
|
||||
return;
|
||||
}
|
||||
changePermission(DocRole.Reader);
|
||||
}, [changePermission, hittingPaywall, openPaywallModal]);
|
||||
return (
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<div className={styles.labelStyle}>
|
||||
{t['com.affine.share-menu.option.permission.label']()}
|
||||
</div>
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
onSelect={selectManage}
|
||||
selected={docRole === DocRole.Manager}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-manage']()}
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectEdit}
|
||||
selected={docRole === DocRole.Editor}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-edit']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={selectRead}
|
||||
selected={docRole === DocRole.Reader}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div className={styles.tagContainerStyle}>
|
||||
{t['com.affine.share-menu.option.permission.can-read']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTriggerStyle}
|
||||
variant="plain"
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentRoleName}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
import { Menu, MenuItem, MenuTrigger, notify } from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import { PublicDocMode } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import track from '@affine/track';
|
||||
import {
|
||||
LockIcon,
|
||||
SingleSelectCheckSolidIcon,
|
||||
ViewIcon,
|
||||
} from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const PublicDoc = () => {
|
||||
const t = useI18n();
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
|
||||
const onDisablePublic = useAsyncCallback(async () => {
|
||||
try {
|
||||
await shareInfoService.shareInfo.disableShare();
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.success.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.success.message'
|
||||
](),
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.fail.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.disable-publish-link.notification.fail.message'
|
||||
](),
|
||||
});
|
||||
console.log(err);
|
||||
}
|
||||
}, [shareInfoService, t]);
|
||||
|
||||
const onClickAnyoneReadOnlyShare = useAsyncCallback(async () => {
|
||||
if (isSharedPage) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// TODO(@JimmFly): remove mode when we have a better way to handle it
|
||||
await shareInfoService.shareInfo.enableShare(PublicDocMode.Page);
|
||||
track.$.sharePanel.$.createShareLink();
|
||||
notify.success({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.create-public-link.notification.success.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.create-public-link.notification.success.message'
|
||||
](),
|
||||
style: 'normal',
|
||||
icon: <SingleSelectCheckSolidIcon color={cssVar('primaryColor')} />,
|
||||
});
|
||||
} catch (err) {
|
||||
notify.error({
|
||||
title:
|
||||
t[
|
||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.title'
|
||||
](),
|
||||
message:
|
||||
t[
|
||||
'com.affine.share-menu.confirm-modify-mode.notification.fail.message'
|
||||
](),
|
||||
});
|
||||
console.error(err);
|
||||
}
|
||||
}, [isSharedPage, shareInfoService.shareInfo, t]);
|
||||
|
||||
return (
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<div className={styles.labelStyle}>
|
||||
{t['com.affine.share-menu.option.link.label']()}
|
||||
</div>
|
||||
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
prefixIcon={<LockIcon />}
|
||||
onSelect={onDisablePublic}
|
||||
selected={!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>{t['com.affine.share-menu.option.link.no-access']()}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
prefixIcon={<ViewIcon />}
|
||||
onSelect={onClickAnyoneReadOnlyShare}
|
||||
data-testid="share-link-menu-enable-share"
|
||||
selected={!!isSharedPage}
|
||||
>
|
||||
<div className={styles.publicItemRowStyle}>
|
||||
<div>{t['com.affine.share-menu.option.link.readonly']()}</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTriggerStyle}
|
||||
data-testid="share-link-menu-trigger"
|
||||
variant="plain"
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{isSharedPage
|
||||
? t['com.affine.share-menu.option.link.readonly']()
|
||||
: t['com.affine.share-menu.option.link.no-access']()}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuTriggerStyle = style({
|
||||
padding: '4px 10px',
|
||||
borderRadius: '4px',
|
||||
justifyContent: 'space-between',
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const rowContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
});
|
||||
export const exportContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const labelStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const publicItemRowStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const tagContainerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
export const headerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '22px',
|
||||
padding: '0 4px',
|
||||
gap: '4px',
|
||||
});
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const menuStyle = style({
|
||||
width: '390px',
|
||||
minHeight: '310px',
|
||||
maxHeight: '562px',
|
||||
padding: '12px',
|
||||
});
|
||||
export const localMenuStyle = style({
|
||||
width: '390px',
|
||||
padding: '12px',
|
||||
});
|
||||
export const menuTriggerStyle = style({
|
||||
width: '150px',
|
||||
padding: '4px 10px',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
export const exportItemStyle = style({
|
||||
padding: '4px',
|
||||
transition: 'all 0.3s',
|
||||
gap: '0px',
|
||||
});
|
||||
globalStyle(`${exportItemStyle} > div:first-child`, {
|
||||
alignItems: 'center',
|
||||
});
|
||||
globalStyle(`${exportItemStyle} svg`, {
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
});
|
||||
|
||||
export const descriptionStyle = style({
|
||||
wordWrap: 'break-word',
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVarV2('text/secondary'),
|
||||
textAlign: 'left',
|
||||
padding: '0 6px',
|
||||
});
|
||||
export const containerStyle = style({
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const indicatorContainerStyle = style({
|
||||
position: 'relative',
|
||||
});
|
||||
export const columnContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
gap: '8px',
|
||||
});
|
||||
export const exportContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
});
|
||||
export const labelStyle = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const disableSharePage = style({
|
||||
color: cssVarV2('button/error'),
|
||||
});
|
||||
export const localSharePage = style({
|
||||
padding: '12px 8px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '8px',
|
||||
backgroundColor: cssVarV2('layer/background/secondary'),
|
||||
minHeight: '84px',
|
||||
position: 'relative',
|
||||
});
|
||||
export const cloudSvgContainer = style({
|
||||
width: '146px',
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
bottom: '0',
|
||||
right: '0',
|
||||
});
|
||||
export const shareLinkStyle = style({
|
||||
padding: '4px',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
transform: 'translateX(-4px)',
|
||||
gap: '4px',
|
||||
});
|
||||
globalStyle(`${shareLinkStyle} > span`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
globalStyle(`${shareLinkStyle} > div > svg`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
export const buttonContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontWeight: 500,
|
||||
});
|
||||
export const button = style({
|
||||
padding: '6px 8px',
|
||||
height: 32,
|
||||
});
|
||||
export const shortcutStyle = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontWeight: 400,
|
||||
});
|
||||
|
||||
export const generalAccessStyle = style({
|
||||
padding: '4px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontWeight: 500,
|
||||
});
|
||||
@@ -0,0 +1,3 @@
|
||||
import { atom } from 'jotai/vanilla';
|
||||
|
||||
export const enableShareMenuAtom = atom(false);
|
||||
@@ -0,0 +1 @@
|
||||
export * from './share-menu';
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { Input } from '@affine/component';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const InviteInput = ({ onFocus }: { onFocus: () => void }) => {
|
||||
const t = useI18n();
|
||||
|
||||
return (
|
||||
<Input
|
||||
preFix={<SearchIcon fontSize={20} />}
|
||||
className={styles.inputStyle}
|
||||
onFocus={onFocus}
|
||||
inputStyle={{
|
||||
paddingLeft: '0',
|
||||
}}
|
||||
placeholder={t['com.affine.share-menu.invite-editor.placeholder']()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+148
@@ -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',
|
||||
});
|
||||
+398
@@ -0,0 +1,398 @@
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Loading,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
notify,
|
||||
RowInput,
|
||||
} from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type Member,
|
||||
MemberSearchService,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import {
|
||||
DocRole,
|
||||
UserFriendlyError,
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type CompositionEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { PlanTag } from '../plan-tag';
|
||||
import { Scroller } from '../scroller';
|
||||
import * as styles from './invite-member-editor.css';
|
||||
import { MemberItem } from './member-item';
|
||||
import { SelectedMemberItem } from './selected-member-item';
|
||||
|
||||
const getRoleName = (role: DocRole, t: ReturnType<typeof useI18n>) => {
|
||||
switch (role) {
|
||||
case DocRole.Manager:
|
||||
return t['com.affine.share-menu.option.permission.can-manage']();
|
||||
case DocRole.Editor:
|
||||
return t['com.affine.share-menu.option.permission.can-edit']();
|
||||
case DocRole.Reader:
|
||||
return t['com.affine.share-menu.option.permission.can-read']();
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
export const InviteMemberEditor = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
onClickCancel,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
onClickCancel: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const [selectedMembers, setSelectedMembers] = useState<Member[]>([]);
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
const [inviteDocRoleType, setInviteDocRoleType] = useState<DocRole>(
|
||||
DocRole.Manager
|
||||
);
|
||||
|
||||
const memberSearchService = useService(MemberSearchService);
|
||||
const searchText = useLiveData(memberSearchService.searchText$);
|
||||
|
||||
useEffect(() => {
|
||||
// reset the search text when the component is mounted
|
||||
memberSearchService.reset();
|
||||
memberSearchService.loadMore();
|
||||
}, [memberSearchService]);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [composing, setComposing] = useState(false);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(value: string) => {
|
||||
if (!composing) {
|
||||
memberSearchService.search(value);
|
||||
}
|
||||
},
|
||||
[composing, memberSearchService]
|
||||
);
|
||||
|
||||
const [shouldSendEmail, setShouldSendEmail] = useState(false);
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
|
||||
const onInvite = useAsyncCallback(async () => {
|
||||
const selectedMemberIds = selectedMembers.map(member => member.id);
|
||||
try {
|
||||
await docGrantedUsersService.grantUsersRole(
|
||||
selectedMemberIds,
|
||||
inviteDocRoleType
|
||||
);
|
||||
|
||||
notify.success({
|
||||
title: 'Invite successful',
|
||||
});
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, inviteDocRoleType, selectedMembers, t]);
|
||||
|
||||
const handleCompositionStart: CompositionEventHandler<HTMLInputElement> =
|
||||
useCallback(() => {
|
||||
setComposing(true);
|
||||
}, []);
|
||||
|
||||
const handleCompositionEnd: CompositionEventHandler<HTMLInputElement> =
|
||||
useCallback(
|
||||
e => {
|
||||
setComposing(false);
|
||||
memberSearchService.search(e.currentTarget.value);
|
||||
},
|
||||
[memberSearchService]
|
||||
);
|
||||
|
||||
const onCheckboxChange = useCallback(() => {
|
||||
setShouldSendEmail(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const focusInput = useCallback(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
const onFocus = useCallback(() => {
|
||||
setFocused(true);
|
||||
}, []);
|
||||
const onBlur = useCallback(() => {
|
||||
setFocused(false);
|
||||
}, []);
|
||||
|
||||
const handleRemoved = useCallback(
|
||||
(memberId: string) => {
|
||||
setSelectedMembers(prev => prev.filter(member => member.id !== memberId));
|
||||
focusInput();
|
||||
},
|
||||
[focusInput]
|
||||
);
|
||||
|
||||
const switchToMemberManagementTab = useCallback(() => {
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: 'workspace:preference',
|
||||
});
|
||||
}, [workspaceDialogService]);
|
||||
|
||||
const handleClickMember = useCallback((member: Member) => {
|
||||
setSelectedMembers(prev => {
|
||||
if (prev.some(m => m.id === member.id)) {
|
||||
// if the member is already in the list, just return
|
||||
return prev;
|
||||
}
|
||||
return [...prev, member];
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleRoleChange = useCallback((role: DocRole) => {
|
||||
setInviteDocRoleType(role);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.containerStyle}>
|
||||
<div className={styles.headerStyle} onClick={onClickCancel}>
|
||||
<ArrowLeftBigIcon className={styles.iconStyle} />
|
||||
{t['com.affine.share-menu.invite-editor.header']()}
|
||||
</div>
|
||||
<div className={styles.memberListStyle}>
|
||||
<div
|
||||
className={clsx(styles.InputContainer, {
|
||||
focus: focused,
|
||||
})}
|
||||
>
|
||||
<div className={styles.inlineMembersContainer}>
|
||||
{selectedMembers.map((member, idx) => {
|
||||
if (!member) {
|
||||
return null;
|
||||
}
|
||||
const onRemoved = () => handleRemoved(member.id);
|
||||
return (
|
||||
<SelectedMemberItem
|
||||
key={member.id}
|
||||
idx={idx}
|
||||
onRemoved={onRemoved}
|
||||
member={member}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<RowInput
|
||||
ref={inputRef}
|
||||
value={searchText}
|
||||
onChange={handleValueChange}
|
||||
onCompositionStart={handleCompositionStart}
|
||||
onCompositionEnd={handleCompositionEnd}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
autoFocus
|
||||
className={styles.searchInput}
|
||||
placeholder={t[
|
||||
'com.affine.share-menu.invite-editor.placeholder'
|
||||
]()}
|
||||
/>
|
||||
</div>
|
||||
{!selectedMembers.length ? null : (
|
||||
<RoleSelector
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={hittingPaywall}
|
||||
inviteDocRoleType={inviteDocRoleType}
|
||||
onRoleChange={handleRoleChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sentEmail} onClick={onCheckboxChange}>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
checked={shouldSendEmail}
|
||||
disabled // not supported yet
|
||||
/>
|
||||
{t['com.affine.share-menu.invite-editor.sent-email']()}
|
||||
</div>
|
||||
<Result onClickMember={handleClickMember} />
|
||||
</div>
|
||||
<div className={styles.footerStyle}>
|
||||
<span
|
||||
className={styles.manageMemberStyle}
|
||||
onClick={switchToMemberManagementTab}
|
||||
>
|
||||
{t['com.affine.share-menu.invite-editor.manage-members']()}
|
||||
</span>
|
||||
<div className={styles.buttonsContainer}>
|
||||
<Button className={styles.button} onClick={onClickCancel}>
|
||||
{t['Cancel']()}
|
||||
</Button>
|
||||
<Button
|
||||
className={styles.button}
|
||||
variant="primary"
|
||||
disabled={!selectedMembers.length}
|
||||
onClick={onInvite}
|
||||
>
|
||||
{t['com.affine.share-menu.invite-editor.invite']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Result = ({
|
||||
onClickMember,
|
||||
}: {
|
||||
onClickMember: (member: Member) => void;
|
||||
}) => {
|
||||
const memberSearchService = useService(MemberSearchService);
|
||||
const result = useLiveData(memberSearchService.result$);
|
||||
const isLoading = useLiveData(memberSearchService.isLoading$);
|
||||
|
||||
const activeMembers = useMemo(() => {
|
||||
return result.filter(
|
||||
member => member.status === WorkspaceMemberStatus.Accepted
|
||||
);
|
||||
}, [result]);
|
||||
|
||||
const itemContentRenderer = useCallback(
|
||||
(_index: number, data: Member) => {
|
||||
const handleSelect = () => {
|
||||
onClickMember(data);
|
||||
};
|
||||
return (
|
||||
<div onClick={handleSelect}>
|
||||
<MemberItem member={data} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[onClickMember]
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
memberSearchService.loadMore();
|
||||
}, [memberSearchService]);
|
||||
|
||||
if (!activeMembers || activeMembers.length === 0) {
|
||||
if (isLoading) {
|
||||
return <Loading />;
|
||||
}
|
||||
return (
|
||||
<div className={styles.noFound}>
|
||||
{t['com.affine.share-menu.invite-editor.no-found']()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Virtuoso
|
||||
components={{
|
||||
Scroller,
|
||||
}}
|
||||
data={activeMembers}
|
||||
itemContent={itemContentRenderer}
|
||||
endReached={loadMore}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const RoleSelector = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
inviteDocRoleType,
|
||||
onRoleChange,
|
||||
}: {
|
||||
openPaywallModal: () => void;
|
||||
inviteDocRoleType: DocRole;
|
||||
onRoleChange: (role: DocRole) => void;
|
||||
hittingPaywall: boolean;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const currentRoleName = useMemo(
|
||||
() => getRoleName(inviteDocRoleType, t),
|
||||
[inviteDocRoleType, t]
|
||||
);
|
||||
|
||||
const changeToAdmin = useCallback(
|
||||
() => onRoleChange(DocRole.Manager),
|
||||
[onRoleChange]
|
||||
);
|
||||
const changeToWrite = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
onRoleChange(DocRole.Editor);
|
||||
}, [hittingPaywall, onRoleChange, openPaywallModal]);
|
||||
const changeToRead = useCallback(() => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
onRoleChange(DocRole.Reader);
|
||||
}, [hittingPaywall, onRoleChange, openPaywallModal]);
|
||||
return (
|
||||
<div className={styles.roleSelectorContainer}>
|
||||
<Menu
|
||||
contentOptions={{
|
||||
align: 'end',
|
||||
}}
|
||||
items={
|
||||
<>
|
||||
<MenuItem
|
||||
onSelect={changeToAdmin}
|
||||
selected={inviteDocRoleType === DocRole.Manager}
|
||||
>
|
||||
{t['com.affine.share-menu.option.permission.can-manage']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={changeToWrite}
|
||||
selected={inviteDocRoleType === DocRole.Editor}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{t['com.affine.share-menu.option.permission.can-edit']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
onSelect={changeToRead}
|
||||
selected={inviteDocRoleType === DocRole.Reader}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{t['com.affine.share-menu.option.permission.can-read']()}
|
||||
<PlanTag />
|
||||
</div>
|
||||
</MenuItem>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<MenuTrigger
|
||||
className={styles.menuTriggerStyle}
|
||||
variant="plain"
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{currentRoleName}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+68
@@ -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',
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
import { Avatar, Tooltip } from '@affine/component';
|
||||
import type { Member } from '@affine/core/modules/permissions';
|
||||
|
||||
import * as styles from './member-item.css';
|
||||
|
||||
export const MemberItem = ({ member }: { member: Member }) => {
|
||||
return (
|
||||
<div className={styles.memberItemStyle}>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<Avatar
|
||||
key={member.id}
|
||||
url={member.avatarUrl || ''}
|
||||
name={member.name || ''}
|
||||
size={36}
|
||||
/>
|
||||
<div className={styles.memberInfoStyle}>
|
||||
<Tooltip
|
||||
content={member.name}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberNameStyle}>{member.name}</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={member.email}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberEmailStyle}>{member.email}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+54
@@ -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)',
|
||||
},
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
import type { Member } from '@affine/core/modules/permissions';
|
||||
import { CloseIcon } from '@blocksuite/icons/rc';
|
||||
import { type MouseEventHandler, useCallback } from 'react';
|
||||
|
||||
import * as styles from './selected-member-item.css';
|
||||
|
||||
export interface TagItemProps {
|
||||
member: Member;
|
||||
idx?: number;
|
||||
onRemoved?: () => void;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SelectedMemberItem = ({
|
||||
member,
|
||||
idx,
|
||||
onRemoved,
|
||||
style,
|
||||
}: TagItemProps) => {
|
||||
const handleRemove: MouseEventHandler<HTMLDivElement> = useCallback(
|
||||
e => {
|
||||
e.stopPropagation();
|
||||
onRemoved?.();
|
||||
},
|
||||
[onRemoved]
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.member}
|
||||
data-idx={idx}
|
||||
style={{
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberInnerWrapper}>
|
||||
<div className={styles.label}>{member.name}</div>
|
||||
{onRemoved ? (
|
||||
<div className={styles.remove} onClick={handleRemove}>
|
||||
<CloseIcon />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const inputStyle = style({
|
||||
marginTop: '6px',
|
||||
padding: '4px',
|
||||
gap: '4px',
|
||||
});
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
import { Avatar, Skeleton, Tooltip } from '@affine/component';
|
||||
import { DocGrantedUsersService } from '@affine/core/modules/permissions';
|
||||
import { DocRole } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export { MemberManagement } from './member-management';
|
||||
|
||||
export const MembersRow = ({ onClick }: { onClick: () => void }) => {
|
||||
const t = useI18n();
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
|
||||
const grantedUserList = useLiveData(docGrantedUsersService.grantedUsers$);
|
||||
const grantedUserCount = useLiveData(
|
||||
docGrantedUsersService.grantedUserCount$
|
||||
);
|
||||
const loading = useLiveData(docGrantedUsersService.isLoading$);
|
||||
const docOwner = useLiveData(
|
||||
docGrantedUsersService.grantedUsers$.map(users =>
|
||||
users.find(user => user.role === DocRole.Owner)
|
||||
)
|
||||
);
|
||||
|
||||
const topThreeMembers = useMemo(
|
||||
() =>
|
||||
grantedUserList
|
||||
?.slice(0, Math.min(3, grantedUserList.length))
|
||||
.map(grantedUser => ({
|
||||
name: grantedUser.user.name,
|
||||
avatarUrl: grantedUser.user.avatarUrl,
|
||||
id: grantedUser.user.id,
|
||||
})),
|
||||
[grantedUserList]
|
||||
);
|
||||
|
||||
const description = useMemo(() => {
|
||||
if (!grantedUserCount || !topThreeMembers || topThreeMembers.length <= 1) {
|
||||
return '';
|
||||
}
|
||||
switch (grantedUserCount) {
|
||||
case 2:
|
||||
return t['com.affine.share-menu.member-management.member-count-2']({
|
||||
member1: topThreeMembers[0].name,
|
||||
member2: topThreeMembers[1].name,
|
||||
});
|
||||
case 3:
|
||||
return t['com.affine.share-menu.member-management.member-count-3']({
|
||||
member1: topThreeMembers[0].name,
|
||||
member2: topThreeMembers[1].name,
|
||||
member3: topThreeMembers[2].name,
|
||||
});
|
||||
default:
|
||||
return t['com.affine.share-menu.member-management.member-count-more']({
|
||||
member1: topThreeMembers[0].name,
|
||||
member2: topThreeMembers[1].name,
|
||||
memberCount: (grantedUserCount - 2).toString(),
|
||||
});
|
||||
}
|
||||
}, [grantedUserCount, t, topThreeMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
docGrantedUsersService.reset();
|
||||
docGrantedUsersService.loadMore();
|
||||
}, [docGrantedUsersService]);
|
||||
|
||||
if (
|
||||
grantedUserCount &&
|
||||
topThreeMembers &&
|
||||
topThreeMembers.length > 1 &&
|
||||
grantedUserCount > 1
|
||||
) {
|
||||
return (
|
||||
<Tooltip content={description}>
|
||||
<div
|
||||
className={clsx(styles.rowContainerStyle, 'clickable')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<div className={styles.avatarsContainerStyle}>
|
||||
{topThreeMembers.map((member, index) => (
|
||||
<Avatar
|
||||
key={member.id}
|
||||
url={member.avatarUrl || ''}
|
||||
name={member.name || ''}
|
||||
size={24}
|
||||
style={{
|
||||
marginLeft: index === 0 ? 0 : -8,
|
||||
border: `1px solid ${cssVarV2('layer/white')}`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className={styles.descriptionStyle}>{description}</span>
|
||||
</div>
|
||||
<div className={styles.IconButtonStyle}>
|
||||
<ArrowRightSmallIcon />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (!docOwner && loading) {
|
||||
// is loading
|
||||
return (
|
||||
<div className={styles.rowContainerStyle}>
|
||||
<Skeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO(@JimmFly): handle the case when there is only one member
|
||||
return (
|
||||
<div
|
||||
className={clsx(styles.rowContainerStyle, 'clickable')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<Avatar
|
||||
url={docOwner?.user.avatarUrl || ''}
|
||||
name={docOwner?.user.name}
|
||||
size={24}
|
||||
/>
|
||||
<span>{docOwner?.user.name}</span>
|
||||
</div>
|
||||
<div className={styles.OwnerStyle}>{t['Owner']()}</div>
|
||||
<div className={styles.IconButtonStyle}>
|
||||
<ArrowRightSmallIcon />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+80
@@ -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',
|
||||
});
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
Avatar,
|
||||
Menu,
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
MenuTrigger,
|
||||
notify,
|
||||
Tooltip,
|
||||
} from '@affine/component';
|
||||
import { useAsyncCallback } from '@affine/core/components/hooks/affine-async-hooks';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { DocRole, UserFriendlyError } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useService } from '@toeverything/infra';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { PlanTag } from '../plan-tag';
|
||||
import * as styles from './member-item.css';
|
||||
|
||||
export const MemberItem = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
grantedUser,
|
||||
}: {
|
||||
grantedUser: GrantedUser;
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
}) => {
|
||||
const user = grantedUser.user;
|
||||
|
||||
const role = useMemo(() => {
|
||||
switch (grantedUser.role) {
|
||||
case DocRole.Owner:
|
||||
return 'Owner';
|
||||
case DocRole.Manager:
|
||||
return 'Can manage';
|
||||
case DocRole.Editor:
|
||||
return 'Can edit';
|
||||
case DocRole.Reader:
|
||||
return 'Can read';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}, [grantedUser.role]);
|
||||
|
||||
return (
|
||||
<div className={styles.memberItemStyle}>
|
||||
<div className={styles.memberContainerStyle}>
|
||||
<Avatar
|
||||
key={user.id}
|
||||
url={user.avatarUrl || ''}
|
||||
name={user.name}
|
||||
size={36}
|
||||
/>
|
||||
<div className={styles.memberInfoStyle}>
|
||||
<Tooltip
|
||||
content={user.name}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberNameStyle}>{user.name}</div>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
content={user.email}
|
||||
rootOptions={{ delayDuration: 1000 }}
|
||||
options={{
|
||||
className: styles.tooltipContentStyle,
|
||||
}}
|
||||
>
|
||||
<div className={styles.memberEmailStyle}>{user.email}</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<Menu
|
||||
items={
|
||||
<Options
|
||||
userId={user.id}
|
||||
memberRole={grantedUser.role}
|
||||
hittingPaywall={hittingPaywall}
|
||||
openPaywallModal={openPaywallModal}
|
||||
/>
|
||||
}
|
||||
contentOptions={{
|
||||
align: 'start',
|
||||
}}
|
||||
>
|
||||
<MenuTrigger
|
||||
variant="plain"
|
||||
className={styles.menuTriggerStyle}
|
||||
contentStyle={{
|
||||
width: '100%',
|
||||
}}
|
||||
>
|
||||
{role}
|
||||
</MenuTrigger>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Options = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
memberRole,
|
||||
userId,
|
||||
}: {
|
||||
userId: string;
|
||||
memberRole: DocRole;
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
}) => {
|
||||
const t = useI18n();
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
|
||||
const changeToManager = useAsyncCallback(async () => {
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Manager);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, userId, t]);
|
||||
|
||||
const changeToEditor = useAsyncCallback(async () => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Editor);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
|
||||
|
||||
const changeToReader = useAsyncCallback(async () => {
|
||||
if (hittingPaywall) {
|
||||
openPaywallModal();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Reader);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, hittingPaywall, openPaywallModal, userId, t]);
|
||||
|
||||
const changeToOwner = useAsyncCallback(async () => {
|
||||
try {
|
||||
await docGrantedUsersService.updateUserRole(userId, DocRole.Owner);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, userId, t]);
|
||||
|
||||
const removeMember = useAsyncCallback(async () => {
|
||||
try {
|
||||
await docGrantedUsersService.revokeUsersRole(userId);
|
||||
} catch (error) {
|
||||
const err = UserFriendlyError.fromAnyError(error);
|
||||
notify.error({
|
||||
title: t[`error.${err.name}`](err.data),
|
||||
});
|
||||
}
|
||||
}, [docGrantedUsersService, userId, t]);
|
||||
|
||||
const operationButtonInfo = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-manage'](),
|
||||
onClick: changeToManager,
|
||||
role: DocRole.Manager,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
},
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-edit'](),
|
||||
onClick: changeToEditor,
|
||||
role: DocRole.Editor,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
showPlanTag: true,
|
||||
},
|
||||
{
|
||||
label: t['com.affine.share-menu.option.permission.can-read'](),
|
||||
onClick: changeToReader,
|
||||
role: DocRole.Reader,
|
||||
show: true, // TODO(@eyhn): add guard here
|
||||
showPlanTag: true,
|
||||
},
|
||||
];
|
||||
}, [changeToEditor, changeToManager, changeToReader, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{operationButtonInfo.map(item =>
|
||||
item.show ? (
|
||||
<MenuItem
|
||||
key={item.label}
|
||||
onSelect={item.onClick}
|
||||
selected={memberRole === item.role}
|
||||
>
|
||||
<div className={styles.planTagContainer}>
|
||||
{item.label} {item.showPlanTag ? <PlanTag /> : null}
|
||||
</div>
|
||||
</MenuItem>
|
||||
) : null
|
||||
)}
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<MenuItem onSelect={changeToOwner}>
|
||||
{t['com.affine.share-menu.member-management.set-as-owner']()}
|
||||
</MenuItem>
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<MenuSeparator />
|
||||
<MenuItem onSelect={removeMember} type="danger" className={styles.remove}>
|
||||
{t['com.affine.share-menu.member-management.remove']()}
|
||||
</MenuItem>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+60
@@ -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,
|
||||
});
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import {
|
||||
DocGrantedUsersService,
|
||||
type GrantedUser,
|
||||
} from '@affine/core/modules/permissions';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { ArrowLeftBigIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import { Scroller } from '../scroller';
|
||||
import { MemberItem } from './member-item';
|
||||
import * as styles from './member-management.css';
|
||||
|
||||
export const MemberManagement = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
onClickBack,
|
||||
onClickInvite,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
openPaywallModal: () => void;
|
||||
onClickBack: () => void;
|
||||
onClickInvite: () => void;
|
||||
}) => {
|
||||
const docGrantedUsersService = useService(DocGrantedUsersService);
|
||||
|
||||
const grantedUserList = useLiveData(docGrantedUsersService.grantedUsers$);
|
||||
const grantedUserCount = useLiveData(
|
||||
docGrantedUsersService.grantedUserCount$
|
||||
);
|
||||
|
||||
const t = useI18n();
|
||||
|
||||
useEffect(() => {
|
||||
// reset the list when mounted
|
||||
docGrantedUsersService.reset();
|
||||
docGrantedUsersService.loadMore();
|
||||
}, [docGrantedUsersService]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
docGrantedUsersService.loadMore();
|
||||
}, [docGrantedUsersService]);
|
||||
|
||||
return (
|
||||
<div className={styles.containerStyle}>
|
||||
<div className={styles.headerStyle} onClick={onClickBack}>
|
||||
<ArrowLeftBigIcon className={styles.iconStyle} />
|
||||
{t['com.affine.share-menu.member-management.header']({
|
||||
memberCount: grantedUserCount?.toString() || '??',
|
||||
})}
|
||||
</div>
|
||||
{grantedUserList ? (
|
||||
<MemberList
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={hittingPaywall}
|
||||
grantedUserList={grantedUserList}
|
||||
grantedUserCount={grantedUserCount}
|
||||
loadMore={loadMore}
|
||||
/>
|
||||
) : (
|
||||
<Skeleton className={styles.scrollableRootStyle} />
|
||||
)}
|
||||
{/* TODO(@eyhn): add guard here */}
|
||||
<div className={styles.footerStyle}>
|
||||
<span className={styles.addCollaboratorsStyle} onClick={onClickInvite}>
|
||||
{t['com.affine.share-menu.member-management.add-collaborators']()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MemberList = ({
|
||||
openPaywallModal,
|
||||
hittingPaywall,
|
||||
grantedUserList,
|
||||
grantedUserCount,
|
||||
loadMore,
|
||||
}: {
|
||||
hittingPaywall: boolean;
|
||||
grantedUserList: GrantedUser[];
|
||||
grantedUserCount?: number;
|
||||
openPaywallModal: () => void;
|
||||
loadMore: () => void;
|
||||
}) => {
|
||||
const itemContentRenderer = useCallback(
|
||||
(_index: number, data: GrantedUser) => {
|
||||
return (
|
||||
<MemberItem
|
||||
key={data.user.id}
|
||||
grantedUser={data}
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={hittingPaywall}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[hittingPaywall, openPaywallModal]
|
||||
);
|
||||
return (
|
||||
<Virtuoso
|
||||
components={{
|
||||
Scroller,
|
||||
}}
|
||||
data={grantedUserList}
|
||||
itemContent={itemContentRenderer}
|
||||
totalCount={grantedUserCount}
|
||||
endReached={loadMore}
|
||||
/>
|
||||
);
|
||||
};
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuTriggerStyle = style({
|
||||
width: '150px',
|
||||
padding: '4px 10px',
|
||||
justifyContent: 'space-between',
|
||||
});
|
||||
|
||||
export const rowContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '4px',
|
||||
selectors: {
|
||||
'&.clickable:hover': {
|
||||
backgroundColor: cssVarV2('layer/background/hoverOverlay'),
|
||||
cursor: 'pointer',
|
||||
borderRadius: '4px',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const memberContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
fontSize: cssVar('fontSm'),
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const descriptionStyle = style({
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const IconButtonStyle = style({
|
||||
flexShrink: 0,
|
||||
marginLeft: '8px',
|
||||
fontSize: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
|
||||
export const OwnerStyle = style({
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: cssVar('fontSm'),
|
||||
});
|
||||
|
||||
export const avatarsContainerStyle = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
});
|
||||
|
||||
export const openWorkspaceSettingsStyle = style({
|
||||
color: cssVarV2('text/link'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
padding: '4px',
|
||||
cursor: 'pointer',
|
||||
});
|
||||
globalStyle(`${openWorkspaceSettingsStyle} svg`, {
|
||||
color: cssVarV2('text/link'),
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const containerStyle = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 500,
|
||||
lineHeight: '20px',
|
||||
padding: '0 4px',
|
||||
color: cssVarV2('button/pureWhiteText'),
|
||||
backgroundColor: cssVarV2('button/primary'),
|
||||
borderRadius: '4px',
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { containerStyle } from './plan-tag.css';
|
||||
|
||||
export const PlanTag = () => {
|
||||
return <div className={containerStyle}>Pro</div>;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
export const result = style({
|
||||
minHeight: '164px',
|
||||
maxHeight: '342px',
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import { forwardRef, type HTMLAttributes, type PropsWithChildren } from 'react';
|
||||
|
||||
import * as styles from './scroller.css';
|
||||
|
||||
export const Scroller = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<HTMLAttributes<HTMLDivElement>>
|
||||
>(({ children, ...props }, ref) => {
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
<Scrollable.Viewport {...props} className={styles.result} ref={ref}>
|
||||
{children}
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
</Scrollable.Root>
|
||||
);
|
||||
});
|
||||
|
||||
Scroller.displayName = 'Scroller';
|
||||
@@ -0,0 +1,45 @@
|
||||
import { useExportPage } from '@affine/core/components/hooks/affine/use-export-page';
|
||||
import {
|
||||
ExportMenuItems,
|
||||
PrintMenuItems,
|
||||
} from '@affine/core/components/page-list';
|
||||
import { EditorService } from '@affine/core/modules/editor';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const ShareExport = () => {
|
||||
const t = useI18n();
|
||||
const editor = useService(EditorService).editor;
|
||||
const exportHandler = useExportPage();
|
||||
const currentMode = useLiveData(editor.mode$);
|
||||
|
||||
return (
|
||||
<div className={styles.exportContainerStyle}>
|
||||
<div className={styles.descriptionStyle}>
|
||||
{t['com.affine.share-menu.ShareViaExportDescription']()}
|
||||
</div>
|
||||
<div className={styles.exportContainerStyle}>
|
||||
<ExportMenuItems
|
||||
exportHandler={exportHandler}
|
||||
className={styles.exportItemStyle}
|
||||
pageMode={currentMode}
|
||||
/>
|
||||
</div>
|
||||
{currentMode === 'page' && (
|
||||
<>
|
||||
<div className={styles.descriptionStyle}>
|
||||
{t['com.affine.share-menu.ShareViaPrintDescription']()}
|
||||
</div>
|
||||
<div>
|
||||
<PrintMenuItems
|
||||
exportHandler={exportHandler}
|
||||
className={styles.exportItemStyle}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,275 @@
|
||||
import { Tabs, Tooltip, useConfirmModal } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { Menu } from '@affine/component/ui/menu';
|
||||
import {
|
||||
ServerService,
|
||||
WorkspaceSubscriptionService,
|
||||
} from '@affine/core/modules/cloud';
|
||||
import { WorkspaceDialogService } from '@affine/core/modules/dialogs';
|
||||
import { WorkspacePermissionService } from '@affine/core/modules/permissions';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import type { WorkspaceMetadata } from '@affine/core/modules/workspace';
|
||||
import { ServerDeploymentType, SubscriptionPlan } from '@affine/graphql';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import type { Store } from '@blocksuite/affine/store';
|
||||
import { LockIcon, PublishIcon } from '@blocksuite/icons/rc';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import {
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
type Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import { InviteMemberEditor } from './invite-member-editor/invite-member-editor';
|
||||
import { MemberManagement } from './member-management';
|
||||
import { ShareExport } from './share-export';
|
||||
import { SharePage } from './share-page';
|
||||
|
||||
export interface ShareMenuProps extends PropsWithChildren {
|
||||
workspaceMetadata: WorkspaceMetadata;
|
||||
currentPage: Store;
|
||||
onEnableAffineCloud: () => void;
|
||||
onOpenShareModal?: (open: boolean) => void;
|
||||
openPaywallModal?: () => void;
|
||||
hittingPaywall?: boolean;
|
||||
}
|
||||
|
||||
export enum ShareMenuTab {
|
||||
Share = 'share',
|
||||
Export = 'export',
|
||||
Invite = 'invite',
|
||||
Members = 'members',
|
||||
}
|
||||
|
||||
export const ShareMenuContent = (props: ShareMenuProps) => {
|
||||
const t = useI18n();
|
||||
const [currentTab, setCurrentTab] = useState(ShareMenuTab.Share);
|
||||
|
||||
const serverService = useService(ServerService);
|
||||
const isSelfhosted = useLiveData(
|
||||
serverService.server.config$.selector(
|
||||
c => c.type === ServerDeploymentType.Selfhosted
|
||||
)
|
||||
);
|
||||
|
||||
const workspaceSubscriptionService = useService(WorkspaceSubscriptionService);
|
||||
const subscription = useLiveData(
|
||||
workspaceSubscriptionService.subscription.subscription$
|
||||
);
|
||||
const hittingPaywall =
|
||||
(!subscription && !isSelfhosted) ||
|
||||
(subscription && subscription.plan === SubscriptionPlan.Free);
|
||||
|
||||
const permissionService = useService(WorkspacePermissionService);
|
||||
const isOwner = useLiveData(permissionService.permission.isOwner$);
|
||||
|
||||
const workspaceDialogService = useService(WorkspaceDialogService);
|
||||
|
||||
const onValueChange = useCallback((value: string) => {
|
||||
setCurrentTab(value as ShareMenuTab);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
workspaceSubscriptionService.subscription.revalidate();
|
||||
}, [workspaceSubscriptionService]);
|
||||
|
||||
const { openConfirmModal } = useConfirmModal();
|
||||
|
||||
const onConfirm = useCallback(() => {
|
||||
if (!isOwner) {
|
||||
return;
|
||||
}
|
||||
workspaceDialogService.open('setting', {
|
||||
activeTab: 'plans',
|
||||
scrollAnchor: 'cloudPricingPlan',
|
||||
});
|
||||
return;
|
||||
}, [isOwner, workspaceDialogService]);
|
||||
|
||||
const openPaywallModal = useCallback(() => {
|
||||
openConfirmModal({
|
||||
title:
|
||||
t[
|
||||
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.title`
|
||||
](),
|
||||
description:
|
||||
t[
|
||||
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.description`
|
||||
](),
|
||||
confirmText:
|
||||
t[
|
||||
`com.affine.share-menu.paywall.${isOwner ? 'owner' : 'member'}.confirm`
|
||||
](),
|
||||
onConfirm: onConfirm,
|
||||
cancelText: t['Cancel'](),
|
||||
cancelButtonOptions: {
|
||||
style: {
|
||||
visibility: isOwner ? 'visible' : 'hidden',
|
||||
},
|
||||
},
|
||||
confirmButtonOptions: {
|
||||
variant: isOwner ? 'primary' : 'custom',
|
||||
},
|
||||
});
|
||||
}, [isOwner, onConfirm, openConfirmModal, t]);
|
||||
|
||||
if (currentTab === ShareMenuTab.Members) {
|
||||
return (
|
||||
<MemberManagement
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={!!hittingPaywall}
|
||||
onClickBack={() => {
|
||||
setCurrentTab(ShareMenuTab.Share);
|
||||
}}
|
||||
onClickInvite={() => {
|
||||
setCurrentTab(ShareMenuTab.Invite);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (currentTab === ShareMenuTab.Invite) {
|
||||
return (
|
||||
<InviteMemberEditor
|
||||
openPaywallModal={openPaywallModal}
|
||||
hittingPaywall={!!hittingPaywall}
|
||||
onClickCancel={() => {
|
||||
setCurrentTab(ShareMenuTab.Share);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={styles.containerStyle}>
|
||||
<Tabs.Root
|
||||
defaultValue={ShareMenuTab.Share}
|
||||
value={currentTab}
|
||||
onValueChange={onValueChange}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Trigger value={ShareMenuTab.Share}>
|
||||
{t['com.affine.share-menu.shareButton']()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value={ShareMenuTab.Export}>
|
||||
{t['Export']()}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value={ShareMenuTab.Invite} style={{ display: 'none' }}>
|
||||
invite
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger
|
||||
value={ShareMenuTab.Members}
|
||||
style={{ display: 'none' }}
|
||||
>
|
||||
members
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
<Tabs.Content value={ShareMenuTab.Share}>
|
||||
<SharePage
|
||||
hittingPaywall={!!hittingPaywall}
|
||||
openPaywallModal={openPaywallModal}
|
||||
onClickInvite={() => {
|
||||
setCurrentTab(ShareMenuTab.Invite);
|
||||
}}
|
||||
onClickMembers={() => {
|
||||
setCurrentTab(ShareMenuTab.Members);
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={ShareMenuTab.Export}>
|
||||
<ShareExport />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={ShareMenuTab.Invite}>
|
||||
<div>null</div>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value={ShareMenuTab.Members}>
|
||||
<div>null</div>
|
||||
</Tabs.Content>
|
||||
</Tabs.Root>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultShareButton = forwardRef(function DefaultShareButton(
|
||||
_,
|
||||
ref: Ref<HTMLButtonElement>
|
||||
) {
|
||||
const t = useI18n();
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const shared = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
shared
|
||||
? t['com.affine.share-menu.option.link.readonly.description']()
|
||||
: t['com.affine.share-menu.option.link.no-access.description']()
|
||||
}
|
||||
>
|
||||
<Button ref={ref} className={styles.button}>
|
||||
<div className={styles.buttonContainer}>
|
||||
{shared ? <PublishIcon fontSize={16} /> : <LockIcon fontSize={16} />}
|
||||
{t['com.affine.share-menu.shareButton']()}
|
||||
</div>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
|
||||
const LocalShareMenu = (props: ShareMenuProps) => {
|
||||
return (
|
||||
<Menu
|
||||
items={<ShareMenuContent {...props} />}
|
||||
contentOptions={{
|
||||
className: styles.localMenuStyle,
|
||||
['data-testid' as string]: 'local-share-menu',
|
||||
align: 'end',
|
||||
}}
|
||||
rootOptions={{
|
||||
modal: false,
|
||||
onOpenChange: props.onOpenShareModal,
|
||||
}}
|
||||
>
|
||||
<div data-testid="local-share-menu-button">
|
||||
{props.children || <DefaultShareButton />}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const CloudShareMenu = (props: ShareMenuProps) => {
|
||||
return (
|
||||
<Menu
|
||||
items={<ShareMenuContent {...props} />}
|
||||
contentOptions={{
|
||||
className: styles.menuStyle,
|
||||
['data-testid' as string]: 'cloud-share-menu',
|
||||
align: 'end',
|
||||
}}
|
||||
rootOptions={{
|
||||
modal: false,
|
||||
onOpenChange: props.onOpenShareModal,
|
||||
}}
|
||||
>
|
||||
<div data-testid="cloud-share-menu-button">
|
||||
{props.children || <DefaultShareButton />}
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
export const ShareMenu = (props: ShareMenuProps) => {
|
||||
const { workspaceMetadata } = props;
|
||||
|
||||
if (workspaceMetadata.flavour === 'local') {
|
||||
return <LocalShareMenu {...props} />;
|
||||
}
|
||||
return <CloudShareMenu {...props} />;
|
||||
};
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Skeleton } from '@affine/component';
|
||||
import { Button } from '@affine/component/ui/button';
|
||||
import { ServerService } from '@affine/core/modules/cloud';
|
||||
import { ShareInfoService } from '@affine/core/modules/share-doc';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { Suspense, useEffect } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { CloudSvg } from '../cloud-svg';
|
||||
import { CopyLinkButton } from './copy-link-button';
|
||||
import { MembersPermission, PublicDoc } from './general-access';
|
||||
import * as styles from './index.css';
|
||||
import { InviteInput } from './invite-member-editor';
|
||||
import { MembersRow } from './member-management';
|
||||
import type { ShareMenuProps } from './share-menu';
|
||||
|
||||
export const LocalSharePage = (props: ShareMenuProps) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workspaceMetadata: { id: workspaceId },
|
||||
} = props;
|
||||
return (
|
||||
<>
|
||||
<div className={styles.localSharePage}>
|
||||
<div className={styles.columnContainerStyle} style={{ gap: '12px' }}>
|
||||
<div
|
||||
className={styles.descriptionStyle}
|
||||
style={{ maxWidth: '230px' }}
|
||||
>
|
||||
{t['com.affine.share-menu.EnableCloudDescription']()}
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
onClick={props.onEnableAffineCloud}
|
||||
variant="primary"
|
||||
data-testid="share-menu-enable-affine-cloud-button"
|
||||
>
|
||||
{t['Enable AFFiNE Cloud']()}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.cloudSvgContainer}>
|
||||
<CloudSvg />
|
||||
</div>
|
||||
</div>
|
||||
<CopyLinkButton workspaceId={workspaceId} secondary />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const AFFiNESharePage = (
|
||||
props: ShareMenuProps & {
|
||||
onClickInvite: () => void;
|
||||
onClickMembers: () => void;
|
||||
}
|
||||
) => {
|
||||
const t = useI18n();
|
||||
const {
|
||||
workspaceMetadata: { id: workspaceId },
|
||||
} = props;
|
||||
const shareInfoService = useService(ShareInfoService);
|
||||
const serverService = useService(ServerService);
|
||||
|
||||
useEffect(() => {
|
||||
shareInfoService.shareInfo.revalidate();
|
||||
}, [shareInfoService]);
|
||||
|
||||
const isSharedPage = useLiveData(shareInfoService.shareInfo.isShared$);
|
||||
const sharedMode = useLiveData(shareInfoService.shareInfo.sharedMode$);
|
||||
const baseUrl = serverService.server.baseUrl;
|
||||
const isLoading =
|
||||
isSharedPage === null || sharedMode === null || baseUrl === null;
|
||||
|
||||
if (isLoading) {
|
||||
// TODO(@eyhn): loading and error UI
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={100} />
|
||||
<Skeleton height={40} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<div className={styles.columnContainerStyle}>
|
||||
<InviteInput onFocus={props.onClickInvite} />
|
||||
<MembersRow onClick={props.onClickMembers} />
|
||||
<div className={styles.generalAccessStyle}>
|
||||
{t['com.affine.share-menu.generalAccess']()}
|
||||
</div>
|
||||
<MembersPermission
|
||||
openPaywallModal={props.openPaywallModal}
|
||||
hittingPaywall={!!props.hittingPaywall}
|
||||
/>
|
||||
<PublicDoc />
|
||||
</div>
|
||||
<CopyLinkButton workspaceId={workspaceId} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SharePage = (
|
||||
props: ShareMenuProps & {
|
||||
onClickInvite: () => void;
|
||||
onClickMembers: () => void;
|
||||
}
|
||||
) => {
|
||||
if (props.workspaceMetadata.flavour === 'local') {
|
||||
return <LocalSharePage {...props} />;
|
||||
} else {
|
||||
return (
|
||||
// TODO(@eyhn): refactor this part
|
||||
<ErrorBoundary fallback={null}>
|
||||
<Suspense>
|
||||
<AFFiNESharePage {...props} />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user