mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-09-11 19:17:43 +03:00
feat: support web document and cypress test (#5116)
* feat: support web document and cypress test * fix: support blocks * fix: support table and outline * fix: update nginx
This commit is contained in:
parent
969726ef73
commit
9135fb94ad
2
.github/workflows/tauri2_ci.yaml
vendored
2
.github/workflows/tauri2_ci.yaml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Tauri2-CI
|
name: Tauri-CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
|
9
.github/workflows/tauri_ci.yaml
vendored
9
.github/workflows/tauri_ci.yaml
vendored
@ -1,11 +1,8 @@
|
|||||||
name: Tauri-CI
|
name: Tauri-CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
push:
|
||||||
paths:
|
branches:
|
||||||
- ".github/workflows/tauri_ci.yaml"
|
- build/tauri
|
||||||
- "frontend/rust-lib/**"
|
|
||||||
- "frontend/appflowy_tauri/**"
|
|
||||||
- "frontend/resources/**"
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
NODE_VERSION: "18.16.0"
|
NODE_VERSION: "18.16.0"
|
||||||
|
7
.github/workflows/web2_ci.yaml
vendored
7
.github/workflows/web2_ci.yaml
vendored
@ -1,4 +1,4 @@
|
|||||||
name: Web2-CI
|
name: Web-CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
@ -17,14 +17,14 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ ubuntu-latest ]
|
platform: [ ubuntu-20.04 ]
|
||||||
|
|
||||||
runs-on: ${{ matrix.platform }}
|
runs-on: ${{ matrix.platform }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Maximize build space (ubuntu only)
|
- name: Maximize build space (ubuntu only)
|
||||||
if: matrix.platform == 'ubuntu-latest'
|
if: matrix.platform == 'ubuntu-20.04'
|
||||||
run: |
|
run: |
|
||||||
sudo rm -rf /usr/share/dotnet
|
sudo rm -rf /usr/share/dotnet
|
||||||
sudo rm -rf /opt/ghc
|
sudo rm -rf /opt/ghc
|
||||||
@ -56,6 +56,7 @@ jobs:
|
|||||||
working-directory: frontend/appflowy_web_app
|
working-directory: frontend/appflowy_web_app
|
||||||
run: |
|
run: |
|
||||||
pnpm run lint
|
pnpm run lint
|
||||||
|
pnpm run test:unit
|
||||||
- name: build and analyze
|
- name: build and analyze
|
||||||
working-directory: frontend/appflowy_web_app
|
working-directory: frontend/appflowy_web_app
|
||||||
run: |
|
run: |
|
||||||
|
48
.github/workflows/web_cypress_ci.yaml
vendored
Normal file
48
.github/workflows/web_cypress_ci.yaml
vendored
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
name: Cypress Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- ".github/workflows/web2_ci.yaml"
|
||||||
|
- "frontend/appflowy_web_app/**"
|
||||||
|
- "frontend/resources/**"
|
||||||
|
env:
|
||||||
|
NODE_VERSION: "18.16.0"
|
||||||
|
PNPM_VERSION: "8.5.0"
|
||||||
|
concurrency:
|
||||||
|
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
jobs:
|
||||||
|
cypress-run:
|
||||||
|
if: github.event.pull_request.draft != true
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Maximize build space (ubuntu only)
|
||||||
|
run: |
|
||||||
|
sudo rm -rf /usr/share/dotnet
|
||||||
|
sudo rm -rf /opt/ghc
|
||||||
|
sudo rm -rf "/usr/local/share/boost"
|
||||||
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||||
|
sudo docker image prune --all --force
|
||||||
|
sudo rm -rf /opt/hostedtoolcache/codeQL
|
||||||
|
sudo rm -rf ${GITHUB_WORKSPACE}/.git
|
||||||
|
sudo rm -rf $ANDROID_HOME/ndk
|
||||||
|
- name: setup node
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
- name: setup pnpm
|
||||||
|
uses: pnpm/action-setup@v2
|
||||||
|
with:
|
||||||
|
version: ${{ env.PNPM_VERSION }}
|
||||||
|
# Install pnpm dependencies, cache them correctly
|
||||||
|
# and run all Cypress tests
|
||||||
|
- name: Cypress run
|
||||||
|
uses: cypress-io/github-action@v6
|
||||||
|
with:
|
||||||
|
working-directory: frontend/appflowy_web_app
|
||||||
|
component: true
|
||||||
|
build: pnpm run build
|
||||||
|
start: pnpm run start
|
||||||
|
browser: chrome
|
@ -14,10 +14,10 @@ export const DatabasePage = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex h-full w-full flex-col overflow-hidden caret-text-title'>
|
<div className="flex h-full w-full flex-col overflow-hidden caret-text-title">
|
||||||
<ViewIdProvider value={viewId}>
|
<ViewIdProvider value={viewId}>
|
||||||
<DatabaseTitle />
|
<DatabaseTitle/>
|
||||||
<Database selectedViewId={selectedViewId} setSelectedViewId={onChange} />
|
<Database selectedViewId={selectedViewId} setSelectedViewId={onChange}/>
|
||||||
</ViewIdProvider>
|
</ViewIdProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -4,4 +4,6 @@ src-tauri/
|
|||||||
.eslintrc.cjs
|
.eslintrc.cjs
|
||||||
tsconfig.json
|
tsconfig.json
|
||||||
**/backend/**
|
**/backend/**
|
||||||
vite.config.ts
|
vite.config.ts
|
||||||
|
**/*.cy.tsx
|
||||||
|
*.config.ts
|
@ -186,7 +186,7 @@ Don't modify the theme file in `frontend/appflowy_web_app/src/styles/variables`
|
|||||||
// ...
|
// ...
|
||||||
{
|
{
|
||||||
find: '$client-services',
|
find: '$client-services',
|
||||||
replacement: process.env.TAURI_MODE
|
replacement: !!process.env.TAURI_PLATFORM
|
||||||
? `${__dirname}/src/application/services/tauri-services`
|
? `${__dirname}/src/application/services/tauri-services`
|
||||||
: `${__dirname}/src/application/services/js-services`,
|
: `${__dirname}/src/application/services/js-services`,
|
||||||
},
|
},
|
||||||
@ -207,7 +207,7 @@ Use the AppFlowy CI/CD pipeline to deploy the application to the test and produc
|
|||||||
- Enter the options
|
- Enter the options
|
||||||
- Click on the Run workflow button
|
- Click on the Run workflow button
|
||||||
|
|
||||||
#### 📦 Deployment (Self-Hosted)
|
#### 📦 Deployment (Self-Hosted EC2)
|
||||||
|
|
||||||
##### Pre-requisites
|
##### Pre-requisites
|
||||||
|
|
||||||
@ -267,6 +267,18 @@ And then follow the steps below:
|
|||||||
|
|
||||||
### 🧪 Testing
|
### 🧪 Testing
|
||||||
|
|
||||||
> To be Continued...
|
> We use Cypress for end-to-end testing and component testing - [Cypress](https://www.cypress.io/)
|
||||||
|
|
||||||
|
#### 🧪 End-to-End Testing
|
||||||
|
|
||||||
|
> to be continued
|
||||||
|
|
||||||
|
#### 🧪 Component Testing
|
||||||
|
|
||||||
|
Run the following command to run the component tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm run test:components
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
18
frontend/appflowy_web_app/cypress.config.ts
Normal file
18
frontend/appflowy_web_app/cypress.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'cypress';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
component: {
|
||||||
|
devServer: {
|
||||||
|
framework: 'react',
|
||||||
|
bundler: 'vite',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
retries: {
|
||||||
|
// Configure retry attempts for `cypress run`
|
||||||
|
// Default is 0
|
||||||
|
runMode: 2,
|
||||||
|
// Configure retry attempts for `cypress open`
|
||||||
|
// Default is 0
|
||||||
|
openMode: 0,
|
||||||
|
},
|
||||||
|
});
|
1
frontend/appflowy_web_app/cypress/fixtures/full_doc.json
Normal file
1
frontend/appflowy_web_app/cypress/fixtures/full_doc.json
Normal file
File diff suppressed because one or more lines are too long
@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTI4Mjk2MjAsImlhdCI6MTcxMjgyNjAyMCwic3ViIjoiY2JmZjA2MGEtMTk2ZC00MTVhLWFhODAtNzU5YzAxODg2NDY2IiwiZW1haWwiOiJsdUBhcHBmbG93eS5pbyIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZ29vZ2xlIiwicHJvdmlkZXJzIjpbImdvb2dsZSJdfSwidXNlcl9tZXRhZGF0YSI6eyJhdmF0YXJfdXJsIjoiaHR0cHM6Ly9saDMuZ29vZ2xldXNlcmNvbnRlbnQuY29tL2EvQUNnOG9jTEhabVZBczRTb0ZlVFFuWG5CU2JiNTBBVXF0YktHNWx5MGllVHZCSklYZ1o3UmdRPXM5Ni1jIiwiY3VzdG9tX2NsYWltcyI6eyJoZCI6ImFwcGZsb3d5LmlvIn0sImVtYWlsIjoibHVAYXBwZmxvd3kuaW8iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZnVsbF9uYW1lIjoiTHUgSGUiLCJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJuYW1lIjoiTHUgSGUiLCJwaG9uZV92ZXJpZmllZCI6ZmFsc2UsInBpY3R1cmUiOiJodHRwczovL2xoMy5nb29nbGV1c2VyY29udGVudC5jb20vYS9BQ2c4b2NMSFptVkFzNFNvRmVUUW5YbkJTYmI1MEFVcXRiS0c1bHkwaWVUdkJKSVhnWjdSZ1E9czk2LWMiLCJwcm92aWRlcl9pZCI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSIsInN1YiI6IjEwMTE2OTI1MDgyOTU1NDAyODM4MSJ9LCJyb2xlIjoiIiwiYWFsIjoiYWFsMSIsImFtciI6W3sibWV0aG9kIjoicGFzc3dvcmQiLCJ0aW1lc3RhbXAiOjE3MTI4MjYwMjB9XSwic2Vzc2lvbl9pZCI6ImJmMzE5OTRlLTQwMTgtNDhjMS05Yzc0LWVmYzkyMGNjOWQ0NSJ9.QeTrRhsnBjBL1GUS3TIWOgU1SPM6RcaWwxZdMVfcFBU",
|
||||||
|
"token_type": "bearer",
|
||||||
|
"expires_in": 3600,
|
||||||
|
"expires_at": 4869016461,
|
||||||
|
"refresh_token": "71vp1jJnSAVluZKaXkhG1A",
|
||||||
|
"user": {
|
||||||
|
"id": "cbff060a-196d-415a-aa80-759c01886466",
|
||||||
|
"aud": "",
|
||||||
|
"role": "",
|
||||||
|
"email": "lu@appflowy.io",
|
||||||
|
"email_confirmed_at": "2024-03-13T10:49:53.165361Z",
|
||||||
|
"phone": "",
|
||||||
|
"confirmed_at": "2024-03-13T10:49:53.165361Z",
|
||||||
|
"last_sign_in_at": "2024-04-11T09:00:20.547468985Z",
|
||||||
|
"app_metadata": {
|
||||||
|
"provider": "google",
|
||||||
|
"providers": [
|
||||||
|
"google"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"user_metadata": {
|
||||||
|
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||||
|
"custom_claims": {
|
||||||
|
"hd": "appflowy.io"
|
||||||
|
},
|
||||||
|
"email": "lu@appflowy.io",
|
||||||
|
"email_verified": true,
|
||||||
|
"full_name": "Lu He",
|
||||||
|
"iss": "https://accounts.google.com",
|
||||||
|
"name": "Lu He",
|
||||||
|
"phone_verified": false,
|
||||||
|
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||||
|
"provider_id": "101169250829554028381",
|
||||||
|
"sub": "101169250829554028381"
|
||||||
|
},
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"identity_id": "e4cf8b69-7f80-42e9-aed2-e25132ad0178",
|
||||||
|
"id": "101169250829554028381",
|
||||||
|
"user_id": "cbff060a-196d-415a-aa80-759c01886466",
|
||||||
|
"identity_data": {
|
||||||
|
"avatar_url": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||||
|
"custom_claims": {
|
||||||
|
"hd": "appflowy.io"
|
||||||
|
},
|
||||||
|
"email": "lu@appflowy.io",
|
||||||
|
"email_verified": true,
|
||||||
|
"full_name": "Lu He",
|
||||||
|
"iss": "https://accounts.google.com",
|
||||||
|
"name": "Lu He",
|
||||||
|
"phone_verified": false,
|
||||||
|
"picture": "https://lh3.googleusercontent.com/a/ACg8ocLHZmVAs4SoFeTQnXnBSbb50AUqtbKG5ly0ieTvBJIXgZ7RgQ=s96-c",
|
||||||
|
"provider_id": "101169250829554028381",
|
||||||
|
"sub": "101169250829554028381"
|
||||||
|
},
|
||||||
|
"provider": "google",
|
||||||
|
"last_sign_in_at": "2024-03-13T07:22:43.110504Z",
|
||||||
|
"created_at": "2024-03-13T07:22:43.110543Z",
|
||||||
|
"updated_at": "2024-04-04T06:15:14.03093Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"created_at": "2024-03-13T07:22:43.102586Z",
|
||||||
|
"updated_at": "2024-04-11T09:00:20.551485Z"
|
||||||
|
}
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
17
frontend/appflowy_web_app/cypress/fixtures/user.json
Normal file
17
frontend/appflowy_web_app/cypress/fixtures/user.json
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"uid": 304120109071339520,
|
||||||
|
"uuid": "cbff060a-196d-415a-aa80-759c01886466",
|
||||||
|
"email": "lu@appflowy.io",
|
||||||
|
"password": "",
|
||||||
|
"name": "Kilu",
|
||||||
|
"metadata": {
|
||||||
|
"icon_url": "🇽🇰"
|
||||||
|
},
|
||||||
|
"encryption_sign": null,
|
||||||
|
"latest_workspace_id": "fcb503f9-9287-4de4-8de0-ea191e680968",
|
||||||
|
"updated_at": 1710421586
|
||||||
|
},
|
||||||
|
"code": 0,
|
||||||
|
"message": "Operation completed successfully."
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"is_new": false
|
||||||
|
}
|
||||||
|
}
|
46
frontend/appflowy_web_app/cypress/support/commands.ts
Normal file
46
frontend/appflowy_web_app/cypress/support/commands.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
/// <reference types="cypress" />
|
||||||
|
// ***********************************************
|
||||||
|
// This example commands.ts shows you how to
|
||||||
|
// create various custom commands and overwrite
|
||||||
|
// existing commands.
|
||||||
|
//
|
||||||
|
// For more comprehensive examples of custom
|
||||||
|
// commands please read more here:
|
||||||
|
// https://on.cypress.io/custom-commands
|
||||||
|
// ***********************************************
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a parent command --
|
||||||
|
// Cypress.Commands.add('login', (email, password) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a child command --
|
||||||
|
// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This is a dual command --
|
||||||
|
// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... })
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// -- This will overwrite an existing command --
|
||||||
|
// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... })
|
||||||
|
//
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
|
// @ts-ignore
|
||||||
|
Cypress.Commands.add('mockAPI', () => {
|
||||||
|
cy.fixture('sign_in_success').then((json) => {
|
||||||
|
cy.intercept('GET', `/api/user/verify/${json.access_token}`, {
|
||||||
|
fixture: 'verify_token',
|
||||||
|
}).as('verifyToken');
|
||||||
|
cy.intercept('POST', '/gotrue/token?grant_type=password', json).as('loginSuccess');
|
||||||
|
cy.intercept('POST', '/gotrue/token?grant_type=refresh_token', json).as('refreshToken');
|
||||||
|
});
|
||||||
|
cy.intercept('GET', '/api/user/profile', { fixture: 'user' }).as('getUserProfile');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Example use:
|
||||||
|
// beforeEach(() => {
|
||||||
|
// cy.mockAPI();
|
||||||
|
// });
|
||||||
|
|
@ -0,0 +1,12 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||||
|
<title>Components App</title>
|
||||||
|
</head>
|
||||||
|
<body id="body">
|
||||||
|
<div data-cy-root></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
42
frontend/appflowy_web_app/cypress/support/component.ts
Normal file
42
frontend/appflowy_web_app/cypress/support/component.ts
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// ***********************************************************
|
||||||
|
// This example support/component.ts is processed and
|
||||||
|
// loaded automatically before your test files.
|
||||||
|
//
|
||||||
|
// This is a great place to put global configuration and
|
||||||
|
// behavior that modifies Cypress.
|
||||||
|
//
|
||||||
|
// You can change the location of this file or turn off
|
||||||
|
// automatically serving support files with the
|
||||||
|
// 'supportFile' configuration option.
|
||||||
|
//
|
||||||
|
// You can read more here:
|
||||||
|
// https://on.cypress.io/configuration
|
||||||
|
// ***********************************************************
|
||||||
|
|
||||||
|
// Import commands.js using ES2015 syntax:
|
||||||
|
import './commands';
|
||||||
|
import './document';
|
||||||
|
// Alternatively you can use CommonJS syntax:
|
||||||
|
// require('./commands')
|
||||||
|
|
||||||
|
import { mount } from 'cypress/react18';
|
||||||
|
|
||||||
|
// Augment the Cypress namespace to include type definitions for
|
||||||
|
// your custom command.
|
||||||
|
// Alternatively, can be defined in cypress/support/component.d.ts
|
||||||
|
// with a <reference path="./component" /> at the top of your spec.
|
||||||
|
declare global {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
|
namespace Cypress {
|
||||||
|
interface Chainable {
|
||||||
|
mount: typeof mount;
|
||||||
|
mockAPI: () => void;
|
||||||
|
mockFullDocument: () => void;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Cypress.Commands.add('mount', mount);
|
||||||
|
|
||||||
|
// Example use:
|
||||||
|
// cy.mount(<MyComponent />)
|
88
frontend/appflowy_web_app/cypress/support/document.ts
Normal file
88
frontend/appflowy_web_app/cypress/support/document.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import { BlockId, BlockType, YBlocks, YChildrenMap, YjsEditorKey, YTextMap } from '@/application/document.type';
|
||||||
|
import { applyDocument } from 'src/application/ydoc/apply';
|
||||||
|
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
Cypress.Commands.add('mockFullDocument', () => {
|
||||||
|
cy.fixture('full_doc').then((docJson) => {
|
||||||
|
const collab = new Y.Doc();
|
||||||
|
const state = new Uint8Array(docJson.data.doc_state);
|
||||||
|
|
||||||
|
applyDocument(collab, state);
|
||||||
|
|
||||||
|
cy.stub(JSDocumentService.prototype, 'openDocument').returns(Promise.resolve(collab));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export class DocumentTest {
|
||||||
|
public doc: Y.Doc;
|
||||||
|
|
||||||
|
private blocks: YBlocks;
|
||||||
|
|
||||||
|
private childrenMap: YChildrenMap;
|
||||||
|
|
||||||
|
private textMap: YTextMap;
|
||||||
|
|
||||||
|
private pageId: string;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
|
||||||
|
this.doc = doc;
|
||||||
|
const collab = doc.getMap(YjsEditorKey.data_section);
|
||||||
|
const document = new Y.Map();
|
||||||
|
const blocks = new Y.Map() as YBlocks;
|
||||||
|
const pageId = nanoid(8);
|
||||||
|
const meta = new Y.Map();
|
||||||
|
const childrenMap = new Y.Map() as YChildrenMap;
|
||||||
|
const textMap = new Y.Map() as YTextMap;
|
||||||
|
|
||||||
|
const block = new Y.Map();
|
||||||
|
|
||||||
|
block.set(YjsEditorKey.block_id, pageId);
|
||||||
|
block.set(YjsEditorKey.block_type, BlockType.Page);
|
||||||
|
block.set(YjsEditorKey.block_children, pageId);
|
||||||
|
block.set(YjsEditorKey.block_external_id, pageId);
|
||||||
|
block.set(YjsEditorKey.block_external_type, YjsEditorKey.text);
|
||||||
|
block.set(YjsEditorKey.block_data, '');
|
||||||
|
blocks.set(pageId, block);
|
||||||
|
|
||||||
|
document.set(YjsEditorKey.page_id, pageId);
|
||||||
|
document.set(YjsEditorKey.blocks, blocks);
|
||||||
|
document.set(YjsEditorKey.meta, meta);
|
||||||
|
meta.set(YjsEditorKey.children_map, childrenMap);
|
||||||
|
meta.set(YjsEditorKey.text_map, textMap);
|
||||||
|
collab.set(YjsEditorKey.document, document);
|
||||||
|
|
||||||
|
this.blocks = blocks;
|
||||||
|
this.childrenMap = childrenMap;
|
||||||
|
this.textMap = textMap;
|
||||||
|
this.pageId = pageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
insertParagraph(text: string) {
|
||||||
|
const blockId = nanoid(8);
|
||||||
|
const block = new Y.Map();
|
||||||
|
|
||||||
|
block.set(YjsEditorKey.block_id, blockId);
|
||||||
|
block.set(YjsEditorKey.block_type, BlockType.Paragraph);
|
||||||
|
block.set(YjsEditorKey.block_children, blockId);
|
||||||
|
block.set(YjsEditorKey.block_external_id, blockId);
|
||||||
|
block.set(YjsEditorKey.block_external_type, YjsEditorKey.text);
|
||||||
|
block.set(YjsEditorKey.block_parent, this.pageId);
|
||||||
|
block.set(YjsEditorKey.block_data, '');
|
||||||
|
this.blocks.set(blockId, block);
|
||||||
|
const pageChildren = this.childrenMap.get(this.pageId) ?? new Y.Array<BlockId>();
|
||||||
|
|
||||||
|
pageChildren.push([blockId]);
|
||||||
|
this.childrenMap.set(this.pageId, pageChildren);
|
||||||
|
|
||||||
|
const blockText = new Y.Text();
|
||||||
|
|
||||||
|
blockText.insert(0, text);
|
||||||
|
this.textMap.set(blockId, blockText);
|
||||||
|
|
||||||
|
return blockText;
|
||||||
|
}
|
||||||
|
}
|
@ -1,16 +1,15 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8"/>
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/appflowy.svg"/>
|
<link rel="icon" type="image/svg+xml" href="/appflowy.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<style>
|
|
||||||
@import url('https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap');
|
|
||||||
</style>
|
|
||||||
<title>AppFlowy</title>
|
<title>AppFlowy</title>
|
||||||
</head>
|
</head>
|
||||||
<body id="body">
|
<body id="body">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
<script src="https://ajax.googleapis.com/ajax/libs/webfont/1.6.26/webfont.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
20
frontend/appflowy_web_app/jest.config.cjs
Normal file
20
frontend/appflowy_web_app/jest.config.cjs
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const { compilerOptions } = require('./tsconfig.json');
|
||||||
|
const { pathsToModuleNameMapper } = require('ts-jest');
|
||||||
|
const esModules = ['lodash-es', 'nanoid'].join('|');
|
||||||
|
|
||||||
|
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>'],
|
||||||
|
modulePaths: [compilerOptions.baseUrl],
|
||||||
|
moduleNameMapper: {
|
||||||
|
...pathsToModuleNameMapper(compilerOptions.paths),
|
||||||
|
'^lodash-es(/(.*)|$)': 'lodash$1',
|
||||||
|
'^nanoid(/(.*)|$)': 'nanoid$1',
|
||||||
|
},
|
||||||
|
'transform': {
|
||||||
|
'(.*)/node_modules/nanoid/.+\\.(j|t)sx?$': 'ts-jest',
|
||||||
|
},
|
||||||
|
'transformIgnorePatterns': [`/node_modules/(?!${esModules})`],
|
||||||
|
};
|
@ -63,6 +63,19 @@ http {
|
|||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html index.htm;
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
expires 30d;
|
||||||
|
access_log off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /appflowy.svg {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
expires 30d;
|
||||||
|
access_log off;
|
||||||
}
|
}
|
||||||
|
|
||||||
error_page 404 /404.html;
|
error_page 404 /404.html;
|
||||||
|
@ -5,27 +5,31 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm run sync:i18n && vite",
|
"dev": "pnpm run sync:i18n && vite",
|
||||||
"dev:tauri": "TAURI_MODE=true pnpm run sync:i18n && vite",
|
"dev:tauri": "pnpm run sync:i18n && vite",
|
||||||
"build": "vite build",
|
"build": "pnpm run sync:i18n && vite build",
|
||||||
"build:tauri": "TAURI_MODE=true vite build",
|
"build:tauri": "vite build",
|
||||||
"lint:tauri": "TAURI_MODE=true pnpm run sync:i18n && tsc --noEmit && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore",
|
"lint:tauri": "pnpm run sync:i18n && tsc --noEmit && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore",
|
||||||
"lint": "pnpm run sync:i18n && tsc --noEmit --project tsconfig.web.json && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore.web",
|
"lint": "pnpm run sync:i18n && tsc --noEmit --project tsconfig.web.json && eslint --ext .js,.ts,.tsx . --ignore-path .eslintignore.web",
|
||||||
"preview": "vite preview --port 8080",
|
"start": "vite preview --port 3000",
|
||||||
"tauri:dev": "tauri dev",
|
"tauri:dev": "tauri dev",
|
||||||
"css:variables": "node style-dictionary/config.cjs",
|
"css:variables": "node style-dictionary/config.cjs",
|
||||||
"sync:i18n": "node scripts/i18n.cjs",
|
"sync:i18n": "node scripts/i18n.cjs",
|
||||||
"link:client-api": "rm -rf node_modules/.vite && node scripts/create-symlink.cjs",
|
"link:client-api": "rm -rf node_modules/.vite && node scripts/create-symlink.cjs",
|
||||||
"analyze": "ANALYZE_MODE=true vite build"
|
"analyze": "cross-env ANALYZE_MODE=true vite build",
|
||||||
|
"cypress:open": "cypress open",
|
||||||
|
"test": "pnpm run test:unit && pnpm run test:components",
|
||||||
|
"test:components": "cypress run --component --browser chrome --headless",
|
||||||
|
"test:unit": "jest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@appflowyinc/client-api-wasm": "^0.0.2",
|
"@appflowyinc/client-api-wasm": "0.0.2-alpha.2",
|
||||||
|
"@atlaskit/primitives": "^5.5.3",
|
||||||
"@emoji-mart/data": "^1.1.2",
|
"@emoji-mart/data": "^1.1.2",
|
||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@emotion/react": "^11.10.6",
|
"@emotion/react": "^11.10.6",
|
||||||
"@emotion/styled": "^11.10.6",
|
"@emotion/styled": "^11.10.6",
|
||||||
"@mui/icons-material": "^5.11.11",
|
"@mui/icons-material": "^5.11.11",
|
||||||
"@mui/material": "^5.11.12",
|
"@mui/material": "6.0.0-alpha.2",
|
||||||
"@mui/system": "^5.14.4",
|
|
||||||
"@mui/x-date-pickers-pro": "^6.18.2",
|
"@mui/x-date-pickers-pro": "^6.18.2",
|
||||||
"@reduxjs/toolkit": "2.0.0",
|
"@reduxjs/toolkit": "2.0.0",
|
||||||
"@slate-yjs/core": "^1.0.2",
|
"@slate-yjs/core": "^1.0.2",
|
||||||
@ -33,6 +37,7 @@
|
|||||||
"@types/react-swipeable-views": "^0.13.4",
|
"@types/react-swipeable-views": "^0.13.4",
|
||||||
"axios": "^1.6.8",
|
"axios": "^1.6.8",
|
||||||
"dayjs": "^1.11.9",
|
"dayjs": "^1.11.9",
|
||||||
|
"dexie": "^4.0.1",
|
||||||
"emoji-mart": "^5.5.2",
|
"emoji-mart": "^5.5.2",
|
||||||
"emoji-regex": "^10.2.1",
|
"emoji-regex": "^10.2.1",
|
||||||
"events": "^3.3.0",
|
"events": "^3.3.0",
|
||||||
@ -80,7 +85,8 @@
|
|||||||
"utf8": "^3.0.0",
|
"utf8": "^3.0.0",
|
||||||
"valtio": "^1.12.1",
|
"valtio": "^1.12.1",
|
||||||
"vite-plugin-wasm": "^3.3.0",
|
"vite-plugin-wasm": "^3.3.0",
|
||||||
"yjs": "^13.5.51"
|
"y-indexeddb": "9.0.12",
|
||||||
|
"yjs": "^13.6.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@svgr/plugin-svgo": "^8.0.1",
|
"@svgr/plugin-svgo": "^8.0.1",
|
||||||
@ -110,6 +116,8 @@
|
|||||||
"autoprefixer": "^10.4.13",
|
"autoprefixer": "^10.4.13",
|
||||||
"babel-jest": "^29.6.2",
|
"babel-jest": "^29.6.2",
|
||||||
"chalk": "^4.1.2",
|
"chalk": "^4.1.2",
|
||||||
|
"cross-env": "^7.0.3",
|
||||||
|
"cypress": "^13.7.2",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-plugin-react": "^7.32.2",
|
"eslint-plugin-react": "^7.32.2",
|
||||||
"eslint-plugin-react-hooks": "^4.6.0",
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -1,35 +0,0 @@
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
|
||||||
import { Provider } from 'react-redux';
|
|
||||||
import { store } from './stores/store';
|
|
||||||
import { ErrorBoundary } from 'react-error-boundary';
|
|
||||||
import { ErrorHandlerPage } from './components/error/ErrorHandlerPage';
|
|
||||||
import '@/i18n/config';
|
|
||||||
import AppTheme from '@/AppTheme';
|
|
||||||
import { Toaster } from 'react-hot-toast';
|
|
||||||
import ProtectedRoutes from '@/components/auth/ProtectedRoutes';
|
|
||||||
import AppConfig from '@/AppConfig';
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<BrowserRouter>
|
|
||||||
<Provider store={store}>
|
|
||||||
<AppTheme>
|
|
||||||
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
|
|
||||||
<AppConfig>
|
|
||||||
<Routes>
|
|
||||||
<Route path={'/'} element={<ProtectedRoutes />}>
|
|
||||||
{/*<Route path={'/page/document/:id'} element={<DocumentPage />} />*/}
|
|
||||||
{/*<Route path={'/page/grid/:id'} element={<DatabasePage />} />*/}
|
|
||||||
{/*<Route path={'/trash'} id={'trash'} element={<TrashPage />} />*/}
|
|
||||||
</Route>
|
|
||||||
</Routes>
|
|
||||||
<Toaster />
|
|
||||||
</AppConfig>
|
|
||||||
</ErrorBoundary>
|
|
||||||
</AppTheme>
|
|
||||||
</Provider>
|
|
||||||
</BrowserRouter>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App;
|
|
@ -1,178 +0,0 @@
|
|||||||
import React, { useMemo } from 'react';
|
|
||||||
import createTheme from '@mui/material/styles/createTheme';
|
|
||||||
import ThemeProvider from '@mui/material/styles/ThemeProvider';
|
|
||||||
|
|
||||||
function AppTheme ({ children }: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
const isDark = false;
|
|
||||||
const theme = useMemo(() => createTheme({
|
|
||||||
typography: {
|
|
||||||
fontFamily: ['Poppins'].join(','),
|
|
||||||
fontSize: 12,
|
|
||||||
button: {
|
|
||||||
textTransform: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
components: {
|
|
||||||
MuiMenuItem: {
|
|
||||||
defaultProps: {
|
|
||||||
sx: {
|
|
||||||
'&.Mui-selected.Mui-focusVisible': {
|
|
||||||
backgroundColor: 'var(--fill-list-hover)',
|
|
||||||
},
|
|
||||||
'&.Mui-focusVisible': {
|
|
||||||
backgroundColor: 'unset',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiIconButton: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'var(--fill-list-hover)',
|
|
||||||
},
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '2px',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiButton: {
|
|
||||||
styleOverrides: {
|
|
||||||
contained: {
|
|
||||||
color: 'var(--content-on-fill)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
},
|
|
||||||
containedPrimary: {
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'var(--fill-default)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
containedInherit: {
|
|
||||||
color: 'var(--text-title)',
|
|
||||||
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)',
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'var(--bg-body)',
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
outlinedInherit: {
|
|
||||||
color: 'var(--text-title)',
|
|
||||||
borderColor: 'var(--line-border)',
|
|
||||||
'&:hover': {
|
|
||||||
boxShadow: 'var(--shadow)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiButtonBase: {
|
|
||||||
defaultProps: {
|
|
||||||
sx: {
|
|
||||||
'&.Mui-selected:hover': {
|
|
||||||
backgroundColor: 'var(--fill-list-hover)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'var(--fill-list-hover)',
|
|
||||||
},
|
|
||||||
'&:active': {
|
|
||||||
backgroundColor: 'var(--fill-list-hover)',
|
|
||||||
},
|
|
||||||
borderRadius: '4px',
|
|
||||||
padding: '2px',
|
|
||||||
boxShadow: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiPaper: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
backgroundImage: 'none',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiDialog: {
|
|
||||||
defaultProps: {
|
|
||||||
sx: {
|
|
||||||
'& .MuiBackdrop-root': {
|
|
||||||
backgroundColor: 'var(--bg-mask)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
MuiTooltip: {
|
|
||||||
styleOverrides: {
|
|
||||||
arrow: {
|
|
||||||
color: 'var(--bg-tips)',
|
|
||||||
},
|
|
||||||
tooltip: {
|
|
||||||
backgroundColor: 'var(--bg-tips)',
|
|
||||||
color: 'var(--text-title)',
|
|
||||||
fontSize: '0.85rem',
|
|
||||||
borderRadius: '8px',
|
|
||||||
fontWeight: 400,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiInputBase: {
|
|
||||||
styleOverrides: {
|
|
||||||
input: {
|
|
||||||
backgroundColor: 'transparent !important',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
MuiDivider: {
|
|
||||||
styleOverrides: {
|
|
||||||
root: {
|
|
||||||
borderColor: 'var(--line-divider)',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
palette: {
|
|
||||||
mode: isDark ? 'dark' : 'light',
|
|
||||||
primary: {
|
|
||||||
main: '#00BCF0',
|
|
||||||
dark: '#00BCF0',
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
main: '#FB006D',
|
|
||||||
dark: '#D32772',
|
|
||||||
},
|
|
||||||
warning: {
|
|
||||||
main: '#FFC107',
|
|
||||||
dark: '#E9B320',
|
|
||||||
},
|
|
||||||
info: {
|
|
||||||
main: '#00BCF0',
|
|
||||||
dark: '#2E9DBB',
|
|
||||||
},
|
|
||||||
success: {
|
|
||||||
main: '#66CF80',
|
|
||||||
dark: '#3BA856',
|
|
||||||
},
|
|
||||||
text: {
|
|
||||||
primary: isDark ? '#E2E9F2' : '#333333',
|
|
||||||
secondary: isDark ? '#7B8A9D' : '#828282',
|
|
||||||
disabled: isDark ? '#363D49' : '#F2F2F2',
|
|
||||||
},
|
|
||||||
divider: isDark ? '#59647A' : '#BDBDBD',
|
|
||||||
background: {
|
|
||||||
default: isDark ? '#1A202C' : '#FFFFFF',
|
|
||||||
paper: isDark ? '#1A202C' : '#FFFFFF',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
}), [isDark]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ThemeProvider theme={theme}>{children}</ThemeProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default AppTheme;
|
|
14
frontend/appflowy_web_app/src/application/collab.type.ts
Normal file
14
frontend/appflowy_web_app/src/application/collab.type.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export enum CollabType {
|
||||||
|
Document = 0,
|
||||||
|
Database = 1,
|
||||||
|
WorkspaceDatabase = 2,
|
||||||
|
Folder = 3,
|
||||||
|
DatabaseRow = 4,
|
||||||
|
UserAwareness = 5,
|
||||||
|
Empty = 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CollabOrigin {
|
||||||
|
Local = 'local',
|
||||||
|
Remote = 'remote',
|
||||||
|
}
|
2
frontend/appflowy_web_app/src/application/constants.ts
Normal file
2
frontend/appflowy_web_app/src/application/constants.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export const databasePrefix = 'af_database';
|
||||||
|
|
176
frontend/appflowy_web_app/src/application/document.type.ts
Normal file
176
frontend/appflowy_web_app/src/application/document.type.ts
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
import Y from 'yjs';
|
||||||
|
|
||||||
|
export type BlockId = string;
|
||||||
|
|
||||||
|
export type ExternalId = string;
|
||||||
|
|
||||||
|
export type ChildrenId = string;
|
||||||
|
|
||||||
|
export enum BlockType {
|
||||||
|
Paragraph = 'paragraph',
|
||||||
|
Page = 'page',
|
||||||
|
HeadingBlock = 'heading',
|
||||||
|
TodoListBlock = 'todo_list',
|
||||||
|
BulletedListBlock = 'bulleted_list',
|
||||||
|
NumberedListBlock = 'numbered_list',
|
||||||
|
ToggleListBlock = 'toggle_list',
|
||||||
|
CodeBlock = 'code',
|
||||||
|
EquationBlock = 'math_equation',
|
||||||
|
QuoteBlock = 'quote',
|
||||||
|
CalloutBlock = 'callout',
|
||||||
|
DividerBlock = 'divider',
|
||||||
|
ImageBlock = 'image',
|
||||||
|
GridBlock = 'grid',
|
||||||
|
OutlineBlock = 'outline',
|
||||||
|
TableBlock = 'table',
|
||||||
|
TableCell = 'table/cell',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum InlineBlockType {
|
||||||
|
Formula = 'formula',
|
||||||
|
Mention = 'mention',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AlignType {
|
||||||
|
Left = 'left',
|
||||||
|
Center = 'center',
|
||||||
|
Right = 'right',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BlockData {
|
||||||
|
bg_color?: string;
|
||||||
|
font_color?: string;
|
||||||
|
align?: AlignType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HeadingBlockData extends BlockData {
|
||||||
|
level: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberedListBlockData extends BlockData {
|
||||||
|
number: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TodoListBlockData extends BlockData {
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ToggleListBlockData extends BlockData {
|
||||||
|
collapsed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeBlockData extends BlockData {
|
||||||
|
language: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalloutBlockData extends BlockData {
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MathEquationBlockData extends BlockData {
|
||||||
|
formula?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ImageType {
|
||||||
|
Local = 0,
|
||||||
|
Internal = 1,
|
||||||
|
External = 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageBlockData extends BlockData {
|
||||||
|
url?: string;
|
||||||
|
width?: number;
|
||||||
|
align?: AlignType;
|
||||||
|
image_type?: ImageType;
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OutlineBlockData extends BlockData {
|
||||||
|
depth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableBlockData extends BlockData {
|
||||||
|
colDefaultWidth: number;
|
||||||
|
colMinimumWidth: number;
|
||||||
|
colsHeight: number;
|
||||||
|
colsLen: number;
|
||||||
|
rowDefaultHeight: number;
|
||||||
|
rowsLen: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableCellBlockData extends BlockData {
|
||||||
|
colPosition: number;
|
||||||
|
height: number;
|
||||||
|
rowPosition: number;
|
||||||
|
width: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum MentionType {
|
||||||
|
PageRef = 'page',
|
||||||
|
Date = 'date',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Mention {
|
||||||
|
// inline page ref id
|
||||||
|
page_id?: string;
|
||||||
|
// reminder date ref id
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
type: MentionType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum YjsEditorKey {
|
||||||
|
data_section = 'data',
|
||||||
|
document = 'document',
|
||||||
|
database = 'database',
|
||||||
|
workspace_database = 'databases',
|
||||||
|
folder = 'folder',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||||
|
database_row = 'data',
|
||||||
|
user_awareness = 'user_awareness',
|
||||||
|
blocks = 'blocks',
|
||||||
|
page_id = 'page_id',
|
||||||
|
meta = 'meta',
|
||||||
|
children_map = 'children_map',
|
||||||
|
text_map = 'text_map',
|
||||||
|
text = 'text',
|
||||||
|
delta = 'delta',
|
||||||
|
|
||||||
|
block_id = 'id',
|
||||||
|
block_type = 'ty',
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-duplicate-enum-values
|
||||||
|
block_data = 'data',
|
||||||
|
block_parent = 'parent',
|
||||||
|
block_children = 'children',
|
||||||
|
block_external_id = 'external_id',
|
||||||
|
block_external_type = 'external_type',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YDoc extends Y.Doc {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
get(key: YjsEditorKey.data_section | string): YSharedRoot | any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YSharedRoot extends Y.Map<unknown> {
|
||||||
|
get(key: YjsEditorKey.document): YDocument;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YDocument extends Y.Map<unknown> {
|
||||||
|
get(key: YjsEditorKey.blocks | YjsEditorKey.page_id | YjsEditorKey.meta): YBlocks | YMeta | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YBlocks extends Y.Map<unknown> {
|
||||||
|
get(key: BlockId): Y.Map<unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YMeta extends Y.Map<unknown> {
|
||||||
|
get(key: YjsEditorKey.children_map | YjsEditorKey.text_map): YChildrenMap | YTextMap;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YChildrenMap extends Y.Map<unknown> {
|
||||||
|
get(key: ChildrenId): Y.Array<BlockId>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YTextMap extends Y.Map<unknown> {
|
||||||
|
get(key: ExternalId): Y.Text;
|
||||||
|
}
|
@ -3,10 +3,9 @@ import { AFClientService } from '$client-services';
|
|||||||
|
|
||||||
let service: AFService;
|
let service: AFService;
|
||||||
|
|
||||||
export async function getService(config: AFServiceConfig) {
|
export async function getService (config: AFServiceConfig) {
|
||||||
if (service) return service;
|
if (service) return service;
|
||||||
|
|
||||||
service = new AFClientService(config);
|
service = new AFClientService(config);
|
||||||
await service.load();
|
|
||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
@ -1,12 +1,12 @@
|
|||||||
import { AuthService } from '@/application/services/services.type';
|
import { AuthService } from '@/application/services/services.type';
|
||||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/services/user.type';
|
import { ProviderType, SignUpWithEmailPasswordParams } from '@/application/user.type';
|
||||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
import { APIService } from 'src/application/services/js-services/wasm';
|
||||||
import { ACCESS_TOKEN_NAME, REFRESH_TOKEN_NAME, TOKEN_TYPE_NAME } from '@/application/services/js-services/http/const';
|
import { signInSuccess } from '@/application/services/js-services/storage/auth';
|
||||||
import { AFWasmService } from '@/application/services/wasm-services';
|
import { invalidToken } from '@/application/services/js-services/storage';
|
||||||
|
import { afterSignInDecorator } from '@/application/services/js-services/decorator';
|
||||||
|
|
||||||
export class JSAuthService implements AuthService {
|
export class JSAuthService implements AuthService {
|
||||||
|
constructor() {
|
||||||
constructor (private httpClient: HttpClient, private wasmService: AFWasmService) {
|
|
||||||
// Do nothing
|
// Do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -14,32 +14,26 @@ export class JSAuthService implements AuthService {
|
|||||||
return Promise.reject('Not implemented');
|
return Promise.reject('Not implemented');
|
||||||
};
|
};
|
||||||
|
|
||||||
signInWithOAuth = async ({ uri }: { uri: string }): Promise<UserProfile> => {
|
@afterSignInDecorator(signInSuccess)
|
||||||
const params = uri.split('#')[1].split('&');
|
async signInWithOAuth(_: { uri: string }): Promise<void> {
|
||||||
const data: Record<string, string> = {};
|
return Promise.reject('Not implemented');
|
||||||
|
}
|
||||||
|
|
||||||
params.forEach((param) => {
|
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||||
const [key, value] = param.split('=');
|
|
||||||
|
|
||||||
data[key] = value;
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionStorage.setItem(TOKEN_TYPE_NAME, data.token_type);
|
|
||||||
sessionStorage.setItem(ACCESS_TOKEN_NAME, data.access_token);
|
|
||||||
sessionStorage.setItem(REFRESH_TOKEN_NAME, data.refresh_token);
|
|
||||||
return this.httpClient.getUser();
|
|
||||||
};
|
|
||||||
signupWithEmailPassword = async (_params: SignUpWithEmailPasswordParams): Promise<UserProfile> => {
|
|
||||||
return Promise.reject('Not implemented');
|
return Promise.reject('Not implemented');
|
||||||
};
|
};
|
||||||
|
|
||||||
signinWithEmailPassword = async (email: string, password: string): Promise<UserProfile> => {
|
@afterSignInDecorator(signInSuccess)
|
||||||
// await this.wasmService.cloudService.signIn(email, password);
|
async signinWithEmailPassword(email: string, password: string): Promise<void> {
|
||||||
// return Promise.reject('Not implemented');
|
try {
|
||||||
return this.httpClient.signInWithEmailPassword(email, password);
|
return APIService.signIn(email, password);
|
||||||
};
|
} catch (e) {
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
signOut = async (): Promise<void> => {
|
signOut = async (): Promise<void> => {
|
||||||
return this.httpClient.logout();
|
invalidToken();
|
||||||
|
return APIService.logout();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,68 @@
|
|||||||
|
import { YDoc } from '@/application/document.type';
|
||||||
|
import { getAuthInfo } from '@/application/services/js-services/storage';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { IndexeddbPersistence } from 'y-indexeddb';
|
||||||
|
import { databasePrefix } from '@/application/constants';
|
||||||
|
import BaseDexie from 'dexie';
|
||||||
|
import { usersSchema, UsersTable } from './tables/users';
|
||||||
|
|
||||||
|
const version = 1;
|
||||||
|
|
||||||
|
type DexieTables = UsersTable;
|
||||||
|
export type Dexie<T = DexieTables> = BaseDexie & T;
|
||||||
|
|
||||||
|
let db: Dexie | undefined;
|
||||||
|
|
||||||
|
export function getDB() {
|
||||||
|
const authInfo = getAuthInfo();
|
||||||
|
|
||||||
|
if (!db && authInfo?.uuid) {
|
||||||
|
return openDB(authInfo?.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDB(uuid: string) {
|
||||||
|
const dbName = `${databasePrefix}_${uuid}`;
|
||||||
|
|
||||||
|
if (db && db.name === dbName) {
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
db = new BaseDexie(dbName) as Dexie;
|
||||||
|
const schema = Object.assign({}, usersSchema);
|
||||||
|
|
||||||
|
db.version(version).stores(schema);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Open the collaboration database, and return a function to close it
|
||||||
|
*/
|
||||||
|
export async function openCollabDB(docName: string): Promise<YDoc> {
|
||||||
|
const name = `${databasePrefix}_${docName}`;
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const provider = new IndexeddbPersistence(name, doc);
|
||||||
|
|
||||||
|
let resolve: (value: unknown) => void;
|
||||||
|
const promise = new Promise((resolveFn) => {
|
||||||
|
resolve = resolveFn;
|
||||||
|
});
|
||||||
|
|
||||||
|
provider.on('synced', () => {
|
||||||
|
resolve(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
return doc as YDoc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCollabDB(docName: string) {
|
||||||
|
const name = `${databasePrefix}_${docName}`;
|
||||||
|
const doc = new Y.Doc();
|
||||||
|
const provider = new IndexeddbPersistence(name, doc);
|
||||||
|
|
||||||
|
await provider.destroy();
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import { Table } from 'dexie';
|
||||||
|
import { UserProfile } from '@/application/user.type';
|
||||||
|
|
||||||
|
export type UsersTable = {
|
||||||
|
users: Table<UserProfile>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const usersSchema = {
|
||||||
|
users: 'uuid, uid, email, name, workspaceId, iconUrl',
|
||||||
|
};
|
@ -0,0 +1,60 @@
|
|||||||
|
/**
|
||||||
|
* @description:
|
||||||
|
* * This is a decorator that can be used to read data from storage and fetch data from the server.
|
||||||
|
* * If the data is already in storage, it will return the data from storage and fetch the data from the server in the background.
|
||||||
|
*
|
||||||
|
* @param getStorage A function that returns the data from storage. eg. `() => Promise<T | undefined>`
|
||||||
|
*
|
||||||
|
* @param setStorage A function that saves the data to storage. eg. `(data: T) => Promise<void>`
|
||||||
|
*
|
||||||
|
* @param fetchFunction A function that fetches the data from the server. eg. `(params: P) => Promise<T | undefined>`
|
||||||
|
*
|
||||||
|
* @returns: A function that returns the data from storage and fetches the data from the server in the background.
|
||||||
|
*/
|
||||||
|
export function asyncDataDecorator<P, T>(
|
||||||
|
getStorage: () => Promise<T | undefined>,
|
||||||
|
setStorage: (data: T) => Promise<void>,
|
||||||
|
fetchFunction: (params: P) => Promise<T | undefined>
|
||||||
|
) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
|
async function fetchData(params: P) {
|
||||||
|
const data = await fetchFunction(params);
|
||||||
|
|
||||||
|
if (!data) return;
|
||||||
|
await setStorage(data);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalMethod = descriptor.value;
|
||||||
|
|
||||||
|
descriptor.value = async function (params: P) {
|
||||||
|
const data = await getStorage();
|
||||||
|
|
||||||
|
await originalMethod.apply(this, [params]);
|
||||||
|
if (data) {
|
||||||
|
void fetchData(params);
|
||||||
|
return data;
|
||||||
|
} else {
|
||||||
|
return fetchData(params);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function afterSignInDecorator(successCallback: () => Promise<void>) {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
|
const originalMethod = descriptor.value;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
descriptor.value = async function (...args: any[]) {
|
||||||
|
await originalMethod.apply(this, args);
|
||||||
|
await successCallback();
|
||||||
|
};
|
||||||
|
|
||||||
|
return descriptor;
|
||||||
|
};
|
||||||
|
}
|
@ -1,17 +1,46 @@
|
|||||||
|
import { YDoc } from '@/application/document.type';
|
||||||
|
import { getDocumentStorage } from '@/application/services/js-services/storage/document';
|
||||||
import { DocumentService } from '@/application/services/services.type';
|
import { DocumentService } from '@/application/services/services.type';
|
||||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
import { APIService } from 'src/application/services/js-services/wasm';
|
||||||
import { CollabType } from '@/application/services/js-services/http/http.type';
|
import { CollabOrigin, CollabType } from '@/application/collab.type';
|
||||||
|
import { applyDocument } from 'src/application/ydoc/apply';
|
||||||
|
|
||||||
export class JSDocumentService implements DocumentService {
|
export class JSDocumentService implements DocumentService {
|
||||||
constructor(private httpClient: HttpClient) {}
|
constructor() {
|
||||||
|
//
|
||||||
|
}
|
||||||
|
|
||||||
async openDocument(docID: string): Promise<void> {
|
fetchDocument(workspaceId: string, docId: string) {
|
||||||
const workspaceId = '9eebea03-3ed5-4298-86b2-a7f77856d48b';
|
return APIService.getCollab(workspaceId, docId, CollabType.Document);
|
||||||
const docId = '26d5c8c1-1c66-459c-bc6c-f4da1a663348';
|
}
|
||||||
const data = await this.httpClient.getObject(workspaceId, docId, CollabType.Document);
|
|
||||||
|
|
||||||
console.log(docID, data);
|
async openDocument(workspaceId: string, docId: string): Promise<YDoc> {
|
||||||
|
const { doc, localExist } = await getDocumentStorage(docId);
|
||||||
|
const asyncApply = async () => {
|
||||||
|
const res = await this.fetchDocument(workspaceId, docId);
|
||||||
|
|
||||||
return;
|
applyDocument(doc, res.state);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the document exists locally, apply the state asynchronously,
|
||||||
|
// otherwise, apply the state synchronously
|
||||||
|
if (localExist) {
|
||||||
|
void asyncApply();
|
||||||
|
} else {
|
||||||
|
await asyncApply();
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUpdate = (update: Uint8Array, origin: CollabOrigin) => {
|
||||||
|
if (origin === CollabOrigin.Remote) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the update to the server
|
||||||
|
console.log('update', update);
|
||||||
|
};
|
||||||
|
|
||||||
|
doc.on('update', handleUpdate);
|
||||||
|
|
||||||
|
return doc;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,86 +0,0 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
|
||||||
import { UserProfile, Workspace } from '@/application/services/user.type';
|
|
||||||
import {
|
|
||||||
CollabType,
|
|
||||||
EncodedCollab,
|
|
||||||
UserProfilePB,
|
|
||||||
WorkspacePB,
|
|
||||||
} from '@/application/services/js-services/http/http.type';
|
|
||||||
import {
|
|
||||||
parseUserPBToUserProfile,
|
|
||||||
getAxiosInstances,
|
|
||||||
parseWorkspacePBToWorkspace,
|
|
||||||
} from '@/application/services/js-services/http/utils';
|
|
||||||
import {
|
|
||||||
ACCESS_TOKEN_NAME,
|
|
||||||
baseHttpUrls,
|
|
||||||
gotrueHttpUrls,
|
|
||||||
REFRESH_TOKEN_NAME,
|
|
||||||
URL_NAME,
|
|
||||||
} from '@/application/services/js-services/http/const';
|
|
||||||
|
|
||||||
export class HttpClient {
|
|
||||||
private gotrueAPI: AxiosInstance;
|
|
||||||
private baseAPI: AxiosInstance;
|
|
||||||
|
|
||||||
constructor(private config: { baseURL: string; gotrueURL: string }) {
|
|
||||||
const { baseInstance, gotrueInstance } = getAxiosInstances(config.baseURL, config.gotrueURL);
|
|
||||||
|
|
||||||
this.gotrueAPI = gotrueInstance;
|
|
||||||
this.baseAPI = baseInstance;
|
|
||||||
}
|
|
||||||
|
|
||||||
async signInWithEmailPassword(email: string, password: string): Promise<UserProfile> {
|
|
||||||
const { data } = await this.gotrueAPI.post<{
|
|
||||||
access_token: string;
|
|
||||||
refresh_token: string;
|
|
||||||
}>(gotrueHttpUrls[URL_NAME.SIGN_IN_WITH_EMAIL], {
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionStorage.setItem(ACCESS_TOKEN_NAME, data.access_token);
|
|
||||||
sessionStorage.setItem(REFRESH_TOKEN_NAME, data.refresh_token);
|
|
||||||
|
|
||||||
return this.getUser();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getUser(): Promise<UserProfile> {
|
|
||||||
const { data } = await this.gotrueAPI.get<UserProfilePB>(gotrueHttpUrls[URL_NAME.GET_USER]);
|
|
||||||
|
|
||||||
return parseUserPBToUserProfile(data);
|
|
||||||
}
|
|
||||||
|
|
||||||
async logout() {
|
|
||||||
await this.gotrueAPI.post(gotrueHttpUrls[URL_NAME.LOGOUT]);
|
|
||||||
sessionStorage.removeItem(REFRESH_TOKEN_NAME);
|
|
||||||
sessionStorage.removeItem(ACCESS_TOKEN_NAME);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getWorkspaces(): Promise<Workspace[]> {
|
|
||||||
const { data } = await this.baseAPI.get<WorkspacePB[]>(baseHttpUrls[URL_NAME.GET_WORKSPACES]);
|
|
||||||
|
|
||||||
return data.map(parseWorkspacePBToWorkspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get object(document/database/view) from workspace
|
|
||||||
* @param workspaceId - workspace id
|
|
||||||
* @param objectId - document id or database id or view id
|
|
||||||
* @param objectType - type of object [CollabType]
|
|
||||||
*/
|
|
||||||
async getObject(workspaceId: string, objectId: string, objectType: CollabType): Promise<EncodedCollab> {
|
|
||||||
// const workspaces = await this.getWorkspaces();
|
|
||||||
//
|
|
||||||
// console.log(workspaces);
|
|
||||||
const { data } = await this.baseAPI.get<EncodedCollab>(baseHttpUrls[URL_NAME.GET_OBJECT](workspaceId, objectId), {
|
|
||||||
data: JSON.stringify({
|
|
||||||
workspace_id: workspaceId,
|
|
||||||
object_id: objectId,
|
|
||||||
collab_type: objectType,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,26 +0,0 @@
|
|||||||
export enum URL_NAME {
|
|
||||||
SIGN_IN_WITH_EMAIL,
|
|
||||||
GET_USER,
|
|
||||||
LOGOUT,
|
|
||||||
REFRESH_TOKEN,
|
|
||||||
GET_WORKSPACES,
|
|
||||||
GET_OBJECT,
|
|
||||||
}
|
|
||||||
|
|
||||||
export const gotrueHttpUrls = {
|
|
||||||
[URL_NAME.SIGN_IN_WITH_EMAIL]: '/token?grant_type=password',
|
|
||||||
[URL_NAME.GET_USER]: '/user',
|
|
||||||
[URL_NAME.LOGOUT]: '/logout',
|
|
||||||
[URL_NAME.REFRESH_TOKEN]: '/token?grant_type=refresh_token',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const baseHttpUrls = {
|
|
||||||
[URL_NAME.GET_WORKSPACES]: '/api/workspace',
|
|
||||||
[URL_NAME.GET_OBJECT]: (workspaceId: string, objectId: string) => `/api/workspace/${workspaceId}/collab/${objectId}`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ACCESS_TOKEN_NAME = 'access_token';
|
|
||||||
export const REFRESH_TOKEN_NAME = 'refresh_token';
|
|
||||||
export const TOKEN_TYPE_NAME = 'token_type';
|
|
||||||
|
|
||||||
export const AUTHORIZATION_NAME = 'Authorization';
|
|
@ -1,40 +0,0 @@
|
|||||||
export interface UserProfilePB {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
email: string;
|
|
||||||
user_metadata: {
|
|
||||||
avatar_url: string;
|
|
||||||
full_name: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkspacePB {
|
|
||||||
workspace_id: string;
|
|
||||||
database_storage_id: string;
|
|
||||||
owner_uid: number;
|
|
||||||
owner_name: string;
|
|
||||||
workspace_type: number;
|
|
||||||
workspace_name: string;
|
|
||||||
created_at: string;
|
|
||||||
icon: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum EncoderVersion {
|
|
||||||
V1 = 0,
|
|
||||||
V2 = 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum CollabType {
|
|
||||||
Document = 0,
|
|
||||||
Database = 1,
|
|
||||||
WorkspaceDatabase = 2,
|
|
||||||
Folder = 3,
|
|
||||||
DatabaseRow = 4,
|
|
||||||
UserAwareness = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EncodedCollab {
|
|
||||||
state_vector: Uint8Array;
|
|
||||||
doc_state: Uint8Array;
|
|
||||||
version: EncoderVersion;
|
|
||||||
}
|
|
@ -1,110 +0,0 @@
|
|||||||
import { UserProfilePB, WorkspacePB } from '@/application/services/js-services/http/http.type';
|
|
||||||
import { Authenticator, UserProfile, Workspace } from '@/application/services/user.type';
|
|
||||||
import axios, { AxiosInstance, InternalAxiosRequestConfig, AxiosResponse, AxiosRequestConfig } from 'axios';
|
|
||||||
import {
|
|
||||||
ACCESS_TOKEN_NAME,
|
|
||||||
AUTHORIZATION_NAME,
|
|
||||||
gotrueHttpUrls,
|
|
||||||
REFRESH_TOKEN_NAME,
|
|
||||||
TOKEN_TYPE_NAME,
|
|
||||||
URL_NAME,
|
|
||||||
} from '@/application/services/js-services/http/const';
|
|
||||||
|
|
||||||
async function refreshToken(instance: AxiosInstance) {
|
|
||||||
const refreshToken = sessionStorage.getItem(REFRESH_TOKEN_NAME);
|
|
||||||
|
|
||||||
if (!refreshToken) {
|
|
||||||
throw new Error('Refresh token not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data } = await instance.post(gotrueHttpUrls[URL_NAME.REFRESH_TOKEN], {
|
|
||||||
refresh_token: refreshToken,
|
|
||||||
});
|
|
||||||
|
|
||||||
sessionStorage.setItem(ACCESS_TOKEN_NAME, data.access_token);
|
|
||||||
sessionStorage.setItem(REFRESH_TOKEN_NAME, data.refresh_token);
|
|
||||||
|
|
||||||
return data.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getAxiosInstances(baseURL: string, gotrueURL: string) {
|
|
||||||
const gotrueInstance = axios.create({
|
|
||||||
baseURL: gotrueURL,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: '*/*',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const baseInstance = axios.create({
|
|
||||||
baseURL,
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
Accept: '*/*',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const requestInterceptor = async (config: InternalAxiosRequestConfig) => {
|
|
||||||
const accessToken = sessionStorage.getItem(ACCESS_TOKEN_NAME);
|
|
||||||
const tokenType = sessionStorage.getItem(TOKEN_TYPE_NAME) || 'Bearer';
|
|
||||||
|
|
||||||
if (accessToken) {
|
|
||||||
config.headers[AUTHORIZATION_NAME] = `${tokenType} ${accessToken}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
const errorInterceptor = async (error: {
|
|
||||||
response?: AxiosResponse;
|
|
||||||
config: AxiosRequestConfig;
|
|
||||||
}) => {
|
|
||||||
if (error.response?.status === 401 && !error.config.url?.includes(gotrueHttpUrls[URL_NAME.LOGOUT])) {
|
|
||||||
try {
|
|
||||||
const tokenType = sessionStorage.getItem(TOKEN_TYPE_NAME) || 'Bearer';
|
|
||||||
const accessToken = await refreshToken(gotrueInstance);
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
...error.config,
|
|
||||||
[AUTHORIZATION_NAME]: `${tokenType} ${accessToken}`,
|
|
||||||
}
|
|
||||||
|
|
||||||
return gotrueInstance.request(config);
|
|
||||||
} catch (e) {
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Promise.reject(error);
|
|
||||||
};
|
|
||||||
|
|
||||||
gotrueInstance.interceptors.request.use(requestInterceptor);
|
|
||||||
gotrueInstance.interceptors.response.use((response) => response, errorInterceptor);
|
|
||||||
|
|
||||||
baseInstance.interceptors.request.use(requestInterceptor);
|
|
||||||
baseInstance.interceptors.response.use((response) => response, errorInterceptor);
|
|
||||||
return {
|
|
||||||
baseInstance,
|
|
||||||
gotrueInstance,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseUserPBToUserProfile(userPB: UserProfilePB): UserProfile {
|
|
||||||
return {
|
|
||||||
id: userPB.id,
|
|
||||||
email: userPB.email,
|
|
||||||
authenticator: Authenticator.AppFlowyCloud,
|
|
||||||
iconUrl: userPB.user_metadata.avatar_url,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWorkspacePBToWorkspace(workspacePB: WorkspacePB): Workspace {
|
|
||||||
return {
|
|
||||||
id: workspacePB.workspace_id,
|
|
||||||
name: workspacePB.workspace_name,
|
|
||||||
icon: workspacePB.icon,
|
|
||||||
owner: {
|
|
||||||
id: workspacePB.owner_uid,
|
|
||||||
name: workspacePB.owner_name,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
@ -7,19 +7,21 @@ import {
|
|||||||
} from '@/application/services/services.type';
|
} from '@/application/services/services.type';
|
||||||
import { JSUserService } from '@/application/services/js-services/user.service';
|
import { JSUserService } from '@/application/services/js-services/user.service';
|
||||||
import { JSAuthService } from '@/application/services/js-services/auth.service';
|
import { JSAuthService } from '@/application/services/js-services/auth.service';
|
||||||
import { AFWasmService } from '@/application/services/wasm-services';
|
|
||||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
|
||||||
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
|
import { initAPIService } from '@/application/services/js-services/wasm/client_api';
|
||||||
|
|
||||||
export class AFClientService implements AFService {
|
export class AFClientService implements AFService {
|
||||||
authService: AuthService;
|
authService: AuthService;
|
||||||
|
|
||||||
userService: UserService;
|
userService: UserService;
|
||||||
wasmService: AFWasmService;
|
|
||||||
httpClient: HttpClient;
|
|
||||||
documentService: DocumentService;
|
documentService: DocumentService;
|
||||||
|
|
||||||
private deviceId: string = nanoid(8);
|
private deviceId: string = nanoid(8);
|
||||||
|
|
||||||
private clientId: string = 'web';
|
private clientId: string = 'web';
|
||||||
|
|
||||||
getDeviceID = (): string => {
|
getDeviceID = (): string => {
|
||||||
return this.deviceId;
|
return this.deviceId;
|
||||||
};
|
};
|
||||||
@ -28,21 +30,15 @@ export class AFClientService implements AFService {
|
|||||||
return this.clientId;
|
return this.clientId;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(private config: AFServiceConfig) {
|
constructor(config: AFServiceConfig) {
|
||||||
this.wasmService = new AFWasmService(config, {
|
initAPIService({
|
||||||
|
...config.cloudConfig,
|
||||||
deviceId: this.deviceId,
|
deviceId: this.deviceId,
|
||||||
clientId: this.clientId,
|
clientId: this.clientId,
|
||||||
});
|
});
|
||||||
this.httpClient = new HttpClient({
|
|
||||||
baseURL: config.cloudConfig.baseURL,
|
|
||||||
gotrueURL: config.cloudConfig.gotrueURL,
|
|
||||||
});
|
|
||||||
this.authService = new JSAuthService(this.httpClient, this.wasmService);
|
|
||||||
this.userService = new JSUserService(this.httpClient);
|
|
||||||
this.documentService = new JSDocumentService(this.httpClient);
|
|
||||||
}
|
|
||||||
|
|
||||||
async load() {
|
this.authService = new JSAuthService();
|
||||||
await this.wasmService.load();
|
this.userService = new JSUserService();
|
||||||
|
this.documentService = new JSDocumentService();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,11 @@
|
|||||||
|
import { getAuthInfo } from '@/application/services/js-services/storage/token';
|
||||||
|
import { openDB } from '@/application/services/js-services/db';
|
||||||
|
|
||||||
|
export async function signInSuccess() {
|
||||||
|
const authInfo = getAuthInfo();
|
||||||
|
|
||||||
|
if (authInfo) {
|
||||||
|
// Open the database
|
||||||
|
openDB(authInfo.uuid);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
import { YjsEditorKey } from '@/application/document.type';
|
||||||
|
import { openCollabDB } from '@/application/services/js-services/db';
|
||||||
|
import { getAuthInfo } from '@/application/services/js-services/storage/token';
|
||||||
|
|
||||||
|
export async function getDocumentStorage(docId: string) {
|
||||||
|
const docName = getDocName(docId);
|
||||||
|
const doc = await openCollabDB(docName);
|
||||||
|
const localExist = doc.share.has(YjsEditorKey.data_section);
|
||||||
|
|
||||||
|
return {
|
||||||
|
doc,
|
||||||
|
localExist,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDocName(docId: string) {
|
||||||
|
const { uuid } = getAuthInfo() || {};
|
||||||
|
|
||||||
|
if (!uuid) throw new Error('No user found');
|
||||||
|
return `${uuid}_document_${docId}`;
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export * from './token';
|
||||||
|
export * from './user';
|
@ -0,0 +1,36 @@
|
|||||||
|
const tokenKey = 'token';
|
||||||
|
|
||||||
|
export function readTokenStr () {
|
||||||
|
return sessionStorage.getItem(tokenKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthInfo () {
|
||||||
|
const token = readTokenStr() || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = JSON.parse(token);
|
||||||
|
|
||||||
|
return {
|
||||||
|
uuid: info.user.id,
|
||||||
|
access_token: info.access_token,
|
||||||
|
email: info.user.email,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeToken (token: string) {
|
||||||
|
if (!token) {
|
||||||
|
invalidToken();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionStorage.setItem(tokenKey, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidToken () {
|
||||||
|
sessionStorage.removeItem(tokenKey);
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
|||||||
|
import { UserProfile } from '@/application/user.type';
|
||||||
|
import { getDB } from '@/application/services/js-services/db';
|
||||||
|
import { getAuthInfo } from '@/application/services/js-services/storage/token';
|
||||||
|
|
||||||
|
const primaryKeyName = 'uid';
|
||||||
|
|
||||||
|
export async function getSignInUser(): Promise<UserProfile | undefined> {
|
||||||
|
const db = getDB();
|
||||||
|
const authInfo = getAuthInfo();
|
||||||
|
|
||||||
|
return db?.users.get(authInfo?.uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setSignInUser(profile: UserProfile) {
|
||||||
|
const db = getDB();
|
||||||
|
|
||||||
|
return db?.users.put(profile, primaryKeyName);
|
||||||
|
}
|
@ -1,11 +1,33 @@
|
|||||||
import { UserService } from '@/application/services/services.type';
|
import { UserService } from '@/application/services/services.type';
|
||||||
import { UserProfile } from '@/application/services/user.type';
|
import { UserProfile } from '@/application/user.type';
|
||||||
import { HttpClient } from '@/application/services/js-services/http/client';
|
import { notify } from '@/components/_shared/notify';
|
||||||
|
import { APIService } from 'src/application/services/js-services/wasm';
|
||||||
|
import { getAuthInfo, getSignInUser, setSignInUser } from '@/application/services/js-services/storage';
|
||||||
|
import { asyncDataDecorator } from '@/application/services/js-services/decorator';
|
||||||
|
|
||||||
export class JSUserService implements UserService {
|
async function getUser() {
|
||||||
constructor(private httpClient: HttpClient) {}
|
try {
|
||||||
|
const user = await APIService.getUser();
|
||||||
|
|
||||||
async getUserProfile(): Promise<UserProfile> {
|
return user;
|
||||||
return this.httpClient.getUser();
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
notify.error('Failed to get user profile, please try refreshing the page');
|
||||||
|
// invalidToken();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JSUserService implements UserService {
|
||||||
|
@asyncDataDecorator<void, UserProfile>(getSignInUser, setSignInUser, getUser)
|
||||||
|
async getUserProfile(): Promise<UserProfile> {
|
||||||
|
if (!getAuthInfo()) {
|
||||||
|
return Promise.reject('Not authenticated');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null!;
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkUser(): Promise<boolean> {
|
||||||
|
return (await getSignInUser()) !== undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,77 @@
|
|||||||
|
import { ClientAPI } from '@appflowyinc/client-api-wasm';
|
||||||
|
import { UserProfile } from '@/application/user.type';
|
||||||
|
import { AFCloudConfig } from '@/application/services/services.type';
|
||||||
|
import { invalidToken, readTokenStr, writeToken } from '@/application/services/js-services/storage';
|
||||||
|
import { CollabType } from '@/application/collab.type';
|
||||||
|
|
||||||
|
let client: ClientAPI;
|
||||||
|
|
||||||
|
export function initAPIService (config: AFCloudConfig & {
|
||||||
|
deviceId: string;
|
||||||
|
clientId: string;
|
||||||
|
}) {
|
||||||
|
window.refresh_token = writeToken;
|
||||||
|
window.invalid_token = invalidToken;
|
||||||
|
client = ClientAPI.new({
|
||||||
|
base_url: config.baseURL,
|
||||||
|
ws_addr: config.wsURL,
|
||||||
|
gotrue_url: config.gotrueURL,
|
||||||
|
device_id: config.deviceId,
|
||||||
|
client_id: config.clientId,
|
||||||
|
configuration: {
|
||||||
|
compression_quality: 8,
|
||||||
|
compression_buffer_size: 10240,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = readTokenStr();
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
client.restore_token(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
client.subscribe();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function signIn (email: string, password: string) {
|
||||||
|
return client.login(email, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout () {
|
||||||
|
return client.logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUser (): Promise<UserProfile> {
|
||||||
|
try {
|
||||||
|
const user = await client.get_user();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new Error('No user found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
uid: parseInt(user.uid),
|
||||||
|
uuid: user.uuid || undefined,
|
||||||
|
email: user.email || undefined,
|
||||||
|
name: user.name || undefined,
|
||||||
|
workspaceId: user.latest_workspace_id,
|
||||||
|
iconUrl: user.icon_url || undefined,
|
||||||
|
};
|
||||||
|
} catch (e) {
|
||||||
|
return Promise.reject(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCollab (workspaceId: string, object_id: string, collabType: CollabType) {
|
||||||
|
const res = await client.get_collab({
|
||||||
|
workspace_id: workspaceId,
|
||||||
|
object_id: object_id,
|
||||||
|
collab_type: Number(collabType) as 0 | 1 | 2 | 3 | 4 | 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = new Uint8Array(res.doc_state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export * as APIService from './client_api';
|
@ -1,4 +1,5 @@
|
|||||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/services/user.type';
|
import { YDoc } from '@/application/document.type';
|
||||||
|
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||||
|
|
||||||
export interface AFService {
|
export interface AFService {
|
||||||
getDeviceID: () => string;
|
getDeviceID: () => string;
|
||||||
@ -6,7 +7,6 @@ export interface AFService {
|
|||||||
authService: AuthService;
|
authService: AuthService;
|
||||||
userService: UserService;
|
userService: UserService;
|
||||||
documentService: DocumentService;
|
documentService: DocumentService;
|
||||||
load: () => Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AFServiceConfig {
|
export interface AFServiceConfig {
|
||||||
@ -20,18 +20,18 @@ export interface AFCloudConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthService {
|
export interface AuthService {
|
||||||
|
|
||||||
getOAuthURL: (provider: ProviderType) => Promise<string>;
|
getOAuthURL: (provider: ProviderType) => Promise<string>;
|
||||||
signInWithOAuth: (params: { uri: string }) => Promise<UserProfile>;
|
signInWithOAuth: (params: { uri: string }) => Promise<void>;
|
||||||
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<UserProfile>;
|
signupWithEmailPassword: (params: SignUpWithEmailPasswordParams) => Promise<void>;
|
||||||
signinWithEmailPassword: (email: string, password: string) => Promise<UserProfile>;
|
signinWithEmailPassword: (email: string, password: string) => Promise<void>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DocumentService {
|
export interface DocumentService {
|
||||||
openDocument: (docID: string) => Promise<void>;
|
openDocument: (workspaceId: string, docId: string) => Promise<YDoc>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserService {
|
export interface UserService {
|
||||||
getUserProfile: () => Promise<UserProfile | null>;
|
getUserProfile: () => Promise<UserProfile | null>;
|
||||||
|
checkUser: () => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
@ -12,7 +12,7 @@ import {
|
|||||||
UserEventSignUp,
|
UserEventSignUp,
|
||||||
UserProfilePB,
|
UserProfilePB,
|
||||||
} from './backend/events/flowy-user';
|
} from './backend/events/flowy-user';
|
||||||
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/services/user.type';
|
import { ProviderType, SignUpWithEmailPasswordParams, UserProfile } from '@/application/user.type';
|
||||||
|
|
||||||
export class TauriAuthService implements AuthService {
|
export class TauriAuthService implements AuthService {
|
||||||
|
|
||||||
@ -41,7 +41,7 @@ export class TauriAuthService implements AuthService {
|
|||||||
return providerData.oauth_url;
|
return providerData.oauth_url;
|
||||||
};
|
};
|
||||||
|
|
||||||
signInWithOAuth = async ({ uri }: { uri: string }): Promise<UserProfile> => {
|
signInWithOAuth = async ({ uri }: { uri: string }): Promise<void> => {
|
||||||
const payload = OauthSignInPB.fromObject({
|
const payload = OauthSignInPB.fromObject({
|
||||||
authenticator: AuthenticatorPB.AppFlowyCloud,
|
authenticator: AuthenticatorPB.AppFlowyCloud,
|
||||||
map: {
|
map: {
|
||||||
@ -56,9 +56,9 @@ export class TauriAuthService implements AuthService {
|
|||||||
throw new Error(res.val.msg);
|
throw new Error(res.val.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseUserProfileFrom(res.val);
|
return;
|
||||||
};
|
};
|
||||||
signinWithEmailPassword = async (email: string, password: string): Promise<UserProfile> => {
|
signinWithEmailPassword = async (email: string, password: string): Promise<void> => {
|
||||||
const payload = SignInPayloadPB.fromObject({
|
const payload = SignInPayloadPB.fromObject({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
@ -70,10 +70,10 @@ export class TauriAuthService implements AuthService {
|
|||||||
return Promise.reject(res.val.msg);
|
return Promise.reject(res.val.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseUserProfileFrom(res.val);
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<UserProfile> => {
|
signupWithEmailPassword = async (params: SignUpWithEmailPasswordParams): Promise<void> => {
|
||||||
const payload = SignUpPayloadPB.fromObject({
|
const payload = SignUpPayloadPB.fromObject({
|
||||||
name: params.name,
|
name: params.name,
|
||||||
email: params.email,
|
email: params.email,
|
||||||
@ -84,11 +84,10 @@ export class TauriAuthService implements AuthService {
|
|||||||
const res = await UserEventSignUp(payload);
|
const res = await UserEventSignUp(payload);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
console.error(res.val.msg);
|
|
||||||
return Promise.reject(res.val.msg);
|
return Promise.reject(res.val.msg);
|
||||||
}
|
}
|
||||||
|
|
||||||
return parseUserProfileFrom(res.val);
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
signOut = async () => {
|
signOut = async () => {
|
||||||
@ -106,16 +105,10 @@ export function parseUserProfileFrom (userPB: UserProfilePB): UserProfile {
|
|||||||
const user = userPB.toObject();
|
const user = userPB.toObject();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: String(user.id),
|
uid: user.id as number,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
name: user.name,
|
name: user.name,
|
||||||
token: user.token,
|
|
||||||
iconUrl: user.icon_url,
|
iconUrl: user.icon_url,
|
||||||
openaiKey: user.openai_key,
|
|
||||||
authenticator: user.authenticator as number,
|
|
||||||
encryptionSign: user.encryption_sign,
|
|
||||||
encryptionType: user.encryption_type as number,
|
|
||||||
workspaceId: user.workspace_id,
|
workspaceId: user.workspace_id,
|
||||||
stabilityAiKey: user.stability_ai_key,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,68 +1,8 @@
|
|||||||
import { DocumentService } from '@/application/services/services.type';
|
import { DocumentService } from '@/application/services/services.type';
|
||||||
import { OpenDocumentPayloadPB } from './backend';
|
import Y from 'yjs';
|
||||||
import { DocumentEventOpenDocument } from './backend/events/flowy-document';
|
|
||||||
|
|
||||||
export class TauriDocumentService implements DocumentService {
|
export class TauriDocumentService implements DocumentService {
|
||||||
async openDocument(docId: string): Promise<void> {
|
async openDocument(_id: string): Promise<Y.Doc> {
|
||||||
const payload = OpenDocumentPayloadPB.fromObject({
|
return Promise.reject('Not implemented');
|
||||||
document_id: docId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await DocumentEventOpenDocument(payload);
|
|
||||||
|
|
||||||
if (!result.ok) {
|
|
||||||
return Promise.reject(result.val);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
// const documentDataPB = result.val;
|
|
||||||
//
|
|
||||||
// if (!documentDataPB) {
|
|
||||||
// return Promise.reject('documentDataPB is null');
|
|
||||||
// }
|
|
||||||
//
|
|
||||||
// const data: {
|
|
||||||
// viewId: string;
|
|
||||||
// rootId: string;
|
|
||||||
// nodeMap: Record<string, any>;
|
|
||||||
// childrenMap: Record<string, string[]>;
|
|
||||||
// relativeMap: Record<string, string>;
|
|
||||||
// deltaMap: Record<string, Op[]>;
|
|
||||||
// externalIdMap: Record<string, string>;
|
|
||||||
// } = {
|
|
||||||
// viewId: docId,
|
|
||||||
// rootId: documentDataPB.page_id,
|
|
||||||
// nodeMap: {},
|
|
||||||
// childrenMap: {},
|
|
||||||
// relativeMap: {},
|
|
||||||
// deltaMap: {},
|
|
||||||
// externalIdMap: {},
|
|
||||||
// };
|
|
||||||
//
|
|
||||||
// get(documentDataPB, BLOCK_MAP_NAME).forEach((block) => {
|
|
||||||
// Object.assign(data.nodeMap, {
|
|
||||||
// [block.id]: blockPB2Node(block),
|
|
||||||
// });
|
|
||||||
// data.relativeMap[block.children_id] = block.id;
|
|
||||||
// if (block.external_id) {
|
|
||||||
// data.externalIdMap[block.external_id] = block.id;
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// get(documentDataPB, [META_NAME, CHILDREN_MAP_NAME]).forEach((child, key) => {
|
|
||||||
// const blockId = data.relativeMap[key];
|
|
||||||
//
|
|
||||||
// data.childrenMap[blockId] = child.children;
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// get(documentDataPB, [META_NAME, TEXT_MAP_NAME]).forEach((delta, key) => {
|
|
||||||
// const blockId = data.externalIdMap[key];
|
|
||||||
//
|
|
||||||
// data.deltaMap[blockId] = delta ? JSON.parse(delta) : [];
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// // return data;
|
|
||||||
// return;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -24,7 +24,7 @@ export class AFClientService implements AFService {
|
|||||||
return this.clientId;
|
return this.clientId;
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(config: AFServiceConfig) {
|
constructor (config: AFServiceConfig) {
|
||||||
this.authService = new TauriAuthService(config.cloudConfig, {
|
this.authService = new TauriAuthService(config.cloudConfig, {
|
||||||
deviceId: this.deviceId,
|
deviceId: this.deviceId,
|
||||||
clientId: this.clientId,
|
clientId: this.clientId,
|
||||||
@ -32,8 +32,4 @@ export class AFClientService implements AFService {
|
|||||||
this.userService = new TauriUserService();
|
this.userService = new TauriUserService();
|
||||||
this.documentService = new TauriDocumentService();
|
this.documentService = new TauriDocumentService();
|
||||||
}
|
}
|
||||||
|
|
||||||
async load() {
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import { UserService } from '@/application/services/services.type';
|
import { UserService } from '@/application/services/services.type';
|
||||||
import { UserProfile } from '@/application/services/user.type';
|
import { UserProfile } from '@/application/user.type';
|
||||||
import { UserEventGetUserProfile } from './backend/events/flowy-user';
|
import { UserEventGetUserProfile } from './backend/events/flowy-user';
|
||||||
import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service';
|
import { parseUserProfileFrom } from '@/application/services/tauri-services/auth.service';
|
||||||
|
|
||||||
export class TauriUserService implements UserService {
|
export class TauriUserService implements UserService {
|
||||||
async getUserProfile (): Promise<UserProfile | null> {
|
async getUserProfile(): Promise<UserProfile | null> {
|
||||||
const res = await UserEventGetUserProfile();
|
const res = await UserEventGetUserProfile();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -13,4 +13,8 @@ export class TauriUserService implements UserService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
async checkUser(): Promise<boolean> {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -1,36 +0,0 @@
|
|||||||
import { CloudServiceConfig } from '@/application/services/wasm-services/cloud.type';
|
|
||||||
|
|
||||||
// import { ClientAPI } from '@appflowyinc/client-api-wasm';
|
|
||||||
|
|
||||||
export class CloudService {
|
|
||||||
// private client?: ClientAPI;
|
|
||||||
|
|
||||||
constructor (private config: CloudServiceConfig) {
|
|
||||||
// Do nothing
|
|
||||||
}
|
|
||||||
|
|
||||||
async init () {
|
|
||||||
// this.client = ClientAPI.new({
|
|
||||||
// base_url: this.config.baseURL,
|
|
||||||
// ws_addr: this.config.wsURL,
|
|
||||||
// gotrue_url: this.config.gotrueURL,
|
|
||||||
// device_id: this.config.deviceId,
|
|
||||||
// client_id: this.config.clientId,
|
|
||||||
// configuration: {
|
|
||||||
// compression_quality: 8,
|
|
||||||
// compression_buffer_size: 10240,
|
|
||||||
// },
|
|
||||||
// });
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
// async signIn (email: string, password: string) {
|
|
||||||
// try {
|
|
||||||
// const res = await this.client?.sign_in_password(email, password);
|
|
||||||
//
|
|
||||||
// console.log(res);
|
|
||||||
// } catch (error) {
|
|
||||||
// console.error(error);
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
}
|
|
@ -1,7 +0,0 @@
|
|||||||
import { AFCloudConfig } from '@/application/services/services.type';
|
|
||||||
|
|
||||||
export type CloudServiceEventPayload = Record<string, string>;
|
|
||||||
export type CloudServiceConfig = AFCloudConfig & {
|
|
||||||
deviceId: string;
|
|
||||||
clientId: string;
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
import { AFServiceConfig } from '@/application/services/services.type';
|
|
||||||
import { CloudService } from '@/application/services/wasm-services/cloud.service';
|
|
||||||
|
|
||||||
export class AFWasmService {
|
|
||||||
cloudService: CloudService;
|
|
||||||
|
|
||||||
constructor (private config: AFServiceConfig, clientConfig: {
|
|
||||||
deviceId: string;
|
|
||||||
clientId: string;
|
|
||||||
}) {
|
|
||||||
this.cloudService = new CloudService({
|
|
||||||
...config.cloudConfig,
|
|
||||||
...clientConfig,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async load () {
|
|
||||||
await this.cloudService.init();
|
|
||||||
}
|
|
||||||
}
|
|
@ -0,0 +1 @@
|
|||||||
|
export * from './plugins/withYjs';
|
@ -0,0 +1,147 @@
|
|||||||
|
import { YjsEditorKey, YSharedRoot } from '@/application/document.type';
|
||||||
|
import { applySlateOp } from '@/application/slate-yjs/utils/applySlateOpts';
|
||||||
|
import { translateYjsEvent } from 'src/application/slate-yjs/utils/translateYjsEvent';
|
||||||
|
import { Editor, Operation, Descendant } from 'slate';
|
||||||
|
import Y, { YEvent, Transaction } from 'yjs';
|
||||||
|
import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert';
|
||||||
|
import { CollabOrigin } from '@/application/collab.type';
|
||||||
|
|
||||||
|
type LocalChange = {
|
||||||
|
op: Operation;
|
||||||
|
slateContent: Descendant[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface YjsEditor extends Editor {
|
||||||
|
connect: () => void;
|
||||||
|
disconnect: () => void;
|
||||||
|
sharedRoot: YSharedRoot;
|
||||||
|
applyRemoteEvents: (events: Array<YEvent<YSharedRoot>>, transaction: Transaction) => void;
|
||||||
|
flushLocalChanges: () => void;
|
||||||
|
storeLocalChange: (op: Operation) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectSet = new WeakSet<YjsEditor>();
|
||||||
|
|
||||||
|
const localChanges = new WeakMap<YjsEditor, LocalChange[]>();
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-redeclare
|
||||||
|
export const YjsEditor = {
|
||||||
|
connected(editor: YjsEditor): boolean {
|
||||||
|
return connectSet.has(editor);
|
||||||
|
},
|
||||||
|
|
||||||
|
connect(editor: YjsEditor): void {
|
||||||
|
editor.connect();
|
||||||
|
},
|
||||||
|
|
||||||
|
disconnect(editor: YjsEditor): void {
|
||||||
|
editor.disconnect();
|
||||||
|
},
|
||||||
|
|
||||||
|
applyRemoteEvents(editor: YjsEditor, events: Array<YEvent<YSharedRoot>>, transaction: Transaction): void {
|
||||||
|
editor.applyRemoteEvents(events, transaction);
|
||||||
|
},
|
||||||
|
|
||||||
|
localChanges(editor: YjsEditor): LocalChange[] {
|
||||||
|
return localChanges.get(editor) ?? [];
|
||||||
|
},
|
||||||
|
|
||||||
|
storeLocalChange(editor: YjsEditor, op: Operation): void {
|
||||||
|
editor.storeLocalChange(op);
|
||||||
|
},
|
||||||
|
|
||||||
|
flushLocalChanges(editor: YjsEditor): void {
|
||||||
|
editor.flushLocalChanges();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function withYjs<T extends Editor>(editor: T, doc: Y.Doc): T & YjsEditor {
|
||||||
|
const e = editor as T & YjsEditor;
|
||||||
|
const { apply, onChange } = e;
|
||||||
|
|
||||||
|
e.sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||||
|
e.applyRemoteEvents = (events: Array<YEvent<YSharedRoot>>, _: Transaction) => {
|
||||||
|
YjsEditor.flushLocalChanges(e);
|
||||||
|
|
||||||
|
Editor.withoutNormalizing(editor, () => {
|
||||||
|
events.forEach((event) => {
|
||||||
|
translateYjsEvent(e.sharedRoot, editor, event).forEach((op) => {
|
||||||
|
// apply remote events to slate, don't call e.apply here because e.apply has been overridden.
|
||||||
|
apply(op);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleYEvents = (events: Array<YEvent<YSharedRoot>>, transaction: Transaction) => {
|
||||||
|
if (transaction.origin === CollabOrigin.Local) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
YjsEditor.applyRemoteEvents(e, events, transaction);
|
||||||
|
};
|
||||||
|
|
||||||
|
e.connect = () => {
|
||||||
|
if (YjsEditor.connected(e)) {
|
||||||
|
throw new Error('Already connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = yDocToSlateContent(doc, true);
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(content);
|
||||||
|
|
||||||
|
e.sharedRoot.observeDeep(handleYEvents);
|
||||||
|
e.children = content.children;
|
||||||
|
Editor.normalize(editor, { force: true });
|
||||||
|
connectSet.add(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
e.disconnect = () => {
|
||||||
|
if (!YjsEditor.connected(e)) {
|
||||||
|
throw new Error('Not connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
e.sharedRoot.unobserveDeep(handleYEvents);
|
||||||
|
connectSet.delete(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
e.storeLocalChange = (op) => {
|
||||||
|
const changes = localChanges.get(e) ?? [];
|
||||||
|
|
||||||
|
localChanges.set(e, [...changes, { op, slateContent: e.children }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
e.flushLocalChanges = () => {
|
||||||
|
const changes = YjsEditor.localChanges(e);
|
||||||
|
|
||||||
|
localChanges.delete(e);
|
||||||
|
// parse changes and apply to ydoc
|
||||||
|
doc.transact(() => {
|
||||||
|
changes.forEach((change) => {
|
||||||
|
applySlateOp(doc, { children: change.slateContent }, change.op);
|
||||||
|
});
|
||||||
|
}, CollabOrigin.Local);
|
||||||
|
};
|
||||||
|
|
||||||
|
e.apply = (op) => {
|
||||||
|
if (YjsEditor.connected(e)) {
|
||||||
|
YjsEditor.storeLocalChange(e, op);
|
||||||
|
}
|
||||||
|
|
||||||
|
apply(op);
|
||||||
|
};
|
||||||
|
|
||||||
|
e.onChange = () => {
|
||||||
|
if (YjsEditor.connected(e)) {
|
||||||
|
YjsEditor.flushLocalChanges(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
return e;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import { Operation, Node } from 'slate';
|
||||||
|
import Y from 'yjs';
|
||||||
|
|
||||||
|
export function applySlateOp (ydoc: Y.Doc, slateRoot: Node, op: Operation) {
|
||||||
|
console.log('applySlateOp', op);
|
||||||
|
}
|
@ -0,0 +1,240 @@
|
|||||||
|
import {
|
||||||
|
InlineBlockType,
|
||||||
|
YBlocks,
|
||||||
|
YChildrenMap,
|
||||||
|
YSharedRoot,
|
||||||
|
YDoc,
|
||||||
|
YjsEditorKey,
|
||||||
|
YMeta,
|
||||||
|
YTextMap,
|
||||||
|
BlockData,
|
||||||
|
BlockType,
|
||||||
|
} from '@/application/document.type';
|
||||||
|
import { getFontFamily } from '@/utils/font';
|
||||||
|
import { uniq } from 'lodash-es';
|
||||||
|
import { Element, Text } from 'slate';
|
||||||
|
|
||||||
|
interface BlockJson {
|
||||||
|
id: string;
|
||||||
|
ty: string;
|
||||||
|
data?: string;
|
||||||
|
children?: string;
|
||||||
|
external_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function yDocToSlateContent(doc: YDoc, includeRoot?: boolean): Element | undefined {
|
||||||
|
console.log(doc);
|
||||||
|
const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
|
||||||
|
|
||||||
|
console.log(sharedRoot.toJSON());
|
||||||
|
const document = sharedRoot.get(YjsEditorKey.document);
|
||||||
|
const pageId = document.get(YjsEditorKey.page_id) as string;
|
||||||
|
const blocks = document.get(YjsEditorKey.blocks) as YBlocks;
|
||||||
|
const meta = document.get(YjsEditorKey.meta) as YMeta;
|
||||||
|
const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap;
|
||||||
|
const textMap = meta.get(YjsEditorKey.text_map) as YTextMap;
|
||||||
|
const fontFamilys: string[] = [];
|
||||||
|
|
||||||
|
function traverse(id: string) {
|
||||||
|
const block = blocks.get(id).toJSON() as BlockJson;
|
||||||
|
const childrenId = block.children as string;
|
||||||
|
|
||||||
|
const children = (childrenMap.get(childrenId)?.toJSON() ?? []).map(traverse) as (Element | Text)[];
|
||||||
|
|
||||||
|
const slateNode = blockToSlateNode(block);
|
||||||
|
|
||||||
|
slateNode.children = children;
|
||||||
|
|
||||||
|
if (slateNode.type === BlockType.Page) {
|
||||||
|
return slateNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
let textId = block.external_id as string;
|
||||||
|
|
||||||
|
let delta;
|
||||||
|
|
||||||
|
if (!textId) {
|
||||||
|
if (children.length === 0) {
|
||||||
|
children.push({
|
||||||
|
text: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compatible data
|
||||||
|
// The old version of delta data is fully covered through the data field
|
||||||
|
if (slateNode.data) {
|
||||||
|
const data = slateNode.data as BlockData;
|
||||||
|
|
||||||
|
if (YjsEditorKey.delta in data) {
|
||||||
|
textId = block.id;
|
||||||
|
delta = data.delta;
|
||||||
|
} else {
|
||||||
|
return slateNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
delta = textMap.get(textId)?.toDelta();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const slateDelta = delta.flatMap(deltaInsertToSlateNode);
|
||||||
|
|
||||||
|
// collect font family
|
||||||
|
slateDelta.forEach((node: Text) => {
|
||||||
|
if (node.font_family) {
|
||||||
|
fontFamilys.push(getFontFamily(node.font_family));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const textNode: Element = {
|
||||||
|
textId,
|
||||||
|
type: YjsEditorKey.text,
|
||||||
|
children: slateDelta,
|
||||||
|
};
|
||||||
|
|
||||||
|
children.unshift(textNode);
|
||||||
|
return slateNode;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const root = blocks.get(pageId);
|
||||||
|
|
||||||
|
if (!root) return;
|
||||||
|
|
||||||
|
const result = traverse(pageId);
|
||||||
|
|
||||||
|
if (!result) return;
|
||||||
|
|
||||||
|
if (!includeRoot) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { children, ...rootNode } = result;
|
||||||
|
|
||||||
|
// load font family
|
||||||
|
if (fontFamilys.length > 0) {
|
||||||
|
window.WebFont?.load({
|
||||||
|
google: {
|
||||||
|
families: uniq(fontFamilys),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
...rootNode,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
textId: root.toJSON().external_id,
|
||||||
|
type: YjsEditorKey.text,
|
||||||
|
children: [{ text: '' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockToSlateNode(block: BlockJson): Element {
|
||||||
|
const data = block.data;
|
||||||
|
let blockData;
|
||||||
|
|
||||||
|
try {
|
||||||
|
blockData = data ? JSON.parse(data) : {};
|
||||||
|
} catch (e) {
|
||||||
|
blockData = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
blockId: block.id,
|
||||||
|
data: blockData,
|
||||||
|
type: block.ty,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deltaInsertToSlateNode({
|
||||||
|
attributes,
|
||||||
|
insert,
|
||||||
|
}: {
|
||||||
|
insert: string;
|
||||||
|
attributes: Record<string, string | number | undefined | boolean>;
|
||||||
|
}): Element | Text | Element[] {
|
||||||
|
const matchInlines = transformToInlineElement({
|
||||||
|
insert,
|
||||||
|
attributes,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchInlines.length > 0) {
|
||||||
|
return matchInlines;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attributes) {
|
||||||
|
if ('font_color' in attributes && attributes['font_color'] === '') {
|
||||||
|
delete attributes['font_color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('bg_color' in attributes && attributes['bg_color'] === '') {
|
||||||
|
delete attributes['bg_color'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('code' in attributes && !attributes['code']) {
|
||||||
|
delete attributes['code'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...attributes,
|
||||||
|
text: insert,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformToInlineElement(op: {
|
||||||
|
insert: string;
|
||||||
|
attributes: Record<string, string | number | undefined | boolean>;
|
||||||
|
}): Element[] {
|
||||||
|
const attributes = op.attributes;
|
||||||
|
|
||||||
|
if (!attributes) return [];
|
||||||
|
const { formula, mention, ...attrs } = attributes;
|
||||||
|
|
||||||
|
if (formula) {
|
||||||
|
const texts = op.insert.split('');
|
||||||
|
|
||||||
|
return texts.map((text) => {
|
||||||
|
return {
|
||||||
|
type: InlineBlockType.Formula,
|
||||||
|
data: formula,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
...attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mention) {
|
||||||
|
const texts = op.insert.split('');
|
||||||
|
|
||||||
|
return texts.map((text) => {
|
||||||
|
return {
|
||||||
|
type: InlineBlockType.Mention,
|
||||||
|
data: mention,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
text,
|
||||||
|
...attrs,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { YSharedRoot } from '@/application/document.type';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Editor, Operation } from 'slate';
|
||||||
|
|
||||||
|
export function translateYArrayEvent(
|
||||||
|
sharedRoot: YSharedRoot,
|
||||||
|
editor: Editor,
|
||||||
|
event: Y.YEvent<Y.Array<string>>
|
||||||
|
): Operation[] {
|
||||||
|
console.log('translateYArrayEvent', sharedRoot, editor, event);
|
||||||
|
return [];
|
||||||
|
}
|
@ -0,0 +1,34 @@
|
|||||||
|
import { YSharedRoot } from '@/application/document.type';
|
||||||
|
import { translateYArrayEvent } from '@/application/slate-yjs/utils/translateYjsEvent/arrayEvent';
|
||||||
|
import { translateYMapEvent } from '@/application/slate-yjs/utils/translateYjsEvent/mapEvent';
|
||||||
|
import { Editor, Operation } from 'slate';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { translateYTextEvent } from 'src/application/slate-yjs/utils/translateYjsEvent/textEvent';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Translate a yjs event into slate operations. The editor state has to match the
|
||||||
|
* yText state before the event occurred.
|
||||||
|
*
|
||||||
|
* @param sharedType
|
||||||
|
* @param op
|
||||||
|
*/
|
||||||
|
export function translateYjsEvent (
|
||||||
|
sharedRoot: YSharedRoot,
|
||||||
|
editor: Editor,
|
||||||
|
event: Y.YEvent<YSharedRoot>,
|
||||||
|
): Operation[] {
|
||||||
|
console.log('translateYjsEvent', event);
|
||||||
|
if (event instanceof Y.YMapEvent) {
|
||||||
|
return translateYMapEvent(sharedRoot, editor, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event instanceof Y.YTextEvent) {
|
||||||
|
return translateYTextEvent(sharedRoot, editor, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event instanceof Y.YArrayEvent) {
|
||||||
|
return translateYArrayEvent(sharedRoot, editor, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Unexpected Y event type');
|
||||||
|
}
|
@ -0,0 +1,12 @@
|
|||||||
|
import { YSharedRoot } from '@/application/document.type';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Editor, Operation } from 'slate';
|
||||||
|
|
||||||
|
export function translateYMapEvent(
|
||||||
|
sharedRoot: YSharedRoot,
|
||||||
|
editor: Editor,
|
||||||
|
event: Y.YEvent<Y.Map<unknown>>
|
||||||
|
): Operation[] {
|
||||||
|
console.log('translateYMapEvent', sharedRoot, editor, event);
|
||||||
|
return [];
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
import { YSharedRoot } from '@/application/document.type';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import { Editor, Operation } from 'slate';
|
||||||
|
|
||||||
|
export function translateYTextEvent(sharedRoot: YSharedRoot, editor: Editor, event: Y.YEvent<Y.Text>): Operation[] {
|
||||||
|
console.log('translateYTextEvent', sharedRoot, editor, event);
|
||||||
|
return [];
|
||||||
|
}
|
@ -10,17 +10,12 @@ export enum EncryptionType {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UserProfile {
|
export interface UserProfile {
|
||||||
id?: string;
|
uid: number;
|
||||||
|
uuid?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
token?: string;
|
|
||||||
iconUrl?: string;
|
iconUrl?: string;
|
||||||
openaiKey?: string;
|
|
||||||
authenticator?: Authenticator;
|
|
||||||
encryptionSign?: string;
|
|
||||||
encryptionType?: EncryptionType;
|
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
stabilityAiKey?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Workspace {
|
export interface Workspace {
|
@ -0,0 +1,18 @@
|
|||||||
|
import { YjsEditorKey } from '@/application/document.type';
|
||||||
|
import { applyDocument } from '@/application/ydoc/apply';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import * as docJson from '../../../../../cypress/fixtures/simple_doc.json';
|
||||||
|
|
||||||
|
describe('apply document', () => {
|
||||||
|
it('should apply document', () => {
|
||||||
|
const collab = new Y.Doc();
|
||||||
|
const data = collab.getMap(YjsEditorKey.data_section);
|
||||||
|
const document = new Y.Map();
|
||||||
|
data.set(YjsEditorKey.document, document);
|
||||||
|
|
||||||
|
const state = new Uint8Array(docJson.data.doc_state);
|
||||||
|
applyDocument(collab, state);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
export {};
|
@ -0,0 +1,18 @@
|
|||||||
|
import * as Y from 'yjs';
|
||||||
|
import { CollabOrigin } from '@/application/collab.type';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply doc state from server to client
|
||||||
|
* Note: origin is always remote
|
||||||
|
* @param doc local Y.Doc
|
||||||
|
* @param state state from server
|
||||||
|
*/
|
||||||
|
export function applyDocument(doc: Y.Doc, state: Uint8Array) {
|
||||||
|
Y.transact(
|
||||||
|
doc,
|
||||||
|
() => {
|
||||||
|
Y.applyUpdate(doc, state);
|
||||||
|
},
|
||||||
|
CollabOrigin.Remote
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1 @@
|
|||||||
|
export * from 'src/application/ydoc/apply/document';
|
@ -0,0 +1,18 @@
|
|||||||
|
import { CollabType } from '@/application/collab.type';
|
||||||
|
import { useContext, createContext } from 'react';
|
||||||
|
|
||||||
|
export const IdContext = createContext<IdProviderProps | null>(null);
|
||||||
|
|
||||||
|
interface IdProviderProps {
|
||||||
|
workspaceId: string;
|
||||||
|
objectId: string;
|
||||||
|
collabType: CollabType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const IdProvider = ({ children, ...props }: IdProviderProps & { children: React.ReactNode }) => {
|
||||||
|
return <IdContext.Provider value={props}>{children}</IdContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useId() {
|
||||||
|
return useContext(IdContext);
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import 'katex/dist/katex.min.css';
|
||||||
|
import { BlockMath, InlineMath } from 'react-katex';
|
||||||
|
import './index.css';
|
||||||
|
|
||||||
|
function KatexMath({ latex, isInline = false }: { latex: string; isInline?: boolean }) {
|
||||||
|
return isInline ? (
|
||||||
|
<InlineMath math={latex} />
|
||||||
|
) : (
|
||||||
|
<BlockMath
|
||||||
|
renderError={(error) => {
|
||||||
|
return (
|
||||||
|
<div className='flex h-[51px] items-center justify-center'>
|
||||||
|
{error.name}: {error.message}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{latex}
|
||||||
|
</BlockMath>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KatexMath;
|
@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
.katex-html {
|
||||||
|
white-space: normal;
|
||||||
|
}
|
@ -0,0 +1,55 @@
|
|||||||
|
import { Scrollbars } from 'react-custom-scrollbars';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export interface AFScrollerProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
overflowXHidden?: boolean;
|
||||||
|
overflowYHidden?: boolean;
|
||||||
|
className?: string;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
export const AFScroller = ({ style, children, overflowXHidden, overflowYHidden, className }: AFScrollerProps) => {
|
||||||
|
return (
|
||||||
|
<Scrollbars
|
||||||
|
autoHide
|
||||||
|
renderThumbHorizontal={(props) => <div {...props} className='appflowy-scrollbar-thumb-horizontal' />}
|
||||||
|
renderThumbVertical={(props) => <div {...props} className='appflowy-scrollbar-thumb-vertical' />}
|
||||||
|
{...(overflowXHidden && {
|
||||||
|
renderTrackHorizontal: (props) => (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
{...(overflowYHidden && {
|
||||||
|
renderTrackVertical: (props) => (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
display: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
style={style}
|
||||||
|
renderView={(props) => (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
style={{
|
||||||
|
...props.style,
|
||||||
|
overflowX: overflowXHidden ? 'hidden' : 'auto',
|
||||||
|
overflowY: overflowYHidden ? 'hidden' : 'auto',
|
||||||
|
marginRight: 0,
|
||||||
|
marginBottom: 0,
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Scrollbars>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './AFScroller';
|
27
frontend/appflowy_web_app/src/components/app/App.tsx
Normal file
27
frontend/appflowy_web_app/src/components/app/App.tsx
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { BrowserRouter, Route, Routes } from 'react-router-dom';
|
||||||
|
import ProtectedRoutes from '@/components/auth/ProtectedRoutes';
|
||||||
|
import LoginPage from '@/pages/LoginPage';
|
||||||
|
import ProductPage from '@/pages/ProductPage';
|
||||||
|
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||||
|
|
||||||
|
const AppMain = withAppWrapper(() => {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route path={'/'} element={<ProtectedRoutes />}>
|
||||||
|
<Route path={'/workspace/:workspaceId/:collabType/:objectId'} element={<ProductPage />} />
|
||||||
|
</Route>
|
||||||
|
<Route path={'/login'} element={<LoginPage />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function App () {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<AppMain />
|
||||||
|
</BrowserRouter>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
|
|
@ -5,13 +5,12 @@ import { useAppSelector } from '@/stores/store';
|
|||||||
|
|
||||||
export const AFConfigContext = createContext<
|
export const AFConfigContext = createContext<
|
||||||
| {
|
| {
|
||||||
service: AFService | undefined;
|
service: AFService | undefined;
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
|
function AppConfig ({ children }: { children: React.ReactNode }) {
|
||||||
function AppConfig({ children }: { children: React.ReactNode }) {
|
|
||||||
const appConfig = useAppSelector((state) => state.app.appConfig);
|
const appConfig = useAppSelector((state) => state.app.appConfig);
|
||||||
const [service, setService] = useState<AFService>();
|
const [service, setService] = useState<AFService>();
|
||||||
|
|
||||||
@ -26,7 +25,7 @@ function AppConfig({ children }: { children: React.ReactNode }) {
|
|||||||
() => ({
|
() => ({
|
||||||
service,
|
service,
|
||||||
}),
|
}),
|
||||||
[service]
|
[service],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <AFConfigContext.Provider value={config}>{children}</AFConfigContext.Provider>;
|
return <AFConfigContext.Provider value={config}>{children}</AFConfigContext.Provider>;
|
181
frontend/appflowy_web_app/src/components/app/AppTheme.tsx
Normal file
181
frontend/appflowy_web_app/src/components/app/AppTheme.tsx
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import createTheme from '@mui/material/styles/createTheme';
|
||||||
|
import ThemeProvider from '@mui/material/styles/ThemeProvider';
|
||||||
|
import '@/i18n/config';
|
||||||
|
|
||||||
|
import 'src/styles/tailwind.css';
|
||||||
|
import 'src/styles/template.css';
|
||||||
|
|
||||||
|
function AppTheme({ children }: { children: React.ReactNode }) {
|
||||||
|
const isDark = false;
|
||||||
|
const theme = useMemo(
|
||||||
|
() =>
|
||||||
|
createTheme({
|
||||||
|
typography: {
|
||||||
|
fontFamily: ['inherit'].join(','),
|
||||||
|
fontSize: 12,
|
||||||
|
button: {
|
||||||
|
textTransform: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
MuiMenuItem: {
|
||||||
|
defaultProps: {
|
||||||
|
sx: {
|
||||||
|
'&.Mui-selected.Mui-focusVisible': {
|
||||||
|
backgroundColor: 'var(--fill-list-hover)',
|
||||||
|
},
|
||||||
|
'&.Mui-focusVisible': {
|
||||||
|
backgroundColor: 'unset',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiIconButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--fill-list-hover)',
|
||||||
|
},
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButton: {
|
||||||
|
styleOverrides: {
|
||||||
|
contained: {
|
||||||
|
color: 'var(--content-on-fill)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
},
|
||||||
|
containedPrimary: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--fill-default)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
containedInherit: {
|
||||||
|
color: 'var(--text-title)',
|
||||||
|
backgroundColor: isDark ? 'rgba(0, 0, 0, 0.4)' : 'rgba(255, 255, 255, 0.4)',
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--bg-body)',
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
outlinedInherit: {
|
||||||
|
color: 'var(--text-title)',
|
||||||
|
borderColor: 'var(--line-border)',
|
||||||
|
'&:hover': {
|
||||||
|
boxShadow: 'var(--shadow)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiButtonBase: {
|
||||||
|
defaultProps: {
|
||||||
|
sx: {
|
||||||
|
'&.Mui-selected:hover': {
|
||||||
|
backgroundColor: 'var(--fill-list-hover)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: 'var(--fill-list-hover)',
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
backgroundColor: 'var(--fill-list-hover)',
|
||||||
|
},
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px',
|
||||||
|
boxShadow: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiPaper: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
backgroundImage: 'none',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDialog: {
|
||||||
|
defaultProps: {
|
||||||
|
sx: {
|
||||||
|
'& .MuiBackdrop-root': {
|
||||||
|
backgroundColor: 'var(--bg-mask)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
MuiTooltip: {
|
||||||
|
styleOverrides: {
|
||||||
|
arrow: {
|
||||||
|
color: 'var(--bg-tips)',
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: 'var(--bg-tips)',
|
||||||
|
color: 'var(--text-title)',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
borderRadius: '8px',
|
||||||
|
fontWeight: 400,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiInputBase: {
|
||||||
|
styleOverrides: {
|
||||||
|
input: {
|
||||||
|
backgroundColor: 'transparent !important',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
MuiDivider: {
|
||||||
|
styleOverrides: {
|
||||||
|
root: {
|
||||||
|
borderColor: 'var(--line-divider)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
palette: {
|
||||||
|
mode: isDark ? 'dark' : 'light',
|
||||||
|
primary: {
|
||||||
|
main: '#00BCF0',
|
||||||
|
dark: '#00BCF0',
|
||||||
|
},
|
||||||
|
error: {
|
||||||
|
main: '#FB006D',
|
||||||
|
dark: '#D32772',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
main: '#FFC107',
|
||||||
|
dark: '#E9B320',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
main: '#00BCF0',
|
||||||
|
dark: '#2E9DBB',
|
||||||
|
},
|
||||||
|
success: {
|
||||||
|
main: '#66CF80',
|
||||||
|
dark: '#3BA856',
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: isDark ? '#E2E9F2' : '#333333',
|
||||||
|
secondary: isDark ? '#7B8A9D' : '#828282',
|
||||||
|
disabled: isDark ? '#363D49' : '#F2F2F2',
|
||||||
|
},
|
||||||
|
divider: isDark ? '#59647A' : '#BDBDBD',
|
||||||
|
background: {
|
||||||
|
default: isDark ? '#1A202C' : '#FFFFFF',
|
||||||
|
paper: isDark ? '#1A202C' : '#FFFFFF',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
[isDark]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <ThemeProvider theme={theme}>{children}</ThemeProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppTheme;
|
@ -0,0 +1,27 @@
|
|||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { store } from 'src/stores/store';
|
||||||
|
import { ErrorBoundary } from 'react-error-boundary';
|
||||||
|
import { ErrorHandlerPage } from 'src/components/error/ErrorHandlerPage';
|
||||||
|
import AppTheme from '@/components/app/AppTheme';
|
||||||
|
import { Toaster } from 'react-hot-toast';
|
||||||
|
import AppConfig from '@/components/app/AppConfig';
|
||||||
|
import { Suspense } from 'react';
|
||||||
|
|
||||||
|
export default function withAppWrapper (Component: React.FC): React.FC {
|
||||||
|
return function AppWrapper (): JSX.Element {
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<AppTheme>
|
||||||
|
<ErrorBoundary FallbackComponent={ErrorHandlerPage}>
|
||||||
|
<AppConfig>
|
||||||
|
<Suspense>
|
||||||
|
<Component />
|
||||||
|
<Toaster />
|
||||||
|
</Suspense>
|
||||||
|
</AppConfig>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</AppTheme>
|
||||||
|
</Provider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
@ -4,7 +4,7 @@ import GithubIcon from '@/assets/settings/github.png';
|
|||||||
import DiscordIcon from '@/assets/settings/discord.png';
|
import DiscordIcon from '@/assets/settings/discord.png';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuth } from './auth.hooks';
|
import { useAuth } from './auth.hooks';
|
||||||
import { ProviderType } from '@/application/services/user.type';
|
import { ProviderType } from '@/application/user.type';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import EmailOutlined from '@mui/icons-material/EmailOutlined';
|
import EmailOutlined from '@mui/icons-material/EmailOutlined';
|
||||||
import SignInWithEmail from './SignInWithEmail';
|
import SignInWithEmail from './SignInWithEmail';
|
||||||
@ -17,6 +17,7 @@ export const LoginButtonGroup = () => {
|
|||||||
return (
|
return (
|
||||||
<div className={'flex w-full flex-col items-center gap-4'}>
|
<div className={'flex w-full flex-col items-center gap-4'}>
|
||||||
<Button
|
<Button
|
||||||
|
data-cy={'signInWithEmail'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setOpenSignInWithEmail(true);
|
setOpenSignInWithEmail(true);
|
||||||
}}
|
}}
|
||||||
@ -24,7 +25,7 @@ export const LoginButtonGroup = () => {
|
|||||||
color={'inherit'}
|
color={'inherit'}
|
||||||
variant={'outlined'}
|
variant={'outlined'}
|
||||||
>
|
>
|
||||||
<EmailOutlined className={'mr-2 h-6 w-6'}/>
|
<EmailOutlined className={'mr-2 h-6 w-6'} />
|
||||||
{t('signIn.signInWithEmail')}
|
{t('signIn.signInWithEmail')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -35,7 +36,7 @@ export const LoginButtonGroup = () => {
|
|||||||
color={'inherit'}
|
color={'inherit'}
|
||||||
variant={'outlined'}
|
variant={'outlined'}
|
||||||
>
|
>
|
||||||
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'}/>
|
<img src={GoogleIcon} alt={'Google'} className={'mr-2 h-6 w-6'} />
|
||||||
{t('button.signInGoogle')}
|
{t('button.signInGoogle')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -46,7 +47,7 @@ export const LoginButtonGroup = () => {
|
|||||||
color={'inherit'}
|
color={'inherit'}
|
||||||
variant={'outlined'}
|
variant={'outlined'}
|
||||||
>
|
>
|
||||||
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'}/>
|
<img src={GithubIcon} alt={'Github'} className={'mr-2 h-6 w-6'} />
|
||||||
{t('button.signInGithub')}
|
{t('button.signInGithub')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -57,10 +58,10 @@ export const LoginButtonGroup = () => {
|
|||||||
color={'inherit'}
|
color={'inherit'}
|
||||||
variant={'outlined'}
|
variant={'outlined'}
|
||||||
>
|
>
|
||||||
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'}/>
|
<img src={DiscordIcon} alt={'Discord'} className={'mr-2 h-6 w-6'} />
|
||||||
{t('button.signInDiscord')}
|
{t('button.signInDiscord')}
|
||||||
</Button>
|
</Button>
|
||||||
<SignInWithEmail open={openSignInWithEmail} onClose={() => setOpenSignInWithEmail(false)}/>
|
<SignInWithEmail open={openSignInWithEmail} onClose={() => setOpenSignInWithEmail(false)} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -7,22 +7,28 @@ import SplashScreen from '@/components/auth/SplashScreen';
|
|||||||
import CircularProgress from '@mui/material/CircularProgress';
|
import CircularProgress from '@mui/material/CircularProgress';
|
||||||
import Portal from '@mui/material/Portal';
|
import Portal from '@mui/material/Portal';
|
||||||
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
import { ReactComponent as Logo } from '@/assets/logo.svg';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
const TauriAuth = lazy(() => import('@/components/tauri/TauriAuth'));
|
const TauriAuth = lazy(() => import('@/components/tauri/TauriAuth'));
|
||||||
|
|
||||||
function ProtectedRoutes () {
|
function ProtectedRoutes () {
|
||||||
const { currentUser, checkUser } = useAuth();
|
const { currentUser, checkUser, isReady } = useAuth();
|
||||||
|
|
||||||
const isLoading = currentUser?.loginState === LoginState.LOADING;
|
const isLoading = currentUser?.loginState === LoginState.LOADING;
|
||||||
const [checked, setChecked] = useState(false);
|
const [checked, setChecked] = useState(false);
|
||||||
|
|
||||||
const checkUserStatus = useCallback(async () => {
|
const checkUserStatus = useCallback(async () => {
|
||||||
|
if (!isReady) return;
|
||||||
|
setChecked(false);
|
||||||
try {
|
try {
|
||||||
await checkUser();
|
if (!currentUser.isAuthenticated) {
|
||||||
|
await checkUser();
|
||||||
|
}
|
||||||
|
|
||||||
} finally {
|
} finally {
|
||||||
setChecked(true);
|
setChecked(true);
|
||||||
}
|
}
|
||||||
}, [checkUser]);
|
}, [checkUser, isReady, currentUser.isAuthenticated]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void checkUserStatus();
|
void checkUserStatus();
|
||||||
@ -30,18 +36,26 @@ function ProtectedRoutes () {
|
|||||||
|
|
||||||
const platform = useMemo(() => getPlatform(), []);
|
const platform = useMemo(() => getPlatform(), []);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
console.log('ProtectedRoutes', currentUser, checked);
|
||||||
|
if (checked && !currentUser.isAuthenticated && window.location.pathname !== '/login') {
|
||||||
|
navigate(`/login?redirect=${encodeURIComponent(window.location.pathname)}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'relative h-screen w-screen'}>
|
<div className={'relative h-screen w-screen'}>
|
||||||
{checked ? (
|
{checked ? (
|
||||||
<SplashScreen isAuthenticated={currentUser.isAuthenticated}/>
|
<SplashScreen />
|
||||||
) : (
|
) : (
|
||||||
<div className={'flex h-screen w-screen items-center justify-center'}>
|
<div className={'flex h-screen w-screen items-center justify-center'}>
|
||||||
<Logo className={'h-20 w-20'}/>
|
<Logo className={'h-20 w-20'} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading && <StartLoading/>}
|
{isLoading && <StartLoading />}
|
||||||
<Suspense>{platform.isTauri && <TauriAuth/>}</Suspense>
|
<Suspense>{platform.isTauri && <TauriAuth />}</Suspense>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -68,7 +82,7 @@ const StartLoading = () => {
|
|||||||
return (
|
return (
|
||||||
<Portal>
|
<Portal>
|
||||||
<div className={'fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-bg-mask bg-opacity-50'}>
|
<div className={'fixed inset-0 z-[1400] flex h-full w-full items-center justify-center bg-bg-mask bg-opacity-50'}>
|
||||||
<CircularProgress/>
|
<CircularProgress />
|
||||||
</div>
|
</div>
|
||||||
</Portal>
|
</Portal>
|
||||||
);
|
);
|
||||||
|
@ -9,10 +9,12 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
|||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const { signInWithEmailPassword } = useAuth();
|
const { signInWithEmailPassword } = useAuth();
|
||||||
|
|
||||||
const handleSignIn = async () => {
|
const handleSignIn = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await signInWithEmailPassword(email, password);
|
await signInWithEmailPassword(email, password);
|
||||||
|
onClose();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Handle error
|
// Handle error
|
||||||
}
|
}
|
||||||
@ -27,9 +29,11 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
|||||||
sx={{
|
sx={{
|
||||||
zIndex: 1500,
|
zIndex: 1500,
|
||||||
}}
|
}}
|
||||||
|
data-cy={'signInWithEmailDialog'}
|
||||||
PaperProps={{
|
PaperProps={{
|
||||||
className: 'w-[400px]',
|
className: 'w-[400px]',
|
||||||
}}
|
}}
|
||||||
|
keepMounted={false}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -41,6 +45,7 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
|||||||
<TextField
|
<TextField
|
||||||
label={'Email'}
|
label={'Email'}
|
||||||
size={'small'}
|
size={'small'}
|
||||||
|
data-cy={'email'}
|
||||||
required={true}
|
required={true}
|
||||||
placeholder={'name@gmail.com'}
|
placeholder={'name@gmail.com'}
|
||||||
type={'email'}
|
type={'email'}
|
||||||
@ -48,6 +53,7 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
|||||||
/>
|
/>
|
||||||
<TextField
|
<TextField
|
||||||
size={'small'}
|
size={'small'}
|
||||||
|
data-cy={'password'}
|
||||||
required={true}
|
required={true}
|
||||||
label={'Password'}
|
label={'Password'}
|
||||||
placeholder={'Password'}
|
placeholder={'Password'}
|
||||||
@ -59,8 +65,15 @@ function SignInWithEmail({ open, onClose }: { open: boolean; onClose: () => void
|
|||||||
<Button variant={'outlined'} className={'flex-1'} color={'inherit'} onClick={onClose}>
|
<Button variant={'outlined'} className={'flex-1'} color={'inherit'} onClick={onClose}>
|
||||||
{t('button.cancel')}
|
{t('button.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button disabled={loading} className={'flex-1'} variant={'contained'} onClick={handleSignIn}>
|
<Button
|
||||||
{loading ? <CircularProgress size={12} /> : t('button.signIn')}
|
data-cy={'submit'}
|
||||||
|
disabled={loading}
|
||||||
|
className={'justify-content flex h-[33px] flex-1 items-center gap-2'}
|
||||||
|
variant={'contained'}
|
||||||
|
onClick={handleSignIn}
|
||||||
|
>
|
||||||
|
{loading && <CircularProgress size={20} />}
|
||||||
|
{t('button.signIn')}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
@ -1,22 +1,14 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import Layout from '@/components/layout/Layout';
|
import Layout from '@/components/layout/Layout';
|
||||||
import Welcome from './Welcome';
|
|
||||||
|
|
||||||
function SplashScreen({
|
function SplashScreen () {
|
||||||
isAuthenticated,
|
|
||||||
}: {
|
return (
|
||||||
isAuthenticated: boolean;
|
<Layout>
|
||||||
}) {
|
<Outlet/>
|
||||||
if (isAuthenticated) {
|
</Layout>
|
||||||
return (
|
);
|
||||||
<Layout>
|
|
||||||
<Outlet/>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return <Welcome/>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default SplashScreen;
|
export default SplashScreen;
|
34
frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx
Normal file
34
frontend/appflowy_web_app/src/components/auth/Welcome.cy.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Welcome from './Welcome';
|
||||||
|
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||||
|
|
||||||
|
describe('<Welcome />', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
cy.mockAPI();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders', () => {
|
||||||
|
const AppWrapper = withAppWrapper(Welcome);
|
||||||
|
|
||||||
|
cy.mount(<AppWrapper />);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle login success', () => {
|
||||||
|
const AppWrapper = withAppWrapper(Welcome);
|
||||||
|
|
||||||
|
cy.mount(<AppWrapper />);
|
||||||
|
|
||||||
|
cy.get('[data-cy=signInWithEmail]').click();
|
||||||
|
|
||||||
|
cy.wait(100);
|
||||||
|
|
||||||
|
cy.get('[data-cy=signInWithEmailDialog]').as('dialog').should('be.visible');
|
||||||
|
cy.get('[data-cy=email]').type('fakeEmail123');
|
||||||
|
cy.get('[data-cy=password]').type('fakePassword123');
|
||||||
|
cy.get('[data-cy=submit]').click();
|
||||||
|
cy.wait('@loginSuccess');
|
||||||
|
cy.wait('@verifyToken');
|
||||||
|
cy.wait('@getUserProfile');
|
||||||
|
cy.get('@dialog').should('not.exist');
|
||||||
|
});
|
||||||
|
});
|
@ -1,51 +1,39 @@
|
|||||||
import { ReactComponent as AppflowyLogo } from '@/assets/logo.svg';
|
import { ReactComponent as AppflowyLogo } from '@/assets/logo.svg';
|
||||||
import Button from '@mui/material/Button';
|
import { Stack } from '@mui/material';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAuth } from './auth.hooks';
|
|
||||||
import { LoginButtonGroup } from './LoginButtonGroup';
|
import { LoginButtonGroup } from './LoginButtonGroup';
|
||||||
|
import { getPlatform } from '@/utils/platform';
|
||||||
|
import { lazy } from 'react';
|
||||||
|
|
||||||
|
const SignInAsAnonymous = lazy(() => import('@/components/tauri/SignInAsAnonymous'));
|
||||||
|
|
||||||
export const Welcome = () => {
|
export const Welcome = () => {
|
||||||
const { signInAsAnonymous } = useAuth();
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<form onSubmit={(e) => e.preventDefault()} method="POST">
|
<form onSubmit={(e) => e.preventDefault()} method='POST'>
|
||||||
<div
|
<Stack className='relative flex h-screen w-screen flex-col items-center justify-center gap-12 bg-bg-body text-center text-text-title'>
|
||||||
className="relative flex h-screen w-screen flex-col items-center justify-center gap-12 bg-bg-body text-center text-text-title">
|
<div className='flex justify-center' id='appflowy'>
|
||||||
<div className="flex justify-center" id="appflowy">
|
<AppflowyLogo className={'h-16 w-16'} />
|
||||||
<AppflowyLogo className={'h-16 w-16'}/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-2xl font-semibold leading-9">
|
<span className='text-2xl font-semibold leading-9'>
|
||||||
{t('welcomeTo')} {t('appName')}
|
{t('welcomeTo')} {t('appName')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="Get-Started" className="flex w-[340px] flex-col gap-4 " aria-label="Get-Started">
|
<div id='Get-Started' className='flex w-[340px] flex-col gap-4 ' aria-label='Get-Started'>
|
||||||
<Button
|
{getPlatform().isTauri && <SignInAsAnonymous />}
|
||||||
size={'large'}
|
|
||||||
color={'inherit'}
|
|
||||||
className={'border-transparent bg-line-divider py-3'}
|
|
||||||
variant={'outlined'}
|
|
||||||
onClick={signInAsAnonymous}
|
|
||||||
>
|
|
||||||
{t('signIn.loginStartWithAnonymous')}
|
|
||||||
</Button>
|
|
||||||
<div className={'flex w-full items-center justify-center gap-2 text-sm'}>
|
|
||||||
<div className={'h-px flex-1 bg-line-divider'}/>
|
|
||||||
{t('signIn.or')}
|
|
||||||
<div className={'h-px flex-1 bg-line-divider'}/>
|
|
||||||
</div>
|
|
||||||
<div className={'w-w-full flex items-center justify-center gap-2 text-sm'}>
|
<div className={'w-w-full flex items-center justify-center gap-2 text-sm'}>
|
||||||
<LoginButtonGroup/>
|
<LoginButtonGroup />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default Welcome;
|
export default Welcome;
|
||||||
|
@ -2,22 +2,24 @@ import { useAppDispatch, useAppSelector } from '@/stores/store';
|
|||||||
import { useCallback, useContext } from 'react';
|
import { useCallback, useContext } from 'react';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { open } from '@tauri-apps/api/shell';
|
import { open } from '@tauri-apps/api/shell';
|
||||||
import { ProviderType, UserProfile } from '@/application/services/user.type';
|
import { ProviderType, UserProfile } from '@/application/user.type';
|
||||||
import { currentUserActions } from '@/stores/currentUser/slice';
|
import { currentUserActions } from '@/stores/currentUser/slice';
|
||||||
import { AFConfigContext } from '@/AppConfig';
|
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||||
import { notify } from '@/components/_shared/notify';
|
import { notify } from '@/components/_shared/notify';
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const AFConfig = useContext(AFConfigContext);
|
const AFConfig = useContext(AFConfigContext);
|
||||||
const currentUser = useAppSelector((state) => state.currentUser);
|
const currentUser = useAppSelector((state) => state.currentUser);
|
||||||
|
const isReady = !!AFConfig?.service;
|
||||||
|
|
||||||
const handleSuccess = useCallback(() => {
|
const handleSuccess = useCallback(() => {
|
||||||
notify.clear();
|
notify.clear();
|
||||||
dispatch(currentUserActions.loginSuccess());
|
dispatch(currentUserActions.loginSuccess());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
const setUser = useCallback(
|
const setUser = useCallback(
|
||||||
async (userProfile: Partial<UserProfile>) => {
|
async (userProfile: UserProfile) => {
|
||||||
handleSuccess();
|
handleSuccess();
|
||||||
dispatch(currentUserActions.updateUser(userProfile));
|
dispatch(currentUserActions.updateUser(userProfile));
|
||||||
},
|
},
|
||||||
@ -41,8 +43,13 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
// Check if the user is authenticated
|
// Check if the user is authenticated
|
||||||
const checkUser = useCallback(async () => {
|
const checkUser = useCallback(async () => {
|
||||||
handleStart();
|
|
||||||
try {
|
try {
|
||||||
|
const userHasSignIn = await AFConfig?.service?.userService.checkUser();
|
||||||
|
|
||||||
|
if (!userHasSignIn) {
|
||||||
|
throw new Error('Failed to check user');
|
||||||
|
}
|
||||||
|
|
||||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
@ -53,13 +60,9 @@ export const useAuth = () => {
|
|||||||
|
|
||||||
return userProfile;
|
return userProfile;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
handleError({
|
|
||||||
message: 'Failed to check user',
|
|
||||||
});
|
|
||||||
|
|
||||||
return Promise.reject('Failed to check user');
|
return Promise.reject('Failed to check user');
|
||||||
}
|
}
|
||||||
}, [AFConfig?.service?.userService, handleError, handleStart, setUser]);
|
}, [AFConfig?.service?.userService, setUser]);
|
||||||
|
|
||||||
const register = useCallback(
|
const register = useCallback(
|
||||||
async (email: string, password: string, name: string): Promise<UserProfile | null> => {
|
async (email: string, password: string, name: string): Promise<UserProfile | null> => {
|
||||||
@ -114,7 +117,7 @@ export const useAuth = () => {
|
|||||||
const url = await AFConfig?.service?.authService.getOAuthURL(provider);
|
const url = await AFConfig?.service?.authService.getOAuthURL(provider);
|
||||||
|
|
||||||
if (!url) {
|
if (!url) {
|
||||||
throw new Error('Failed to sign in');
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
await open(url);
|
await open(url);
|
||||||
@ -135,7 +138,7 @@ export const useAuth = () => {
|
|||||||
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
throw new Error('Failed to sign in');
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
await setUser(userProfile);
|
await setUser(userProfile);
|
||||||
@ -154,10 +157,12 @@ export const useAuth = () => {
|
|||||||
async (email: string, password: string) => {
|
async (email: string, password: string) => {
|
||||||
handleStart();
|
handleStart();
|
||||||
try {
|
try {
|
||||||
const userProfile = await AFConfig?.service?.authService.signinWithEmailPassword(email, password);
|
await AFConfig?.service?.authService.signinWithEmailPassword(email, password);
|
||||||
|
|
||||||
|
const userProfile = await AFConfig?.service?.userService.getUserProfile();
|
||||||
|
|
||||||
if (!userProfile) {
|
if (!userProfile) {
|
||||||
throw new Error('Failed to sign in');
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
await setUser(userProfile);
|
await setUser(userProfile);
|
||||||
@ -169,10 +174,11 @@ export const useAuth = () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[AFConfig?.service?.authService, handleError, handleStart, setUser]
|
[AFConfig?.service?.authService, AFConfig?.service?.userService, handleError, handleStart, setUser]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
isReady,
|
||||||
currentUser,
|
currentUser,
|
||||||
checkUser,
|
checkUser,
|
||||||
register,
|
register,
|
||||||
|
@ -0,0 +1,19 @@
|
|||||||
|
import { useId } from '@/components/_shared/context-provider/IdProvider';
|
||||||
|
import { Editor } from '@/components/editor';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export const Document = () => {
|
||||||
|
const { objectId: documentId, workspaceId } = useId() || {};
|
||||||
|
|
||||||
|
if (!documentId || !workspaceId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={'relative w-full'}>
|
||||||
|
<div className={'flex w-full justify-center'}>
|
||||||
|
<div className={'max-w-screen mt-6 w-[964px] min-w-0'}>
|
||||||
|
<Editor readOnly={true} documentId={documentId} workspaceId={workspaceId} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -0,0 +1 @@
|
|||||||
|
export * from './Document';
|
@ -0,0 +1,33 @@
|
|||||||
|
import { withYjs, YjsEditor } from '@/application/slate-yjs/plugins/withYjs';
|
||||||
|
import EditorEditable from '@/components/editor/Editable';
|
||||||
|
import { withPlugins } from '@/components/editor/plugins';
|
||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { createEditor, Descendant } from 'slate';
|
||||||
|
import { Slate, withReact } from 'slate-react';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
|
||||||
|
const defaultInitialValue: Descendant[] = [];
|
||||||
|
|
||||||
|
function CollaborativeEditor({ doc }: { doc: Y.Doc }) {
|
||||||
|
const editor = useMemo(() => doc && (withPlugins(withReact(withYjs(createEditor(), doc))) as YjsEditor), [doc]);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [_, setIsConnected] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editor) return;
|
||||||
|
editor.connect();
|
||||||
|
setIsConnected(true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.disconnect();
|
||||||
|
};
|
||||||
|
}, [editor]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Slate editor={editor} initialValue={defaultInitialValue}>
|
||||||
|
<EditorEditable editor={editor} />
|
||||||
|
</Slate>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CollaborativeEditor;
|
35
frontend/appflowy_web_app/src/components/editor/Editable.tsx
Normal file
35
frontend/appflowy_web_app/src/components/editor/Editable.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { useDecorate } from '@/components/editor/components/blocks/code/useDecorate';
|
||||||
|
import { Leaf } from '@/components/editor/components/leaf';
|
||||||
|
import { useEditorContext } from '@/components/editor/EditorContext';
|
||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { NodeEntry } from 'slate';
|
||||||
|
import { Editable, ReactEditor } from 'slate-react';
|
||||||
|
import { Element } from './components/element';
|
||||||
|
|
||||||
|
const EditorEditable = ({ editor }: { editor: ReactEditor }) => {
|
||||||
|
const { readOnly } = useEditorContext();
|
||||||
|
const codeDecorate = useDecorate(editor);
|
||||||
|
|
||||||
|
const decorate = useCallback(
|
||||||
|
(entry: NodeEntry) => {
|
||||||
|
return [...codeDecorate(entry)];
|
||||||
|
},
|
||||||
|
[codeDecorate]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Editable
|
||||||
|
role={'textbox'}
|
||||||
|
decorate={decorate}
|
||||||
|
className={'px-16 outline-none focus:outline-none max-md:px-4'}
|
||||||
|
renderLeaf={Leaf}
|
||||||
|
renderElement={Element}
|
||||||
|
readOnly={readOnly}
|
||||||
|
spellCheck={false}
|
||||||
|
autoCorrect={'off'}
|
||||||
|
autoComplete={'off'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default EditorEditable;
|
@ -0,0 +1,37 @@
|
|||||||
|
import { JSDocumentService } from '@/application/services/js-services/document.service';
|
||||||
|
import { DocumentTest } from '@/../cypress/support/document';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import React from 'react';
|
||||||
|
import { Editor } from './Editor';
|
||||||
|
import withAppWrapper from '@/components/app/withAppWrapper';
|
||||||
|
|
||||||
|
describe('<Editor />', () => {
|
||||||
|
it('renders with a paragraph', () => {
|
||||||
|
const documentTest = new DocumentTest();
|
||||||
|
|
||||||
|
documentTest.insertParagraph('Hello, world!');
|
||||||
|
cy.stub(JSDocumentService.prototype, 'openDocument').returns(Promise.resolve(documentTest.doc));
|
||||||
|
renderEditor();
|
||||||
|
cy.get('[role="textbox"]').should('contain', 'Hello, world!');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with a full document', () => {
|
||||||
|
cy.mockFullDocument();
|
||||||
|
renderEditor();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderEditor() {
|
||||||
|
const documentId = nanoid(8);
|
||||||
|
const workspaceId = nanoid(8);
|
||||||
|
|
||||||
|
const AppWrapper = withAppWrapper(() => {
|
||||||
|
return (
|
||||||
|
<div className={'h-screen w-screen overflow-y-auto'}>
|
||||||
|
<Editor documentId={documentId} readOnly workspaceId={workspaceId} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
cy.mount(<AppWrapper />);
|
||||||
|
}
|
48
frontend/appflowy_web_app/src/components/editor/Editor.tsx
Normal file
48
frontend/appflowy_web_app/src/components/editor/Editor.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import { AFConfigContext } from '@/components/app/AppConfig';
|
||||||
|
import CollaborativeEditor from '@/components/editor/CollaborativeEditor';
|
||||||
|
import { EditorContextProvider } from '@/components/editor/EditorContext';
|
||||||
|
import { CircularProgress } from '@mui/material';
|
||||||
|
import React, { useCallback, useContext, useEffect, useState } from 'react';
|
||||||
|
import * as Y from 'yjs';
|
||||||
|
import './editor.scss';
|
||||||
|
|
||||||
|
export const Editor = ({
|
||||||
|
workspaceId,
|
||||||
|
documentId,
|
||||||
|
readOnly,
|
||||||
|
}: {
|
||||||
|
documentId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
readOnly: boolean;
|
||||||
|
}) => {
|
||||||
|
const [doc, setDoc] = useState<Y.Doc>();
|
||||||
|
|
||||||
|
const documentService = useContext(AFConfigContext)?.service?.documentService;
|
||||||
|
|
||||||
|
const handleOpenDocument = useCallback(async () => {
|
||||||
|
if (!documentService) return;
|
||||||
|
const doc = await documentService.openDocument(workspaceId, documentId);
|
||||||
|
|
||||||
|
setDoc(doc);
|
||||||
|
}, [documentService, workspaceId, documentId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void handleOpenDocument();
|
||||||
|
}, [handleOpenDocument]);
|
||||||
|
|
||||||
|
if (!doc) {
|
||||||
|
return (
|
||||||
|
<div className={'justify-content flex h-full w-full items-center'}>
|
||||||
|
<CircularProgress />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<EditorContextProvider readOnly={readOnly}>
|
||||||
|
<CollaborativeEditor doc={doc} />
|
||||||
|
</EditorContextProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Editor;
|
@ -0,0 +1,17 @@
|
|||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
|
||||||
|
interface EditorContextState {
|
||||||
|
readOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EditorContext = createContext<EditorContextState>({
|
||||||
|
readOnly: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EditorContextProvider = ({ children, ...props }: EditorContextState & { children: React.ReactNode }) => {
|
||||||
|
return <EditorContext.Provider value={props}>{children}</EditorContext.Provider>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useEditorContext() {
|
||||||
|
return useContext(EditorContext);
|
||||||
|
}
|
@ -0,0 +1,16 @@
|
|||||||
|
import { BulletedListNode, EditorElementProps } from '@/components/editor/editor.type';
|
||||||
|
import React, { forwardRef, memo } from 'react';
|
||||||
|
|
||||||
|
export const BulletedList = memo(
|
||||||
|
forwardRef<HTMLDivElement, EditorElementProps<BulletedListNode>>(
|
||||||
|
({ node: _, children, className, ...attributes }, ref) => {
|
||||||
|
return (
|
||||||
|
<div ref={ref} {...attributes} className={`${className}`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export default BulletedList;
|
@ -0,0 +1,49 @@
|
|||||||
|
import { BulletedListNode } from '@/components/editor/editor.type';
|
||||||
|
import { getListLevel } from '@/components/editor/utils/list';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
|
|
||||||
|
enum Letter {
|
||||||
|
Disc,
|
||||||
|
Circle,
|
||||||
|
Square,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BulletedListIcon({ block, className }: { block: BulletedListNode; className: string }) {
|
||||||
|
const staticEditor = useSlateStatic();
|
||||||
|
const path = ReactEditor.findPath(staticEditor, block);
|
||||||
|
|
||||||
|
const letter = useMemo(() => {
|
||||||
|
const level = getListLevel(staticEditor, block.type, path);
|
||||||
|
|
||||||
|
if (level % 3 === 0) {
|
||||||
|
return Letter.Disc;
|
||||||
|
} else if (level % 3 === 1) {
|
||||||
|
return Letter.Circle;
|
||||||
|
} else {
|
||||||
|
return Letter.Square;
|
||||||
|
}
|
||||||
|
}, [block.type, staticEditor, path]);
|
||||||
|
|
||||||
|
const dataLetter = useMemo(() => {
|
||||||
|
switch (letter) {
|
||||||
|
case Letter.Disc:
|
||||||
|
return '•';
|
||||||
|
case Letter.Circle:
|
||||||
|
return '◦';
|
||||||
|
case Letter.Square:
|
||||||
|
return '▪';
|
||||||
|
}
|
||||||
|
}, [letter]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
}}
|
||||||
|
data-letter={dataLetter}
|
||||||
|
contentEditable={false}
|
||||||
|
className={`${className} bulleted-icon flex min-w-[24px] justify-center pr-1 font-medium`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,2 @@
|
|||||||
|
export * from './BulletedList';
|
||||||
|
export * from './BulletedListIcon';
|
@ -0,0 +1,22 @@
|
|||||||
|
import { EditorElementProps, CalloutNode } from '@/components/editor/editor.type';
|
||||||
|
import React, { forwardRef, memo } from 'react';
|
||||||
|
import CalloutIcon from './CalloutIcon';
|
||||||
|
|
||||||
|
export const Callout = memo(
|
||||||
|
forwardRef<HTMLDivElement, EditorElementProps<CalloutNode>>(({ node, children, ...attributes }, ref) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div contentEditable={false} className={'absolute w-full select-none px-2 pt-[15px]'}>
|
||||||
|
<CalloutIcon node={node} />
|
||||||
|
</div>
|
||||||
|
<div ref={ref} className={`${attributes.className ?? ''} w-full bg-bg-body py-2`}>
|
||||||
|
<div {...attributes} className={`flex w-full flex-col rounded bg-content-blue-50 py-2 pl-10`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
export default Callout;
|
@ -0,0 +1,17 @@
|
|||||||
|
import { CalloutNode } from '@/components/editor/editor.type';
|
||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { IconButton } from '@mui/material';
|
||||||
|
|
||||||
|
function CalloutIcon({ node }: { node: CalloutNode }) {
|
||||||
|
const ref = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<IconButton contentEditable={false} ref={ref} className={`h-8 w-8 p-1`}>
|
||||||
|
{node.data.icon}
|
||||||
|
</IconButton>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default React.memo(CalloutIcon);
|
@ -0,0 +1 @@
|
|||||||
|
export * from './Callout';
|
@ -0,0 +1,27 @@
|
|||||||
|
import { CodeNode } from '@/components/editor/editor.type';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
import { ReactEditor, useSlateStatic } from 'slate-react';
|
||||||
|
import { Element as SlateElement, Transforms } from 'slate';
|
||||||
|
|
||||||
|
export function useCodeBlock(node: CodeNode) {
|
||||||
|
const language = node.data.language;
|
||||||
|
const editor = useSlateStatic() as ReactEditor;
|
||||||
|
const handleChangeLanguage = useCallback(
|
||||||
|
(newLang: string) => {
|
||||||
|
const path = ReactEditor.findPath(editor, node);
|
||||||
|
const newProperties = {
|
||||||
|
data: {
|
||||||
|
language: newLang,
|
||||||
|
},
|
||||||
|
} as Partial<SlateElement>;
|
||||||
|
|
||||||
|
Transforms.setNodes(editor, newProperties, { at: path });
|
||||||
|
},
|
||||||
|
[editor, node]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
language,
|
||||||
|
handleChangeLanguage,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
import { useCodeBlock } from '@/components/editor/components/blocks/code/Code.hooks';
|
||||||
|
import { CodeNode, EditorElementProps } from '@/components/editor/editor.type';
|
||||||
|
import { forwardRef, memo } from 'react';
|
||||||
|
import LanguageSelect from './SelectLanguage';
|
||||||
|
|
||||||
|
export const CodeBlock = memo(
|
||||||
|
forwardRef<HTMLDivElement, EditorElementProps<CodeNode>>(({ node, children, ...attributes }, ref) => {
|
||||||
|
const { language, handleChangeLanguage } = useCodeBlock(node);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div contentEditable={false} className={'absolute mt-2 flex h-20 w-full select-none items-center px-6'}>
|
||||||
|
<LanguageSelect readOnly language={language} onChangeLanguage={handleChangeLanguage} />
|
||||||
|
</div>
|
||||||
|
<div {...attributes} ref={ref} className={`${attributes.className ?? ''} flex w-full bg-bg-body py-2`}>
|
||||||
|
<pre
|
||||||
|
spellCheck={false}
|
||||||
|
className={`flex w-full rounded border border-solid border-line-divider bg-content-blue-50 p-5 pt-20`}
|
||||||
|
>
|
||||||
|
<code>{children}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
@ -0,0 +1,43 @@
|
|||||||
|
import React, { useRef } from 'react';
|
||||||
|
import { TextField } from '@mui/material';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
function SelectLanguage({
|
||||||
|
readOnly,
|
||||||
|
language = 'json',
|
||||||
|
}: {
|
||||||
|
readOnly?: boolean;
|
||||||
|
language: string;
|
||||||
|
onChangeLanguage: (language: string) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<TextField
|
||||||
|
ref={ref}
|
||||||
|
size={'small'}
|
||||||
|
variant={'standard'}
|
||||||
|
sx={{
|
||||||
|
'& .MuiInputBase-root, & .MuiInputBase-input': {
|
||||||
|
userSelect: 'none',
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
className={'w-[150px]'}
|
||||||
|
value={language}
|
||||||
|
onClick={() => {
|
||||||
|
if (readOnly) return;
|
||||||
|
}}
|
||||||
|
InputProps={{
|
||||||
|
readOnly: true,
|
||||||
|
}}
|
||||||
|
placeholder={t('document.codeBlock.language.placeholder')}
|
||||||
|
label={t('document.codeBlock.language.label')}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SelectLanguage;
|
@ -0,0 +1,154 @@
|
|||||||
|
export const supportLanguage = [
|
||||||
|
{
|
||||||
|
id: 'bash',
|
||||||
|
title: 'Bash',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'basic',
|
||||||
|
title: 'Basic',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'c',
|
||||||
|
title: 'C',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clojure',
|
||||||
|
title: 'Clojure',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cpp',
|
||||||
|
title: 'C++',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cs',
|
||||||
|
title: 'CS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'css',
|
||||||
|
title: 'CSS',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dart',
|
||||||
|
title: 'Dart',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'elixir',
|
||||||
|
title: 'Elixir',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'elm',
|
||||||
|
title: 'Elm',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'erlang',
|
||||||
|
title: 'Erlang',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fortran',
|
||||||
|
title: 'Fortran',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'go',
|
||||||
|
title: 'Go',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'graphql',
|
||||||
|
title: 'GraphQL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'haskell',
|
||||||
|
title: 'Haskell',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'java',
|
||||||
|
title: 'Java',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'javascript',
|
||||||
|
title: 'JavaScript',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'json',
|
||||||
|
title: 'JSON',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kotlin',
|
||||||
|
title: 'Kotlin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lisp',
|
||||||
|
title: 'Lisp',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lua',
|
||||||
|
title: 'Lua',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'markdown',
|
||||||
|
title: 'Markdown',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'matlab',
|
||||||
|
title: 'Matlab',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ocaml',
|
||||||
|
title: 'OCaml',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'perl',
|
||||||
|
title: 'Perl',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'php',
|
||||||
|
title: 'PHP',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'powershell',
|
||||||
|
title: 'Powershell',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'python',
|
||||||
|
title: 'Python',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'r',
|
||||||
|
title: 'R',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ruby',
|
||||||
|
title: 'Ruby',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rust',
|
||||||
|
title: 'Rust',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'scala',
|
||||||
|
title: 'Scala',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shell',
|
||||||
|
title: 'Shell',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sql',
|
||||||
|
title: 'SQL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'swift',
|
||||||
|
title: 'Swift',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'typescript',
|
||||||
|
title: 'TypeScript',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'xml',
|
||||||
|
title: 'XML',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yaml',
|
||||||
|
title: 'YAML',
|
||||||
|
},
|
||||||
|
];
|
@ -0,0 +1 @@
|
|||||||
|
export * from './Code';
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user