diff --git a/packages/backend/server/src/modules/workspaces/controller.ts b/packages/backend/server/src/modules/workspaces/controller.ts index 219a10fdf8..65cee2bc3d 100644 --- a/packages/backend/server/src/modules/workspaces/controller.ts +++ b/packages/backend/server/src/modules/workspaces/controller.ts @@ -12,12 +12,13 @@ import { import type { Response } from 'express'; import format from 'pretty-time'; +import { PrismaService } from '../../prisma'; import { StorageProvide } from '../../storage'; import { DocID } from '../../utils/doc'; import { Auth, CurrentUser, Publicable } from '../auth'; import { DocManager } from '../doc'; import { UserType } from '../users'; -import { PermissionService } from './permission'; +import { PermissionService, PublicPageMode } from './permission'; @Controller('/api/workspaces') export class WorkspacesController { @@ -26,7 +27,8 @@ export class WorkspacesController { constructor( @Inject(StorageProvide) private readonly storage: Storage, private readonly permission: PermissionService, - private readonly docManager: DocManager + private readonly docManager: DocManager, + private readonly prisma: PrismaService ) {} // get workspace blob @@ -82,6 +84,22 @@ export class WorkspacesController { throw new NotFoundException('Doc not found'); } + if (!docId.isWorkspace) { + // fetch the publish page mode for publish page + const publishPage = await this.prisma.workspacePage.findUnique({ + where: { + workspaceId_pageId: { + workspaceId: docId.workspace, + pageId: docId.guid, + }, + }, + }); + const publishPageMode = + publishPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page'; + + res.setHeader('publish-mode', publishPageMode); + } + res.setHeader('content-type', 'application/octet-stream'); res.send(update); this.logger.debug(`workspaces doc api: ${format(process.hrtime(start))}`); diff --git a/packages/frontend/component/src/components/share-menu/disable-public-link/index.tsx b/packages/frontend/component/src/components/disable-public-link/index.tsx similarity index 100% rename from packages/frontend/component/src/components/share-menu/disable-public-link/index.tsx rename to packages/frontend/component/src/components/disable-public-link/index.tsx diff --git a/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx b/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx index e89b37d76d..296c220db8 100644 --- a/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx +++ b/packages/frontend/component/src/components/page-list/operation-menu-items/disable-public-sharing.tsx @@ -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(); diff --git a/packages/frontend/component/src/components/share-menu/index.tsx b/packages/frontend/component/src/components/share-menu/index.tsx deleted file mode 100644 index 6dabe4f3d1..0000000000 --- a/packages/frontend/component/src/components/share-menu/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export * from './disable-public-link'; -export * from './share-menu'; -export * from './styles'; diff --git a/packages/frontend/component/src/components/share-menu/styles.ts b/packages/frontend/component/src/components/share-menu/styles.ts deleted file mode 100644 index 86e2e18c85..0000000000 --- a/packages/frontend/component/src/components/share-menu/styles.ts +++ /dev/null @@ -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', - }; -}); diff --git a/packages/frontend/core/src/atoms/index.ts b/packages/frontend/core/src/atoms/index.ts index 82e911331b..c275bf321a 100644 --- a/packages/frontend/core/src/atoms/index.ts +++ b/packages/frontend/core/src/atoms/index.ts @@ -40,7 +40,7 @@ export const authAtom = atom({ export const openDisableCloudAlertModalAtom = atom(false); -type PageMode = 'page' | 'edgeless'; +export type PageMode = 'page' | 'edgeless'; type PageLocalSetting = { mode: PageMode; }; diff --git a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx index 80b0b9973f..1e4395ce46 100644 --- a/packages/frontend/core/src/components/affine/share-page-modal/index.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/index.tsx @@ -1,4 +1,3 @@ -import { ShareMenu } from '@affine/component/share-menu'; import { type AffineOfficialWorkspace, WorkspaceFlavour, @@ -6,10 +5,9 @@ import { import type { Page } from '@blocksuite/store'; import { useCallback, useState } from 'react'; -import { useExportPage } from '../../../hooks/affine/use-export-page'; -import { useIsSharedPage } from '../../../hooks/affine/use-is-shared-page'; import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace'; import { EnableAffineCloudModal } from '../enable-affine-cloud-modal'; +import { ShareMenu } from './share-menu'; type SharePageModalProps = { workspace: AffineOfficialWorkspace; @@ -19,7 +17,7 @@ type SharePageModalProps = { export const SharePageModal = ({ workspace, page }: SharePageModalProps) => { const onTransformWorkspace = useOnTransformWorkspace(); const [open, setOpen] = useState(false); - const exportHandler = useExportPage(page); + const handleConfirm = useCallback(() => { if (workspace.flavour !== WorkspaceFlavour.LOCAL) { return; @@ -31,15 +29,13 @@ export const SharePageModal = ({ workspace, page }: SharePageModalProps) => { ); setOpen(false); }, [onTransformWorkspace, workspace]); + return ( <> setOpen(true)} - togglePagePublic={async () => {}} - exportHandler={exportHandler} /> {workspace.flavour === WorkspaceFlavour.LOCAL ? ( { +export const ShareExport = ({ workspace, currentPage }: ShareMenuProps) => { const t = useAFFiNEI18N(); const workspaceId = workspace.id; const pageId = currentPage.id; @@ -22,6 +19,7 @@ export const ShareExport = ({ pageId, urlType: 'workspace', }); + const exportHandler = useExportPage(currentPage); return ( <> diff --git a/packages/frontend/component/src/components/share-menu/share-menu.tsx b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx similarity index 87% rename from packages/frontend/component/src/components/share-menu/share-menu.tsx rename to packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx index a40c42adfa..9b364cc04a 100644 --- a/packages/frontend/component/src/components/share-menu/share-menu.tsx +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/share-menu.tsx @@ -12,9 +12,11 @@ import { Button } from '@toeverything/components/button'; import { Divider } from '@toeverything/components/divider'; import { Menu } from '@toeverything/components/menu'; +import { useIsSharedPage } from '../../../../hooks/affine/use-is-shared-page'; import * as styles from './index.css'; import { ShareExport } from './share-export'; import { SharePage } from './share-page'; + export interface ShareMenuProps< Workspace extends AffineOfficialWorkspace = | AffineCloudWorkspace @@ -23,13 +25,7 @@ export interface ShareMenuProps< > { workspace: Workspace; currentPage: Page; - useIsSharedPage: ( - workspaceId: string, - pageId: string - ) => [isSharePage: boolean, setIsSharePage: (enable: boolean) => void]; onEnableAffineCloud: () => void; - togglePagePublic: () => Promise; - exportHandler: (type: 'pdf' | 'html' | 'png' | 'markdown') => Promise; } const ShareMenuContent = (props: ShareMenuProps) => { @@ -73,9 +69,14 @@ const LocalShareMenu = (props: ShareMenuProps) => { const CloudShareMenu = (props: ShareMenuProps) => { const t = useAFFiNEI18N(); - - const { workspace, currentPage, useIsSharedPage } = props; - const [isSharedPage] = useIsSharedPage(workspace.id, currentPage.id); + const { + workspace: { id: workspaceId }, + currentPage, + } = props; + const { isSharedPage } = useIsSharedPage( + workspaceId, + currentPage.spaceDoc.guid + ); return ( { export const AffineSharePage = (props: ShareMenuProps) => { const { workspace: { id: workspaceId }, - currentPage: { id: pageId }, + currentPage, } = props; - const [isPublic, setIsPublic] = props.useIsSharedPage(workspaceId, pageId); + const pageId = currentPage.id; const [showDisable, setShowDisable] = useState(false); + const { + isSharedPage, + enableShare, + changeShare, + currentShareMode, + disableShare, + } = useIsSharedPage(workspaceId, currentPage.spaceDoc.guid); + const currentPageMode = useAtomValue(currentModeAtom); + + const defaultMode = useMemo(() => { + if (isSharedPage) { + // if it's a shared page, use the share mode + return currentShareMode; + } + // default to current page mode + return currentPageMode; + }, [currentPageMode, currentShareMode, isSharedPage]); + const [mode, setMode] = useState(defaultMode); + const { sharingUrl, onClickCopyLink } = useSharingUrl({ workspaceId, pageId, @@ -75,16 +101,26 @@ export const AffineSharePage = (props: ShareMenuProps) => { const t = useAFFiNEI18N(); const onClickCreateLink = useCallback(() => { - setIsPublic(true); - }, [setIsPublic]); + enableShare(mode); + }, [enableShare, mode]); const onDisablePublic = useCallback(() => { - setIsPublic(false); + disableShare(); toast('Successfully disabled', { portal: document.body, }); setShowDisable(false); - }, [setIsPublic]); + }, [disableShare]); + + const onShareModeChange = useCallback( + (value: PageMode) => { + setMode(value); + if (isSharedPage) { + changeShare(value); + } + }, + [changeShare, isSharedPage] + ); return ( <> @@ -103,10 +139,12 @@ export const AffineSharePage = (props: ShareMenuProps) => { fontSize: 'var(--affine-font-xs)', lineHeight: '20px', }} - value={isPublic ? sharingUrl : `${runtimeConfig.serverUrlPrefix}/...`} + value={ + isSharedPage ? sharingUrl : `${runtimeConfig.serverUrlPrefix}/...` + } readOnly /> - {isPublic ? ( + {isSharedPage ? ( )} - {runtimeConfig.enableEnhanceShareMode ? ( -
-
- {t['com.affine.share-menu.ShareMode']()} -
-
- {}} - > - - {t['com.affine.pageMode.page']()} - - - {t['com.affine.pageMode.edgeless']()} - - -
+
+
+ {t['com.affine.share-menu.ShareMode']()}
- ) : null} - {isPublic ? ( +
+ + + {t['com.affine.pageMode.page']()} + + + {t['com.affine.pageMode.edgeless']()} + + +
+
+ {isSharedPage ? ( <> {runtimeConfig.enableEnhanceShareMode && ( <> diff --git a/packages/frontend/component/src/components/share-menu/use-share-url.ts b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts similarity index 67% rename from packages/frontend/component/src/components/share-menu/use-share-url.ts rename to packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts index a1eaea3d66..d349235f6f 100644 --- a/packages/frontend/component/src/components/share-menu/use-share-url.ts +++ b/packages/frontend/core/src/components/affine/share-page-modal/share-menu/use-share-url.ts @@ -1,8 +1,7 @@ +import { toast } from '@affine/component'; import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useCallback, useMemo } from 'react'; -import { toast } from '../../ui/toast'; - type UrlType = 'share' | 'workspace'; type UseSharingUrl = { @@ -16,7 +15,14 @@ export const generateUrl = ({ pageId, urlType, }: UseSharingUrl) => { - return `${runtimeConfig.serverUrlPrefix}/${urlType}/${workspaceId}/${pageId}`; + // to generate a private url like https://affine.app/workspace/123/456 + // to generate a public url like https://affine.app/share/123/456 + // or https://affine.app/share/123/456?mode=edgeless + + const url = new URL( + `${runtimeConfig.serverUrlPrefix}/${urlType}/${workspaceId}/${pageId}` + ); + return url.toString(); }; export const useSharingUrl = ({ @@ -27,7 +33,7 @@ export const useSharingUrl = ({ const t = useAFFiNEI18N(); const sharingUrl = useMemo( () => generateUrl({ workspaceId, pageId, urlType }), - [urlType, workspaceId, pageId] + [workspaceId, pageId, urlType] ); const onClickCopyLink = useCallback(() => { diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx index 68d02cb88b..cdbbd3bc8b 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-header-title/index.tsx @@ -1,5 +1,4 @@ import type { AffineOfficialWorkspace } from '@affine/env/workspace'; -import { WorkspaceFlavour } from '@affine/env/workspace'; import { useBlockSuitePageMeta, usePageMetaHelper, @@ -13,6 +12,7 @@ import { useState, } from 'react'; +import type { PageMode } from '../../../atoms'; import { EditorModeSwitch } from '../block-suite-mode-switch'; import { PageMenu } from './operation-menu'; import * as styles from './styles.css'; @@ -20,6 +20,8 @@ import * as styles from './styles.css'; export interface BlockSuiteHeaderTitleProps { workspace: AffineOfficialWorkspace; pageId: string; + isPublic?: boolean; + publicMode?: PageMode; } const EditableTitle = ({ @@ -54,6 +56,8 @@ const StableTitle = ({ workspace, pageId, onRename, + isPublic, + publicMode, }: BlockSuiteHeaderTitleProps & { onRename?: () => void; }) => { @@ -64,11 +68,19 @@ const StableTitle = ({ const title = pageMeta?.title; + const handleRename = useCallback(() => { + if (!isPublic && onRename) { + onRename(); + } + }, [isPublic, onRename]); + return (
{title || 'Untitled'} - + {isPublic ? null : }
); }; @@ -139,7 +151,7 @@ const BlockSuiteTitleWithRename = (props: BlockSuiteHeaderTitleProps) => { }; export const BlockSuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => { - if (props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC) { + if (props.isPublic) { return ; } return ; diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx index 7cc1f7a348..477fa4be2c 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-mode-switch/index.tsx @@ -6,6 +6,7 @@ import { useAtomValue } from 'jotai'; import type { CSSProperties } from 'react'; import { useCallback, useEffect } from 'react'; +import type { PageMode } from '../../../atoms'; import { currentModeAtom } from '../../../atoms/mode'; import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper'; import type { BlockSuiteWorkspace } from '../../../shared'; @@ -18,6 +19,8 @@ export type EditorModeSwitchProps = { blockSuiteWorkspace: BlockSuiteWorkspace; pageId: string; style?: CSSProperties; + isPublic?: boolean; + publicMode?: PageMode; }; const TooltipContent = () => { const t = useAFFiNEI18N(); @@ -34,6 +37,8 @@ export const EditorModeSwitch = ({ style, blockSuiteWorkspace, pageId, + isPublic, + publicMode, }: EditorModeSwitchProps) => { const t = useAFFiNEI18N(); const pageMeta = useBlockSuitePageMeta(blockSuiteWorkspace).find( @@ -47,7 +52,7 @@ export const EditorModeSwitch = ({ const currentMode = useAtomValue(currentModeAtom); useEffect(() => { - if (trash) { + if (trash || isPublic) { return; } const keydown = (e: KeyboardEvent) => { @@ -64,41 +69,58 @@ export const EditorModeSwitch = ({ document.addEventListener('keydown', keydown, { capture: true }); return () => document.removeEventListener('keydown', keydown, { capture: true }); - }, [currentMode, pageId, t, togglePageMode, trash]); + }, [currentMode, isPublic, pageId, t, togglePageMode, trash]); const onSwitchToPageMode = useCallback(() => { - if (currentMode === 'page') { + if (currentMode === 'page' || isPublic) { return; } switchToPageMode(pageId); toast(t['com.affine.toastMessage.pageMode']()); - }, [currentMode, pageId, switchToPageMode, t]); + }, [currentMode, isPublic, pageId, switchToPageMode, t]); + const onSwitchToEdgelessMode = useCallback(() => { - if (currentMode === 'edgeless') { + if (currentMode === 'edgeless' || isPublic) { return; } switchToEdgelessMode(pageId); toast(t['com.affine.toastMessage.edgelessMode']()); - }, [currentMode, pageId, switchToEdgelessMode, t]); + }, [currentMode, isPublic, pageId, switchToEdgelessMode, t]); + + const shouldHide = useCallback( + (mode: PageMode) => + (trash && currentMode !== mode) || (isPublic && publicMode !== mode), + [currentMode, isPublic, publicMode, trash] + ); + + const shouldActive = useCallback( + (mode: PageMode) => (isPublic ? false : currentMode === mode), + [currentMode, isPublic] + ); return ( - }> + } + options={{ + hidden: isPublic || trash, + }} + > diff --git a/packages/frontend/core/src/components/cloud/share-header-left-item/index.tsx b/packages/frontend/core/src/components/cloud/share-header-left-item/index.tsx new file mode 100644 index 0000000000..7a674c2ff1 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-left-item/index.tsx @@ -0,0 +1,26 @@ +import { Logo1Icon } from '@blocksuite/icons'; + +import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; +import * as styles from './styles.css'; +import { PublishPageUserAvatar } from './user-avatar'; + +const ShareHeaderLeftItem = () => { + const loginStatus = useCurrentLoginStatus(); + if (loginStatus === 'authenticated') { + return ; + } + + return ( + + + + ); +}; + +export default ShareHeaderLeftItem; diff --git a/packages/frontend/core/src/components/cloud/share-header-left-item/styles.css.ts b/packages/frontend/core/src/components/cloud/share-header-left-item/styles.css.ts new file mode 100644 index 0000000000..3000c5d257 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-left-item/styles.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const iconWrapper = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '24px', + cursor: 'pointer', + color: 'var(--affine-text-primary-color)', + selectors: { + '&:visited': { + color: 'var(--affine-text-primary-color)', + }, + }, +}); diff --git a/packages/frontend/core/src/components/cloud/share-header-left-item/user-avatar.tsx b/packages/frontend/core/src/components/cloud/share-header-left-item/user-avatar.tsx new file mode 100644 index 0000000000..43558bdd03 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-left-item/user-avatar.tsx @@ -0,0 +1,45 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { SignOutIcon } from '@blocksuite/icons'; +import { Avatar } from '@toeverything/components/avatar'; +import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu'; +import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks'; +import { useMemo } from 'react'; +import { useLocation } from 'react-router-dom'; + +import { useCurrentUser } from '../../../hooks/affine/use-current-user'; +import { signOutCloud } from '../../../utils/cloud-utils'; +import * as styles from './styles.css'; + +export const PublishPageUserAvatar = () => { + const user = useCurrentUser(); + const t = useAFFiNEI18N(); + const location = useLocation(); + + const handleSignOut = useAsyncCallback(async () => { + await signOutCloud({ callbackUrl: location.pathname }); + }, [location.pathname]); + + const menuItem = useMemo(() => { + return ( + + + + } + data-testid="share-page-sign-out-option" + onClick={handleSignOut} + > + {t['com.affine.workspace.cloud.account.logout']()} + + ); + }, [handleSignOut, t]); + + return ( + +
+ +
+
+ ); +}; diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx new file mode 100644 index 0000000000..f927723365 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/authenticated-item.tsx @@ -0,0 +1,30 @@ +import { useAFFiNEI18N } from '@affine/i18n/hooks'; +import { Button } from '@toeverything/components/button'; + +import { useCurrentUser } from '../../../hooks/affine/use-current-user'; +import { useMembers } from '../../../hooks/affine/use-members'; +import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; +import type { ShareHeaderRightItemProps } from '.'; + +export const AuthenticatedItem = ({ ...props }: ShareHeaderRightItemProps) => { + const { workspaceId, pageId } = props; + const user = useCurrentUser(); + const members = useMembers(workspaceId, 0); + const isMember = members.some(m => m.id === user.id); + const t = useAFFiNEI18N(); + const { jumpToPage } = useNavigateHelper(); + + if (isMember) { + return ( + + ); + } + + return null; +}; diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx b/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx new file mode 100644 index 0000000000..564b657917 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/index.tsx @@ -0,0 +1,18 @@ +import { useCurrentLoginStatus } from '../../../hooks/affine/use-current-login-status'; +import { AuthenticatedItem } from './authenticated-item'; + +export type ShareHeaderRightItemProps = { + workspaceId: string; + pageId: string; +}; + +const ShareHeaderRightItem = ({ ...props }: ShareHeaderRightItemProps) => { + const loginStatus = useCurrentLoginStatus(); + if (loginStatus === 'authenticated') { + return ; + } + // TODO: Add TOC + return null; +}; + +export default ShareHeaderRightItem; diff --git a/packages/frontend/core/src/components/cloud/share-header-right-item/styles.css.ts b/packages/frontend/core/src/components/cloud/share-header-right-item/styles.css.ts new file mode 100644 index 0000000000..3000c5d257 --- /dev/null +++ b/packages/frontend/core/src/components/cloud/share-header-right-item/styles.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const iconWrapper = style({ + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '24px', + cursor: 'pointer', + color: 'var(--affine-text-primary-color)', + selectors: { + '&:visited': { + color: 'var(--affine-text-primary-color)', + }, + }, +}); diff --git a/packages/frontend/core/src/components/page-detail-editor.tsx b/packages/frontend/core/src/components/page-detail-editor.tsx index 084843a2bc..5cb4c47eac 100644 --- a/packages/frontend/core/src/components/page-detail-editor.tsx +++ b/packages/frontend/core/src/components/page-detail-editor.tsx @@ -31,7 +31,7 @@ import { import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import { useLocation } from 'react-router-dom'; -import { pageSettingFamily } from '../atoms'; +import { type PageMode, pageSettingFamily } from '../atoms'; import { fontStyleOptions } from '../atoms/settings'; import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper'; import { useBlockSuiteMetaHelper } from '../hooks/affine/use-block-suite-meta-helper'; @@ -50,6 +50,7 @@ export type OnLoadEditor = (page: Page, editor: EditorContainer) => () => void; export interface PageDetailEditorProps { isPublic?: boolean; + publishMode?: PageMode; workspace: Workspace; pageId: string; onLoad?: OnLoadEditor; @@ -91,6 +92,7 @@ const EditorWrapper = memo(function EditorWrapper({ pageId, onLoad, isPublic, + publishMode, }: PageDetailEditorProps) { const page = useBlockSuiteWorkspacePage(workspace, pageId); if (!page) { @@ -105,7 +107,16 @@ const EditorWrapper = memo(function EditorWrapper({ const pageSettingAtom = pageSettingFamily(pageId); const pageSetting = useAtomValue(pageSettingAtom); - const currentMode = pageSetting?.mode ?? 'page'; + + const mode = useMemo(() => { + const currentMode = pageSetting.mode; + const shareMode = publishMode || currentMode; + + if (isPublic) { + return shareMode; + } + return currentMode; + }, [isPublic, publishMode, pageSetting.mode]); const { appSettings } = useAppSettingHelper(); @@ -120,13 +131,16 @@ const EditorWrapper = memo(function EditorWrapper({ const setEditorMode = useCallback( (mode: 'page' | 'edgeless') => { + if (isPublic) { + return; + } if (mode === 'edgeless') { switchToEdgelessMode(pageId); } else { switchToPageMode(pageId); } }, - [switchToEdgelessMode, switchToPageMode, pageId] + [isPublic, switchToEdgelessMode, pageId, switchToPageMode] ); const [editor, setEditor] = useState(); @@ -191,7 +205,7 @@ const EditorWrapper = memo(function EditorWrapper({ '--affine-font-family': value, } as CSSProperties } - mode={isPublic ? 'page' : currentMode} + mode={mode} page={page} onModeChange={setEditorMode} defaultSelectedBlockId={blockId} diff --git a/packages/frontend/core/src/components/share-header.tsx b/packages/frontend/core/src/components/share-header.tsx new file mode 100644 index 0000000000..20f6c6ab64 --- /dev/null +++ b/packages/frontend/core/src/components/share-header.tsx @@ -0,0 +1,44 @@ +import type { Workspace } from '@blocksuite/store'; +import { useSetAtom } from 'jotai/react'; + +import type { PageMode } from '../atoms'; +import { appHeaderAtom, mainContainerAtom } from '../atoms/element'; +import { useWorkspace } from '../hooks/use-workspace'; +import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title'; +import ShareHeaderLeftItem from './cloud/share-header-left-item'; +import ShareHeaderRightItem from './cloud/share-header-right-item'; +import { Header } from './pure/header'; + +export function ShareHeader({ + workspace, + pageId, + publishMode, +}: { + workspace: Workspace; + pageId: string; + publishMode: PageMode; +}) { + const setAppHeader = useSetAtom(appHeaderAtom); + + const currentWorkspace = useWorkspace(workspace.id); + + return ( +
} + center={ + + } + right={ + + } + bottomBorder + /> + ); +} diff --git a/packages/frontend/core/src/components/share-page-not-found-error.css.ts b/packages/frontend/core/src/components/share-page-not-found-error.css.ts new file mode 100644 index 0000000000..bfd01d03f4 --- /dev/null +++ b/packages/frontend/core/src/components/share-page-not-found-error.css.ts @@ -0,0 +1,15 @@ +import { style } from '@vanilla-extract/css'; + +export const iconWrapper = style({ + position: 'absolute', + top: '16px', + left: '16px', + fontSize: '24px', + cursor: 'pointer', + color: 'var(--affine-text-primary-color)', + selectors: { + '&:visited': { + color: 'var(--affine-text-primary-color)', + }, + }, +}); diff --git a/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts b/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts index 5f8cba129c..49f6cc110b 100644 --- a/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts +++ b/packages/frontend/core/src/hooks/affine/use-is-shared-page.ts @@ -1,57 +1,190 @@ +import { pushNotificationAtom } from '@affine/component/notification-center'; import { - getWorkspaceSharedPagesQuery, - revokePageMutation, - sharePageMutation, + getWorkspacePublicPagesQuery, + PublicPageMode, + publishPageMutation, + revokePublicPageMutation, } from '@affine/graphql'; +import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useMutation, useQuery } from '@affine/workspace/affine/gql'; +import { useSetAtom } from 'jotai'; import { useCallback, useMemo } from 'react'; +import type { PageMode } from '../../atoms'; + +type NoParametersKeys = { + [K in keyof T]: T[K] extends () => any ? K : never; +}[keyof T]; + +type i18nKey = NoParametersKeys>; + +type NotificationKey = + | 'enableSuccessTitle' + | 'enableSuccessMessage' + | 'enableErrorTitle' + | 'enableErrorMessage' + | 'changeSuccessTitle' + | 'changeErrorTitle' + | 'changeErrorMessage' + | 'disableSuccessTitle' + | 'disableSuccessMessage' + | 'disableErrorTitle' + | 'disableErrorMessage'; + +const notificationToI18nKey: Record = { + enableSuccessTitle: + 'com.affine.share-menu.create-public-link.notification.success.title', + enableSuccessMessage: + 'com.affine.share-menu.create-public-link.notification.success.message', + enableErrorTitle: + 'com.affine.share-menu.create-public-link.notification.fail.title', + enableErrorMessage: + 'com.affine.share-menu.create-public-link.notification.fail.message', + changeSuccessTitle: + 'com.affine.share-menu.confirm-modify-mode.notification.success.title', + changeErrorTitle: + 'com.affine.share-menu.confirm-modify-mode.notification.fail.title', + changeErrorMessage: + 'com.affine.share-menu.confirm-modify-mode.notification.fail.message', + disableSuccessTitle: + 'com.affine.share-menu.disable-publish-link.notification.success.title', + disableSuccessMessage: + 'com.affine.share-menu.disable-publish-link.notification.success.message', + disableErrorTitle: + 'com.affine.share-menu.disable-publish-link.notification.fail.title', + disableErrorMessage: + 'com.affine.share-menu.disable-publish-link.notification.fail.message', +}; + export function useIsSharedPage( workspaceId: string, pageId: string -): [isSharedPage: boolean, setSharedPage: (enable: boolean) => void] { +): { + isSharedPage: boolean; + changeShare: (mode: PageMode) => void; + disableShare: () => void; + currentShareMode: PageMode; + enableShare: (mode: PageMode) => void; +} { + const t = useAFFiNEI18N(); + const pushNotification = useSetAtom(pushNotificationAtom); const { data, mutate } = useQuery({ - query: getWorkspaceSharedPagesQuery, + query: getWorkspacePublicPagesQuery, variables: { workspaceId, }, }); + const { trigger: enableSharePage } = useMutation({ - mutation: sharePageMutation, + mutation: publishPageMutation, }); const { trigger: disableSharePage } = useMutation({ - mutation: revokePageMutation, + mutation: revokePublicPageMutation, }); - return [ - useMemo( - () => data.workspace.sharedPages.some(id => id === pageId), - [data.workspace.sharedPages, pageId] - ), - useCallback( - (enable: boolean) => { - // todo: push notification - if (enable) { - enableSharePage({ - workspaceId, - pageId, - }) - .then(() => { - return mutate(); - }) - .catch(console.error); - } else { - disableSharePage({ - workspaceId, - pageId, - }) - .then(() => { - return mutate(); - }) - .catch(console.error); - } - mutate().catch(console.error); - }, - [disableSharePage, enableSharePage, mutate, pageId, workspaceId] - ), - ]; + + const [isSharedPage, currentShareMode] = useMemo(() => { + const publicPage = data?.workspace.publicPages.find( + publicPage => publicPage.id === pageId + ); + const isPageShared = !!publicPage; + + const currentShareMode: PageMode = + publicPage?.mode === PublicPageMode.Edgeless ? 'edgeless' : 'page'; + + return [isPageShared, currentShareMode]; + }, [data?.workspace.publicPages, pageId]); + + const enableShare = useCallback( + (mode: PageMode) => { + const publishMode = + mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page; + + enableSharePage({ workspaceId, pageId, mode: publishMode }) + .then(() => { + pushNotification({ + title: t[notificationToI18nKey['enableSuccessTitle']](), + message: t[notificationToI18nKey['enableSuccessMessage']](), + type: 'success', + theme: 'default', + }); + return mutate(); + }) + .catch(e => { + pushNotification({ + title: t[notificationToI18nKey['enableErrorTitle']](), + message: t[notificationToI18nKey['enableErrorMessage']](), + type: 'error', + }); + console.error(e); + }); + }, + [enableSharePage, mutate, pageId, pushNotification, t, workspaceId] + ); + + const changeShare = useCallback( + (mode: PageMode) => { + const publishMode = + mode === 'edgeless' ? PublicPageMode.Edgeless : PublicPageMode.Page; + + enableSharePage({ workspaceId, pageId, mode: publishMode }) + .then(() => { + pushNotification({ + title: t[notificationToI18nKey['changeSuccessTitle']](), + message: t[ + 'com.affine.share-menu.confirm-modify-mode.notification.success.message' + ]({ + preMode: + publishMode === PublicPageMode.Edgeless + ? PublicPageMode.Page + : PublicPageMode.Edgeless, + currentMode: publishMode, + }), + type: 'success', + theme: 'default', + }); + return mutate(); + }) + .catch(e => { + pushNotification({ + title: t[notificationToI18nKey['changeErrorTitle']](), + message: t[notificationToI18nKey['changeErrorMessage']](), + type: 'error', + }); + console.error(e); + }); + }, + [enableSharePage, mutate, pageId, pushNotification, t, workspaceId] + ); + + const disableShare = useCallback(() => { + disableSharePage({ workspaceId, pageId }) + .then(() => { + pushNotification({ + title: t[notificationToI18nKey['disableSuccessTitle']](), + message: t[notificationToI18nKey['disableSuccessMessage']](), + type: 'success', + theme: 'default', + }); + return mutate(); + }) + .catch(e => { + pushNotification({ + title: t[notificationToI18nKey['disableErrorTitle']](), + message: t[notificationToI18nKey['disableErrorMessage']](), + type: 'error', + }); + console.error(e); + }); + }, [disableSharePage, mutate, pageId, pushNotification, t, workspaceId]); + + return useMemo( + () => ({ + isSharedPage, + currentShareMode, + enableShare, + disableShare, + changeShare, + }), + [isSharedPage, currentShareMode, enableShare, disableShare, changeShare] + ); } diff --git a/packages/frontend/core/src/pages/share/detail-page.tsx b/packages/frontend/core/src/pages/share/detail-page.tsx index ab9bb26a58..07ce82d3a8 100644 --- a/packages/frontend/core/src/pages/share/detail-page.tsx +++ b/packages/frontend/core/src/pages/share/detail-page.tsx @@ -3,6 +3,7 @@ import { DebugLogger } from '@affine/debug'; import { WorkspaceFlavour } from '@affine/env/workspace'; import { getOrCreateWorkspace } from '@affine/workspace/manager'; import { downloadBinaryFromCloud } from '@affine/workspace/providers'; +import type { CloudDoc } from '@affine/workspace/providers/cloud'; import { assertExists } from '@blocksuite/global/utils'; import type { Page } from '@blocksuite/store'; import { noop } from 'foxact/noop'; @@ -18,12 +19,25 @@ import { import { applyUpdate } from 'yjs'; import { PageDetailEditor } from '../../adapters/shared'; +import type { PageMode } from '../../atoms'; import { AppContainer } from '../../components/affine/app-container'; +import { ShareHeader } from '../../components/share-header'; import { SharePageNotFoundError } from '../../components/share-page-not-found-error'; -function assertArrayBuffer(value: unknown): asserts value is ArrayBuffer { - if (!(value instanceof ArrayBuffer)) { - throw new Error('value is not ArrayBuffer'); +type LoaderData = { + page: Page; + publishMode: PageMode; +}; + +function assertDownloadResponse( + value: CloudDoc | boolean +): asserts value is CloudDoc { + if ( + !value || + !((value as CloudDoc).arrayBuffer instanceof ArrayBuffer) || + typeof (value as CloudDoc).publishMode !== 'string' + ) { + throw new Error('value is not a valid download response'); } } @@ -41,33 +55,42 @@ export const loader: LoaderFunction = async ({ params }) => { ); // download root workspace { - const buffer = await downloadBinaryFromCloud(workspaceId, workspaceId); - assertArrayBuffer(buffer); - applyUpdate(workspace.doc, new Uint8Array(buffer)); + const response = await downloadBinaryFromCloud(workspaceId, workspaceId); + assertDownloadResponse(response); + const { arrayBuffer } = response; + applyUpdate(workspace.doc, new Uint8Array(arrayBuffer)); } const page = workspace.getPage(pageId); assertExists(page, 'cannot find page'); // download page - { - const buffer = await downloadBinaryFromCloud( - workspaceId, - page.spaceDoc.guid - ); - assertArrayBuffer(buffer); - applyUpdate(page.spaceDoc, new Uint8Array(buffer)); - } + + const response = await downloadBinaryFromCloud( + workspaceId, + page.spaceDoc.guid + ); + assertDownloadResponse(response); + const { arrayBuffer, publishMode } = response; + + applyUpdate(page.spaceDoc, new Uint8Array(arrayBuffer)); + logger.info('workspace', workspace); workspace.awarenessStore.setReadonly(page, true); - return page; + return { page, publishMode }; }; export const Component = (): ReactElement => { - const page = useLoaderData() as Page; + const { page, publishMode } = useLoaderData() as LoaderData; return ( + noop, [])} diff --git a/packages/frontend/graphql/src/graphql/get-workspace-public-pages.gql b/packages/frontend/graphql/src/graphql/get-workspace-public-pages.gql new file mode 100644 index 0000000000..fcfd7fc6e7 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/get-workspace-public-pages.gql @@ -0,0 +1,8 @@ +query getWorkspacePublicPages($workspaceId: String!) { + workspace(id: $workspaceId) { + publicPages { + id + mode + } + } +} diff --git a/packages/frontend/graphql/src/graphql/get-workspace-shared-pages.gql b/packages/frontend/graphql/src/graphql/get-workspace-shared-pages.gql deleted file mode 100644 index 3d94e0a739..0000000000 --- a/packages/frontend/graphql/src/graphql/get-workspace-shared-pages.gql +++ /dev/null @@ -1,5 +0,0 @@ -query getWorkspaceSharedPages($workspaceId: String!) { - workspace(id: $workspaceId) { - sharedPages - } -} diff --git a/packages/frontend/graphql/src/graphql/index.ts b/packages/frontend/graphql/src/graphql/index.ts index 5de29e1419..4fd2c04148 100644 --- a/packages/frontend/graphql/src/graphql/index.ts +++ b/packages/frontend/graphql/src/graphql/index.ts @@ -320,15 +320,18 @@ query getWorkspacePublicById($id: String!) { }`, }; -export const getWorkspaceSharedPagesQuery = { - id: 'getWorkspaceSharedPagesQuery' as const, - operationName: 'getWorkspaceSharedPages', +export const getWorkspacePublicPagesQuery = { + id: 'getWorkspacePublicPagesQuery' as const, + operationName: 'getWorkspacePublicPages', definitionName: 'workspace', containsFile: false, query: ` -query getWorkspaceSharedPages($workspaceId: String!) { +query getWorkspacePublicPages($workspaceId: String!) { workspace(id: $workspaceId) { - sharedPages + publicPages { + id + mode + } } }`, }; @@ -428,6 +431,20 @@ query prices { }`, }; +export const publishPageMutation = { + id: 'publishPageMutation' as const, + operationName: 'publishPage', + definitionName: 'publishPage', + containsFile: false, + query: ` +mutation publishPage($workspaceId: String!, $pageId: String!, $mode: PublicPageMode = Page) { + publishPage(workspaceId: $workspaceId, pageId: $pageId, mode: $mode) { + id + mode + } +}`, +}; + export const removeAvatarMutation = { id: 'removeAvatarMutation' as const, operationName: 'removeAvatar', @@ -469,14 +486,18 @@ mutation revokeMemberPermission($workspaceId: String!, $userId: String!) { }`, }; -export const revokePageMutation = { - id: 'revokePageMutation' as const, - operationName: 'revokePage', - definitionName: 'revokePage', +export const revokePublicPageMutation = { + id: 'revokePublicPageMutation' as const, + operationName: 'revokePublicPage', + definitionName: 'revokePublicPage', containsFile: false, query: ` -mutation revokePage($workspaceId: String!, $pageId: String!) { - revokePage(workspaceId: $workspaceId, pageId: $pageId) +mutation revokePublicPage($workspaceId: String!, $pageId: String!) { + revokePublicPage(workspaceId: $workspaceId, pageId: $pageId) { + id + mode + public + } }`, }; @@ -537,17 +558,6 @@ mutation setWorkspacePublicById($id: ID!, $public: Boolean!) { }`, }; -export const sharePageMutation = { - id: 'sharePageMutation' as const, - operationName: 'sharePage', - definitionName: 'sharePage', - containsFile: false, - query: ` -mutation sharePage($workspaceId: String!, $pageId: String!) { - sharePage(workspaceId: $workspaceId, pageId: $pageId) -}`, -}; - export const signInMutation = { id: 'signInMutation' as const, operationName: 'signIn', diff --git a/packages/frontend/graphql/src/graphql/public-page.gql b/packages/frontend/graphql/src/graphql/public-page.gql new file mode 100644 index 0000000000..7074bc153a --- /dev/null +++ b/packages/frontend/graphql/src/graphql/public-page.gql @@ -0,0 +1,10 @@ +mutation publishPage( + $workspaceId: String! + $pageId: String! + $mode: PublicPageMode = Page +) { + publishPage(workspaceId: $workspaceId, pageId: $pageId, mode: $mode) { + id + mode + } +} diff --git a/packages/frontend/graphql/src/graphql/revoke-page.gql b/packages/frontend/graphql/src/graphql/revoke-page.gql deleted file mode 100644 index df8de2aba0..0000000000 --- a/packages/frontend/graphql/src/graphql/revoke-page.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation revokePage($workspaceId: String!, $pageId: String!) { - revokePage(workspaceId: $workspaceId, pageId: $pageId) -} diff --git a/packages/frontend/graphql/src/graphql/revoke-public-page.gql b/packages/frontend/graphql/src/graphql/revoke-public-page.gql new file mode 100644 index 0000000000..1515b3d501 --- /dev/null +++ b/packages/frontend/graphql/src/graphql/revoke-public-page.gql @@ -0,0 +1,7 @@ +mutation revokePublicPage($workspaceId: String!, $pageId: String!) { + revokePublicPage(workspaceId: $workspaceId, pageId: $pageId) { + id + mode + public + } +} diff --git a/packages/frontend/graphql/src/graphql/share-page.gql b/packages/frontend/graphql/src/graphql/share-page.gql deleted file mode 100644 index 26a4259412..0000000000 --- a/packages/frontend/graphql/src/graphql/share-page.gql +++ /dev/null @@ -1,3 +0,0 @@ -mutation sharePage($workspaceId: String!, $pageId: String!) { - sharePage(workspaceId: $workspaceId, pageId: $pageId) -} diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index f026ab0f8d..99b6282ca3 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -340,13 +340,20 @@ export type GetWorkspacePublicByIdQuery = { workspace: { __typename?: 'WorkspaceType'; public: boolean }; }; -export type GetWorkspaceSharedPagesQueryVariables = Exact<{ +export type GetWorkspacePublicPagesQueryVariables = Exact<{ workspaceId: Scalars['String']['input']; }>; -export type GetWorkspaceSharedPagesQuery = { +export type GetWorkspacePublicPagesQuery = { __typename?: 'Query'; - workspace: { __typename?: 'WorkspaceType'; sharedPages: Array }; + workspace: { + __typename?: 'WorkspaceType'; + publicPages: Array<{ + __typename?: 'WorkspacePage'; + id: string; + mode: PublicPageMode; + }>; + }; }; export type GetWorkspaceQueryVariables = Exact<{ @@ -422,6 +429,21 @@ export type PricesQuery = { }>; }; +export type PublishPageMutationVariables = Exact<{ + workspaceId: Scalars['String']['input']; + pageId: Scalars['String']['input']; + mode?: InputMaybe; +}>; + +export type PublishPageMutation = { + __typename?: 'Mutation'; + publishPage: { + __typename?: 'WorkspacePage'; + id: string; + mode: PublicPageMode; + }; +}; + export type RemoveAvatarMutationVariables = Exact<{ [key: string]: never }>; export type RemoveAvatarMutation = { @@ -455,14 +477,19 @@ export type RevokeMemberPermissionMutation = { revoke: boolean; }; -export type RevokePageMutationVariables = Exact<{ +export type RevokePublicPageMutationVariables = Exact<{ workspaceId: Scalars['String']['input']; pageId: Scalars['String']['input']; }>; -export type RevokePageMutation = { +export type RevokePublicPageMutation = { __typename?: 'Mutation'; - revokePage: boolean; + revokePublicPage: { + __typename?: 'WorkspacePage'; + id: string; + mode: PublicPageMode; + public: boolean; + }; }; export type SendChangeEmailMutationVariables = Exact<{ @@ -516,13 +543,6 @@ export type SetWorkspacePublicByIdMutation = { updateWorkspace: { __typename?: 'WorkspaceType'; id: string }; }; -export type SharePageMutationVariables = Exact<{ - workspaceId: Scalars['String']['input']; - pageId: Scalars['String']['input']; -}>; - -export type SharePageMutation = { __typename?: 'Mutation'; sharePage: boolean }; - export type SignInMutationVariables = Exact<{ email: Scalars['String']['input']; password: Scalars['String']['input']; @@ -683,9 +703,9 @@ export type Queries = response: GetWorkspacePublicByIdQuery; } | { - name: 'getWorkspaceSharedPagesQuery'; - variables: GetWorkspaceSharedPagesQueryVariables; - response: GetWorkspaceSharedPagesQuery; + name: 'getWorkspacePublicPagesQuery'; + variables: GetWorkspacePublicPagesQueryVariables; + response: GetWorkspacePublicPagesQuery; } | { name: 'getWorkspaceQuery'; @@ -774,6 +794,11 @@ export type Mutations = variables: LeaveWorkspaceMutationVariables; response: LeaveWorkspaceMutation; } + | { + name: 'publishPageMutation'; + variables: PublishPageMutationVariables; + response: PublishPageMutation; + } | { name: 'removeAvatarMutation'; variables: RemoveAvatarMutationVariables; @@ -790,9 +815,9 @@ export type Mutations = response: RevokeMemberPermissionMutation; } | { - name: 'revokePageMutation'; - variables: RevokePageMutationVariables; - response: RevokePageMutation; + name: 'revokePublicPageMutation'; + variables: RevokePublicPageMutationVariables; + response: RevokePublicPageMutation; } | { name: 'sendChangeEmailMutation'; @@ -819,11 +844,6 @@ export type Mutations = variables: SetWorkspacePublicByIdMutationVariables; response: SetWorkspacePublicByIdMutation; } - | { - name: 'sharePageMutation'; - variables: SharePageMutationVariables; - response: SharePageMutation; - } | { name: 'signInMutation'; variables: SignInMutationVariables; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index bc73c4fdf4..0d2e5f1cb3 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -334,6 +334,21 @@ "com.affine.share-menu.ShareViaExportDescription": "Download a static copy of your page to share with others.", "com.affine.share-menu.ShareWithLink": "Share with link", "com.affine.share-menu.ShareWithLinkDescription": "Create a link you can easily share with anyone. The visitors will open your page in the form od a document", + "com.affine.share-menu.confirm-modify-mode.title": "Modify the sharing method?", + "com.affine.share-menu.confirm-modify-mode.description": "Once modified, new public link will be created. Please share it with others again.", + "com.affine.share-menu.confirm-modify-mode.confirm-button": "Modify", + "com.affine.share-menu.confirm-modify-mode.notification.success.title": "Modified successfully", + "com.affine.share-menu.confirm-modify-mode.notification.success.message": "You have changed the public link from {{preMode}} Mode to {{currentMode}} Mode.", + "com.affine.share-menu.confirm-modify-mode.notification.fail.title": "Failed to modify", + "com.affine.share-menu.confirm-modify-mode.notification.fail.message": "Please try again later.", + "com.affine.share-menu.create-public-link.notification.success.title": "Public link created", + "com.affine.share-menu.create-public-link.notification.success.message": "You can share this document with link.", + "com.affine.share-menu.create-public-link.notification.fail.title": "Failed to create public link", + "com.affine.share-menu.create-public-link.notification.fail.message": "Please try again later.", + "com.affine.share-menu.disable-publish-link.notification.success.title": "Public link disabled", + "com.affine.share-menu.disable-publish-link.notification.success.message": "This page is no longer shared publicly.", + "com.affine.share-menu.disable-publish-link.notification.fail.title": "Failed to disable public link", + "com.affine.share-menu.disable-publish-link.notification.fail.message": "Please try again later.", "com.affine.shortcutsTitle.edgeless": "Edgeless", "com.affine.shortcutsTitle.general": "General", "com.affine.shortcutsTitle.markdownSyntax": "Markdown Syntax", diff --git a/packages/frontend/workspace/src/providers/cloud/index.ts b/packages/frontend/workspace/src/providers/cloud/index.ts index b374451d5e..a5dd71c4b6 100644 --- a/packages/frontend/workspace/src/providers/cloud/index.ts +++ b/packages/frontend/workspace/src/providers/cloud/index.ts @@ -10,10 +10,17 @@ const logger = new DebugLogger('affine:cloud'); const hashMap = new Map(); +type DocPublishMode = 'edgeless' | 'page'; + +export type CloudDoc = { + arrayBuffer: ArrayBuffer; + publishMode: DocPublishMode; +}; + export async function downloadBinaryFromCloud( rootGuid: string, pageGuid: string -): Promise { +): Promise { if (hashMap.has(`${rootGuid}/${pageGuid}`)) { return true; } @@ -25,17 +32,22 @@ export async function downloadBinaryFromCloud( } ); if (response.ok) { + const publishMode = (response.headers.get('publish-mode') || + 'page') as DocPublishMode; const arrayBuffer = await response.arrayBuffer(); hashMap.set(`${rootGuid}/${pageGuid}`, arrayBuffer); - return arrayBuffer; + + // return both arrayBuffer and publish mode + return { arrayBuffer, publishMode }; } return false; } async function downloadBinary(rootGuid: string, doc: Doc) { - const buffer = await downloadBinaryFromCloud(rootGuid, doc.guid); - if (typeof buffer !== 'boolean') { - Y.applyUpdate(doc, new Uint8Array(buffer), 'affine-cloud'); + const response = await downloadBinaryFromCloud(rootGuid, doc.guid); + if (typeof response !== 'boolean') { + const { arrayBuffer } = response; + Y.applyUpdate(doc, new Uint8Array(arrayBuffer), 'affine-cloud'); } } diff --git a/tests/affine-cloud/e2e/collaboration.spec.ts b/tests/affine-cloud/e2e/collaboration.spec.ts index a97af04e28..2be5db6571 100644 --- a/tests/affine-cloud/e2e/collaboration.spec.ts +++ b/tests/affine-cloud/e2e/collaboration.spec.ts @@ -7,6 +7,7 @@ import { loginUser, } from '@affine-test/kit/utils/cloud'; import { dropFile } from '@affine-test/kit/utils/drop-file'; +import { clickEdgelessModeButton } from '@affine-test/kit/utils/editor'; import { clickNewPageButton, getBlockSuiteEditorTitle, @@ -78,6 +79,52 @@ test.describe('collaboration', () => { } }); + test('share page with default edgeless', async ({ page, browser }) => { + await page.reload(); + await waitForEditorLoad(page); + await createLocalWorkspace( + { + name: 'test', + }, + page + ); + await enableCloudWorkspaceFromShareButton(page); + const title = getBlockSuiteEditorTitle(page); + await title.pressSequentially('TEST TITLE', { + delay: 50, + }); + await page.keyboard.press('Enter', { delay: 50 }); + await page.keyboard.type('TEST CONTENT', { delay: 50 }); + await clickEdgelessModeButton(page); + await expect(page.locator('affine-edgeless-page')).toBeVisible({ + timeout: 1000, + }); + await page.getByTestId('cloud-share-menu-button').click(); + await page.getByTestId('share-menu-create-link-button').click(); + await page.getByTestId('share-menu-copy-link-button').click(); + + // check share page is accessible + { + const context = await browser.newContext(); + const url: string = await page.evaluate(() => + navigator.clipboard.readText() + ); + const page2 = await context.newPage(); + await page2.goto(url); + await waitForEditorLoad(page2); + await expect(page.locator('affine-edgeless-page')).toBeVisible({ + timeout: 1000, + }); + expect(await page2.textContent('affine-paragraph')).toContain( + 'TEST CONTENT' + ); + const logo = page2.getByTestId('share-page-logo'); + const editButton = page2.getByTestId('share-page-edit-button'); + await expect(editButton).not.toBeVisible(); + await expect(logo).toBeVisible(); + } + }); + test('can collaborate with other user and name should display when editing', async ({ page, browser, diff --git a/tests/storybook/src/stories/share-menu.stories.tsx b/tests/storybook/src/stories/share-menu.stories.tsx index b711e0c2e4..7bafe3c318 100644 --- a/tests/storybook/src/stories/share-menu.stories.tsx +++ b/tests/storybook/src/stories/share-menu.stories.tsx @@ -1,9 +1,6 @@ import { toast } from '@affine/component'; -import { - PublicLinkDisableModal, - StyledDisableButton, -} from '@affine/component/share-menu'; -import { ShareMenu } from '@affine/component/share-menu'; +import { PublicLinkDisableModal } from '@affine/component/disable-public-link'; +import { ShareMenu } from '@affine/core/components/affine/share-page-modal/share-menu'; import type { AffineCloudWorkspace, LocalWorkspace, @@ -24,20 +21,6 @@ export default { }, } satisfies Meta; -const sharePageMap = new Map([]); -// todo: use a real hook -const useIsSharedPage = ( - _workspaceId: string, - pageId: string -): [isSharePage: boolean, setIsSharePage: (enable: boolean) => void] => { - const [isShared, setIsShared] = useState(sharePageMap.get(pageId) ?? false); - const togglePagePublic = (enable: boolean) => { - setIsShared(enable); - sharePageMap.set(pageId, enable); - }; - return [isShared, togglePagePublic]; -}; - async function initPage(page: Page) { await page.waitForLoaded(); // Add page block and surface block at root level @@ -88,11 +71,8 @@ export const Basic: StoryFn = () => { return ( ); }; @@ -119,11 +99,8 @@ export const AffineBasic: StoryFn = () => { return ( ); }; @@ -133,9 +110,7 @@ export const DisableModal: StoryFn = () => { use(promise); return ( <> - setOpen(!open)}> - Disable Public Link - +
setOpen(!open)}>Disable Public Link
{