refactor!: next generation AFFiNE code structure (#1176)

This commit is contained in:
Himself65 2023-03-01 01:40:01 -06:00 committed by GitHub
parent 2dcccc772c
commit e0481d29ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
270 changed files with 8308 additions and 6829 deletions

View File

@ -1,3 +1,4 @@
node_modules
dist
.next
out

View File

@ -36,6 +36,7 @@ module.exports = {
'no-empty': 'off',
'no-func-assign': 'off',
'no-cond-assign': 'off',
'react/prop-types': 'off',
'@typescript-eslint/no-non-null-assertion': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-empty-function': 'off',

View File

@ -10,4 +10,4 @@ COPY --from=relocate /app .
EXPOSE 80
ENV API_SERVER=$API_SERVER
CMD ["caddy", "run"]
CMD ["caddy", "run"]

3
.gitignore vendored
View File

@ -58,7 +58,8 @@ module-resolve.cjs
# Cache
.eslintcache
next-env.d.ts
# generated assets
apps/desktop/public/affine-out
apps/desktop/public/preload
apps/desktop/public/preload

View File

@ -9,3 +9,6 @@ NEXT_PUBLIC_FIREBASE_MEASUREMENT_ID=
LOCAL_BLOCK_SUITE=
# see next.config.js
NODE_API_SERVER=
# save workspace to idb
ENABLE_IDB_PROVIDER=1
PREFETCH_WORKSPACE=1

View File

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

View File

@ -1,34 +0,0 @@
This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `pages/index.tsx`. The page auto-updates as you edit the file.
[API routes](https://nextjs.org/docs/api-routes/introduction) can be accessed on [http://localhost:3000/api/hello](http://localhost:3000/api/hello). This endpoint can be edited in `pages/api/hello.ts`.
The `pages/api` directory is mapped to `/api/*`. Files in this directory are treated as [API routes](https://nextjs.org/docs/api-routes/introduction) instead of React pages.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details.

View File

@ -1,17 +1,14 @@
import { getGitVersion, getCommitHash } from './scripts/gitInfo.mjs';
import fs from 'node:fs';
import path from 'node:path';
import { printer } from './scripts/printer.mjs';
import debugLocal from 'next-debug-local';
import preset from './preset.config.mjs';
import { createRequire } from 'node:module';
import { getCommitHash, getGitVersion } from './scripts/gitInfo.mjs';
const dependencies = JSON.parse(fs.readFileSync('./package.json', 'utf8'))[
'dependencies'
];
const require = createRequire(import.meta.url);
console.info('Runtime Preset', preset);
const enableDebugLocal = path.isAbsolute(process.env.LOCAL_BLOCK_SUITE ?? '');
const EDITOR_VERSION = enableDebugLocal
? 'local-version'
: dependencies['@blocksuite/editor'];
const profileTarget = {
ac: '100.85.73.88:12001',
@ -20,6 +17,7 @@ const profileTarget = {
stage: '',
pro: 'http://pathfinder.affine.pro',
local: '127.0.0.1:3000',
rem: 'stage.affine.pro',
};
const getRedirectConfig = profile => {
@ -41,23 +39,39 @@ const getRedirectConfig = profile => {
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
reactStrictMode: true,
swcMinify: false,
publicRuntimeConfig: {
NODE_ENV: process.env.NODE_ENV,
PROJECT_NAME: process.env.npm_package_name,
BUILD_DATE: new Date().toISOString(),
CI: process.env.CI || null,
VERSION: getGitVersion(),
COMMIT_HASH: getCommitHash(),
EDITOR_VERSION,
compiler: {
removeConsole: {
exclude: ['error', 'log', 'warn', 'info'],
},
emotion: {
sourceMap: true,
},
},
images: {
unoptimized: true,
},
experimental: {
swcPlugins: [
['@swc-jotai/debug-label', {}],
// ['@swc-jotai/react-refresh', {}],
],
},
reactStrictMode: true,
transpilePackages: [
'@affine/component',
'@affine/i18n',
'@affine/datacenter',
'@toeverything/pathfinder-logger',
'@affine/i18n',
],
publicRuntimeConfig: {
PROJECT_NAME: process.env.npm_package_name,
BUILD_DATE: new Date().toISOString(),
gitVersion: getGitVersion(),
hash: getCommitHash(),
serverAPI:
profileTarget[process.env.NODE_API_SERVER || 'dev'] ?? profileTarget.dev,
editorVersion: require('./package.json').dependencies['@blocksuite/editor'],
...preset,
},
webpack: config => {
config.experiments = { ...config.experiments, topLevelAwait: true };
config.module.rules.push({
@ -67,20 +81,14 @@ const nextConfig = {
return config;
},
images: {
unoptimized: true,
},
rewrites: async () => {
const [profile, target, desc] = getRedirectConfig(
process.env.NODE_API_SERVER
);
printer.info(`API request proxy to [${desc} Server]: ` + target);
console.info(`API request proxy to [${desc} Server]: ` + target);
return profile;
},
basePath: process.env.NEXT_BASE_PATH,
experimental: {
forceSwcTransforms: true,
},
};
const baseDir = process.env.LOCAL_BLOCK_SUITE ?? '/';
@ -111,9 +119,9 @@ const withDebugLocal = debugLocal(
const detectFirebaseConfig = () => {
if (!process.env.NEXT_PUBLIC_FIREBASE_API_KEY) {
printer.warn('NEXT_PUBLIC_FIREBASE_API_KEY not found, please check it');
console.warn('NEXT_PUBLIC_FIREBASE_API_KEY not found, please check it');
} else {
printer.info('NEXT_PUBLIC_FIREBASE_API_KEY found');
console.info('NEXT_PUBLIC_FIREBASE_API_KEY found');
}
};
detectFirebaseConfig();

View File

@ -1,6 +1,6 @@
{
"name": "@affine/app",
"version": "0.3.1",
"private": true,
"scripts": {
"dev": "next dev -p 8080",
"build": "next build",
@ -11,47 +11,44 @@
"dependencies": {
"@affine/component": "workspace:*",
"@affine/datacenter": "workspace:*",
"@affine/debug": "workspace:*",
"@affine/i18n": "workspace:*",
"@blocksuite/blocks": "0.4.1-20230220214107-0a354de",
"@blocksuite/editor": "0.4.1-20230220214107-0a354de",
"@blocksuite/global": "0.4.1-20230220214107-0a354de",
"@blocksuite/icons": "2.0.17",
"@blocksuite/icons": "^2.0.17",
"@blocksuite/react": "0.4.1-20230220214107-0a354de",
"@blocksuite/store": "0.4.1-20230220214107-0a354de",
"@emotion/cache": "^11.10.5",
"@emotion/css": "^11.10.6",
"@emotion/react": "^11.10.6",
"@emotion/server": "^11.10.0",
"@emotion/styled": "^11.10.6",
"@fontsource/poppins": "^4.5.10",
"@fontsource/space-mono": "^4.5.12",
"@mui/base": "5.0.0-alpha.118",
"@mui/icons-material": "^5.11.9",
"@mui/material": "^5.11.9",
"@toeverything/pathfinder-logger": "workspace:@affine/logger@*",
"@mui/material": "^5.11.10",
"cmdk": "^0.1.22",
"css-spring": "^4.1.0",
"dayjs": "^1.11.7",
"jotai": "^2.0.2",
"jotai-devtools": "^0.2.0",
"lit": "^2.6.1",
"next": "13.1.0",
"next-debug-local": "^0.1.5",
"prettier": "^2.8.4",
"quill": "^1.3.7",
"quill-cursors": "^4.0.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"yjs": "^13.5.46",
"zustand": "^4.3.3"
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-helmet-async": "^1.3.0",
"swr": "^2.0.4",
"y-indexeddb": "^9.0.9",
"yjs": "^13.5.47",
"zod": "^3.20.6"
},
"devDependencies": {
"@types/node": "18.14.0",
"@types/react": "18.0.28",
"@types/react-dom": "18.0.11",
"@types/wicg-file-system-access": "^2020.9.5",
"chalk": "^5.2.0",
"eslint": "^8.34.0",
"eslint-config-next": "13.1.6",
"@redux-devtools/extension": "^3.2.5",
"@swc-jotai/debug-label": "^0.0.6",
"@swc-jotai/react-refresh": "^0.0.4",
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/webpack-env": "^1.18.0",
"dotenv": "^16.0.3",
"eslint-config-next": "^13.2.2",
"next": "^13.2.2",
"next-debug-local": "^0.1.5",
"next-router-mock": "^0.9.2",
"raw-loader": "^4.0.2",
"typescript": "^4.9.5",
"webpack": "^5.75.0"
"redux": "^4.2.1",
"typescript": "^4.9.5"
}
}

View File

@ -0,0 +1,6 @@
import 'dotenv/config';
export default {
enableIndexedDBProvider: Boolean(process.env.ENABLE_IDB_PROVIDER ?? '1'),
prefetchWorkspace: Boolean(process.env.PREFETCH_WORKSPACE ?? '1'),
};

View File

@ -1,23 +0,0 @@
import { expect, test } from '@playwright/test';
import { printer } from './../printer';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const chalk = require('chalk');
test.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

@ -1,18 +0,0 @@
import chalk from 'chalk';
export 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;
},
};

View File

@ -0,0 +1,36 @@
import { atom } from 'jotai';
import { createStore } from 'jotai/index';
import { atomWithStorage } from 'jotai/utils';
import { unstable_batchedUpdates } from 'react-dom';
// workspace necessary atoms
export const currentWorkspaceIdAtom = atomWithStorage<string | null>(
'affine-current-workspace-id',
null
);
export const currentPageIdAtom = atomWithStorage<string | null>(
'affine-current-page-id',
null
);
// If the workspace is locked, it means that the user maybe updating the workspace
// from local to remote or vice versa
export const workspaceLockAtom = atom(false);
export async function lockMutex(fn: () => Promise<unknown>) {
if (jotaiStore.get(workspaceLockAtom)) {
throw new Error('Workspace is locked');
}
unstable_batchedUpdates(() => {
jotaiStore.set(workspaceLockAtom, true);
});
await fn();
unstable_batchedUpdates(() => {
jotaiStore.set(workspaceLockAtom, false);
});
}
// modal atoms
export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom(false);
export const openQuickSearchModalAtom = atom(false);
export const jotaiStore = createStore();

View File

@ -0,0 +1,48 @@
import { atom } from 'jotai/index';
import {
BlockSuiteWorkspace,
LocalWorkspace,
RemWorkspaceFlavour,
} from '../../shared';
import { apis } from '../../shared/apis';
import { createEmptyBlockSuiteWorkspace } from '../../utils';
export const publicWorkspaceIdAtom = atom<string | null>(null);
export const publicBlockSuiteAtom = atom<Promise<BlockSuiteWorkspace>>(
async get => {
const workspaceId = get(publicWorkspaceIdAtom);
if (!workspaceId) {
throw new Error('No workspace id');
}
const binary = await apis.downloadWorkspace(workspaceId, true);
const blockSuiteWorkspace = createEmptyBlockSuiteWorkspace(workspaceId);
BlockSuiteWorkspace.Y.applyUpdate(
blockSuiteWorkspace.doc,
new Uint8Array(binary)
);
blockSuiteWorkspace.awarenessStore.setFlag('enable_block_hub', false);
blockSuiteWorkspace.awarenessStore.setFlag('enable_set_remote_flag', false);
blockSuiteWorkspace.awarenessStore.setFlag('enable_database', false);
blockSuiteWorkspace.awarenessStore.setFlag(
'enable_edgeless_toolbar',
false
);
blockSuiteWorkspace.awarenessStore.setFlag('enable_slash_menu', false);
blockSuiteWorkspace.awarenessStore.setFlag('enable_drag_handle', false);
return new Promise(resolve => {
setTimeout(() => {
const workspace: LocalWorkspace = {
id: workspaceId,
blockSuiteWorkspace,
flavour: RemWorkspaceFlavour.LOCAL,
syncBinary: () => Promise.resolve(workspace),
providers: [],
};
dataCenter.workspaces.push(workspace);
dataCenter.callbacks.forEach(cb => cb());
resolve(blockSuiteWorkspace);
}, 0);
});
}
);

View File

@ -0,0 +1,32 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, test } from 'vitest';
import { BlockSuiteWorkspace } from '../../shared';
import { createAffineProviders, createLocalProviders } from '..';
let blockSuiteWorkspace: BlockSuiteWorkspace;
beforeEach(() => {
blockSuiteWorkspace = new BlockSuiteWorkspace({
room: 'test',
});
});
describe('blocksuite providers', () => {
test('should be valid provider', () => {
[createLocalProviders, createAffineProviders].forEach(createProviders => {
createProviders(blockSuiteWorkspace).forEach(provider => {
expect(provider).toBeTypeOf('object');
expect(provider).toHaveProperty('flavour');
expect(provider).toHaveProperty('connect');
expect(provider.connect).toBeTypeOf('function');
expect(provider).toHaveProperty('disconnect');
expect(provider.disconnect).toBeTypeOf('function');
});
});
});
});

View File

@ -0,0 +1,26 @@
import { BlockSuiteWorkspace, Provider } from '../shared';
import { config } from '../shared/env';
import { createIndexedDBProvider, createWebSocketProvider } from './providers';
export const createAffineProviders = (
blockSuiteWorkspace: BlockSuiteWorkspace
): Provider[] => {
return (
[
createWebSocketProvider(blockSuiteWorkspace),
config.enableIndexedDBProvider &&
createIndexedDBProvider(blockSuiteWorkspace),
] as any[]
).filter(v => Boolean(v));
};
export const createLocalProviders = (
blockSuiteWorkspace: BlockSuiteWorkspace
): Provider[] => {
return (
[
config.enableIndexedDBProvider &&
createIndexedDBProvider(blockSuiteWorkspace),
] as any[]
).filter(v => Boolean(v));
};

View File

@ -0,0 +1,71 @@
import { WebsocketProvider } from '@affine/datacenter';
import { assertExists } from '@blocksuite/store';
import { IndexeddbPersistence } from 'y-indexeddb';
import {
AffineWebSocketProvider,
BlockSuiteWorkspace,
LocalIndexedDBProvider,
} from '../../shared';
import { apis } from '../../shared/apis';
export const createWebSocketProvider = (
blockSuiteWorkspace: BlockSuiteWorkspace
): AffineWebSocketProvider => {
let webSocketProvider: WebsocketProvider | null = null;
return {
flavour: 'affine-websocket',
cleanup: () => {
assertExists(webSocketProvider);
webSocketProvider?.destroy();
},
connect: () => {
const wsUrl = `${
window.location.protocol === 'https:' ? 'wss' : 'ws'
}://${window.location.host}/api/sync/`;
webSocketProvider = new WebsocketProvider(
wsUrl,
blockSuiteWorkspace.room as string,
blockSuiteWorkspace.doc,
{
params: { token: apis.auth.refresh },
// @ts-expect-error ignore the type
awareness: blockSuiteWorkspace.awarenessStore.awareness,
}
);
console.log('connect', webSocketProvider.roomname);
webSocketProvider.connect();
},
disconnect: () => {
assertExists(webSocketProvider);
console.log('disconnect', webSocketProvider.roomname);
webSocketProvider?.disconnect();
},
};
};
export const createIndexedDBProvider = (
blockSuiteWorkspace: BlockSuiteWorkspace
): LocalIndexedDBProvider => {
let indexdbProvider: IndexeddbPersistence | null = null;
return {
flavour: 'local-indexeddb',
cleanup: () => {
assertExists(indexdbProvider);
indexdbProvider.clearData();
},
connect: () => {
console.info('connect indexeddb provider', blockSuiteWorkspace.room);
indexdbProvider = new IndexeddbPersistence(
blockSuiteWorkspace.room as string,
blockSuiteWorkspace.doc
);
},
disconnect: () => {
assertExists(indexdbProvider);
console.info('disconnect indexeddb provider', blockSuiteWorkspace.room);
indexdbProvider.destroy();
indexdbProvider = null;
},
};
};

View File

@ -1,29 +0,0 @@
import { Button } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import Image from 'next/image';
import { useRouter } from 'next/router';
import ErrorImg from '../../../public/imgs/invite-error.svg';
import { StyledContainer } from './styles';
export const NotfoundPage = () => {
const { t } = useTranslation();
const router = useRouter();
return (
<StyledContainer data-testid="notFound">
<Image alt="404" src={ErrorImg}></Image>
<p>{t('404 - Page Not Found')}</p>
<Button
shape="round"
onClick={() => {
router.push('/workspace');
}}
>
{t('Back Home')}
</Button>
</StyledContainer>
);
};
export default NotfoundPage;

View File

@ -1,19 +0,0 @@
import { displayFlex, styled } from '@affine/component';
export const StyledContainer = styled.div(() => {
return {
...displayFlex('center', 'center'),
flexDirection: 'column',
height: '100vh',
img: {
width: '360px',
height: '270px',
},
p: {
fontSize: '22px',
fontWeight: 600,
margin: '24px 0',
},
};
});

View File

@ -0,0 +1,30 @@
/**
* @vitest-environment happy-dom
*/
import { render } from '@testing-library/react';
import React, { createContext, useContext } from 'react';
import { expect, test } from 'vitest';
import { ProviderComposer } from '../provider-composer';
test('ProviderComposer', async () => {
const Context = createContext('null');
const Provider: React.FC<React.PropsWithChildren> = ({ children }) => {
return <Context.Provider value="test1">{children}</Context.Provider>;
};
const ConsumerComponent = () => {
const value = useContext(Context);
return <>{value}</>;
};
const Component = () => {
return (
<ProviderComposer contexts={[<Provider key={1} />]}>
<ConsumerComponent />
</ProviderComposer>
);
};
const result = render(<Component />);
await result.findByText('test1');
expect(result.asFragment()).toMatchSnapshot();
});

View File

@ -0,0 +1,89 @@
/**
* @vitest-environment happy-dom
*/
import 'fake-indexeddb/auto';
import { assertExists } from '@blocksuite/store';
import { render, renderHook } from '@testing-library/react';
import { useRouter } from 'next/router';
import { useCallback, useState } from 'react';
import { describe, expect, test, vi } from 'vitest';
import createFetchMock from 'vitest-fetch-mock';
import { useCurrentPageId } from '../../hooks/current/use-current-page-id';
import { useCurrentWorkspace } from '../../hooks/current/use-current-workspace';
import { useBlockSuiteWorkspaceHelper } from '../../hooks/use-blocksuite-workspace-helper';
import { useWorkspacesHelper } from '../../hooks/use-workspaces';
import { ThemeProvider } from '../../providers/ThemeProvider';
import { pathGenerator } from '../../shared';
import { WorkSpaceSliderBar } from '../pure/workspace-slider-bar';
const fetchMocker = createFetchMock(vi);
// fetchMocker.enableMocks();
describe('WorkSpaceSliderBar', () => {
test('basic', async () => {
// fetchMocker.mock
const onOpenWorkspaceListModalFn = vi.fn();
const onOpenQuickSearchModalFn = vi.fn();
const mutationHook = renderHook(() => useWorkspacesHelper());
const id = mutationHook.result.current.createRemLocalWorkspace('test0');
mutationHook.result.current.createWorkspacePage(id, 'test1');
const currentWorkspaceHook = renderHook(() => useCurrentWorkspace());
let i = 0;
const Component = () => {
const [show, setShow] = useState(false);
const [currentWorkspace] = useCurrentWorkspace();
const [currentPageId] = useCurrentPageId();
assertExists(currentWorkspace);
const helper = useBlockSuiteWorkspaceHelper(
currentWorkspace.blockSuiteWorkspace
);
return (
<WorkSpaceSliderBar
currentWorkspace={currentWorkspace}
currentPageId={currentPageId}
onOpenQuickSearchModal={onOpenQuickSearchModalFn}
onOpenWorkspaceListModal={onOpenWorkspaceListModalFn}
openPage={useCallback(() => {}, [])}
createPage={() => {
i++;
return helper.createPage('page-test-' + i);
}}
show={show}
setShow={setShow}
currentPath={useRouter().asPath}
paths={pathGenerator}
isPublicWorkspace={false}
/>
);
};
const App = () => {
return (
<ThemeProvider>
<Component />
</ThemeProvider>
);
};
currentWorkspaceHook.result.current[1](id);
const app = render(<App />);
const card = await app.findByTestId('current-workspace');
expect(onOpenWorkspaceListModalFn).toBeCalledTimes(0);
card.click();
expect(onOpenWorkspaceListModalFn).toBeCalledTimes(1);
const newPageButton = await app.findByTestId('new-page-button');
newPageButton.click();
expect(
currentWorkspaceHook.result.current[0]?.blockSuiteWorkspace.meta
.pageMetas[1].id
).toBe('page-test-1');
expect(onOpenQuickSearchModalFn).toBeCalledTimes(0);
const quickSearchButton = await app.findByTestId(
'slider-bar-quick-search-button'
);
quickSearchButton.click();
expect(onOpenQuickSearchModalFn).toBeCalledTimes(1);
});
});

View File

@ -0,0 +1,7 @@
// Vitest Snapshot v1
exports[`ProviderComposer 1`] = `
<DocumentFragment>
test1
</DocumentFragment>
`;

View File

@ -0,0 +1,19 @@
# Affine Official Workspace Component
This component need specific configuration to work properly.
## Configuration
### SWR
Each component use SWR to fetch data from the API. You need to provide a configuration to SWR to make it work.
```tsx
const Wrapper = () => {
return (
<AffineSWRConfigProvider>
<Component />
</AffineSWRConfigProvider>
);
};
```

View File

@ -0,0 +1,129 @@
import { RequestError } from '@affine/datacenter';
import { NextRouter } from 'next/router';
import React, { Component, ErrorInfo } from 'react';
import { BlockSuiteWorkspace } from '../../shared';
export type AffineErrorBoundaryProps = React.PropsWithChildren<{
router: NextRouter;
}>;
export class PageNotFoundError extends TypeError {
readonly workspace: BlockSuiteWorkspace;
readonly pageId: string;
constructor(workspace: BlockSuiteWorkspace, pageId: string) {
super();
this.workspace = workspace;
this.pageId = pageId;
}
}
export class WorkspaceNotFoundError extends TypeError {
readonly workspaceId: string;
constructor(workspaceId: string) {
super();
this.workspaceId = workspaceId;
}
}
export class QueryParamError extends TypeError {
readonly targetKey: string;
readonly query: unknown;
constructor(targetKey: string, query: unknown) {
super();
this.targetKey = targetKey;
this.query = query;
}
}
export class Unreachable extends Error {
constructor(message?: string) {
super(message);
}
}
type AffineError =
| QueryParamError
| Unreachable
| WorkspaceNotFoundError
| PageNotFoundError
| RequestError
| Error;
interface AffineErrorBoundaryState {
error: AffineError | null;
}
export class AffineErrorBoundary extends Component<
AffineErrorBoundaryProps,
AffineErrorBoundaryState
> {
public state: AffineErrorBoundaryState = {
error: null,
};
public static getDerivedStateFromError(
error: AffineError
): AffineErrorBoundaryState {
return { error };
}
public componentDidCatch(error: AffineError, errorInfo: ErrorInfo) {
console.error('Uncaught error:', error, errorInfo);
}
public render() {
if (this.state.error) {
const error = this.state.error;
if (error instanceof PageNotFoundError) {
return (
<>
<h1>Sorry.. there was an error</h1>
<>
<span> Page error </span>
<span>
Cannot find page {error.pageId} in workspace{' '}
{error.workspace.meta.name}
</span>
<button
onClick={() => {
this.props.router
.replace({
pathname: '/workspace/[workspaceId]/[pageId]',
query: {
workspaceId: error.workspace.room,
pageId: error.workspace.meta.pageMetas[0].id,
},
})
.then(() => {
this.setState({ error: null });
});
}}
>
{' '}
refresh{' '}
</button>
</>
</>
);
} else if (error instanceof RequestError) {
return (
<>
<h1>Sorry.. there was an error</h1>
{error.message}
</>
);
}
return (
<>
<h1>Sorry.. there was an error</h1>
</>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,64 @@
import { IconButton, Modal, ModalWrapper } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { CloseIcon } from '@blocksuite/icons';
import React from 'react';
import { useCurrentUser } from '../../../hooks/current/use-current-user';
import { AffineRemoteWorkspace } from '../../../shared';
import { Content, ContentTitle, Header, StyleButton, StyleTips } from './style';
interface EnableAffineCloudModalProps {
workspace: AffineRemoteWorkspace;
open: boolean;
onConfirm: () => void;
onClose: () => void;
}
export const EnableAffineCloudModal: React.FC<EnableAffineCloudModalProps> = ({
onConfirm,
open,
onClose,
}) => {
const { t } = useTranslation();
const user = useCurrentUser();
return (
<Modal open={open} onClose={onClose} data-testid="logout-modal">
<ModalWrapper width={560} height={292}>
<Header>
<IconButton
onClick={() => {
onClose();
}}
>
<CloseIcon />
</IconButton>
</Header>
<Content>
<ContentTitle>{t('Enable AFFiNE Cloud')}?</ContentTitle>
<StyleTips>{t('Enable AFFiNE Cloud Description')}</StyleTips>
{/* <StyleTips>{t('Retain cached cloud data')}</StyleTips> */}
<div>
<StyleButton
shape="round"
type="primary"
onClick={() => {
onConfirm();
}}
>
{user ? t('Enable') : t('Sign in and Enable')}
</StyleButton>
<StyleButton
shape="round"
onClick={() => {
onClose();
}}
>
{t('Not now')}
</StyleButton>
</div>
</Content>
</ModalWrapper>
</Modal>
);
};

View File

@ -1,31 +1,22 @@
import { IconButton, Modal, ModalWrapper, toast } from '@affine/component';
import { IconButton, Modal, ModalWrapper } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { CloseIcon } from '@blocksuite/icons';
import { useRouter } from 'next/router';
import { useCallback, useState } from 'react';
import { useGlobalState } from '@/store/app';
import React from 'react';
import { useCurrentUser } from '../../../hooks/current/use-current-user';
import { Content, ContentTitle, Header, StyleButton, StyleTips } from './style';
interface EnableWorkspaceModalProps {
export type TransformWorkspaceToAffineModalProps = {
open: boolean;
onClose: () => void;
}
onConform: () => void;
};
export const EnableWorkspaceModal = ({
open,
onClose,
}: EnableWorkspaceModalProps) => {
export const TransformWorkspaceToAffineModal: React.FC<
TransformWorkspaceToAffineModalProps
> = ({ open, onClose, onConform }) => {
const { t } = useTranslation();
const login = useGlobalState(store => store.login);
const user = useGlobalState(store => store.user);
const dataCenter = useGlobalState(store => store.dataCenter);
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const [loading, setLoading] = useState(false);
const router = useRouter();
const user = useCurrentUser();
return (
<Modal open={open} onClose={onClose} data-testid="logout-modal">
@ -47,22 +38,22 @@ export const EnableWorkspaceModal = ({
<StyleButton
shape="round"
type="primary"
loading={loading}
onClick={async () => {
setLoading(true);
if (user || (await login())) {
if (currentWorkspace) {
const workspace = await dataCenter.enableWorkspaceCloud(
currentWorkspace
);
toast(t('Enabled success'));
if (workspace) {
router.push(`/workspace/${workspace.id}/setting`);
}
}
}
setLoading(false);
onConform();
// setLoading(true);
// if (user || (await login())) {
// if (currentWorkspace) {
// const workspace = await dataCenter.enableWorkspaceCloud(
// currentWorkspace
// );
// toast(t('Enabled success'));
//
// if (workspace) {
// router.push(`/workspace/${workspace.id}/setting`);
// }
// }
// }
// setLoading(false);
}}
>
{user ? t('Enable') : t('Sign in and Enable')}

View File

@ -0,0 +1,40 @@
import { Button, styled } from '@affine/component';
export const Header = styled('div')({
height: '44px',
display: 'flex',
flexDirection: 'row-reverse',
paddingRight: '10px',
paddingTop: '10px',
flexShrink: 0,
});
export const Content = styled('div')({
textAlign: 'center',
});
export const ContentTitle = styled('h1')({
fontSize: '20px',
lineHeight: '28px',
fontWeight: 600,
textAlign: 'center',
});
export const StyleTips = styled('div')(() => {
return {
userSelect: 'none',
width: '400px',
margin: 'auto',
marginBottom: '32px',
marginTop: '12px',
};
});
export const StyleButton = styled(Button)(() => {
return {
width: '284px',
display: 'block',
margin: 'auto',
marginTop: '16px',
};
});

View File

@ -0,0 +1,159 @@
import { useTranslation } from '@affine/i18n';
import React, {
MouseEvent,
useCallback,
useEffect,
useMemo,
useRef,
} from 'react';
import { preload } from 'swr';
import { useIsWorkspaceOwner } from '../../../hooks/affine/use-is-workspace-owner';
import { fetcher, QueryKey } from '../../../plugins/affine/fetcher';
import {
AffineOfficialWorkspace,
SettingPanel,
settingPanel,
} from '../../../shared';
import { CollaborationPanel } from './panel/collaboration';
import { ExportPanel } from './panel/export';
import { GeneralPanel } from './panel/general';
import { PublishPanel } from './panel/publish';
import {
StyledIndicator,
StyledSettingContainer,
StyledSettingContent,
StyledTabButtonWrapper,
WorkspaceSettingTagItem,
} from './style';
export type WorkspaceSettingDetailProps = {
workspace: AffineOfficialWorkspace;
currentTab: SettingPanel;
onChangeTab: (tab: SettingPanel) => void;
onDeleteWorkspace: () => void;
onTransferWorkspace: (targetWorkspaceId: string) => void;
};
export type PanelProps = WorkspaceSettingDetailProps;
const panelMap = {
[settingPanel.General]: {
name: 'General',
ui: GeneralPanel,
},
[settingPanel.Collaboration]: {
name: 'Collaboration',
ui: CollaborationPanel,
},
[settingPanel.Publish]: {
name: 'Publish',
ui: PublishPanel,
},
[settingPanel.Export]: {
name: 'Export',
ui: ExportPanel,
},
} satisfies {
[Key in SettingPanel]: {
name: string;
ui: React.FC<PanelProps>;
};
};
function assertInstanceOf<T, U extends T>(
obj: T,
type: new (...args: any[]) => U
): asserts obj is U {
if (!(obj instanceof type)) {
throw new Error('Object is not instance of type');
}
}
export const WorkspaceSettingDetail: React.FC<
WorkspaceSettingDetailProps
> = props => {
const {
workspace,
currentTab,
onChangeTab,
// onDeleteWorkspace,
// onTransferWorkspace,
} = props;
const isAffine = workspace.flavour === 'affine';
const isOwner = useIsWorkspaceOwner(workspace);
if (!(workspace.flavour === 'affine' || workspace.flavour === 'local')) {
throw new Error('Unsupported workspace flavour');
}
if (!(currentTab in panelMap)) {
throw new Error('Invalid activeTab: ' + currentTab);
}
const { t } = useTranslation();
const workspaceId = workspace.id;
useEffect(() => {
if (isAffine && isOwner) {
preload([QueryKey.getMembers, workspaceId], fetcher);
}
}, [isAffine, isOwner, workspaceId]);
const containerRef = useRef<HTMLDivElement | null>(null);
const indicatorRef = useRef<HTMLDivElement | null>(null);
const startTransaction = useCallback(() => {
if (indicatorRef.current && containerRef.current) {
const indicator = indicatorRef.current;
const activeTabElement = containerRef.current.querySelector(
`[data-tab-key="${currentTab}"]`
);
assertInstanceOf(activeTabElement, HTMLElement);
requestAnimationFrame(() => {
indicator.style.left = `${activeTabElement.offsetLeft}px`;
indicator.style.width = `${activeTabElement.offsetWidth}px`;
});
}
}, [currentTab]);
const handleTabClick = useCallback(
(event: MouseEvent<HTMLElement>) => {
assertInstanceOf(event.target, HTMLElement);
const key = event.target.getAttribute('data-tab-key');
if (!key || !(key in panelMap)) {
throw new Error('data-tab-key is invalid: ' + key);
}
onChangeTab(key as SettingPanel);
startTransaction();
},
[onChangeTab, startTransaction]
);
const Component = useMemo(() => panelMap[currentTab].ui, [currentTab]);
return (
<StyledSettingContainer
aria-label="workspace-setting-detail"
ref={containerRef}
>
<StyledTabButtonWrapper>
{Object.entries(panelMap).map(([key, value]) => {
if (!isAffine && key === 'Sync') {
return null;
}
return (
<WorkspaceSettingTagItem
key={key}
isActive={currentTab === key}
data-tab-key={key}
onClick={handleTabClick}
>
{t(value.name)}
</WorkspaceSettingTagItem>
);
})}
<StyledIndicator
ref={ref => {
indicatorRef.current = ref;
startTransaction();
}}
/>
</StyledTabButtonWrapper>
<StyledSettingContent>
<Component {...props} key={currentTab} data-tab-ui={currentTab} />
</StyledSettingContent>
</StyledSettingContainer>
);
};

View File

@ -0,0 +1,227 @@
import {
Button,
IconButton,
Menu,
MenuItem,
toast,
Wrapper,
} from '@affine/component';
import { PermissionType } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import {
DeleteTemporarilyIcon,
EmailIcon,
MoreVerticalIcon,
} from '@blocksuite/icons';
import React, { useCallback, useState } from 'react';
import { lockMutex } from '../../../../../atoms';
import { useMembers } from '../../../../../hooks/affine/use-members';
import { transformWorkspace } from '../../../../../plugins';
import {
AffineRemoteWorkspace,
LocalWorkspace,
RemWorkspaceFlavour,
} from '../../../../../shared';
import { Unreachable } from '../../../affine-error-eoundary';
import { TransformWorkspaceToAffineModal } from '../../../transform-workspace-to-affine-modal';
import { PanelProps } from '../../index';
import { InviteMemberModal } from './invite-member-modal';
import {
StyledMemberAvatar,
StyledMemberButtonContainer,
StyledMemberContainer,
StyledMemberEmail,
StyledMemberInfo,
StyledMemberListContainer,
StyledMemberListItem,
StyledMemberName,
StyledMemberNameContainer,
StyledMemberRoleContainer,
StyledMemberTitleContainer,
StyledMoreVerticalButton,
StyledMoreVerticalDiv,
} from './style';
const AffineRemoteCollaborationPanel: React.FC<
Omit<PanelProps, 'workspace'> & {
workspace: AffineRemoteWorkspace;
}
> = ({ workspace }) => {
const [isInviteModalShow, setIsInviteModalShow] = useState(false);
const { t } = useTranslation();
const { members, removeMember } = useMembers(workspace.id);
return (
<>
<StyledMemberContainer>
<ul>
<StyledMemberTitleContainer>
<StyledMemberNameContainer>
{t('Users')} ({members.length})
</StyledMemberNameContainer>
<StyledMemberRoleContainer>
{t('Access level')}
</StyledMemberRoleContainer>
<div style={{ width: '24px', paddingRight: '48px' }}></div>
</StyledMemberTitleContainer>
</ul>
<StyledMemberListContainer>
{members.length > 0 && (
<>
{members
.sort((b, a) => a.type - b.type)
.map((member, index) => {
const user = {
avatar_url: '',
id: '',
name: '',
...member.user,
};
return (
<StyledMemberListItem key={index}>
<StyledMemberNameContainer>
<StyledMemberAvatar
alt="member avatar"
src={user.avatar_url}
>
<EmailIcon />
</StyledMemberAvatar>
<StyledMemberInfo>
<StyledMemberName>{user.name}</StyledMemberName>
<StyledMemberEmail>
{member.user.email}
</StyledMemberEmail>
</StyledMemberInfo>
</StyledMemberNameContainer>
<StyledMemberRoleContainer>
{member.accepted
? member.type !== PermissionType.Owner
? t('Member')
: t('Owner')
: t('Pending')}
</StyledMemberRoleContainer>
{member.type === PermissionType.Owner ? (
<StyledMoreVerticalDiv />
) : (
<StyledMoreVerticalButton>
<Menu
content={
<>
<MenuItem
onClick={async () => {
// FIXME: remove ignore
// @ts-ignore
await removeMember(member.id);
toast(
t('Member has been removed', {
name: user.name,
})
);
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Remove from workspace')}
</MenuItem>
</>
}
placement="bottom-end"
disablePortal={true}
trigger="click"
>
<IconButton>
<MoreVerticalIcon />
</IconButton>
</Menu>
</StyledMoreVerticalButton>
)}
</StyledMemberListItem>
);
})}
</>
)}
</StyledMemberListContainer>
<StyledMemberButtonContainer>
<Button
onClick={() => {
setIsInviteModalShow(true);
}}
type="primary"
shape="circle"
>
{t('Invite Members')}
</Button>
</StyledMemberButtonContainer>
</StyledMemberContainer>
<InviteMemberModal
onClose={useCallback(() => {
setIsInviteModalShow(false);
}, [])}
onInviteSuccess={useCallback(() => {
setIsInviteModalShow(false);
}, [])}
workspaceId={workspace.id}
open={isInviteModalShow}
/>
</>
);
};
const LocalCollaborationPanel: React.FC<
Omit<PanelProps, 'workspace'> & {
workspace: LocalWorkspace;
}
> = ({ workspace, onTransferWorkspace }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<>
<Wrapper marginBottom="32px">{t('Collaboration Description')}</Wrapper>
<Button
type="light"
shape="circle"
onClick={() => {
setOpen(true);
}}
>
{t('Enable AFFiNE Cloud')}
</Button>
<TransformWorkspaceToAffineModal
open={open}
onClose={() => {
setOpen(false);
}}
onConform={() => {
// todo(himself65): move this function out of affine component
lockMutex(async () => {
const id = await transformWorkspace(
RemWorkspaceFlavour.LOCAL,
RemWorkspaceFlavour.AFFINE,
workspace
);
onTransferWorkspace(id);
setOpen(false);
});
}}
/>
</>
);
};
export const CollaborationPanel: React.FC<PanelProps> = props => {
switch (props.workspace.flavour) {
case RemWorkspaceFlavour.AFFINE: {
const workspace = props.workspace as AffineRemoteWorkspace;
return (
<AffineRemoteCollaborationPanel {...props} workspace={workspace} />
);
}
case RemWorkspaceFlavour.LOCAL: {
const workspace = props.workspace as LocalWorkspace;
return <LocalCollaborationPanel {...props} workspace={workspace} />;
}
}
throw new Unreachable();
};

View File

@ -3,12 +3,13 @@ import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { Button } from '@affine/component';
import { Input } from '@affine/component';
import { MuiAvatar } from '@affine/component';
import { User } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import { EmailIcon } from '@blocksuite/icons';
import { useState } from 'react';
import React, { Suspense, useCallback, useState } from 'react';
import { useMembers } from '../../../../../../hooks/affine/use-members';
import { useUsersByEmail } from '../../../../../../hooks/affine/use-users-by-email';
import useMembers from '@/hooks/use-members';
interface LoginModalProps {
open: boolean;
onClose: () => void;
@ -16,60 +17,47 @@ interface LoginModalProps {
onInviteSuccess: () => void;
}
export const debounce = <T extends (...args: any) => any>(
fn: T,
time?: number,
immediate?: boolean
): ((...args: any) => any) => {
let timeoutId: null | number;
let defaultImmediate = immediate || false;
const delay = time || 300;
const gmailReg = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@gmail\.com$/;
return (...args: any) => {
if (defaultImmediate) {
fn.apply(this, args);
defaultImmediate = false;
return;
}
if (timeoutId) {
clearTimeout(timeoutId);
}
// @ts-ignore
timeoutId = setTimeout(() => {
fn.apply(this, args);
timeoutId = null;
}, delay);
};
const Result: React.FC<{
workspaceId: string;
queryEmail: string;
}> = ({ workspaceId, queryEmail }) => {
const users = useUsersByEmail(workspaceId, queryEmail);
const firstUser = users?.at(0) ?? null;
if (!firstUser || !firstUser.email) {
return null;
}
return (
<Members>
<Member>
{firstUser.avatar_url ? (
<MuiAvatar src={firstUser.avatar_url}></MuiAvatar>
) : (
<MemberIcon>
<EmailIcon></EmailIcon>
</MemberIcon>
)}
<Email>{firstUser.email}</Email>
{/* <div>invited</div> */}
</Member>
</Members>
);
};
const gmailReg = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@gmail\.com$/;
export const InviteMemberModal = ({
open,
onClose,
onInviteSuccess,
workspaceId,
}: LoginModalProps) => {
const { inviteMember } = useMembers(workspaceId);
const [email, setEmail] = useState<string>('');
const [showMember, setShowMember] = useState<boolean>(false);
const [showTip, setShowTip] = useState<boolean>(false);
const [userData, setUserData] = useState<User | null>(null);
const { inviteMember, getUserByEmail } = useMembers();
const [showMemberPreview, setShowMemberPreview] = useState(false);
const { t } = useTranslation();
const inputChange = (value: string) => {
setShowMember(true);
if (gmailReg.test(value)) {
setEmail(value);
setShowTip(false);
getUserByEmail(value).then(data => {
if (data?.name) {
setUserData(data);
setShowTip(false);
}
});
} else {
setShowTip(true);
}
};
const inputChange = useCallback((value: string) => {
setEmail(value);
}, []);
return (
<div>
<Modal open={open} onClose={onClose}>
@ -89,36 +77,24 @@ export const InviteMemberModal = ({
width={360}
value={email}
onChange={inputChange}
onBlur={() => {
setShowMember(false);
}}
onFocus={useCallback(() => {
setShowMemberPreview(true);
}, [])}
onBlur={useCallback(() => {
setShowMemberPreview(false);
}, [])}
placeholder={t('Invite placeholder')}
></Input>
{showMember ? (
<Members>
{showTip ? (
<NoFind>{t('Non-Gmail')}</NoFind>
) : (
<Member>
{userData?.avatar ? (
<MuiAvatar src={userData?.avatar}></MuiAvatar>
) : (
<MemberIcon>
<EmailIcon></EmailIcon>
</MemberIcon>
)}
<Email>{email}</Email>
{/* <div>invited</div> */}
</Member>
)}
</Members>
) : (
<></>
{showMemberPreview && gmailReg.test(email) && (
<Suspense fallback="loading...">
<Result workspaceId={workspaceId} queryEmail={email} />
</Suspense>
)}
</InviteBox>
</Content>
<Footer>
<Button
disabled={!gmailReg.test(email)}
shape="circle"
type="primary"
style={{ width: '364px', height: '38px', borderRadius: '40px' }}
@ -184,15 +160,15 @@ const Members = styled('div')(({ theme }) => {
};
});
const NoFind = styled('div')(({ theme }) => {
return {
color: theme.colors.iconColor,
fontSize: theme.font.sm,
lineHeight: '40px',
userSelect: 'none',
width: '100%',
};
});
// 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 {

View File

@ -86,7 +86,7 @@ export const StyledMemberButtonContainer = styled('div')(() => {
};
});
export const StyledMoreVerticalButton = styled('button')(() => {
export const StyledMoreVerticalDiv = styled('div')(() => {
return {
display: 'flex',
justifyContent: 'center',
@ -97,3 +97,5 @@ export const StyledMoreVerticalButton = styled('button')(() => {
paddingRight: '48px',
};
});
export const StyledMoreVerticalButton = styled(StyledMoreVerticalDiv)();

View File

@ -1,10 +1,7 @@
import { Wrapper } from '@affine/component';
import { Button } from '@affine/component';
import { WorkspaceUnit } from '@affine/datacenter';
import { Button, Wrapper } from '@affine/component';
import { useTranslation } from '@affine/i18n';
export const ExportPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
console.log('workspace', workspace);
export const ExportPanel = () => {
const { t } = useTranslation();
return (
<>

View File

@ -1,14 +1,11 @@
import { Modal } from '@affine/component';
import { Input } from '@affine/component';
import { ModalCloseButton } from '@affine/component';
import { Button } from '@affine/component';
import { WorkspaceUnit } from '@affine/datacenter';
import { Button, Input, Modal, ModalCloseButton } from '@affine/component';
import { Trans, useTranslation } from '@affine/i18n';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useWorkspaceHelper } from '@/hooks/use-workspace-helper';
import { useCallback, useState } from 'react';
import {
AffineOfficialWorkspace,
RemWorkspaceFlavour,
} from '../../../../../../shared';
import {
StyledButtonContent,
StyledInputContent,
@ -21,40 +18,35 @@ import {
interface WorkspaceDeleteProps {
open: boolean;
onClose: () => void;
workspace: WorkspaceUnit;
workspace: AffineOfficialWorkspace;
onDeleteWorkspace: () => void;
}
export const WorkspaceDelete = ({
export const WorkspaceDeleteModal = ({
open,
onClose,
workspace,
onDeleteWorkspace,
}: WorkspaceDeleteProps) => {
const [deleteStr, setDeleteStr] = useState<string>('');
const allowDelete = deleteStr.toLowerCase() === 'delete';
const { t } = useTranslation();
const router = useRouter();
const { deleteWorkSpace } = useWorkspaceHelper();
const handlerInputChange = (workspaceName: string) => {
setDeleteStr(workspaceName);
};
const handleDelete = async () => {
await deleteWorkSpace();
onClose();
router.push(`/workspace`);
};
console.log('workspace', workspace);
const handleDelete = useCallback(() => {
onDeleteWorkspace();
}, [onDeleteWorkspace]);
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
<ModalCloseButton onClick={onClose} />
<StyledModalHeader>{t('Delete Workspace')}?</StyledModalHeader>
{workspace.provider === 'local' ? (
{workspace.flavour === RemWorkspaceFlavour.LOCAL ? (
<StyledTextContent>
<Trans i18nKey="Delete Workspace Description">
Deleting (
<StyledWorkspaceName>
{{ workspace: workspace.name } as any}
{{ workspace: workspace.blockSuiteWorkspace.meta.name } as any}
</StyledWorkspaceName>
) cannot be undone, please proceed with caution. All contents will
be lost.
@ -65,7 +57,7 @@ export const WorkspaceDelete = ({
<Trans i18nKey="Delete Workspace Description2">
Deleting (
<StyledWorkspaceName>
{{ workspace: workspace.name } as any}
{{ workspace: workspace.blockSuiteWorkspace.meta.name } as any}
</StyledWorkspaceName>
) will delete both local and cloud data, this operation cannot be
undone, please proceed with caution.
@ -74,7 +66,7 @@ export const WorkspaceDelete = ({
)}
<StyledInputContent>
<Input
onChange={handlerInputChange}
onChange={setDeleteStr}
placeholder={t('Delete Workspace placeholder')}
value={deleteStr}
width={284}
@ -86,7 +78,7 @@ export const WorkspaceDelete = ({
{t('Cancel')}
</Button>
<Button
disabled={deleteStr.toLowerCase() !== 'delete'}
disabled={!allowDelete}
onClick={handleDelete}
type="danger"
shape="circle"
@ -99,5 +91,3 @@ export const WorkspaceDelete = ({
</Modal>
);
};
export default WorkspaceDelete;

View File

@ -1,23 +1,21 @@
import { FlexWrapper, MuiAvatar } from '@affine/component';
import { Button } from '@affine/component';
import { MuiFade } from '@affine/component';
import { WorkspaceUnit } from '@affine/datacenter';
import { Button, FlexWrapper, MuiFade } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { EmailIcon } from '@blocksuite/icons';
import { useCallback, useState } from 'react';
import { assertExists } from '@blocksuite/store';
import React, { useState } from 'react';
import { Upload } from '@/components/file-upload';
import { useIsWorkspaceOwner } from '../../../../../hooks/affine/use-is-workspace-owner';
import { refreshDataCenter } from '../../../../../hooks/use-workspaces';
import { RemWorkspaceFlavour } from '../../../../../shared';
import { Upload } from '../../../../pure/file-upload';
import {
CloudWorkspaceIcon,
JoinedWorkspaceIcon,
LocalWorkspaceIcon,
} from '@/components/icons';
import { WorkspaceUnitAvatar } from '@/components/workspace-avatar';
import { useWorkspaceHelper } from '@/hooks/use-workspace-helper';
import { useGlobalState } from '@/store/app';
import { StyledRow, StyledSettingKey } from '../style';
import { WorkspaceDelete } from './delete';
} from '../../../../pure/icons';
import { WorkspaceAvatar } from '../../../../pure/workspace-avatar';
import { PanelProps } from '../../index';
import { StyledRow, StyledSettingKey } from '../../style';
import { WorkspaceDeleteModal } from './delete';
import { CameraIcon } from './icons';
import { WorkspaceLeave } from './leave';
import {
@ -26,31 +24,35 @@ import {
StyledInput,
StyledWorkspaceInfo,
} from './style';
export const GeneralPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
export const GeneralPanel: React.FC<PanelProps> = ({
workspace,
onDeleteWorkspace,
}) => {
const [showDelete, setShowDelete] = useState<boolean>(false);
const [showLeave, setShowLeave] = useState<boolean>(false);
const [workspaceName, setWorkspaceName] = useState<string>(workspace?.name);
const [showEditInput, setShowEditInput] = useState(false);
const isOwner = useGlobalState(store => store.isOwner);
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
const [workspaceName, setWorkspaceName] = useState<string>(
workspace.blockSuiteWorkspace.meta.name
);
const { updateWorkspace } = useWorkspaceHelper();
const isOwner = useIsWorkspaceOwner(workspace);
const [showEditInput, setShowEditInput] = useState(false);
const { t } = useTranslation();
const handleUpdateWorkspaceName = () => {
currentWorkspace &&
updateWorkspace({ name: workspaceName }, currentWorkspace);
workspace.blockSuiteWorkspace.meta.setName(workspaceName);
// fixme(himself65): don't refresh
refreshDataCenter();
};
const fileChange = async (file: File) => {
const blob = new Blob([file], { type: file.type });
currentWorkspace &&
(await updateWorkspace({ avatarBlob: blob }, currentWorkspace));
const blobs = await workspace.blockSuiteWorkspace.blobs;
assertExists(blobs);
const blobId = await blobs.set(blob);
workspace.blockSuiteWorkspace.meta.setAvatar(blobId);
// fixme(himself65): don't refresh
refreshDataCenter();
};
if (!workspace) {
return null;
}
return (
<>
@ -66,19 +68,11 @@ export const GeneralPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
<div className="camera-icon">
<CameraIcon></CameraIcon>
</div>
<WorkspaceUnitAvatar
size={72}
name={workspace.name}
workspaceUnit={workspace}
/>
<WorkspaceAvatar size={72} workspace={workspace} />
</>
</Upload>
) : (
<WorkspaceUnitAvatar
size={72}
name={workspace.name}
workspaceUnit={workspace}
/>
<WorkspaceAvatar size={72} workspace={workspace} />
)}
</StyledAvatar>
</StyledRow>
@ -89,7 +83,7 @@ export const GeneralPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
<div style={{ position: 'relative' }}>
<MuiFade in={!showEditInput}>
<FlexWrapper>
{workspace.name}
{workspace.blockSuiteWorkspace.meta.name}
{isOwner && (
<StyledEditButton
onClick={() => {
@ -120,7 +114,9 @@ export const GeneralPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
type="light"
shape="circle"
style={{ marginLeft: '24px' }}
disabled={workspaceName === workspace.name}
disabled={
workspaceName === workspace.blockSuiteWorkspace.meta.name
}
onClick={() => {
handleUpdateWorkspaceName();
setShowEditInput(false);
@ -133,7 +129,7 @@ export const GeneralPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
shape="circle"
style={{ marginLeft: '24px' }}
onClick={() => {
setWorkspaceName(workspace.name);
setWorkspaceName(workspace.blockSuiteWorkspace.meta.name);
setShowEditInput(false);
}}
>
@ -145,34 +141,35 @@ export const GeneralPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
</div>
</StyledRow>
{!isOwner && (
<StyledRow>
<StyledSettingKey>{t('Workspace Owner')}</StyledSettingKey>
<FlexWrapper alignItems="center">
<MuiAvatar
sx={{ width: 72, height: 72, marginRight: '12px' }}
alt="owner avatar"
src={currentWorkspace?.owner?.avatar}
>
<EmailIcon />
</MuiAvatar>
<span>{currentWorkspace?.owner?.name}</span>
</FlexWrapper>
</StyledRow>
)}
{!isOwner && (
<StyledRow>
<StyledSettingKey>{t('Members')}</StyledSettingKey>
<FlexWrapper alignItems="center">
<span>{currentWorkspace?.memberCount}</span>
</FlexWrapper>
</StyledRow>
)}
{/* fixme(himself65): how to know a workspace owner by api? */}
{/*{!isOwner && (*/}
{/* <StyledRow>*/}
{/* <StyledSettingKey>{t('Workspace Owner')}</StyledSettingKey>*/}
{/* <FlexWrapper alignItems="center">*/}
{/* <MuiAvatar*/}
{/* sx={{ width: 72, height: 72, marginRight: '12px' }}*/}
{/* alt="owner avatar"*/}
{/* // src={currentWorkspace?.owner?.avatar}*/}
{/* >*/}
{/* <EmailIcon />*/}
{/* </MuiAvatar>*/}
{/* /!*<span>{currentWorkspace?.owner?.name}</span>*!/*/}
{/* </FlexWrapper>*/}
{/* </StyledRow>*/}
{/*)}*/}
{/*{!isOwner && (*/}
{/* <StyledRow>*/}
{/* <StyledSettingKey>{t('Members')}</StyledSettingKey>*/}
{/* <FlexWrapper alignItems="center">*/}
{/* /!*<span>{currentWorkspace?.memberCount}</span>*!/*/}
{/* </FlexWrapper>*/}
{/* </StyledRow>*/}
{/*)}*/}
<StyledRow>
<StyledSettingKey>{t('Workspace Type')}</StyledSettingKey>
{isOwner ? (
currentWorkspace?.provider === 'local' ? (
workspace.flavour === RemWorkspaceFlavour.LOCAL ? (
<StyledWorkspaceInfo>
<LocalWorkspaceIcon />
<span>{t('Local Workspace')}</span>
@ -205,7 +202,8 @@ export const GeneralPage = ({ workspace }: { workspace: WorkspaceUnit }) => {
>
{t('Delete Workspace')}
</Button>
<WorkspaceDelete
<WorkspaceDeleteModal
onDeleteWorkspace={onDeleteWorkspace}
open={showDelete}
onClose={() => {
setShowDelete(false);

View File

@ -3,16 +3,12 @@ import { ModalCloseButton } from '@affine/component';
import { Button } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { useWorkspaceHelper } from '@/hooks/use-workspace-helper';
import {
StyledButtonContent,
StyledModalHeader,
StyledModalWrapper,
StyledTextContent,
} from './style';
// import { getDataCenter } from '@affine/datacenter';
// import { useAppState } from '@/providers/app-state-provider';
interface WorkspaceDeleteProps {
open: boolean;
@ -20,10 +16,10 @@ interface WorkspaceDeleteProps {
}
export const WorkspaceLeave = ({ open, onClose }: WorkspaceDeleteProps) => {
const { leaveWorkSpace } = useWorkspaceHelper();
// const { leaveWorkSpace } = useWorkspaceHelper();
const { t } = useTranslation();
const handleLeave = async () => {
await leaveWorkSpace();
// await leaveWorkSpace();
onClose();
};
@ -52,5 +48,3 @@ export const WorkspaceLeave = ({ open, onClose }: WorkspaceDeleteProps) => {
</Modal>
);
};
export default WorkspaceLeave;

View File

@ -8,6 +8,7 @@ export const StyledInput = styled(Input)(({ theme }) => {
fontSize: theme.font.sm,
};
});
export const StyledWorkspaceInfo = styled.div(({ theme }) => {
return {
...displayFlex('flex-start', 'center'),
@ -18,6 +19,7 @@ export const StyledWorkspaceInfo = styled.div(({ theme }) => {
},
};
});
export const StyledAvatar = styled('div')(
({ disabled }: { disabled: boolean }) => {
return {

View File

@ -0,0 +1,147 @@
import {
Button,
Content,
FlexWrapper,
Input,
toast,
Wrapper,
} from '@affine/component';
import { useTranslation } from '@affine/i18n';
import React, { useCallback, useEffect, useState } from 'react';
import { lockMutex } from '../../../../../atoms';
import { useToggleWorkspacePublish } from '../../../../../hooks/affine/use-toggle-workspace-publish';
import {
AffineOfficialWorkspace,
AffineRemoteWorkspace,
LocalWorkspace,
RemWorkspaceFlavour,
} from '../../../../../shared';
import { Unreachable } from '../../../affine-error-eoundary';
import { EnableAffineCloudModal } from '../../../enable-affine-cloud-modal';
export type PublishPanelProps = {
workspace: AffineOfficialWorkspace;
};
export type PublishPanelAffineProps = {
workspace: AffineRemoteWorkspace;
};
const PublishPanelAffine: React.FC<PublishPanelAffineProps> = ({
workspace,
}) => {
const [origin, setOrigin] = useState('');
useEffect(() => {
setOrigin(
typeof window !== 'undefined' && window.location.origin
? window.location.origin
: ''
);
}, []);
const shareUrl = origin + '/public-workspace/' + workspace.id;
const { t } = useTranslation();
const publishWorkspace = useToggleWorkspacePublish(workspace);
const copyUrl = useCallback(() => {
navigator.clipboard.writeText(shareUrl);
toast(t('Copied link to clipboard'));
}, [shareUrl, t]);
const [open, setOpen] = useState(false);
if (workspace.public) {
return (
<>
<Wrapper marginBottom="42px">{t('Published Description')}</Wrapper>
<Wrapper marginBottom="12px">
<Content weight="500">{t('Share with link')}</Content>
</Wrapper>
<FlexWrapper>
<Input width={582} value={shareUrl} disabled={true}></Input>
<Button
onClick={copyUrl}
type="light"
shape="circle"
style={{ marginLeft: '24px' }}
>
{t('Copy Link')}
</Button>
</FlexWrapper>
<Button
onClick={async () => {
lockMutex(async () => {
return publishWorkspace(false);
});
}}
loading={false}
type="danger"
shape="circle"
style={{ marginTop: '38px' }}
>
{t('Stop publishing')}
</Button>
</>
);
}
return (
<>
<Wrapper marginBottom="42px">{t('Publishing Description')}</Wrapper>
<Button
onClick={() => {
setOpen(true);
}}
type="light"
shape="circle"
>
{t('Publish to web')}
</Button>
<EnableAffineCloudModal
workspace={workspace}
open={open}
onClose={() => {
setOpen(false);
}}
onConfirm={() => {
lockMutex(async () => {
return publishWorkspace(true);
}).then(() => {
setOpen(false);
});
}}
/>
</>
);
};
export type PublishPanelLocalProps = {
workspace: LocalWorkspace;
};
const PublishPanelLocal: React.FC<PublishPanelLocalProps> = ({ workspace }) => {
const { t } = useTranslation();
return (
<>
<Wrapper marginBottom="42px">{t('Publishing')}</Wrapper>
<Button
type="light"
shape="circle"
onClick={async () => {
// fixme: regression
toast('You need to enable AFFiNE Cloud to use this feature.');
}}
>
{t('Enable AFFiNE Cloud')}
</Button>
</>
);
};
export const PublishPanel: React.FC<PublishPanelProps> = ({ workspace }) => {
if (workspace.flavour === RemWorkspaceFlavour.AFFINE) {
return <PublishPanelAffine workspace={workspace} />;
} else if (workspace.flavour === RemWorkspaceFlavour.LOCAL) {
return <PublishPanelLocal workspace={workspace} />;
}
throw new Unreachable();
};

View File

@ -65,6 +65,24 @@ export const StyledWorkspaceName = styled('span')(({ theme }) => {
};
});
export const StyledIndicator = styled.div(({ theme }) => {
return {
height: '2px',
background: theme.colors.primaryColor,
position: 'absolute',
left: '0',
bottom: '0',
transition: 'left .3s, width .3s',
};
});
export const StyledTabButtonWrapper = styled.div(() => {
return {
display: 'flex',
position: 'relative',
};
});
// export const StyledDownloadCard = styled.div<{ active?: boolean }>(
// ({ theme, active }) => {
// return {

View File

@ -0,0 +1,104 @@
import { BlockHub } from '@blocksuite/blocks';
import { EditorContainer } from '@blocksuite/editor';
import type { Page } from '@blocksuite/store';
import { assertExists } from '@blocksuite/store';
import { useEffect, useRef } from 'react';
import { BlockSuiteWorkspace } from '../../../shared';
export type EditorProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
page: Page;
mode: 'page' | 'edgeless';
onInit?: (page: Page, editor: Readonly<EditorContainer>) => void;
onLoad?: (page: Page, editor: EditorContainer) => void;
};
import markdown from '../../../templates/Welcome-to-AFFiNE-Alpha-Downhills.md';
const exampleTitle = markdown
.split('\n')
.splice(0, 1)
.join('')
.replaceAll('#', '')
.trim();
const exampleText = markdown.split('\n').slice(1).join('\n');
const kFirstPage = 'affine-first-page';
export const BlockSuiteEditor = (props: EditorProps) => {
const page = props.page;
const editorRef = useRef<EditorContainer | null>(null);
const blockHubRef = useRef<BlockHub | null>(null);
if (editorRef.current === null) {
editorRef.current = new EditorContainer();
// fixme(himself65): remove `globalThis.editor`
// @ts-expect-error
globalThis.editor = editorRef.current;
}
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (editorRef.current) {
editorRef.current.mode = props.mode;
}
}, [props.mode]);
useEffect(() => {
const editor = editorRef.current;
if (!editor || !ref.current || !page) {
return;
}
editor.page = page;
if (page.root === null) {
if (props.onInit) {
props.onInit(page, editor);
} else {
console.debug('Initializing page with default content');
// Add page block and surface block at root level
const title =
localStorage.getItem(kFirstPage) === null ? exampleTitle : undefined;
const pageBlockId = page.addBlockByFlavour('affine:page', {
title,
});
page.addBlockByFlavour('affine:surface', {}, null);
const frameId = page.addBlockByFlavour('affine:frame', {}, pageBlockId);
page.addBlockByFlavour('affine:paragraph', {}, frameId);
if (localStorage.getItem(kFirstPage) === null) {
// fixme(himself65): remove
editor.clipboard.importMarkdown(exampleText, frameId);
props.blockSuiteWorkspace.setPageMeta(page.id, { title });
localStorage.setItem(kFirstPage, 'true');
}
page.resetHistory();
}
}
props.onLoad?.(page, editor);
return;
}, [page, props]);
useEffect(() => {
const editor = editorRef.current;
const container = ref.current;
if (!editor || !container || !page) {
return;
}
editor.createBlockHub().then(blockHub => {
if (blockHubRef.current) {
blockHubRef.current.remove();
}
blockHubRef.current = blockHub;
const toolWrapper = document.querySelector('#toolWrapper');
assertExists(toolWrapper);
toolWrapper.appendChild(blockHub);
});
container.appendChild(editor);
return () => {
blockHubRef.current?.remove();
container.removeChild(editor);
};
}, [page]);
return <div className="editor-wrapper" ref={ref} />;
};

View File

@ -0,0 +1,36 @@
import React from 'react';
import { BlockSuiteWorkspace } from '../../../shared';
import PageList from './page-list';
export type BlockSuitePageListProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
onOpenPage: (pageId: string, newTab?: boolean) => void;
};
export const BlockSuitePageList: React.FC<BlockSuitePageListProps> = ({
blockSuiteWorkspace,
onOpenPage,
}) => {
return (
<PageList
blockSuiteWorkspace={blockSuiteWorkspace}
onClickPage={onOpenPage}
listType="all"
/>
);
};
export const BlockSuitePublicPageList: React.FC<BlockSuitePageListProps> = ({
blockSuiteWorkspace,
onOpenPage,
}) => {
return (
<PageList
isPublic={true}
blockSuiteWorkspace={blockSuiteWorkspace}
onClickPage={onOpenPage}
listType="all"
/>
);
};

View File

@ -1,10 +1,9 @@
import { TableCell, TableCellProps } from '@affine/component';
import { PageMeta } from '@blocksuite/store';
import dayjs from 'dayjs';
import localizedFormat from 'dayjs/plugin/localizedFormat';
import React from 'react';
import { PageMeta } from '@/providers/app-state-provider';
dayjs.extend(localizedFormat);
export const DateCell = ({

View File

@ -0,0 +1,171 @@
import {
Confirm,
FlexWrapper,
IconButton,
Menu,
MenuItem,
Tooltip,
} from '@affine/component';
import { toast } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
DeletePermanentlyIcon,
DeleteTemporarilyIcon,
FavoritedIcon,
FavoriteIcon,
MoreVerticalIcon,
OpenInNewIcon,
ResetIcon,
} from '@blocksuite/icons';
import { PageMeta } from '@blocksuite/store';
import React, { useState } from 'react';
export type OperationCellProps = {
pageMeta: PageMeta;
onOpenPageInNewTab: (pageId: string) => void;
onToggleFavoritePage: (pageId: string) => void;
onToggleTrashPage: (pageId: string) => void;
};
export const OperationCell: React.FC<OperationCellProps> = ({
pageMeta,
onOpenPageInNewTab,
onToggleFavoritePage,
onToggleTrashPage,
}) => {
const { id, favorite } = pageMeta;
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const OperationMenu = (
<>
<MenuItem
onClick={() => {
onToggleFavoritePage(id);
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
icon={favorite ? <FavoritedIcon /> : <FavoriteIcon />}
>
{favorite ? t('Remove from favorites') : t('Add to Favorites')}
</MenuItem>
<MenuItem
onClick={() => {
onOpenPageInNewTab(id);
}}
icon={<OpenInNewIcon />}
>
{t('Open in new tab')}
</MenuItem>
<MenuItem
onClick={() => {
setOpen(true);
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Delete')}
</MenuItem>
</>
);
return (
<>
<FlexWrapper alignItems="center" justifyContent="center">
<Menu
content={OperationMenu}
placement="bottom-end"
disablePortal={true}
trigger="click"
>
<IconButton darker={true}>
<MoreVerticalIcon />
</IconButton>
</Menu>
</FlexWrapper>
<Confirm
open={open}
title={t('Delete page?')}
content={t('will be permanently deleted', {
title: pageMeta.title || 'Untitled',
})}
confirmText={t('Delete')}
confirmType="danger"
onConfirm={() => {
onToggleTrashPage(id);
toast(t('Deleted'));
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
onCancel={() => {
setOpen(false);
}}
/>
</>
);
};
export type TrashOperationCellProps = {
pageMeta: PageMeta;
onPermanentlyDeletePage: (pageId: string) => void;
onRestorePage: (pageId: string) => void;
onOpenPage: (pageId: string) => void;
};
export const TrashOperationCell: React.FC<TrashOperationCellProps> = ({
pageMeta,
onPermanentlyDeletePage,
onRestorePage,
onOpenPage,
}) => {
const { id, title } = pageMeta;
// const { openPage, getPageMeta } = usePageHelper();
// const { toggleDeletePage, permanentlyDeletePage } = usePageHelper();
// const confirm = useConfirm(store => store.confirm);
const { t } = useTranslation();
const [open, setOpen] = useState(false);
return (
<FlexWrapper>
<Tooltip content={t('Restore it')} placement="top-start">
<IconButton
darker={true}
style={{ marginRight: '12px' }}
onClick={() => {
onRestorePage(id);
toast(t('restored', { title: title || 'Untitled' }));
onOpenPage(id);
}}
>
<ResetIcon />
</IconButton>
</Tooltip>
<Tooltip content={t('Delete permanently')} placement="top-start">
<IconButton
darker={true}
onClick={() => {
setOpen(true);
}}
>
<DeletePermanentlyIcon />
</IconButton>
</Tooltip>
<Confirm
title={t('Delete permanently?')}
content={t("Once deleted, you can't undo this action.")}
confirmText={t('Delete')}
confirmType="danger"
open={open}
onConfirm={() => {
onPermanentlyDeletePage(id);
toast(t('Permanently deleted'));
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
onCancel={() => {
setOpen(false);
}}
/>
</FlexWrapper>
);
};

View File

@ -0,0 +1,216 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@affine/component';
import { Content, IconButton, toast, Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
EdgelessIcon,
FavoritedIcon,
FavoriteIcon,
PaperIcon,
} from '@blocksuite/icons';
import { PageMeta } from '@blocksuite/store';
import { useMediaQuery, useTheme as useMuiTheme } from '@mui/material';
import React, { useMemo } from 'react';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
import { useTheme } from '../../../../providers/ThemeProvider';
import { BlockSuiteWorkspace } from '../../../../shared';
import DateCell from './DateCell';
import Empty from './Empty';
import { OperationCell, TrashOperationCell } from './OperationCell';
import {
StyledTableContainer,
StyledTableRow,
StyledTitleLink,
StyledTitleWrapper,
} from './styles';
const FavoriteTag = ({
pageMeta: { favorite, id },
}: {
pageMeta: PageMeta;
}) => {
const { theme } = useTheme();
const { t } = useTranslation();
return (
<Tooltip
content={favorite ? t('Favorited') : t('Favorite')}
placement="top-start"
>
<IconButton
darker={true}
iconSize={[20, 20]}
onClick={e => {
e.stopPropagation();
// toggleFavoritePage(id);
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
style={{
color: favorite ? theme.colors.primaryColor : theme.colors.iconColor,
}}
className={favorite ? '' : 'favorite-button'}
>
{favorite ? (
<FavoritedIcon data-testid="favorited-icon" />
) : (
<FavoriteIcon />
)}
</IconButton>
</Tooltip>
);
};
type PageListProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
isPublic?: boolean;
listType?: 'all' | 'trash' | 'favorite';
onClickPage: (pageId: string, newTab?: boolean) => void;
};
const filter = {
all: (pageMeta: PageMeta) => !pageMeta.trash,
trash: (pageMeta: PageMeta) => pageMeta.trash,
favorite: (pageMeta: PageMeta) => pageMeta.favorite,
};
export const PageList: React.FC<PageListProps> = ({
blockSuiteWorkspace,
isPublic = false,
listType,
onClickPage,
}) => {
const pageList = usePageMeta(blockSuiteWorkspace);
const helper = usePageMetaHelper(blockSuiteWorkspace);
const { t } = useTranslation();
const theme = useMuiTheme();
const matches = useMediaQuery(theme.breakpoints.up('sm'));
const isTrash = listType === 'trash';
const list = useMemo(
() => pageList.filter(filter[listType ?? 'all']),
[pageList, listType]
);
if (list.length === 0) {
return <Empty listType={listType} />;
}
return (
<StyledTableContainer>
<Table>
<TableHead>
<TableRow>
{matches && (
<>
<TableCell proportion={0.5}>{t('Title')}</TableCell>
<TableCell proportion={0.2}>{t('Created')}</TableCell>
<TableCell proportion={0.2}>
{isTrash ? t('Moved to Trash') : t('Updated')}
</TableCell>
<TableCell proportion={0.1}></TableCell>
</>
)}
</TableRow>
</TableHead>
<TableBody>
{list.map((pageMeta, index) => {
return (
<StyledTableRow
data-testid={`page-list-item-${pageMeta.id}}`}
key={`${pageMeta.id}-${index}`}
>
<TableCell
onClick={() => {
onClickPage(pageMeta.id);
}}
>
<StyledTitleWrapper>
<StyledTitleLink>
{pageMeta.mode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PaperIcon />
)}
<Content ellipsis={true} color="inherit">
{pageMeta.title || t('Untitled')}
</Content>
</StyledTitleLink>
{!isTrash && <FavoriteTag pageMeta={pageMeta} />}
</StyledTitleWrapper>
</TableCell>
{matches && (
<>
<DateCell
pageMeta={pageMeta}
dateKey="createDate"
onClick={() => {
onClickPage(pageMeta.id);
}}
/>
<DateCell
pageMeta={pageMeta}
dateKey={isTrash ? 'trashDate' : 'updatedDate'}
backupKey={isTrash ? 'trashDate' : 'createDate'}
onClick={() => {
onClickPage(pageMeta.id);
}}
/>
{!isPublic && (
<TableCell
style={{ padding: 0 }}
data-testid={`more-actions-${pageMeta.id}`}
>
{isTrash ? (
<TrashOperationCell
pageMeta={pageMeta}
onPermanentlyDeletePage={pageId => {
blockSuiteWorkspace.removePage(pageId);
}}
onRestorePage={() => {
helper.setPageMeta(pageMeta.id, {
trash: false,
});
}}
onOpenPage={pageId => {
onClickPage(pageId, false);
}}
/>
) : (
<OperationCell
pageMeta={pageMeta}
onOpenPageInNewTab={pageId => {
onClickPage(pageId, true);
}}
onToggleFavoritePage={(pageId: string) => {
helper.setPageMeta(pageId, {
favorite: !pageMeta.favorite,
});
}}
onToggleTrashPage={() => {
helper.setPageMeta(pageMeta.id, {
trash: !pageMeta.trash,
});
}}
/>
)}
</TableCell>
)}
</>
)}
</StyledTableRow>
);
})}
</TableBody>
</Table>
</StyledTableContainer>
);
};
export default PageList;

View File

@ -1,10 +1,14 @@
import { useTranslation } from '@affine/i18n';
import React, { cloneElement, useEffect, useState } from 'react';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useTheme } from '@/providers/ThemeProvider';
import { assertExists } from '@blocksuite/store';
import React, { cloneElement, CSSProperties, useEffect, useState } from 'react';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
// todo(himself65): remove `useTheme` hook
import { useTheme } from '../../../../providers/ThemeProvider';
import { BlockSuiteWorkspace } from '../../../../shared';
import { EdgelessIcon, PaperIcon } from './Icons';
import {
StyledAnimateRadioContainer,
@ -13,11 +17,7 @@ import {
StyledMiddleLine,
StyledRadioItem,
} from './style';
import type {
AnimateRadioItemProps,
AnimateRadioProps,
RadioItemStatus,
} from './type';
import type { AnimateRadioItemProps, RadioItemStatus } from './type';
const PaperItem = ({ active }: { active?: boolean }) => {
const {
theme: {
@ -64,13 +64,27 @@ const AnimateRadioItem = ({
);
};
export const EditorModeSwitch = ({
export type EditorModeSwitchProps = {
// todo(himself65): combine these two properties
blockSuiteWorkspace: BlockSuiteWorkspace;
pageId: string;
isHover: boolean;
style: CSSProperties;
};
export const EditorModeSwitch: React.FC<EditorModeSwitchProps> = ({
isHover,
style = {},
}: AnimateRadioProps) => {
blockSuiteWorkspace,
pageId,
}) => {
const { mode: themeMode } = useTheme();
const { changePageMode } = usePageHelper();
const { trash, mode = 'page', id = '' } = useCurrentPageMeta() || {};
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const { trash, mode = 'page' } = pageMeta;
const modifyRadioItemStatus = (): RadioItemStatus => {
return {
@ -113,7 +127,7 @@ export const EditorModeSwitch = ({
active={mode === 'page'}
status={radioItemStatus.left}
onClick={() => {
changePageMode(id, 'page');
setPageMeta(pageId, { mode: 'page' });
}}
onMouseEnter={() => {
setRadioItemStatus({
@ -134,7 +148,7 @@ export const EditorModeSwitch = ({
active={mode === 'edgeless'}
status={radioItemStatus.right}
onClick={() => {
changePageMode(id, 'edgeless');
setPageMeta(pageId, { mode: 'edgeless' });
}}
onMouseEnter={() => {
setRadioItemStatus({

View File

@ -1,4 +1,4 @@
import { CSSProperties, DOMAttributes, ReactElement } from 'react';
import { DOMAttributes, ReactElement } from 'react';
export type ItemStatus = 'normal' | 'stretch' | 'shrink' | 'hidden';
@ -6,10 +6,6 @@ export type RadioItemStatus = {
left: ItemStatus;
right: ItemStatus;
};
export type AnimateRadioProps = {
isHover: boolean;
style: CSSProperties;
};
export type AnimateRadioItemProps = {
active: boolean;
status: ItemStatus;

View File

@ -1,42 +1,49 @@
// fixme(himself65): refactor this file
import { Menu, MenuItem } from '@affine/component';
import { IconButton } from '@affine/component';
import { toast } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
DeleteTemporarilyIcon,
EdgelessIcon,
ExportIcon,
ExportToHtmlIcon,
ExportToMarkdownIcon,
FavoritedIcon,
FavoriteIcon,
MoreVerticalIcon,
PaperIcon,
} from '@blocksuite/icons';
import { assertExists } from '@blocksuite/store';
import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
import { EdgelessIcon, PaperIcon } from '../editor-mode-switch/Icons';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useConfirm } from '@/providers/ConfirmProvider';
import { useGlobalState } from '@/store/app';
const PopoverContent = () => {
const editor = useGlobalState(store => store.editor);
const { toggleFavoritePage, toggleDeletePage } = usePageHelper();
const { changePageMode } = usePageHelper();
const confirm = useConfirm(store => store.confirm);
const { t } = useTranslation();
const {
mode = 'page',
id = '',
favorite = false,
title = '',
} = useCurrentPageMeta() || {};
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
const [pageId] = useCurrentPageId();
assertExists(workspace);
assertExists(pageId);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const { mode = 'page', favorite, trash } = pageMeta;
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
//
return (
<>
<MenuItem
data-testid="editor-option-menu-favorite"
onClick={() => {
toggleFavoritePage(id);
setPageMeta(pageId, { favorite: !favorite });
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
@ -49,7 +56,9 @@ const PopoverContent = () => {
icon={mode === 'page' ? <EdgelessIcon /> : <PaperIcon />}
data-testid="editor-option-menu-edgeless"
onClick={() => {
changePageMode(id, mode === 'page' ? 'edgeless' : 'page');
setPageMeta(pageId, {
mode: mode === 'page' ? 'edgeless' : 'page',
});
}}
>
{t('Convert to ')}
@ -61,7 +70,8 @@ const PopoverContent = () => {
<>
<MenuItem
onClick={() => {
editor && editor.contentParser.onExportHtml();
// @ts-expect-error
globalThis.editor.contentParser.onExportHtml();
}}
icon={<ExportToHtmlIcon />}
>
@ -69,7 +79,8 @@ const PopoverContent = () => {
</MenuItem>
<MenuItem
onClick={() => {
editor && editor.contentParser.onExportMarkdown();
// @ts-expect-error
globalThis.editor.contentParser.onExportMarkdown();
}}
icon={<ExportToMarkdownIcon />}
>
@ -85,17 +96,9 @@ const PopoverContent = () => {
<MenuItem
data-testid="editor-option-menu-delete"
onClick={() => {
confirm({
title: t('Delete page?'),
content: t('will be moved to Trash', {
title: title || 'Untitled',
}),
confirmText: t('Delete'),
confirmType: 'danger',
}).then(confirm => {
confirm && toggleDeletePage(id);
confirm && toast(t('Moved to Trash'));
});
// fixme(himself65): regression that don't have conform dialog
setPageMeta(pageId, { trash: !trash });
toast(t('Moved to Trash'));
}}
icon={<DeleteTemporarilyIcon />}
>
@ -108,11 +111,9 @@ const PopoverContent = () => {
export const EditorOptionMenu = () => {
return (
<Menu content={<PopoverContent />} placement="bottom-end" trigger="click">
<IconButton>
<IconButton data-testid="editor-option-menu">
<MoreVerticalIcon />
</IconButton>
</Menu>
);
};
export default EditorOptionMenu;

View File

@ -1,11 +1,20 @@
import { displayFlex, IconButton, styled, Tooltip } from '@affine/component';
import { WorkspaceUnit } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import { CloudWorkspaceIcon, LocalWorkspaceIcon } from '@blocksuite/icons';
import { useCallback, useEffect, useState } from 'react';
import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { assertEquals, assertExists } from '@blocksuite/store';
import { useRouter } from 'next/router';
import React, { useEffect, useState } from 'react';
import { useGlobalState } from '@/store/app';
import { useModal } from '@/store/globalModal';
import { lockMutex } from '../../../../atoms';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { transformWorkspace } from '../../../../plugins';
import {
AffineOfficialWorkspace,
LocalWorkspace,
RemWorkspaceFlavour,
} from '../../../../shared';
import { TransformWorkspaceToAffineModal } from '../../../affine/transform-workspace-to-affine-modal';
import { LocalWorkspaceIcon } from '../../../pure/icons';
const NoNetWorkIcon = () => {
return (
@ -34,28 +43,34 @@ const IconWrapper = styled.div(() => {
};
});
const getStatus = (workspace: WorkspaceUnit | null) => {
const getStatus = (workspace: AffineOfficialWorkspace) => {
if (!navigator.onLine) {
return 'offline';
}
if (workspace?.provider === 'local') {
if (workspace.flavour === 'local') {
return 'local';
}
return 'cloud';
};
export const SyncUser = () => {
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const { triggerEnableWorkspaceModal } = useModal();
//#region fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
assertExists(workspace);
const router = useRouter();
const [status, setStatus] = useState<'offline' | 'local' | 'cloud'>(
getStatus(currentWorkspace)
getStatus(workspace)
);
const [prevWorkspace, setPrevWorkspace] = useState(workspace);
if (prevWorkspace !== workspace) {
setPrevWorkspace(workspace);
setStatus(getStatus(workspace));
}
useEffect(() => {
const online = () => {
setStatus(getStatus(currentWorkspace));
setStatus(getStatus(workspace));
};
const offline = () => {
@ -67,7 +82,10 @@ export const SyncUser = () => {
window.removeEventListener('online', online);
window.removeEventListener('offline', offline);
};
}, [currentWorkspace]);
}, [workspace]);
//#endregion
const [open, setOpen] = useState(false);
const { t } = useTranslation();
@ -86,19 +104,50 @@ export const SyncUser = () => {
if (status === 'local') {
return (
<Tooltip
content={t('Saved then enable AFFiNE Cloud')}
placement="bottom-end"
>
<IconButton
onClick={() => {
triggerEnableWorkspaceModal();
}}
style={{ marginRight: '12px' }}
<>
<Tooltip
content={t('Saved then enable AFFiNE Cloud')}
placement="bottom-end"
>
<LocalWorkspaceIcon />
</IconButton>
</Tooltip>
<IconButton
onClick={() => {
setOpen(true);
}}
style={{ marginRight: '12px' }}
>
<LocalWorkspaceIcon />
</IconButton>
</Tooltip>
<TransformWorkspaceToAffineModal
open={open}
onClose={() => {
setOpen(false);
}}
onConform={() => {
// todo(himself65): move this function out of affine component
lockMutex(async () => {
assertEquals(workspace.flavour, RemWorkspaceFlavour.LOCAL);
const id = await transformWorkspace(
RemWorkspaceFlavour.LOCAL,
RemWorkspaceFlavour.AFFINE,
workspace as LocalWorkspace
);
// fixme(himself65): refactor this
router
.replace({
pathname: `/workspace/[workspaceId]/all`,
query: {
workspaceId: id,
},
})
.then(() => {
router.reload();
});
setOpen(false);
});
}}
/>
</>
);
}

View File

@ -0,0 +1,83 @@
import { Button, Confirm } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { assertExists } from '@blocksuite/store';
import { useRouter } from 'next/router';
import { useState } from 'react';
import { useCurrentPageId } from '../../../../hooks/current/use-current-page-id';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import {
usePageMeta,
usePageMetaHelper,
} from '../../../../hooks/use-page-meta';
export const TrashButtonGroup = () => {
// fixme(himself65): remove these hooks ASAP
const [workspace] = useCurrentWorkspace();
const [pageId] = useCurrentPageId();
assertExists(workspace);
assertExists(pageId);
const blockSuiteWorkspace = workspace.blockSuiteWorkspace;
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const { setPageMeta } = usePageMetaHelper(blockSuiteWorkspace);
const router = useRouter();
//
const [open, setOpen] = useState(false);
const { t } = useTranslation();
return (
<>
<Button
bold={true}
shape="round"
style={{ marginRight: '24px' }}
onClick={() => {
setPageMeta(pageId, { trash: false });
}}
>
{t('Restore it')}
</Button>
<Button
bold={true}
shape="round"
type="danger"
onClick={() => {
setOpen(true);
}}
>
{t('Delete permanently')}
</Button>
<Confirm
title={t('TrashButtonGroupTitle')}
content={t('TrashButtonGroupDescription')}
confirmText={t('Delete')}
confirmType="danger"
open={open}
onConfirm={() => {
// fixme(himself65): remove these hooks ASAP
router
.push({
pathname: '/workspace/[workspaceId]/all',
query: {
workspaceId: workspace.id,
},
})
.then(() => {
blockSuiteWorkspace.removePage(pageId);
});
}}
onCancel={() => {
setOpen(false);
}}
onClose={() => {
setOpen(false);
}}
/>
</>
);
};
export default TrashButtonGroup;

View File

@ -1,7 +1,6 @@
import { useState } from 'react';
import { useTheme } from '@/providers/ThemeProvider';
import { useTheme } from '../../../../../providers/ThemeProvider';
import { MoonIcon, SunIcon } from './Icons';
import { StyledSwitchItem, StyledThemeModeSwitch } from './style';
export const ThemeModeSwitch = () => {

View File

@ -1,7 +1,7 @@
import { CloseIcon } from '@blocksuite/icons';
import React, { PropsWithChildren, ReactNode, useState } from 'react';
import React, { PropsWithChildren, useEffect, useMemo, useState } from 'react';
import EditorOptionMenu from './header-right-items/EditorOptionMenu';
import { EditorOptionMenu } from './header-right-items/EditorOptionMenu';
import SyncUser from './header-right-items/SyncUser';
import ThemeModeSwitch from './header-right-items/theme-mode-switch';
import TrashButtonGroup from './header-right-items/TrashButtonGroup';
@ -12,7 +12,7 @@ import {
StyledHeaderContainer,
StyledHeaderRightSide,
} from './styles';
import { shouldShowWarning, useWarningMessage } from './utils';
import { OSWarningMessage, shouldShowWarning } from './utils';
const BrowserWarning = ({
show,
@ -23,7 +23,7 @@ const BrowserWarning = ({
}) => {
return (
<StyledBrowserWarning show={show}>
{useWarningMessage()}
<OSWarningMessage />
<StyledCloseButton onClick={onClose}>
<CloseIcon />
</StyledCloseButton>
@ -37,18 +37,25 @@ type HeaderRightItemNames =
| 'themeModeSwitch'
| 'syncUser';
const HeaderRightItems: Record<HeaderRightItemNames, ReactNode> = {
editorOptionMenu: <EditorOptionMenu key="editorOptionMenu" />,
trashButtonGroup: <TrashButtonGroup key="trashButtonGroup" />,
themeModeSwitch: <ThemeModeSwitch key="themeModeSwitch" />,
syncUser: <SyncUser key="syncUser" />,
const HeaderRightItems: Record<HeaderRightItemNames, React.FC> = {
editorOptionMenu: EditorOptionMenu,
trashButtonGroup: TrashButtonGroup,
themeModeSwitch: ThemeModeSwitch,
syncUser: SyncUser,
};
export const Header = ({
export type HeaderProps = PropsWithChildren<{
rightItems?: HeaderRightItemNames[];
}>;
export const Header: React.FC<HeaderProps> = ({
rightItems = ['syncUser', 'themeModeSwitch'],
children,
}: PropsWithChildren<{ rightItems?: HeaderRightItemNames[] }>) => {
const [showWarning, setShowWarning] = useState(shouldShowWarning());
}) => {
const [showWarning, setShowWarning] = useState(false);
useEffect(() => {
setShowWarning(shouldShowWarning());
}, []);
return (
<StyledHeaderContainer hasWarning={showWarning}>
<BrowserWarning
@ -64,9 +71,14 @@ export const Header = ({
>
{children}
<StyledHeaderRightSide>
{rightItems.map(itemName => {
return HeaderRightItems[itemName];
})}
{useMemo(
() =>
rightItems.map(itemName => {
const Item = HeaderRightItems[itemName];
return <Item key={itemName} />;
}),
[rightItems]
)}
</StyledHeaderRightSide>
</StyledHeader>
</StyledHeaderContainer>

View File

@ -0,0 +1,90 @@
import { Content } from '@affine/component';
import { assertExists } from '@blocksuite/store';
import { useSetAtom } from 'jotai';
import React, { useState } from 'react';
import { openQuickSearchModalAtom } from '../../../atoms';
import { usePageMeta } from '../../../hooks/use-page-meta';
import { BlockSuiteWorkspace } from '../../../shared';
import { PageNotFoundError } from '../../affine/affine-error-eoundary';
import { EditorModeSwitch } from './editor-mode-switch';
import Header from './header';
import { QuickSearchButton } from './quick-search-button';
import {
StyledSearchArrowWrapper,
StyledSwitchWrapper,
StyledTitle,
StyledTitleWrapper,
} from './styles';
export type BlockSuiteEditorHeaderProps = React.PropsWithChildren<{
blockSuiteWorkspace: BlockSuiteWorkspace;
pageId: string;
}>;
export const BlockSuiteEditorHeader: React.FC<BlockSuiteEditorHeaderProps> = ({
blockSuiteWorkspace,
pageId,
children,
}) => {
const page = blockSuiteWorkspace.getPage(pageId);
// fixme(himself65): remove this atom and move it to props
const setOpenQuickSearch = useSetAtom(openQuickSearchModalAtom);
if (!page) {
throw new PageNotFoundError(blockSuiteWorkspace, pageId);
}
const pageMeta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(pageMeta);
const title = pageMeta.title;
const [isHover, setIsHover] = useState(false);
const { trash: isTrash } = pageMeta;
return (
<Header
rightItems={
isTrash
? ['trashButtonGroup']
: ['syncUser', 'themeModeSwitch', 'editorOptionMenu']
}
>
{children}
{title && (
<StyledTitle
data-tauri-drag-region
onMouseEnter={() => {
if (isTrash) return;
setIsHover(true);
}}
onMouseLeave={() => {
if (isTrash) return;
setIsHover(false);
}}
>
<StyledTitleWrapper>
<StyledSwitchWrapper>
<EditorModeSwitch
blockSuiteWorkspace={blockSuiteWorkspace}
pageId={pageId}
isHover={isHover}
style={{
marginRight: '12px',
}}
/>
</StyledSwitchWrapper>
<Content ellipsis={true}>{title}</Content>
<StyledSearchArrowWrapper>
<QuickSearchButton
onClick={() => {
setOpenQuickSearch(true);
}}
/>
</StyledSearchArrowWrapper>
</StyledTitleWrapper>
</StyledTitle>
)}
</Header>
);
};

View File

@ -3,8 +3,6 @@ import { styled } from '@affine/component';
import { ArrowDownSmallIcon } from '@blocksuite/icons';
import React from 'react';
import { useModal } from '@/store/globalModal';
const StyledIconButtonWithAnimate = styled(IconButton)(({ theme }) => {
return {
svg: {
@ -20,23 +18,21 @@ const StyledIconButtonWithAnimate = styled(IconButton)(({ theme }) => {
},
};
});
// fixme(himself65): need to refactor
export const QuickSearchButton = ({
onClick,
...props
}: Omit<IconButtonProps, 'children'>) => {
const { triggerQuickSearchModal } = useModal();
return (
<StyledIconButtonWithAnimate
data-testid="header-quickSearchButton"
{...props}
onClick={e => {
onClick?.(e);
triggerQuickSearchModal();
}}
>
<ArrowDownSmallIcon />
</StyledIconButtonWithAnimate>
);
};
export default QuickSearchButton;

View File

@ -8,12 +8,12 @@ export const StyledHeaderContainer = styled.div<{ hasWarning: boolean }>(
};
}
);
export const StyledHeader = styled.div<{ hasWarning: boolean }>(() => {
export const StyledHeader = styled.div<{ hasWarning: boolean }>(({ theme }) => {
return {
height: '60px',
width: '100%',
...displayFlex('flex-end', 'center'),
background: 'var(--affine-page-background)',
background: theme.colors.pageBackground,
transition: 'background-color 0.5s',
zIndex: 99,
};

View File

@ -1,6 +1,8 @@
import { Trans, useTranslation } from '@affine/i18n';
import React, { useEffect, useState } from 'react';
import { getIsMobile } from '../../../utils/get-is-mobile';
import getIsMobile from '@/utils/get-is-mobile';
// Inspire by https://stackoverflow.com/a/4900484/8415727
const getChromeVersion = () => {
const raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./);
@ -21,9 +23,15 @@ export const shouldShowWarning = () => {
);
};
export const useWarningMessage = () => {
export const OSWarningMessage: React.FC = () => {
const { t } = useTranslation();
if (!getIsChrome()) {
const [notChrome, setNotChrome] = useState(false);
const [notGoodVersion, setNotGoodVersion] = useState(false);
useEffect(() => {
setNotChrome(getIsChrome());
setNotGoodVersion(getChromeVersion() < minimumChromeVersion);
}, []);
if (notChrome) {
return (
<span>
<Trans i18nKey="recommendBrowser">
@ -32,9 +40,8 @@ export const useWarningMessage = () => {
</Trans>
</span>
);
}
if (getChromeVersion() < minimumChromeVersion) {
} else if (notGoodVersion) {
return <span>{t('upgradeBrowser')}</span>;
}
return '';
return null;
};

View File

@ -1,95 +0,0 @@
import { styled } from '@affine/component';
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { Button } from '@affine/component';
import { Input } from '@affine/component';
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
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

@ -1,151 +0,0 @@
export const SelectIcon = () => {
return (
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_3076_4847)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M11.6091 10.2222C11.61 8.86837 13.1871 8.12745 14.2297 8.99105L25.8121 18.5849C27.0294 19.5932 26.2062 21.4939 24.7271 21.4744C23.7186 21.4611 22.5692 21.4841 21.496 21.5947C20.4058 21.707 19.4662 21.9034 18.8355 22.1997C18.2047 22.4961 17.4537 23.0939 16.6713 23.8612C15.9009 24.6167 15.1495 25.4868 14.5159 26.2714C13.5867 27.4223 11.5982 26.8425 11.5992 25.2619L11.6091 10.2222ZM13.2091 10.2232L13.1992 25.263C13.1992 25.2637 13.1992 25.2644 13.1992 25.2651C13.2029 25.2676 13.2091 25.2712 13.2183 25.2743C13.237 25.2806 13.2518 25.2793 13.2576 25.2779C13.2597 25.2773 13.2604 25.2769 13.2607 25.2767L13.2607 25.2767C13.2608 25.2766 13.2645 25.2744 13.2711 25.2663C13.9256 24.4557 14.72 23.5339 15.551 22.7189C16.3698 21.9159 17.2757 21.1647 18.1551 20.7516C19.0346 20.3384 20.1911 20.1207 21.3319 20.0031C22.4897 19.8838 23.7064 19.8608 24.7481 19.8745C24.7586 19.8747 24.7627 19.8732 24.7628 19.8732L24.7628 19.8732C24.7632 19.8731 24.7639 19.8728 24.7657 19.8715C24.7705 19.868 24.7809 19.8575 24.788 19.839C24.7915 19.8299 24.7927 19.8229 24.7931 19.8185C24.7926 19.818 24.7921 19.8176 24.7915 19.8171L13.2091 10.2232Z"
/>
</g>
<defs>
<clipPath id="clip0_3076_4847">
<rect
width="24"
height="24"
fill="white"
transform="translate(6 6)"
/>
</clipPath>
</defs>
</svg>
);
};
export const TextIcon = () => {
return (
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M27 9H9V11L17 11L17 27H19L19 11L27 11V9Z"
/>
</svg>
);
};
export const ShapeIcon = () => {
return (
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M25.2284 12.9441L17.822 10.9596L15.8375 18.3659L17.451 18.7982L16.7206 20.259L13.8779 19.4973L16.6907 9L27.188 11.8127L24.3752 22.31L23.1393 21.9789L22.183 20.0662L23.2438 20.3504L25.2284 12.9441ZM14.981 13.0626C14.8225 13.0489 14.6621 13.0419 14.5 13.0419C11.4624 13.0419 9 15.5043 9 18.5419C9 21.5794 11.4624 24.0419 14.5 24.0419C14.6122 24.0419 14.7236 24.0385 14.8341 24.0319L15.7277 22.2447C15.3417 22.3726 14.9289 22.4419 14.5 22.4419C12.3461 22.4419 10.6 20.6958 10.6 18.5419C10.6 16.388 12.3461 14.6419 14.5 14.6419C14.5193 14.6419 14.5385 14.642 14.5578 14.6423L14.981 13.0626ZM19.5 16.0419L14 27.0419H25L19.5 16.0419ZM19.5 19.6196L16.5889 25.4419H22.4111L19.5 19.6196Z"
/>
</svg>
);
};
export const PenIcon = () => {
return (
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12.0153 23.0152L12.3988 21.8646L23.1317 11.1317C23.6114 10.6519 24.3892 10.6519 24.869 11.1317C25.3487 11.6114 25.3487 12.3892 24.869 12.8689L14.136 23.6019L12.9854 23.9854L12.0153 23.0152ZM11.1339 20.8668L22.0003 10.0003C23.1049 8.89573 24.8958 8.89573 26.0003 10.0003C27.1049 11.1049 27.1049 12.8957 26.0003 14.0003L15.1339 24.8668C15.0461 24.9546 14.939 25.0207 14.8212 25.06L10.5182 26.4943C9.89283 26.7028 9.29784 26.1078 9.5063 25.4824L10.9406 21.1795C10.9799 21.0616 11.0461 20.9546 11.1339 20.8668Z"
/>
</svg>
);
};
export const StickerIcon = () => {
return (
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M23.75 9C24.612 9 25.4386 9.34241 26.0481 9.9519C26.6576 10.5614 27 11.388 27 12.25V19.129C26.9997 19.7254 26.7627 20.2973 26.341 20.719L20.72 26.341C20.5111 26.5499 20.263 26.7157 19.99 26.8287C19.7171 26.9418 19.4245 27 19.129 27H12.25C11.388 27 10.5614 26.6576 9.9519 26.0481C9.34241 25.4386 9 24.612 9 23.75V12.25C9 11.388 9.34241 10.5614 9.9519 9.9519C10.5614 9.34241 11.388 9 12.25 9H23.75ZM23.75 10.5H12.25C11.7859 10.5 11.3408 10.6844 11.0126 11.0126C10.6844 11.3408 10.5 11.7859 10.5 12.25V23.75C10.5 24.716 11.284 25.5 12.25 25.5H19V22.25C18.9999 21.4199 19.3176 20.6212 19.8877 20.0178C20.4578 19.4144 21.2372 19.052 22.066 19.005L22.25 19H25.5V12.25C25.5 11.7859 25.3156 11.3408 24.9874 11.0126C24.6592 10.6844 24.2141 10.5 23.75 10.5ZM24.439 20.5H22.25C21.8107 20.5 21.3874 20.6653 21.0643 20.963C20.7412 21.2608 20.5419 21.6691 20.506 22.107L20.5 22.25V24.439L24.439 20.5Z" />
</svg>
);
};
export const ConnectorIcon = () => {
return (
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.6 12.5C21.6 13.5493 22.4507 14.4 23.5 14.4C24.5493 14.4 25.4 13.5493 25.4 12.5C25.4 11.4507 24.5493 10.6 23.5 10.6C22.4507 10.6 21.6 11.4507 21.6 12.5ZM23.5 9C21.567 9 20 10.567 20 12.5C20 13.2108 20.2119 13.872 20.5759 14.4241L14.4241 20.5759C13.872 20.2119 13.2108 20 12.5 20C10.567 20 9 21.567 9 23.5C9 25.433 10.567 27 12.5 27C14.433 27 16 25.433 16 23.5C16 22.8575 15.8269 22.2555 15.5248 21.738L21.738 15.5248C22.2555 15.8269 22.8575 16 23.5 16C25.433 16 27 14.433 27 12.5C27 10.567 25.433 9 23.5 9ZM10.6 23.5C10.6 24.5493 11.4507 25.4 12.5 25.4C13.5493 25.4 14.4 24.5493 14.4 23.5C14.4 22.4507 13.5493 21.6 12.5 21.6C11.4507 21.6 10.6 22.4507 10.6 23.5Z"
/>
</svg>
);
};
export const UndoIcon = () => {
return (
<svg
width="36"
height="35"
viewBox="0 0 36 35"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.8277 12.2008C15.0955 12.4686 15.0955 12.9028 14.8277 13.1706L12.3412 15.6571H20.9551C21.6982 15.6571 22.2977 15.6571 22.7831 15.6968C23.2828 15.7376 23.7218 15.8239 24.128 16.0308C24.7731 16.3595 25.2976 16.884 25.6263 17.5292C25.8332 17.9353 25.9195 18.3743 25.9604 18.8741C26 19.3595 26 19.9589 26 20.7021V21.8286C26 22.2073 25.693 22.5143 25.3143 22.5143C24.9356 22.5143 24.6286 22.2073 24.6286 21.8286V20.7314C24.6286 19.952 24.628 19.4087 24.5935 18.9858C24.5596 18.5708 24.4964 18.3324 24.4044 18.1518C24.2071 17.7647 23.8924 17.45 23.5054 17.2528C23.3248 17.1608 23.0864 17.0976 22.6714 17.0637C22.2484 17.0291 21.7051 17.0286 20.9257 17.0286H12.3412L14.8277 19.5151C15.0955 19.7829 15.0955 20.2171 14.8277 20.4849C14.5599 20.7527 14.1258 20.7527 13.858 20.4849L10.2008 16.8277C9.93305 16.5599 9.93305 16.1258 10.2008 15.858L13.858 12.2008C14.1258 11.9331 14.5599 11.9331 14.8277 12.2008Z"
/>
</svg>
);
};
export const RedoIcon = () => {
return (
<svg
width="36"
height="36"
viewBox="0 0 36 36"
fill="currentColor"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M21.1723 12.715C21.4401 12.4472 21.8742 12.4472 22.142 12.715L25.7992 16.3721C26.0669 16.6399 26.0669 17.0741 25.7992 17.3419L22.142 20.999C21.8742 21.2668 21.4401 21.2668 21.1723 20.999C20.9045 20.7312 20.9045 20.2971 21.1723 20.0293L23.6588 17.5427H15.0743C14.2949 17.5427 13.7516 17.5433 13.3286 17.5778C12.9136 17.6117 12.6752 17.6749 12.4946 17.7669C12.1076 17.9642 11.7929 18.2789 11.5956 18.666C11.5036 18.8465 11.4404 19.085 11.4065 19.4999C11.372 19.9229 11.3714 20.4662 11.3714 21.2456V22.3427C11.3714 22.7214 11.0644 23.0284 10.6857 23.0284C10.307 23.0284 10 22.7214 10 22.3427L10 21.2162C9.99999 20.4731 9.99999 19.8736 10.0396 19.3882C10.0805 18.8885 10.1668 18.4495 10.3737 18.0433C10.7024 17.3982 11.2269 16.8737 11.872 16.545C12.2782 16.3381 12.7172 16.2518 13.2169 16.2109C13.7023 16.1713 14.3018 16.1713 15.0449 16.1713H23.6588L21.1723 13.6847C20.9045 13.417 20.9045 12.9828 21.1723 12.715Z"
/>
</svg>
);
};

View File

@ -1,164 +0,0 @@
import { MuiSlide } from '@affine/component';
import { Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { useEffect, useState } from 'react';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import useHistoryUpdated from '@/hooks/use-history-update';
import { useGlobalState } from '@/store/app';
import {
ConnectorIcon,
PenIcon,
RedoIcon,
SelectIcon,
ShapeIcon,
StickerIcon,
TextIcon,
UndoIcon,
} from './Icons';
import {
StyledEdgelessToolbar,
StyledToolbarItem,
StyledToolbarWrapper,
} from './style';
const useToolbarList1 = () => {
const { t } = useTranslation();
return [
{
flavor: 'select',
icon: <SelectIcon />,
toolTip: t('Select'),
disable: false,
callback: () => {
window.dispatchEvent(
new CustomEvent('affine.switch-mouse-mode', {
detail: {
type: 'default',
},
})
);
},
},
{
flavor: 'text',
icon: <TextIcon />,
toolTip: t('Text'),
disable: true,
},
{
flavor: 'shape',
icon: <ShapeIcon />,
toolTip: t('Shape'),
disable: false,
callback: () => {
window.dispatchEvent(
new CustomEvent('affine.switch-mouse-mode', {
detail: {
type: 'shape',
color: 'black',
shape: 'rectangle',
},
})
);
},
},
{
flavor: 'sticky',
icon: <StickerIcon />,
toolTip: t('Sticky'),
disable: true,
},
{
flavor: 'pen',
icon: <PenIcon />,
toolTip: t('Pen'),
disable: true,
},
{
flavor: 'connector',
icon: <ConnectorIcon />,
toolTip: t('Connector'),
disable: true,
},
];
};
const UndoRedo = () => {
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
const currentPage = useGlobalState(store => store.currentPage);
const onHistoryUpdated = useHistoryUpdated();
const { t } = useTranslation();
useEffect(() => {
onHistoryUpdated(page => {
setCanUndo(page.canUndo);
setCanRedo(page.canRedo);
});
}, [onHistoryUpdated]);
return (
<StyledToolbarWrapper>
<Tooltip content={t('Undo')} placement="right-start">
<StyledToolbarItem
disable={!canUndo}
onClick={() => {
currentPage?.undo();
}}
>
<UndoIcon />
</StyledToolbarItem>
</Tooltip>
<Tooltip content={t('Redo')} placement="right-start">
<StyledToolbarItem
disable={!canRedo}
onClick={() => {
currentPage?.redo();
}}
>
<RedoIcon />
</StyledToolbarItem>
</Tooltip>
</StyledToolbarWrapper>
);
};
export const EdgelessToolbar = () => {
const { mode } = useCurrentPageMeta() || {};
return (
<MuiSlide
direction="right"
in={mode === 'edgeless'}
mountOnEnter
unmountOnExit
>
<StyledEdgelessToolbar aria-label="edgeless-toolbar">
<StyledToolbarWrapper>
{useToolbarList1().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>
</MuiSlide>
);
};
export default EdgelessToolbar;

View File

@ -1,3 +0,0 @@
<svg width="36" height="35" viewBox="0 0 36 35" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M14.8277 12.2008C15.0955 12.4686 15.0955 12.9028 14.8277 13.1706L12.3412 15.6571H20.9551C21.6982 15.6571 22.2977 15.6571 22.7831 15.6968C23.2828 15.7376 23.7218 15.8239 24.128 16.0308C24.7731 16.3595 25.2976 16.884 25.6263 17.5292C25.8332 17.9353 25.9195 18.3743 25.9604 18.8741C26 19.3595 26 19.9589 26 20.7021V21.8286C26 22.2073 25.693 22.5143 25.3143 22.5143C24.9356 22.5143 24.6286 22.2073 24.6286 21.8286V20.7314C24.6286 19.952 24.628 19.4087 24.5935 18.9858C24.5596 18.5708 24.4964 18.3324 24.4044 18.1518C24.2071 17.7647 23.8924 17.45 23.5054 17.2528C23.3248 17.1608 23.0864 17.0976 22.6714 17.0637C22.2484 17.0291 21.7051 17.0286 20.9257 17.0286H12.3412L14.8277 19.5151C15.0955 19.7829 15.0955 20.2171 14.8277 20.4849C14.5599 20.7527 14.1258 20.7527 13.858 20.4849L10.2008 16.8277C9.93305 16.5599 9.93305 16.1258 10.2008 15.858L13.858 12.2008C14.1258 11.9331 14.5599 11.9331 14.8277 12.2008Z" fill="#9096A5"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -1,41 +0,0 @@
import { displayFlex, styled } from '@affine/component';
export const StyledEdgelessToolbar = styled.div(({ theme }) => ({
height: '320px',
position: 'absolute',
left: '12px',
top: 0,
bottom: 0,
margin: 'auto',
zIndex: theme.zIndex.modal - 1,
}));
export const StyledToolbarWrapper = styled.div(({ theme }) => ({
width: '44px',
borderRadius: '10px',
boxShadow: theme.shadow.modal,
padding: '4px',
background: theme.colors.popoverBackground,
transition: 'background .5s',
marginBottom: '12px',
}));
export const StyledToolbarItem = styled.div<{
disable?: boolean;
}>(({ theme, disable = false }) => ({
width: '36px',
height: '36px',
...displayFlex('center', 'center'),
color: disable ? theme.colors.disableColor : theme.colors.iconColor,
cursor: disable ? 'not-allowed' : 'pointer',
svg: {
width: '36px',
height: '36px',
},
':hover': disable
? {}
: {
color: theme.colors.primaryColor,
background: theme.colors.hoverBackground,
},
}));

View File

@ -1,72 +0,0 @@
import '@blocksuite/blocks';
import { styled } from '@affine/component';
import { EditorContainer } from '@blocksuite/editor';
import type { Page, Workspace } from '@blocksuite/store';
import { useEffect, useRef } from 'react';
const StyledEditorContainer = styled('div')(() => {
return {
position: 'relative',
height: 'calc(100% - 60px)',
padding: '0 32px',
};
});
type Props = {
page: Page;
workspace: Workspace;
setEditor: (editor: EditorContainer) => void;
templateMarkdown?: string;
templateTitle?: string;
};
export const Editor = ({
page,
workspace,
setEditor,
templateMarkdown,
templateTitle = '',
}: Props) => {
const editorContainer = useRef<HTMLDivElement>(null);
useEffect(() => {
const ret = () => {
const node = editorContainer.current;
while (node?.firstChild) {
node.removeChild(node.firstChild);
}
};
const editor = new EditorContainer();
editor.page = page;
editor.mode = page.meta.mode as typeof editor.mode;
editorContainer.current?.appendChild(editor);
if (page.isEmpty) {
// Can not use useCurrentPageMeta to get new title, cause meta title will trigger rerender, but the second time can not remove title
const { title: metaTitle } = page.meta;
const title = metaTitle ? metaTitle : templateTitle;
workspace?.setPageMeta(page.id, { title });
const pageBlockId = page.addBlockByFlavour('affine:page', { title });
page.addBlockByFlavour('affine:surface', {}, null);
// Add frame block inside page block
const frameId = page.addBlockByFlavour('affine:frame', {}, pageBlockId);
// Add paragraph block inside frame block
// If this is a first page in workspace, init an introduction markdown
if (templateMarkdown) {
editor.clipboard.importMarkdown(templateMarkdown, frameId);
workspace.setPageMeta(page.id, { title });
} else {
page.addBlockByFlavour('affine:paragraph', {}, frameId);
}
page.resetHistory();
}
setEditor(editor);
return ret;
}, [workspace, page, setEditor, templateTitle, templateMarkdown]);
return <StyledEditorContainer ref={editorContainer} />;
};
export default Editor;

View File

@ -1,80 +0,0 @@
import { Content } from '@affine/component';
import React, { useEffect, useState } from 'react';
import EditorModeSwitch from '@/components/editor-mode-switch';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import usePropsUpdated from '@/hooks/use-props-updated';
import { useGlobalState } from '@/store/app';
import Header from './Header';
import QuickSearchButton from './QuickSearchButton';
import {
StyledSearchArrowWrapper,
StyledSwitchWrapper,
StyledTitle,
StyledTitleWrapper,
} from './styles';
export const EditorHeader = () => {
const [title, setTitle] = useState('');
const [isHover, setIsHover] = useState(false);
const editor = useGlobalState(store => store.editor);
const { trash: isTrash = false } = useCurrentPageMeta() || {};
const onPropsUpdated = usePropsUpdated();
useEffect(() => {
onPropsUpdated(editor => {
setTitle(editor.pageBlockModel?.title || 'Untitled');
});
}, [onPropsUpdated]);
useEffect(() => {
setTimeout(() => {
// If first time in, need to wait for editor to be inserted into DOM
setTitle(editor?.pageBlockModel?.title || 'Untitled');
}, 300);
}, [editor]);
return (
<Header
rightItems={
isTrash
? ['trashButtonGroup']
: ['syncUser', 'themeModeSwitch', 'editorOptionMenu']
}
>
{title && (
<StyledTitle
data-tauri-drag-region
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

@ -1,23 +0,0 @@
import { PropsWithChildren, ReactNode } from 'react';
import Header from './Header';
import QuickSearchButton from './QuickSearchButton';
import { StyledPageListTittleWrapper } from './styles';
// import QuickSearchButton from './QuickSearchButton';
export type PageListHeaderProps = PropsWithChildren<{
icon?: ReactNode;
}>;
export const PageListHeader = ({ icon, children }: PageListHeaderProps) => {
return (
<Header>
<StyledPageListTittleWrapper>
{icon}
{children}
<QuickSearchButton />
</StyledPageListTittleWrapper>
</Header>
);
};
export default PageListHeader;

View File

@ -1,57 +0,0 @@
import { Button } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { useRouter } from 'next/router';
import { useCallback } from 'react';
import useCurrentPageMeta from '@/hooks/use-current-page-meta';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useConfirm } from '@/providers/ConfirmProvider';
import { useGlobalState } from '@/store/app';
export const TrashButtonGroup = () => {
const { permanentlyDeletePage } = usePageHelper();
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const { toggleDeletePage } = usePageHelper();
const confirm = useConfirm(store => store.confirm);
const router = useRouter();
const { id = '' } = useCurrentPageMeta() || {};
const { t } = useTranslation();
return (
<>
<Button
bold={true}
shape="round"
style={{ marginRight: '24px' }}
onClick={() => {
toggleDeletePage(id);
}}
>
{t('Restore it')}
</Button>
<Button
bold={true}
shape="round"
type="danger"
onClick={() => {
confirm({
title: t('TrashButtonGroupTitle'),
content: t('TrashButtonGroupDescription'),
confirmText: t('Delete'),
confirmType: 'danger',
}).then(confirm => {
if (confirm) {
router.push(`/workspace/${currentWorkspace?.id}/all`);
permanentlyDeletePage(id);
}
});
}}
>
{t('Delete permanently')}
</Button>
</>
);
};
export default TrashButtonGroup;

View File

@ -1,3 +0,0 @@
export * from './EditorHeader';
export * from './Header';
export * from './PageListHeader';

View File

@ -1,97 +0,0 @@
import { Tooltip } from '@affine/component';
import { MuiFade } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { CloseIcon } from '@blocksuite/icons';
import { useEffect, useState } from 'react';
import { useGlobalState } from '@/store/app';
import { useModal } from '@/store/globalModal';
import { ContactIcon, HelpIcon, KeyboardIcon } from './Icons';
import {
StyledAnimateWrapper,
StyledIconWrapper,
StyledIsland,
StyledTriggerWrapper,
} from './style';
export type IslandItemNames = 'contact' | 'shortcuts';
export const HelpIsland = ({
showList = ['contact', 'shortcuts'],
}: {
showList?: IslandItemNames[];
}) => {
const [spread, setShowSpread] = useState(false);
const { triggerShortcutsModal, triggerContactModal } = useModal();
const blockHub = useGlobalState(store => store.blockHub);
const { t } = useTranslation();
useEffect(() => {
blockHub?.blockHubStatusUpdated.on(status => {
if (status) {
setShowSpread(false);
}
});
return () => {
blockHub?.blockHubStatusUpdated.dispose();
};
}, [blockHub]);
useEffect(() => {
spread && blockHub?.toggleMenu(false);
}, [blockHub, spread]);
return (
<StyledIsland
spread={spread}
data-testid="help-island"
onClick={() => {
setShowSpread(!spread);
}}
>
<StyledAnimateWrapper
style={{ height: spread ? `${showList.length * 44}px` : 0 }}
>
{showList.includes('contact') && (
<Tooltip content={t('Contact Us')} placement="left-end">
<StyledIconWrapper
data-testid="right-bottom-contact-us-icon"
onClick={() => {
setShowSpread(false);
triggerContactModal();
}}
>
<ContactIcon />
</StyledIconWrapper>
</Tooltip>
)}
{showList.includes('shortcuts') && (
<Tooltip content={t('Keyboard Shortcuts')} placement="left-end">
<StyledIconWrapper
data-testid="shortcuts-icon"
onClick={() => {
setShowSpread(false);
triggerShortcutsModal();
}}
>
<KeyboardIcon />
</StyledIconWrapper>
</Tooltip>
)}
</StyledAnimateWrapper>
<Tooltip content={t('Help and Feedback')} placement="left-end">
<MuiFade in={!spread} data-testid="faq-icon">
<StyledTriggerWrapper>
<HelpIcon />
</StyledTriggerWrapper>
</MuiFade>
</Tooltip>
<MuiFade in={spread}>
<StyledTriggerWrapper>
<CloseIcon />
</StyledTriggerWrapper>
</MuiFade>
</StyledIsland>
);
};
export default HelpIsland;

View File

@ -1,140 +0,0 @@
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { Button } from '@affine/component';
import { Content, FlexWrapper } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { useCallback, useEffect, useState } from 'react';
import Loading from '@/components/loading';
import { usePageHelper } from '@/hooks/use-page-helper';
import { useGlobalState } from '@/store/app';
import { StyledButtonWrapper, StyledTitle } from './styles';
// import { Tooltip } from '@affine/component';
type ImportModalProps = {
open: boolean;
onClose: () => void;
};
type Template = {
name: string;
source: string;
};
export const ImportModal = ({ open, onClose }: ImportModalProps) => {
const [status, setStatus] = useState<'unImported' | 'importing'>('importing');
const { openPage, createPage } = usePageHelper();
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const { t } = useTranslation();
const _applyTemplate = function (pageId: string, template: Template) {
const page = currentWorkspace?.blocksuiteWorkspace?.getPage(pageId);
const title = template.name;
if (page) {
currentWorkspace?.blocksuiteWorkspace?.setPageMeta(page.id, { title });
if (page.root === null) {
setTimeout(() => {
try {
const editor = document.querySelector('editor-container');
if (editor) {
page.addBlock({ flavour: 'affine:surface' }, null);
const frameId = page.addBlock(
{ flavour: 'affine:frame' },
pageId
);
// TODO blocksuite should offer a method to import markdown from store
editor.clipboard.importMarkdown(template.source, `${frameId}`);
page.resetHistory();
editor.requestUpdate();
}
} catch (e) {
console.error(e);
}
}, 300);
}
}
};
const _handleAppleTemplate = async function (template: Template) {
const pageId = await createPage();
if (pageId) {
openPage(pageId);
_applyTemplate(pageId, template);
}
};
const _handleAppleTemplateFromFilePicker = async () => {
if (!window.showOpenFilePicker) {
return;
}
const arrFileHandle = await window.showOpenFilePicker({
types: [
{
accept: {
'text/markdown': ['.md'],
'text/html': ['.html', '.htm'],
'text/plain': ['.text'],
},
},
],
multiple: false,
});
for (const fileHandle of arrFileHandle) {
const file = await fileHandle.getFile();
const text = await file.text();
_handleAppleTemplate({
name: file.name,
source: text,
});
}
onClose && onClose();
};
useEffect(() => {
if (status === 'importing') {
setTimeout(() => {
setStatus('unImported');
}, 1500);
}
}, [status]);
return (
<Modal open={open} onClose={onClose}>
<ModalWrapper width={460} minHeight={240}>
<ModalCloseButton onClick={onClose} />
<StyledTitle>{t('Import')}</StyledTitle>
{status === 'unImported' && (
<StyledButtonWrapper>
<Button
onClick={() => {
_handleAppleTemplateFromFilePicker();
}}
>
Markdown
</Button>
<Button
onClick={() => {
_handleAppleTemplateFromFilePicker();
}}
>
HTML
</Button>
</StyledButtonWrapper>
)}
{status === 'importing' && (
<FlexWrapper
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>
</FlexWrapper>
)}
</ModalWrapper>
</Modal>
);
};
export default ImportModal;

View File

@ -1,25 +0,0 @@
import { styled } from '@affine/component';
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

@ -1,28 +0,0 @@
export const GoogleIcon = () => {
return (
<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>
);
};

View File

@ -1,77 +0,0 @@
import { positionAbsolute, styled } from '@affine/component';
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { Button } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { useGlobalState } from '@/store/app';
import { GoogleIcon } from './GoogleIcon';
interface LoginModalProps {
open: boolean;
onClose: () => void;
}
export const LoginModal = ({ open, onClose }: LoginModalProps) => {
const login = useGlobalState(store => store.login);
const { t } = useTranslation();
return (
<Modal open={open} onClose={onClose} data-testid="login-modal">
<ModalWrapper width={560} height={292} style={{ paddingTop: '44px' }}>
<ModalCloseButton
onClick={() => {
onClose();
}}
/>
<Content>
<ContentTitle>{t('Sign in')}</ContentTitle>
<SignDes>{t('Set up an AFFiNE account to sync data')}</SignDes>
<StyledLoginButton
shape="round"
onClick={async () => {
await login();
onClose();
}}
>
<GoogleIcon />
{t('Continue with Google')}
</StyledLoginButton>
</Content>
</ModalWrapper>
</Modal>
);
};
const StyledLoginButton = styled(Button)(() => {
return {
width: '284px',
marginTop: '30px',
position: 'relative',
svg: {
...positionAbsolute({ left: '18px', top: '0', bottom: '0' }),
margin: 'auto',
},
};
});
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 SignDes = styled('div')(({ theme }) => {
return {
fontWeight: 400,
color: theme.colors.textColor,
fontSize: '16px',
};
});

View File

@ -1,48 +0,0 @@
export const Check = () => {
return (
<span>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g clipPath="url(#clip0_9266_16831)">
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.83301 3.33331C4.4523 3.33331 3.33301 4.4526 3.33301 5.83331V14.1666C3.33301 15.5474 4.4523 16.6666 5.83301 16.6666H14.1663C15.5471 16.6666 16.6663 15.5474 16.6663 14.1666V5.83331C16.6663 4.4526 15.5471 3.33331 14.1663 3.33331H5.83301ZM14.6385 7.97059C14.8984 7.70982 14.8977 7.28771 14.6369 7.02778C14.3762 6.76785 13.9541 6.76852 13.6941 7.02929L8.62861 12.1111L6.30522 9.77929C6.04534 9.51847 5.62323 9.51771 5.36241 9.77759C5.10159 10.0375 5.10083 10.4596 5.36071 10.7204L8.03822 13.4076C8.36386 13.7344 8.89304 13.7344 9.21874 13.4077L14.6385 7.97059Z"
fill="#888A9E"
/>
</g>
<defs>
<clipPath id="clip0_9266_16831">
<rect width="20" height="20" fill="white" />
</clipPath>
</defs>
</svg>
</span>
);
};
export const UnCheck = () => {
return (
<span>
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M14.1673 4.66665H5.83398C5.18965 4.66665 4.66732 5.18898 4.66732 5.83331V14.1666C4.66732 14.811 5.18965 15.3333 5.83398 15.3333H14.1673C14.8116 15.3333 15.334 14.811 15.334 14.1666V5.83331C15.334 5.18898 14.8116 4.66665 14.1673 4.66665ZM5.83398 3.33331C4.45327 3.33331 3.33398 4.4526 3.33398 5.83331V14.1666C3.33398 15.5474 4.45327 16.6666 5.83398 16.6666H14.1673C15.548 16.6666 16.6673 15.5474 16.6673 14.1666V5.83331C16.6673 4.4526 15.548 3.33331 14.1673 3.33331H5.83398Z"
fill="#888A9E"
/>
</svg>
</span>
);
};

View File

@ -1,150 +0,0 @@
import { styled } from '@affine/component';
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { Button } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { useState } from 'react';
import { useAppState } from '@/providers/app-state-provider';
import { Check, UnCheck } from './icon';
interface LoginModalProps {
open: boolean;
onClose: (wait: boolean) => void;
}
export const LogoutModal = ({ open, onClose }: LoginModalProps) => {
const [localCache, setLocalCache] = useState(true);
const { blobDataSynced } = useAppState();
const { t } = useTranslation();
return (
<Modal open={open} onClose={onClose} data-testid="logout-modal">
<ModalWrapper width={560} height={292}>
<Header>
<ModalCloseButton
onClick={() => {
onClose(true);
}}
/>
</Header>
<Content>
<ContentTitle>{t('Sign out')}?</ContentTitle>
<SignDes>
{blobDataSynced
? t('Sign out description')
: t('All data has been stored in the cloud')}
</SignDes>
<StyleTips>
{localCache ? (
<StyleCheck
onClick={() => {
setLocalCache(false);
}}
>
<Check></Check>
</StyleCheck>
) : (
<StyleCheck
onClick={() => {
setLocalCache(true);
}}
>
<UnCheck></UnCheck>
</StyleCheck>
)}
{t('Retain cached cloud data')}
</StyleTips>
{blobDataSynced ? (
<div>
<Button
type="danger"
shape="round"
style={{ marginRight: '16px' }}
onClick={async () => {
onClose(false);
}}
>
{t('Force Sign Out')}
</Button>
<Button
shape="round"
onClick={() => {
onClose(true);
}}
>
{t('Wait for Sync')}
</Button>
</div>
) : (
<div>
<Button
type="primary"
style={{ marginRight: '16px' }}
shape="round"
onClick={() => {
onClose(true);
}}
>
{t('Cancel')}
</Button>
<Button
shape="round"
onClick={() => {
onClose(false);
}}
>
{t('Sign out')}
</Button>
</div>
)}
</Content>
</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 SignDes = styled('div')(({ theme }) => {
return {
fontWeight: 400,
color: theme.colors.textColor,
fontSize: '16px',
};
});
const StyleCheck = styled('span')(() => {
return {
display: 'inline-block',
cursor: 'pointer',
svg: {
verticalAlign: 'sub',
marginRight: '8px',
},
};
});
const StyleTips = styled('span')(() => {
return {
userSelect: 'none',
};
});

View File

@ -1,55 +0,0 @@
import { toast } from '@affine/component';
import { DataCenter, MessageCenter } from '@affine/datacenter';
import { AffineProvider } from '@affine/datacenter';
import { DebugLogger } from '@affine/debug';
import { useRouter } from 'next/router';
import { ReactNode, useCallback, useEffect } from 'react';
import { useGlobalState } from '@/store/app';
const logger = new DebugLogger('messageCenter');
const clearAuth = (dataCenter: DataCenter, providerName: string) => {
const affineProvider = dataCenter.providers.find(p => p.id === providerName);
if (affineProvider && affineProvider instanceof AffineProvider) {
affineProvider.apis.auth.clear();
} else {
logger.error('cannot find affine provider, please fix this ASAP');
}
};
export function MessageCenterHandler({ children }: { children?: ReactNode }) {
const router = useRouter();
const dataCenter = useGlobalState(useCallback(store => store.dataCenter, []));
useEffect(() => {
const instance = MessageCenter.getInstance();
if (instance) {
return instance.onMessage(async message => {
if (message.code === MessageCenter.messageCode.noPermission) {
// todo: translate message
// todo: more specific message for accessing different resources
// todo: error toast style
toast('You have no permission to access this workspace');
// todo(himself65): remove dynamic lookup
clearAuth(dataCenter, 'affine');
// the status of the app right now is unknown, and it won't help if we let
// the app continue and let the user auth the app.
// that's why so we need to reload the page for now.
//
// fix: a better option is to keep loading the app, and prompt the user to login
// or perhaps displaying page 401?
await router.push('/');
router.reload();
}
if (message.code === MessageCenter.messageCode.refreshTokenError) {
toast('Session expired, please log in again');
clearAuth(dataCenter, 'affine');
await router.push('/');
router.reload();
}
});
}
}, [dataCenter, router]);
return <>{children}</>;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

View File

@ -1,49 +0,0 @@
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import React, { useState } from 'react';
import getIsMobile from '@/utils/get-is-mobile';
import bg from './bg.png';
import { StyledButton, StyledContent, StyledTitle } from './styles';
export const MobileModal = () => {
const [showModal, setShowModal] = useState(getIsMobile());
const { t } = useTranslation();
return (
<Modal
open={showModal}
onClose={() => {
setShowModal(false);
}}
>
<ModalWrapper
width={348}
height={388}
style={{ backgroundImage: `url(${bg.src})` }}
>
<ModalCloseButton
size={[30, 30]}
iconSize={[20, 20]}
onClick={() => {
setShowModal(false);
}}
/>
<StyledTitle>{t('Ooops!')}</StyledTitle>
<StyledContent>
<p>{t('mobile device')}</p>
<p>{t('mobile device description')}</p>
</StyledContent>
<StyledButton
onClick={() => {
setShowModal(false);
}}
>
{t('Got it')}
</StyledButton>
</ModalWrapper>
</Modal>
);
};
export default MobileModal;

View File

@ -1,38 +0,0 @@
import { displayFlex, styled } from '@affine/component';
export const StyledTitle = styled.div(() => {
return {
...displayFlex('center', 'center'),
fontSize: '20px',
fontWeight: 500,
marginTop: '60px',
lineHeight: 1,
};
});
export const StyledContent = styled.div(() => {
return {
padding: '0 40px',
marginTop: '32px',
fontSize: '18px',
lineHeight: '25px',
'p:not(last-of-type)': {
marginBottom: '10px',
},
};
});
export const StyledButton = styled.div(({ theme }) => {
return {
width: '146px',
height: '42px',
background: theme.colors.primaryColor,
color: '#FFFFFF',
fontSize: '18px',
fontWeight: 500,
borderRadius: '21px',
margin: '52px auto 0',
cursor: 'pointer',
...displayFlex('center', 'center'),
};
});

View File

@ -0,0 +1,65 @@
import type { EditorContainer } from '@blocksuite/editor';
import { assertExists, Page } from '@blocksuite/store';
import dynamic from 'next/dynamic';
import React from 'react';
import { Helmet } from 'react-helmet-async';
import { useBlockSuiteWorkspacePageTitle } from '../hooks/use-blocksuite-workspace-page-title';
import { usePageMeta } from '../hooks/use-page-meta';
import { BlockSuiteWorkspace } from '../shared';
import { PageNotFoundError } from './affine/affine-error-eoundary';
import { BlockSuiteEditorHeader } from './blocksuite/header';
export type PageDetailEditorProps = {
blockSuiteWorkspace: BlockSuiteWorkspace;
pageId: string;
onInit?: (page: Page, editor: Readonly<EditorContainer>) => void;
onLoad?: (page: Page, editor: EditorContainer) => void;
header?: React.ReactNode;
};
const Editor = dynamic(
async () =>
(await import('./blocksuite/block-suite-editor')).BlockSuiteEditor,
{
ssr: false,
}
);
export const PageDetailEditor: React.FC<PageDetailEditorProps> = ({
blockSuiteWorkspace,
pageId,
onInit,
onLoad,
header,
}) => {
const page = blockSuiteWorkspace.getPage(pageId);
if (!page) {
throw new PageNotFoundError(blockSuiteWorkspace, pageId);
}
const title = useBlockSuiteWorkspacePageTitle(blockSuiteWorkspace, pageId);
const meta = usePageMeta(blockSuiteWorkspace).find(
meta => meta.id === pageId
);
assertExists(meta);
return (
<>
<Helmet defaultTitle={title}>
<title>{title}</title>
</Helmet>
<BlockSuiteEditorHeader
blockSuiteWorkspace={blockSuiteWorkspace}
pageId={pageId}
>
{header}
</BlockSuiteEditorHeader>
<Editor
blockSuiteWorkspace={blockSuiteWorkspace}
mode={meta.mode ?? 'page'}
page={page}
onInit={onInit}
onLoad={onLoad}
/>
</>
);
};

View File

@ -1,130 +0,0 @@
import {
FlexWrapper,
IconButton,
Menu,
MenuItem,
Tooltip,
} from '@affine/component';
import { toast } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
DeletePermanentlyIcon,
DeleteTemporarilyIcon,
FavoritedIcon,
FavoriteIcon,
MoreVerticalIcon,
OpenInNewIcon,
ResetIcon,
} from '@blocksuite/icons';
import { usePageHelper } from '@/hooks/use-page-helper';
import { PageMeta } from '@/providers/app-state-provider';
import { useConfirm } from '@/providers/ConfirmProvider';
export const OperationCell = ({ pageMeta }: { pageMeta: PageMeta }) => {
const { id, favorite } = pageMeta;
const { openPage } = usePageHelper();
const { toggleFavoritePage, toggleDeletePage } = usePageHelper();
const confirm = useConfirm(store => store.confirm);
const { t } = useTranslation();
const OperationMenu = (
<>
<MenuItem
onClick={() => {
toggleFavoritePage(id);
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
icon={favorite ? <FavoritedIcon /> : <FavoriteIcon />}
>
{favorite ? t('Remove from favorites') : t('Add to Favorites')}
</MenuItem>
<MenuItem
onClick={() => {
openPage(id, {}, true);
}}
icon={<OpenInNewIcon />}
>
{t('Open in new tab')}
</MenuItem>
<MenuItem
onClick={() => {
confirm({
title: t('Delete page?'),
content: t('will be moved to Trash', {
title: pageMeta.title || 'Untitled',
}),
confirmText: t('Delete'),
confirmType: 'danger',
}).then(confirm => {
confirm && toggleDeletePage(id);
confirm && toast(t('Moved to Trash'));
});
}}
icon={<DeleteTemporarilyIcon />}
>
{t('Delete')}
</MenuItem>
</>
);
return (
<FlexWrapper alignItems="center" justifyContent="center">
<Menu
content={OperationMenu}
placement="bottom-end"
disablePortal={true}
trigger="click"
>
<IconButton darker={true}>
<MoreVerticalIcon />
</IconButton>
</Menu>
</FlexWrapper>
);
};
export const TrashOperationCell = ({ pageMeta }: { pageMeta: PageMeta }) => {
const { id } = pageMeta;
const { openPage, getPageMeta } = usePageHelper();
const { toggleDeletePage, permanentlyDeletePage } = usePageHelper();
const confirm = useConfirm(store => store.confirm);
const { t } = useTranslation();
return (
<FlexWrapper>
<Tooltip content={t('Restore it')} placement="top-start">
<IconButton
darker={true}
style={{ marginRight: '12px' }}
onClick={() => {
toggleDeletePage(id);
toast(
t('restored', { title: getPageMeta(id)?.title || 'Untitled' })
);
openPage(id);
}}
>
<ResetIcon />
</IconButton>
</Tooltip>
<Tooltip content={t('Delete permanently')} placement="top-start">
<IconButton
darker={true}
onClick={() => {
confirm({
title: t('Delete permanently?'),
content: t("Once deleted, you can't undo this action."),
confirmText: t('Delete'),
confirmType: 'danger',
}).then(confirm => {
confirm && permanentlyDeletePage(id);
toast(t('Permanently deleted'));
});
}}
>
<DeletePermanentlyIcon />
</IconButton>
</Tooltip>
</FlexWrapper>
);
};

View File

@ -1,172 +0,0 @@
import {
Table,
TableBody,
TableCell,
TableHead,
TableRow,
} from '@affine/component';
import { Content, IconButton, toast, Tooltip } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import {
EdgelessIcon,
FavoritedIcon,
FavoriteIcon,
PaperIcon,
} from '@blocksuite/icons';
import { useRouter } from 'next/router';
import React, { useCallback } from 'react';
import DateCell from '@/components/page-list/DateCell';
import { usePageHelper } from '@/hooks/use-page-helper';
import { PageMeta } from '@/providers/app-state-provider';
import { useTheme } from '@/providers/ThemeProvider';
import { useGlobalState } from '@/store/app';
import Empty from './Empty';
import { OperationCell, TrashOperationCell } from './OperationCell';
import {
StyledTableContainer,
StyledTableRow,
StyledTitleLink,
StyledTitleWrapper,
} from './styles';
const FavoriteTag = ({
pageMeta: { favorite, id },
}: {
pageMeta: PageMeta;
}) => {
const { toggleFavoritePage } = usePageHelper();
const { theme } = useTheme();
const { t } = useTranslation();
return (
<Tooltip
content={favorite ? t('Favorited') : t('Favorite')}
placement="top-start"
>
<IconButton
darker={true}
iconSize={[20, 20]}
onClick={e => {
e.stopPropagation();
toggleFavoritePage(id);
toast(
favorite ? t('Removed from Favorites') : t('Added to Favorites')
);
}}
style={{
color: favorite ? theme.colors.primaryColor : theme.colors.iconColor,
}}
className={favorite ? '' : 'favorite-button'}
>
{favorite ? (
<FavoritedIcon data-testid="favorited-icon" />
) : (
<FavoriteIcon />
)}
</IconButton>
</Tooltip>
);
};
export const PageList = ({
pageList,
showFavoriteTag = false,
isTrash = false,
isPublic = false,
listType,
}: {
pageList: PageMeta[];
showFavoriteTag?: boolean;
isTrash?: boolean;
isPublic?: boolean;
listType?: 'all' | 'trash' | 'favorite';
}) => {
const router = useRouter();
const currentWorkspace = useGlobalState(
useCallback(store => store.currentDataCenterWorkspace, [])
);
const { t } = useTranslation();
if (pageList.length === 0) {
return <Empty listType={listType} />;
}
return (
<StyledTableContainer>
<Table>
<TableHead>
<TableRow>
<TableCell proportion={0.5}>{t('Title')}</TableCell>
<TableCell proportion={0.2}>{t('Created')}</TableCell>
<TableCell proportion={0.2}>
{isTrash ? t('Moved to Trash') : t('Updated')}
</TableCell>
<TableCell proportion={0.1}></TableCell>
</TableRow>
</TableHead>
<TableBody>
{pageList.map((pageMeta, index) => {
// On click event must be set on the table cell, since the last operation cell is not clickable, and if set on the row, the menu will have bug on close.
const onClick = () => {
if (isPublic) {
router.push(
`/public-workspace/${router.query.workspaceId}/${pageMeta.id}`
);
} else {
router.push(
`/workspace/${currentWorkspace?.id}/${pageMeta.id}`
);
}
};
return (
<StyledTableRow
data-testid="page-list-item"
key={`${pageMeta.id}-${index}`}
>
<TableCell onClick={onClick}>
<StyledTitleWrapper>
<StyledTitleLink>
{pageMeta.mode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PaperIcon />
)}
<Content ellipsis={true} color="inherit">
{pageMeta.title || t('Untitled')}
</Content>
</StyledTitleLink>
{showFavoriteTag && <FavoriteTag pageMeta={pageMeta} />}
</StyledTitleWrapper>
</TableCell>
<DateCell
pageMeta={pageMeta}
dateKey="createDate"
onClick={onClick}
/>
<DateCell
pageMeta={pageMeta}
dateKey={isTrash ? 'trashDate' : 'updatedDate'}
backupKey={isTrash ? 'trashDate' : 'createDate'}
onClick={onClick}
/>
{!isPublic && (
<TableCell
style={{ padding: 0 }}
data-testid={`more-actions-${pageMeta.id}`}
>
{isTrash ? (
<TrashOperationCell pageMeta={pageMeta} />
) : (
<OperationCell pageMeta={pageMeta} />
)}
</TableCell>
)}
</StyledTableRow>
);
})}
</TableBody>
</Table>
</StyledTableContainer>
);
};
export default PageList;

View File

@ -12,5 +12,3 @@ export const ProviderComposer: FC<
}),
children
);
export default ProviderComposer;

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@ -2,42 +2,34 @@ import { styled } from '@affine/component';
import { Modal, ModalCloseButton, ModalWrapper } from '@affine/component';
import { Button } from '@affine/component';
import { Input } from '@affine/component';
import { toast } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { useRouter } from 'next/router';
import { useRef, useState } from 'react';
import { useCallback, useRef, useState } from 'react';
import { KeyboardEvent } from 'react';
import { useWorkspaceHelper } from '@/hooks/use-workspace-helper';
interface ModalProps {
open: boolean;
onClose: () => void;
onCreate: (name: string) => void;
}
export const CreateWorkspaceModal = ({ open, onClose }: ModalProps) => {
export const CreateWorkspaceModal = ({
open,
onClose,
onCreate,
}: ModalProps) => {
const [workspaceName, setWorkspaceName] = useState('');
const [loading, setLoading] = useState(false);
const { createWorkspace } = useWorkspaceHelper();
const isComposition = useRef(false);
const router = useRouter();
const handleCreateWorkspace = async () => {
setLoading(true);
const workspace = await createWorkspace(workspaceName);
if (workspace && workspace.id) {
setLoading(false);
router.replace(`/workspace/${workspace.id}`);
onClose();
} else {
toast('create error');
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && workspaceName && !isComposition.current) {
handleCreateWorkspace();
}
};
const handleCreateWorkspace = useCallback(() => {
onCreate(workspaceName);
}, [onCreate, workspaceName]);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter' && workspaceName && !isComposition.current) {
handleCreateWorkspace();
}
},
[handleCreateWorkspace, workspaceName]
);
const { t } = useTranslation();
return (
<div>
@ -78,7 +70,6 @@ export const CreateWorkspaceModal = ({ open, onClose }: ModalProps) => {
marginTop: '16px',
opacity: !workspaceName ? 0.5 : 1,
}}
loading={loading}
type="primary"
onClick={() => {
handleCreateWorkspace();
@ -121,10 +112,3 @@ const ContentTitle = styled('div')(() => {
paddingBottom: '16px',
};
});
// const Footer = styled('div')({
// height: '70px',
// paddingLeft: '24px',
// marginTop: '32px',
// textAlign: 'center',
// });

View File

@ -1,15 +1,19 @@
import { Button } from '@affine/component';
import { styled } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { ChangeEvent, FC, ReactElement, useRef } from 'react';
interface Props {
import React, { ChangeEvent, useRef } from 'react';
export type UploadProps = React.PropsWithChildren<{
uploadType?: string;
children?: ReactElement;
accept?: string;
fileChange: (file: File) => void;
}
export const Upload: FC<Props> = props => {
const { fileChange, accept } = props;
}>;
export const Upload: React.FC<UploadProps> = ({
fileChange,
accept,
children,
}) => {
const { t } = useTranslation();
const input_ref = useRef<HTMLInputElement>(null);
const _chooseFile = () => {
@ -30,7 +34,7 @@ export const Upload: FC<Props> = props => {
};
return (
<UploadStyle onClick={_chooseFile}>
{props.children ?? <Button>{t('Upload')}</Button>}
{children ?? <Button>{t('Upload')}</Button>}
<input
ref={input_ref}
type="file"

View File

@ -0,0 +1,125 @@
import { FlexWrapper } from '@affine/component';
import { IconButton } from '@affine/component';
import { Tooltip } from '@affine/component';
import { AccessTokenMessage } from '@affine/datacenter';
import { useTranslation } from '@affine/i18n';
import { CloudWorkspaceIcon, SignOutIcon } from '@blocksuite/icons';
import React, { CSSProperties } from 'react';
import { stringToColour } from '../../../utils';
import { StyledFooter, StyledSignInButton, StyleUserInfo } from './styles';
export type FooterProps = {
user: AccessTokenMessage | null;
onLogin: () => void;
onLogout: () => void;
};
export const Footer: React.FC<FooterProps> = ({ user, onLogin, onLogout }) => {
const { t } = useTranslation();
return (
<StyledFooter data-testid="workspace-list-modal-footer">
{user && (
<>
<FlexWrapper>
<WorkspaceAvatar
size={40}
name={user.name}
avatar={user.avatar_url}
></WorkspaceAvatar>
<StyleUserInfo>
<p>{user.name}</p>
<p>{user.email}</p>
</StyleUserInfo>
</FlexWrapper>
<Tooltip content={t('Sign out')} disablePortal={true}>
<IconButton
onClick={() => {
onLogout();
}}
>
<SignOutIcon />
</IconButton>
</Tooltip>
</>
)}
{!user && (
<StyledSignInButton
noBorder
bold
icon={
<div className="circle">
<CloudWorkspaceIcon fontSize={16} />
</div>
}
onClick={async () => {
onLogin();
}}
>
{t('Sign in')}
</StyledSignInButton>
)}
</StyledFooter>
);
};
interface WorkspaceAvatarProps {
size: number;
name: string;
avatar: string;
style?: CSSProperties;
}
export const WorkspaceAvatar: React.FC<WorkspaceAvatarProps> = props => {
const size = props.size || 20;
const sizeStr = size + 'px';
return (
<>
{props.avatar ? (
<div
style={{
...props.style,
width: sizeStr,
height: sizeStr,
color: '#fff',
borderRadius: '50%',
overflow: 'hidden',
display: 'inline-block',
verticalAlign: 'middle',
}}
>
<picture>
<img
style={{ width: sizeStr, height: sizeStr }}
src={props.avatar}
alt=""
referrerPolicy="no-referrer"
/>
</picture>
</div>
) : (
<div
style={{
...props.style,
width: sizeStr,
height: sizeStr,
border: '1px solid #fff',
color: '#fff',
fontSize: Math.ceil(0.5 * size) + 'px',
background: stringToColour(props.name || 'AFFiNE'),
borderRadius: '50%',
textAlign: 'center',
lineHeight: size + 'px',
display: 'inline-block',
verticalAlign: 'middle',
}}
>
{(props.name || 'AFFiNE').substring(0, 1)}
</div>
)}
</>
);
};

View File

@ -90,7 +90,7 @@ export const StyleUserInfo = styled.div(({ theme }) => {
lineHeight: '24px',
color: theme.colors.iconColor,
},
'p:first-child': {
'p:first-of-type': {
color: theme.colors.textColor,
fontWeight: 600,
},
@ -166,8 +166,6 @@ export const StyledSignInButton = styled(Button)(({ theme }) => {
backgroundColor: theme.colors.innerHoverBackground,
flexShrink: 0,
marginRight: '16px',
fontSize: '24px',
color: theme.colors.primaryColor,
...displayInlineFlex('center', 'center'),
},
};

View File

@ -0,0 +1,103 @@
import { Tooltip } from '@affine/component';
import { MuiFade } from '@affine/component';
import { useTranslation } from '@affine/i18n';
import { CloseIcon } from '@blocksuite/icons';
import { useState } from 'react';
import ContactModal from '../contact-modal';
import { ShortcutsModal } from '../shortcuts-modal';
import { ContactIcon, HelpIcon, KeyboardIcon } from './Icons';
import {
StyledAnimateWrapper,
StyledIconWrapper,
StyledIsland,
StyledTriggerWrapper,
} from './style';
export type IslandItemNames = 'contact' | 'shortcuts';
export const HelpIsland = ({
showList = ['contact', 'shortcuts'],
}: {
showList?: IslandItemNames[];
}) => {
const [spread, setShowSpread] = useState(false);
// const { triggerShortcutsModal, triggerContactModal } = useModal();
// const blockHub = useGlobalState(store => store.blockHub);
const { t } = useTranslation();
//
// useEffect(() => {
// blockHub?.blockHubStatusUpdated.on(status => {
// if (status) {
// setShowSpread(false);
// }
// });
// return () => {
// blockHub?.blockHubStatusUpdated.dispose();
// };
// }, [blockHub]);
//
// useEffect(() => {
// spread && blockHub?.toggleMenu(false);
// }, [blockHub, spread]);
const [open, setOpen] = useState(false);
const [openShortCut, setOpenShortCut] = useState(false);
return (
<>
<StyledIsland
spread={spread}
data-testid="help-island"
onClick={() => {
setShowSpread(!spread);
}}
>
<StyledAnimateWrapper
style={{ height: spread ? `${showList.length * 44}px` : 0 }}
>
{showList.includes('contact') && (
<Tooltip content={t('Contact Us')} placement="left-end">
<StyledIconWrapper
data-testid="right-bottom-contact-us-icon"
onClick={() => {
setShowSpread(false);
setOpen(true);
}}
>
<ContactIcon />
</StyledIconWrapper>
</Tooltip>
)}
{showList.includes('shortcuts') && (
<Tooltip content={t('Keyboard Shortcuts')} placement="left-end">
<StyledIconWrapper
data-testid="shortcuts-icon"
onClick={() => {
setShowSpread(false);
setOpenShortCut(true);
}}
>
<KeyboardIcon />
</StyledIconWrapper>
</Tooltip>
)}
</StyledAnimateWrapper>
<Tooltip content={t('Help and Feedback')} placement="left-end">
<MuiFade in={!spread} data-testid="faq-icon">
<StyledTriggerWrapper>
<HelpIcon />
</StyledTriggerWrapper>
</MuiFade>
</Tooltip>
<MuiFade in={spread}>
<StyledTriggerWrapper>
<CloseIcon />
</StyledTriggerWrapper>
</MuiFade>
</StyledIsland>
<ContactModal open={open} onClose={() => setOpen(false)} />
<ShortcutsModal
open={openShortCut}
onClose={() => setOpenShortCut(false)}
/>
</>
);
};

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