feat(core): support share edgeless mode (#4856)

Close #3287

<!--
copilot:all
-->
### <samp>🤖 Generated by Copilot at d3fdf86</samp>

### Summary
📄🚀🔗

<!--
1.  📄 - This emoji represents the page and edgeless modes of sharing a page, as well as the GraphQL operations and types related to public pages.
2.  🚀 - This emoji represents the functionality of publishing and revoking public pages, as well as the confirmation modal and the notifications for the user.
3.  🔗 - This emoji represents the sharing URL and the query parameter for the share mode, as well as the hooks and functions that generate and use the URL.
-->
This pull request adds a feature to the frontend component of AFFiNE that allows the user to share a page in either `page` or `edgeless` mode, which affects the appearance and functionality of the page. It also adds the necessary GraphQL operations, types, and schema to support this feature in the backend, and updates the tests and the storybook stories accordingly.

*  Modify the `useIsSharedPage` hook to accept an optional `shareMode` argument and use the `getWorkspacePublicPagesQuery`, `publishPageMutation`, and `revokePublicPageMutation` from `@affine/graphql`
This commit is contained in:
JimmFly
2023-11-15 07:49:25 +00:00
committed by LongYinan
parent e7e617a791
commit ddd7cab414
39 changed files with 800 additions and 332 deletions

View File

@@ -6,7 +6,7 @@ import {
type MenuItemProps,
} from '@toeverything/components/menu';
import { PublicLinkDisableModal } from '../../share-menu';
import { PublicLinkDisableModal } from '../../disable-public-link';
export const DisablePublicSharing = (props: MenuItemProps) => {
const t = useAFFiNEI18N();

View File

@@ -1,162 +0,0 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const headerStyle = style({
display: 'flex',
alignItems: 'center',
fontSize: 'var(--affine-font-sm)',
fontWeight: 600,
lineHeight: '22px',
padding: '0 4px',
gap: '4px',
});
export const menuStyle = style({
width: '410px',
height: 'auto',
padding: '12px',
transform: 'translateX(-10px)',
});
export const menuItemStyle = style({
padding: '4px',
transition: 'all 0.3s',
});
export const descriptionStyle = style({
wordWrap: 'break-word',
// wordBreak: 'break-all',
fontSize: 'var(--affine-font-xs)',
lineHeight: '20px',
color: 'var(--affine-text-secondary-color)',
textAlign: 'left',
padding: '0 6px',
});
export const buttonStyle = style({
marginTop: '18px',
// todo: new color scheme should be used
});
export const actionsStyle = style({
display: 'flex',
gap: '9px',
flexDirection: 'column',
justifyContent: 'center',
alignItems: 'flex-start',
});
export const containerStyle = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
gap: '8px',
});
export const indicatorContainerStyle = style({
position: 'relative',
});
export const inputButtonRowStyle = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '16px',
});
export const titleContainerStyle = style({
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: 'var(--affine-font-sm)',
fontWeight: 500,
lineHeight: '22px',
padding: '0 4px',
});
export const subTitleStyle = style({
fontSize: 'var(--affine-font-sm)',
fontWeight: 500,
lineHeight: '22px',
});
export const columnContainerStyle = style({
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
width: '100%',
gap: '8px',
});
export const rowContainerStyle = style({
display: 'flex',
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
gap: '12px',
padding: '4px',
});
export const radioButtonGroup = style({
display: 'flex',
justifyContent: 'flex-end',
padding: '2px',
minWidth: '154px',
maxWidth: '250px',
});
export const radioButton = style({
color: 'var(--affine-text-secondary-color)',
selectors: {
'&[data-state="checked"]': {
color: 'var(--affine-text-primary-color)',
},
},
});
export const spanStyle = style({
padding: '4px 20px',
});
export const disableSharePage = style({
color: 'var(--affine-error-color)',
});
export const localSharePage = style({
padding: '12px 8px',
display: 'flex',
alignItems: 'center',
borderRadius: '8px',
backgroundColor: 'var(--affine-background-secondary-color)',
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 shareIconStyle = style({
fontSize: '16px',
color: 'var(--affine-icon-color)',
display: 'flex',
alignItems: 'center',
});
export const shareLinkStyle = style({
padding: '4px',
fontSize: 'var(--affine-font-xs)',
fontWeight: 500,
lineHeight: '20px',
transform: 'translateX(-4px)',
gap: '4px',
});
globalStyle(`${shareLinkStyle} > span`, {
color: 'var(--affine-link-color)',
});
globalStyle(`${shareLinkStyle} > div > svg`, {
color: 'var(--affine-link-color)',
});

View File

@@ -1,3 +0,0 @@
import { atom } from 'jotai/vanilla';
export const enableShareMenuAtom = atom(false);

View File

@@ -1,3 +0,0 @@
export * from './disable-public-link';
export * from './share-menu';
export * from './styles';

View File

@@ -1,63 +0,0 @@
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { LinkIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Divider } from '@toeverything/components/divider';
import { ExportMenuItems } from '../page-list/operation-menu-items/export';
import * as styles from './index.css';
import type { ShareMenuProps } from './share-menu';
import { useSharingUrl } from './use-share-url';
export const ShareExport = ({
workspace,
currentPage,
exportHandler,
}: ShareMenuProps) => {
const t = useAFFiNEI18N();
const workspaceId = workspace.id;
const pageId = currentPage.id;
const { onClickCopyLink } = useSharingUrl({
workspaceId,
pageId,
urlType: 'workspace',
});
return (
<>
<div className={styles.titleContainerStyle}>
{t['com.affine.share-menu.ShareViaExport']()}
</div>
<div className={styles.descriptionStyle}>
{t['com.affine.share-menu.ShareViaExportDescription']()}
</div>
<div>
<ExportMenuItems
exportHandler={exportHandler}
className={styles.menuItemStyle}
/>
</div>
{workspace.flavour !== WorkspaceFlavour.LOCAL ? (
<div className={styles.columnContainerStyle}>
<Divider size="thinner" />
<div className={styles.titleContainerStyle}>
{t['com.affine.share-menu.share-privately']()}
</div>
<div className={styles.descriptionStyle}>
{t['com.affine.share-menu.share-privately.description']()}
</div>
<div>
<Button
className={styles.shareLinkStyle}
onClick={onClickCopyLink}
icon={<LinkIcon />}
type="plain"
>
{t['com.affine.share-menu.copy-private-link']()}
</Button>
</div>
</div>
) : null}
</>
);
};

View File

@@ -1,115 +0,0 @@
import {
type AffineCloudWorkspace,
type AffineOfficialWorkspace,
type AffinePublicWorkspace,
type LocalWorkspace,
WorkspaceFlavour,
} from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { WebIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { Button } from '@toeverything/components/button';
import { Divider } from '@toeverything/components/divider';
import { Menu } from '@toeverything/components/menu';
import * as styles from './index.css';
import { ShareExport } from './share-export';
import { SharePage } from './share-page';
export interface ShareMenuProps<
Workspace extends AffineOfficialWorkspace =
| AffineCloudWorkspace
| LocalWorkspace
| AffinePublicWorkspace,
> {
workspace: Workspace;
currentPage: Page;
useIsSharedPage: (
workspaceId: string,
pageId: string
) => [isSharePage: boolean, setIsSharePage: (enable: boolean) => void];
onEnableAffineCloud: () => void;
togglePagePublic: () => Promise<void>;
exportHandler: (type: 'pdf' | 'html' | 'png' | 'markdown') => Promise<void>;
}
const ShareMenuContent = (props: ShareMenuProps) => {
const t = useAFFiNEI18N();
return (
<div className={styles.containerStyle}>
<div className={styles.headerStyle}>
<div className={styles.shareIconStyle}>
<WebIcon />
</div>
{t['com.affine.share-menu.SharePage']()}
</div>
<SharePage {...props} />
<div className={styles.columnContainerStyle}>
<Divider size="thinner" />
</div>
<ShareExport {...props} />
</div>
);
};
const LocalShareMenu = (props: ShareMenuProps) => {
const t = useAFFiNEI18N();
return (
<Menu
items={<ShareMenuContent {...props} />}
contentOptions={{
className: styles.menuStyle,
['data-testid' as string]: 'local-share-menu',
}}
rootOptions={{
modal: false,
}}
>
<Button data-testid="local-share-menu-button" type="plain">
{t['com.affine.share-menu.shareButton']()}
</Button>
</Menu>
);
};
const CloudShareMenu = (props: ShareMenuProps) => {
const t = useAFFiNEI18N();
const { workspace, currentPage, useIsSharedPage } = props;
const [isSharedPage] = useIsSharedPage(workspace.id, currentPage.id);
return (
<Menu
items={<ShareMenuContent {...props} />}
contentOptions={{
className: styles.menuStyle,
['data-testid' as string]: 'cloud-share-menu',
}}
rootOptions={{
modal: false,
}}
>
<Button data-testid="cloud-share-menu-button" type="plain">
<div
style={{
color: isSharedPage
? 'var(--affine-link-color)'
: 'var(--affine-text-primary-color)',
}}
>
{isSharedPage
? t['com.affine.share-menu.sharedButton']()
: t['com.affine.share-menu.shareButton']()}
</div>
</Button>
</Menu>
);
};
export const ShareMenu = (props: ShareMenuProps) => {
const { workspace } = props;
if (workspace.flavour === WorkspaceFlavour.LOCAL) {
return <LocalShareMenu {...props} />;
}
return <CloudShareMenu {...props} />;
};

View File

@@ -1,219 +0,0 @@
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { Menu, MenuItem, MenuTrigger } from '@toeverything/components/menu';
import { useState } from 'react';
import { useCallback } from 'react';
import { RadioButton, RadioButtonGroup } from '../../ui/button';
import Input from '../../ui/input';
import { Switch } from '../../ui/switch';
import { toast } from '../../ui/toast';
import { PublicLinkDisableModal } from './disable-public-link';
import * as styles from './index.css';
import type { ShareMenuProps } from './share-menu';
import { useSharingUrl } from './use-share-url';
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="var(--affine-icon-color)"
/>
</g>
</svg>
);
export const LocalSharePage = (props: ShareMenuProps) => {
const t = useAFFiNEI18N();
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}
type="primary"
data-testid="share-menu-enable-affine-cloud-button"
>
{t['Enable AFFiNE Cloud']()}
</Button>
</div>
</div>
<div className={styles.cloudSvgContainer}>
<CloudSvg />
</div>
</div>
);
};
export const AffineSharePage = (props: ShareMenuProps) => {
const {
workspace: { id: workspaceId },
currentPage: { id: pageId },
} = props;
const [isPublic, setIsPublic] = props.useIsSharedPage(workspaceId, pageId);
const [showDisable, setShowDisable] = useState(false);
const { sharingUrl, onClickCopyLink } = useSharingUrl({
workspaceId,
pageId,
urlType: 'share',
});
const t = useAFFiNEI18N();
const onClickCreateLink = useCallback(() => {
setIsPublic(true);
}, [setIsPublic]);
const onDisablePublic = useCallback(() => {
setIsPublic(false);
toast('Successfully disabled', {
portal: document.body,
});
setShowDisable(false);
}, [setIsPublic]);
return (
<>
<div className={styles.titleContainerStyle}>
{t['com.affine.share-menu.publish-to-web']()}
</div>
<div className={styles.columnContainerStyle}>
<div className={styles.descriptionStyle}>
{t['com.affine.share-menu.publish-to-web.description']()}
</div>
</div>
<div className={styles.rowContainerStyle}>
<Input
inputStyle={{
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
lineHeight: '20px',
}}
value={isPublic ? sharingUrl : `${runtimeConfig.serverUrlPrefix}/...`}
readOnly
/>
{isPublic ? (
<Button
onClick={onClickCopyLink}
data-testid="share-menu-copy-link-button"
style={{ padding: '4px 12px', whiteSpace: 'nowrap' }}
>
{t.Copy()}
</Button>
) : (
<Button
onClick={onClickCreateLink}
type="primary"
data-testid="share-menu-create-link-button"
style={{ padding: '4px 12px', whiteSpace: 'nowrap' }}
>
{t.Create()}
</Button>
)}
</div>
{runtimeConfig.enableEnhanceShareMode ? (
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>
{t['com.affine.share-menu.ShareMode']()}
</div>
<div>
<RadioButtonGroup
className={styles.radioButtonGroup}
defaultValue={'page'}
onValueChange={() => {}}
>
<RadioButton
className={styles.radioButton}
value={'page'}
spanStyle={styles.spanStyle}
>
{t['com.affine.pageMode.page']()}
</RadioButton>
<RadioButton
className={styles.radioButton}
value={'edgeless'}
spanStyle={styles.spanStyle}
>
{t['com.affine.pageMode.edgeless']()}
</RadioButton>
</RadioButtonGroup>
</div>
</div>
) : null}
{isPublic ? (
<>
{runtimeConfig.enableEnhanceShareMode && (
<>
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>Link expires</div>
<div>
<Menu items={<MenuItem>Never</MenuItem>}>
<MenuTrigger>Never</MenuTrigger>
</Menu>
</div>
</div>
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>
{'Show "Created with AFFiNE"'}
</div>
<div>
<Switch />
</div>
</div>
<div className={styles.rowContainerStyle}>
<div className={styles.subTitleStyle}>
Search engine indexing
</div>
<div>
<Switch />
</div>
</div>
</>
)}
<MenuItem
endFix={<ArrowRightSmallIcon />}
block
type="danger"
className={styles.menuItemStyle}
onSelect={e => {
e.preventDefault();
setShowDisable(true);
}}
>
<div className={styles.disableSharePage}>
{t['Disable Public Link']()}
</div>
</MenuItem>
<PublicLinkDisableModal
open={showDisable}
onConfirm={onDisablePublic}
onOpenChange={setShowDisable}
/>
</>
) : null}
</>
);
};
export const SharePage = (props: ShareMenuProps) => {
if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return <LocalSharePage {...props} />;
} else if (props.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
return <AffineSharePage {...props} />;
}
throw new Error('Unreachable');
};

