diff --git a/apps/web/src/components/blocksuite/header/header.tsx b/apps/web/src/components/blocksuite/header/header.tsx index e060e20735..e0c69fcb8c 100644 --- a/apps/web/src/components/blocksuite/header/header.tsx +++ b/apps/web/src/components/blocksuite/header/header.tsx @@ -106,6 +106,7 @@ export const Header = forwardRef< }), [rightItems] )} + {/**/} diff --git a/apps/web/src/components/blocksuite/header/index.tsx b/apps/web/src/components/blocksuite/header/index.tsx index 8d287ef9b1..1fe783ae00 100644 --- a/apps/web/src/components/blocksuite/header/index.tsx +++ b/apps/web/src/components/blocksuite/header/index.tsx @@ -109,7 +109,11 @@ export const BlockSuiteEditorHeader = forwardRef< ? ['themeModeSwitch'] : isTrash ? ['trashButtonGroup'] - : ['syncUser', 'themeModeSwitch', 'editorOptionMenu'] + : [ + 'syncUser', + /* 'shareMenu', */ 'themeModeSwitch', + 'editorOptionMenu', + ] } {...props} > diff --git a/packages/component/src/components/share-menu/Export.tsx b/packages/component/src/components/share-menu/Export.tsx new file mode 100644 index 0000000000..ac855fc5c4 --- /dev/null +++ b/packages/component/src/components/share-menu/Export.tsx @@ -0,0 +1,40 @@ +import { ContentParser } from '@blocksuite/blocks/content-parser'; +import type { FC } from 'react'; +import { useRef } from 'react'; + +import { Button } from '../..'; +import type { ShareMenuProps } from './index'; +import { actionsStyle, descriptionStyle, menuItemStyle } from './index.css'; + +export const Export: FC = props => { + const contentParserRef = useRef(); + return ( +
+
+ Download a static copy of your page to share with others. +
+
+ + +
+
+ ); +}; diff --git a/packages/component/src/components/share-menu/SharePage.tsx b/packages/component/src/components/share-menu/SharePage.tsx new file mode 100644 index 0000000000..82e991518c --- /dev/null +++ b/packages/component/src/components/share-menu/SharePage.tsx @@ -0,0 +1,70 @@ +import { getEnvironment } from '@affine/env'; +import type { LocalWorkspace } from '@affine/workspace/type'; +import { WorkspaceFlavour } from '@affine/workspace/type'; +import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public'; +import type { FC } from 'react'; +import { useCallback, useMemo } from 'react'; + +import { Button } from '../..'; +import type { ShareMenuProps } from './index'; +import { buttonStyle, descriptionStyle, menuItemStyle } from './index.css'; + +export const LocalSharePage: FC = props => { + return ( +
+
+ Sharing page publicly requires AFFiNE Cloud service. +
+ +
+ ); +}; + +export const AffineSharePage: FC = props => { + const [isPublic, setIsPublic] = useBlockSuiteWorkspacePageIsPublic( + props.currentPage + ); + const sharingUrl = useMemo(() => { + const env = getEnvironment(); + if (env.isBrowser) { + return `${env.origin}/public-workspace/${props.workspace.id}/${props.currentPage.id}`; + } else { + return ''; + } + }, [props.workspace.id, props.currentPage.id]); + const onClickCreateLink = useCallback(() => { + setIsPublic(true); + }, [isPublic]); + const onClickCopyLink = useCallback(() => { + navigator.clipboard.writeText(sharingUrl); + }, []); + return ( +
+
+ Create a link you can easily share with anyone. +
+ {isPublic ? sharingUrl : 'not public'} + {!isPublic && } + {isPublic && } +
+ ); +}; + +export const SharePage: FC = props => { + if (props.workspace.flavour === WorkspaceFlavour.LOCAL) { + return ; + } else if (props.workspace.flavour === WorkspaceFlavour.AFFINE) { + return ; + } + throw new Error('Unreachable'); +}; diff --git a/packages/component/src/components/share-menu/ShareWorkspace.tsx b/packages/component/src/components/share-menu/ShareWorkspace.tsx new file mode 100644 index 0000000000..1eb480a222 --- /dev/null +++ b/packages/component/src/components/share-menu/ShareWorkspace.tsx @@ -0,0 +1,64 @@ +import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type'; +import { WorkspaceFlavour } from '@affine/workspace/type'; +import type { FC } from 'react'; + +import { Button } from '../..'; +import type { ShareMenuProps } from '.'; +import { buttonStyle, descriptionStyle, menuItemStyle } from './index.css'; + +const ShareLocalWorkspace: FC> = props => { + return ( +
+
+ Sharing page publicly requires AFFiNE Cloud service. +
+ +
+ ); +}; + +const ShareAffineWorkspace: FC> = props => { + const isPublicWorkspace = props.workspace.public; + return ( +
+
+ {isPublicWorkspace + ? `Current workspace has been published to the web as a public workspace.` + : `Invite others to join the Workspace or publish it to web`} +
+ +
+ ); +}; + +export const ShareWorkspace: FC = props => { + if (props.workspace.flavour === WorkspaceFlavour.LOCAL) { + return ( + )} /> + ); + } else if (props.workspace.flavour === WorkspaceFlavour.AFFINE) { + return ( + )} /> + ); + } + throw new Error('Unreachable'); +}; diff --git a/packages/component/src/components/share-menu/index.css.ts b/packages/component/src/components/share-menu/index.css.ts new file mode 100644 index 0000000000..d8e94b83b5 --- /dev/null +++ b/packages/component/src/components/share-menu/index.css.ts @@ -0,0 +1,34 @@ +import { style } from '@vanilla-extract/css'; + +export const tabStyle = style({ + display: 'flex', + justifyContent: 'space-around', + alignItems: 'center', + position: 'relative', + marginTop: '4px', + marginLeft: '10px', + marginRight: '10px', +}); + +export const menuItemStyle = style({ + marginLeft: '20px', + marginRight: '20px', + marginTop: '30px', +}); + +export const descriptionStyle = style({ + fontSize: '1rem', +}); + +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: 'start', +}); diff --git a/packages/component/src/components/share-menu/index.tsx b/packages/component/src/components/share-menu/index.tsx new file mode 100644 index 0000000000..63940a0529 --- /dev/null +++ b/packages/component/src/components/share-menu/index.tsx @@ -0,0 +1,100 @@ +import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type'; +import { ExportIcon } from '@blocksuite/icons'; +import type { Page } from '@blocksuite/store'; +import type { FC } from 'react'; +import { useCallback, useState } from 'react'; + +import { Menu } from '../..'; +import { Export } from './Export'; +import { tabStyle } from './index.css'; +import { SharePage } from './SharePage'; +import { ShareWorkspace } from './ShareWorkspace'; +import { StyledIndicator, StyledShareButton, TabItem } from './styles'; + +type SharePanel = 'SharePage' | 'Export' | 'ShareWorkspace'; +const MenuItems: Record> = { + SharePage: SharePage, + Export: Export, + ShareWorkspace: ShareWorkspace, +}; + +export type ShareMenuProps< + Workspace extends AffineWorkspace | LocalWorkspace = + | AffineWorkspace + | LocalWorkspace +> = { + workspace: Workspace; + currentPage: Page; + onEnableAffineCloud: (workspace: LocalWorkspace) => void; + onOpenWorkspaceSettings: (workspace: Workspace) => void; + togglePagePublic: (page: Page, publish: boolean) => Promise; + toggleWorkspacePublish: ( + workspace: Workspace, + publish: boolean + ) => Promise; +}; + +export const ShareMenu: FC = props => { + const [activeItem, setActiveItem] = useState('SharePage'); + const [open, setOpen] = useState(false); + const handleMenuChange = useCallback((selectedItem: SharePanel) => { + setActiveItem(selectedItem); + }, []); + + const ActiveComponent = MenuItems[activeItem]; + interface ShareMenuProps { + activeItem: SharePanel; + onChangeTab: (selectedItem: SharePanel) => void; + } + const ShareMenu: FC = ({ activeItem, onChangeTab }) => { + const handleButtonClick = (itemName: SharePanel) => { + onChangeTab(itemName); + setActiveItem(itemName); + }; + + return ( +
+ {Object.keys(MenuItems).map(item => ( + handleButtonClick(item as SharePanel)} + > + {item} + + ))} +
+ ); + }; + const activeIndex = Object.keys(MenuItems).indexOf(activeItem); + const Share = ( + <> + + + + + ); + return ( + { + setOpen(false); + }} + > + { + setOpen(!open); + }} + > + +
Share
+
+
+ ); +}; diff --git a/packages/component/src/components/share-menu/styles.ts b/packages/component/src/components/share-menu/styles.ts new file mode 100644 index 0000000000..ffc02614ee --- /dev/null +++ b/packages/component/src/components/share-menu/styles.ts @@ -0,0 +1,64 @@ +import { displayFlex, styled, TextButton } from '../..'; + +export const StyledShareButton = styled(TextButton)(({ theme }) => { + return { + padding: '4px 8px', + marginLeft: '4px', + marginRight: '16px', + border: `1px solid ${theme.colors.primaryColor}`, + color: theme.colors.primaryColor, + borderRadius: '8px', + span: { + ...displayFlex('center', 'center'), + }, + }; +}); + +export const StyledTabsWrapper = styled('div')(() => { + return { + ...displayFlex('space-around', 'center'), + position: 'relative', + }; +}); + +export const TabItem = styled('li')<{ isActive?: boolean }>( + ({ theme, isActive }) => { + { + return { + ...displayFlex('center', 'center'), + width: 'calc(100% / 3)', + height: '34px', + color: theme.colors.textColor, + opacity: isActive ? 1 : 0.2, + fontWeight: '500', + fontSize: theme.font.h6, + lineHeight: theme.font.lineHeight, + cursor: 'pointer', + transition: 'all 0.15s ease', + ':after': { + content: '""', + position: 'absolute', + bottom: '-2px', + left: '-2px', + width: 'calc(100% + 4px)', + height: '2px', + background: theme.colors.textColor, + opacity: 0.2, + }, + }; + } + } +); +export const StyledIndicator = styled('div')<{ activeIndex: number }>( + ({ theme, activeIndex }) => { + return { + height: '2px', + margin: '0 10px', + background: theme.colors.textColor, + position: 'absolute', + left: `calc(${activeIndex * 100}% / 3)`, + width: `calc(100% / 3)`, + transition: 'left .3s, width .3s', + }; + } +); diff --git a/packages/component/src/index.ts b/packages/component/src/index.ts index 7f19329b10..cd5d124f86 100644 --- a/packages/component/src/index.ts +++ b/packages/component/src/index.ts @@ -12,6 +12,7 @@ export * from './ui/modal'; export * from './ui/mui'; export * from './ui/popper'; export * from './ui/shared/Container'; +export * from './ui/switch'; export * from './ui/table'; export * from './ui/toast'; export * from './ui/tooltip'; diff --git a/packages/component/src/stories/ShareMenu.stories.tsx b/packages/component/src/stories/ShareMenu.stories.tsx new file mode 100644 index 0000000000..09da8aca50 --- /dev/null +++ b/packages/component/src/stories/ShareMenu.stories.tsx @@ -0,0 +1,102 @@ +import { PermissionType, WorkspaceType } from '@affine/workspace/affine/api'; +import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type'; +import { WorkspaceFlavour } from '@affine/workspace/type'; +import { createEmptyBlockSuiteWorkspace } from '@affine/workspace/utils'; +import type { Page } from '@blocksuite/store'; +import { expect } from '@storybook/jest'; +import type { StoryFn } from '@storybook/react'; + +import { ShareMenu } from '../components/share-menu'; +import toast from '../ui/toast/toast'; + +export default { + title: 'AFFiNE/ShareMenu', + component: ShareMenu, +}; + +function initPage(page: Page): void { + // Add page block and surface block at root level + const pageBlockId = page.addBlock('affine:page', { + title: new page.Text('Hello, world!'), + }); + page.addBlock('affine:surface', {}, null); + const frameId = page.addBlock('affine:frame', {}, pageBlockId); + page.addBlock( + 'affine:paragraph', + { + text: new page.Text('This is a paragraph.'), + }, + frameId + ); + page.resetHistory(); +} + +const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace('test-workspace'); + +initPage(blockSuiteWorkspace.createPage('page0')); +initPage(blockSuiteWorkspace.createPage('page1')); +initPage(blockSuiteWorkspace.createPage('page2')); + +const localWorkspace: LocalWorkspace = { + id: 'test-workspace', + flavour: WorkspaceFlavour.LOCAL, + blockSuiteWorkspace, + providers: [], +}; + +const affineWorkspace: AffineWorkspace = { + id: 'test-workspace', + flavour: WorkspaceFlavour.AFFINE, + blockSuiteWorkspace, + providers: [], + public: false, + type: WorkspaceType.Normal, + permission: PermissionType.Owner, +}; + +async function unimplemented() { + toast('work in progress'); +} + +export const Basic: StoryFn = () => { + return ( + + ); +}; + +Basic.play = async ({ canvasElement }) => { + { + const button = canvasElement.querySelector( + '[data-testid="share-menu-button"]' + ) as HTMLButtonElement; + expect(button).not.toBeNull(); + button.click(); + } + await new Promise(resolve => setTimeout(resolve, 100)); + { + const button = canvasElement.querySelector( + '[data-testid="share-menu-enable-affine-cloud-button"]' + ); + expect(button).not.toBeNull(); + } +}; + +export const AffineBasic: StoryFn = () => { + return ( + + ); +}; diff --git a/packages/component/src/stories/Switch.stories.tsx b/packages/component/src/stories/Switch.stories.tsx new file mode 100644 index 0000000000..cef366da6f --- /dev/null +++ b/packages/component/src/stories/Switch.stories.tsx @@ -0,0 +1,16 @@ +/* deepscan-disable USELESS_ARROW_FUNC_BIND */ +import type { StoryFn } from '@storybook/react'; + +import { Switch } from '..'; + +export default { + title: 'AFFiNE/Switch', + component: Switch, +}; + +export const Basic: StoryFn = () => { + return ; +}; +Basic.args = { + logoSrc: '/imgs/affine-text-logo.png', +}; diff --git a/packages/component/src/ui/button/styles.ts b/packages/component/src/ui/button/styles.ts index fef2db9875..a3c68279fc 100644 --- a/packages/component/src/ui/button/styles.ts +++ b/packages/component/src/ui/button/styles.ts @@ -117,7 +117,6 @@ export const StyledTextButton = styled('button', { // type = 'default', }) => { const { fontSize, borderRadius, padding, height } = getSize(size); - console.log('size', size, height); return { height, diff --git a/packages/component/src/ui/switch/Switch.tsx b/packages/component/src/ui/switch/Switch.tsx new file mode 100644 index 0000000000..323bad0722 --- /dev/null +++ b/packages/component/src/ui/switch/Switch.tsx @@ -0,0 +1,80 @@ +// components/Switch.tsx +import { styled } from '@affine/component'; +import { useState } from 'react'; + +const StyledLabel = styled('label')(({ theme }) => { + return { + display: 'flex', + alignItems: 'center', + gap: '10px', + cursor: 'pointer', + }; +}); +const StyledInput = styled('input')(({ theme }) => { + return { + opacity: 0, + position: 'absolute', + + '&:checked': { + '& + span': { + background: '#6880FF', + '&:before': { + transform: 'translate(28px, -50%)', + }, + }, + }, + }; +}); +const StyledSwitch = styled('span')(() => { + return { + position: 'relative', + width: '60px', + height: '28px', + background: '#b3b3b3', + borderRadius: '32px', + padding: '4px', + transition: '300ms all', + + '&:before': { + transition: '300ms all', + content: '""', + position: 'absolute', + width: '28px', + height: '28px', + borderRadius: '35px', + top: '50%', + left: '4px', + background: 'white', + transform: 'translate(-4px, -50%)', + }, + }; +}); + +type SwitchProps = { + checked?: boolean; + onChange?: (checked: boolean) => void; + children?: React.ReactNode; +}; + +export const Switch = (props: SwitchProps) => { + const { checked, onChange, children } = props; + const [isChecked, setIsChecked] = useState(checked); + + const handleChange = (event: React.ChangeEvent) => { + const newChecked = event.target.checked; + setIsChecked(newChecked); + onChange?.(newChecked); + }; + + return ( + + {children} + + + + ); +}; diff --git a/packages/component/src/ui/switch/index.ts b/packages/component/src/ui/switch/index.ts new file mode 100644 index 0000000000..1b19c1d39c --- /dev/null +++ b/packages/component/src/ui/switch/index.ts @@ -0,0 +1 @@ +export * from './Switch'; diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index 5044970442..d520c488af 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -4,7 +4,41 @@ import { z } from 'zod'; import { getUaHelper } from './ua-helper'; +export const publicRuntimeConfigSchema = z.object({ + PROJECT_NAME: z.string(), + BUILD_DATE: z.string(), + gitVersion: z.string(), + hash: z.string(), + serverAPI: z.string(), + editorVersion: z.string(), + enableIndexedDBProvider: z.boolean(), + enableBroadCastChannelProvider: z.boolean(), + prefetchWorkspace: z.boolean(), + enableDebugPage: z.boolean(), + // expose internal api to globalThis, **development only** + exposeInternal: z.boolean(), + enableSubpage: z.boolean(), + enableChangeLog: z.boolean(), +}); + +export type PublicRuntimeConfig = z.infer; + +const { publicRuntimeConfig: config } = + getConfig() ?? + ({ + publicRuntimeConfig: {}, + } as { + publicRuntimeConfig: PublicRuntimeConfig; + }); + +publicRuntimeConfigSchema.parse(config); + type BrowserBase = { + /** + * @example https://app.affine.pro + * @example http://localhost:3000 + */ + origin: string; isDesktop: boolean; isBrowser: true; isServer: false; @@ -66,7 +100,9 @@ export function getEnvironment() { } satisfies Server; } else { const uaHelper = getUaHelper(); + environment = { + origin: window.location.origin, isDesktop: window.appInfo?.electron, isBrowser: true, isServer: false, @@ -97,35 +133,6 @@ export function getEnvironment() { return environment; } -export const publicRuntimeConfigSchema = z.object({ - PROJECT_NAME: z.string(), - BUILD_DATE: z.string(), - gitVersion: z.string(), - hash: z.string(), - serverAPI: z.string(), - editorVersion: z.string(), - enableIndexedDBProvider: z.boolean(), - enableBroadCastChannelProvider: z.boolean(), - prefetchWorkspace: z.boolean(), - enableDebugPage: z.boolean(), - // expose internal api to globalThis, **development only** - exposeInternal: z.boolean(), - enableSubpage: z.boolean(), - enableChangeLog: z.boolean(), -}); - -export type PublicRuntimeConfig = z.infer; - -const { publicRuntimeConfig: config } = - getConfig() ?? - ({ - publicRuntimeConfig: {}, - } as { - publicRuntimeConfig: PublicRuntimeConfig; - }); - -publicRuntimeConfigSchema.parse(config); - function printBuildInfo() { console.group('Build info'); console.log('Project:', config.PROJECT_NAME); diff --git a/packages/hooks/src/__tests__/index.spec.ts b/packages/hooks/src/__tests__/index.spec.ts index a81ff8afd9..e7fac359ad 100644 --- a/packages/hooks/src/__tests__/index.spec.ts +++ b/packages/hooks/src/__tests__/index.spec.ts @@ -8,6 +8,7 @@ import type { Page } from '@blocksuite/store'; import { assertExists } from '@blocksuite/store'; import { Workspace as BlockSuiteWorkspace } from '@blocksuite/store'; import { renderHook } from '@testing-library/react'; +import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public'; import { useBlockSuiteWorkspacePageTitle } from '@toeverything/hooks/use-blocksuite-workspace-page-title'; import { describe, expect, test } from 'vitest'; import { beforeEach } from 'vitest'; @@ -60,3 +61,16 @@ describe('useBlockSuiteWorkspacePageTitle', () => { expect(pageTitleHook.result.current).toBe('1'); }); }); + +describe('useBlockSuiteWorkspacePageIsPublic', () => { + test('basic', async () => { + const page = blockSuiteWorkspace.getPage('page0') as Page; + expect(page).not.toBeNull(); + const hook = renderHook(() => useBlockSuiteWorkspacePageIsPublic(page)); + expect(hook.result.current[0]).toBe(false); + hook.result.current[1](true); + expect(page.meta.isPublic).toBe(true); + hook.rerender(); + expect(hook.result.current[0]).toBe(true); + }); +}); diff --git a/packages/hooks/src/use-blocksuite-workspace-page-is-public.ts b/packages/hooks/src/use-blocksuite-workspace-page-is-public.ts new file mode 100644 index 0000000000..d9de10b9f9 --- /dev/null +++ b/packages/hooks/src/use-blocksuite-workspace-page-is-public.ts @@ -0,0 +1,24 @@ +import type { Page } from '@blocksuite/store'; +import { useCallback, useEffect, useState } from 'react'; + +declare module '@blocksuite/store' { + interface PageMeta { + isPublic?: boolean; + } +} + +export function useBlockSuiteWorkspacePageIsPublic(page: Page) { + const [isPublic, set] = useState(() => page.meta.isPublic ?? false); + useEffect(() => { + page.workspace.meta.pageMetasUpdated.on(() => { + set(page.meta.isPublic ?? false); + }); + }, []); + const setIsPublic = useCallback((isPublic: boolean) => { + set(isPublic); + page.workspace.setPageMeta(page.id, { + isPublic, + }); + }, []); + return [isPublic, setIsPublic] as const; +}