feat: enable share menu (#1883)

Co-authored-by: JimmFly <yangjinfei001@gmail.com>
This commit is contained in:
Himself65
2023-04-13 16:22:49 -05:00
committed by GitHub
parent 32b206a137
commit 01a686dc28
48 changed files with 2666 additions and 2113 deletions

View File

@@ -123,6 +123,47 @@ jobs:
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
build-frontend-dev:
name: Build @affine/web dev
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Cache Next.js
uses: actions/cache@v3
with:
path: |
${{ github.workspace }}/apps/web/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-${{ hashFiles('**.[jt]s', '**.[jt]sx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/yarn.lock') }}-
- name: Build
run: yarn build
env:
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN }}
NEXT_PUBLIC_FIREBASE_PROJECT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECT_ID }}
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET }}
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
API_SERVER_PROFILE: local
ENABLE_DEBUG_PAGE: true
- name: Export
run: yarn export
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: next-js-dev
path: ./apps/web/out
if-no-files-found: error
storybook-test: storybook-test:
name: Storybook Test name: Storybook Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -160,7 +201,7 @@ jobs:
matrix: matrix:
shard: [1, 2, 3, 4] shard: [1, 2, 3, 4]
environment: development environment: development
needs: [build-frontend, build-storybook] needs: [build-frontend-dev, build-storybook]
services: services:
octobase: octobase:
image: ghcr.io/toeverything/cloud-self-hosted:nightly-latest image: ghcr.io/toeverything/cloud-self-hosted:nightly-latest
@@ -183,7 +224,7 @@ jobs:
- name: Download artifact - name: Download artifact
uses: actions/download-artifact@v3 uses: actions/download-artifact@v3
with: with:
name: next-js name: next-js-dev
path: ./apps/web/.next path: ./apps/web/.next
- name: Download storybook artifact - name: Download storybook artifact
@@ -213,7 +254,7 @@ jobs:
if: ${{ failure() }} if: ${{ failure() }}
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: test-results-e2e name: test-results-e2e-${{ matrix.shard }}
path: ./test-results path: ./test-results
if-no-files-found: ignore if-no-files-found: ignore

View File

@@ -73,6 +73,8 @@ jobs:
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }} NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID }}
NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }} NEXT_PUBLIC_FIREBASE_APP_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APP_ID }}
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }} NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID }}
API_SERVER_PROFILE: local
ENABLE_DEBUG_PAGE: true
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -168,7 +170,7 @@ jobs:
if: ${{ failure() }} if: ${{ failure() }}
uses: actions/upload-artifact@v2 uses: actions/upload-artifact@v2
with: with:
name: test-results-e2e name: test-results-e2e-${{ matrix.shard }}
path: ./test-results path: ./test-results
if-no-files-found: ignore if-no-files-found: ignore

File diff suppressed because it is too large Load Diff

View File

@@ -2,10 +2,10 @@
"name": "@affine/web", "name": "@affine/web",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node src/server.mjs", "dev": "next dev",
"build": "next build", "build": "next build",
"export": "next export", "export": "next export",
"start": "NODE_ENV=production node src/server.mjs", "start": "next start",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
@@ -17,18 +17,18 @@
"@affine/jotai": "workspace:*", "@affine/jotai": "workspace:*",
"@affine/templates": "workspace:*", "@affine/templates": "workspace:*",
"@affine/workspace": "workspace:*", "@affine/workspace": "workspace:*",
"@blocksuite/blocks": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/blocks": "0.0.0-20230413112150-e058f87e-nightly",
"@blocksuite/editor": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/editor": "0.0.0-20230413112150-e058f87e-nightly",
"@blocksuite/global": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/global": "0.0.0-20230413112150-e058f87e-nightly",
"@blocksuite/icons": "^2.1.9", "@blocksuite/icons": "^2.1.10",
"@blocksuite/store": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/store": "0.0.0-20230413112150-e058f87e-nightly",
"@dnd-kit/core": "^6.0.8", "@dnd-kit/core": "^6.0.8",
"@dnd-kit/sortable": "^7.0.2", "@dnd-kit/sortable": "^7.0.2",
"@emotion/cache": "^11.10.7", "@emotion/cache": "^11.10.7",
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@mui/material": "^5.11.16", "@mui/material": "^5.12.0",
"@sentry/nextjs": "^7.47.0", "@sentry/nextjs": "^7.47.0",
"@toeverything/hooks": "workspace:*", "@toeverything/hooks": "workspace:*",
"cmdk": "^0.2.0", "cmdk": "^0.2.0",
@@ -57,7 +57,7 @@
"@types/react-dom": "^18.0.11", "@types/react-dom": "^18.0.11",
"@types/webpack-env": "^1.18.0", "@types/webpack-env": "^1.18.0",
"@vanilla-extract/css": "^1.11.0", "@vanilla-extract/css": "^1.11.0",
"@vanilla-extract/next-plugin": "^2.1.1", "@vanilla-extract/next-plugin": "^2.1.2",
"dotenv": "^16.0.3", "dotenv": "^16.0.3",
"eslint": "^8.38.0", "eslint": "^8.38.0",
"eslint-config-next": "^13.3.0", "eslint-config-next": "^13.3.0",
@@ -68,6 +68,6 @@
"redux": "^4.2.1", "redux": "^4.2.1",
"swc-plugin-coverage-instrument": "=0.0.14", "swc-plugin-coverage-instrument": "=0.0.14",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"webpack": "^5.78.0" "webpack": "^5.79.0"
} }
} }

View File

@@ -10,7 +10,7 @@ const config = {
enableDebugPage: Boolean( enableDebugPage: Boolean(
process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development' process.env.ENABLE_DEBUG_PAGE ?? process.env.NODE_ENV === 'development'
), ),
enableSubpage: Boolean(process.env.ENABLE_SUBPAGE), enableSubpage: Boolean(process.env.ENABLE_SUBPAGE ?? '1'),
enableChangeLog: Boolean(process.env.ENABLE_CHANGELOG), enableChangeLog: Boolean(process.env.ENABLE_CHANGELOG ?? '1'),
}; };
export default config; export default config;

View File

@@ -122,6 +122,7 @@ export class AffineErrorBoundary extends Component<
return ( return (
<> <>
<h1>Sorry.. there was an error</h1> <h1>Sorry.. there was an error</h1>
{error.message ?? error.toString()}
</> </>
); );
} }

View File

@@ -0,0 +1,58 @@
import { MenuItem, styled } from '@affine/component';
import type { PublicLinkDisableProps } from '@affine/component/share-menu';
import { PublicLinkDisableModal } from '@affine/component/share-menu';
import { useTranslation } from '@affine/i18n';
import { ShareIcon } from '@blocksuite/icons';
import type { CommonMenuItemProps } from './types';
const StyledMenuItem = styled(MenuItem)(({ theme }) => {
return {
div: {
color: theme.palette.error.main,
svg: {
color: theme.palette.error.main,
},
},
':hover': {
div: {
color: theme.palette.error.main,
svg: {
color: theme.palette.error.main,
},
},
},
};
});
export const DisablePublicSharing = ({
onSelect,
onItemClick,
testId,
}: CommonMenuItemProps) => {
const { t } = useTranslation();
return (
<>
<StyledMenuItem
data-testid={testId}
onClick={() => {
onItemClick?.();
onSelect?.();
}}
style={{ color: 'red' }}
icon={<ShareIcon />}
>
{t('Disable Public Sharing')}
</StyledMenuItem>
</>
);
};
const DisablePublicSharingModal = ({
page,
open,
onClose,
}: PublicLinkDisableProps) => {
return <PublicLinkDisableModal page={page} open={open} onClose={onClose} />;
};
DisablePublicSharing.DisablePublicSharingModal = DisablePublicSharingModal;

View File

@@ -1,4 +1,5 @@
export * from './CopyLink'; export * from './CopyLink';
export * from './DisablePublicSharing';
export * from './Export'; export * from './Export';
export * from './MoveTo'; export * from './MoveTo';
export * from './MoveToTrash'; export * from './MoveToTrash';

View File