View File

@@ -1,91 +0,0 @@
import { Button } from '@toeverything/components/button';
import { displayFlex, styled } from '../..';
export const TabItem = styled('li')<{ isActive?: boolean }>(({ isActive }) => {
{
return {
...displayFlex('center', 'center'),
flex: '1',
height: '30px',
color: 'var(--affine-text-primary-color)',
opacity: isActive ? 1 : 0.2,
fontWeight: '500',
fontSize: 'var(--affine-font-base)',
lineHeight: 'var(--affine-line-height)',
cursor: 'pointer',
transition: 'all 0.15s ease',
padding: '0 10px',
marginBottom: '4px',
borderRadius: '4px',
position: 'relative',
':hover': {
background: 'var(--affine-hover-color)',
opacity: 1,
color: isActive
? 'var(--affine-t/ext-primary-color)'
: 'var(--affine-text-secondary-color)',
svg: {
fill: isActive
? 'var(--affine-text-primary-color)'
: 'var(--affine-text-secondary-color)',
},
},
svg: {
fontSize: '20px',
marginRight: '12px',
},
':after': {
content: '""',
position: 'absolute',
bottom: '-6px',
left: '0',
width: '100%',
height: '2px',
background: 'var(--affine-text-primary-color)',
opacity: 0.2,
},
};
}
});
export const StyledIndicator = styled('div')(() => {
return {
height: '2px',
background: 'var(--affine-text-primary-color)',
position: 'absolute',
left: '0',
transition: 'left .3s, width .3s',
};
});
export const StyledInput = styled('input')(() => {
return {
padding: '4px 8px',
height: '28px',
color: 'var(--affine-placeholder-color)',
border: `1px solid ${'var(--affine-placeholder-color)'}`,
cursor: 'default',
overflow: 'hidden',
userSelect: 'text',
borderRadius: '4px',
flexGrow: 1,
marginRight: '10px',
};
});
export const StyledDisableButton = styled(Button)(() => {
return {
color: '#FF631F',
height: '32px',
border: 'none',
marginTop: '16px',
borderRadius: '8px',
padding: '0',
};
});
export const StyledLinkSpan = styled('span')(() => {
return {
marginLeft: '4px',
color: 'var(--affine-primary-color)',
fontWeight: '500',
cursor: 'pointer',
};
});

View File

@@ -1,48 +0,0 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useCallback, useMemo } from 'react';
import { toast } from '../../ui/toast';
type UrlType = 'share' | 'workspace';
type UseSharingUrl = {
workspaceId: string;
pageId: string;
urlType: UrlType;
};
export const generateUrl = ({
workspaceId,
pageId,
urlType,
}: UseSharingUrl) => {
return `${runtimeConfig.serverUrlPrefix}/${urlType}/${workspaceId}/${pageId}`;
};
export const useSharingUrl = ({
workspaceId,
pageId,
urlType,
}: UseSharingUrl) => {
const t = useAFFiNEI18N();
const sharingUrl = useMemo(
() => generateUrl({ workspaceId, pageId, urlType }),
[urlType, workspaceId, pageId]
);
const onClickCopyLink = useCallback(() => {
navigator.clipboard
.writeText(sharingUrl)
.then(() => {
toast(t['Copied link to clipboard']());
})
.catch(err => {
console.error(err);
});
}, [sharingUrl, t]);
return {
sharingUrl,
onClickCopyLink,
};
};