milestone: publish alpha version (#637)

- document folder
- full-text search
- blob storage
- basic edgeless support

Co-authored-by: tzhangchi <terry.zhangchi@outlook.com>
Co-authored-by: QiShaoXuan <qishaoxuan777@gmail.com>
Co-authored-by: DiamondThree <diamond.shx@gmail.com>
Co-authored-by: MingLiang Wang <mingliangwang0o0@gmail.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Yifeng Wang <doodlewind@toeverything.info>
Co-authored-by: Himself65 <himself65@outlook.com>
Co-authored-by: lawvs <18554747+lawvs@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
This commit is contained in:
DarkSky
2022-12-30 21:40:15 +08:00
committed by GitHub
parent cc790dcbc2
commit 6c2c7dcd48
296 changed files with 16139 additions and 2072 deletions

View File

@@ -0,0 +1,9 @@
NEXT_PUBLIC_FIREBASE_API_KEY=
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN=
NEXT_PUBLIC_FIREBASE_PROJECT_ID=
NEXT_PUBLIC_FIREBASE_STORAGE_BUCKET=
NEXT_PUBLIC_FIREBASE_MESSAGING_SENDER_ID=
NEXT_PUBLIC_FIREBASE_APP_ID=
NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
# absolute path to the block suite directory
LOCAL_BLOCK_SUITE=

View File

@@ -1,18 +0,0 @@
// https://eslint.org/docs/latest/user-guide/configuring
// "off" or 0 - turn the rule off
// "warn" or 1 - turn the rule on as a warning (doesnt affect exit code)
// "error" or 2 - turn the rule on as an error (exit code will be 1)
/** @type { import('eslint').Linter.Config } */
module.exports = {
extends: [
'next/core-web-vitals',
'plugin:@next/next/recommended',
'plugin:prettier/recommended',
],
rules: {
'prettier/prettier': 'warn',
},
reportUnusedDisableDirectives: true,
};

View File

@@ -0,0 +1,7 @@
# @affine/app
## 1.0.0
### Major Changes
- cc72448: add changeset config

View File

@@ -1,10 +1,13 @@
// @ts-check
/* eslint @typescript-eslint/no-var-requires: "off" */
const { getGitVersion, getCommitHash } = require('./scripts/gitInfo');
const { dependencies } = require('./package.json');
const path = require('node:path');
const printer = require('./scripts/printer').printer;
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
reactStrictMode: true,
reactStrictMode: false,
swcMinify: false,
publicRuntimeConfig: {
NODE_ENV: process.env.NODE_ENV,
@@ -13,8 +16,58 @@ const nextConfig = {
CI: process.env.CI || null,
VERSION: getGitVersion(),
COMMIT_HASH: getCommitHash(),
EDITOR_VERSION: dependencies['@blocksuite/editor'],
},
webpack: config => {
config.resolve.alias['yjs'] = require.resolve('yjs');
config.module.rules.push({
test: /\.md$/i,
loader: 'raw-loader',
});
return config;
},
images: {
unoptimized: true,
},
// XXX not test yet
rewrites: async () => {
if (process.env.NODE_API_SERVER === 'ac') {
let destinationAC = 'http://100.85.73.88:12001/api/:path*';
printer.info('API request proxy to [AC Server] ' + destinationAC);
return [
{
source: '/api/:path*',
destination: destinationAC,
},
];
} else {
let destinationStandard = 'http://100.77.180.48:11001/api/:path*';
printer.info(
'API request proxy to [Standard Server] ' + destinationStandard
);
return [
{
source: '/api/:path*',
destination: destinationStandard,
},
];
}
},
basePath: process.env.BASE_PATH,
};
module.exports = nextConfig;
const baseDir = process.env.LOCAL_BLOCK_SUITE ?? '/';
const withDebugLocal = require('next-debug-local')(
{
'@blocksuite/editor': path.resolve(baseDir, 'packages', 'editor'),
'@blocksuite/blocks': path.resolve(baseDir, 'packages', 'blocks'),
'@blocksuite/store': path.resolve(baseDir, 'packages', 'store'),
},
{
enable: path.isAbsolute(process.env.LOCAL_BLOCK_SUITE ?? ''),
}
);
module.exports = withDebugLocal(nextConfig);

View File

@@ -1,17 +1,20 @@
{
"name": "@pathfinder/app",
"version": "0.1.0",
"name": "@affine/app",
"version": "0.3.1",
"scripts": {
"dev": "next dev",
"dev": "next dev -p 8080",
"dev:ac": "NODE_API_SERVER=ac next dev -p 8080",
"build": "next build",
"export": "next export",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@blocksuite/blocks": "0.3.0-alpha.4",
"@blocksuite/editor": "0.3.0-alpha.4",
"@blocksuite/store": "0.3.0-alpha.4",
"@affine/data-services": "workspace:*",
"@blocksuite/blocks": "0.3.0-20221230100352-5dfe65e",
"@blocksuite/editor": "0.3.0-20221230100352-5dfe65e",
"@blocksuite/icons": "^2.0.2",
"@blocksuite/store": "0.3.0-20221230100352-5dfe65e",
"@emotion/css": "^11.10.0",
"@emotion/react": "^11.10.4",
"@emotion/server": "^11.10.0",
@@ -21,24 +24,43 @@
"@mui/base": "^5.0.0-alpha.87",
"@mui/icons-material": "^5.10.9",
"@mui/material": "^5.8.6",
"@toeverything/pathfinder-logger": "workspace:@pathfinder/logger@*",
"@toeverything/pathfinder-logger": "workspace:@affine/logger@*",
"cmdk": "^0.1.20",
"css-spring": "^4.1.0",
"dayjs": "^1.11.7",
"i18next": "^21.9.1",
"lit": "^2.3.1",
"next": "13.0.1",
"next": "13.1.0",
"next-debug-local": "^0.1.5",
"prettier": "^2.7.1",
"quill": "^1.3.7",
"quill-cursors": "^4.0.0",
"react": "18.2.0",
"react-dom": "18.2.0"
"react-dom": "18.2.0",
"react-i18next": "^11.18.4",
"yjs": "^13.5.43"
},
"devDependencies": {
"@types/node": "18.7.18",
"@types/react": "18.0.20",
"@types/react-dom": "18.0.6",
"@types/wicg-file-system-access": "^2020.9.5",
"chalk-next": "^6.1.5",
"eslint": "8.22.0",
"eslint-config-next": "12.3.1",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-prettier": "^4.2.1",
"raw-loader": "^4.0.2",
"typescript": "4.8.3"
},
"eslintConfig": {
"extends": [
"next/core-web-vitals",
"plugin:@next/next/recommended"
],
"rules": {
"prettier/prettier": "warn"
},
"reportUnusedDisableDirectives": true
}
}

View File

@@ -145,7 +145,9 @@ select,
keygen,
legend {
color: var(--affine-text-color);
background-color: unset;
outline: 0;
border: 0;
font-size: 18px;
line-height: 1.5;
font-family: var(--affine-font-family);

View File

@@ -0,0 +1,21 @@
import { describe, test, expect } from '@jest/globals';
import { printer } from './../printer';
const chalk = require('chalk');
describe('printer', () => {
test('test debug', () => {
expect(printer.debug('test debug')).toBe(
chalk.green`debug` + chalk.white(' - test debug')
);
});
test('test info', () => {
expect(printer.info('test info')).toBe(
chalk.rgb(19, 167, 205)`info` + chalk.white(' - test info')
);
});
test('test warn', () => {
expect(printer.warn('test warn')).toBe(
chalk.yellow`warn` + chalk.white(' - test warn')
);
});
});

View File

@@ -0,0 +1,20 @@
const chalk = require('chalk');
const printer = {
debug: msg => {
const result = chalk.green`debug` + chalk.white(' - ' + msg);
console.log(result);
return result;
},
info: msg => {
const result = chalk.rgb(19, 167, 205)`info` + chalk.white(' - ' + msg);
console.log(result);
return result;
},
warn: msg => {
const result = chalk.yellow`warn` + chalk.white(' - ' + msg);
console.log(result);
return result;
},
};
module.exports = { printer };

View File

@@ -0,0 +1,11 @@
import { NotFoundTitle, PageContainer } from './styles';
export const NotfoundPage = () => {
return (
<PageContainer>
<NotFoundTitle>404 - Page Not Found</NotFoundTitle>
</PageContainer>
);
};
export default NotfoundPage;

View File

@@ -0,0 +1,21 @@
import { styled } from '@/styles';
export const PageContainer = styled('div')(({ theme }) => {
return {
width: '100%',
height: 'calc(100vh)',
backgroundColor: theme.colors.pageBackground,
};
});
export const NotFoundTitle = styled('h1')(({ theme }) => {
return {
position: 'relative',
top: 'calc(50% - 100px)',
height: '100px',
fontSize: '60px',
lineHeight: '100px',
color: theme.colors.textColor,
textAlign: 'center',
};
});

View File

@@ -1,119 +0,0 @@
import type { DOMAttributes, CSSProperties } from 'react';
type IconProps = {
style?: CSSProperties;
} & DOMAttributes<SVGElement>;
export const RightArrow = ({ style = {}, ...props }: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
style={style}
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.667 10.0007L8.33366 6.66732L8.33366 13.334L11.667 10.0007Z"
/>
</svg>
);
};
export const Export2Markdown = ({ style = {}, ...props }: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
style={style}
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99963 1.66707V1.66699H10.833L16.6663 7.50033V7.50037L16.6663 7.50041V15.8337C16.6663 17.2145 15.5471 18.3337 14.1663 18.3337H5.83301C4.4523 18.3337 3.33301 17.2145 3.33301 15.8337V4.16707C3.33301 2.78636 4.4523 1.66707 5.83301 1.66707H9.99963ZM9.99963 3.00041H5.83301C5.18868 3.00041 4.66634 3.52274 4.66634 4.16707V15.8337C4.66634 16.4781 5.18868 17.0004 5.83301 17.0004H14.1663C14.8107 17.0004 15.333 16.4781 15.333 15.8337V8.33366H12.4996C11.1189 8.33366 9.99963 7.21437 9.99963 5.83366V3.00041ZM11.333 4.05265L14.2806 7.00033H12.4996C11.8553 7.00033 11.333 6.47799 11.333 5.83366V4.05265Z"
/>
<path d="M11.245 14.1671L11.2683 11.2446H11.2508L10.1775 14.1671H9.4775L8.43333 11.2446H8.41583L8.43917 14.1671H7.5V10.0371H8.9175L9.85667 12.6854H9.88L10.7783 10.0371H12.2192V14.1671H11.245Z" />
</svg>
);
};
export const Export2HTML = ({ style = {}, ...props }: IconProps) => {
return (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg"
fill="currentColor"
style={style}
{...props}
>
<path d="M5.35987 18.3335V16.7055H3.84187V18.3335H2.90137V14.4395H3.84187V15.9135H5.35987V14.4395H6.30037V18.3335H5.35987Z" />
<path d="M8.76806 15.2425V18.3335H7.82756V15.2425H6.72756V14.4395H9.86806V15.2425H8.76806Z" />
<path d="M13.8284 18.3335L13.8504 15.578H13.8339L12.8219 18.3335H12.1619L11.1774 15.578H11.1609L11.1829 18.3335H10.2974V14.4395H11.6339L12.5194 16.9365H12.5414L13.3884 14.4395H14.7469V18.3335H13.8284Z" />
<path d="M15.5503 18.3335V14.4395H16.4963V17.514H18.0033V18.3335H15.5503Z" />
<path
fillRule="evenodd"
clipRule="evenodd"
d="M9.99963 1.66707V1.66699H10.833L11.333 2.16699L16.1663 7.00032L16.6663 7.50033V7.50037L16.6663 7.50041V13.3337H15.333V8.33366H15.333H12.4996C11.1189 8.33366 9.99963 7.21437 9.99963 5.83366V3.00041H5.83301C5.18868 3.00041 4.66634 3.52274 4.66634 4.16707V13.3337H3.33301V4.16707C3.33301 2.78636 4.4523 1.66707 5.83301 1.66707H9.99963ZM11.333 4.05265V5.83366C11.333 6.47799 11.8553 7.00032 12.4996 7.00032H14.2806L11.333 4.05265Z"
/>
</svg>
);
};
export const LogoIcon = ({ style = {}, ...props }: IconProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
style={style}
{...props}
>
<path
fillRule="evenodd"
d="M10.552 2 4 21h3.838l4.168-13.14L16.176 21H20L13.447 2h-2.895Z"
clipRule="evenodd"
/>
</svg>
);
};
export const MoreIcon = ({ style = {}, ...props }: IconProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
>
<circle cx="12" cy="5.5" r="1.5" />
<circle cx="12" cy="12" r="1.5" />
<circle cx="12" cy="18.5" r="1.5" />
</svg>
);
};
export const ExportIcon = ({ style = {}, ...props }: IconProps) => {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
width="24"
height="24"
fill="currentColor"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 3.19995C12.2121 3.19995 12.4156 3.28424 12.5656 3.43427L16.5656 7.43427L15.4343 8.56564L12.8 5.93132V14H11.2V5.93132L8.56564 8.56564L7.43427 7.43427L11.4343 3.43427C11.5843 3.28424 11.7878 3.19995 12 3.19995ZM3.79995 12V16.7992C3.79995 17.3724 3.80057 17.7543 3.82454 18.0476C3.84775 18.3317 3.88879 18.4616 3.93074 18.544C4.04579 18.7698 4.22937 18.9533 4.45516 19.0684C4.5375 19.1103 4.66747 19.1514 4.9515 19.1746C5.24487 19.1985 5.6267 19.1992 6.19995 19.1992H17.8C18.3732 19.1992 18.755 19.1985 19.0484 19.1746C19.3324 19.1514 19.4624 19.1103 19.5447 19.0684C19.7705 18.9533 19.9541 18.7698 20.0692 18.544C20.1111 18.4616 20.1522 18.3317 20.1754 18.0476C20.1993 17.7543 20.2 17.3724 20.2 16.7992V12H21.8V16.8314C21.8 17.364 21.8 17.8116 21.77 18.1779C21.7388 18.5609 21.6708 18.9249 21.4948 19.2703C21.2263 19.7972 20.798 20.2255 20.2711 20.494C19.9256 20.67 19.5617 20.738 19.1787 20.7693C18.8124 20.7992 18.3648 20.7992 17.8322 20.7992H6.16775C5.63509 20.7992 5.18749 20.7992 4.82121 20.7693C4.43823 20.738 4.07426 20.67 3.72878 20.494C3.20193 20.2255 2.77358 19.7972 2.50513 19.2703C2.3291 18.9249 2.26115 18.5609 2.22986 18.1779C2.19993 17.8116 2.19994 17.364 2.19995 16.8313L2.19995 12H3.79995Z"
/>
</svg>
);
};

View File

@@ -1,158 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
LogoIcon,
MoreIcon,
ExportIcon,
Export2Markdown,
Export2HTML,
RightArrow,
} from './icons';
import {
StyledHeader,
StyledTitle,
StyledTitleWrapper,
StyledLogo,
StyledHeaderRightSide,
IconButton,
StyledHeaderContainer,
StyledBrowserWarning,
StyledCloseButton,
StyledMenuItemWrapper,
} from './styles';
import { useEditor } from '@/components/editor-provider';
import EditorModeSwitch from '@/components/editor-mode-switch';
import { EdgelessIcon, PaperIcon } from '../editor-mode-switch/icons';
import ThemeModeSwitch from '@/components/theme-mode-switch';
import { useModal } from '@/components/global-modal-provider';
import CloseIcon from '@mui/icons-material/Close';
import { getWarningMessage, shouldShowWarning } from './utils';
import { Menu, MenuItem } from '@/ui/menu';
const PopoverContent = () => {
const { editor, mode, setMode } = useEditor();
return (
<>
<MenuItem
onClick={() => {
setMode(mode === 'page' ? 'edgeless' : 'page');
}}
>
<StyledMenuItemWrapper>
{mode === 'page' ? <EdgelessIcon /> : <PaperIcon />}
Convert to {mode === 'page' ? 'Edgeless' : 'Page'}
</StyledMenuItemWrapper>
</MenuItem>
<Menu
placement="left-start"
content={
<>
<MenuItem
onClick={() => {
editor && editor.contentParser.onExportHtml();
}}
>
<StyledMenuItemWrapper>
<Export2HTML />
Export to HTML
</StyledMenuItemWrapper>
</MenuItem>
<MenuItem
onClick={() => {
editor && editor.contentParser.onExportMarkdown();
}}
>
<StyledMenuItemWrapper>
<Export2Markdown />
Export to Markdown
</StyledMenuItemWrapper>
</MenuItem>
</>
}
>
<MenuItem>
<StyledMenuItemWrapper>
<ExportIcon />
Export
<RightArrow />
</StyledMenuItemWrapper>
</MenuItem>
</Menu>
</>
);
};
const BrowserWarning = ({ onClose }: { onClose: () => void }) => {
return (
<StyledBrowserWarning>
{getWarningMessage()}
<StyledCloseButton onClick={onClose}>
<CloseIcon />
</StyledCloseButton>
</StyledBrowserWarning>
);
};
export const Header = () => {
const [title, setTitle] = useState('');
const [isHover, setIsHover] = useState(false);
const [showWarning, setShowWarning] = useState(shouldShowWarning());
const { contactModalHandler } = useModal();
const { editor } = useEditor();
useEffect(() => {
if (editor) {
setTitle(editor.model.title || '');
editor.model.propsUpdated.on(() => {
setTitle(editor.model.title);
});
}
}, [editor]);
return (
<StyledHeaderContainer hasWarning={showWarning} data-tauri-drag-region>
<BrowserWarning
onClose={() => {
setShowWarning(false);
}}
/>
<StyledHeader hasWarning={showWarning} data-tauri-drag-region>
<StyledLogo
data-testid="left-top-corner-logo"
onClick={() => {
contactModalHandler(true);
}}
>
<LogoIcon />
</StyledLogo>
{title ? (
<StyledTitle
data-tauri-drag-region
onMouseEnter={() => {
setIsHover(true);
}}
onMouseLeave={() => {
setIsHover(false);
}}
>
<EditorModeSwitch
isHover={isHover}
style={{
marginRight: '12px',
}}
/>
<StyledTitleWrapper>{title}</StyledTitleWrapper>
</StyledTitle>
) : null}
<StyledHeaderRightSide>
<ThemeModeSwitch />
<Menu content={<PopoverContent />} placement="bottom-end">
<IconButton>
<MoreIcon />
</IconButton>
</Menu>
</StyledHeaderRightSide>
</StyledHeader>
</StyledHeaderContainer>
);
};

View File

