mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-22 14:41:37 +03:00
refactor!: next generation AFFiNE code structure (#1176)
This commit is contained in:
parent
2dcccc772c
commit
e0481d29ad
@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.next
|
||||
out
|
||||
|
@ -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',
|
||||
|
2
.github/deployment/Dockerfile
vendored
2
.github/deployment/Dockerfile
vendored
@ -10,4 +10,4 @@ COPY --from=relocate /app .
|
||||
|
||||
EXPOSE 80
|
||||
ENV API_SERVER=$API_SERVER
|
||||
CMD ["caddy", "run"]
|
||||
CMD ["caddy", "run"]
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@ -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
|
||||
|
@ -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
|
||||
|
@ -1,7 +0,0 @@
|
||||
# @affine/app
|
||||
|
||||
## 1.0.0
|
||||
|
||||
### Major Changes
|
||||
|
||||
- cc72448: add changeset config
|
@ -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.
|
@ -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();
|
||||
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
6
apps/web/preset.config.mjs
Normal file
6
apps/web/preset.config.mjs
Normal 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'),
|
||||
};
|
@ -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')
|
||||
);
|
||||
});
|
||||
});
|
@ -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;
|
||||
},
|
||||
};
|
36
apps/web/src/atoms/index.ts
Normal file
36
apps/web/src/atoms/index.ts
Normal 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();
|
48
apps/web/src/atoms/public-workspace/index.ts
Normal file
48
apps/web/src/atoms/public-workspace/index.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
);
|
32
apps/web/src/blocksuite/__tests__/index.spec.ts
Normal file
32
apps/web/src/blocksuite/__tests__/index.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
26
apps/web/src/blocksuite/index.ts
Normal file
26
apps/web/src/blocksuite/index.ts
Normal 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));
|
||||
};
|
71
apps/web/src/blocksuite/providers/index.ts
Normal file
71
apps/web/src/blocksuite/providers/index.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
};
|
@ -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;
|
@ -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',
|
||||
},
|
||||
};
|
||||
});
|
30
apps/web/src/components/__tests__/ProviderComposer.spec.tsx
Normal file
30
apps/web/src/components/__tests__/ProviderComposer.spec.tsx
Normal 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();
|
||||
});
|
@ -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);
|
||||
});
|
||||
});
|
@ -0,0 +1,7 @@
|
||||
// Vitest Snapshot v1
|
||||
|
||||
exports[`ProviderComposer 1`] = `
|
||||
<DocumentFragment>
|
||||
test1
|
||||
</DocumentFragment>
|
||||
`;
|
19
apps/web/src/components/affine/README.md
Normal file
19
apps/web/src/components/affine/README.md
Normal 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>
|
||||
);
|
||||
};
|
||||
```
|
129
apps/web/src/components/affine/affine-error-eoundary.tsx
Normal file
129
apps/web/src/components/affine/affine-error-eoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
@ -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>
|
||||
);
|
||||
};
|
@ -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')}
|
@ -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',
|
||||
};
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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();
|
||||
};
|
@ -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 {
|
@ -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)();
|
@ -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 (
|
||||
<>
|
@ -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;
|
@ -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);
|
@ -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;
|
@ -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 {
|
@ -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();
|
||||
};
|
@ -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 {
|
104
apps/web/src/components/blocksuite/block-suite-editor/index.tsx
Normal file
104
apps/web/src/components/blocksuite/block-suite-editor/index.tsx
Normal 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} />;
|
||||
};
|
@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
@ -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 = ({
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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({
|
@ -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;
|
@ -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;
|
@ -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);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -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;
|
@ -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 = () => {
|
@ -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>
|
90
apps/web/src/components/blocksuite/header/index.tsx
Normal file
90
apps/web/src/components/blocksuite/header/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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,
|
||||
};
|
@ -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;
|
||||
};
|
@ -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',
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -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 |
@ -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,
|
||||
},
|
||||
}));
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -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;
|
@ -1,3 +0,0 @@
|
||||
export * from './EditorHeader';
|
||||
export * from './Header';
|
||||
export * from './PageListHeader';
|
@ -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;
|
@ -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;
|
@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
};
|
||||
});
|
@ -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>
|
||||
);
|
||||
};
|
@ -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',
|
||||
};
|
||||
});
|
@ -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 |
@ -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;
|
@ -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'),
|
||||
};
|
||||
});
|
65
apps/web/src/components/page-detail-editor.tsx
Normal file
65
apps/web/src/components/page-detail-editor.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
@ -12,5 +12,3 @@ export const ProviderComposer: FC<
|
||||
}),
|
||||
children
|
||||
);
|
||||
|
||||
export default ProviderComposer;
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
@ -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',
|
||||
// });
|
@ -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"
|
125
apps/web/src/components/pure/footer/index.tsx
Normal file
125
apps/web/src/components/pure/footer/index.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
@ -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'),
|
||||
},
|
||||
};
|
103
apps/web/src/components/pure/help-island/index.tsx
Normal file
103
apps/web/src/components/pure/help-island/index.tsx
Normal 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
Loading…
Reference in New Issue
Block a user