@@ -19,7 +19,11 @@ export const TransformWorkspaceToAffineModal: React.FC<
const user = useCurrentUser(); const user = useCurrentUser();
return ( return (
<Modal open={open} onClose={onClose} data-testid="logout-modal"> <Modal
open={open}
onClose={onClose}
data-testid="enable-affine-cloud-modal"
>
<ModalWrapper width={560} height={292}> <ModalWrapper width={560} height={292}>
<Header> <Header>
<IconButton <IconButton

View File

@@ -16,12 +16,17 @@ import {
ResetIcon, ResetIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store'; import type { PageMeta } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
import type React from 'react'; import type React from 'react';
import { useState } from 'react'; import { useState } from 'react';
import type { BlockSuiteWorkspace } from '../../../../shared'; import type { BlockSuiteWorkspace } from '../../../../shared';
import { toast } from '../../../../utils'; import { toast } from '../../../../utils';
import { MoveTo, MoveToTrash } from '../../../affine/operation-menu-items'; import {
DisablePublicSharing,
MoveTo,
MoveToTrash,
} from '../../../affine/operation-menu-items';
export type OperationCellProps = { export type OperationCellProps = {
pageMeta: PageMeta; pageMeta: PageMeta;
@@ -40,12 +45,24 @@ export const OperationCell: React.FC<OperationCellProps> = ({
onToggleFavoritePage, onToggleFavoritePage,
onToggleTrashPage, onToggleTrashPage,
}) => { }) => {
const { id, favorite } = pageMeta; const { id, favorite, isPublic } = pageMeta;
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [openDisableShared, setOpenDisableShared] = useState(false);
const page = blockSuiteWorkspace.getPage(id);
assertExists(page);
const OperationMenu = ( const OperationMenu = (
<> <>
{isPublic && (
<DisablePublicSharing
testId="disable-public-sharing"
onItemClick={() => {
setOpenDisableShared(true);
}}
/>
)}
<MenuItem <MenuItem
onClick={() => { onClick={() => {
onToggleFavoritePage(id); onToggleFavoritePage(id);
@@ -111,6 +128,13 @@ export const OperationCell: React.FC<OperationCellProps> = ({
setOpen(false); setOpen(false);
}} }}
/> />
<DisablePublicSharing.DisablePublicSharingModal
page={page}
open={openDisableShared}
onClose={() => {
setOpenDisableShared(false);
}}
/>
</> </>
); );
}; };

View File

@@ -81,7 +81,7 @@ const FavoriteTag: React.FC<FavoriteTagProps> = ({
type PageListProps = { type PageListProps = {
blockSuiteWorkspace: BlockSuiteWorkspace; blockSuiteWorkspace: BlockSuiteWorkspace;
isPublic?: boolean; isPublic?: boolean;
listType?: 'all' | 'trash' | 'favorite'; listType?: 'all' | 'trash' | 'favorite' | 'shared';
onClickPage: (pageId: string, newTab?: boolean) => void; onClickPage: (pageId: string, newTab?: boolean) => void;
}; };
@@ -92,6 +92,7 @@ const filter = {
return !parentMeta?.trash && pageMeta.trash; return !parentMeta?.trash && pageMeta.trash;
}, },
favorite: (pageMeta: PageMeta) => pageMeta.favorite && !pageMeta.trash, favorite: (pageMeta: PageMeta) => pageMeta.favorite && !pageMeta.trash,
shared: (pageMeta: PageMeta) => pageMeta.isPublic && !pageMeta.trash,
}; };
export const PageList: React.FC<PageListProps> = ({ export const PageList: React.FC<PageListProps> = ({
@@ -108,6 +109,7 @@ export const PageList: React.FC<PageListProps> = ({
const theme = useTheme(); const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.up('sm')); const matches = useMediaQuery(theme.breakpoints.up('sm'));
const isTrash = listType === 'trash'; const isTrash = listType === 'trash';
const isShared = listType === 'shared';
const record = useAtomValue(workspacePreferredModeAtom); const record = useAtomValue(workspacePreferredModeAtom);
const list = useMemo( const list = useMemo(
() => () =>
@@ -130,7 +132,11 @@ export const PageList: React.FC<PageListProps> = ({
<TableCell proportion={0.5}>{t('Title')}</TableCell> <TableCell proportion={0.5}>{t('Title')}</TableCell>
<TableCell proportion={0.2}>{t('Created')}</TableCell> <TableCell proportion={0.2}>{t('Created')}</TableCell>
<TableCell proportion={0.2}> <TableCell proportion={0.2}>
{isTrash ? t('Moved to Trash') : t('Updated')} {isTrash
? t('Moved to Trash')
: isShared
? 'Shared'
: t('Updated')}
</TableCell> </TableCell>
<TableCell proportion={0.1}></TableCell> <TableCell proportion={0.1}></TableCell>
</> </>

View File

@@ -0,0 +1,39 @@
import { displayFlex, styled, TextButton } from '@affine/component';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
import { useRouterHelper } from '../../../../hooks/use-router-helper';
export const EditPage = () => {
const router = useRouter();
const pageId = router.query.pageId as string;
const workspaceId = router.query.workspaceId as string;
const { jumpToPage } = useRouterHelper(router);
const onClickPage = useCallback(() => {
if (workspaceId && pageId) {
jumpToPage(workspaceId, pageId);
}
}, [jumpToPage, pageId, workspaceId]);
return (
<div>
<StyledEditPageButton onClick={() => onClickPage()}>
Edit Page
</StyledEditPageButton>
</div>
);
};
export default EditPage;
const StyledEditPageButton = styled(
TextButton,
{}
)(({ theme }) => {
return {
border: `1px solid ${theme.colors.primaryColor}`,
color: theme.colors.primaryColor,
width: '100%',
borderRadius: '8px',
whiteSpace: 'nowrap',
padding: '0 16px',
...displayFlex('center', 'center'),
};
});

View File

@@ -0,0 +1,116 @@
import { ShareMenu } from '@affine/component/share-menu';
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import type { Page } from '@blocksuite/store';
import { assertEquals } from '@blocksuite/store';
import { useRouter } from 'next/router';
import type React from 'react';
import { useCallback, useState } from 'react';
import { useToggleWorkspacePublish } from '../../../../hooks/affine/use-toggle-workspace-publish';
import { useOnTransformWorkspace } from '../../../../hooks/root/use-on-transform-workspace';
import { useRouterHelper } from '../../../../hooks/use-router-helper';
import { WorkspaceSubPath } from '../../../../shared';
import { Unreachable } from '../../../affine/affine-error-eoundary';
import { TransformWorkspaceToAffineModal } from '../../../affine/transform-workspace-to-affine-modal';
import type { BaseHeaderProps } from '../header';
const AffineHeaderShareMenu: React.FC<BaseHeaderProps> = props => {
// todo: these hooks should be moved to the top level
const togglePublish = useToggleWorkspacePublish(
props.workspace as AffineWorkspace
);
const helper = useRouterHelper(useRouter());
return (
<ShareMenu
workspace={props.workspace as AffineWorkspace}
currentPage={props.currentPage as Page}
onEnableAffineCloud={useCallback(async () => {
throw new Unreachable(
'Affine workspace should not enable affine cloud again'
);
}, [])}
onOpenWorkspaceSettings={useCallback(
async workspace => {
return helper.jumpToSubPath(workspace.id, WorkspaceSubPath.SETTING);
},
[helper]
)}
togglePagePublic={useCallback(async (page, isPublic) => {
page.workspace.setPageMeta(page.id, { isPublic });
}, [])}
toggleWorkspacePublish={useCallback(
async (workspace, publish) => {
assertEquals(workspace.flavour, WorkspaceFlavour.AFFINE);
assertEquals(workspace.id, props.workspace.id);
await togglePublish(publish);
},
[props.workspace.id, togglePublish]
)}
/>
);
};
const LocalHeaderShareMenu: React.FC<BaseHeaderProps> = props => {
// todo: these hooks should be moved to the top level
const onTransformWorkspace = useOnTransformWorkspace();
const helper = useRouterHelper(useRouter());
const [open, setOpen] = useState(false);
return (
<>
<ShareMenu
workspace={props.workspace as LocalWorkspace}
currentPage={props.currentPage as Page}
onEnableAffineCloud={useCallback(
async workspace => {
assertEquals(workspace.flavour, WorkspaceFlavour.LOCAL);
assertEquals(workspace.id, props.workspace.id);
setOpen(true);
},
[props.workspace.id]
)}
onOpenWorkspaceSettings={useCallback(
async workspace => {
await helper.jumpToSubPath(workspace.id, WorkspaceSubPath.SETTING);
},
[helper]
)}
togglePagePublic={useCallback(async (page, isPublic) => {
// local workspace should not have public page
throw new Error('unreachable');
}, [])}
toggleWorkspacePublish={useCallback(
async (workspace, publish) => {
assertEquals(workspace.flavour, WorkspaceFlavour.LOCAL);
assertEquals(workspace.id, props.workspace.id);
await helper.jumpToSubPath(workspace.id, WorkspaceSubPath.SETTING);
},
[helper, props.workspace.id]
)}
/>
<TransformWorkspaceToAffineModal
open={open}
onClose={() => {
setOpen(false);
}}
onConform={() => {
onTransformWorkspace(
WorkspaceFlavour.LOCAL,
WorkspaceFlavour.AFFINE,
props.workspace as LocalWorkspace
);
setOpen(false);
}}
/>
</>
);
};
export const HeaderShareMenu: React.FC<BaseHeaderProps> = props => {
if (props.workspace.flavour === WorkspaceFlavour.AFFINE) {
return <AffineHeaderShareMenu {...props} />;
} else if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return <LocalHeaderShareMenu {...props} />;
}
throw new Error('unreachable');
};

View File

@@ -0,0 +1,101 @@
import { Menu, MenuItem } from '@affine/component';
import { AffineIcon, SignOutIcon } from '@blocksuite/icons';
import type { CSSProperties } from 'react';
import { forwardRef } from 'react';
import { useCurrentUser } from '../../../../hooks/current/use-current-user';
const EditMenu = (
<MenuItem data-testid="editor-option-menu-favorite" icon={<SignOutIcon />}>
Sign Out
</MenuItem>
);
export const UserAvatar = () => {
const user = useCurrentUser();
return (
<Menu
width={276}
content={EditMenu}
placement="bottom-end"
disablePortal={true}
trigger="click"
>
{user ? (
<WorkspaceAvatar
size={24}
name={user.name}
avatar={user.avatar_url}
></WorkspaceAvatar>
) : (
<WorkspaceAvatar size={24}></WorkspaceAvatar>
)}
</Menu>
);
};
interface WorkspaceAvatarProps {
size: number;
name?: string;
avatar?: string;
style?: CSSProperties;
}
export const WorkspaceAvatar = forwardRef<HTMLDivElement, WorkspaceAvatarProps>(
function WorkspaceAvatar(props, ref) {
const size = props.size || 20;
const sizeStr = size + 'px';
return (
<>
{props.avatar ? (
<div
style={{
...props.style,
width: sizeStr,
height: sizeStr,
color: '#fff',
borderRadius: '50%',
overflow: 'hidden',
display: 'inline-block',
verticalAlign: 'middle',
}}
ref={ref}
>
<picture>
<img
style={{ width: sizeStr, height: sizeStr }}
src={props.avatar}
alt=""
referrerPolicy="no-referrer"
/>
</picture>
</div>
) : (
<div
style={{
...props.style,
width: sizeStr,
height: sizeStr,
border: '1px solid #fff',
color: '#fff',
fontSize: Math.ceil(0.5 * size) + 'px',
borderRadius: '50%',
textAlign: 'center',
lineHeight: size + 'px',
display: 'inline-block',
verticalAlign: 'middle',
}}
ref={ref}
>
{props.name ? (
props.name.substring(0, 1)
) : (
<AffineIcon fontSize={24} color={'#5438FF'} />
)}
</div>
)}
</>
);
}
);
export default UserAvatar;

View File

@@ -1,8 +1,8 @@
import { useTranslation } from '@affine/i18n'; import { useTranslation } from '@affine/i18n';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { CloseIcon } from '@blocksuite/icons'; import { CloseIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store'; import type { Page } from '@blocksuite/store';
import type { HTMLAttributes, PropsWithChildren } from 'react'; import type { FC, HTMLAttributes, PropsWithChildren } from 'react';
import type React from 'react';
import { forwardRef, useEffect, useMemo, useState } from 'react'; import { forwardRef, useEffect, useMemo, useState } from 'react';
import { import {
@@ -12,9 +12,12 @@ import {
import type { AffineOfficialWorkspace } from '../../../shared'; import type { AffineOfficialWorkspace } from '../../../shared';
import { SidebarSwitch } from '../../affine/sidebar-switch'; import { SidebarSwitch } from '../../affine/sidebar-switch';
import { EditorOptionMenu } from './header-right-items/EditorOptionMenu'; import { EditorOptionMenu } from './header-right-items/EditorOptionMenu';
import EditPage from './header-right-items/EditPage';
import { HeaderShareMenu } from './header-right-items/ShareMenu';
import SyncUser from './header-right-items/SyncUser'; import SyncUser from './header-right-items/SyncUser';
import ThemeModeSwitch from './header-right-items/theme-mode-switch'; import ThemeModeSwitch from './header-right-items/theme-mode-switch';
import TrashButtonGroup from './header-right-items/TrashButtonGroup'; import TrashButtonGroup from './header-right-items/TrashButtonGroup';
import UserAvatar from './header-right-items/UserAvatar';
import { import {
StyledBrowserWarning, StyledBrowserWarning,
StyledCloseButton, StyledCloseButton,
@@ -56,10 +59,12 @@ export const enum HeaderRightItemName {
ThemeModeSwitch = 'themeModeSwitch', ThemeModeSwitch = 'themeModeSwitch',
SyncUser = 'syncUser', SyncUser = 'syncUser',
ShareMenu = 'shareMenu', ShareMenu = 'shareMenu',
EditPage = 'editPage',
UserAvatar = 'userAvatar',
} }
type HeaderItem = { type HeaderItem = {
Component: React.FC<BaseHeaderProps>; Component: FC<BaseHeaderProps>;
// todo: public workspace should be one of the flavour // todo: public workspace should be one of the flavour
availableWhen: ( availableWhen: (
workspace: AffineOfficialWorkspace, workspace: AffineOfficialWorkspace,
@@ -70,7 +75,6 @@ type HeaderItem = {
} }
) => boolean; ) => boolean;
}; };
const HeaderRightItems: Record<HeaderRightItemName, HeaderItem> = { const HeaderRightItems: Record<HeaderRightItemName, HeaderItem> = {
[HeaderRightItemName.TrashButtonGroup]: { [HeaderRightItemName.TrashButtonGroup]: {
Component: TrashButtonGroup, Component: TrashButtonGroup,
@@ -90,18 +94,30 @@ const HeaderRightItems: Record<HeaderRightItemName, HeaderItem> = {
return currentPage?.meta.trash !== true; return currentPage?.meta.trash !== true;
}, },
}, },
[HeaderRightItemName.ShareMenu]: {
Component: HeaderShareMenu,
availableWhen: (workspace, currentPage, { isPublic, isPreview }) => {
return workspace.flavour !== WorkspaceFlavour.PUBLIC && !!currentPage;
},
},
[HeaderRightItemName.EditPage]: {
Component: EditPage,
availableWhen: (workspace, currentPage, { isPublic, isPreview }) => {
return isPublic;
},
},
[HeaderRightItemName.UserAvatar]: {
Component: UserAvatar,
availableWhen: (workspace, currentPage, { isPublic, isPreview }) => {
return isPublic;
},
},
[HeaderRightItemName.EditorOptionMenu]: { [HeaderRightItemName.EditorOptionMenu]: {
Component: EditorOptionMenu, Component: EditorOptionMenu,
availableWhen: (_, currentPage, { isPublic, isPreview }) => { availableWhen: (_, currentPage, { isPublic, isPreview }) => {
return !!currentPage && !isPublic && !isPreview; return !!currentPage && !isPublic && !isPreview;
}, },
}, },
[HeaderRightItemName.ShareMenu]: {
Component: () => null,
availableWhen: (_, currentPage, { isPublic, isPreview }) => {
return false;
},
},
}; };
export type HeaderProps = BaseHeaderProps; export type HeaderProps = BaseHeaderProps;

View File

@@ -6,6 +6,7 @@ import type { AccessTokenMessage } from '@affine/workspace/affine/login';
import { CloudWorkspaceIcon, SignOutIcon } from '@blocksuite/icons'; import { CloudWorkspaceIcon, SignOutIcon } from '@blocksuite/icons';
import type { CSSProperties } from 'react'; import type { CSSProperties } from 'react';
import type React from 'react'; import type React from 'react';
import { forwardRef } from 'react';
import { stringToColour } from '../../../utils'; import { stringToColour } from '../../../utils';
import { StyledFooter, StyledSignInButton, StyleUserInfo } from './styles'; import { StyledFooter, StyledSignInButton, StyleUserInfo } from './styles';
@@ -74,54 +75,58 @@ interface WorkspaceAvatarProps {
style?: CSSProperties; style?: CSSProperties;
} }
export const WorkspaceAvatar: React.FC<WorkspaceAvatarProps> = props => { export const WorkspaceAvatar = forwardRef<HTMLDivElement, WorkspaceAvatarProps>(
const size = props.size || 20; function WorkspaceAvatar(props, ref) {
const sizeStr = size + 'px'; const size = props.size || 20;
const sizeStr = size + 'px';
return ( return (
<> <>
{props.avatar ? ( {props.avatar ? (
<div <div
style={{ style={{
...props.style, ...props.style,
width: sizeStr, width: sizeStr,
height: sizeStr, height: sizeStr,
color: '#fff', color: '#fff',
borderRadius: '50%', borderRadius: '50%',
overflow: 'hidden', overflow: 'hidden',
display: 'inline-block', display: 'inline-block',
verticalAlign: 'middle', verticalAlign: 'middle',
}} }}
> ref={ref}
<picture> >
<img <picture>
style={{ width: sizeStr, height: sizeStr }} <img
src={props.avatar} style={{ width: sizeStr, height: sizeStr }}
alt="" src={props.avatar}
referrerPolicy="no-referrer" alt=""
/> referrerPolicy="no-referrer"
</picture> />
</div> </picture>
) : ( </div>
<div ) : (
style={{ <div
...props.style, style={{
width: sizeStr, ...props.style,
height: sizeStr, width: sizeStr,
border: '1px solid #fff', height: sizeStr,
color: '#fff', border: '1px solid #fff',
fontSize: Math.ceil(0.5 * size) + 'px', color: '#fff',
background: stringToColour(props.name || 'AFFiNE'), fontSize: Math.ceil(0.5 * size) + 'px',
borderRadius: '50%', background: stringToColour(props.name || 'AFFiNE'),
textAlign: 'center', borderRadius: '50%',
lineHeight: size + 'px', textAlign: 'center',
display: 'inline-block', lineHeight: size + 'px',
verticalAlign: 'middle', display: 'inline-block',
}} verticalAlign: 'middle',
> }}
{(props.name || 'AFFiNE').substring(0, 1)} ref={ref}
</div> >
)} {(props.name || 'AFFiNE').substring(0, 1)}
</> </div>
); )}
}; </>
);
}
);

View File

@@ -1,14 +1,17 @@
import { config } from '@affine/env'; import { config } from '@affine/env';
import { useTranslation } from '@affine/i18n'; import { useTranslation } from '@affine/i18n';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { import {
DeleteTemporarilyIcon, DeleteTemporarilyIcon,
FolderIcon, FolderIcon,
PlusIcon, PlusIcon,
SearchIcon, SearchIcon,
SettingsIcon, SettingsIcon,
ShareIcon,
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { Page, PageMeta } from '@blocksuite/store'; import type { Page, PageMeta } from '@blocksuite/store';
import type React from 'react'; import type React from 'react';
import type { UIEvent } from 'react';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { usePageMeta } from '../../../hooks/use-page-meta'; import { usePageMeta } from '../../../hooks/use-page-meta';
@@ -57,6 +60,7 @@ export type WorkSpaceSliderBarProps = {
favorite: (workspaceId: string) => string; favorite: (workspaceId: string) => string;
trash: (workspaceId: string) => string; trash: (workspaceId: string) => string;
setting: (workspaceId: string) => string; setting: (workspaceId: string) => string;
shared: (workspaceId: string) => string;
}; };
}; };
@@ -174,7 +178,7 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
<StyledScrollWrapper <StyledScrollWrapper
showTopBorder={!isScrollAtTop} showTopBorder={!isScrollAtTop}
onScroll={e => { onScroll={(e: UIEvent<HTMLDivElement>) => {
(e.target as HTMLDivElement).scrollTop === 0 (e.target as HTMLDivElement).scrollTop === 0
? setIsScrollAtTop(true) ? setIsScrollAtTop(true)
: setIsScrollAtTop(false); : setIsScrollAtTop(false);
@@ -196,6 +200,37 @@ export const WorkSpaceSliderBar: React.FC<WorkSpaceSliderBarProps> = ({
)} )}
</StyledScrollWrapper> </StyledScrollWrapper>
{currentWorkspace?.flavour === WorkspaceFlavour.AFFINE &&
currentWorkspace.public ? (
<StyledListItem>
<StyledLink
href={{
pathname:
currentWorkspaceId && paths.setting(currentWorkspaceId),
}}
>
<ShareIcon />
<span data-testid="Published-to-web">Published to web</span>
</StyledLink>
</StyledListItem>
) : (
<StyledListItem
active={
currentPath ===
(currentWorkspaceId && paths.shared(currentWorkspaceId))
}
>
<StyledLink
href={{
pathname:
currentWorkspaceId && paths.shared(currentWorkspaceId),
}}
>
<ShareIcon />
<span data-testid="shared-pages">{t('Shared Pages')}</span>
</StyledLink>
</StyledListItem>
)}
<StyledListItem <StyledListItem
active={ active={
currentPath === currentPath ===

View File

@@ -18,6 +18,7 @@ beforeAll(() => {
'/workspace/[workspaceId]/favorite', '/workspace/[workspaceId]/favorite',
'/workspace/[workspaceId]/trash', '/workspace/[workspaceId]/trash',
'/workspace/[workspaceId]/setting', '/workspace/[workspaceId]/setting',
'/workspace/[workspaceId]/shared',
]) ])
); );
}); });

View File

@@ -0,0 +1,58 @@
import { currentAffineUserAtom } from '@affine/workspace/affine/atom';
import {
getLoginStorage,
parseIdToken,
setLoginStorage,
SignMethod,
storageChangeSlot,
} from '@affine/workspace/affine/login';
import type { WorkspaceRegistry } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type';
import { useSetAtom } from 'jotai';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
import { affineAuth } from '../../plugins/affine';
import { useTransformWorkspace } from '../use-transform-workspace';
export function useOnTransformWorkspace() {
const transformWorkspace = useTransformWorkspace();
const setUser = useSetAtom(currentAffineUserAtom);
const router = useRouter();
return useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<void> => {
const needRefresh = to === WorkspaceFlavour.AFFINE && !getLoginStorage();
if (needRefresh) {
const response = await affineAuth.generateToken(SignMethod.Google);
if (response) {
setLoginStorage(response);
setUser(parseIdToken(response.token));
storageChangeSlot.emit();
}
}
const workspaceId = await transformWorkspace(from, to, workspace);
await router.replace({
pathname: `/workspace/[workspaceId]/setting`,
query: {
...router.query,
workspaceId,
},
});
window.dispatchEvent(
new CustomEvent('affine-workspace:transform', {
detail: {
from,
to,
oldId: workspace.id,
newId: workspaceId,
},
})
);
},
[router, setUser, transformWorkspace]
);
}

View File

@@ -55,7 +55,8 @@ export function useSyncRouterWithCurrentWorkspaceAndPage(router: NextRouter) {
path[3] === 'all' || path[3] === 'all' ||
path[3] === 'setting' || path[3] === 'setting' ||
path[3] === 'trash' || path[3] === 'trash' ||
path[3] === 'favorite' path[3] === 'favorite' ||
path[3] === 'shared'
) { ) {
return; return;
} }

View File

@@ -1,5 +1,5 @@
import { ListSkeleton } from '@affine/component'; import { ListSkeleton } from '@affine/component';
import { useAtomValue } from 'jotai'; import type { AffinePublicWorkspace } from '@affine/workspace/type';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
@@ -7,10 +7,6 @@ import type React from 'react';
import { lazy, Suspense } from 'react'; import { lazy, Suspense } from 'react';
import { openQuickSearchModalAtom } from '../atoms'; import { openQuickSearchModalAtom } from '../atoms';
import {
publicWorkspaceAtom,
publicWorkspaceIdAtom,
} from '../atoms/public-workspace';
import { StyledTableContainer } from '../components/blocksuite/block-suite-page-list/page-list/styles'; import { StyledTableContainer } from '../components/blocksuite/block-suite-page-list/page-list/styles';
import { useRouterTitle } from '../hooks/use-router-title'; import { useRouterTitle } from '../hooks/use-router-title';
import { MainContainer, StyledPage } from './styles'; import { MainContainer, StyledPage } from './styles';
@@ -21,8 +17,13 @@ const QuickSearchModal = lazy(() =>
})) }))
); );
export const PublicQuickSearch: React.FC = () => { type PublicQuickSearchProps = {
const publicWorkspace = useAtomValue(publicWorkspaceAtom); workspace: AffinePublicWorkspace;
};
export const PublicQuickSearch: React.FC<PublicQuickSearchProps> = ({
workspace,
}) => {
const router = useRouter(); const router = useRouter();
const [openQuickSearchModal, setOpenQuickSearchModalAtom] = useAtom( const [openQuickSearchModal, setOpenQuickSearchModalAtom] = useAtom(
openQuickSearchModalAtom openQuickSearchModalAtom
@@ -30,7 +31,7 @@ export const PublicQuickSearch: React.FC = () => {
return ( return (
<Suspense> <Suspense>
<QuickSearchModal <QuickSearchModal
blockSuiteWorkspace={publicWorkspace.blockSuiteWorkspace} blockSuiteWorkspace={workspace.blockSuiteWorkspace}
open={openQuickSearchModal} open={openQuickSearchModal}
setOpen={setOpenQuickSearchModalAtom} setOpen={setOpenQuickSearchModalAtom}
router={router} router={router}
@@ -42,7 +43,6 @@ export const PublicQuickSearch: React.FC = () => {
const PublicWorkspaceLayoutInner: React.FC<React.PropsWithChildren> = props => { const PublicWorkspaceLayoutInner: React.FC<React.PropsWithChildren> = props => {
const router = useRouter(); const router = useRouter();
const title = useRouterTitle(router); const title = useRouterTitle(router);
const workspaceId = useAtomValue(publicWorkspaceIdAtom);
return ( return (
<> <>
<Head> <Head>
@@ -52,10 +52,6 @@ const PublicWorkspaceLayoutInner: React.FC<React.PropsWithChildren> = props => {
<MainContainer className="main-container"> <MainContainer className="main-container">
{props.children} {props.children}
</MainContainer> </MainContainer>
<Suspense fallback="">
{/* `publicBlockSuiteAtom` is available only when `publicWorkspaceIdAtom` loaded */}
{workspaceId && <PublicQuickSearch />}
</Suspense>
</StyledPage> </StyledPage>
</> </>
); );

View File

@@ -16,7 +16,10 @@ import { QueryParamError } from '../../components/affine/affine-error-eoundary';
import { StyledTableContainer } from '../../components/blocksuite/block-suite-page-list/page-list/styles'; import { StyledTableContainer } from '../../components/blocksuite/block-suite-page-list/page-list/styles';
import { WorkspaceAvatar } from '../../components/pure/footer'; import { WorkspaceAvatar } from '../../components/pure/footer';
import { PageLoading } from '../../components/pure/loading'; import { PageLoading } from '../../components/pure/loading';
import { PublicWorkspaceLayout } from '../../layouts/public-workspace-layout'; import {
PublicQuickSearch,
PublicWorkspaceLayout,
} from '../../layouts/public-workspace-layout';
import type { NextPageWithLayout } from '../../shared'; import type { NextPageWithLayout } from '../../shared';
import { NavContainer, StyledBreadcrumbs } from './[workspaceId]/[pageId]'; import { NavContainer, StyledBreadcrumbs } from './[workspaceId]/[pageId]';
@@ -58,6 +61,7 @@ const ListPageInner: React.FC<{
} }
return ( return (
<> <>
<PublicQuickSearch workspace={publicWorkspace} />
<NavContainer sx={{ px: '20px' }}> <NavContainer sx={{ px: '20px' }}>
<Breadcrumbs> <Breadcrumbs>
<StyledBreadcrumbs <StyledBreadcrumbs

View File

@@ -21,7 +21,10 @@ import { WorkspaceAvatar } from '../../../components/pure/footer';
import { PageLoading } from '../../../components/pure/loading'; import { PageLoading } from '../../../components/pure/loading';
import { useReferenceLink } from '../../../hooks/affine/use-reference-link'; import { useReferenceLink } from '../../../hooks/affine/use-reference-link';
import { useRouterHelper } from '../../../hooks/use-router-helper'; import { useRouterHelper } from '../../../hooks/use-router-helper';
import { PublicWorkspaceLayout } from '../../../layouts/public-workspace-layout'; import {
PublicQuickSearch,
PublicWorkspaceLayout,
} from '../../../layouts/public-workspace-layout';
import type { NextPageWithLayout } from '../../../shared'; import type { NextPageWithLayout } from '../../../shared';
import { initPage } from '../../../utils'; import { initPage } from '../../../utils';
@@ -62,10 +65,6 @@ const PublicWorkspaceDetailPageInner: React.FC<{
} }
const router = useRouter(); const router = useRouter();
const { openPage } = useRouterHelper(router); const { openPage } = useRouterHelper(router);
useEffect(() => {
blockSuiteWorkspace.awarenessStore.setFlag('enable_block_hub', false);
}, [blockSuiteWorkspace]);
useReferenceLink({ useReferenceLink({
pageLinkClicked: useCallback( pageLinkClicked: useCallback(
({ pageId }: { pageId: string }) => { ({ pageId }: { pageId: string }) => {
@@ -81,6 +80,7 @@ const PublicWorkspaceDetailPageInner: React.FC<{
const pageTitle = blockSuiteWorkspace.meta.getPageMeta(pageId)?.title; const pageTitle = blockSuiteWorkspace.meta.getPageMeta(pageId)?.title;
return ( return (
<> <>
<PublicQuickSearch workspace={publicWorkspace} />
<PageDetailEditor <PageDetailEditor
isPublic={true} isPublic={true}
pageId={pageId} pageId={pageId}

View File

@@ -1,13 +1,6 @@
import { useTranslation } from '@affine/i18n'; import { useTranslation } from '@affine/i18n';
import { atomWithSyncStorage } from '@affine/jotai'; import { atomWithSyncStorage } from '@affine/jotai';
import { currentAffineUserAtom } from '@affine/workspace/affine/atom'; import type { SettingPanel } from '@affine/workspace/type';
import {
getLoginStorage,
parseIdToken,
setLoginStorage,
SignMethod,
} from '@affine/workspace/affine/login';
import type { SettingPanel, WorkspaceRegistry } from '@affine/workspace/type';
import { import {
settingPanel, settingPanel,
settingPanelValues, settingPanelValues,
@@ -15,7 +8,7 @@ import {
} from '@affine/workspace/type'; } from '@affine/workspace/type';
import { SettingsIcon } from '@blocksuite/icons'; import { SettingsIcon } from '@blocksuite/icons';
import { assertExists } from '@blocksuite/store'; import { assertExists } from '@blocksuite/store';
import { useAtom, useSetAtom } from 'jotai'; import { useAtom } from 'jotai';
import Head from 'next/head'; import Head from 'next/head';
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
@@ -24,12 +17,11 @@ import { Unreachable } from '../../../components/affine/affine-error-eoundary';
import { PageLoading } from '../../../components/pure/loading'; import { PageLoading } from '../../../components/pure/loading';
import { WorkspaceTitle } from '../../../components/pure/workspace-title'; import { WorkspaceTitle } from '../../../components/pure/workspace-title';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace'; import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace';
import { useSyncRouterWithCurrentWorkspace } from '../../../hooks/use-sync-router-with-current-workspace'; import { useSyncRouterWithCurrentWorkspace } from '../../../hooks/use-sync-router-with-current-workspace';
import { useTransformWorkspace } from '../../../hooks/use-transform-workspace';
import { useWorkspacesHelper } from '../../../hooks/use-workspaces'; import { useWorkspacesHelper } from '../../../hooks/use-workspaces';
import { WorkspaceLayout } from '../../../layouts'; import { WorkspaceLayout } from '../../../layouts';
import { WorkspacePlugins } from '../../../plugins'; import { WorkspacePlugins } from '../../../plugins';
import { affineAuth } from '../../../plugins/affine';
import type { NextPageWithLayout } from '../../../shared'; import type { NextPageWithLayout } from '../../../shared';
const settingPanelAtom = atomWithSyncStorage<SettingPanel>( const settingPanelAtom = atomWithSyncStorage<SettingPanel>(
@@ -107,33 +99,7 @@ const SettingPage: NextPageWithLayout = () => {
const workspaceId = currentWorkspace.id; const workspaceId = currentWorkspace.id;
return helper.deleteWorkspace(workspaceId); return helper.deleteWorkspace(workspaceId);
}, [currentWorkspace, helper]); }, [currentWorkspace, helper]);
const transformWorkspace = useTransformWorkspace(); const onTransformWorkspace = useOnTransformWorkspace();
const setUser = useSetAtom(currentAffineUserAtom);
const onTransformWorkspace = useCallback(
async <From extends WorkspaceFlavour, To extends WorkspaceFlavour>(
from: From,
to: To,
workspace: WorkspaceRegistry[From]
): Promise<void> => {
const needRefresh = to === WorkspaceFlavour.AFFINE && !getLoginStorage();
if (needRefresh) {
const response = await affineAuth.generateToken(SignMethod.Google);
if (response) {
setLoginStorage(response);
setUser(parseIdToken(response.token));
}
}
const workspaceId = await transformWorkspace(from, to, workspace);
await router.replace({
pathname: `/workspace/[workspaceId]/setting`,
query: {
...router.query,
workspaceId,
},
});
},
[router, setUser, transformWorkspace]
);
if (!router.isReady) { if (!router.isReady) {
return <PageLoading />; return <PageLoading />;
} else if (currentWorkspace === null) { } else if (currentWorkspace === null) {

View File

@@ -0,0 +1,66 @@
import { useTranslation } from '@affine/i18n';
import { ShareIcon } from '@blocksuite/icons';
import { assertExists } from '@blocksuite/store';
import Head from 'next/head';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
import PageList from '../../../components/blocksuite/block-suite-page-list/page-list';
import { PageLoading } from '../../../components/pure/loading';
import { WorkspaceTitle } from '../../../components/pure/workspace-title';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useRouterHelper } from '../../../hooks/use-router-helper';
import { useSyncRouterWithCurrentWorkspace } from '../../../hooks/use-sync-router-with-current-workspace';
import { WorkspaceLayout } from '../../../layouts';
import type { NextPageWithLayout } from '../../../shared';
const SharedPages: NextPageWithLayout = () => {
const router = useRouter();
const { jumpToPage } = useRouterHelper(router);
const [currentWorkspace] = useCurrentWorkspace();
const { t } = useTranslation();
useSyncRouterWithCurrentWorkspace(router);
const onClickPage = useCallback(
(pageId: string, newTab?: boolean) => {
assertExists(currentWorkspace);
if (newTab) {
window.open(`/workspace/${currentWorkspace?.id}/${pageId}`, '_blank');
} else {
jumpToPage(currentWorkspace.id, pageId);
}
},
[currentWorkspace, jumpToPage]
);
if (currentWorkspace === null) {
return <PageLoading />;
}
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
assertExists(blockSuiteWorkspace);
return (
<>
<Head>
<title>{t('Shared Pages')} - AFFiNE</title>
</Head>
<WorkspaceTitle
workspace={currentWorkspace}
currentPage={null}
isPreview={false}
isPublic={false}
icon={<ShareIcon />}
>
{t('Shared Pages')}
</WorkspaceTitle>
<PageList
blockSuiteWorkspace={blockSuiteWorkspace}
onClickPage={onClickPage}
listType="shared"
/>
</>
);
};
export default SharedPages;
SharedPages.getLayout = page => {
return <WorkspaceLayout>{page}</WorkspaceLayout>;
};

View File

@@ -1,33 +0,0 @@
// server.js
import { createServer } from 'http';
import next from 'next';
import { parse } from 'url';
const dev = process.env.NODE_ENV !== 'production';
const hostname = 'localhost';
const port = 8080;
// when using middleware `hostname` and `port` must be provided below
const app = next({ dev, hostname, port });
const handle = app.getRequestHandler();
app.prepare().then(() => {
createServer(async (req, res) => {
try {
// Be sure to pass `true` as the second argument to `url.parse`.
// This tells it to parse the query portion of the URL.
const parsedUrl = parse(req.url, true);
await handle(req, res, parsedUrl);
} catch (err) {
console.error('Error occurred handling', req.url, err);
res.statusCode = 500;
res.end('internal server error');
}
})
.once('error', err => {
console.error(err);
process.exit(1);
})
.listen(port, () => {
console.log(`> Ready on http://${hostname}:${port}`);
});
});

View File

@@ -25,6 +25,7 @@ export const enum WorkspaceSubPath {
FAVORITE = 'favorite', FAVORITE = 'favorite',
SETTING = 'setting', SETTING = 'setting',
TRASH = 'trash', TRASH = 'trash',
SHARED = 'shared',
} }
export const WorkspaceSubPathName = { export const WorkspaceSubPathName = {
@@ -32,6 +33,7 @@ export const WorkspaceSubPathName = {
[WorkspaceSubPath.FAVORITE]: 'Favorites', [WorkspaceSubPath.FAVORITE]: 'Favorites',
[WorkspaceSubPath.SETTING]: 'Settings', [WorkspaceSubPath.SETTING]: 'Settings',
[WorkspaceSubPath.TRASH]: 'Trash', [WorkspaceSubPath.TRASH]: 'Trash',
[WorkspaceSubPath.SHARED]: 'Shared',
} satisfies { } satisfies {
[Path in WorkspaceSubPath]: string; [Path in WorkspaceSubPath]: string;
}; };
@@ -41,6 +43,7 @@ export const pathGenerator = {
favorite: workspaceId => `/workspace/${workspaceId}/favorite`, favorite: workspaceId => `/workspace/${workspaceId}/favorite`,
trash: workspaceId => `/workspace/${workspaceId}/trash`, trash: workspaceId => `/workspace/${workspaceId}/trash`,
setting: workspaceId => `/workspace/${workspaceId}/setting`, setting: workspaceId => `/workspace/${workspaceId}/setting`,
shared: workspaceId => `/workspace/${workspaceId}/shared`,
} satisfies { } satisfies {
[Path in WorkspaceSubPath]: (workspaceId: string) => string; [Path in WorkspaceSubPath]: (workspaceId: string) => string;
}; };
@@ -50,6 +53,7 @@ export const publicPathGenerator = {
favorite: workspaceId => `/public-workspace/${workspaceId}/favorite`, favorite: workspaceId => `/public-workspace/${workspaceId}/favorite`,
trash: workspaceId => `/public-workspace/${workspaceId}/trash`, trash: workspaceId => `/public-workspace/${workspaceId}/trash`,
setting: workspaceId => `/public-workspace/${workspaceId}/setting`, setting: workspaceId => `/public-workspace/${workspaceId}/setting`,
shared: workspaceId => `/public-workspace/${workspaceId}/shared`,
} satisfies { } satisfies {
[Path in WorkspaceSubPath]: (workspaceId: string) => string; [Path in WorkspaceSubPath]: (workspaceId: string) => string;
}; };

View File

@@ -38,8 +38,8 @@
}, },
"devDependencies": { "devDependencies": {
"@affine/cli": "workspace:*", "@affine/cli": "workspace:*",
"@commitlint/cli": "^17.5.1", "@commitlint/cli": "^17.6.0",
"@commitlint/config-conventional": "^17.4.4", "@commitlint/config-conventional": "^17.6.0",
"@faker-js/faker": "^7.6.0", "@faker-js/faker": "^7.6.0",
"@istanbuljs/schema": "^0.1.3", "@istanbuljs/schema": "^0.1.3",
"@perfsee/sdk": "^1.5.2", "@perfsee/sdk": "^1.5.2",
@@ -51,8 +51,8 @@
"@typescript-eslint/parser": "^5.58.0", "@typescript-eslint/parser": "^5.58.0",
"@vanilla-extract/vite-plugin": "^3.8.0", "@vanilla-extract/vite-plugin": "^3.8.0",
"@vitejs/plugin-react": "^3.1.0", "@vitejs/plugin-react": "^3.1.0",
"@vitest/coverage-istanbul": "^0.30.0", "@vitest/coverage-istanbul": "^0.30.1",
"@vitest/ui": "^0.30.0", "@vitest/ui": "^0.30.1",
"eslint": "^8.38.0", "eslint": "^8.38.0",
"eslint-config-prettier": "^8.8.0", "eslint-config-prettier": "^8.8.0",
"eslint-plugin-import": "^2.27.5", "eslint-plugin-import": "^2.27.5",
@@ -62,7 +62,7 @@
"eslint-plugin-unused-imports": "^2.0.0", "eslint-plugin-unused-imports": "^2.0.0",
"fake-indexeddb": "4.0.1", "fake-indexeddb": "4.0.1",
"got": "^12.6.0", "got": "^12.6.0",
"happy-dom": "^9.1.9", "happy-dom": "^9.5.0",
"husky": "^8.0.3", "husky": "^8.0.3",
"lint-staged": "^13.2.1", "lint-staged": "^13.2.1",
"msw": "^1.2.1", "msw": "^1.2.1",
@@ -75,8 +75,8 @@
"typescript": "^5.0.4", "typescript": "^5.0.4",
"vite": "^4.2.1", "vite": "^4.2.1",
"vite-plugin-istanbul": "^4.0.1", "vite-plugin-istanbul": "^4.0.1",
"vite-tsconfig-paths": "^4.0.9", "vite-tsconfig-paths": "^4.2.0",
"vitest": "^0.30.0", "vitest": "^0.30.1",
"vitest-fetch-mock": "^0.2.2" "vitest-fetch-mock": "^0.2.2"
}, },
"resolutions": { "resolutions": {

View File

@@ -17,7 +17,7 @@
"@blocksuite/blocks": "0.0.0-20230409084303-221991d4-nightly", "@blocksuite/blocks": "0.0.0-20230409084303-221991d4-nightly",
"@blocksuite/editor": "0.0.0-20230409084303-221991d4-nightly", "@blocksuite/editor": "0.0.0-20230409084303-221991d4-nightly",
"@blocksuite/global": "0.0.0-20230409084303-221991d4-nightly", "@blocksuite/global": "0.0.0-20230409084303-221991d4-nightly",
"@blocksuite/icons": "2.1.7", "@blocksuite/icons": "2.1.10",
"@blocksuite/store": "0.0.0-20230409084303-221991d4-nightly" "@blocksuite/store": "0.0.0-20230409084303-221991d4-nightly"
}, },
"dependencies": { "dependencies": {
@@ -31,9 +31,9 @@
"@emotion/react": "^11.10.6", "@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0", "@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.6", "@emotion/styled": "^11.10.6",
"@mui/base": "5.0.0-alpha.124", "@mui/base": "5.0.0-alpha.125",
"@mui/icons-material": "^5.11.16", "@mui/icons-material": "^5.11.16",
"@mui/material": "^5.11.16", "@mui/material": "^5.12.0",
"@radix-ui/react-avatar": "^1.0.2", "@radix-ui/react-avatar": "^1.0.2",
"@toeverything/hooks": "workspace:*", "@toeverything/hooks": "workspace:*",
"clsx": "^1.2.1", "clsx": "^1.2.1",
@@ -48,22 +48,22 @@
"react-is": "^18.2.0" "react-is": "^18.2.0"
}, },
"devDependencies": { "devDependencies": {
"@blocksuite/blocks": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/blocks": "0.0.0-20230413112150-e058f87e-nightly",
"@blocksuite/editor": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/editor": "0.0.0-20230413112150-e058f87e-nightly",
"@blocksuite/global": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/global": "0.0.0-20230413112150-e058f87e-nightly",
"@blocksuite/icons": "^2.1.9", "@blocksuite/icons": "^2.1.10",
"@blocksuite/store": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/store": "0.0.0-20230413112150-e058f87e-nightly",
"@storybook/addon-actions": "^7.0.2", "@storybook/addon-actions": "^7.0.4",
"@storybook/addon-coverage": "^0.0.8", "@storybook/addon-coverage": "^0.0.8",
"@storybook/addon-essentials": "^7.0.2", "@storybook/addon-essentials": "^7.0.4",
"@storybook/addon-interactions": "^7.0.2", "@storybook/addon-interactions": "^7.0.4",
"@storybook/addon-links": "^7.0.2", "@storybook/addon-links": "^7.0.4",
"@storybook/addon-storysource": "^7.0.2", "@storybook/addon-storysource": "^7.0.4",
"@storybook/blocks": "^7.0.2", "@storybook/blocks": "^7.0.4",
"@storybook/builder-vite": "^7.0.2", "@storybook/builder-vite": "^7.0.4",
"@storybook/jest": "^0.1.0", "@storybook/jest": "^0.1.0",
"@storybook/react": "^7.0.2", "@storybook/react": "^7.0.4",
"@storybook/react-vite": "^7.0.2", "@storybook/react-vite": "^7.0.4",
"@storybook/test-runner": "^0.10.0", "@storybook/test-runner": "^0.10.0",
"@storybook/testing-library": "^0.1.0", "@storybook/testing-library": "^0.1.0",
"@types/react": "=18.0.31", "@types/react": "=18.0.31",
@@ -74,7 +74,7 @@
"concurrently": "^8.0.1", "concurrently": "^8.0.1",
"jest-mock": "^29.5.0", "jest-mock": "^29.5.0",
"serve": "^14.2.0", "serve": "^14.2.0",
"storybook": "^7.0.2", "storybook": "^7.0.4",
"storybook-dark-mode": "^3.0.0", "storybook-dark-mode": "^3.0.0",
"typescript": "^5.0.4", "typescript": "^5.0.4",
"vite": "^4.2.1", "vite": "^4.2.1",

View File

@@ -1,20 +1,30 @@
import { useTranslation } from '@affine/i18n';
import { ContentParser } from '@blocksuite/blocks/content-parser'; import { ContentParser } from '@blocksuite/blocks/content-parser';
import { ExportToHtmlIcon, ExportToMarkdownIcon } from '@blocksuite/icons';
import type { FC } from 'react'; import type { FC } from 'react';
import { useRef } from 'react'; import { useRef } from 'react';
import { Button } from '../..'; import { Button } from '../..';
import type { ShareMenuProps } from './index'; import {
import { actionsStyle, descriptionStyle, menuItemStyle } from './index.css'; actionsStyle,
descriptionStyle,
exportButtonStyle,
menuItemStyle,
svgStyle,
} from './index.css';
import type { ShareMenuProps } from './ShareMenu';
export const Export: FC<ShareMenuProps> = props => { export const Export: FC<ShareMenuProps> = props => {
const contentParserRef = useRef<ContentParser>(); const contentParserRef = useRef<ContentParser>();
const { t } = useTranslation();
return ( return (
<div className={menuItemStyle}> <div className={menuItemStyle}>
<div className={descriptionStyle}> <div className={descriptionStyle}>
Download a static copy of your page to share with others. {t('Export Shared Pages Description')}
</div> </div>
<div className={actionsStyle}> <div className={actionsStyle}>
<Button <Button
className={exportButtonStyle}
onClick={() => { onClick={() => {
if (!contentParserRef.current) { if (!contentParserRef.current) {
contentParserRef.current = new ContentParser(props.currentPage); contentParserRef.current = new ContentParser(props.currentPage);
@@ -22,9 +32,11 @@ export const Export: FC<ShareMenuProps> = props => {
return contentParserRef.current.onExportHtml(); return contentParserRef.current.onExportHtml();
}} }}
> >
Export to HTML <ExportToHtmlIcon className={svgStyle} />
{t('Export to HTML')}
</Button> </Button>
<Button <Button
className={exportButtonStyle}
onClick={() => { onClick={() => {
if (!contentParserRef.current) { if (!contentParserRef.current) {
contentParserRef.current = new ContentParser(props.currentPage); contentParserRef.current = new ContentParser(props.currentPage);
@@ -32,7 +44,8 @@ export const Export: FC<ShareMenuProps> = props => {
return contentParserRef.current.onExportMarkdown(); return contentParserRef.current.onExportMarkdown();
}} }}
> >
Export to Markdown <ExportToMarkdownIcon className={svgStyle} />
{t('Export to Markdown')}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,145 @@
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { ExportIcon, PublishIcon, ShareIcon } from '@blocksuite/icons';
import type { Page } from '@blocksuite/store';
import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public';
import type { FC } from 'react';
import { useRef } from 'react';
import { useCallback, useState } from 'react';
import { Menu } from '../..';
import { Export } from './Export';
import { containerStyle, indicatorContainerStyle, 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<SharePanel, FC<ShareMenuProps>> = {
SharePage: SharePage,
Export: Export,
ShareWorkspace: ShareWorkspace,
};
const tabIcons = {
SharePage: <ShareIcon />,
Export: <ExportIcon />,
ShareWorkspace: <PublishIcon />,
};
export type ShareMenuProps<
Workspace extends AffineWorkspace | LocalWorkspace =
| AffineWorkspace
| LocalWorkspace
> = {
workspace: Workspace;
currentPage: Page;
onEnableAffineCloud: (workspace: LocalWorkspace) => void;
onOpenWorkspaceSettings: (workspace: Workspace) => void;
togglePagePublic: (page: Page, isPublic: boolean) => Promise<void>;
toggleWorkspacePublish: (
workspace: Workspace,
publish: boolean
) => Promise<void>;
};
function assertInstanceOf<T, U extends T>(
obj: T,
type: new (...args: any[]) => U
): asserts obj is U {
if (!(obj instanceof type)) {
throw new Error('Object is not instance of type');
}
}
export const ShareMenu: FC<ShareMenuProps> = props => {
const [activeItem, setActiveItem] = useState<SharePanel>('SharePage');
const [isPublic] = useBlockSuiteWorkspacePageIsPublic(props.currentPage);
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement | null>(null);
const indicatorRef = useRef<HTMLDivElement | null>(null);
const startTransaction = useCallback(() => {
if (indicatorRef.current && containerRef.current) {
const indicator = indicatorRef.current;
const activeTabElement = containerRef.current.querySelector(
`[data-tab-key="${activeItem}"]`
);
assertInstanceOf(activeTabElement, HTMLElement);
requestAnimationFrame(() => {
indicator.style.left = `${activeTabElement.offsetLeft}px`;
indicator.style.width = `${activeTabElement.offsetWidth}px`;
});
}
}, [activeItem]);
const handleMenuChange = useCallback(
(selectedItem: SharePanel) => {
setActiveItem(selectedItem);
startTransaction();
},
[setActiveItem, startTransaction]
);
const ActiveComponent = MenuItems[activeItem];
interface ShareMenuProps {
activeItem: SharePanel;
onChangeTab: (selectedItem: SharePanel) => void;
}
const ShareMenu: FC<ShareMenuProps> = ({ activeItem, onChangeTab }) => {
const handleButtonClick = (itemName: SharePanel) => {
onChangeTab(itemName);
setActiveItem(itemName);
};
return (
<div className={tabStyle} ref={containerRef}>
{Object.keys(MenuItems).map(item => (
<TabItem
isActive={activeItem === item}
key={item}
data-tab-key={item}
onClick={() => handleButtonClick(item as SharePanel)}
>
{tabIcons[item as SharePanel]}
{isPublic ? (item === 'SharePage' ? 'SharedPage' : item) : item}
</TabItem>
))}
</div>
);
};
const Share = (
<>
<ShareMenu activeItem={activeItem} onChangeTab={handleMenuChange} />
<div className={indicatorContainerStyle}>
<StyledIndicator
ref={(ref: HTMLDivElement | null) => {
indicatorRef.current = ref;
startTransaction();
}}
/>
</div>
<div className={containerStyle}>
<ActiveComponent {...props} />
</div>
</>
);
return (
<Menu
content={Share}
visible={open}
placement="bottom-end"
trigger={['click']}
width={439}
disablePortal={true}
onClickAway={() => {
setOpen(false);
}}
>
<StyledShareButton
data-testid="share-menu-button"
onClick={() => {
setOpen(!open);
}}
isShared={isPublic}
>
<div>{isPublic ? 'Shared' : 'Share'}</div>
</StyledShareButton>
</Menu>
);
};

View File

@@ -3,11 +3,22 @@ import type { LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type'; import { WorkspaceFlavour } from '@affine/workspace/type';
import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public'; import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public';
import type { FC } from 'react'; import type { FC } from 'react';
import { useState } from 'react';
import { useCallback, useMemo } from 'react'; import { useCallback, useMemo } from 'react';
import { Button } from '../..'; import { PublicLinkDisableModal } from './disable-public-link';
import type { ShareMenuProps } from './index'; import {
import { buttonStyle, descriptionStyle, menuItemStyle } from './index.css'; descriptionStyle,
inputButtonRowStyle,
menuItemStyle,
} from './index.css';
import type { ShareMenuProps } from './ShareMenu';
import {
StyledButton,
StyledDisableButton,
StyledInput,
StyledLinkSpan,
} from './styles';
export const LocalSharePage: FC<ShareMenuProps> = props => { export const LocalSharePage: FC<ShareMenuProps> = props => {
return ( return (
@@ -15,17 +26,14 @@ export const LocalSharePage: FC<ShareMenuProps> = props => {
<div className={descriptionStyle}> <div className={descriptionStyle}>
Sharing page publicly requires AFFiNE Cloud service. Sharing page publicly requires AFFiNE Cloud service.
</div> </div>
<Button <StyledButton
data-testid="share-menu-enable-affine-cloud-button" data-testid="share-menu-enable-affine-cloud-button"
className={buttonStyle}
type="light"
shape="round"
onClick={() => { onClick={() => {
props.onEnableAffineCloud(props.workspace as LocalWorkspace); props.onEnableAffineCloud(props.workspace as LocalWorkspace);
}} }}
> >
Enable AFFiNE Cloud Enable AFFiNE Cloud
</Button> </StyledButton>
</div> </div>
); );
}; };
@@ -34,6 +42,7 @@ export const AffineSharePage: FC<ShareMenuProps> = props => {
const [isPublic, setIsPublic] = useBlockSuiteWorkspacePageIsPublic( const [isPublic, setIsPublic] = useBlockSuiteWorkspacePageIsPublic(
props.currentPage props.currentPage
); );
const [showDisable, setShowDisable] = useState(false);
const sharingUrl = useMemo(() => { const sharingUrl = useMemo(() => {
const env = getEnvironment(); const env = getEnvironment();
if (env.isBrowser) { if (env.isBrowser) {
@@ -48,14 +57,59 @@ export const AffineSharePage: FC<ShareMenuProps> = props => {
const onClickCopyLink = useCallback(() => { const onClickCopyLink = useCallback(() => {
navigator.clipboard.writeText(sharingUrl); navigator.clipboard.writeText(sharingUrl);
}, []); }, []);
return ( return (
<div className={menuItemStyle}> <div className={menuItemStyle}>
<div className={descriptionStyle}> <div className={descriptionStyle}>
Create a link you can easily share with anyone. Create a link you can easily share with anyone.
</div> </div>
<span>{isPublic ? sharingUrl : 'not public'}</span> <div className={inputButtonRowStyle}>
{!isPublic && <Button onClick={onClickCreateLink}>Create</Button>} <StyledInput
{isPublic && <Button onClick={onClickCopyLink}>Copy Link</Button>} type="text"
readOnly
value={isPublic ? sharingUrl : 'https://app.affine.pro/xxxx'}
/>
{!isPublic && (
<StyledButton
data-testid="affine-share-create-link"
onClick={onClickCreateLink}
>
Create
</StyledButton>
)}
{isPublic && (
<StyledButton
data-testid="affine-share-copy-link"
onClick={onClickCopyLink}
>
Copy Link
</StyledButton>
)}
</div>
<div className={descriptionStyle}>
The entire Workspace is published on the web and can be edited via
<StyledLinkSpan
onClick={() => {
props.onOpenWorkspaceSettings(props.workspace);
}}
>
Workspace Settings.
</StyledLinkSpan>
</div>
{isPublic && (
<>
<StyledDisableButton onClick={() => setShowDisable(true)}>
Disable Public Link
</StyledDisableButton>
<PublicLinkDisableModal
page={props.currentPage}
open={showDisable}
onClose={() => {
setShowDisable(false);
}}
/>
</>
)}
</div> </div>
); );
}; };

View File

@@ -2,27 +2,24 @@ import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type';
import { WorkspaceFlavour } from '@affine/workspace/type'; import { WorkspaceFlavour } from '@affine/workspace/type';
import type { FC } from 'react'; import type { FC } from 'react';
import { Button } from '../..'; import { descriptionStyle, menuItemStyle } from './index.css';
import type { ShareMenuProps } from '.'; import type { ShareMenuProps } from './ShareMenu';
import { buttonStyle, descriptionStyle, menuItemStyle } from './index.css'; import { StyledButton } from './styles';
const ShareLocalWorkspace: FC<ShareMenuProps<LocalWorkspace>> = props => { const ShareLocalWorkspace: FC<ShareMenuProps<LocalWorkspace>> = props => {
return ( return (
<div className={menuItemStyle}> <div className={menuItemStyle}>
<div className={descriptionStyle}> <div className={descriptionStyle}>
Sharing page publicly requires AFFiNE Cloud service. Invite others to join the Workspace or publish it to web.
</div> </div>
<Button <StyledButton
data-testid="share-menu-enable-affine-cloud-button" data-testid="share-menu-enable-affine-cloud-button"
className={buttonStyle}
type="light"
shape="circle"
onClick={() => { onClick={() => {
props.onEnableAffineCloud(props.workspace as LocalWorkspace); props.onOpenWorkspaceSettings(props.workspace);
}} }}
> >
Enable AFFiNE Cloud Open Workspace Settings
</Button> </StyledButton>
</div> </div>
); );
}; };
@@ -36,16 +33,14 @@ const ShareAffineWorkspace: FC<ShareMenuProps<AffineWorkspace>> = props => {
? `Current workspace has been published to the web as a public workspace.` ? `Current workspace has been published to the web as a public workspace.`
: `Invite others to join the Workspace or publish it to web`} : `Invite others to join the Workspace or publish it to web`}
</div> </div>
<Button <StyledButton
data-testid="share-menu-publish-to-web-button" data-testid="share-menu-publish-to-web-button"
onClick={() => { onClick={() => {
props.onOpenWorkspaceSettings(props.workspace); props.onOpenWorkspaceSettings(props.workspace);
}} }}
type="light"
shape="circle"
> >
Open Workspace Settings Open Workspace Settings
</Button> </StyledButton>
</div> </div>
); );
}; };

View File

@@ -0,0 +1,59 @@
import { useTranslation } from '@affine/i18n';
import type { Page } from '@blocksuite/store';
import { useBlockSuiteWorkspacePageIsPublic } from '@toeverything/hooks/use-blocksuite-workspace-page-is-public';
import { useCallback } from 'react';
import { Modal, ModalCloseButton, toast } from '../../..';
import {
StyledButton,
StyledButtonContent,
StyledDangerButton,
StyledModalHeader,
StyledModalWrapper,
StyledTextContent,
} from './style';
export type PublicLinkDisableProps = {
page: Page;
open: boolean;
onClose: () => void;
};
export const PublicLinkDisableModal = ({
page,
open,
onClose,
}: PublicLinkDisableProps) => {
const { t } = useTranslation();
const [, setIsPublic] = useBlockSuiteWorkspacePageIsPublic(page);
const handleDisable = useCallback(() => {
setIsPublic(false);
toast('Successfully disabled', {
portal: document.body,
});
onClose();
}, []);
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
<ModalCloseButton onClick={onClose} top={12} right={12} />
<StyledModalHeader>{t('Disable Public Link ?')}</StyledModalHeader>
<StyledTextContent>
{t('Disable Public Link Description')}
</StyledTextContent>
<StyledButtonContent>
<StyledButton onClick={onClose}>{t('Cancel')}</StyledButton>
<StyledDangerButton
data-testid="disable-public-link-confirm-button"
onClick={handleDisable}
style={{ marginLeft: '24px' }}
>
{t('Disable')}
</StyledDangerButton>
</StyledButtonContent>
</StyledModalWrapper>
</Modal>
);
};

View File

@@ -0,0 +1,63 @@
import { styled, TextButton } from '@affine/component';
export const StyledModalWrapper = styled('div')(({ theme }) => {
return {
position: 'relative',
padding: '0px',
width: '560px',
background: theme.colors.popoverBackground,
borderRadius: '12px',
// height: '312px',
};
});
export const StyledModalHeader = styled('div')(({ theme }) => {
return {
margin: '44px 0px 12px 0px',
width: '560px',
fontWeight: '600',
fontSize: theme.font.h6,
textAlign: 'center',
};
});
export const StyledTextContent = styled('div')(({ theme }) => {
return {
margin: 'auto',
width: '560px',
padding: '0px 84px',
fontWeight: '400',
fontSize: theme.font.base,
textAlign: 'center',
};
});
export const StyledButtonContent = styled('div')(() => {
return {
margin: '32px 0',
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
};
});
export const StyledButton = styled(TextButton)(({ theme }) => {
return {
color: theme.colors.primaryColor,
height: '32px',
background: '#F3F0FF',
border: 'none',
borderRadius: '8px',
padding: '4px 20px',
};
});
export const StyledDangerButton = styled(TextButton)(({ theme }) => {
return {
color: '#FF631F',
height: '32px',
background:
'linear-gradient(0deg, rgba(255, 99, 31, 0.1), rgba(255, 99, 31, 0.1)), #FFFFFF;',
border: 'none',
borderRadius: '8px',
padding: '4px 20px',
};
});

View File

@@ -2,22 +2,27 @@ import { style } from '@vanilla-extract/css';
export const tabStyle = style({ export const tabStyle = style({
display: 'flex', display: 'flex',
justifyContent: 'space-around', flex: '1',
width: '100%',
padding: '0 10px',
margin: '0',
justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
position: 'relative', position: 'relative',
marginTop: '4px',
marginLeft: '10px',
marginRight: '10px',
}); });
export const menuItemStyle = style({ export const menuItemStyle = style({
marginLeft: '20px', padding: '4px 18px',
marginRight: '20px', paddingBottom: '16px',
marginTop: '30px', width: '100%',
}); });
export const descriptionStyle = style({ export const descriptionStyle = style({
fontSize: '1rem', wordWrap: 'break-word',
// wordBreak: 'break-all',
fontSize: '16px',
marginTop: '16px',
marginBottom: '16px',
}); });
export const buttonStyle = style({ export const buttonStyle = style({
@@ -30,5 +35,32 @@ export const actionsStyle = style({
gap: '9px', gap: '9px',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'start', alignItems: 'flex-start',
});
export const containerStyle = style({
display: 'flex',
width: '100%',
flexDirection: 'column',
});
export const indicatorContainerStyle = style({
position: 'relative',
});
export const inputButtonRowStyle = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '16px',
});
export const exportButtonStyle = style({
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: '0',
border: 'none',
});
export const svgStyle = style({
fontSize: '20px',
marginRight: '12px',
verticalAlign: 'top',
}); });

View File

@@ -1,100 +1,2 @@
import type { AffineWorkspace, LocalWorkspace } from '@affine/workspace/type'; export * from './disable-public-link';
import { ExportIcon } from '@blocksuite/icons'; export * from './ShareMenu';
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<SharePanel, FC<ShareMenuProps>> = {
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<void>;
toggleWorkspacePublish: (
workspace: Workspace,
publish: boolean
) => Promise<void>;
};
export const ShareMenu: FC<ShareMenuProps> = props => {
const [activeItem, setActiveItem] = useState<SharePanel>('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<ShareMenuProps> = ({ activeItem, onChangeTab }) => {
const handleButtonClick = (itemName: SharePanel) => {
onChangeTab(itemName);
setActiveItem(itemName);
};
return (
<div className={tabStyle}>
{Object.keys(MenuItems).map(item => (
<TabItem
isActive={activeItem === item}
key={item}
onClick={() => handleButtonClick(item as SharePanel)}
>
{item}
</TabItem>
))}
</div>
);
};
const activeIndex = Object.keys(MenuItems).indexOf(activeItem);
const Share = (
<>
<ShareMenu activeItem={activeItem} onChangeTab={handleMenuChange} />
<StyledIndicator activeIndex={activeIndex} />
<ActiveComponent {...props} />
</>
);
return (
<Menu
content={Share}
visible={open}
width={439}
placement="bottom-end"
trigger={['click']}
disablePortal={true}
onClickAway={() => {
setOpen(false);
}}
>
<StyledShareButton
data-testid="share-menu-button"
onClick={() => {
setOpen(!open);
}}
>
<ExportIcon />
<div>Share</div>
</StyledShareButton>
</Menu>
);
};

View File

@@ -1,13 +1,20 @@
import { displayFlex, styled, TextButton } from '../..'; import { Button, displayFlex, styled, TextButton } from '../..';
export const StyledShareButton = styled(TextButton)(({ theme }) => { export const StyledShareButton = styled(TextButton, {
shouldForwardProp: (prop: string) => prop !== 'isShared',
})<{ isShared?: boolean }>(({ theme, isShared }) => {
return { return {
padding: '4px 8px', padding: '4px 8px',
marginLeft: '4px', marginLeft: '4px',
marginRight: '16px', marginRight: '16px',
border: `1px solid ${theme.colors.primaryColor}`, border: `1px solid ${
color: theme.colors.primaryColor, isShared ? theme.colors.primaryColor : theme.colors.iconColor
}`,
color: isShared ? theme.colors.primaryColor : theme.colors.iconColor,
borderRadius: '8px', borderRadius: '8px',
':hover': {
border: `1px solid ${theme.colors.primaryColor}`,
},
span: { span: {
...displayFlex('center', 'center'), ...displayFlex('center', 'center'),
}, },
@@ -26,21 +33,41 @@ export const TabItem = styled('li')<{ isActive?: boolean }>(
{ {
return { return {
...displayFlex('center', 'center'), ...displayFlex('center', 'center'),
width: 'calc(100% / 3)', flex: '1',
height: '34px', height: '30px',
color: theme.colors.textColor, color: theme.colors.textColor,
opacity: isActive ? 1 : 0.2, opacity: isActive ? 1 : 0.2,
fontWeight: '500', fontWeight: '500',
fontSize: theme.font.h6, fontSize: theme.font.base,
lineHeight: theme.font.lineHeight, lineHeight: theme.font.lineHeight,
cursor: 'pointer', cursor: 'pointer',
transition: 'all 0.15s ease', transition: 'all 0.15s ease',
padding: '0 10px',
marginBottom: '4px',
borderRadius: '4px',
position: 'relative',
':hover': {
background: theme.colors.hoverBackground,
opacity: 1,
color: isActive
? theme.colors.textColor
: theme.colors.secondaryTextColor,
svg: {
fill: isActive
? theme.colors.textColor
: theme.colors.secondaryTextColor,
},
},
svg: {
fontSize: '20px',
marginRight: '12px',
},
':after': { ':after': {
content: '""', content: '""',
position: 'absolute', position: 'absolute',
bottom: '-2px', bottom: '-6px',
left: '-2px', left: '0',
width: 'calc(100% + 4px)', width: '100%',
height: '2px', height: '2px',
background: theme.colors.textColor, background: theme.colors.textColor,
opacity: 0.2, opacity: 0.2,
@@ -49,16 +76,54 @@ export const TabItem = styled('li')<{ isActive?: boolean }>(
} }
} }
); );
export const StyledIndicator = styled('div')<{ activeIndex: number }>( export const StyledIndicator = styled('div')(({ theme }) => {
({ theme, activeIndex }) => { return {
return { height: '2px',
height: '2px', background: theme.colors.textColor,
margin: '0 10px', position: 'absolute',
background: theme.colors.textColor, left: '0',
position: 'absolute', transition: 'left .3s, width .3s',
left: `calc(${activeIndex * 100}% / 3)`, };
width: `calc(100% / 3)`, });
transition: 'left .3s, width .3s', export const StyledInput = styled('input')(({ theme }) => {
}; return {
} padding: '4px 8px',
); height: '28px',
color: theme.colors.placeHolderColor,
border: `1px solid ${theme.colors.placeHolderColor}`,
cursor: 'default',
overflow: 'hidden',
userSelect: 'text',
borderRadius: '4px',
flexGrow: 1,
marginRight: '10px',
};
});
export const StyledButton = styled(TextButton)(({ theme }) => {
return {
color: theme.colors.primaryColor,
height: '32px',
background: '#F3F0FF',
border: 'none',
borderRadius: '8px',
padding: '4px 20px',
};
});
export const StyledDisableButton = styled(Button)(() => {
return {
color: '#FF631F',
height: '32px',
border: 'none',
marginTop: '16px',
borderRadius: '8px',
padding: '0',
};
});
export const StyledLinkSpan = styled('span')(({ theme }) => {
return {
marginLeft: '4px',
color: theme.colors.primaryColor,
fontWeight: '500',
cursor: 'pointer',
};
});

View File

@@ -6,7 +6,7 @@ import type { Page } from '@blocksuite/store';
import { expect } from '@storybook/jest'; import { expect } from '@storybook/jest';
import type { StoryFn } from '@storybook/react'; import type { StoryFn } from '@storybook/react';
import { ShareMenu } from '../components/share-menu'; import { ShareMenu } from '../components/share-menu/ShareMenu';
import toast from '../ui/toast/toast'; import toast from '../ui/toast/toast';
export default { export default {

View File

@@ -4,7 +4,7 @@
"main": "./src/index.ts", "main": "./src/index.ts",
"module": "./src/index.ts", "module": "./src/index.ts",
"devDependencies": { "devDependencies": {
"@blocksuite/global": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/global": "0.0.0-20230413112150-e058f87e-nightly",
"next": "=13.2.3", "next": "=13.2.3",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",

View File

@@ -204,5 +204,11 @@
"Discover what's new!": "Discover what's new!", "Discover what's new!": "Discover what's new!",
"Navigation Path": "Navigation Path", "Navigation Path": "Navigation Path",
"View Navigation Path": "View Navigation Path", "View Navigation Path": "View Navigation Path",
"Back to Quick Search": "Back to Quick Search" "Back to Quick Search": "Back to Quick Search",
"Shared Pages": "Shared Pages",
"Disable Public Sharing": "Disable Public Sharing",
"Disable": "Disable",
"Disable Public Link ?": "Disable Public Link ?",
"Disable Public Link Description": "Disabling this public link will prevent anyone with the link from accessing this page.",
"Export Shared Pages Description": "Download a static copy of your page to share with others."
} }

View File

@@ -381,7 +381,9 @@ export function createWorkspaceApis(prefixUrl = '/') {
method: 'GET', method: 'GET',
} }
).then(r => ).then(r =>
r.ok ? r.arrayBuffer() : Promise.reject(new Error(`${r.status}`)) r.ok
? r.arrayBuffer()
: Promise.reject(new RequestError(MessageCode.noPermission))
); );
}, },
downloadWorkspace: async ( downloadWorkspace: async (

View File

@@ -25,8 +25,8 @@
"idb": "^7.1.1" "idb": "^7.1.1"
}, },
"devDependencies": { "devDependencies": {
"@blocksuite/blocks": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/blocks": "0.0.0-20230413112150-e058f87e-nightly",
"@blocksuite/store": "0.0.0-20230412041719-76e5b5b9-nightly", "@blocksuite/store": "0.0.0-20230413112150-e058f87e-nightly",
"vite": "^4.2.1", "vite": "^4.2.1",
"vite-plugin-dts": "^2.2.0", "vite-plugin-dts": "^2.2.0",
"y-indexeddb": "^9.0.10" "y-indexeddb": "^9.0.10"

View File

@@ -24,6 +24,7 @@ const config: PlaywrightTestConfig = {
browserName: browserName:
(process.env.BROWSER as PlaywrightWorkerOptions['browserName']) ?? (process.env.BROWSER as PlaywrightWorkerOptions['browserName']) ??
'chromium', 'chromium',
permissions: ['clipboard-read', 'clipboard-write'],
viewport: { width: 1440, height: 800 }, viewport: { width: 1440, height: 800 },
actionTimeout: 5 * 1000, actionTimeout: 5 * 1000,
locale: 'en-US', locale: 'en-US',
@@ -66,8 +67,9 @@ const config: PlaywrightTestConfig = {
ENABLE_DEBUG_PAGE: '1', ENABLE_DEBUG_PAGE: '1',
}, },
}, },
// Intentionally not building the web, reminds you to run it by yourself.
{ {
command: 'yarn build && yarn start -p 8080', command: 'yarn start -p 8080',
port: 8080, port: 8080,
timeout: 120 * 1000, timeout: 120 * 1000,
reuseExistingServer: !process.env.CI, reuseExistingServer: !process.env.CI,

View File

@@ -0,0 +1,65 @@
import { expect } from '@playwright/test';
import { waitMarkdownImported } from '../../libs/page-logic';
import { test } from '../../libs/playwright';
import { clickNewPageButton } from '../../libs/sidebar';
import { createFakeUser, loginUser, openHomePage } from '../../libs/utils';
import { createWorkspace } from '../../libs/workspace';
test.describe('affine single page', () => {
test('public single page', async ({ page, browser }) => {
await openHomePage(page);
const [a] = await createFakeUser();
await loginUser(page, a);
await waitMarkdownImported(page);
const name = `test-${Date.now()}`;
await createWorkspace({ name }, page);
await waitMarkdownImported(page);
await clickNewPageButton(page);
const page1Id = page.url().split('/').at(-1);
await clickNewPageButton(page);
const page2Id = page.url().split('/').at(-1);
expect(typeof page2Id).toBe('string');
expect(page1Id).not.toBe(page2Id);
const title = 'This is page 2';
await page.locator('[data-block-is-title="true"]').type(title, {
delay: 50,
});
await page.getByTestId('share-menu-button').click();
await page.getByTestId('share-menu-enable-affine-cloud-button').click();
const promise = page.evaluate(
async () =>
new Promise(resolve =>
window.addEventListener('affine-workspace:transform', resolve, {
once: true,
})
)
);
await page.getByTestId('confirm-enable-cloud-button').click();
await promise;
const newPage2Url = page.url().split('/');
newPage2Url[newPage2Url.length - 1] = page2Id as string;
await page.goto(newPage2Url.join('/'));
await page.waitForSelector('v-line');
const currentTitle = await page
.locator('[data-block-is-title="true"]')
.textContent();
expect(currentTitle).toBe(title);
await page.getByTestId('share-menu-button').click();
await page.getByTestId('affine-share-create-link').click();
await page.getByTestId('affine-share-copy-link').click();
const url = await page.evaluate(() => navigator.clipboard.readText());
expect(url.startsWith('http://localhost:8080/public-workspace/')).toBe(
true
);
await page.waitForTimeout(1000);
const context2 = await browser.newContext();
const page2 = await context2.newPage();
await page2.goto(url);
await page2.waitForSelector('v-line');
const currentTitle2 = await page2
.locator('[data-block-is-title="true"]')
.textContent();
expect(currentTitle2).toBe(title);
});
});

View File

@@ -3,11 +3,11 @@ import { expect } from '@playwright/test';
import { test } from '../libs/playwright'; import { test } from '../libs/playwright';
test.describe('Debug page broadcast', () => { test.describe('Debug page broadcast', () => {
test('should broadcast a message to all debug pages', async ({ page }) => { test('should have page0', async ({ page }) => {
await page.goto( await page.goto(
'http://localhost:8080/_debug/init-page?type=importMarkdown' 'http://localhost:8080/_debug/init-page?type=importMarkdown'
); );
await page.waitForSelector('rich-text'); await page.waitForSelector('v-line');
const pageId = await page.evaluate(async () => { const pageId = await page.evaluate(async () => {
// @ts-ignore // @ts-ignore
return globalThis.page.id; return globalThis.page.id;

View File

@@ -49,6 +49,7 @@ test.describe('AFFiNE change log', () => {
const editorRightBottomChangeLog = page.locator( const editorRightBottomChangeLog = page.locator(
'[data-testid=right-bottom-change-log-icon]' '[data-testid=right-bottom-change-log-icon]'
); );
await page.waitForTimeout(50);
expect(await editorRightBottomChangeLog.isVisible()).toEqual(true); expect(await editorRightBottomChangeLog.isVisible()).toEqual(true);
await page.getByRole('link', { name: 'All pages' }).click(); await page.getByRole('link', { name: 'All pages' }).click();
const normalRightBottomChangeLog = page.locator( const normalRightBottomChangeLog = page.locator(

1645
yarn.lock

File diff suppressed because it is too large Load Diff