@@ -71,7 +71,7 @@ export const GithubIcon = () => {
</svg>
);
};
export const DiscordIcon = (props: any) => {
export const DiscordIcon = () => {
return (
<svg
width="22"

View File

@@ -1,5 +1,4 @@
import Modal from '@/ui/modal';
import CloseIcon from '@mui/icons-material/Close';
import { Modal, ModalCloseButton, ModalWrapper } from '@/ui/modal';
import {
LogoIcon,
DocIcon,
@@ -12,7 +11,6 @@ import {
} from './icons';
import logo from './affine-text-logo.png';
import {
StyledModalWrapper,
StyledBigLink,
StyledSmallLink,
StyledSubTitle,
@@ -22,9 +20,9 @@ import {
StyledLogo,
StyledModalHeader,
StyledModalHeaderLeft,
StyledCloseButton,
StyledModalFooter,
} from './style';
import bg from '@/components/contact-modal/bg.png';
const linkList = [
{
@@ -62,9 +60,9 @@ const rightLinkList = [
},
{
icon: <DocIcon />,
title: 'Check Our Docs',
subTitle: 'docs.AFFiNE.pro',
link: 'https://docs.affine.pro',
title: 'AFFiNE Community',
subTitle: 'community.affine.pro',
link: 'https://community.affine.pro',
},
];
@@ -75,20 +73,24 @@ type TransitionsModalProps = {
export const ContactModal = ({ open, onClose }: TransitionsModalProps) => {
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper data-testid="contact-us-modal-content">
<Modal open={open} onClose={onClose} data-testid="contact-us-modal-content">
<ModalWrapper
width={860}
height={540}
style={{ backgroundImage: `url(${bg.src})` }}
>
<StyledModalHeader>
<StyledModalHeaderLeft>
<StyledLogo src={logo.src} alt="" />
<span>Alpha</span>
</StyledModalHeaderLeft>
<StyledCloseButton
<ModalCloseButton
top={6}
right={6}
onClick={() => {
onClose();
}}
>
<CloseIcon width={12} height={12} />
</StyledCloseButton>
/>
</StyledModalHeader>
<StyledContent>
@@ -107,10 +109,7 @@ export const ContactModal = ({ open, onClose }: TransitionsModalProps) => {
})}
</StyledLeftContainer>
<StyledRightContainer>
<StyledSubTitle>
Get in touch! <br />
Join our community.
</StyledSubTitle>
<StyledSubTitle>Get in touch!</StyledSubTitle>
{linkList.map(({ icon, title, link }) => {
return (
<StyledSmallLink key={title} href={link} target="_blank">
@@ -134,7 +133,7 @@ export const ContactModal = ({ open, onClose }: TransitionsModalProps) => {
</p>
<p>Copyright &copy; 2022 Toeverything</p>
</StyledModalFooter>
</StyledModalWrapper>
</ModalWrapper>
</Modal>
);
};

View File

@@ -1,28 +1,11 @@
import { absoluteCenter, displayFlex, styled } from '@/styles';
import bg from './bg.png';
export const StyledModalWrapper = styled('div')(({ theme }) => {
return {
width: '860px',
height: '540px',
backgroundColor: theme.colors.popoverBackground,
backgroundImage: `url(${bg.src})`,
borderRadius: '20px',
position: 'absolute',
left: 0,
right: 0,
top: 0,
bottom: 0,
margin: 'auto',
};
});
export const StyledBigLink = styled('a')(({ theme }) => {
return {
width: '320px',
width: '334px',
height: '100px',
marginBottom: '48px',
paddingLeft: '114px',
paddingLeft: '90px',
fontSize: '24px',
lineHeight: '36px',
fontWeight: '600',
@@ -46,7 +29,7 @@ export const StyledBigLink = styled('a')(({ theme }) => {
height: '50px',
marginRight: '40px',
color: theme.colors.primaryColor,
...absoluteCenter({ vertical: true, position: { left: '32px' } }),
...absoluteCenter({ vertical: true, position: { left: '20px' } }),
},
p: {
width: '100%',
@@ -102,6 +85,7 @@ export const StyledSmallLink = styled('a')(({ theme }) => {
});
export const StyledSubTitle = styled('div')(({ theme }) => {
return {
width: '190px',
fontSize: '18px',
fontWeight: '600',
color: theme.colors.textColor,
@@ -136,7 +120,7 @@ export const StyledLogo = styled('img')({
width: 'auto',
});
export const StyledModalHeader = styled('div')(({ theme }) => {
export const StyledModalHeader = styled('div')(() => {
return {
height: '20px',
marginTop: '36px',
@@ -162,40 +146,6 @@ export const StyledModalHeaderLeft = styled('div')(({ theme }) => {
};
});
export const StyledCloseButton = styled('div')(({ theme }) => {
return {
width: '60px',
height: '60px',
color: theme.colors.iconColor,
cursor: 'pointer',
...displayFlex('center', 'center'),
position: 'absolute',
right: '0',
top: '0',
// TODO: we need to add @emotion/babel-plugin
'::after': {
content: '""',
width: '30px',
height: '30px',
borderRadius: '6px',
...absoluteCenter({ horizontal: true, vertical: true }),
},
':hover': {
color: theme.colors.primaryColor,
'::after': {
background: theme.colors.hoverBackground,
},
},
svg: {
width: '20px',
height: '20px',
position: 'relative',
zIndex: 1,
},
};
});
export const StyledModalFooter = styled('div')(({ theme }) => {
return {
fontSize: '14px',

View File

@@ -0,0 +1,97 @@
import { styled } from '@/styles';
import { Modal, ModalWrapper, ModalCloseButton } from '@/ui/modal';
import { Button } from '@/ui/button';
import Input from '@/ui/input';
import { useState } from 'react';
interface LoginModalProps {
open: boolean;
onClose: () => void;
workSpaceName: string;
}
export const DeleteModal = ({
open,
onClose,
workSpaceName,
}: LoginModalProps) => {
const [canDelete, setCanDelete] = useState<boolean>(true);
const InputChange = (value: string) => {
if (value === workSpaceName) {
setCanDelete(false);
} else {
setCanDelete(true);
}
};
return (
<div>
<Modal open={open} onClose={onClose}>
<ModalWrapper width={620} height={334}>
<Header>
<ModalCloseButton
top={6}
right={6}
onClick={() => {
onClose();
}}
/>
</Header>
<Content>
<ContentTitle>Delete Workspace</ContentTitle>
<div>
This action cannot be undone. This will permanently delete{' '}
{workSpaceName} workspace name along with all its content.
</div>
<Input
onChange={InputChange}
placeholder="Please type “delete” to confirm"
></Input>
</Content>
<Footer>
<Button
style={{ marginRight: '12px' }}
shape="circle"
onClick={() => {
onClose();
}}
>
Cancel
</Button>
<Button shape="circle" type="danger" disabled={canDelete}>
Delete
</Button>
</Footer>
</ModalWrapper>
</Modal>
</div>
);
};
const Header = styled('div')({
position: 'relative',
height: '44px',
});
const Content = styled('div')({
display: 'flex',
padding: '0 48px',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
});
const ContentTitle = styled('h1')({
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
textAlign: 'center',
paddingBottom: '16px',
});
const Footer = styled('div')({
height: '70px',
paddingLeft: '24px',
marginTop: '32px',
textAlign: 'center',
});

View File

@@ -16,7 +16,9 @@ import {
} from './icons';
import { Tooltip } from '@/ui/tooltip';
import Slide from '@mui/material/Slide';
import { useEditor } from '@/components/editor-provider';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { useAppState } from '@/providers/app-state-provider';
import useHistoryUpdated from '@/hooks/use-history-update';
const toolbarList1 = [
{
@@ -24,6 +26,15 @@ const toolbarList1 = [
icon: <SelectIcon />,
toolTip: 'Select',
disable: false,
callback: () => {
window.dispatchEvent(
new CustomEvent('affine.switch-mouse-mode', {
detail: {
type: 'default',
},
})
);
},
},
{
flavor: 'text',
@@ -34,8 +45,19 @@ const toolbarList1 = [
{
flavor: 'shape',
icon: <ShapeIcon />,
toolTip: 'Shape (coming soon)',
disable: true,
toolTip: 'Shape',
disable: false,
callback: () => {
window.dispatchEvent(
new CustomEvent('affine.switch-mouse-mode', {
detail: {
type: 'shape',
color: 'black',
shape: 'rectangle',
},
})
);
},
},
{
flavor: 'sticky',
@@ -57,34 +79,19 @@ const toolbarList1 = [
disable: true,
},
];
const toolbarList2 = [
{
flavor: 'undo',
icon: <UndoIcon />,
toolTip: 'Undo',
disable: false,
},
{
flavor: 'redo',
icon: <RedoIcon />,
toolTip: 'Redo',
disable: false,
},
];
const UndoRedo = () => {
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const { editor } = useEditor();
useEffect(() => {
if (!editor) return;
const { page } = editor;
const { currentPage } = useAppState();
const onHistoryUpdated = useHistoryUpdated();
page.signals.historyUpdated.on(() => {
useEffect(() => {
onHistoryUpdated(page => {
setCanUndo(page.canUndo);
setCanRedo(page.canRedo);
});
}, [editor]);
}, [onHistoryUpdated]);
return (
<StyledToolbarWrapper>
@@ -92,7 +99,7 @@ const UndoRedo = () => {
<StyledToolbarItem
disable={!canUndo}
onClick={() => {
editor?.page?.undo();
currentPage?.undo();
}}
>
<UndoIcon />
@@ -102,7 +109,7 @@ const UndoRedo = () => {
<StyledToolbarItem
disable={!canRedo}
onClick={() => {
editor?.page?.redo();
currentPage?.redo();
}}
>
<RedoIcon />
@@ -113,7 +120,7 @@ const UndoRedo = () => {
};
export const EdgelessToolbar = () => {
const { mode } = useEditor();
const { mode } = useCurrentPageMeta() || {};
return (
<Slide
@@ -122,22 +129,25 @@ export const EdgelessToolbar = () => {
mountOnEnter
unmountOnExit
>
<StyledEdgelessToolbar>
<StyledEdgelessToolbar aria-label="edgeless-toolbar">
<StyledToolbarWrapper>
{toolbarList1.map(({ icon, toolTip, flavor, disable }, index) => {
return (
<Tooltip key={index} content={toolTip} placement="right-start">
<StyledToolbarItem
disable={disable}
onClick={() => {
console.log('flavor', flavor);
}}
>
{icon}
</StyledToolbarItem>
</Tooltip>
);
})}
{toolbarList1.map(
({ icon, toolTip, flavor, disable, callback }, index) => {
return (
<Tooltip key={index} content={toolTip} placement="right-start">
<StyledToolbarItem
disable={disable}
onClick={() => {
console.log('click toolbar button:', flavor);
callback?.();
}}
>
{icon}
</StyledToolbarItem>
</Tooltip>
);
}
)}
</StyledToolbarWrapper>
<UndoRedo />
</StyledEdgelessToolbar>

View File

@@ -2,12 +2,12 @@ import { styled, displayFlex } from '@/styles';
export const StyledEdgelessToolbar = styled.div(({ theme }) => ({
height: '320px',
position: 'fixed',
position: 'absolute',
left: '12px',
top: 0,
bottom: 0,
margin: 'auto',
zIndex: theme.zIndex.modal,
zIndex: theme.zIndex.modal - 1,
}));
export const StyledToolbarWrapper = styled.div(({ theme }) => ({

View File

@@ -11,9 +11,10 @@ import type {
AnimateRadioProps,
AnimateRadioItemProps,
} from './type';
import { useTheme } from '@/styles';
import { useTheme } from '@/providers/themeProvider';
import { EdgelessIcon, PaperIcon } from './icons';
import { useEditor } from '@/components/editor-provider';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { usePageHelper } from '@/hooks/use-page-helper';
const PaperItem = ({ active }: { active?: boolean }) => {
const {
@@ -66,7 +67,9 @@ export const EditorModeSwitch = ({
style = {},
}: AnimateRadioProps) => {
const { mode: themeMode } = useTheme();
const { mode, setMode } = useEditor();
const { changePageMode } = usePageHelper();
const { trash, mode = 'page', id = '' } = useCurrentPageMeta() || {};
const modifyRadioItemStatus = (): RadioItemStatus => {
return {
left: isHover
@@ -99,6 +102,7 @@ export const EditorModeSwitch = ({
data-testid="editor-mode-switcher"
shrink={!isHover}
style={style}
disabled={!!trash}
>
<AnimateRadioItem
isLeft={true}
@@ -107,7 +111,7 @@ export const EditorModeSwitch = ({
active={mode === 'page'}
status={radioItemStatus.left}
onClick={() => {
setMode('page');
changePageMode(id, 'page');
}}
onMouseEnter={() => {
setRadioItemStatus({
@@ -123,11 +127,12 @@ export const EditorModeSwitch = ({
<AnimateRadioItem
isLeft={false}
label="Edgeless"
data-testid="switch-edgeless-item"
icon={<EdgelessItem />}
active={mode === 'edgeless'}
status={radioItemStatus.right}
onClick={() => {
setMode('edgeless');
changePageMode(id, 'edgeless');
}}
onMouseEnter={() => {
setRadioItemStatus({

View File

@@ -1,45 +1,47 @@
import { displayFlex, keyframes, styled } from '@/styles';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import spring, { toString } from 'css-spring';
import type { ItemStatus } from './type';
const ANIMATE_DURATION = 500;
export const StyledAnimateRadioContainer = styled('div')<{ shrink: boolean }>(
({ shrink, theme }) => {
const animateScaleStretch = keyframes`${toString(
spring({ width: '36px' }, { width: '160px' }, { preset: 'gentle' })
)}`;
const animateScaleShrink = keyframes(
`${toString(
spring({ width: '160px' }, { width: '36px' }, { preset: 'gentle' })
)}`
);
const shrinkStyle = shrink
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
background: 'transparent',
}
: {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
};
export const StyledAnimateRadioContainer = styled('div')<{
shrink: boolean;
disabled: boolean;
}>(({ shrink, theme, disabled }) => {
const animateScaleStretch = keyframes`${toString(
spring({ width: '36px' }, { width: '160px' }, { preset: 'gentle' })
)}`;
const animateScaleShrink = keyframes(
`${toString(
spring({ width: '160px' }, { width: '36px' }, { preset: 'gentle' })
)}`
);
const shrinkStyle = shrink
? {
animation: `${animateScaleShrink} ${ANIMATE_DURATION}ms forwards`,
background: 'transparent',
}
: {
animation: `${animateScaleStretch} ${ANIMATE_DURATION}ms forwards`,
};
return {
height: '36px',
borderRadius: '18px',
background: theme.colors.hoverBackground,
position: 'relative',
display: 'flex',
transition: `background ${ANIMATE_DURATION}ms, border ${ANIMATE_DURATION}ms`,
border: '1px solid transparent',
return {
height: '36px',
borderRadius: '18px',
background: disabled ? 'transparent' : theme.colors.hoverBackground,
position: 'relative',
display: 'flex',
transition: `background ${ANIMATE_DURATION}ms, border ${ANIMATE_DURATION}ms`,
border: '1px solid transparent',
...shrinkStyle,
':hover': {
border: `1px solid ${theme.colors.primaryColor}`,
},
};
}
);
...(disabled ? { pointerEvents: 'none' } : shrinkStyle),
':hover': {
border: disabled ? '' : `1px solid ${theme.colors.primaryColor}`,
},
};
});
export const StyledMiddleLine = styled('div')<{
hidden: boolean;

View File

@@ -1,42 +0,0 @@
import type { EditorContainer } from '@blocksuite/editor';
import { createContext, useContext, useEffect, useState } from 'react';
import type { PropsWithChildren } from 'react';
type EditorContextValue = {
editor: EditorContainer | null;
mode: EditorContainer['mode'];
setEditor: (editor: EditorContainer) => void;
setMode: (mode: EditorContainer['mode']) => void;
};
type EditorContextProps = PropsWithChildren<{}>;
export const EditorContext = createContext<EditorContextValue>({
editor: null,
mode: 'page',
setEditor: () => {},
setMode: () => {},
});
export const useEditor = () => useContext(EditorContext);
export const EditorProvider = ({
children,
}: PropsWithChildren<EditorContextProps>) => {
const [editor, setEditor] = useState<EditorContainer | null>(null);
const [mode, setMode] = useState<EditorContainer['mode']>('page');
useEffect(() => {
const event = new CustomEvent('affine.switch-mode', { detail: mode });
window.dispatchEvent(event);
}, [mode]);
return (
<EditorContext.Provider value={{ editor, setEditor, mode, setMode }}>
{children}
</EditorContext.Provider>
);
};
export default EditorProvider;

View File

@@ -1,79 +0,0 @@
import { useEditor } from '@/components/editor-provider';
import '@blocksuite/blocks';
import '@blocksuite/blocks/style';
import type { EditorContainer } from '@blocksuite/editor';
import { createEditor, BlockSchema } from '@blocksuite/editor';
import { Workspace } from '@blocksuite/store';
import { forwardRef, Suspense, useEffect, useRef } from 'react';
import pkg from '../../package.json';
import exampleMarkdown from './example-markdown';
// eslint-disable-next-line react/display-name
const BlockSuiteEditor = forwardRef<EditorContainer>(({}, ref) => {
const containerElement = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!containerElement.current) {
return;
}
const workspace = new Workspace({});
const page = workspace.createPage('page0').register(BlockSchema);
const editor = createEditor(page);
containerElement.current.appendChild(editor);
if (ref) {
if ('current' in ref) {
ref.current = editor;
} else {
ref(editor);
}
}
return () => {
editor.remove();
};
}, [ref]);
return <div id="editor" style={{ height: '100%' }} ref={containerElement} />;
});
export const Editor = () => {
const editorRef = useRef<EditorContainer>(null);
const { setEditor } = useEditor();
useEffect(() => {
if (!editorRef.current) {
return;
}
setEditor(editorRef.current);
const { page } = editorRef.current as EditorContainer;
const pageId = page.addBlock({
flavour: 'affine:page',
title: 'Welcome to the AFFiNE Alpha',
});
const groupId = page.addBlock({ flavour: 'affine:group' }, pageId);
editorRef.current.clipboard.importMarkdown(exampleMarkdown, `${groupId}`);
page.resetHistory();
}, [setEditor]);
useEffect(() => {
const version = pkg.dependencies['@blocksuite/editor'].substring(1);
console.log(`BlockSuite live demo ${version}`);
}, []);
return (
<Suspense fallback={<div>Error!</div>}>
<BlockSuiteEditor ref={editorRef} />
</Suspense>
);
};
declare global {
interface HTMLElementTagNameMap {
'editor-container': EditorContainer;
}
namespace JSX {
interface IntrinsicElements {
// TODO fix types on react
'editor-container': EditorContainer;
}
}
}
export default Editor;

View File

@@ -1,62 +0,0 @@
const exampleMarkdown = `The AFFiNE Alpha is here! You can also view our [Official Website](https://affine.pro/)!
**What's different in AFFiNE Alpha?**
1. A much ~smoother editing~ experience, with much ~greater stability~;
2. More complete ~Markdown~ support and improved ~keyboard shortcuts~;
3. New features such as ~dark mode~;
* **Switch between view styles using the** ☀ **and** 🌙.
4. ~Clean and modern UI/UX~ design.
**Looking for Markdown syntax or keyboard shortcuts?**
* Find the (?) in the bottom right, then the ️⌨️, to view a full list of \`Keyboard Shortcuts\`
**Have an enjoyable editing experience !!!** 😃
Have some feedback or just want to get in touch? Use the (?), then 🎧 to get in touch and join our communities.
**Still in development**
There are also some things you should consider about this AFFiNE Alpha including some ~limitations~:
* Single page editing - currently editing multiple docs/pages is not supported;
* Changes are not automatically stored, to save changes you should export your data. Options can be found by going to the top right and finding the \`\` icon;
* Without an import/open feature you are still able to access your data by copying it back in.
**Keyboard Shortcuts:**
1. Undo: \`⌘+Z\` or \`Ctrl+Z\`
2. Redo: \`⌘+⇧+Z\` or \`Ctrl+Shift+Z\`
3. Bold: \`⌘+B\` or \`Ctrl+B\`
4. Italic: \`⌘+I\` or \`Ctrl+I\`
5. Underline: \`⌘+U\` or \`Ctrl+U\`
6. Strikethrough: \`⌘+⇧+S\` or \`Ctrl+Shift+S\`
7. Inline code: \`⌘+E\` or \`Ctrl+E\`
8. Link: \`⌘+K\` or \`Ctrl+K\`
9. Body text: \`⌘+⌥+0\` or \`Ctrl+Shift+0\`
10. Heading 1: \`⌘+⌥+1\` or \`Ctrl+Shift+1\`
11. Heading 2: \`⌘+⌥+2\` or \`Ctrl+Shift+2\`
12. Heading 3: \`⌘+⌥+3\` or \`Ctrl+Shift+3\`
13. Heading 4: \`⌘+⌥+4\` or \`Ctrl+Shift+4\`
14. Heading 5: \`⌘+⌥+5\` or \`Ctrl+Shift+5\`
15. Heading 6: \`⌘+⌥+6\` or \`Ctrl+Shift+6\`
16. Increase indent: \`Tab\`
17. Reduce indent: \`⇧+Tab\` or \`Shift+Tab\`
**Markdown Syntax:**
1. Bold: \`**text**\`
2. Italic: \`*text*\`
3. Underline: \`~text~\`
4. Strikethrough: \`~~text~~\`
5. Inline code: \`\` \`text\` \`\`
6. Heading 1: \`# text\`
7. Heading 2: \`## text\`
8. Heading 3: \`### text\`
9. Heading 4: \`#### text\`
10. Heading 5: \`##### text\`
11. Heading 6: \`###### text\`
`;
export default exampleMarkdown;

View File

@@ -1,87 +0,0 @@
import { useState } from 'react';
import {
StyledFAQ,
StyledIconWrapper,
StyledFAQWrapper,
StyledTransformIcon,
} from './style';
import { CloseIcon, ContactIcon, HelpIcon, KeyboardIcon } from './icons';
import Grow from '@mui/material/Grow';
import { Tooltip } from '@/ui/tooltip';
import { useEditor } from '@/components/editor-provider';
import { useModal } from '@/components/global-modal-provider';
import { useTheme } from '@/styles';
export const FAQ = () => {
const [showContent, setShowContent] = useState(false);
const { mode } = useTheme();
const { mode: editorMode } = useEditor();
const { shortcutsModalHandler, contactModalHandler } = useModal();
const isEdgelessDark = mode === 'dark' && editorMode === 'edgeless';
return (
<>
<StyledFAQ
className=""
onMouseEnter={() => {
setShowContent(true);
}}
onMouseLeave={() => {
setShowContent(false);
}}
>
<Grow in={showContent}>
<StyledFAQWrapper>
<Tooltip content="Contact Us" placement="left-end">
<StyledIconWrapper
data-testid="right-bottom-contact-us-icon"
isEdgelessDark={isEdgelessDark}
onClick={() => {
setShowContent(false);
contactModalHandler(true);
}}
>
<ContactIcon />
</StyledIconWrapper>
</Tooltip>
<Tooltip content="Keyboard Shortcuts" placement="left-end">
<StyledIconWrapper
data-testid="shortcuts-icon"
isEdgelessDark={isEdgelessDark}
onClick={() => {
setShowContent(false);
shortcutsModalHandler(true);
}}
>
<KeyboardIcon />
</StyledIconWrapper>
</Tooltip>
</StyledFAQWrapper>
</Grow>
<div style={{ position: 'relative' }}>
<StyledIconWrapper
data-testid="faq-icon"
isEdgelessDark={isEdgelessDark}
>
<HelpIcon />
</StyledIconWrapper>
<StyledTransformIcon in={showContent}>
<CloseIcon />
</StyledTransformIcon>
</div>
</StyledFAQ>
</>
);
};
const routesLIst: any = [
{
path: '/',
children: [
{
element: <HelpIcon />,
},
],
},
];

View File

@@ -0,0 +1,47 @@
import { Button } from '@/ui/button';
import { FC, useRef, ChangeEvent, ReactElement } from 'react';
import { styled } from '@/styles';
interface Props {
uploadType?: string;
children?: ReactElement;
accept?: string;
fileChange: (file: File) => void;
}
export const Upload: FC<Props> = props => {
const { fileChange, accept } = props;
const input_ref = useRef<HTMLInputElement>(null);
const _chooseFile = () => {
if (input_ref.current) {
input_ref.current.click();
}
};
const _handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files) {
return;
}
const file = files[0];
fileChange(file);
if (input_ref.current) {
input_ref.current.value = '';
}
};
return (
<UploadStyle onClick={_chooseFile}>
{props.children ?? <Button>Upload</Button>}
<input
ref={input_ref}
type="file"
style={{ display: 'none' }}
onChange={_handleInputChange}
accept={accept}
/>
</UploadStyle>
);
};
const UploadStyle = styled('div')(() => {
return {
display: 'inline-block',
};
});

View File

@@ -1,53 +0,0 @@
import { createContext, useContext, useState } from 'react';
import type { PropsWithChildren } from 'react';
import ShortcutsModal from './shortcuts-modal';
import ContactModal from '@/components/contact-modal';
type ModalContextValue = {
shortcutsModalHandler: (visible: boolean) => void;
contactModalHandler: (visible: boolean) => void;
};
type ModalContextProps = PropsWithChildren<{}>;
export const ModalContext = createContext<ModalContextValue>({
shortcutsModalHandler: () => {},
contactModalHandler: () => {},
});
export const useModal = () => useContext(ModalContext);
export const ModalProvider = ({
children,
}: PropsWithChildren<ModalContextProps>) => {
const [openContactModal, setOpenContactModal] = useState(false);
const [openShortcutsModal, setOpenShortcutsModal] = useState(false);
return (
<ModalContext.Provider
value={{
shortcutsModalHandler: visible => {
setOpenShortcutsModal(visible);
},
contactModalHandler: visible => {
setOpenContactModal(visible);
},
}}
>
<ContactModal
open={openContactModal}
onClose={() => {
setOpenContactModal(false);
}}
></ContactModal>
<ShortcutsModal
open={openShortcutsModal}
onClose={() => {
setOpenShortcutsModal(false);
}}
></ShortcutsModal>
{children}
</ModalContext.Provider>
);
};
export default ModalProvider;

View File

@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import {
StyledSearchArrowWrapper,
StyledSwitchWrapper,
StyledTitle,
StyledTitleWrapper,
} from './styles';
import { Content } from '@/ui/layout';
import { useAppState } from '@/providers/app-state-provider/context';
import EditorModeSwitch from '@/components/editor-mode-switch';
import QuickSearchButton from './quick-search-button';
import Header from './header';
import usePropsUpdated from '@/hooks/use-props-updated';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
export const EditorHeader = () => {
const [title, setTitle] = useState('');
const [isHover, setIsHover] = useState(false);
const { editor } = useAppState();
const { trash: isTrash = false } = useCurrentPageMeta() || {};
const onPropsUpdated = usePropsUpdated();
useEffect(() => {
onPropsUpdated(editor => {
setTitle(editor.model?.title || 'Untitled');
});
}, [onPropsUpdated]);
useEffect(() => {
setTimeout(() => {
// If first time in, need to wait for editor to be inserted into DOM
setTitle(editor?.model?.title || 'Untitled');
}, 300);
}, [editor]);
return (
<Header
rightItems={
isTrash
? ['trashButtonGroup']
: ['syncUser', 'themeModeSwitch', 'editorOptionMenu']
}
>
{title && (
<StyledTitle
onMouseEnter={() => {
if (isTrash) return;
setIsHover(true);
}}
onMouseLeave={() => {
if (isTrash) return;
setIsHover(false);
}}
>
<StyledTitleWrapper>
<StyledSwitchWrapper>
<EditorModeSwitch
isHover={isHover}
style={{
marginRight: '12px',
}}
/>
</StyledSwitchWrapper>
<Content ellipsis={true}>{title}</Content>
<StyledSearchArrowWrapper>
<QuickSearchButton />
</StyledSearchArrowWrapper>
</StyledTitleWrapper>
</StyledTitle>
)}
</Header>
);
};
export default EditorHeader;

View File

@@ -0,0 +1,111 @@
import { Menu, MenuItem } from '@/ui/menu';
import { IconButton } from '@/ui/button';
import {
EdgelessIcon,
ExportIcon,
ExportToHtmlIcon,
ExportToMarkdownIcon,
FavouritedIcon,
FavouritesIcon,
MoreVerticalIcon,
PaperIcon,
TrashIcon,
} from '@blocksuite/icons';
import { useAppState } from '@/providers/app-state-provider';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useConfirm } from '@/providers/confirm-provider';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { toast } from '@/ui/toast';
const PopoverContent = () => {
const { editor } = useAppState();
const { toggleFavoritePage, toggleDeletePage } = usePageHelper();
const { changePageMode } = usePageHelper();
const { confirm } = useConfirm();
const {
mode = 'page',
id = '',
favorite = false,
title = '',
} = useCurrentPageMeta() || {};
return (
<>
<MenuItem
data-testid="editor-option-menu-favorite"
onClick={() => {
toggleFavoritePage(id);
toast(!favorite ? 'Removed to Favourites' : 'Added to Favourites');
}}
icon={favorite ? <FavouritedIcon /> : <FavouritesIcon />}
>
{favorite ? 'Remove' : 'Add'} to favourites
</MenuItem>
<MenuItem
icon={mode === 'page' ? <EdgelessIcon /> : <PaperIcon />}
data-testid="editor-option-menu-edgeless"
onClick={() => {
changePageMode(id, mode === 'page' ? 'edgeless' : 'page');
}}
>
Convert to {mode === 'page' ? 'Edgeless' : 'Page'}
</MenuItem>
<Menu
placement="left-start"
content={
<>
<MenuItem
onClick={() => {
editor && editor.contentParser.onExportHtml();
}}
icon={<ExportToHtmlIcon />}
>
Export to HTML
</MenuItem>
<MenuItem
onClick={() => {
editor && editor.contentParser.onExportMarkdown();
}}
icon={<ExportToMarkdownIcon />}
>
Export to Markdown
</MenuItem>
</>
}
>
<MenuItem icon={<ExportIcon />} isDir={true}>
Export
</MenuItem>
</Menu>
<MenuItem
data-testid="editor-option-menu-delete"
onClick={() => {
confirm({
title: 'Delete page?',
content: `${title || 'Untitled'} will be moved to Trash`,
confirmText: 'Delete',
confirmType: 'danger',
}).then(confirm => {
confirm && toggleDeletePage(id);
toast('Moved to Trash');
});
}}
icon={<TrashIcon />}
>
Delete
</MenuItem>
</>
);
};
export const EditorOptionMenu = () => {
return (
<Menu content={<PopoverContent />} placement="bottom-end">
<IconButton>
<MoreVerticalIcon />
</IconButton>
</Menu>
);
};
export default EditorOptionMenu;

View File

@@ -0,0 +1,25 @@
import { CloudUnsyncedIcon, CloudInsyncIcon } from '@blocksuite/icons';
import { useModal } from '@/providers/global-modal-provider';
import { useAppState } from '@/providers/app-state-provider/context';
import { IconButton } from '@/ui/button';
export const SyncUser = () => {
const { triggerLoginModal } = useModal();
const appState = useAppState();
return appState.user ? (
<IconButton iconSize="middle" disabled>
<CloudInsyncIcon />
</IconButton>
) : (
<IconButton
iconSize="middle"
data-testid="cloud-unsync-icon"
onClick={triggerLoginModal}
>
<CloudUnsyncedIcon />
</IconButton>
);
};
export default SyncUser;

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useTheme } from '@/styles';
import { useTheme } from '@/providers/themeProvider';
import { MoonIcon, SunIcon } from './icons';
import { StyledThemeModeSwitch, StyledSwitchItem } from './style';

View File

@@ -1,5 +1,6 @@
import { displayFlex, keyframes, styled } from '@/styles';
import { CSSProperties } from 'react';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
import spring, { toString } from 'css-spring';

View File

@@ -0,0 +1,53 @@
import { Button } from '@/ui/button';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useAppState } from '@/providers/app-state-provider';
import { useConfirm } from '@/providers/confirm-provider';
import { useRouter } from 'next/router';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
export const TrashButtonGroup = () => {
const { permanentlyDeletePage } = usePageHelper();
const { currentWorkspaceId } = useAppState();
const { toggleDeletePage } = usePageHelper();
const { confirm } = useConfirm();
const router = useRouter();
const { id = '' } = useCurrentPageMeta() || {};
return (
<>
<Button
bold={true}
shape="round"
style={{ marginRight: '24px' }}
onClick={() => {
toggleDeletePage(id);
}}
>
Restore it
</Button>
<Button
bold={true}
shape="round"
type="danger"
onClick={() => {
confirm({
title: 'Permanently delete',
content:
"Once deleted, you can't undo this action. Do you confirm?",
confirmText: 'Delete',
confirmType: 'danger',
}).then(confirm => {
if (confirm) {
router.push(`/workspace/${currentWorkspaceId}/all`);
permanentlyDeletePage(id);
}
});
}}
>
Delete permanently
</Button>
</>
);
};
export default TrashButtonGroup;

View File

@@ -0,0 +1,71 @@
import React, { PropsWithChildren, ReactNode, useState } from 'react';
import {
StyledHeader,
StyledHeaderRightSide,
StyledHeaderContainer,
StyledBrowserWarning,
StyledCloseButton,
} from './styles';
import CloseIcon from '@mui/icons-material/Close';
import { getWarningMessage, shouldShowWarning } from './utils';
import EditorOptionMenu from './header-right-items/editor-option-menu';
import TrashButtonGroup from './header-right-items/trash-button-group';
import ThemeModeSwitch from './header-right-items/theme-mode-switch';
// import SyncUser from './header-right-items/sync-user';
const BrowserWarning = ({
show,
onClose,
}: {
show: boolean;
onClose: () => void;
}) => {
return (
<StyledBrowserWarning show={show}>
{getWarningMessage()}
<StyledCloseButton onClick={onClose}>
<CloseIcon />
</StyledCloseButton>
</StyledBrowserWarning>
);
};
type HeaderRightItemNames =
| 'editorOptionMenu'
| 'trashButtonGroup'
| 'themeModeSwitch'
| 'syncUser';
const HeaderRightItems: Record<HeaderRightItemNames, ReactNode> = {
editorOptionMenu: <EditorOptionMenu key="editorOptionMenu" />,
trashButtonGroup: <TrashButtonGroup key="trashButtonGroup" />,
themeModeSwitch: <ThemeModeSwitch key="themeModeSwitch" />,
syncUser: null,
};
export const Header = ({
rightItems = ['syncUser'],
children,
}: PropsWithChildren<{ rightItems?: HeaderRightItemNames[] }>) => {
const [showWarning, setShowWarning] = useState(shouldShowWarning());
return (
<StyledHeaderContainer hasWarning={showWarning}>
<BrowserWarning
show={showWarning}
onClose={() => {
setShowWarning(false);
}}
/>
<StyledHeader hasWarning={showWarning} data-testid="editor-header-items">
{children}
<StyledHeaderRightSide>
{rightItems.map(itemName => {
return HeaderRightItems[itemName];
})}
</StyledHeaderRightSide>
</StyledHeader>
</StyledHeaderContainer>
);
};
export default Header;

View File

@@ -0,0 +1,3 @@
export * from './header';
export * from './editor-header';
export * from './page-list-header';

View File

@@ -0,0 +1,21 @@
import { PropsWithChildren, ReactNode } from 'react';
import Header from './header';
import { StyledPageListTittleWrapper } from './styles';
import QuickSearchButton from './quick-search-button';
export type PageListHeaderProps = PropsWithChildren<{
icon?: ReactNode;
}>;
export const PageListHeader = ({ icon, children }: PageListHeaderProps) => {
return (
<Header>
<StyledPageListTittleWrapper>
{icon}
{children}
<QuickSearchButton style={{ marginLeft: '5px' }} />
</StyledPageListTittleWrapper>
</Header>
);
};
export default PageListHeader;

View File

@@ -0,0 +1,29 @@
import React from 'react';
import { IconButton, IconButtonProps } from '@/ui/button';
import { Tooltip } from '@/ui/tooltip';
import { ArrowDownIcon } from '@blocksuite/icons';
import { useModal } from '@/providers/global-modal-provider';
export const QuickSearchButton = ({
onClick,
...props
}: Omit<IconButtonProps, 'children'>) => {
const { triggerQuickSearchModal } = useModal();
return (
<Tooltip content="Switch to" placement="bottom">
<IconButton
data-testid="header-quickSearchButton"
{...props}
onClick={e => {
onClick?.(e);
triggerQuickSearchModal();
}}
>
<ArrowDownIcon />
</IconButton>
</Tooltip>
);
};
export default QuickSearchButton;

View File

@@ -1,5 +1,4 @@
import { displayFlex, styled } from '@/styles';
import { MenuItem } from '@/ui/menu';
export const StyledHeaderContainer = styled.div<{ hasWarning: boolean }>(
({ hasWarning }) => {
@@ -10,14 +9,14 @@ export const StyledHeaderContainer = styled.div<{ hasWarning: boolean }>(
}
);
export const StyledHeader = styled.div<{ hasWarning: boolean }>(
({ hasWarning, theme }) => {
({ hasWarning }) => {
return {
height: '60px',
width: '100vw',
...displayFlex('space-between', 'center'),
width: '100%',
...displayFlex('flex-end', 'center'),
background: 'var(--affine-page-background)',
transition: 'background-color 0.5s',
position: 'fixed',
position: 'absolute',
left: '0',
top: hasWarning ? '36px' : '0',
padding: '0 22px',
@@ -41,20 +40,10 @@ export const StyledTitle = styled('div')(({ theme }) => ({
export const StyledTitleWrapper = styled('div')({
maxWidth: '720px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
height: '100%',
position: 'relative',
});
export const StyledLogo = styled('div')(({ theme }) => ({
color: theme.colors.primaryColor,
width: '60px',
height: '60px',
cursor: 'pointer',
marginLeft: '-22px',
...displayFlex('center', 'center'),
}));
});
export const StyledHeaderRightSide = styled('div')({
height: '100%',
@@ -62,54 +51,23 @@ export const StyledHeaderRightSide = styled('div')({
alignItems: 'center',
});
export const StyledMenuItemWrapper = styled.div(({ theme }) => {
return {
height: '32px',
position: 'relative',
cursor: 'pointer',
...displayFlex('flex-start', 'center'),
svg: {
width: '16px',
height: '16px',
marginRight: '14px',
},
'svg:nth-child(2)': {
position: 'absolute',
right: 0,
top: 0,
bottom: 0,
margin: 'auto',
},
};
});
export const IconButton = styled('div')(({ theme }) => {
return {
width: '32px',
height: '32px',
...displayFlex('center', 'center'),
color: theme.colors.iconColor,
borderRadius: '5px',
':hover': {
color: theme.colors.primaryColor,
background: theme.colors.hoverBackground,
},
};
});
export const StyledBrowserWarning = styled.div(({ theme }) => {
return {
backgroundColor: theme.colors.warningBackground,
color: theme.colors.warningColor,
height: '36px',
width: '100vw',
fontSize: theme.font.sm,
position: 'fixed',
left: '0',
top: '0',
...displayFlex('center', 'center'),
};
});
export const StyledBrowserWarning = styled.div<{ show: boolean }>(
({ theme, show }) => {
return {
backgroundColor: theme.colors.warningBackground,
color: theme.colors.warningColor,
height: '36px',
width: '100vw',
fontSize: theme.font.sm,
position: 'fixed',
left: '0',
top: '0',
display: show ? 'flex' : 'none',
justifyContent: 'center',
alignItems: 'center',
};
}
);
export const StyledCloseButton = styled.div(({ theme }) => {
return {
@@ -130,3 +88,36 @@ export const StyledCloseButton = styled.div(({ theme }) => {
},
};
});
export const StyledSwitchWrapper = styled.div(() => {
return {
position: 'absolute',
right: '100%',
top: 0,
bottom: 0,
margin: 'auto',
...displayFlex('center', 'center'),
};
});
export const StyledSearchArrowWrapper = styled.div(() => {
return {
position: 'absolute',
left: 'calc(100% + 4px)',
top: 0,
bottom: 0,
margin: 'auto',
...displayFlex('center', 'center'),
};
});
export const StyledPageListTittleWrapper = styled(StyledTitle)(({ theme }) => {
return {
fontSize: theme.font.sm,
color: theme.colors.textColor,
'>svg': {
fontSize: '20px',
marginRight: '12px',
},
};
});

View File

@@ -0,0 +1,86 @@
import { useState } from 'react';
import {
StyledIsland,
StyledIconWrapper,
StyledIslandWrapper,
StyledTransformIcon,
} from './style';
import { CloseIcon, ContactIcon, HelpIcon, KeyboardIcon } from './icons';
import Grow from '@mui/material/Grow';
import { Tooltip } from '@/ui/tooltip';
import { useModal } from '@/providers/global-modal-provider';
import { useTheme } from '@/providers/themeProvider';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
export type IslandItemNames = 'contact' | 'shortcuts';
export const HelpIsland = ({
showList = ['contact', 'shortcuts'],
}: {
showList?: IslandItemNames[];
}) => {
const [showContent, setShowContent] = useState(false);
const { mode } = useTheme();
const { mode: editorMode } = useCurrentPageMeta() || {};
const { triggerShortcutsModal, triggerContactModal } = useModal();
const isEdgelessDark = mode === 'dark' && editorMode === 'edgeless';
return (
<>
<StyledIsland
className=""
onMouseEnter={() => {
setShowContent(true);
}}
onMouseLeave={() => {
setShowContent(false);
}}
>
<Grow in={showContent}>
<StyledIslandWrapper>
{showList.includes('contact') && (
<Tooltip content="Contact Us" placement="left-end">
<StyledIconWrapper
data-testid="right-bottom-contact-us-icon"
isEdgelessDark={isEdgelessDark}
onClick={() => {
setShowContent(false);
triggerContactModal();
}}
>
<ContactIcon />
</StyledIconWrapper>
</Tooltip>
)}
{showList.includes('shortcuts') && (
<Tooltip content="Keyboard Shortcuts" placement="left-end">
<StyledIconWrapper
data-testid="shortcuts-icon"
isEdgelessDark={isEdgelessDark}
onClick={() => {
setShowContent(false);
triggerShortcutsModal();
}}
>
<KeyboardIcon />
</StyledIconWrapper>
</Tooltip>
)}
</StyledIslandWrapper>
</Grow>
<div style={{ position: 'relative' }}>
<StyledIconWrapper
isEdgelessDark={isEdgelessDark}
data-testid="faq-icon"
>
<HelpIcon />
</StyledIconWrapper>
<StyledTransformIcon in={showContent}>
<CloseIcon />
</StyledTransformIcon>
</div>
</StyledIsland>
</>
);
};
export default HelpIsland;

View File

@@ -1,6 +1,6 @@
import { displayFlex, styled } from '@/styles';
export const StyledFAQ = styled('div')(({ theme }) => {
export const StyledIsland = styled('div')(({ theme }) => {
return {
width: '32px',
height: '32px',
@@ -12,24 +12,24 @@ export const StyledFAQ = styled('div')(({ theme }) => {
zIndex: theme.zIndex.popover,
};
});
export const StyledTransformIcon = styled.div<{ in: boolean }>(
({ in: isIn, theme }) => ({
height: '32px',
width: '32px',
borderRadius: '50%',
position: 'absolute',
left: '0',
right: '0',
bottom: '0',
top: '0',
margin: 'auto',
...displayFlex('center', 'center'),
opacity: isIn ? 1 : 0,
backgroundColor: isIn
? theme.colors.hoverBackground
: theme.colors.pageBackground,
})
);
export const StyledTransformIcon = styled('div', {
shouldForwardProp: prop => prop !== 'in',
})<{ in: boolean }>(({ in: isIn, theme }) => ({
height: '32px',
width: '32px',
borderRadius: '50%',
position: 'absolute',
left: '0',
right: '0',
bottom: '0',
top: '0',
margin: 'auto',
...displayFlex('center', 'center'),
opacity: isIn ? 1 : 0,
backgroundColor: isIn
? theme.colors.hoverBackground
: theme.colors.pageBackground,
}));
export const StyledIconWrapper = styled('div')<{ isEdgelessDark: boolean }>(
({ theme, isEdgelessDark }) => {
return {
@@ -57,7 +57,7 @@ export const StyledIconWrapper = styled('div')<{ isEdgelessDark: boolean }>(
}
);
export const StyledFAQWrapper = styled('div')(({ theme }) => {
export const StyledIslandWrapper = styled('div')(({ theme }) => {
return {
position: 'absolute',
bottom: '100%',

View File

@@ -0,0 +1,65 @@
import { Modal, ModalWrapper, ModalCloseButton } from '@/ui/modal';
import { StyledButtonWrapper, StyledTitle } from './styles';
import { Button } from '@/ui/button';
import { Wrapper, Content } from '@/ui/layout';
import Loading from '@/components/loading';
import { useEffect, useState } from 'react';
type ImportModalProps = {
open: boolean;
onClose: () => void;
};
export const ImportModal = ({ open, onClose }: ImportModalProps) => {
const [status, setStatus] = useState<'unImported' | 'importing'>('importing');
useEffect(() => {
if (status === 'importing') {
setTimeout(() => {
setStatus('unImported');
}, 1500);
}
}, [status]);
return (
<Modal open={open} onClose={onClose}>
<ModalWrapper width={460} minHeight={240}>
<ModalCloseButton onClick={onClose} />
<StyledTitle>Import</StyledTitle>
{status === 'unImported' && (
<StyledButtonWrapper>
<Button
onClick={() => {
setStatus('importing');
}}
>
Markdown
</Button>
<Button
onClick={() => {
setStatus('importing');
}}
>
HTML
</Button>
</StyledButtonWrapper>
)}
{status === 'importing' && (
<Wrapper
wrap={true}
justifyContent="center"
style={{ marginTop: 22, paddingBottom: '32px' }}
>
<Loading size={25}></Loading>
<Content align="center" weight="500">
OOOOPS! Sorry forgot to remind you that we are working on the
import function
</Content>
</Wrapper>
)}
</ModalWrapper>
</Modal>
);
};
export default ImportModal;

View File

@@ -0,0 +1,25 @@
import { styled } from '@/styles';
export const StyledTitle = styled.div(({ theme }) => {
return {
fontSize: theme.font.h6,
fontWeight: 600,
textAlign: 'center',
marginTop: '45px',
color: theme.colors.popoverColor,
};
});
export const StyledButtonWrapper = styled.div(() => {
return {
width: '280px',
margin: '24px auto 0',
button: {
display: 'block',
width: '100%',
':not(:last-child)': {
marginBottom: '16px',
},
},
};
});

View File

@@ -0,0 +1,251 @@
import { EmailIcon } from '@blocksuite/icons';
import { styled } from '@/styles';
import { Modal, ModalWrapper, ModalCloseButton } from '@/ui/modal';
import { Button } from '@/ui/button';
import Input from '@/ui/input';
import { useState } from 'react';
import { inviteMember, getUserByEmail } from '@affine/data-services';
import { Avatar } from '@mui/material';
interface LoginModalProps {
open: boolean;
onClose: () => void;
workspaceId: string;
onInviteSuccess: () => void;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const debounce = <T extends (...args: any) => any>(
fn: T,
time?: number,
immediate?: boolean
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): ((...args: any) => any) => {
let timeoutId: null | number;
let defaultImmediate = immediate || false;
const delay = time || 300;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (...args: any) => {
if (defaultImmediate) {
fn.apply(this, args);
defaultImmediate = false;
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
}
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
timeoutId = setTimeout(() => {
fn.apply(this, args);
timeoutId = null;
}, delay);
};
};
const gmailReg = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@gmail\.com$/;
export const InviteMembers = ({
open,
onClose,
workspaceId,
onInviteSuccess,
}: LoginModalProps) => {
const [email, setEmail] = useState<string>('');
const [showMember, setShowMember] = useState<boolean>(false);
const [showTip, setShowTip] = useState<boolean>(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [userData, setUserData] = useState<any>({});
const inputChange = (value: string) => {
setEmail(value);
setShowMember(true);
if (gmailReg.test(value)) {
setShowTip(false);
debounce(
() => {
getUserByEmail({
email: value,
workspace_id: workspaceId,
}).then(data => {
if (data?.name) {
setUserData(data);
setShowTip(false);
}
});
},
300,
true
)();
} else {
setShowTip(true);
}
};
return (
<div>
<Modal open={open} onClose={onClose}>
<ModalWrapper width={460} height={236}>
<Header>
<ModalCloseButton
top={6}
right={6}
onClick={() => {
onClose();
}}
/>
</Header>
<Content>
<ContentTitle>Invite members</ContentTitle>
<InviteBox>
<Input
width={360}
value={email}
onChange={inputChange}
onBlur={() => {
setShowMember(false);
}}
placeholder="Search mail (Gmail support only)"
></Input>
{showMember ? (
<Members>
{showTip ? (
<NoFind>Non-Gmail is not supported</NoFind>
) : (
<Member>
{userData?.avatar_url ? (
<Avatar src={userData?.avatar_url}></Avatar>
) : (
<MemberIcon>
<EmailIcon></EmailIcon>
</MemberIcon>
)}
<Email>{email}</Email>
{/* <div>invited</div> */}
</Member>
)}
</Members>
) : (
<></>
)}
</InviteBox>
</Content>
<Footer>
<Button
shape="circle"
type="primary"
onClick={() => {
inviteMember({ id: workspaceId, email: email })
.then(() => {
onClose();
onInviteSuccess && onInviteSuccess();
})
.catch(err => {
// toast('Invite failed');
console.log(err);
});
}}
>
Invite
</Button>
</Footer>
</ModalWrapper>
</Modal>
</div>
);
};
const Header = styled('div')({
position: 'relative',
height: '44px',
});
const Content = styled('div')({
display: 'flex',
padding: '0 48px',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
});
const ContentTitle = styled('h1')({
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
textAlign: 'center',
paddingBottom: '16px',
});
const Footer = styled('div')({
height: '70px',
paddingLeft: '24px',
marginTop: '32px',
textAlign: 'center',
});
const InviteBox = styled('div')({
position: 'relative',
});
const Members = styled('div')(({ theme }) => {
return {
position: 'absolute',
width: '100%',
background: theme.colors.pageBackground,
textAlign: 'left',
zIndex: 1,
borderRadius: '0px 10px 10px 10px',
height: '56px',
padding: '8px 12px',
input: {
'&::placeholder': {
color: theme.colors.placeHolderColor,
},
},
};
});
const NoFind = styled('div')(({ theme }) => {
return {
color: theme.colors.iconColor,
fontSize: theme.font.sm,
lineHeight: '40px',
userSelect: 'none',
width: '100%',
};
});
const Member = styled('div')(({ theme }) => {
return {
color: theme.colors.iconColor,
fontSize: theme.font.sm,
lineHeight: '40px',
userSelect: 'none',
display: 'flex',
};
});
const MemberIcon = styled('div')(({ theme }) => {
return {
width: '40px',
height: '40px',
borderRadius: '50%',
color: theme.colors.primaryColor,
background: '#F5F5F5',
marginRight: '8px',
textAlign: 'center',
lineHeight: '45px',
// icon size
fontSize: '20px',
overflow: 'hidden',
img: {
width: '100%',
height: '100%',
},
};
});
const Email = styled('div')(({ theme }) => {
return {
flex: '1',
color: theme.colors.popoverColor,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});

View File

@@ -1,14 +1,3 @@
import { StyledLoading, StyledLoadingItem } from './styled';
export const Loading = () => {
return (
<StyledLoading>
<StyledLoadingItem />
<StyledLoadingItem />
<StyledLoadingItem />
<StyledLoadingItem />
</StyledLoading>
);
};
import Loading from './loading';
export * from './page-loading';
export default Loading;

View File

@@ -0,0 +1,20 @@
import {
StyledLoadingWrapper,
StyledLoading,
StyledLoadingItem,
} from './styled';
export const Loading = ({ size = 40 }: { size?: number }) => {
return (
<StyledLoadingWrapper size={size}>
<StyledLoading>
<StyledLoadingItem size={size} />
<StyledLoadingItem size={size} />
<StyledLoadingItem size={size} />
<StyledLoadingItem size={size} />
</StyledLoading>
</StyledLoadingWrapper>
);
};
export default Loading;

View File

@@ -0,0 +1,31 @@
import { styled } from '@/styles';
import Loading from './loading';
// Used for the full page loading
const StyledLoadingContainer = styled('div')(() => {
return {
height: '100vh',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: '#6880FF',
h1: {
fontSize: '2em',
marginTop: '15px',
fontWeight: '600',
},
};
});
export const PageLoading = ({ text = 'Loading...' }: { text?: string }) => {
return (
<StyledLoadingContainer>
<div className="wrapper">
<Loading />
<h1>{text}</h1>
</div>
</StyledLoadingContainer>
);
};
export default PageLoading;

View File

@@ -1,14 +1,19 @@
import { styled } from '@/styles';
// Inspired by https://codepen.io/graphilla/pen/rNvBMYY
const loadingItemSize = '40px';
export const StyledLoadingWrapper = styled.div<{ size?: number }>(
({ size = 40 }) => {
return {
width: size * 4,
height: size * 4,
position: 'relative',
};
}
);
export const StyledLoading = styled.div`
position: relative;
left: 50%;
transform: rotateX(55deg) rotateZ(-45deg)
translate(calc(${loadingItemSize} * -2), 0);
margin-bottom: calc(3 * ${loadingItemSize});
position: absolute;
left: 25%;
top: 25%;
@keyframes slide {
0% {
transform: translate(var(--sx), var(--sy));
@@ -21,24 +26,14 @@ export const StyledLoading = styled.div`
transform: translate(var(--ex), var(--ey));
}
}
@keyframes load {
20% {
content: '.';
}
40% {
content: '..';
}
80%,
100% {
content: '...';
}
}
`;
export const StyledLoadingItem = styled.div`
export const StyledLoadingItem = styled.div<{ size: number }>(
({ size = 40 }) => {
return `
position: absolute;
width: ${loadingItemSize};
height: ${loadingItemSize};
width: ${size}px;
height: ${size}px;
background: #9dacf9;
animation: slide 0.9s cubic-bezier(0.65, 0.53, 0.59, 0.93) infinite;
@@ -92,3 +87,5 @@ export const StyledLoadingItem = styled.div`
--ey: -50%;
}
`;
}
);

View File

@@ -0,0 +1,118 @@
import { signInWithGoogle } from '@affine/data-services';
import { styled } from '@/styles';
import { Button } from '@/ui/button';
import { useModal } from '@/providers/global-modal-provider';
import { GoogleIcon, StayLogOutIcon } from './icons';
export const GoogleLoginButton = () => {
const { triggerLoginModal } = useModal();
return (
<StyledGoogleButton
onClick={() => {
signInWithGoogle()
.then(() => {
triggerLoginModal();
})
.catch(error => {
console.log('sign google error', error);
});
}}
>
<ButtonWrapper>
<IconWrapper>
<GoogleIcon />
</IconWrapper>
<TextWrapper>
<Title>Continue with Google</Title>
<Description>Set up an AFFiNE account to sync data</Description>
</TextWrapper>
</ButtonWrapper>
</StyledGoogleButton>
);
};
export const StayLogOutButton = () => {
return (
<StyledStayLogOutButton>
<ButtonWrapper>
<IconWrapper>
<StayLogOutIcon />
</IconWrapper>
<TextWrapper>
<Title>Stay logged out</Title>
<Description>All changes are saved locally</Description>
</TextWrapper>
</ButtonWrapper>
</StyledStayLogOutButton>
);
};
const StyledGoogleButton = styled(Button)(() => {
return {
width: '361px',
height: '56px',
padding: '4px',
background: '#6880FF',
color: '#fff',
'& > span': {
marginLeft: 0,
},
':hover': {
background: '#516BF4',
color: '#fff',
},
};
});
const StyledStayLogOutButton = styled(Button)(() => {
return {
width: '361px',
height: '56px',
padding: '4px',
'& > span': {
marginLeft: 0,
},
':hover': {
borderColor: '#6880FF',
},
};
});
const ButtonWrapper = styled('div')({
display: 'flex',
flexDirection: 'row',
});
const IconWrapper = styled('div')({
width: '48px',
height: '48px',
flex: '0 48px',
borderRadius: '5px',
overflow: 'hidden',
marginRight: '12px',
});
const TextWrapper = styled('div')({
flex: 1,
textAlign: 'left',
});
const Title = styled('h1')(() => {
return {
fontSize: '18px',
lineHeight: '26px',
fontWeight: 500,
};
});
const Description = styled('p')(() => {
return {
fontSize: '16px',
lineHeight: '22px',
fontWeight: 400,
};
});

View File

@@ -0,0 +1,6 @@
<svg width="25" height="24" viewBox="0 0 25 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22.3055 10.0415H21.5V10H12.5V14H18.1515C17.327 16.3285 15.1115 18 12.5 18C9.1865 18 6.5 15.3135 6.5 12C6.5 8.6865 9.1865 6 12.5 6C14.0295 6 15.421 6.577 16.4805 7.5195L19.309 4.691C17.523 3.0265 15.134 2 12.5 2C6.9775 2 2.5 6.4775 2.5 12C2.5 17.5225 6.9775 22 12.5 22C18.0225 22 22.5 17.5225 22.5 12C22.5 11.3295 22.431 10.675 22.3055 10.0415Z" fill="#FFC107"/>
<path d="M3.65234 7.3455L6.93784 9.755C7.82684 7.554 9.97984 6 12.4993 6C14.0288 6 15.4203 6.577 16.4798 7.5195L19.3083 4.691C17.5223 3.0265 15.1333 2 12.4993 2C8.65834 2 5.32734 4.1685 3.65234 7.3455Z" fill="#FF3D00"/>
<path d="M12.5002 22.0003C15.0832 22.0003 17.4302 21.0118 19.2047 19.4043L16.1097 16.7853C15.0719 17.5745 13.8039 18.0014 12.5002 18.0003C9.89916 18.0003 7.69066 16.3418 6.85866 14.0273L3.59766 16.5398C5.25266 19.7783 8.61366 22.0003 12.5002 22.0003Z" fill="#4CAF50"/>
<path d="M22.3055 10.0415H21.5V10H12.5V14H18.1515C17.7571 15.1082 17.0467 16.0766 16.108 16.7855L16.1095 16.7845L19.2045 19.4035C18.9855 19.6025 22.5 17 22.5 12C22.5 11.3295 22.431 10.675 22.3055 10.0415Z" fill="#1976D2"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,42 @@
import { CloudUnsyncedIcon } from '@blocksuite/icons';
import { styled } from '@/styles';
import GoogleSvg from './google.svg';
export const GoogleIcon = () => {
return (
<GoogleIconWrapper>
<picture>
<img src={GoogleSvg.src} alt="Google" />
</picture>
</GoogleIconWrapper>
);
};
const GoogleIconWrapper = styled('div')(({ theme }) => ({
width: '48px',
height: '48px',
background: theme.colors.pageBackground,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}));
export const StayLogOutIcon = () => {
return (
<StayLogOutWrapper>
<CloudUnsyncedIcon />
</StayLogOutWrapper>
);
};
const StayLogOutWrapper = styled('div')(({ theme }) => {
return {
width: '48px',
height: '48px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '24px',
background: theme.colors.hoverBackground,
};
});

View File

@@ -0,0 +1,68 @@
import { ResetIcon } from '@blocksuite/icons';
import { styled } from '@/styles';
import { Modal, ModalWrapper, ModalCloseButton } from '@/ui/modal';
import { TextButton } from '@/ui/button';
import { GoogleLoginButton, StayLogOutButton } from './LoginOptionButton';
interface LoginModalProps {
open: boolean;
onClose: () => void;
}
export const LoginModal = ({ open, onClose }: LoginModalProps) => {
return (
<Modal open={open} onClose={onClose} data-testid="login-modal">
<ModalWrapper width={620} height={334}>
<Header>
<ModalCloseButton
top={6}
right={6}
onClick={() => {
onClose();
}}
/>
</Header>
<Content>
<ContentTitle>Currently not logged in</ContentTitle>
<GoogleLoginButton />
<StayLogOutButton />
</Content>
<Footer>
<TextButton icon={<StyledResetIcon />}>Clear local data</TextButton>
</Footer>
</ModalWrapper>
</Modal>
);
};
const Header = styled('div')({
position: 'relative',
height: '44px',
});
const Content = styled('div')({
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '16px',
});
const ContentTitle = styled('h1')({
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
textAlign: 'center',
paddingBottom: '16px',
});
const Footer = styled('div')({
height: '70px',
paddingLeft: '24px',
marginTop: '32px',
});
const StyledResetIcon = styled(ResetIcon)({
marginRight: '12px',
width: '20px',
height: '20px',
});

View File

@@ -1,14 +1,8 @@
import React, { useState } from 'react';
import Modal from '@/ui/modal';
import Modal, { ModalCloseButton, ModalWrapper } from '@/ui/modal';
import getIsMobile from '@/utils/get-is-mobile';
import {
ModalWrapper,
StyledButton,
StyledCloseButton,
StyledContent,
StyledTitle,
} from './styles';
import CloseIcon from '@mui/icons-material/Close';
import { StyledButton, StyledContent, StyledTitle } from './styles';
import bg from './bg.png';
export const MobileModal = () => {
const [showModal, setShowModal] = useState(getIsMobile());
return (
@@ -18,14 +12,18 @@ export const MobileModal = () => {
setShowModal(false);
}}
>
<ModalWrapper>
<StyledCloseButton
<ModalWrapper
width={348}
height={388}
style={{ backgroundImage: `url(${bg.src})` }}
>
<ModalCloseButton
size={[30, 30]}
iconSize={[20, 20]}
onClick={() => {
setShowModal(false);
}}
>
<CloseIcon />
</StyledCloseButton>
/>
<StyledTitle>Ooops!</StyledTitle>
<StyledContent>

View File

@@ -1,38 +1,6 @@
import { displayFlex, styled } from '@/styles';
import bg from './bg.png';
export const ModalWrapper = styled.div(({ theme }) => {
return {
width: '348px',
height: '388px',
background: theme.colors.popoverBackground,
borderRadius: '28px',
position: 'relative',
backgroundImage: `url(${bg.src})`,
};
});
export const StyledCloseButton = styled.div(({ theme }) => {
return {
width: '66px',
height: '66px',
color: theme.colors.iconColor,
cursor: 'pointer',
...displayFlex('center', 'center'),
position: 'absolute',
right: '0',
top: '0',
svg: {
width: '15px',
height: '15px',
position: 'relative',
zIndex: 1,
},
};
});
export const StyledTitle = styled.div(({ theme }) => {
export const StyledTitle = styled.div(() => {
return {
...displayFlex('center', 'center'),
fontSize: '20px',
@@ -42,7 +10,7 @@ export const StyledTitle = styled.div(({ theme }) => {
};
});
export const StyledContent = styled.div(({ theme }) => {
export const StyledContent = styled.div(() => {
return {
padding: '0 40px',
marginTop: '32px',

View File

@@ -0,0 +1,27 @@
import localizedFormat from 'dayjs/plugin/localizedFormat';
import dayjs from 'dayjs';
import { PageMeta } from '@/providers/app-state-provider';
import { TableCell } from '@/ui/table';
import React from 'react';
dayjs.extend(localizedFormat);
export const DateCell = ({
pageMeta,
dateKey,
backupKey = '',
}: {
pageMeta: PageMeta;
dateKey: keyof PageMeta;
backupKey?: keyof PageMeta;
}) => {
// dayjs().format('L LT');
const value = pageMeta[dateKey] ?? pageMeta[backupKey];
return (
<TableCell ellipsis={true}>
{value ? dayjs(value as string).format('YYYY-MM-DD HH:mm') : '--'}
</TableCell>
);
};
export default DateCell;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Empty } from '@/ui/empty';
export const PageListEmpty = () => {
return (
<div style={{ textAlign: 'center' }}>
<Empty
width={800}
height={300}
sx={{ marginTop: '100px', marginBottom: '30px' }}
/>
<p>Tips: Click Add to Favourites/Trash and the page will appear here.</p>
<p>(Designer is grappling with designing)</p>
</div>
);
};
export default PageListEmpty;

View File

@@ -0,0 +1,145 @@
import { PageMeta } from '@/providers/app-state-provider';
import {
FavouritedIcon,
FavouritesIcon,
PaperIcon,
EdgelessIcon,
} from '@blocksuite/icons';
import {
StyledTableContainer,
StyledTableRow,
StyledTitleLink,
StyledTitleWrapper,
} from './styles';
import { Table, TableBody, TableCell, TableHead, TableRow } from '@/ui/table';
import { OperationCell, TrashOperationCell } from './operation-cell';
import Empty from './empty';
import { Content } from '@/ui/layout';
import React from 'react';
import DateCell from '@/components/page-list/date-cell';
import { IconButton } from '@/ui/button';
import { Tooltip } from '@/ui/tooltip';
import { useRouter } from 'next/router';
import { useAppState } from '@/providers/app-state-provider/context';
import { toast } from '@/ui/toast';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useTheme } from '@/providers/themeProvider';
const FavoriteTag = ({
pageMeta: { favorite, id },
}: {
pageMeta: PageMeta;
}) => {
const { toggleFavoritePage } = usePageHelper();
const { theme } = useTheme();
return (
<Tooltip
content={favorite ? 'Favourited' : 'Favourite'}
placement="top-start"
>
<IconButton
darker={true}
iconSize={[20, 20]}
onClick={e => {
e.stopPropagation();
toggleFavoritePage(id);
toast(!favorite ? 'Removed to Favourites' : 'Added to Favourites');
}}
style={{
color: favorite ? theme.colors.primaryColor : theme.colors.iconColor,
}}
className="favorite-button"
>
{favorite ? (
<FavouritedIcon data-testid="favourited-icon" />
) : (
<FavouritesIcon />
)}
</IconButton>
</Tooltip>
);
};
export const PageList = ({
pageList,
showFavoriteTag = false,
isTrash = false,
}: {
pageList: PageMeta[];
showFavoriteTag?: boolean;
isTrash?: boolean;
}) => {
const router = useRouter();
const { currentWorkspaceId } = useAppState();
if (pageList.length === 0) {
return <Empty />;
}
return (
<StyledTableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell proportion={0.5}>Title</TableCell>
<TableCell proportion={0.2}>Created</TableCell>
<TableCell proportion={0.2}>
{isTrash ? 'Moved to Trash' : 'Updated'}
</TableCell>
<TableCell proportion={0.1}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{pageList.map((pageMeta, index) => {
return (
<StyledTableRow
key={`${pageMeta.id}-${index}`}
onClick={() => {
router.push(
`/workspace/${currentWorkspaceId}/${pageMeta.id}`
);
}}
>
<TableCell>
<StyledTitleWrapper>
<StyledTitleLink>
{pageMeta.mode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PaperIcon />
)}
<Content ellipsis={true} color="inherit">
{pageMeta.title || 'Untitled'}
</Content>
</StyledTitleLink>
{showFavoriteTag && <FavoriteTag pageMeta={pageMeta} />}
</StyledTitleWrapper>
</TableCell>
<DateCell pageMeta={pageMeta} dateKey="createDate" />
<DateCell
pageMeta={pageMeta}
dateKey={isTrash ? 'trashDate' : 'updatedDate'}
backupKey={isTrash ? 'trashDate' : 'createDate'}
/>
<TableCell
style={{ padding: 0 }}
data-testid={`more-actions-${pageMeta.id}`}
onClick={e => {
e.stopPropagation();
}}
>
{isTrash ? (
<TrashOperationCell pageMeta={pageMeta} />
) : (
<OperationCell pageMeta={pageMeta} />
)}
</TableCell>
</StyledTableRow>
);
})}
</TableBody>
</Table>
</StyledTableContainer>
);
};
export default PageList;

View File

@@ -0,0 +1,109 @@
import { useConfirm } from '@/providers/confirm-provider';
import { PageMeta } from '@/providers/app-state-provider';
import { Menu, MenuItem } from '@/ui/menu';
import { Wrapper } from '@/ui/layout';
import { IconButton } from '@/ui/button';
import {
MoreVerticalIcon,
RestoreIcon,
DeleteIcon,
FavouritesIcon,
FavouritedIcon,
OpenInNewIcon,
TrashIcon,
} from '@blocksuite/icons';
import { toast } from '@/ui/toast';
import { usePageHelper } from '@/hooks/use-page-helper';
export const OperationCell = ({ pageMeta }: { pageMeta: PageMeta }) => {
const { id, favorite } = pageMeta;
const { openPage } = usePageHelper();
const { toggleFavoritePage, toggleDeletePage } = usePageHelper();
const { confirm } = useConfirm();
const OperationMenu = (
<>
<MenuItem
onClick={() => {
toggleFavoritePage(id);
toast(!favorite ? 'Removed to Favourites' : 'Added to Favourites');
}}
icon={favorite ? <FavouritedIcon /> : <FavouritesIcon />}
>
{favorite ? 'Remove' : 'Add'} to favourites
</MenuItem>
<MenuItem
onClick={() => {
openPage(id, {}, true);
}}
icon={<OpenInNewIcon />}
>
Open in new tab
</MenuItem>
<MenuItem
onClick={() => {
confirm({
title: 'Delete page?',
content: `${pageMeta.title || 'Untitled'} will be moved to Trash`,
confirmText: 'Delete',
confirmType: 'danger',
}).then(confirm => {
confirm && toggleDeletePage(id);
toast('Moved to Trash');
});
}}
icon={<TrashIcon />}
>
Delete
</MenuItem>
</>
);
return (
<Wrapper alignItems="center" justifyContent="center">
<Menu content={OperationMenu} placement="bottom-end" disablePortal={true}>
<IconButton darker={true}>
<MoreVerticalIcon />
</IconButton>
</Menu>
</Wrapper>
);
};
export const TrashOperationCell = ({ pageMeta }: { pageMeta: PageMeta }) => {
const { id } = pageMeta;
const { openPage, getPageMeta } = usePageHelper();
const { toggleDeletePage, permanentlyDeletePage } = usePageHelper();
const { confirm } = useConfirm();
return (
<Wrapper>
<IconButton
darker={true}
style={{ marginRight: '12px' }}
onClick={() => {
toggleDeletePage(id);
toast(`${getPageMeta(id)?.title || 'Untitled'} restored`);
openPage(id);
}}
>
<RestoreIcon />
</IconButton>
<IconButton
darker={true}
onClick={() => {
confirm({
title: 'Delete permanently?',
content: "Once deleted, you can't undo this action.",
confirmText: 'Delete',
confirmType: 'danger',
}).then(confirm => {
confirm && permanentlyDeletePage(id);
toast('Permanently deleted');
});
}}
>
<DeleteIcon />
</IconButton>
</Wrapper>
);
};

View File

@@ -0,0 +1,51 @@
import { displayFlex, styled } from '@/styles';
import { TableRow } from '@/ui/table';
export const StyledTableContainer = styled.div(() => {
return {
height: 'calc(100vh - 60px)',
padding: '78px 72px',
overflowY: 'auto',
};
});
export const StyledTitleWrapper = styled.div(({ theme }) => {
return {
...displayFlex('flex-start', 'center'),
a: {
color: 'inherit',
},
'a:visited': {
color: 'unset',
},
'a:hover': {
color: theme.colors.primaryColor,
},
};
});
export const StyledTitleLink = styled.div(({ theme }) => {
return {
maxWidth: '80%',
marginRight: '18px',
...displayFlex('flex-start', 'center'),
color: theme.colors.textColor,
'>svg': {
fontSize: '24px',
marginRight: '12px',
color: theme.colors.iconColor,
},
};
});
export const StyledTableRow = styled(TableRow)(() => {
return {
cursor: 'pointer',
'.favorite-button': {
display: 'none',
},
'&:hover': {
'.favorite-button': {
display: 'flex',
},
},
};
});

View File

@@ -0,0 +1,18 @@
import { cloneElement, FC, PropsWithChildren, ReactNode } from 'react';
export const ProviderComposer: FC<
PropsWithChildren<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
contexts: any;
}>
> = ({ contexts, children }) =>
contexts.reduceRight(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(kids: ReactNode, parent: any) =>
cloneElement(parent, {
children: kids,
}),
children
);
export default ProviderComposer;

View File

@@ -0,0 +1,24 @@
import { AllPagesIcon, FavouritesIcon, TrashIcon } from '@blocksuite/icons';
export const config = (currentWorkspaceId: string) => {
const List = [
{
title: 'All pages',
href: currentWorkspaceId ? `/workspace/${currentWorkspaceId}/all` : '',
icon: AllPagesIcon,
},
{
title: 'Favourites',
href: currentWorkspaceId
? `/workspace/${currentWorkspaceId}/favorite`
: '',
icon: FavouritesIcon,
},
{
title: 'Trash',
href: currentWorkspaceId ? `/workspace/${currentWorkspaceId}/trash` : '',
icon: TrashIcon,
},
];
return List;
};

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { AddIcon } from '@blocksuite/icons';
import { StyledModalFooterContent } from './style';
import { useModal } from '@/providers/global-modal-provider';
import { Command } from 'cmdk';
import { usePageHelper } from '@/hooks/use-page-helper';
export const Footer = (props: { query: string }) => {
const { triggerQuickSearchModal } = useModal();
const { openPage, createPage } = usePageHelper();
const query = props.query;
return (
<Command.Item
data-testid="quickSearch-addNewPage"
onSelect={async () => {
const pageId = await createPage({ title: query });
if (pageId) {
openPage(pageId);
}
triggerQuickSearchModal();
}}
>
<StyledModalFooterContent>
<AddIcon />
{query ? (
<span>New &quot;{query}&quot; page</span>
) : (
<span>New page</span>
)}
</StyledModalFooterContent>
</Command.Item>
);
};

View File

@@ -0,0 +1,118 @@
import { Modal, ModalWrapper } from '@/ui/modal';
import {
StyledContent,
StyledModalHeader,
StyledModalFooter,
StyledModalDivider,
StyledShortcut,
} from './style';
import { Input } from './input';
import { Results } from './results';
import { Footer } from './footer';
import { Command } from 'cmdk';
import { useEffect, useState } from 'react';
import { useModal } from '@/providers/global-modal-provider';
import { getUaHelper } from '@/utils';
import { useAppState } from '@/providers/app-state-provider';
type TransitionsModalProps = {
open: boolean;
onClose: () => void;
};
const isMac = () => {
return getUaHelper().isMacOs;
};
export const QuickSearch = ({ open, onClose }: TransitionsModalProps) => {
const [query, setQuery] = useState('');
const [loading, setLoading] = useState(true);
const [showCreatePage, setShowCreatePage] = useState(true);
const { triggerQuickSearchModal } = useModal();
const { currentWorkspaceId, workspacesMeta } = useAppState();
const currentWorkspace = workspacesMeta.find(
meta => String(meta.id) === String(currentWorkspaceId)
);
const isPublic = currentWorkspace?.public;
// Add ‘⌘+K shortcut keys as switches
useEffect(() => {
const down = (e: KeyboardEvent) => {
if ((e.key === 'k' && e.metaKey) || (e.key === 'k' && e.ctrlKey)) {
const selection = window.getSelection();
if (selection?.toString()) {
triggerQuickSearchModal(false);
return;
}
if (selection?.isCollapsed) {
triggerQuickSearchModal(!open);
}
}
};
if (!open) {
setQuery('');
}
document.addEventListener('keydown', down, { capture: true });
return () =>
document.removeEventListener('keydown', down, { capture: true });
}, [open, triggerQuickSearchModal]);
return (
<Modal
open={open}
onClose={onClose}
wrapperPosition={['top', 'center']}
data-testid="quickSearch"
>
<ModalWrapper
width={620}
style={{
maxHeight: '80vh',
minHeight: '350px',
top: '12vh',
}}
>
<Command
shouldFilter={false}
//Handle KeyboardEvent conflicts with blocksuite
onKeyDown={(e: React.KeyboardEvent) => {
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight'
) {
e.stopPropagation();
}
}}
>
<StyledModalHeader>
<Input query={query} setQuery={setQuery} setLoading={setLoading} />
<StyledShortcut>{isMac() ? '⌘ + K' : 'Ctrl + K'}</StyledShortcut>
</StyledModalHeader>
<StyledModalDivider />
<Command.List>
<StyledContent>
<Results
query={query}
loading={loading}
setLoading={setLoading}
setShowCreatePage={setShowCreatePage}
/>
</StyledContent>
{isPublic ? (
<></>
) : showCreatePage ? (
<>
<StyledModalDivider />
<StyledModalFooter>
<Footer query={query} />
</StyledModalFooter>
</>
) : null}
</Command.List>
</Command>
</ModalWrapper>
</Modal>
);
};
export default QuickSearch;

View File

@@ -0,0 +1,90 @@
import React, {
Dispatch,
SetStateAction,
useEffect,
useRef,
useState,
} from 'react';
import { SearchIcon } from '@blocksuite/icons';
import { StyledInputContent, StyledLabel } from './style';
import { Command } from 'cmdk';
import { useAppState } from '@/providers/app-state-provider';
export const Input = (props: {
query: string;
setQuery: Dispatch<SetStateAction<string>>;
setLoading: Dispatch<SetStateAction<boolean>>;
}) => {
const [isComposition, setIsComposition] = useState(false);
const [inputValue, setInputValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const { currentWorkspaceId, workspacesMeta, currentWorkspace } =
useAppState();
const isPublic = workspacesMeta.find(
meta => String(meta.id) === String(currentWorkspaceId)
)?.public;
useEffect(() => {
inputRef.current?.addEventListener(
'blur',
() => {
inputRef.current?.focus();
},
true
);
return inputRef.current?.focus();
}, [inputRef]);
useEffect(() => {
return setInputValue(props.query);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<StyledInputContent>
<StyledLabel htmlFor=":r5:">
<SearchIcon />
</StyledLabel>
<Command.Input
ref={inputRef}
value={inputValue}
onCompositionStart={() => {
setIsComposition(true);
}}
onCompositionEnd={e => {
props.setQuery(e.data);
setIsComposition(false);
if (!props.query) {
props.setLoading(true);
}
}}
onValueChange={str => {
setInputValue(str);
if (!isComposition) {
props.setQuery(str);
if (!props.query) {
props.setLoading(true);
}
}
}}
onKeyDown={(e: React.KeyboardEvent) => {
if (e.key === 'a' && e.metaKey) {
e.stopPropagation();
inputRef.current?.select();
return;
}
if (isComposition) {
if (
e.key === 'ArrowDown' ||
e.key === 'ArrowUp' ||
e.key === 'Enter'
) {
e.stopPropagation();
}
}
}}
placeholder={
isPublic
? `Search in ${currentWorkspace?.meta.name}`
: 'Quick Search...'
}
/>
</StyledInputContent>
);
};

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,102 @@
import { Command } from 'cmdk';
import { StyledListItem, StyledNotFound } from './style';
import { useModal } from '@/providers/global-modal-provider';
import { PaperIcon, EdgelessIcon } from '@blocksuite/icons';
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
import { useAppState } from '@/providers/app-state-provider';
import { useRouter } from 'next/router';
import { config } from './config';
import { NoResultSVG } from './noResultSVG';
import usePageHelper from '@/hooks/use-page-helper';
import usePageMetaList from '@/hooks/use-page-meta-list';
export const Results = (props: {
query: string;
loading: boolean;
setLoading: Dispatch<SetStateAction<boolean>>;
setShowCreatePage: Dispatch<SetStateAction<boolean>>;
}) => {
const query = props.query;
const loading = props.loading;
const setLoading = props.setLoading;
const setShowCreatePage = props.setShowCreatePage;
const { triggerQuickSearchModal } = useModal();
const pageMetaList = usePageMetaList();
const { openPage } = usePageHelper();
const router = useRouter();
const { currentWorkspaceId } = useAppState();
const { search } = usePageHelper();
const List = config(currentWorkspaceId);
const [results, setResults] = useState(new Map<string, string | undefined>());
useEffect(() => {
setResults(search(query));
setLoading(false);
//Save the Map<BlockId, PageId> obtained from the search as state
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query, setResults, setLoading]);
const pageIds = [...results.values()];
const resultsPageMeta = pageMetaList.filter(
page => pageIds.indexOf(page.id) > -1 && !page.trash
);
useEffect(() => {
setShowCreatePage(resultsPageMeta.length ? false : true);
//Determine whether to display the + New page
}, [resultsPageMeta, setShowCreatePage]);
return loading ? null : (
<>
{query ? (
resultsPageMeta.length ? (
<Command.Group heading={`Find ${resultsPageMeta.length} results`}>
{resultsPageMeta.map(result => {
return (
<Command.Item
key={result.id}
onSelect={() => {
openPage(result.id);
triggerQuickSearchModal();
}}
value={result.id}
>
<StyledListItem>
{result.mode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PaperIcon />
)}
<span>{result.title}</span>
</StyledListItem>
</Command.Item>
);
})}
</Command.Group>
) : (
<StyledNotFound>
<span>Find 0 result</span>
<NoResultSVG />
</StyledNotFound>
)
) : (
<Command.Group heading="Switch to">
{List.map(link => {
return (
<Command.Item
key={link.title}
value={link.title}
onSelect={() => {
router.push(link.href);
triggerQuickSearchModal();
}}
>
<StyledListItem>
<link.icon />
<span>{link.title}</span>
</StyledListItem>
</Command.Item>
);
})}
</Command.Group>
)}
</>
);
};

View File

@@ -0,0 +1,153 @@
import { displayFlex, styled } from '@/styles';
export const StyledContent = styled('div')(({ theme }) => {
return {
minHeight: '220px',
maxHeight: '55vh',
width: '100%',
overflow: 'auto',
marginBottom: '10px',
...displayFlex('center', 'flex-start'),
color: theme.colors.popoverColor,
letterSpacing: '0.06em',
'[cmdk-group-heading]': {
margin: '5px 16px',
fontSize: theme.font.sm,
fontWeight: '500',
},
'[aria-selected="true"]': {
transition: 'background .15s, color .15s',
borderRadius: '5px',
color: theme.colors.primaryColor,
backgroundColor: theme.colors.hoverBackground,
},
};
});
export const StyledJumpTo = styled('div')(({ theme }) => {
return {
...displayFlex('center', 'start'),
flexDirection: 'column',
padding: '10px 10px 10px 0',
fontSize: theme.font.sm,
strong: {
fontWeight: '500',
marginBottom: '10px',
},
};
});
export const StyledNotFound = styled('div')(({ theme }) => {
return {
width: '612px',
...displayFlex('center', 'center'),
flexDirection: 'column',
padding: '5px 16px',
fontSize: theme.font.sm,
span: {
width: '100%',
fontWeight: '500',
},
'>svg': {
marginTop: '10px',
fontSize: '150px',
},
};
});
export const StyledInputContent = styled('div')(({ theme }) => {
return {
margin: '13px 0',
...displayFlex('space-between', 'center'),
input: {
width: '492px',
height: '22px',
padding: '0 12px',
fontSize: theme.font.base,
...displayFlex('space-between', 'center'),
letterSpacing: '0.06em',
color: theme.colors.popoverColor,
'::placeholder': {
color: theme.colors.placeHolderColor,
},
},
};
});
export const StyledShortcut = styled('div')(({ theme }) => {
return { color: theme.colors.placeHolderColor, fontSize: theme.font.sm };
});
export const StyledLabel = styled('label')(({ theme }) => {
return {
width: '24px',
height: '24px',
color: theme.colors.iconColor,
};
});
export const StyledModalHeader = styled('div')(({ theme }) => {
return {
height: '48px',
margin: '12px 24px 0px 24px',
...displayFlex('space-between', 'center'),
color: theme.colors.popoverColor,
};
});
export const StyledModalDivider = styled('div')(({ theme }) => {
return {
width: 'auto',
height: '0',
margin: '6px 16px 6.5px 16px',
position: 'relative',
borderTop: `0.5px solid ${theme.colors.placeHolderColor}`,
};
});
export const StyledModalFooter = styled('div')(({ theme }) => {
return {
fontSize: theme.font.sm,
lineHeight: '22px',
marginBottom: '8px',
textAlign: 'center',
...displayFlex('center', 'center'),
color: theme.colors.popoverColor,
'[aria-selected="true"]': {
transition: 'background .15s, color .15s',
borderRadius: '5px',
color: theme.colors.primaryColor,
backgroundColor: theme.colors.hoverBackground,
},
};
});
export const StyledModalFooterContent = styled.button(({ theme }) => {
return {
width: '612px',
height: '32px',
fontSize: theme.font.sm,
lineHeight: '22px',
textAlign: 'center',
...displayFlex('center', 'center'),
color: 'inherit',
borderRadius: '5px',
transition: 'background .15s, color .15s',
'>svg': {
fontSize: '20px',
marginRight: '12px',
},
};
});
export const StyledListItem = styled.button(({ theme }) => {
return {
width: '612px',
height: '32px',
fontSize: theme.font.sm,
color: 'inherit',
paddingLeft: '12px',
borderRadius: '5px',
transition: 'background .15s, color .15s',
...displayFlex('flex-start', 'center'),
'>svg': {
fontSize: '20px',
marginRight: '12px',
},
};
});

View File

@@ -6,6 +6,7 @@ export const macKeyboardShortcuts = {
Underline: '⌘+U',
Strikethrough: '⌘+⇧+S',
'Inline code': ' ⌘+E',
'Code block': '⌘+⌥+C',
Link: '⌘+K',
'Body text': '⌘+⌥+0',
'Heading 1': '⌘+⌥+1',
@@ -23,7 +24,9 @@ export const macMarkdownShortcuts = {
Italic: '*Text* ',
Underline: '~Text~ ',
Strikethrough: '~~Text~~ ',
Divider: '***',
'Inline code': '`Text` ',
'Code block': '``` Space',
'Heading 1': '# Text',
'Heading 2': '## Text',
'Heading 3': '### Text',
@@ -40,6 +43,7 @@ export const windowsKeyboardShortcuts = {
Underline: 'Ctrl+U',
Strikethrough: 'Ctrl+Shift+S',
'Inline code': ' Ctrl+E',
'Code block': 'Ctrl+Alt+C',
Link: 'Ctrl+K',
'Body text': 'Ctrl+Shift+0',
'Heading 1': 'Ctrl+Shift+1',
@@ -57,6 +61,7 @@ export const winMarkdownShortcuts = {
Underline: '~Text~ ',
Strikethrough: '~~Text~~ ',
'Inline code': '`Text` ',
'Code block': '``` Text',
'Heading 1': '# Text',
'Heading 2': '## Text',
'Heading 3': '### Text',

View File

@@ -6,7 +6,6 @@ import {
StyledShortcutsModal,
StyledSubTitle,
StyledTitle,
CloseButton,
} from './style';
import {
macKeyboardShortcuts,
@@ -14,15 +13,16 @@ import {
windowsKeyboardShortcuts,
winMarkdownShortcuts,
} from '@/components/shortcuts-modal/config';
import CloseIcon from '@mui/icons-material/Close';
import Slide from '@mui/material/Slide';
import { ModalCloseButton } from '@/ui/modal';
import { getUaHelper } from '@/utils';
type ModalProps = {
open: boolean;
onClose: () => void;
};
const isMac = () => {
return /macintosh|mac os x/i.test(navigator.userAgent);
return getUaHelper().isMacOs;
};
export const ShortcutsModal = ({ open, onClose }: ModalProps) => {
@@ -42,13 +42,15 @@ export const ShortcutsModal = ({ open, onClose }: ModalProps) => {
Shortcuts
</StyledTitle>
<CloseButton
<ModalCloseButton
top={6}
right={6}
size={[24, 24]}
iconSize={[15, 15]}
onClick={() => {
onClose();
}}
>
<CloseIcon />
</CloseButton>
/>
</StyledModalHeader>
<StyledSubTitle style={{ marginTop: 0 }}>
Keyboard Shortcuts

View File

@@ -38,7 +38,7 @@ export const StyledSubTitle = styled.div(({ theme }) => ({
marginTop: '28px',
padding: '0 16px',
}));
export const StyledModalHeader = styled.div(({ theme }) => ({
export const StyledModalHeader = styled.div(() => ({
...displayFlex('space-between', 'center'),
paddingTop: '8px 4px 0 4px',
width: '100%',
@@ -57,22 +57,3 @@ export const StyledListItem = styled.div(({ theme }) => ({
fontSize: theme.font.sm,
padding: '0 16px',
}));
export const CloseButton = styled('div')(({ theme }) => {
return {
width: '24px',
height: '24px',
borderRadius: '5px',
color: theme.colors.iconColor,
cursor: 'pointer',
...displayFlex('center', 'center'),
svg: {
width: '15px',
height: '15px',
},
':hover': {
background: theme.colors.hoverBackground,
color: theme.colors.primaryColor,
},
};
});

View File

@@ -6,6 +6,7 @@ export const tagName = 'simple-counter';
// Adapt React in order to be able to use custom tags properly
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace JSX {
interface IntrinsicElements {
[tagName]: PersonInfoProps;
@@ -49,10 +50,10 @@ export class Counter extends LitElement {
</div>`;
}
private _increment(e: Event) {
private _increment() {
this.count++;
}
private _subtract(e: Event) {
private _subtract() {
this.count--;
}
}

View File

@@ -0,0 +1,35 @@
import HelpIsland from '@/components/help-island';
import { WorkSpaceSliderBar } from '@/components/workspace-slider-bar';
import { useRouter } from 'next/router';
import { StyledPage, StyledWrapper } from './styles';
import { PropsWithChildren } from 'react';
import useEnsureWorkspace from '@/hooks/use-ensure-workspace';
import { PageLoading } from '@/components/loading';
export const WorkspaceDefender = ({ children }: PropsWithChildren) => {
const { workspaceLoaded } = useEnsureWorkspace();
return <>{workspaceLoaded ? children : <PageLoading />}</>;
};
export const WorkspaceLayout = ({ children }: PropsWithChildren) => {
const router = useRouter();
return (
<StyledPage>
<WorkSpaceSliderBar />
<StyledWrapper>
{children}
<HelpIsland showList={router.query.pageId ? undefined : ['contact']} />
</StyledWrapper>
</StyledPage>
);
};
export const Layout = ({ children }: PropsWithChildren) => {
return (
<WorkspaceDefender>
<WorkspaceLayout>{children}</WorkspaceLayout>
</WorkspaceDefender>
);
};
export default Layout;

View File

@@ -0,0 +1,18 @@
import { styled } from '@/styles';
export const StyledPage = styled('div')(({ theme }) => {
return {
height: '100vh',
backgroundColor: theme.colors.pageBackground,
transition: 'background-color .5s',
display: 'flex',
flexGrow: '1',
};
});
export const StyledWrapper = styled('div')(() => {
return {
flexGrow: 1,
position: 'relative',
};
});

View File

@@ -0,0 +1,84 @@
import Modal from '@/ui/modal';
import Input from '@/ui/input';
import {
StyledModalHeader,
StyledTextContent,
StyledModalWrapper,
StyledInputContent,
StyledButtonContent,
StyledWorkspaceName,
} from './style';
import { useState } from 'react';
import { ModalCloseButton } from '@/ui/modal';
import { Button } from '@/ui/button';
import { deleteWorkspace } from '@affine/data-services';
import { useRouter } from 'next/router';
import { useAppState } from '@/providers/app-state-provider';
interface WorkspaceDeleteProps {
open: boolean;
onClose: () => void;
workspaceName: string;
workspaceId: string;
nextWorkSpaceId: string;
}
export const WorkspaceDelete = ({
open,
onClose,
workspaceId,
workspaceName,
nextWorkSpaceId,
}: WorkspaceDeleteProps) => {
const [deleteStr, setDeleteStr] = useState<string>('');
const { refreshWorkspacesMeta } = useAppState();
const router = useRouter();
const handlerInputChange = (workspaceName: string) => {
setDeleteStr(workspaceName);
};
const handleDelete = async () => {
await deleteWorkspace({ id: workspaceId });
router.push(`/workspace/${nextWorkSpaceId}`);
refreshWorkspacesMeta();
onClose();
};
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
<ModalCloseButton onClick={onClose} />
<StyledModalHeader>Delete Workspace</StyledModalHeader>
<StyledTextContent>
This action cannot be undone. This will permanently delete (
<StyledWorkspaceName>{workspaceName}</StyledWorkspaceName>) along with
all its content.
</StyledTextContent>
<StyledInputContent>
<Input
onChange={handlerInputChange}
placeholder="Please type “Delete” to confirm"
value={deleteStr}
></Input>
</StyledInputContent>
<StyledButtonContent>
<Button shape="circle" onClick={onClose}>
Cancel
</Button>
<Button
disabled={deleteStr.toLowerCase() !== 'delete'}
onClick={handleDelete}
type="danger"
shape="circle"
style={{ marginLeft: '24px' }}
>
Delete
</Button>
</StyledButtonContent>
</StyledModalWrapper>
</Modal>
);
};
export default WorkspaceDelete;

View File

@@ -0,0 +1 @@
export * from './delete';

View File

@@ -0,0 +1,75 @@
import { styled } from '@/styles';
export const StyledModalWrapper = styled('div')(({ theme }) => {
return {
position: 'relative',
padding: '0px',
width: '460px',
background: theme.colors.popoverBackground,
borderRadius: '12px',
};
});
export const StyledModalHeader = styled('div')(({ theme }) => {
return {
margin: '44px 0px 12px 0px',
width: '460px',
fontWeight: '600',
fontSize: '20px;',
textAlign: 'center',
color: theme.colors.popoverColor,
};
});
// export const StyledModalContent = styled('div')(({ theme }) => {});
export const StyledTextContent = styled('div')(() => {
return {
margin: 'auto',
width: '425px',
fontFamily: 'Avenir Next',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
};
});
export const StyledInputContent = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
margin: '40px 0 24px 0',
};
});
export const StyledButtonContent = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
margin: '0px 0 32px 0',
};
});
export const StyledWorkspaceName = styled('span')(() => {
return {
color: '#E8178A',
};
});
// export const StyledCancelButton = styled(Button)(({ theme }) => {
// return {
// width: '100px',
// justifyContent: 'center',
// };
// });
// export const StyledDeleteButton = styled(Button)(({ theme }) => {
// return {
// width: '100px',
// justifyContent: 'center',
// };
// });

View File

@@ -0,0 +1,166 @@
import {
StyledDeleteButtonContainer,
StyledSettingAvatar,
StyledSettingAvatarContent,
StyledSettingInputContainer,
} from './style';
import { StyledSettingH2 } from '../style';
import { useState } from 'react';
import { Button } from '@/ui/button';
import Input from '@/ui/input';
import { uploadBlob, Workspace, WorkspaceType } from '@affine/data-services';
import { useAppState } from '@/providers/app-state-provider';
import { WorkspaceDetails } from '@/components/workspace-slider-bar/WorkspaceSelector/SelectorPopperContent';
import { WorkspaceDelete } from './delete';
import { Workspace as StoreWorkspace } from '@blocksuite/store';
import { debounce } from '@/utils';
import { WorkspaceLeave } from './leave';
import { Upload } from '@/components/file-upload';
export const GeneralPage = ({
workspace,
owner,
}: {
workspace: Workspace;
owner: WorkspaceDetails[string]['owner'];
workspaces: Record<string, StoreWorkspace | null>;
}) => {
const {
user,
currentWorkspace,
workspacesMeta,
workspaces,
refreshWorkspacesMeta,
} = useAppState();
const [showDelete, setShowDelete] = useState<boolean>(false);
const [showLeave, setShowLeave] = useState<boolean>(false);
const [uploading, setUploading] = useState<boolean>(false);
const [workspaceName, setWorkspaceName] = useState<string>(
workspaces[workspace.id]?.meta.name ||
(workspace.type === WorkspaceType.Private && user ? user.name : '')
);
const debouncedRefreshWorkspacesMeta = debounce(() => {
refreshWorkspacesMeta();
}, 100);
const isOwner = user && owner.id === user.id;
const handleChangeWorkSpaceName = (newName: string) => {
setWorkspaceName(newName);
currentWorkspace?.meta.setName(newName);
workspaces[workspace.id]?.meta.setName(newName);
debouncedRefreshWorkspacesMeta();
};
const currentWorkspaceIndex = workspacesMeta.findIndex(
meta => meta.id === workspace.id
);
const nextWorkSpaceId =
currentWorkspaceIndex === workspacesMeta.length - 1
? workspacesMeta[currentWorkspaceIndex - 1]?.id
: workspacesMeta[currentWorkspaceIndex + 1]?.id;
const handleClickDelete = () => {
setShowDelete(true);
};
const handleCloseDelete = () => {
setShowDelete(false);
};
const handleClickLeave = () => {
setShowLeave(true);
};
const handleCloseLeave = () => {
setShowLeave(false);
};
const fileChange = async (file: File) => {
setUploading(true);
const blob = new Blob([file], { type: file.type });
const blobId = await uploadBlob({ blob }).finally(() => {
setUploading(false);
});
if (blobId) {
currentWorkspace?.meta.setAvatar(blobId);
workspaces[workspace.id]?.meta.setAvatar(blobId);
setUploading(false);
debouncedRefreshWorkspacesMeta();
}
};
return workspace ? (
<div>
<StyledSettingH2 marginTop={56}>Workspace Avatar</StyledSettingH2>
<StyledSettingAvatarContent>
<StyledSettingAvatar
alt="workspace avatar"
src={
workspaces[workspace.id]?.meta.avatar
? '/api/blob/' + workspaces[workspace.id]?.meta.avatar
: ''
}
>
{workspaces[workspace.id]?.meta.name[0]}
</StyledSettingAvatar>
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={fileChange}
>
<Button loading={uploading}>Upload</Button>
</Upload>
{/* TODO: add upload logic */}
{/* {isOwner ? (
<StyledAvatarUploadBtn shape="round">upload</StyledAvatarUploadBtn>
) : null} */}
{/* <Button shape="round">remove</Button> */}
</StyledSettingAvatarContent>
<StyledSettingH2 marginTop={36}>Workspace Name</StyledSettingH2>
<StyledSettingInputContainer>
<Input
width={327}
value={workspaceName}
placeholder="Workspace Name"
disabled={!isOwner}
maxLength={14}
minLength={1}
onChange={handleChangeWorkSpaceName}
></Input>
</StyledSettingInputContainer>
<StyledSettingH2 marginTop={36}>Workspace Owner</StyledSettingH2>
<StyledSettingInputContainer>
<Input
width={327}
disabled
value={owner.name}
placeholder="Workspace Owner"
></Input>
</StyledSettingInputContainer>
<StyledDeleteButtonContainer>
{isOwner ? (
<>
<Button type="danger" shape="circle" onClick={handleClickDelete}>
Delete Workspace
</Button>
<WorkspaceDelete
open={showDelete}
onClose={handleCloseDelete}
workspaceName={workspaceName}
workspaceId={workspace.id}
nextWorkSpaceId={nextWorkSpaceId}
/>
</>
) : (
<>
<Button type="danger" shape="circle" onClick={handleClickLeave}>
Leave Workspace
</Button>
<WorkspaceLeave
open={showLeave}
onClose={handleCloseLeave}
workspaceName={workspaceName}
workspaceId={workspace.id}
nextWorkSpaceId={nextWorkSpaceId}
/>
</>
)}
</StyledDeleteButtonContainer>
</div>
) : null;
};

View File

@@ -0,0 +1 @@
export * from './general';

View File

@@ -0,0 +1 @@
export * from './leave';

View File

@@ -0,0 +1,64 @@
import Modal from '@/ui/modal';
import {
StyledModalHeader,
StyledTextContent,
StyledModalWrapper,
StyledButtonContent,
} from './style';
import { ModalCloseButton } from '@/ui/modal';
import { Button } from '@/ui/button';
import { leaveWorkspace } from '@affine/data-services';
import { useRouter } from 'next/router';
import { useAppState } from '@/providers/app-state-provider';
interface WorkspaceDeleteProps {
open: boolean;
onClose: () => void;
workspaceName: string;
workspaceId: string;
nextWorkSpaceId: string;
}
export const WorkspaceLeave = ({
open,
onClose,
nextWorkSpaceId,
workspaceId,
}: WorkspaceDeleteProps) => {
const router = useRouter();
const { refreshWorkspacesMeta } = useAppState();
const handleLeave = async () => {
await leaveWorkspace({ id: workspaceId });
router.push(`/workspace/${nextWorkSpaceId}`);
refreshWorkspacesMeta();
onClose();
};
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
<ModalCloseButton onClick={onClose} />
<StyledModalHeader>Leave Workspace</StyledModalHeader>
<StyledTextContent>
After you leave, you will not be able to access all the contents of
this workspace.
</StyledTextContent>
<StyledButtonContent>
<Button shape="circle" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleLeave}
type="danger"
shape="circle"
style={{ marginLeft: '24px' }}
>
Leave
</Button>
</StyledButtonContent>
</StyledModalWrapper>
</Modal>
);
};
export default WorkspaceLeave;

View File

@@ -0,0 +1,46 @@
import { styled } from '@/styles';
export const StyledModalWrapper = styled('div')(({ theme }) => {
return {
position: 'relative',
padding: '0px',
width: '460px',
background: theme.colors.popoverBackground,
borderRadius: '12px',
};
});
export const StyledModalHeader = styled('div')(({ theme }) => {
return {
margin: '44px 0px 12px 0px',
width: '460px',
fontWeight: '600',
fontSize: '20px;',
textAlign: 'center',
color: theme.colors.popoverColor,
};
});
// export const StyledModalContent = styled('div')(({ theme }) => {});
export const StyledTextContent = styled('div')(() => {
return {
margin: 'auto',
width: '425px',
fontFamily: 'Avenir Next',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
};
});
export const StyledButtonContent = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
margin: '0px 0 32px 0',
};
});

View File

@@ -0,0 +1,28 @@
import { styled } from '@/styles';
import MuiAvatar from '@mui/material/Avatar';
export const StyledSettingInputContainer = styled('div')(() => {
return {
marginTop: '12px',
};
});
export const StyledDeleteButtonContainer = styled('div')(() => {
return {
marginTop: '154px',
};
});
export const StyledSettingAvatarContent = styled('div')(() => {
return {
marginTop: '12px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
height: '72px',
};
});
export const StyledSettingAvatar = styled(MuiAvatar)(() => {
return { height: '72px', width: '72px', marginRight: '24px' };
});

View File

@@ -0,0 +1 @@
export * from './workspace-setting';

View File

@@ -0,0 +1,230 @@
import { styled } from '@/styles';
import { Button } from '@/ui/button';
import MuiAvatar from '@mui/material/Avatar';
export const StyledSettingContainer = styled('div')(({ theme }) => {
return {
position: 'relative',
display: 'flex',
padding: '0px',
width: '961px',
background: theme.colors.popoverBackground,
borderRadius: '12px',
overflow: 'hidden',
};
});
export const StyledSettingSidebar = styled('div')(({ theme }) => {
{
return {
width: '212px',
height: '620px',
background: theme.mode === 'dark' ? '#272727' : '#FBFBFC',
flexShrink: 0,
flexGrow: 0,
};
}
});
export const StyledSettingContent = styled('div')(() => {
return {
paddingLeft: '48px',
height: '620px',
};
});
export const StyledSetting = styled('div')(({ theme }) => {
{
return {
width: '236px',
height: '620px',
background: theme.mode === 'dark' ? '#272727' : '#FBFBFC',
};
}
});
export const StyledSettingSidebarHeader = styled('div')(() => {
{
return {
fontWeight: '500',
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
marginTop: '37px',
};
}
});
export const StyledSettingTabContainer = styled('ul')(() => {
{
return {
display: 'flex',
flexDirection: 'column',
marginTop: '25px',
};
}
});
export const WorkspaceSettingTagItem = styled('li')<{ isActive?: boolean }>(
({ theme, isActive }) => {
{
return {
display: 'flex',
marginBottom: '12px',
padding: '0 24px',
height: '32px',
color: isActive ? theme.colors.primaryColor : theme.colors.textColor,
fontWeight: '400',
fontSize: '16px',
lineHeight: '32px',
cursor: 'pointer',
};
}
}
);
export const StyledSettingTagIconContainer = styled('div')(() => {
return {
display: 'flex',
alignItems: 'center',
marginRight: '14.64px',
width: '14.47px',
fontSize: '14.47px',
};
});
export const StyledSettingH2 = styled('h2')<{ marginTop?: number }>(
({ marginTop }) => {
return {
fontWeight: '500',
fontSize: '18px',
lineHeight: '26px',
marginTop: marginTop ? `${marginTop}px` : '0px',
};
}
);
export const StyledAvatarUploadBtn = styled(Button)(({ theme }) => {
return {
backgroundColor: theme.colors.hoverBackground,
color: theme.colors.primaryColor,
margin: '0 12px 0 24px',
};
});
export const StyledMemberTitleContainer = styled('div')(() => {
return {
display: 'flex',
marginTop: '60px',
fontWeight: '500',
};
});
export const StyledMemberAvatar = styled(MuiAvatar)(() => {
return { height: '40px', width: '40px' };
});
export const StyledMemberNameContainer = styled('div')(() => {
return {
display: 'flex',
alignItems: 'center',
width: '402px',
};
});
export const StyledMemberRoleContainer = styled('div')(() => {
return {
display: 'flex',
alignItems: 'center',
width: '222px',
};
});
export const StyledMemberListContainer = styled('ul')(() => {
return {
marginTop: '15px',
height: '432px',
overflowY: 'scroll',
};
});
export const StyledMemberListItem = styled('li')(() => {
return {
display: 'flex',
alignItems: 'center',
height: '72px',
};
});
export const StyledMemberInfo = styled('div')(() => {
return {
paddingLeft: '12px',
};
});
export const StyledMemberName = styled('div')(({ theme }) => {
return {
fontWeight: '400',
fontSize: '18px',
lineHeight: '16px',
color: theme.colors.textColor,
};
});
export const StyledMemberEmail = styled('div')(({ theme }) => {
return {
fontWeight: '400',
fontSize: '16px',
lineHeight: '22px',
color: theme.colors.iconColor,
};
});
export const StyledMemberButtonContainer = styled('div')(() => {
return {
marginTop: '14px',
};
});
export const StyledMoreVerticalButton = styled('button')(() => {
return {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '24px',
height: '24px',
cursor: 'pointer',
};
});
export const StyledPublishExplanation = styled('div')(() => {
return {
marginTop: '56px',
paddingRight: '48px',
fontWeight: '500',
fontSize: '18px',
lineHeight: '26px',
};
});
export const StyledPublishCopyContainer = styled('div')(() => {
return {
marginTop: '12px',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
height: '38px',
};
});
export const StyledCopyButtonContainer = styled('div')(() => {
return {
marginLeft: '12px',
};
});
export const StyledPublishContent = styled('div')(() => {
return {
height: '494px',
};
});

View File

@@ -0,0 +1,361 @@
import Modal, { ModalCloseButton } from '@/ui/modal';
import {
StyledCopyButtonContainer,
StyledMemberAvatar,
StyledMemberButtonContainer,
StyledMemberEmail,
StyledMemberInfo,
StyledMemberListContainer,
StyledMemberListItem,
StyledMemberName,
StyledMemberNameContainer,
StyledMemberRoleContainer,
StyledMemberTitleContainer,
StyledMoreVerticalButton,
StyledPublishContent,
StyledPublishCopyContainer,
StyledPublishExplanation,
StyledSettingContainer,
StyledSettingContent,
StyledSettingH2,
StyledSettingSidebar,
StyledSettingSidebarHeader,
StyledSettingTabContainer,
StyledSettingTagIconContainer,
WorkspaceSettingTagItem,
} from './style';
import {
EditIcon,
UsersIcon,
PublishIcon,
MoreVerticalIcon,
EmailIcon,
TrashIcon,
} from '@blocksuite/icons';
import { useCallback, useEffect, useState } from 'react';
import { Button, IconButton } from '@/ui/button';
import Input from '@/ui/input';
import { InviteMembers } from '../invite-members/index';
import {
getWorkspaceMembers,
Workspace,
Member,
removeMember,
updateWorkspace,
} from '@affine/data-services';
import { Avatar } from '@mui/material';
import { Menu, MenuItem } from '@/ui/menu';
import { toast } from '@/ui/toast';
import { Empty } from '@/ui/empty';
import { useAppState } from '@/providers/app-state-provider';
import { WorkspaceDetails } from '../workspace-slider-bar/WorkspaceSelector/SelectorPopperContent';
import { GeneralPage } from './general';
enum ActiveTab {
'general' = 'general',
'members' = 'members',
'publish' = 'publish',
}
type SettingTabProps = {
activeTab: ActiveTab;
onTabChange?: (tab: ActiveTab) => void;
};
type WorkspaceSettingProps = {
isShow: boolean;
onClose?: () => void;
workspace: Workspace;
owner: WorkspaceDetails[string]['owner'];
};
const WorkspaceSettingTab = ({ activeTab, onTabChange }: SettingTabProps) => {
return (
<StyledSettingTabContainer>
<WorkspaceSettingTagItem
isActive={activeTab === ActiveTab.general}
onClick={() => {
onTabChange && onTabChange(ActiveTab.general);
}}
>
<StyledSettingTagIconContainer>
<EditIcon />
</StyledSettingTagIconContainer>
General
</WorkspaceSettingTagItem>
<WorkspaceSettingTagItem
isActive={activeTab === ActiveTab.members}
onClick={() => {
onTabChange && onTabChange(ActiveTab.members);
}}
>
<StyledSettingTagIconContainer>
<UsersIcon />
</StyledSettingTagIconContainer>
Members
</WorkspaceSettingTagItem>
<WorkspaceSettingTagItem
isActive={activeTab === ActiveTab.publish}
onClick={() => {
onTabChange && onTabChange(ActiveTab.publish);
}}
>
<StyledSettingTagIconContainer>
<PublishIcon />
</StyledSettingTagIconContainer>
Publish
</WorkspaceSettingTagItem>
</StyledSettingTabContainer>
);
};
export const WorkspaceSetting = ({
isShow,
onClose,
workspace,
owner,
}: WorkspaceSettingProps) => {
const { user, workspaces } = useAppState();
const [activeTab, setActiveTab] = useState<ActiveTab>(ActiveTab.general);
const handleTabChange = (tab: ActiveTab) => {
setActiveTab(tab);
};
const handleClickClose = () => {
onClose && onClose();
};
const isOwner = user && owner.id === user.id;
useEffect(() => {
// reset tab when modal is closed
if (!isShow) {
setActiveTab(ActiveTab.general);
}
}, [isShow]);
return (
<Modal open={isShow}>
<StyledSettingContainer>
<ModalCloseButton onClick={handleClickClose} />
{isOwner ? (
<StyledSettingSidebar>
<StyledSettingSidebarHeader>
Workspace Settings
</StyledSettingSidebarHeader>
<WorkspaceSettingTab
activeTab={activeTab}
onTabChange={handleTabChange}
/>
</StyledSettingSidebar>
) : null}
<StyledSettingContent>
{activeTab === ActiveTab.general && (
<GeneralPage
workspace={workspace}
owner={owner}
workspaces={workspaces}
/>
)}
{activeTab === ActiveTab.members && workspace && (
<MembersPage workspace={workspace} />
)}
{activeTab === ActiveTab.publish && (
<PublishPage workspace={workspace} />
)}
</StyledSettingContent>
</StyledSettingContainer>
</Modal>
);
};
const MembersPage = ({ workspace }: { workspace: Workspace }) => {
const [isInviteModalShow, setIsInviteModalShow] = useState(false);
const [members, setMembers] = useState<Member[]>([]);
const refreshMembers = useCallback(() => {
getWorkspaceMembers({
id: workspace.id,
})
.then(data => {
setMembers(data);
})
.catch(err => {
console.log(err);
});
}, [workspace.id]);
useEffect(() => {
refreshMembers();
}, [refreshMembers]);
return (
<div>
<StyledMemberTitleContainer>
<StyledMemberNameContainer>
Users({members.length})
</StyledMemberNameContainer>
<StyledMemberRoleContainer>Access level</StyledMemberRoleContainer>
</StyledMemberTitleContainer>
<StyledMemberListContainer>
{members.length === 0 && (
<Empty width={648} sx={{ marginTop: '60px' }} height={300}></Empty>
)}
{members.length ? (
members.map(member => {
return (
<StyledMemberListItem key={member.id}>
<StyledMemberNameContainer>
{member.user.type === 'Registered' ? (
<Avatar src={member.user.avatar_url}></Avatar>
) : (
<StyledMemberAvatar alt="member avatar">
<EmailIcon></EmailIcon>
</StyledMemberAvatar>
)}
<StyledMemberInfo>
{member.user.type === 'Registered' ? (
<StyledMemberName>{member.user.name}</StyledMemberName>
) : (
<></>
)}
<StyledMemberEmail>{member.user.email}</StyledMemberEmail>
</StyledMemberInfo>
</StyledMemberNameContainer>
<StyledMemberRoleContainer>
{member.accepted
? member.type !== 99
? 'Member'
: 'Workspace Owner'
: 'Pending'}
</StyledMemberRoleContainer>
<StyledMoreVerticalButton>
<Menu
content={
<>
<MenuItem
onClick={() => {
// confirm({
// title: 'Delete Member?',
// content: `will delete member`,
// confirmText: 'Delete',
// confirmType: 'danger',
// }).then(confirm => {
removeMember({
permissionId: member.id,
}).then(() => {
// console.log('data: ', data);
toast('Moved to Trash');
refreshMembers();
});
// });
}}
icon={<TrashIcon />}
>
Delete
</MenuItem>
</>
}
placement="bottom-end"
disablePortal={true}
>
<IconButton>
<MoreVerticalIcon />
</IconButton>
</Menu>
</StyledMoreVerticalButton>
</StyledMemberListItem>
);
})
) : (
<></>
)}
</StyledMemberListContainer>
<StyledMemberButtonContainer>
<Button
onClick={() => {
setIsInviteModalShow(true);
}}
type="primary"
shape="circle"
>
Invite Members
</Button>
<InviteMembers
onClose={() => {
setIsInviteModalShow(false);
}}
onInviteSuccess={() => {
refreshMembers();
}}
workspaceId={workspace.id}
open={isInviteModalShow}
></InviteMembers>
</StyledMemberButtonContainer>
</div>
);
};
const PublishPage = ({ workspace }: { workspace: Workspace }) => {
const shareUrl = window.location.host + '/workspace/' + workspace.id;
const [publicStatus, setPublicStatus] = useState<boolean | null>(
workspace.public
);
const togglePublic = (flag: boolean) => {
updateWorkspace({
id: workspace.id,
public: flag,
}).then(data => {
setPublicStatus(data?.public);
toast('Updated Public Status Success');
});
};
const copyUrl = () => {
navigator.clipboard.writeText(shareUrl);
toast('Copied url to clipboard');
};
return (
<div>
<StyledPublishContent>
{publicStatus ? (
<>
<StyledPublishExplanation>
The current workspace has been published to the web, everyone can
view the contents of this workspace through the link.
</StyledPublishExplanation>
<StyledSettingH2 marginTop={48}>Share with link</StyledSettingH2>
<StyledPublishCopyContainer>
<Input width={500} value={shareUrl} disabled={true}></Input>
<StyledCopyButtonContainer>
<Button onClick={copyUrl} type="primary" shape="circle">
Copy Link
</Button>
</StyledCopyButtonContainer>
</StyledPublishCopyContainer>
</>
) : (
<StyledPublishExplanation>
After publishing to the web, everyone can view the content of this
workspace through the link.
</StyledPublishExplanation>
)}
</StyledPublishContent>
{!publicStatus ? (
<Button
onClick={() => {
togglePublic(true);
}}
type="primary"
shape="circle"
>
Publish to web
</Button>
) : (
<Button
onClick={() => {
togglePublic(false);
}}
type="primary"
shape="circle"
>
Stop publishing
</Button>
)}
</div>
);
};

View File

@@ -0,0 +1,184 @@
import { InformationIcon, LogOutIcon } from '@blocksuite/icons';
import { styled } from '@/styles';
import { Divider } from '@/ui/divider';
import { useAppState } from '@/providers/app-state-provider/context';
import { SelectorPopperContainer } from './styles';
import {
PrivateWorkspaceItem,
WorkspaceItem,
CreateWorkspaceItem,
ListItem,
LoginItem,
} from './WorkspaceItem';
import { WorkspaceSetting } from '@/components/workspace-setting';
import { useCallback, useEffect, useState } from 'react';
import { getWorkspaceDetail, WorkspaceType } from '@affine/data-services';
import { useModal } from '@/providers/global-modal-provider';
export type WorkspaceDetails = Record<
string,
{ memberCount: number; owner: { id: string; name: string } }
>;
type SelectorPopperContentProps = {
isShow: boolean;
};
export const SelectorPopperContent = ({
isShow,
}: SelectorPopperContentProps) => {
const { user, workspacesMeta, workspaces, refreshWorkspacesMeta } =
useAppState();
const [settingWorkspaceId, setSettingWorkspaceId] = useState<string | null>(
null
);
const [workSpaceDetails, setWorkSpaceDetails] = useState<WorkspaceDetails>(
{}
);
const { triggerContactModal } = useModal();
const handleClickSettingWorkspace = (workspaceId: string) => {
setSettingWorkspaceId(workspaceId);
};
const handleCloseWorkSpace = () => {
setSettingWorkspaceId(null);
};
const settingWorkspace = settingWorkspaceId
? workspacesMeta.find(workspace => workspace.id === settingWorkspaceId)
: undefined;
const refreshDetails = useCallback(async () => {
const workspaceDetailList = await Promise.all(
workspacesMeta.map(async ({ id, type }) => {
if (user) {
if (type === WorkspaceType.Private) {
return { id, member_count: 1, owner: user };
} else {
const data = await getWorkspaceDetail({ id });
return { id, ...data } || { id, member_count: 0, owner: user };
}
}
})
);
const workSpaceDetails: WorkspaceDetails = {};
workspaceDetailList.forEach(details => {
if (details) {
const { id, member_count, owner } = details;
if (!owner) return;
workSpaceDetails[id] = {
memberCount: member_count || 1,
owner: {
id: owner.id,
name: owner.name,
},
};
}
});
setWorkSpaceDetails(workSpaceDetails);
}, [user, workspacesMeta]);
useEffect(() => {
if (isShow) {
setSettingWorkspaceId(null);
refreshWorkspacesMeta();
refreshDetails();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isShow]);
return !user ? (
<SelectorPopperContainer placement="bottom-start">
<LoginItem />
<StyledDivider />
<ListItem
icon={<InformationIcon />}
name="About AFFiNE"
onClick={() => triggerContactModal()}
/>
</SelectorPopperContainer>
) : (
<SelectorPopperContainer placement="bottom-start">
<PrivateWorkspaceItem
privateWorkspaceId={
workspacesMeta.find(
workspace => workspace.type === WorkspaceType.Private
)?.id
}
/>
<StyledDivider />
<WorkspaceGroupTitle>Workspace</WorkspaceGroupTitle>
<WorkspaceWrapper>
{workspacesMeta.map(workspace => {
return workspace.type !== WorkspaceType.Private ? (
<WorkspaceItem
type={workspace.type}
key={workspace.id}
id={workspace.id}
icon={
(workspaces[workspace.id]?.meta.avatar &&
`/api/blob/${workspaces[workspace.id]?.meta.avatar}`) ||
`loading...`
}
onClickSetting={handleClickSettingWorkspace}
name={workspaces[workspace.id]?.meta.name || `loading...`}
memberCount={workSpaceDetails[workspace.id]?.memberCount || 1}
/>
) : null;
})}
</WorkspaceWrapper>
<CreateWorkspaceItem />
{settingWorkspace ? (
<WorkspaceSetting
isShow={Boolean(settingWorkspaceId)}
onClose={handleCloseWorkSpace}
workspace={settingWorkspace}
owner={
(settingWorkspaceId &&
workSpaceDetails[settingWorkspaceId]?.owner) || {
id: user.id,
name: user.name,
}
}
/>
) : null}
<StyledDivider />
<ListItem
icon={<InformationIcon />}
name="About AFFiNE"
onClick={() => triggerContactModal()}
/>
<ListItem
icon={<LogOutIcon />}
name="Sign out"
onClick={() => {
console.log('Sign out');
// FIXME: remove token from local storage and reload the page
localStorage.removeItem('affine_token');
window.location.reload();
}}
/>
</SelectorPopperContainer>
);
};
const StyledDivider = styled(Divider)({
margin: '8px 12px',
width: 'calc(100% - 24px)',
});
const WorkspaceGroupTitle = styled('div')(({ theme }) => {
return {
color: theme.colors.iconColor,
fontSize: theme.font.sm,
lineHeight: '30px',
height: '30px',
padding: '0 12px',
};
});
const WorkspaceWrapper = styled('div')(() => {
return {
maxHeight: '200px',
overflow: 'auto',
};
});

View File

@@ -0,0 +1,39 @@
import { useState } from 'react';
import { AddIcon } from '@blocksuite/icons';
import { styled } from '@/styles';
import {
WorkspaceItemAvatar,
WorkspaceItemWrapper,
WorkspaceItemContent,
} from '../styles';
import { WorkspaceCreate } from './workspace-create';
const name = 'Create new Workspace';
export const CreateWorkspaceItem = () => {
const [open, setOpen] = useState(false);
return (
<>
<WorkspaceItemWrapper onClick={() => setOpen(true)}>
<WorkspaceItemAvatar>
<AddIcon />
</WorkspaceItemAvatar>
<WorkspaceItemContent>
<Name title={name}>{name}</Name>
</WorkspaceItemContent>
</WorkspaceItemWrapper>
<WorkspaceCreate open={open} onClose={() => setOpen(false)} />
</>
);
};
const Name = styled('div')(({ theme }) => {
return {
color: theme.colors.quoteColor,
fontSize: theme.font.base,
fontWeight: 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});

View File

@@ -0,0 +1 @@
export * from './CreateWorkspaceItem';

View File

@@ -0,0 +1,63 @@
import { styled } from '@/styles';
import { Button } from '@/ui/button';
export const StyledModalWrapper = styled('div')(({ theme }) => {
return {
position: 'relative',
padding: '0px',
width: '460px',
background: theme.colors.popoverBackground,
borderRadius: '12px',
};
});
export const StyledModalHeader = styled('div')(({ theme }) => {
return {
margin: '44px 0px 12px 0px',
width: '460px',
fontWeight: '600',
fontSize: '20px;',
textAlign: 'center',
color: theme.colors.popoverColor,
};
});
// export const StyledModalContent = styled('div')(({ theme }) => {});
export const StyledTextContent = styled('div')(() => {
return {
margin: 'auto',
width: '425px',
fontFamily: 'Avenir Next',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
};
});
export const StyledInputContent = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
margin: '40px 0 24px 0',
};
});
export const StyledButtonContent = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
margin: '0px 0 32px 0',
};
});
export const StyledButton = styled(Button)(() => {
return {
width: '260px',
justifyContent: 'center',
};
});

View File

@@ -0,0 +1,118 @@
import { createWorkspace, uploadBlob } from '@affine/data-services';
import Modal from '@/ui/modal';
import Input from '@/ui/input';
import {
StyledModalHeader,
StyledTextContent,
StyledModalWrapper,
StyledInputContent,
StyledButtonContent,
StyledButton,
} from './style';
import { useState } from 'react';
import { ModalCloseButton } from '@/ui/modal';
import router from 'next/router';
import { useAppState } from '@/providers/app-state-provider';
interface WorkspaceCreateProps {
open: boolean;
onClose: () => void;
}
const DefaultHeadImgColors = [
['#C6F2F3', '#0C6066'],
['#FFF5AB', '#896406'],
['#FFCCA7', '#8F4500'],
['#FFCECE', '#AF1212'],
['#E3DEFF', '#511AAB'],
];
export const WorkspaceCreate = ({ open, onClose }: WorkspaceCreateProps) => {
const [workspaceName, setWorkspaceId] = useState<string>('');
const [creating, setCreating] = useState<boolean>(false);
const { refreshWorkspacesMeta } = useAppState();
const handlerInputChange = (workspaceName: string) => {
setWorkspaceId(workspaceName);
};
const createDefaultHeadImg = (workspaceName: string) => {
const canvas = document.createElement('canvas');
canvas.height = 100;
canvas.width = 100;
const ctx = canvas.getContext('2d');
return new Promise<string>((resolve, reject) => {
if (ctx) {
const randomNumber = Math.floor(Math.random() * 5);
const randomColor = DefaultHeadImgColors[randomNumber];
ctx.fillStyle = randomColor[0];
ctx.fillRect(0, 0, 100, 100);
ctx.font = "600 50px 'PingFang SC', 'Microsoft Yahei'";
ctx.fillStyle = randomColor[1];
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(workspaceName[0], 50, 50);
canvas.toBlob(blob => {
if (blob) {
const blobId = uploadBlob({ blob });
resolve(blobId);
} else {
reject();
}
}, 'image/png');
} else {
reject();
}
});
};
const handleCreateWorkspace = async () => {
setCreating(true);
const blobId = await createDefaultHeadImg(workspaceName).catch(() => {
setCreating(false);
});
if (blobId) {
createWorkspace({ name: workspaceName, avatar: blobId })
.then(async data => {
await refreshWorkspacesMeta();
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
router.push(`/workspace/${data.id}`);
onClose();
})
.catch(err => {
console.log(err, 'err');
})
.finally(() => {
setCreating(false);
});
}
};
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
<ModalCloseButton onClick={onClose} />
<StyledModalHeader>Create new Workspace</StyledModalHeader>
<StyledTextContent>
Workspaces are shared environments where teams can collaborate. After
creating a Workspace, you can invite others to join.
</StyledTextContent>
<StyledInputContent>
<Input
onChange={handlerInputChange}
placeholder="Set a Workspace name"
value={workspaceName}
></Input>
</StyledInputContent>
<StyledButtonContent>
<StyledButton
disabled={!workspaceName.length || creating}
onClick={handleCreateWorkspace}
loading={creating}
>
Create
</StyledButton>
</StyledButtonContent>
</StyledModalWrapper>
</Modal>
);
};
export default WorkspaceCreate;

View File

@@ -0,0 +1,37 @@
import type { ReactNode } from 'react';
import { styled } from '@/styles';
import { WorkspaceItemWrapper, WorkspaceItemContent } from './styles';
interface ListItemProps {
name: string;
icon: ReactNode;
onClick: () => void;
}
export const ListItem = ({ name, icon, onClick }: ListItemProps) => {
return (
<WorkspaceItemWrapper onClick={onClick}>
<StyledIconWrapper>{icon}</StyledIconWrapper>
<WorkspaceItemContent>
<Name title={name}>{name}</Name>
</WorkspaceItemContent>
</WorkspaceItemWrapper>
);
};
const Name = styled('div')(({ theme }) => {
return {
color: theme.colors.quoteColor,
fontSize: theme.font.sm,
fontWeight: 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});
const StyledIconWrapper = styled('div')({
width: '20px',
height: '20px',
fontSize: '20px',
});

View File

@@ -0,0 +1,52 @@
import { useModal } from '@/providers/global-modal-provider';
import { styled } from '@/styles';
import { AffineIcon } from '../../icons/icons';
import {
WorkspaceItemAvatar,
LoginItemWrapper,
WorkspaceItemContent,
} from './styles';
export const LoginItem = () => {
const { triggerLoginModal } = useModal();
return (
<LoginItemWrapper
onClick={() => triggerLoginModal()}
data-testid="open-login-modal"
>
<WorkspaceItemAvatar alt="AFFiNE" src={''}>
<AffineIcon />
</WorkspaceItemAvatar>
<WorkspaceItemContent>
<Name title="AFFiNE">AFFiNE</Name>
<Description
title="Log in to sync with affine"
className="login-description"
>
Log in to sync with affine
</Description>
</WorkspaceItemContent>
</LoginItemWrapper>
);
};
const Name = styled('div')(({ theme }) => {
return {
color: theme.colors.quoteColor,
fontSize: theme.font.base,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});
const Description = styled('div')(({ theme }) => {
return {
color: theme.colors.iconColor,
fontSize: theme.font.sm,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});

View File

@@ -0,0 +1,60 @@
import { styled } from '@/styles';
import { useAppState } from '@/providers/app-state-provider/context';
import {
WorkspaceItemAvatar,
PrivateWorkspaceWrapper,
WorkspaceItemContent,
} from './styles';
import { useRouter } from 'next/router';
type PrivateWorkspaceItemProps = {
privateWorkspaceId?: string;
};
export const PrivateWorkspaceItem = ({
privateWorkspaceId,
}: PrivateWorkspaceItemProps) => {
const { user } = useAppState();
const router = useRouter();
const handleClick = () => {
if (privateWorkspaceId) {
router.push(`/workspace/${privateWorkspaceId}`);
}
};
if (user) {
const Username = user.name;
return (
<PrivateWorkspaceWrapper onClick={handleClick}>
<WorkspaceItemAvatar alt={Username} src={user.avatar_url}>
{Username}
</WorkspaceItemAvatar>
<WorkspaceItemContent>
<Name title={Username}>{Username}</Name>
<Email title={user.email}>{user.email}</Email>
</WorkspaceItemContent>
</PrivateWorkspaceWrapper>
);
}
return null;
};
const Name = styled('div')(({ theme }) => {
return {
color: theme.colors.quoteColor,
fontSize: theme.font.base,
fontWeight: 500,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});
const Email = styled('div')(({ theme }) => {
return {
color: theme.colors.iconColor,
fontSize: theme.font.sm,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});

View File

@@ -0,0 +1,32 @@
import { SettingsIcon } from '@blocksuite/icons';
import { styled } from '@/styles';
import { IconButton } from '@/ui/button';
import { MouseEventHandler } from 'react';
type SettingProps = {
onClick?: () => void;
};
export const FooterSetting = ({ onClick }: SettingProps) => {
const handleClick: MouseEventHandler<HTMLButtonElement> = e => {
e.stopPropagation();
onClick && onClick();
};
return (
<Wrapper
className="footer-setting"
onClick={e => {
e.stopPropagation();
handleClick(e);
}}
>
<SettingsIcon />
</Wrapper>
);
};
const Wrapper = styled(IconButton)(() => {
return {
fontSize: '20px',
};
});

View File

@@ -0,0 +1,29 @@
import { UsersIcon } from '@blocksuite/icons';
import { styled } from '@/styles';
import { IconButton } from '@/ui/button';
type FooterUsersProps = {
memberCount: number;
};
export const FooterUsers = ({ memberCount = 1 }: FooterUsersProps) => {
return (
<Wrapper className="footer-users">
<>
<UsersIcon />
<Tip>{memberCount > 99 ? '99+' : memberCount}</Tip>
</>
</Wrapper>
);
};
const Wrapper = styled(IconButton)({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '16px',
});
const Tip = styled('span')({
fontSize: '12px',
});

View File

@@ -0,0 +1,96 @@
import { useRouter } from 'next/router';
import { styled } from '@/styles';
import {
WorkspaceItemAvatar,
WorkspaceItemWrapper,
WorkspaceItemContent,
} from '../styles';
import { FooterSetting } from './FooterSetting';
import { FooterUsers } from './FooterUsers';
import { WorkspaceType } from '@affine/data-services';
import { useAppState } from '@/providers/app-state-provider';
interface WorkspaceItemProps {
id: string;
name: string;
icon: string;
type: WorkspaceType;
memberCount: number;
onClickSetting?: (workspaceId: string) => void;
}
export const WorkspaceItem = ({
id,
name,
icon,
type,
onClickSetting,
memberCount,
}: WorkspaceItemProps) => {
const router = useRouter();
const { currentWorkspaceId } = useAppState();
const handleClickSetting = async () => {
onClickSetting && onClickSetting(id);
};
return (
<StyledWrapper
onClick={() => {
router.push(`/workspace/${id}`);
}}
canSet={
type !== WorkspaceType.Private && currentWorkspaceId === String(id)
}
>
<WorkspaceItemAvatar alt={name} src={icon}>
{name.charAt(0)}
</WorkspaceItemAvatar>
<WorkspaceItemContent>
<Name title={name}>{name}</Name>
</WorkspaceItemContent>
<Footer>
<FooterUsers memberCount={memberCount} />
<FooterSetting onClick={handleClickSetting} />
</Footer>
</StyledWrapper>
);
};
const Name = styled('div')(({ theme }) => {
return {
color: theme.colors.quoteColor,
fontSize: theme.font.sm,
fontWeight: 400,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
});
const StyledWrapper = styled(WorkspaceItemWrapper)<{ canSet: boolean }>(
({ canSet }) => {
return {
'& .footer-setting': {
display: 'none',
},
':hover .footer-users': {
display: canSet ? 'none' : '',
},
':hover .footer-setting': {
display: canSet ? 'block' : 'none',
},
};
}
);
const Footer = styled('div')({
width: '42px',
flex: '0 42px',
fontSize: '20px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginLeft: '12px',
});

View File

@@ -0,0 +1 @@
export * from './WorkspaceItem';

View File

@@ -0,0 +1,5 @@
export * from './PrivateWorkspaceItem';
export * from './WorkspaceItem';
export * from './CreateWorkspaceItem';
export * from './ListItem';
export * from './LoginItem';

Some files were not shown because too many files have changed in this diff Show More