mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-22 08:47:10 +08:00
Compare commits
140 Commits
v0.26.3-be
...
v0.12.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9203980a8c | ||
|
|
d34eb2cbe5 | ||
|
|
7f3f993ce4 | ||
|
|
df17001284 | ||
|
|
e400abf1f4 | ||
|
|
640aa00148 | ||
|
|
5ae8f029f7 | ||
|
|
a26e0b3ec9 | ||
|
|
f492b6711b | ||
|
|
81aae61394 | ||
|
|
e08f58beea | ||
|
|
4560819f76 | ||
|
|
193c197a54 | ||
|
|
449c0a38a7 | ||
|
|
8d141e5a81 | ||
|
|
e04911315f | ||
|
|
75d58679b6 | ||
|
|
2e6386e4cf | ||
|
|
f345a61df0 | ||
|
|
a6420fcd76 | ||
|
|
fec406f7e8 | ||
|
|
769398591b | ||
|
|
e01569fff7 | ||
|
|
6bde2de783 | ||
|
|
3513ced6cb | ||
|
|
8dc9addc40 | ||
|
|
9d9f89ef2e | ||
|
|
6cfe5d4566 | ||
|
|
6032b432f8 | ||
|
|
5823787ded | ||
|
|
b3f272ba70 | ||
|
|
a5df5a7c8a | ||
|
|
90de90403a | ||
|
|
4d4e4fc4e2 | ||
|
|
aa73e532d3 | ||
|
|
31faa93c71 | ||
|
|
def60f4c61 | ||
|
|
d15ec0ff77 | ||
|
|
d2acd0385a | ||
|
|
1effb2f25f | ||
|
|
9189d26332 | ||
|
|
79a8be7799 | ||
|
|
1a643cc70c | ||
|
|
9321be3ff5 | ||
|
|
24dc3f95ff | ||
|
|
4257b5f3a4 | ||
|
|
ea17e86032 | ||
|
|
48cd8999bd | ||
|
|
cdf1d9002e | ||
|
|
79b39f14d2 | ||
|
|
619420cfd1 | ||
|
|
739e914b5f | ||
|
|
5e9739eb3a | ||
|
|
0a89b7f528 | ||
|
|
0a0ee37ac2 | ||
|
|
a143379161 | ||
|
|
8e7dedfe82 | ||
|
|
d25a8547d0 | ||
|
|
4d16229fea | ||
|
|
99371be7e8 | ||
|
|
34ed8dd7a5 | ||
|
|
39b7b671b1 | ||
|
|
207b56d5af | ||
|
|
9e94e7195b | ||
|
|
de951c8779 | ||
|
|
fd37026ca5 | ||
|
|
4fd5812a89 | ||
|
|
d01e987ecc | ||
|
|
d87c218c0b | ||
|
|
a5bf5cc244 | ||
|
|
16bcd6e76b | ||
|
|
2e2ace8472 | ||
|
|
37cff8fe8d | ||
|
|
70ab3b4916 | ||
|
|
f42ba54578 | ||
|
|
a67c8181fc | ||
|
|
613efbded9 | ||
|
|
549419d102 | ||
|
|
21c42f8771 | ||
|
|
9012adda7a | ||
|
|
fb442e9055 | ||
|
|
a231474dd2 | ||
|
|
833b42000b | ||
|
|
7690c48710 | ||
|
|
579828a700 | ||
|
|
746db2ccfc | ||
|
|
eff344a9c1 | ||
|
|
c89ebab596 | ||
|
|
62f4421b7c | ||
|
|
42383dbd29 | ||
|
|
120e7397ba | ||
|
|
24123ad01c | ||
|
|
ad50320391 | ||
|
|
eb21a60dda | ||
|
|
c0e3be2d40 | ||
|
|
09d3b72358 | ||
|
|
246e16c6c0 | ||
|
|
dc279d062b | ||
|
|
47d5f9e1c2 | ||
|
|
a226eb8d5f | ||
|
|
908c4e1a6f | ||
|
|
1d0bcc80a0 | ||
|
|
50010bd824 | ||
|
|
c0ede1326d | ||
|
|
89197bacef | ||
|
|
f97d323ab5 | ||
|
|
2acb219dcc | ||
|
|
992ed89a89 | ||
|
|
d272d7922d | ||
|
|
c1cd1713b9 | ||
|
|
b20e91bee0 | ||
|
|
9a4e5ec8c3 | ||
|
|
2019838ae7 | ||
|
|
30ff25f400 | ||
|
|
e766208c18 | ||
|
|
8742f28148 | ||
|
|
cd291bb60e | ||
|
|
62c0efcfd1 | ||
|
|
87248b3337 | ||
|
|
00c940f7df | ||
|
|
931b459fbd | ||
|
|
51e71f4a0a | ||
|
|
9b631f2328 | ||
|
|
01f481a9b6 | ||
|
|
0177ab5c87 | ||
|
|
4db35d341c | ||
|
|
3c4a803c97 | ||
|
|
05154dc7ca | ||
|
|
c90b477f60 | ||
|
|
6f18ddbe85 | ||
|
|
dde779a71d | ||
|
|
bd9f66fbc7 | ||
|
|
92f1f40bfa | ||
|
|
48dc1049b3 | ||
|
|
9add530370 | ||
|
|
b77460d871 | ||
|
|
42db41776b | ||
|
|
075439c74f | ||
|
|
fc6c553ece | ||
|
|
59cb3d5df1 |
@@ -12,3 +12,4 @@ static
|
|||||||
web-static
|
web-static
|
||||||
public
|
public
|
||||||
packages/frontend/i18n/src/i18n-generated.ts
|
packages/frontend/i18n/src/i18n-generated.ts
|
||||||
|
packages/frontend/templates/edgeless-templates.gen.ts
|
||||||
|
|||||||
2
.github/deployment/front/Dockerfile
vendored
2
.github/deployment/front/Dockerfile
vendored
@@ -1,6 +1,6 @@
|
|||||||
FROM openresty/openresty:1.21.4.3-0-buster
|
FROM openresty/openresty:1.21.4.3-0-buster
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY ./packages/frontend/core/dist/index.html ./dist/index.html
|
COPY ./packages/frontend/core/dist ./dist
|
||||||
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
COPY ./.github/deployment/front/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf
|
||||||
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
|
COPY ./.github/deployment/front/affine.nginx.conf /etc/nginx/conf.d/affine.nginx.conf
|
||||||
|
|
||||||
|
|||||||
6
.github/deployment/self-host/compose.yaml
vendored
6
.github/deployment/self-host/compose.yaml
vendored
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
affine:
|
affine:
|
||||||
image: ghcr.io/toeverything/affine-graphql:beta
|
image: ghcr.io/toeverything/affine-graphql:stable
|
||||||
container_name: affine_selfhosted
|
container_name: affine_selfhosted
|
||||||
command:
|
command:
|
||||||
['sh', '-c', 'node ./scripts/self-host-predeploy && node ./dist/index.js']
|
['sh', '-c', 'node ./scripts/self-host-predeploy && node ./dist/index.js']
|
||||||
@@ -23,13 +23,11 @@ services:
|
|||||||
max-size: '1000m'
|
max-size: '1000m'
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
- NODE_OPTIONS=--es-module-specifier-resolution node
|
- NODE_OPTIONS=--es-module-specifier-resolution=node
|
||||||
- AFFINE_CONFIG_PATH=/root/.affine/config
|
- AFFINE_CONFIG_PATH=/root/.affine/config
|
||||||
- REDIS_SERVER_HOST=redis
|
- REDIS_SERVER_HOST=redis
|
||||||
- DATABASE_URL=postgres://affine:affine@postgres:5432/affine
|
- DATABASE_URL=postgres://affine:affine@postgres:5432/affine
|
||||||
- DISABLE_TELEMETRY=true
|
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- SERVER_FLAVOR=selfhosted
|
|
||||||
- AFFINE_ADMIN_EMAIL=${AFFINE_ADMIN_EMAIL}
|
- AFFINE_ADMIN_EMAIL=${AFFINE_ADMIN_EMAIL}
|
||||||
- AFFINE_ADMIN_PASSWORD=${AFFINE_ADMIN_PASSWORD}
|
- AFFINE_ADMIN_PASSWORD=${AFFINE_ADMIN_PASSWORD}
|
||||||
redis:
|
redis:
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ spec:
|
|||||||
value: "--max-old-space-size=4096"
|
value: "--max-old-space-size=4096"
|
||||||
- name: NO_COLOR
|
- name: NO_COLOR
|
||||||
value: "1"
|
value: "1"
|
||||||
|
- name: DEPLOYMENT_TYPE
|
||||||
|
value: "affine"
|
||||||
- name: SERVER_FLAVOR
|
- name: SERVER_FLAVOR
|
||||||
value: "graphql"
|
value: "graphql"
|
||||||
- name: AFFINE_ENV
|
- name: AFFINE_ENV
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ spec:
|
|||||||
value: "{{ .Values.env }}"
|
value: "{{ .Values.env }}"
|
||||||
- name: NO_COLOR
|
- name: NO_COLOR
|
||||||
value: "1"
|
value: "1"
|
||||||
|
- name: DEPLOYMENT_TYPE
|
||||||
|
value: "affine"
|
||||||
- name: SERVER_FLAVOR
|
- name: SERVER_FLAVOR
|
||||||
value: "sync"
|
value: "sync"
|
||||||
- name: NEXTAUTH_URL
|
- name: NEXTAUTH_URL
|
||||||
|
|||||||
4
.github/renovate.json
vendored
4
.github/renovate.json
vendored
@@ -47,11 +47,11 @@
|
|||||||
"groupName": "electron-forge"
|
"groupName": "electron-forge"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "blocksuite-nightly",
|
"groupName": "blocksuite-canary",
|
||||||
"matchPackagePatterns": ["^@blocksuite"],
|
"matchPackagePatterns": ["^@blocksuite"],
|
||||||
"excludePackageNames": ["@blocksuite/icons"],
|
"excludePackageNames": ["@blocksuite/icons"],
|
||||||
"rangeStrategy": "replace",
|
"rangeStrategy": "replace",
|
||||||
"followTag": "nightly"
|
"followTag": "canary"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"groupName": "all non-major dependencies",
|
"groupName": "all non-major dependencies",
|
||||||
|
|||||||
3
.github/workflows/build-test.yml
vendored
3
.github/workflows/build-test.yml
vendored
@@ -19,7 +19,7 @@ env:
|
|||||||
MACOSX_DEPLOYMENT_TARGET: '10.13'
|
MACOSX_DEPLOYMENT_TARGET: '10.13'
|
||||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/node_modules/.cache/ms-playwright
|
||||||
DISABLE_TELEMETRY: true
|
DEPLOYMENT_TYPE: affine
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ${{ github.workflow }}-${{ github.ref }}
|
group: ${{ github.workflow }}-${{ github.ref }}
|
||||||
@@ -291,6 +291,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build-storage
|
needs: build-storage
|
||||||
env:
|
env:
|
||||||
|
NODE_ENV: test
|
||||||
DISTRIBUTION: browser
|
DISTRIBUTION: browser
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
|
|||||||
2
.github/workflows/release-desktop.yml
vendored
2
.github/workflows/release-desktop.yml
vendored
@@ -143,7 +143,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p builds
|
mkdir -p builds
|
||||||
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
|
mv packages/frontend/electron/out/*/make/zip/linux/x64/*.zip ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.zip
|
||||||
mv packages/frontend/electron/out/*/make/AppImage/x64/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
mv packages/frontend/electron/out/*/make/*.AppImage ./builds/affine-${{ env.BUILD_TYPE }}-linux-x64.AppImage
|
||||||
|
|
||||||
- name: Upload Artifact
|
- name: Upload Artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -79,3 +79,6 @@ lib
|
|||||||
affine.db
|
affine.db
|
||||||
apps/web/next-routes.conf
|
apps/web/next-routes.conf
|
||||||
.nx
|
.nx
|
||||||
|
|
||||||
|
packages/frontend/templates/edgeless
|
||||||
|
packages/frontend/core/public/static/templates
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ packages/frontend/i18n/src/i18n-generated.ts
|
|||||||
packages/frontend/graphql/src/graphql/index.ts
|
packages/frontend/graphql/src/graphql/index.ts
|
||||||
tests/affine-legacy/**/static
|
tests/affine-legacy/**/static
|
||||||
.yarnrc.yml
|
.yarnrc.yml
|
||||||
|
packages/frontend/templates/edgeless-templates.gen.ts
|
||||||
packages/frontend/templates/templates.gen.ts
|
packages/frontend/templates/templates.gen.ts
|
||||||
packages/frontend/templates/onboarding
|
packages/frontend/templates/onboarding
|
||||||
|
|
||||||
|
|||||||
1000
Cargo.lock
generated
1000
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -76,7 +76,7 @@
|
|||||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||||
"@vitest/coverage-istanbul": "1.1.3",
|
"@vitest/coverage-istanbul": "1.1.3",
|
||||||
"@vitest/ui": "1.1.3",
|
"@vitest/ui": "1.1.3",
|
||||||
"electron": "^28.1.4",
|
"electron": "^28.2.1",
|
||||||
"eslint": "^8.54.0",
|
"eslint": "^8.54.0",
|
||||||
"eslint-config-prettier": "^9.0.0",
|
"eslint-config-prettier": "^9.0.0",
|
||||||
"eslint-plugin-i": "^2.29.0",
|
"eslint-plugin-i": "^2.29.0",
|
||||||
|
|||||||
@@ -40,21 +40,21 @@
|
|||||||
"@node-rs/crc32": "^1.7.2",
|
"@node-rs/crc32": "^1.7.2",
|
||||||
"@node-rs/jsonwebtoken": "^0.3.0",
|
"@node-rs/jsonwebtoken": "^0.3.0",
|
||||||
"@opentelemetry/api": "^1.7.0",
|
"@opentelemetry/api": "^1.7.0",
|
||||||
"@opentelemetry/core": "^1.20.0",
|
"@opentelemetry/core": "^1.21.0",
|
||||||
"@opentelemetry/exporter-prometheus": "^0.47.0",
|
"@opentelemetry/exporter-prometheus": "^0.48.0",
|
||||||
"@opentelemetry/exporter-zipkin": "^1.20.0",
|
"@opentelemetry/exporter-zipkin": "^1.21.0",
|
||||||
"@opentelemetry/host-metrics": "^0.34.0",
|
"@opentelemetry/host-metrics": "^0.35.0",
|
||||||
"@opentelemetry/instrumentation": "^0.47.0",
|
"@opentelemetry/instrumentation": "^0.48.0",
|
||||||
"@opentelemetry/instrumentation-graphql": "^0.36.0",
|
"@opentelemetry/instrumentation-graphql": "^0.37.0",
|
||||||
"@opentelemetry/instrumentation-http": "^0.47.0",
|
"@opentelemetry/instrumentation-http": "^0.48.0",
|
||||||
"@opentelemetry/instrumentation-ioredis": "^0.36.0",
|
"@opentelemetry/instrumentation-ioredis": "^0.37.0",
|
||||||
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
|
"@opentelemetry/instrumentation-nestjs-core": "^0.34.0",
|
||||||
"@opentelemetry/instrumentation-socket.io": "^0.35.0",
|
"@opentelemetry/instrumentation-socket.io": "^0.36.0",
|
||||||
"@opentelemetry/resources": "^1.20.0",
|
"@opentelemetry/resources": "^1.21.0",
|
||||||
"@opentelemetry/sdk-metrics": "^1.20.0",
|
"@opentelemetry/sdk-metrics": "^1.21.0",
|
||||||
"@opentelemetry/sdk-node": "^0.47.0",
|
"@opentelemetry/sdk-node": "^0.48.0",
|
||||||
"@opentelemetry/sdk-trace-node": "^1.20.0",
|
"@opentelemetry/sdk-trace-node": "^1.21.0",
|
||||||
"@opentelemetry/semantic-conventions": "^1.20.0",
|
"@opentelemetry/semantic-conventions": "^1.21.0",
|
||||||
"@prisma/client": "^5.7.1",
|
"@prisma/client": "^5.7.1",
|
||||||
"@prisma/instrumentation": "^5.7.1",
|
"@prisma/instrumentation": "^5.7.1",
|
||||||
"@socket.io/redis-adapter": "^8.2.1",
|
"@socket.io/redis-adapter": "^8.2.1",
|
||||||
@@ -162,7 +162,6 @@
|
|||||||
"env": {
|
"env": {
|
||||||
"TS_NODE_TRANSPILE_ONLY": true,
|
"TS_NODE_TRANSPILE_ONLY": true,
|
||||||
"TS_NODE_PROJECT": "./tsconfig.json",
|
"TS_NODE_PROJECT": "./tsconfig.json",
|
||||||
"NODE_ENV": "development",
|
|
||||||
"DEBUG": "affine:*",
|
"DEBUG": "affine:*",
|
||||||
"FORCE_COLOR": true,
|
"FORCE_COLOR": true,
|
||||||
"DEBUG_COLORS": true
|
"DEBUG_COLORS": true
|
||||||
|
|||||||
@@ -265,7 +265,9 @@ model Snapshot {
|
|||||||
seq Int @default(0) @db.Integer
|
seq Int @default(0) @db.Integer
|
||||||
state Bytes? @db.ByteA
|
state Bytes? @db.ByteA
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
// the `updated_at` field will not record the time of record changed,
|
||||||
|
// but the created time of last seen update that has been merged into snapshot.
|
||||||
|
updatedAt DateTime @map("updated_at") @db.Timestamptz(6)
|
||||||
|
|
||||||
@@id([id, workspaceId])
|
@@id([id, workspaceId])
|
||||||
@@map("snapshots")
|
@@map("snapshots")
|
||||||
|
|||||||
@@ -13,7 +13,10 @@ const configFiles = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function configCleaner(content) {
|
function configCleaner(content) {
|
||||||
return content.replace(/(\/\/#.*$)|(\/\/\s+TODO.*$)/gm, '');
|
return content.replace(
|
||||||
|
/(^\/\/#.*$)|(^\/\/\s+TODO.*$)|("use\sstrict";?)|(^.*eslint-disable.*$)/gm,
|
||||||
|
''
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepare() {
|
function prepare() {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export class AppController {
|
|||||||
return {
|
return {
|
||||||
compatibility: this.config.version,
|
compatibility: this.config.version,
|
||||||
message: `AFFiNE ${this.config.version} Server`,
|
message: `AFFiNE ${this.config.version} Server`,
|
||||||
|
type: this.config.type,
|
||||||
flavor: this.config.flavor,
|
flavor: this.config.flavor,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ export class AppModuleBuilder {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
imports: this.modules,
|
imports: this.modules,
|
||||||
controllers: this.config.flavor.selfhosted ? [] : [AppController],
|
controllers: this.config.isSelfhosted ? [] : [AppController],
|
||||||
})
|
})
|
||||||
class AppModule {}
|
class AppModule {}
|
||||||
|
|
||||||
@@ -132,9 +132,9 @@ function buildAppModule() {
|
|||||||
// sync server only
|
// sync server only
|
||||||
.useIf(config => config.flavor.sync, SyncModule)
|
.useIf(config => config.flavor.sync, SyncModule)
|
||||||
|
|
||||||
// main server only
|
// graphql server only
|
||||||
.useIf(
|
.useIf(
|
||||||
config => config.flavor.main,
|
config => config.flavor.graphql,
|
||||||
ServerConfigModule,
|
ServerConfigModule,
|
||||||
WebSocketModule,
|
WebSocketModule,
|
||||||
GqlModule,
|
GqlModule,
|
||||||
@@ -147,7 +147,7 @@ function buildAppModule() {
|
|||||||
|
|
||||||
// self hosted server only
|
// self hosted server only
|
||||||
.useIf(
|
.useIf(
|
||||||
config => config.flavor.selfhosted,
|
config => config.isSelfhosted,
|
||||||
ServeStaticModule.forRoot({
|
ServeStaticModule.forRoot({
|
||||||
rootPath: join('/app', 'static'),
|
rootPath: join('/app', 'static'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ AFFiNE.ENV_MAP = {
|
|||||||
AFFINE_SERVER_PORT: ['port', 'int'],
|
AFFINE_SERVER_PORT: ['port', 'int'],
|
||||||
AFFINE_SERVER_HOST: 'host',
|
AFFINE_SERVER_HOST: 'host',
|
||||||
AFFINE_SERVER_SUB_PATH: 'path',
|
AFFINE_SERVER_SUB_PATH: 'path',
|
||||||
AFFIHE_SERVER_HTTPS: ['https', 'boolean'],
|
AFFINE_SERVER_HTTPS: ['https', 'boolean'],
|
||||||
AFFINE_ENV: 'affineEnv',
|
|
||||||
DATABASE_URL: 'db.url',
|
DATABASE_URL: 'db.url',
|
||||||
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
ENABLE_CAPTCHA: ['auth.captcha.enable', 'boolean'],
|
||||||
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
CAPTCHA_TURNSTILE_SECRET: ['auth.captcha.turnstile.secret', 'string'],
|
||||||
@@ -28,7 +27,7 @@ AFFiNE.ENV_MAP = {
|
|||||||
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
|
REDIS_SERVER_DATABASE: ['plugins.redis.db', 'int'],
|
||||||
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
|
DOC_MERGE_INTERVAL: ['doc.manager.updatePollInterval', 'int'],
|
||||||
DOC_MERGE_USE_JWST_CODEC: [
|
DOC_MERGE_USE_JWST_CODEC: [
|
||||||
'doc.manager.experimentalMergeWithJwstCodec',
|
'doc.manager.experimentalMergeWithYOcto',
|
||||||
'boolean',
|
'boolean',
|
||||||
],
|
],
|
||||||
ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'],
|
ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'],
|
||||||
@@ -36,5 +35,3 @@ AFFiNE.ENV_MAP = {
|
|||||||
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
|
STRIPE_WEBHOOK_KEY: 'plugins.payment.stripe.keys.webhookKey',
|
||||||
FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'],
|
FEATURES_EARLY_ACCESS_PREVIEW: ['featureFlags.earlyAccessPreview', 'boolean'],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default AFFiNE;
|
|
||||||
|
|||||||
46
packages/backend/server/src/config/affine.self.ts
Normal file
46
packages/backend/server/src/config/affine.self.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
// Custom configurations for AFFiNE Cloud
|
||||||
|
// ====================================================================================
|
||||||
|
// Q: WHY THIS FILE EXISTS?
|
||||||
|
// A: AFFiNE deployment environment may have a lot of custom environment variables,
|
||||||
|
// which are not suitable to be put in the `affine.ts` file.
|
||||||
|
// For example, AFFiNE Cloud Clusters are deployed on Google Cloud Platform.
|
||||||
|
// We need to enable the `gcloud` plugin to make sure the nodes working well,
|
||||||
|
// but the default selfhost version may not require it.
|
||||||
|
// So it's not a good idea to put such logic in the common `affine.ts` file.
|
||||||
|
//
|
||||||
|
// ```
|
||||||
|
// if (AFFiNE.deploy) {
|
||||||
|
// AFFiNE.plugins.use('gcloud');
|
||||||
|
// }
|
||||||
|
// ```
|
||||||
|
// ====================================================================================
|
||||||
|
const env = process.env;
|
||||||
|
|
||||||
|
AFFiNE.metrics.enabled = !AFFiNE.node.test;
|
||||||
|
|
||||||
|
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
||||||
|
AFFiNE.storage.providers.r2 = {
|
||||||
|
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
|
||||||
|
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
AFFiNE.storage.storages.avatar.provider = 'r2';
|
||||||
|
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
|
||||||
|
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
|
||||||
|
`https://avatar.affineassets.com/${key}`;
|
||||||
|
|
||||||
|
AFFiNE.storage.storages.blob.provider = 'r2';
|
||||||
|
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
|
||||||
|
AFFiNE.affine.canary ? 'canary' : 'prod'
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
AFFiNE.plugins.use('redis');
|
||||||
|
AFFiNE.plugins.use('payment');
|
||||||
|
|
||||||
|
if (AFFiNE.deploy) {
|
||||||
|
AFFiNE.plugins.use('gcloud');
|
||||||
|
}
|
||||||
@@ -1,39 +1,94 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
// Custom configurations
|
//
|
||||||
const env = process.env;
|
// ###############################################################
|
||||||
|
// ## AFFiNE Configuration System ##
|
||||||
// TODO(@forehalo): detail explained
|
// ###############################################################
|
||||||
// Storage
|
// Here is the file of all AFFiNE configurations that will affect runtime behavior.
|
||||||
if (env.R2_OBJECT_STORAGE_ACCOUNT_ID) {
|
// Override any configuration here and it will be merged when starting the server.
|
||||||
AFFiNE.storage.providers.r2 = {
|
// Any changes in this file won't take effect before server restarted.
|
||||||
accountId: env.R2_OBJECT_STORAGE_ACCOUNT_ID,
|
//
|
||||||
credentials: {
|
//
|
||||||
accessKeyId: env.R2_OBJECT_STORAGE_ACCESS_KEY_ID!,
|
// > Configurations merge order
|
||||||
secretAccessKey: env.R2_OBJECT_STORAGE_SECRET_ACCESS_KEY!,
|
// 1. load environment variables (`.env` if provided, and from system)
|
||||||
},
|
// 2. load `src/fundamentals/config/default.ts` for all default settings
|
||||||
};
|
// 3. apply `./affine.ts` patches (this file)
|
||||||
AFFiNE.storage.storages.avatar.provider = 'r2';
|
// 4. apply `./affine.env.ts` patches
|
||||||
AFFiNE.storage.storages.avatar.bucket = 'account-avatar';
|
//
|
||||||
AFFiNE.storage.storages.avatar.publicLinkFactory = key =>
|
//
|
||||||
`https://avatar.affineassets.com/${key}`;
|
// ###############################################################
|
||||||
|
// ## General settings ##
|
||||||
AFFiNE.storage.storages.blob.provider = 'r2';
|
// ###############################################################
|
||||||
AFFiNE.storage.storages.blob.bucket = `workspace-blobs-${
|
//
|
||||||
AFFiNE.affine.canary ? 'canary' : 'prod'
|
// /* The unique identity of the server */
|
||||||
}`;
|
// AFFiNE.serverId = 'some-randome-uuid';
|
||||||
}
|
//
|
||||||
|
// /* The name of AFFiNE Server, may show on the UI */
|
||||||
// Metrics
|
// AFFiNE.serverName = 'Your Cool AFFiNE Selfhosted Cloud';
|
||||||
AFFiNE.metrics.enabled = true;
|
//
|
||||||
|
// /* Whether the server is deployed behind a HTTPS proxied environment */
|
||||||
// Plugins Section Start
|
AFFiNE.https = false;
|
||||||
AFFiNE.plugins.use('payment', {
|
// /* Domain of your server that your server will be available at */
|
||||||
stripe: {
|
AFFiNE.host = 'localhost';
|
||||||
keys: {},
|
// /* The local port of your server that will listen on */
|
||||||
apiVersion: '2023-10-16',
|
AFFiNE.port = 3010;
|
||||||
},
|
// /* The sub path of your server */
|
||||||
|
// /* For example, if you set `AFFiNE.path = '/affine'`, then the server will be available at `${domain}/affine` */
|
||||||
|
// AFFiNE.path = '/affine';
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// ###############################################################
|
||||||
|
// ## Database settings ##
|
||||||
|
// ###############################################################
|
||||||
|
//
|
||||||
|
// /* The URL of the database where most of AFFiNE server data will be stored in */
|
||||||
|
// AFFiNE.db.url = 'postgres://user:passsword@localhost:5432/affine';
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// ###############################################################
|
||||||
|
// ## Server Function settings ##
|
||||||
|
// ###############################################################
|
||||||
|
//
|
||||||
|
// /* Whether enable metrics and tracing while running the server */
|
||||||
|
// /* The metrics will be available at `http://localhost:9464/metrics` with [Prometheus] format exported */
|
||||||
|
// AFFiNE.metrics.enabled = true;
|
||||||
|
//
|
||||||
|
// /* GraphQL configurations that control the behavior of the Apollo Server behind */
|
||||||
|
// /* @see https://www.apollographql.com/docs/apollo-server/api/apollo-server */
|
||||||
|
// AFFiNE.graphql = {
|
||||||
|
// /* Path to mount GraphQL API */
|
||||||
|
// path: '/graphql',
|
||||||
|
// buildSchemaOptions: {
|
||||||
|
// numberScalarMode: 'integer',
|
||||||
|
// },
|
||||||
|
// /* Whether allow client to query the schema introspection */
|
||||||
|
// introspection: true,
|
||||||
|
// /* Whether enable GraphQL Playground UI */
|
||||||
|
// playground: true,
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// /* Doc Store & Collaberation */
|
||||||
|
// /* How long the buffer time of creating a new history snapshot when doc get updated */
|
||||||
|
// AFFiNE.doc.history.interval = 1000 * 60 * 10; // 10 minutes
|
||||||
|
//
|
||||||
|
// /* Use `y-octo` to merge updates at the same time when merging using Yjs */
|
||||||
|
// AFFiNE.doc.manager.experimentalMergeWithYOcto = true;
|
||||||
|
//
|
||||||
|
// /* How often the manager will start a new turn of merging pending updates into doc snapshot */
|
||||||
|
// AFFiNE.doc.manager.updatePollInterval = 1000 * 3;
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// ###############################################################
|
||||||
|
// ## Plugins settings ##
|
||||||
|
// ###############################################################
|
||||||
|
//
|
||||||
|
// /* Redis Plugin */
|
||||||
|
// /* Provide caching and session storing backed by Redis. */
|
||||||
|
// /* Useful when you deploy AFFiNE server in a cluster. */
|
||||||
|
AFFiNE.plugins.use('redis', {
|
||||||
|
/* override options */
|
||||||
});
|
});
|
||||||
AFFiNE.plugins.use('redis');
|
// /* Payment Plugin */
|
||||||
// Plugins Section end
|
AFFiNE.plugins.use('payment', {
|
||||||
|
stripe: { keys: {}, apiVersion: '2023-10-16' },
|
||||||
export default AFFiNE;
|
});
|
||||||
|
//
|
||||||
|
|||||||
@@ -96,6 +96,24 @@ export const NextAuthOptionsProvider: FactoryProvider<NextAuthOptions> = {
|
|||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
prismaAdapter.createVerificationToken = async data => {
|
||||||
|
await session.set(
|
||||||
|
`${data.identifier}:${data.token}`,
|
||||||
|
Date.now() + session.sessionTtl
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
prismaAdapter.useVerificationToken = async ({ identifier, token }) => {
|
||||||
|
const expires = await session.get(`${identifier}:${token}`);
|
||||||
|
if (expires) {
|
||||||
|
return { identifier, token, expires: new Date(expires) };
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const nextAuthOptions: NextAuthOptions = {
|
const nextAuthOptions: NextAuthOptions = {
|
||||||
providers: [
|
providers: [
|
||||||
// @ts-expect-error esm interop issue
|
// @ts-expect-error esm interop issue
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export class AuthService {
|
|||||||
return (
|
return (
|
||||||
!!outcome.success &&
|
!!outcome.success &&
|
||||||
// skip hostname check in dev mode
|
// skip hostname check in dev mode
|
||||||
(this.config.affineEnv === 'dev' || outcome.hostname === this.config.host)
|
(this.config.node.dev || outcome.hostname === this.config.host)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
|
import { Field, ObjectType, Query, registerEnumType } from '@nestjs/graphql';
|
||||||
|
|
||||||
|
import { DeploymentType } from '../fundamentals';
|
||||||
|
|
||||||
export enum ServerFeature {
|
export enum ServerFeature {
|
||||||
Payment = 'payment',
|
Payment = 'payment',
|
||||||
}
|
}
|
||||||
@@ -9,6 +11,10 @@ registerEnumType(ServerFeature, {
|
|||||||
name: 'ServerFeature',
|
name: 'ServerFeature',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
registerEnumType(DeploymentType, {
|
||||||
|
name: 'ServerDeploymentType',
|
||||||
|
});
|
||||||
|
|
||||||
const ENABLED_FEATURES: ServerFeature[] = [];
|
const ENABLED_FEATURES: ServerFeature[] = [];
|
||||||
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
export function ADD_ENABLED_FEATURES(feature: ServerFeature) {
|
||||||
ENABLED_FEATURES.push(feature);
|
ENABLED_FEATURES.push(feature);
|
||||||
@@ -28,6 +34,9 @@ export class ServerConfigType {
|
|||||||
@Field({ description: 'server base url' })
|
@Field({ description: 'server base url' })
|
||||||
baseUrl!: string;
|
baseUrl!: string;
|
||||||
|
|
||||||
|
@Field(() => DeploymentType, { description: 'server type' })
|
||||||
|
type!: DeploymentType;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @deprecated
|
* @deprecated
|
||||||
*/
|
*/
|
||||||
@@ -46,7 +55,11 @@ export class ServerConfigResolver {
|
|||||||
name: AFFiNE.serverName,
|
name: AFFiNE.serverName,
|
||||||
version: AFFiNE.version,
|
version: AFFiNE.version,
|
||||||
baseUrl: AFFiNE.baseUrl,
|
baseUrl: AFFiNE.baseUrl,
|
||||||
flavor: AFFiNE.flavor.type,
|
type: AFFiNE.type,
|
||||||
|
// BACKWARD COMPATIBILITY
|
||||||
|
// the old flavors contains `selfhosted` but it actually not flavor but deployment type
|
||||||
|
// this field should be removed after frontend feature flags implemented
|
||||||
|
flavor: AFFiNE.type,
|
||||||
features: ENABLED_FEATURES,
|
features: ENABLED_FEATURES,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { chunk } from 'lodash-es';
|
|||||||
import { defer, retry } from 'rxjs';
|
import { defer, retry } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
applyUpdate,
|
applyUpdate,
|
||||||
decodeStateVector,
|
|
||||||
Doc,
|
Doc,
|
||||||
encodeStateAsUpdate,
|
encodeStateAsUpdate,
|
||||||
encodeStateVector,
|
encodeStateVector,
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
Cache,
|
Cache,
|
||||||
|
CallTimer,
|
||||||
Config,
|
Config,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
type EventPayload,
|
type EventPayload,
|
||||||
@@ -45,36 +45,6 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
|||||||
return compare(yBinary, yBinary2, true);
|
return compare(yBinary, yBinary2, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect whether rhs state is newer than lhs state.
|
|
||||||
*
|
|
||||||
* How could we tell a state is newer:
|
|
||||||
*
|
|
||||||
* i. if the state vector size is larger, it's newer
|
|
||||||
* ii. if the state vector size is same, compare each client's state
|
|
||||||
*/
|
|
||||||
function isStateNewer(lhs: Buffer, rhs: Buffer): boolean {
|
|
||||||
const lhsVector = decodeStateVector(lhs);
|
|
||||||
const rhsVector = decodeStateVector(rhs);
|
|
||||||
|
|
||||||
if (lhsVector.size < rhsVector.size) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [client, state] of lhsVector) {
|
|
||||||
const rstate = rhsVector.get(client);
|
|
||||||
if (!rstate) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (state < rstate) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isEmptyBuffer(buf: Buffer): boolean {
|
export function isEmptyBuffer(buf: Buffer): boolean {
|
||||||
return (
|
return (
|
||||||
buf.length === 0 ||
|
buf.length === 0 ||
|
||||||
@@ -119,6 +89,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
this.destroy();
|
this.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@CallTimer('doc', 'yjs_recover_updates_to_doc')
|
||||||
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
private recoverDoc(...updates: Buffer[]): Promise<Doc> {
|
||||||
const doc = new Doc();
|
const doc = new Doc();
|
||||||
const chunks = chunk(updates, 10);
|
const chunks = chunk(updates, 10);
|
||||||
@@ -154,11 +125,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
const doc = await this.recoverDoc(...updates);
|
const doc = await this.recoverDoc(...updates);
|
||||||
|
|
||||||
// test jwst codec
|
// test jwst codec
|
||||||
if (
|
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
||||||
this.config.affine.canary &&
|
|
||||||
this.config.doc.manager.experimentalMergeWithJwstCodec &&
|
|
||||||
updates.length < 100 /* avoid overloading */
|
|
||||||
) {
|
|
||||||
metrics.jwst.counter('codec_merge_counter').add(1);
|
metrics.jwst.counter('codec_merge_counter').add(1);
|
||||||
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
||||||
let log = false;
|
let log = false;
|
||||||
@@ -209,7 +176,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
}, this.config.doc.manager.updatePollInterval);
|
}, this.config.doc.manager.updatePollInterval);
|
||||||
|
|
||||||
this.logger.log('Automation started');
|
this.logger.log('Automation started');
|
||||||
if (this.config.doc.manager.experimentalMergeWithJwstCodec) {
|
if (this.config.doc.manager.experimentalMergeWithYOcto) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
'Experimental feature enabled: merge updates with jwst codec is enabled'
|
'Experimental feature enabled: merge updates with jwst codec is enabled'
|
||||||
);
|
);
|
||||||
@@ -382,7 +349,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
const updates = await this.getUpdates(workspaceId, guid);
|
const updates = await this.getUpdates(workspaceId, guid);
|
||||||
|
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
const doc = await this.squash(updates, snapshot);
|
const doc = await this.squash(snapshot, updates);
|
||||||
return Buffer.from(encodeStateVector(doc));
|
return Buffer.from(encodeStateVector(doc));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -415,7 +382,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
// take it ease, we don't want to overload db and or cpu
|
// take it ease, we don't want to overload db and or cpu
|
||||||
// if we limit the taken number here,
|
// if we limit the taken number here,
|
||||||
// user will never see the latest doc if there are too many updates pending to be merged.
|
// user will never see the latest doc if there are too many updates pending to be merged.
|
||||||
take: 100,
|
take: this.config.doc.manager.maxUpdatesPullCount,
|
||||||
});
|
});
|
||||||
|
|
||||||
// perf(memory): avoid sorting in db
|
// perf(memory): avoid sorting in db
|
||||||
@@ -463,80 +430,92 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @returns whether the snapshot is updated to the latest, `undefined` means the doc to be upserted is outdated.
|
||||||
|
*/
|
||||||
|
@CallTimer('doc', 'upsert')
|
||||||
private async upsert(
|
private async upsert(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
guid: string,
|
guid: string,
|
||||||
doc: Doc,
|
doc: Doc,
|
||||||
// we always delay the snapshot update to avoid db overload,
|
// we always delay the snapshot update to avoid db overload,
|
||||||
// so the value of `updatedAt` will not be accurate to user's real action time
|
// so the value of auto updated `updatedAt` by db will never be accurate to user's real action time
|
||||||
updatedAt: Date,
|
updatedAt: Date,
|
||||||
initialSeq?: number
|
seq: number
|
||||||
) {
|
) {
|
||||||
return this.lockSnapshotForUpsert(workspaceId, guid, async () => {
|
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
||||||
const blob = Buffer.from(encodeStateAsUpdate(doc));
|
|
||||||
|
|
||||||
if (isEmptyBuffer(blob)) {
|
if (isEmptyBuffer(blob)) {
|
||||||
return false;
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = Buffer.from(encodeStateVector(doc));
|
||||||
|
|
||||||
|
// CONCERNS:
|
||||||
|
// i. Because we save the real user's last seen action time as `updatedAt`,
|
||||||
|
// it's possible to simply compare the `updatedAt` to determine if the snapshot is older than the one we are going to save.
|
||||||
|
//
|
||||||
|
// ii. Prisma doesn't support `upsert` with additional `where` condition along side unique constraint.
|
||||||
|
// In our case, we need to manually check the `updatedAt` to avoid overriding the newer snapshot.
|
||||||
|
// where: { id_workspaceId: {}, updatedAt: { lt: updatedAt } }
|
||||||
|
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||||
|
//
|
||||||
|
// iii. Only set the seq number when creating the snapshot.
|
||||||
|
// For updating scenario, the seq number will be updated when updates pushed to db.
|
||||||
|
try {
|
||||||
|
const result: { updatedAt: Date }[] = await this.db.$queryRaw`
|
||||||
|
INSERT INTO "snapshots" ("workspace_id", "guid", "blob", "state", "seq", "created_at", "updated_at")
|
||||||
|
VALUES (${workspaceId}, ${guid}, ${blob}, ${state}, ${seq}, DEFAULT, ${updatedAt})
|
||||||
|
ON CONFLICT ("workspace_id", "guid")
|
||||||
|
DO UPDATE SET "blob" = ${blob}, "state" = ${state}, "updated_at" = ${updatedAt}, "seq" = ${seq}
|
||||||
|
WHERE "snapshots"."workspace_id" = ${workspaceId} AND "snapshots"."guid" = ${guid} AND "snapshots"."updated_at" <= ${updatedAt}
|
||||||
|
RETURNING "snapshots"."workspace_id" as "workspaceId", "snapshots"."guid" as "id", "snapshots"."updated_at" as "updatedAt"
|
||||||
|
`;
|
||||||
|
|
||||||
|
// const result = await this.db.snapshot.upsert({
|
||||||
|
// select: {
|
||||||
|
// updatedAt: true,
|
||||||
|
// seq: true,
|
||||||
|
// },
|
||||||
|
// where: {
|
||||||
|
// id_workspaceId: {
|
||||||
|
// workspaceId,
|
||||||
|
// id: guid,
|
||||||
|
// },
|
||||||
|
// ⬇️ NOT SUPPORTED BY PRISMA YET
|
||||||
|
// updatedAt: {
|
||||||
|
// lt: updatedAt,
|
||||||
|
// },
|
||||||
|
// },
|
||||||
|
// update: {
|
||||||
|
// blob,
|
||||||
|
// state,
|
||||||
|
// updatedAt,
|
||||||
|
// },
|
||||||
|
// create: {
|
||||||
|
// workspaceId,
|
||||||
|
// id: guid,
|
||||||
|
// blob,
|
||||||
|
// state,
|
||||||
|
// updatedAt,
|
||||||
|
// seq,
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
|
||||||
|
// if the condition `snapshot.updatedAt > updatedAt` is true, by which means the snapshot has already been updated by other process,
|
||||||
|
// the updates has been applied to current `doc` must have been seen by the other process as well.
|
||||||
|
// The `updatedSnapshot` will be `undefined` in this case.
|
||||||
|
const updatedSnapshot = result.at(0);
|
||||||
|
|
||||||
|
if (!updatedSnapshot) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = Buffer.from(encodeStateVector(doc));
|
return true;
|
||||||
|
} catch (e) {
|
||||||
return await this.db.$transaction(async db => {
|
this.logger.error('Failed to upsert snapshot', e);
|
||||||
const snapshot = await db.snapshot.findUnique({
|
return false;
|
||||||
where: {
|
}
|
||||||
id_workspaceId: {
|
|
||||||
id: guid,
|
|
||||||
workspaceId,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// update
|
|
||||||
if (snapshot) {
|
|
||||||
// only update if state is newer
|
|
||||||
if (isStateNewer(snapshot.state ?? Buffer.from([0]), state)) {
|
|
||||||
await db.snapshot.update({
|
|
||||||
select: {
|
|
||||||
seq: true,
|
|
||||||
},
|
|
||||||
where: {
|
|
||||||
id_workspaceId: {
|
|
||||||
workspaceId,
|
|
||||||
id: guid,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
blob,
|
|
||||||
state,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// create
|
|
||||||
await db.snapshot.create({
|
|
||||||
select: {
|
|
||||||
seq: true,
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
id: guid,
|
|
||||||
workspaceId,
|
|
||||||
blob,
|
|
||||||
state,
|
|
||||||
seq: initialSeq,
|
|
||||||
createdAt: updatedAt,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async _get(
|
private async _get(
|
||||||
@@ -548,7 +527,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
if (updates.length) {
|
if (updates.length) {
|
||||||
return {
|
return {
|
||||||
doc: await this.squash(updates, snapshot),
|
doc: await this.squash(snapshot, updates),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -559,17 +538,17 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
* Squash updates into a single update and save it as snapshot,
|
* Squash updates into a single update and save it as snapshot,
|
||||||
* and delete the updates records at the same time.
|
* and delete the updates records at the same time.
|
||||||
*/
|
*/
|
||||||
private async squash(updates: Update[], snapshot: Snapshot | null) {
|
@CallTimer('doc', 'squash')
|
||||||
|
private async squash(snapshot: Snapshot | null, updates: Update[]) {
|
||||||
if (!updates.length) {
|
if (!updates.length) {
|
||||||
throw new Error('No updates to squash');
|
throw new Error('No updates to squash');
|
||||||
}
|
}
|
||||||
const first = updates[0];
|
|
||||||
const last = updates[updates.length - 1];
|
|
||||||
|
|
||||||
const { id, workspaceId } = first;
|
const last = updates[updates.length - 1];
|
||||||
|
const { id, workspaceId } = last;
|
||||||
|
|
||||||
const doc = await this.applyUpdates(
|
const doc = await this.applyUpdates(
|
||||||
first.id,
|
id,
|
||||||
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
snapshot ? snapshot.blob : Buffer.from([0, 0]),
|
||||||
...updates.map(u => u.blob)
|
...updates.map(u => u.blob)
|
||||||
);
|
);
|
||||||
@@ -600,19 +579,24 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// always delete updates
|
// we will keep the updates only if the upsert failed on unknown reason
|
||||||
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
// `done === undefined` means the updates is outdated(have already been merged by other process), safe to be deleted
|
||||||
const { count } = await this.db.update.deleteMany({
|
// `done === true` means the upsert is successful, safe to be deleted
|
||||||
where: {
|
if (done !== false) {
|
||||||
id,
|
// always delete updates
|
||||||
workspaceId,
|
// the upsert will return false if the state is not newer, so we don't need to worry about it
|
||||||
seq: {
|
const { count } = await this.db.update.deleteMany({
|
||||||
in: updates.map(u => u.seq),
|
where: {
|
||||||
|
id,
|
||||||
|
workspaceId,
|
||||||
|
seq: {
|
||||||
|
in: updates.map(u => u.seq),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
});
|
||||||
});
|
|
||||||
|
|
||||||
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
await this.updateCachedUpdatesCount(workspaceId, id, -count);
|
||||||
|
}
|
||||||
|
|
||||||
return doc;
|
return doc;
|
||||||
}
|
}
|
||||||
@@ -761,18 +745,6 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async lockSnapshotForUpsert<T>(
|
|
||||||
workspaceId: string,
|
|
||||||
guid: string,
|
|
||||||
job: () => Promise<T>
|
|
||||||
) {
|
|
||||||
return this.doWithLock(
|
|
||||||
'doc:manager:snapshot',
|
|
||||||
`${workspaceId}::${guid}`,
|
|
||||||
job
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Cron(CronExpression.EVERY_MINUTE)
|
@Cron(CronExpression.EVERY_MINUTE)
|
||||||
async reportUpdatesQueueCount() {
|
async reportUpdatesQueueCount() {
|
||||||
metrics.doc
|
metrics.doc
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export class WorkspacesController {
|
|||||||
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
this.logger.warn(`Blob ${workspaceId}/${name} has no metadata`);
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('cache-control', 'public, max-age=31536000, immutable');
|
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||||
body.pipe(res);
|
body.pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +106,7 @@ export class WorkspacesController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader('content-type', 'application/octet-stream');
|
res.setHeader('content-type', 'application/octet-stream');
|
||||||
|
res.setHeader('cache-control', 'no-cache');
|
||||||
res.send(update);
|
res.send(update);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +143,7 @@ export class WorkspacesController {
|
|||||||
|
|
||||||
if (history) {
|
if (history) {
|
||||||
res.setHeader('content-type', 'application/octet-stream');
|
res.setHeader('content-type', 'application/octet-stream');
|
||||||
|
res.setHeader('cache-control', 'public, max-age=2592000, immutable');
|
||||||
res.send(history.blob);
|
res.send(history.blob);
|
||||||
} else {
|
} else {
|
||||||
throw new NotFoundException('Doc history not found');
|
throw new NotFoundException('Doc history not found');
|
||||||
|
|||||||
@@ -277,6 +277,7 @@ export class WorkspaceResolver {
|
|||||||
id: workspace.id,
|
id: workspace.id,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
blob: buffer,
|
blob: buffer,
|
||||||
|
updatedAt: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export class SelfHostAdmin1605053000403 {
|
|||||||
// do the migration
|
// do the migration
|
||||||
static async up(db: PrismaClient, ref: ModuleRef) {
|
static async up(db: PrismaClient, ref: ModuleRef) {
|
||||||
const config = ref.get(Config, { strict: false });
|
const config = ref.get(Config, { strict: false });
|
||||||
if (config.flavor.selfhosted) {
|
if (config.isSelfhosted) {
|
||||||
if (
|
if (
|
||||||
!process.env.AFFINE_ADMIN_EMAIL ||
|
!process.env.AFFINE_ADMIN_EMAIL ||
|
||||||
!process.env.AFFINE_ADMIN_PASSWORD
|
!process.env.AFFINE_ADMIN_PASSWORD
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
|
||||||
|
import { FeatureType } from '../../core/features';
|
||||||
|
import { upsertLatestFeatureVersion } from './utils/user-features';
|
||||||
|
|
||||||
|
export class RefreshUnlimitedWorkspaceFeature1708321519830 {
|
||||||
|
// do the migration
|
||||||
|
static async up(db: PrismaClient) {
|
||||||
|
// add unlimited workspace feature
|
||||||
|
await upsertLatestFeatureVersion(db, FeatureType.UnlimitedWorkspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
// revert the migration
|
||||||
|
static async down(_db: PrismaClient) {}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Prisma, PrismaClient } from '@prisma/client';
|
|||||||
import {
|
import {
|
||||||
CommonFeature,
|
CommonFeature,
|
||||||
FeatureKind,
|
FeatureKind,
|
||||||
|
Features,
|
||||||
FeatureType,
|
FeatureType,
|
||||||
} from '../../../core/features';
|
} from '../../../core/features';
|
||||||
|
|
||||||
@@ -33,6 +34,16 @@ export async function upsertFeature(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function upsertLatestFeatureVersion(
|
||||||
|
db: PrismaClient,
|
||||||
|
type: FeatureType
|
||||||
|
) {
|
||||||
|
const feature = Features.filter(f => f.feature === type);
|
||||||
|
feature.sort((a, b) => b.version - a.version);
|
||||||
|
const latestFeature = feature[0];
|
||||||
|
await upsertFeature(db, latestFeature);
|
||||||
|
}
|
||||||
|
|
||||||
export async function migrateNewFeatureTable(prisma: PrismaClient) {
|
export async function migrateNewFeatureTable(prisma: PrismaClient) {
|
||||||
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
const waitingList = await prisma.newFeaturesWaitingList.findMany();
|
||||||
for (const oldUser of waitingList) {
|
for (const oldUser of waitingList) {
|
||||||
|
|||||||
@@ -18,18 +18,22 @@ export enum ExternalAccount {
|
|||||||
firebase = 'firebase',
|
firebase = 'firebase',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ServerFlavor =
|
export type ServerFlavor = 'allinone' | 'graphql' | 'sync';
|
||||||
| 'allinone'
|
export type AFFINE_ENV = 'dev' | 'beta' | 'production';
|
||||||
| 'main'
|
export type NODE_ENV = 'development' | 'test' | 'production';
|
||||||
// @deprecated
|
|
||||||
| 'graphql'
|
export enum DeploymentType {
|
||||||
| 'sync'
|
Affine = 'affine',
|
||||||
| 'selfhosted';
|
Selfhosted = 'selfhosted',
|
||||||
|
}
|
||||||
|
|
||||||
export type ConfigPaths = LeafPaths<
|
export type ConfigPaths = LeafPaths<
|
||||||
Omit<
|
Omit<
|
||||||
AFFiNEConfig,
|
AFFiNEConfig,
|
||||||
| 'ENV_MAP'
|
| 'ENV_MAP'
|
||||||
| 'version'
|
| 'version'
|
||||||
|
| 'type'
|
||||||
|
| 'isSelfhosted'
|
||||||
| 'flavor'
|
| 'flavor'
|
||||||
| 'env'
|
| 'env'
|
||||||
| 'affine'
|
| 'affine'
|
||||||
@@ -63,27 +67,36 @@ export interface AFFiNEConfig {
|
|||||||
*/
|
*/
|
||||||
readonly version: string;
|
readonly version: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deployment type, AFFiNE Cloud, or Selfhosted
|
||||||
|
*/
|
||||||
|
get type(): DeploymentType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast detect whether currently deployed in a selfhosted environment
|
||||||
|
*/
|
||||||
|
get isSelfhosted(): boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server flavor
|
* Server flavor
|
||||||
*/
|
*/
|
||||||
get flavor(): {
|
get flavor(): {
|
||||||
type: string;
|
type: string;
|
||||||
main: boolean;
|
graphql: boolean;
|
||||||
sync: boolean;
|
sync: boolean;
|
||||||
selfhosted: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deployment environment
|
* Deployment environment
|
||||||
*/
|
*/
|
||||||
readonly affineEnv: 'dev' | 'beta' | 'production';
|
readonly AFFINE_ENV: AFFINE_ENV;
|
||||||
/**
|
/**
|
||||||
* alias to `process.env.NODE_ENV`
|
* alias to `process.env.NODE_ENV`
|
||||||
*
|
*
|
||||||
* @default 'production'
|
* @default 'development'
|
||||||
* @env NODE_ENV
|
* @env NODE_ENV
|
||||||
*/
|
*/
|
||||||
readonly env: string;
|
readonly NODE_ENV: NODE_ENV;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* fast AFFiNE environment judge
|
* fast AFFiNE environment judge
|
||||||
@@ -101,6 +114,7 @@ export interface AFFiNEConfig {
|
|||||||
dev: boolean;
|
dev: boolean;
|
||||||
test: boolean;
|
test: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
get deploy(): boolean;
|
get deploy(): boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -302,11 +316,17 @@ export interface AFFiNEConfig {
|
|||||||
updatePollInterval: number;
|
updatePollInterval: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Use JwstCodec to merge updates at the same time when merging using Yjs.
|
* The maximum number of updates that will be pulled from the server at once.
|
||||||
|
* Existing for avoiding the server to be overloaded when there are too many updates for one doc.
|
||||||
|
*/
|
||||||
|
maxUpdatesPullCount: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use `y-octo` to merge updates at the same time when merging using Yjs.
|
||||||
*
|
*
|
||||||
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
|
* This is an experimental feature, and aimed to check the correctness of JwstCodec.
|
||||||
*/
|
*/
|
||||||
experimentalMergeWithJwstCodec: boolean;
|
experimentalMergeWithYOcto: boolean;
|
||||||
};
|
};
|
||||||
history: {
|
history: {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import { merge } from 'lodash-es';
|
|||||||
import parse from 'parse-duration';
|
import parse from 'parse-duration';
|
||||||
|
|
||||||
import pkg from '../../../package.json' assert { type: 'json' };
|
import pkg from '../../../package.json' assert { type: 'json' };
|
||||||
import type { AFFiNEConfig, ServerFlavor } from './def';
|
import {
|
||||||
|
type AFFINE_ENV,
|
||||||
|
AFFiNEConfig,
|
||||||
|
DeploymentType,
|
||||||
|
type NODE_ENV,
|
||||||
|
type ServerFlavor,
|
||||||
|
} from './def';
|
||||||
|
import { readEnv } from './env';
|
||||||
import { getDefaultAFFiNEStorageConfig } from './storage';
|
import { getDefaultAFFiNEStorageConfig } from './storage';
|
||||||
|
|
||||||
// Don't use this in production
|
// Don't use this in production
|
||||||
@@ -46,40 +53,62 @@ const jwtKeyPair = (function () {
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||||
let isHttps: boolean | null = null;
|
const NODE_ENV = readEnv<NODE_ENV>('NODE_ENV', 'development', [
|
||||||
let flavor = (process.env.SERVER_FLAVOR ?? 'allinone') as ServerFlavor;
|
'development',
|
||||||
|
'test',
|
||||||
|
'production',
|
||||||
|
]);
|
||||||
|
const AFFINE_ENV = readEnv<AFFINE_ENV>('AFFINE_ENV', 'dev', [
|
||||||
|
'dev',
|
||||||
|
'beta',
|
||||||
|
'production',
|
||||||
|
]);
|
||||||
|
const flavor = readEnv<ServerFlavor>('SERVER_FLAVOR', 'allinone', [
|
||||||
|
'allinone',
|
||||||
|
'graphql',
|
||||||
|
'sync',
|
||||||
|
]);
|
||||||
|
const deploymentType = readEnv<DeploymentType>(
|
||||||
|
'DEPLOYMENT_TYPE',
|
||||||
|
NODE_ENV === 'development'
|
||||||
|
? DeploymentType.Affine
|
||||||
|
: DeploymentType.Selfhosted,
|
||||||
|
Object.values(DeploymentType)
|
||||||
|
);
|
||||||
|
const isSelfhosted = deploymentType === DeploymentType.Selfhosted;
|
||||||
|
|
||||||
const defaultConfig = {
|
const defaultConfig = {
|
||||||
serverId: 'affine-nestjs-server',
|
serverId: 'affine-nestjs-server',
|
||||||
serverName: flavor === 'selfhosted' ? 'Self-Host Cloud' : 'AFFiNE Cloud',
|
serverName: isSelfhosted ? 'Self-Host Cloud' : 'AFFiNE Cloud',
|
||||||
version: pkg.version,
|
version: pkg.version,
|
||||||
|
get type() {
|
||||||
|
return deploymentType;
|
||||||
|
},
|
||||||
|
get isSelfhosted() {
|
||||||
|
return isSelfhosted;
|
||||||
|
},
|
||||||
get flavor() {
|
get flavor() {
|
||||||
if (flavor === 'graphql') {
|
|
||||||
flavor = 'main';
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
type: flavor,
|
type: flavor,
|
||||||
main: flavor === 'main' || flavor === 'allinone',
|
graphql: flavor === 'graphql' || flavor === 'allinone',
|
||||||
sync: flavor === 'sync' || flavor === 'allinone',
|
sync: flavor === 'sync' || flavor === 'allinone',
|
||||||
selfhosted: flavor === 'selfhosted',
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
ENV_MAP: {},
|
ENV_MAP: {},
|
||||||
affineEnv: 'dev',
|
AFFINE_ENV,
|
||||||
get affine() {
|
get affine() {
|
||||||
const env = this.affineEnv;
|
|
||||||
return {
|
return {
|
||||||
canary: env === 'dev',
|
canary: AFFINE_ENV === 'dev',
|
||||||
beta: env === 'beta',
|
beta: AFFINE_ENV === 'beta',
|
||||||
stable: env === 'production',
|
stable: AFFINE_ENV === 'production',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
env: process.env.NODE_ENV ?? 'development',
|
NODE_ENV,
|
||||||
get node() {
|
get node() {
|
||||||
const env = this.env;
|
|
||||||
return {
|
return {
|
||||||
prod: env === 'production',
|
prod: NODE_ENV === 'production',
|
||||||
dev: env === 'development',
|
dev: NODE_ENV === 'development',
|
||||||
test: env === 'test',
|
test: NODE_ENV === 'test',
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
get deploy() {
|
get deploy() {
|
||||||
@@ -88,12 +117,7 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
|||||||
featureFlags: {
|
featureFlags: {
|
||||||
earlyAccessPreview: false,
|
earlyAccessPreview: false,
|
||||||
},
|
},
|
||||||
get https() {
|
https: false,
|
||||||
return isHttps ?? !this.node.dev;
|
|
||||||
},
|
|
||||||
set https(value: boolean) {
|
|
||||||
isHttps = value;
|
|
||||||
},
|
|
||||||
host: 'localhost',
|
host: 'localhost',
|
||||||
port: 3010,
|
port: 3010,
|
||||||
path: '',
|
path: '',
|
||||||
@@ -160,7 +184,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
|||||||
manager: {
|
manager: {
|
||||||
enableUpdateAutoMerging: flavor !== 'sync',
|
enableUpdateAutoMerging: flavor !== 'sync',
|
||||||
updatePollInterval: 3000,
|
updatePollInterval: 3000,
|
||||||
experimentalMergeWithJwstCodec: false,
|
maxUpdatesPullCount: 500,
|
||||||
|
experimentalMergeWithYOcto: false,
|
||||||
},
|
},
|
||||||
history: {
|
history: {
|
||||||
interval: 1000 * 60 * 10 /* 10 mins */,
|
interval: 1000 * 60 * 10 /* 10 mins */,
|
||||||
|
|||||||
@@ -48,3 +48,24 @@ export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function readEnv<T>(
|
||||||
|
env: string,
|
||||||
|
defaultValue: T,
|
||||||
|
availableValues?: T[]
|
||||||
|
) {
|
||||||
|
const value = process.env[env];
|
||||||
|
if (value === undefined) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableValues && !availableValues.includes(value as any)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid value '${value}' for environment variable ${env}, expected one of [${availableValues.join(
|
||||||
|
', '
|
||||||
|
)}]`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as T;
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export {
|
|||||||
applyEnvToConfig,
|
applyEnvToConfig,
|
||||||
Config,
|
Config,
|
||||||
type ConfigPaths,
|
type ConfigPaths,
|
||||||
|
DeploymentType,
|
||||||
getDefaultAFFiNEStorageConfig,
|
getDefaultAFFiNEStorageConfig,
|
||||||
} from './config';
|
} from './config';
|
||||||
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
export { EventEmitter, type EventPayload, OnEvent } from './event';
|
||||||
|
|||||||
@@ -1,28 +1,48 @@
|
|||||||
import { Global, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
import {
|
||||||
|
Global,
|
||||||
|
Module,
|
||||||
|
OnModuleDestroy,
|
||||||
|
OnModuleInit,
|
||||||
|
Provider,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||||
|
|
||||||
import { Config, parseEnvValue } from '../config';
|
import { Config } from '../config';
|
||||||
import { createSDK, registerCustomMetrics } from './opentelemetry';
|
import {
|
||||||
|
LocalOpentelemetryFactory,
|
||||||
|
OpentelemetryFactory,
|
||||||
|
registerCustomMetrics,
|
||||||
|
} from './opentelemetry';
|
||||||
|
|
||||||
|
const factorProvider: Provider = {
|
||||||
|
provide: OpentelemetryFactory,
|
||||||
|
useFactory: (config: Config) => {
|
||||||
|
return config.metrics.enabled ? new LocalOpentelemetryFactory() : null;
|
||||||
|
},
|
||||||
|
inject: [Config],
|
||||||
|
};
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({})
|
@Module({
|
||||||
|
providers: [factorProvider],
|
||||||
|
exports: [factorProvider],
|
||||||
|
})
|
||||||
export class MetricsModule implements OnModuleInit, OnModuleDestroy {
|
export class MetricsModule implements OnModuleInit, OnModuleDestroy {
|
||||||
private sdk: NodeSDK | null = null;
|
private sdk: NodeSDK | null = null;
|
||||||
constructor(private readonly config: Config) {}
|
constructor(private readonly ref: ModuleRef) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
if (
|
const factor = this.ref.get(OpentelemetryFactory, { strict: false });
|
||||||
this.config.metrics.enabled &&
|
if (factor) {
|
||||||
!parseEnvValue(process.env.DISABLE_TELEMETRY, 'boolean')
|
this.sdk = factor.create();
|
||||||
) {
|
|
||||||
this.sdk = createSDK();
|
|
||||||
this.sdk.start();
|
this.sdk.start();
|
||||||
registerCustomMetrics();
|
registerCustomMetrics();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleDestroy() {
|
async onModuleDestroy() {
|
||||||
if (this.config.metrics.enabled && this.sdk) {
|
if (this.sdk) {
|
||||||
await this.sdk.shutdown();
|
await this.sdk.shutdown();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,3 +50,4 @@ export class MetricsModule implements OnModuleInit, OnModuleDestroy {
|
|||||||
|
|
||||||
export * from './metrics';
|
export * from './metrics';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
export { OpentelemetryFactory };
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
|
import { OnModuleDestroy } from '@nestjs/common';
|
||||||
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
|
|
||||||
import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util';
|
|
||||||
import { metrics } from '@opentelemetry/api';
|
import { metrics } from '@opentelemetry/api';
|
||||||
import {
|
import {
|
||||||
CompositePropagator,
|
CompositePropagator,
|
||||||
@@ -18,16 +16,13 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
|
|||||||
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
|
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
|
||||||
import { Resource } from '@opentelemetry/resources';
|
import { Resource } from '@opentelemetry/resources';
|
||||||
import {
|
import {
|
||||||
ConsoleMetricExporter,
|
|
||||||
type MeterProvider,
|
type MeterProvider,
|
||||||
MetricProducer,
|
MetricProducer,
|
||||||
MetricReader,
|
MetricReader,
|
||||||
PeriodicExportingMetricReader,
|
|
||||||
} from '@opentelemetry/sdk-metrics';
|
} from '@opentelemetry/sdk-metrics';
|
||||||
import { NodeSDK } from '@opentelemetry/sdk-node';
|
import { NodeSDK } from '@opentelemetry/sdk-node';
|
||||||
import {
|
import {
|
||||||
BatchSpanProcessor,
|
BatchSpanProcessor,
|
||||||
ConsoleSpanExporter,
|
|
||||||
SpanExporter,
|
SpanExporter,
|
||||||
TraceIdRatioBasedSampler,
|
TraceIdRatioBasedSampler,
|
||||||
} from '@opentelemetry/sdk-trace-node';
|
} from '@opentelemetry/sdk-trace-node';
|
||||||
@@ -38,7 +33,7 @@ import { PrismaMetricProducer } from './prisma';
|
|||||||
|
|
||||||
const { PrismaInstrumentation } = prismaInstrument;
|
const { PrismaInstrumentation } = prismaInstrument;
|
||||||
|
|
||||||
abstract class OpentelemetryFactor {
|
export abstract class OpentelemetryFactory {
|
||||||
abstract getMetricReader(): MetricReader;
|
abstract getMetricReader(): MetricReader;
|
||||||
abstract getSpanExporter(): SpanExporter;
|
abstract getSpanExporter(): SpanExporter;
|
||||||
|
|
||||||
@@ -59,7 +54,7 @@ abstract class OpentelemetryFactor {
|
|||||||
|
|
||||||
getResource() {
|
getResource() {
|
||||||
return new Resource({
|
return new Resource({
|
||||||
[SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.affineEnv,
|
[SemanticResourceAttributes.K8S_NAMESPACE_NAME]: AFFiNE.AFFINE_ENV,
|
||||||
[SemanticResourceAttributes.SERVICE_NAME]: AFFiNE.flavor.type,
|
[SemanticResourceAttributes.SERVICE_NAME]: AFFiNE.flavor.type,
|
||||||
[SemanticResourceAttributes.SERVICE_VERSION]: AFFiNE.version,
|
[SemanticResourceAttributes.SERVICE_VERSION]: AFFiNE.version,
|
||||||
});
|
});
|
||||||
@@ -85,32 +80,20 @@ abstract class OpentelemetryFactor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class GCloudOpentelemetryFactor extends OpentelemetryFactor {
|
export class LocalOpentelemetryFactory
|
||||||
override getResource(): Resource {
|
extends OpentelemetryFactory
|
||||||
return super.getResource().merge(new GcpDetectorSync().detect());
|
implements OnModuleDestroy
|
||||||
|
{
|
||||||
|
private readonly metricsExporter = new PrometheusExporter({
|
||||||
|
metricProducers: this.getMetricsProducers(),
|
||||||
|
});
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
await this.metricsExporter.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
override getMetricReader(): MetricReader {
|
override getMetricReader(): MetricReader {
|
||||||
return new PeriodicExportingMetricReader({
|
return this.metricsExporter;
|
||||||
exportIntervalMillis: 30000,
|
|
||||||
exportTimeoutMillis: 10000,
|
|
||||||
exporter: new MetricExporter({
|
|
||||||
prefix: 'custom.googleapis.com',
|
|
||||||
}),
|
|
||||||
metricProducers: this.getMetricsProducers(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override getSpanExporter(): SpanExporter {
|
|
||||||
return new TraceExporter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class LocalOpentelemetryFactor extends OpentelemetryFactor {
|
|
||||||
override getMetricReader(): MetricReader {
|
|
||||||
return new PrometheusExporter({
|
|
||||||
metricProducers: this.getMetricsProducers(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override getSpanExporter(): SpanExporter {
|
override getSpanExporter(): SpanExporter {
|
||||||
@@ -118,33 +101,6 @@ class LocalOpentelemetryFactor extends OpentelemetryFactor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class DebugOpentelemetryFactor extends OpentelemetryFactor {
|
|
||||||
override getMetricReader(): MetricReader {
|
|
||||||
return new PeriodicExportingMetricReader({
|
|
||||||
exporter: new ConsoleMetricExporter(),
|
|
||||||
metricProducers: this.getMetricsProducers(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
override getSpanExporter(): SpanExporter {
|
|
||||||
return new ConsoleSpanExporter();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO(@forehalo): make it configurable
|
|
||||||
export function createSDK() {
|
|
||||||
let factor: OpentelemetryFactor | null = null;
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
factor = new GCloudOpentelemetryFactor();
|
|
||||||
} else if (process.env.DEBUG_METRICS) {
|
|
||||||
factor = new DebugOpentelemetryFactor();
|
|
||||||
} else {
|
|
||||||
factor = new LocalOpentelemetryFactor();
|
|
||||||
}
|
|
||||||
|
|
||||||
return factor?.create();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMeterProvider() {
|
function getMeterProvider() {
|
||||||
return metrics.getMeterProvider();
|
return metrics.getMeterProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { SessionCache } from '../cache';
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionService {
|
export class SessionService {
|
||||||
private readonly prefix = 'session:';
|
private readonly prefix = 'session:';
|
||||||
private readonly sessionTtl = 30 * 60 * 1000; // 30 min
|
public readonly sessionTtl = 30 * 60 * 1000; // 30 min
|
||||||
|
|
||||||
constructor(private readonly cache: SessionCache) {}
|
constructor(private readonly cache: SessionCache) {}
|
||||||
|
|
||||||
|
|||||||
@@ -15,8 +15,6 @@ try {
|
|||||||
: require('../../../storage.node');
|
: require('../../../storage.node');
|
||||||
}
|
}
|
||||||
|
|
||||||
export { storageModule as OctoBaseStorageModule };
|
|
||||||
|
|
||||||
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
export const mergeUpdatesInApplyWay = storageModule.mergeUpdatesInApplyWay;
|
||||||
|
|
||||||
export const verifyChallengeResponse = async (
|
export const verifyChallengeResponse = async (
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
/// <reference types="./global.d.ts" />
|
/// <reference types="./global.d.ts" />
|
||||||
// keep the config import at the top
|
|
||||||
// eslint-disable-next-line simple-import-sort/imports
|
|
||||||
import './prelude';
|
import './prelude';
|
||||||
|
|
||||||
|
import { Logger } from '@nestjs/common';
|
||||||
|
|
||||||
import { createApp } from './app';
|
import { createApp } from './app';
|
||||||
|
|
||||||
const app = await createApp();
|
const app = await createApp();
|
||||||
const listeningHost = AFFiNE.deploy ? '0.0.0.0' : 'localhost';
|
const listeningHost = AFFiNE.deploy ? '0.0.0.0' : 'localhost';
|
||||||
await app.listen(AFFiNE.port, listeningHost);
|
await app.listen(AFFiNE.port, listeningHost);
|
||||||
|
|
||||||
console.log(
|
const logger = new Logger('App');
|
||||||
`AFFiNE Server has been started on http://${listeningHost}:${AFFiNE.port}.`
|
|
||||||
);
|
logger.log(`AFFiNE Server is running in [${AFFiNE.type}] mode`);
|
||||||
console.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);
|
logger.log(`Listening on http://${listeningHost}:${AFFiNE.port}`);
|
||||||
|
logger.log(`And the public server should be recognized as ${AFFiNE.baseUrl}`);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { GCloudConfig } from './gcloud/config';
|
||||||
import { PaymentConfig } from './payment';
|
import { PaymentConfig } from './payment';
|
||||||
import { RedisOptions } from './redis';
|
import { RedisOptions } from './redis';
|
||||||
|
|
||||||
@@ -5,6 +6,7 @@ declare module '../fundamentals/config' {
|
|||||||
interface PluginsConfig {
|
interface PluginsConfig {
|
||||||
readonly payment: PaymentConfig;
|
readonly payment: PaymentConfig;
|
||||||
readonly redis: RedisOptions;
|
readonly redis: RedisOptions;
|
||||||
|
readonly gcloud: GCloudConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvailablePlugins = keyof PluginsConfig;
|
export type AvailablePlugins = keyof PluginsConfig;
|
||||||
|
|||||||
1
packages/backend/server/src/plugins/gcloud/config.ts
Normal file
1
packages/backend/server/src/plugins/gcloud/config.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export interface GCloudConfig {}
|
||||||
10
packages/backend/server/src/plugins/gcloud/index.ts
Normal file
10
packages/backend/server/src/plugins/gcloud/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Global } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { OptionalModule } from '../../fundamentals';
|
||||||
|
import { GCloudMetrics } from './metrics';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@OptionalModule({
|
||||||
|
imports: [GCloudMetrics],
|
||||||
|
})
|
||||||
|
export class GCloudModule {}
|
||||||
46
packages/backend/server/src/plugins/gcloud/metrics.ts
Normal file
46
packages/backend/server/src/plugins/gcloud/metrics.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
|
||||||
|
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
|
||||||
|
import { GcpDetectorSync } from '@google-cloud/opentelemetry-resource-util';
|
||||||
|
import { Global, Provider } from '@nestjs/common';
|
||||||
|
import { Resource } from '@opentelemetry/resources';
|
||||||
|
import {
|
||||||
|
MetricReader,
|
||||||
|
PeriodicExportingMetricReader,
|
||||||
|
} from '@opentelemetry/sdk-metrics';
|
||||||
|
import { SpanExporter } from '@opentelemetry/sdk-trace-node';
|
||||||
|
|
||||||
|
import { OptionalModule } from '../../fundamentals';
|
||||||
|
import { OpentelemetryFactory } from '../../fundamentals/metrics';
|
||||||
|
|
||||||
|
export class GCloudOpentelemetryFactory extends OpentelemetryFactory {
|
||||||
|
override getResource(): Resource {
|
||||||
|
return super.getResource().merge(new GcpDetectorSync().detect());
|
||||||
|
}
|
||||||
|
|
||||||
|
override getMetricReader(): MetricReader {
|
||||||
|
return new PeriodicExportingMetricReader({
|
||||||
|
exportIntervalMillis: 30000,
|
||||||
|
exportTimeoutMillis: 10000,
|
||||||
|
exporter: new MetricExporter({
|
||||||
|
prefix: 'custom.googleapis.com',
|
||||||
|
}),
|
||||||
|
metricProducers: this.getMetricsProducers(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
override getSpanExporter(): SpanExporter {
|
||||||
|
return new TraceExporter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const factorProvider: Provider = {
|
||||||
|
provide: OpentelemetryFactory,
|
||||||
|
useFactory: () => new GCloudOpentelemetryFactory(),
|
||||||
|
};
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@OptionalModule({
|
||||||
|
if: config => config.metrics.enabled,
|
||||||
|
overrides: [factorProvider],
|
||||||
|
})
|
||||||
|
export class GCloudMetrics {}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { AvailablePlugins } from '../fundamentals/config';
|
import type { AvailablePlugins } from '../fundamentals/config';
|
||||||
|
import { GCloudModule } from './gcloud';
|
||||||
import { PaymentModule } from './payment';
|
import { PaymentModule } from './payment';
|
||||||
import { RedisModule } from './redis';
|
import { RedisModule } from './redis';
|
||||||
|
|
||||||
export const pluginsMap = new Map<AvailablePlugins, AFFiNEModule>([
|
export const pluginsMap = new Map<AvailablePlugins, AFFiNEModule>([
|
||||||
['payment', PaymentModule],
|
['payment', PaymentModule],
|
||||||
['redis', RedisModule],
|
['redis', RedisModule],
|
||||||
|
['gcloud', GCloudModule],
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { StripeWebhook } from './webhook';
|
|||||||
// 'plugins.payment.stripe.keys.webhookKey',
|
// 'plugins.payment.stripe.keys.webhookKey',
|
||||||
// ],
|
// ],
|
||||||
contributesTo: ServerFeature.Payment,
|
contributesTo: ServerFeature.Payment,
|
||||||
|
if: config => config.flavor.graphql,
|
||||||
})
|
})
|
||||||
export class PaymentModule {}
|
export class PaymentModule {}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Args,
|
Args,
|
||||||
Context,
|
Context,
|
||||||
Field,
|
Field,
|
||||||
|
InputType,
|
||||||
Int,
|
Int,
|
||||||
Mutation,
|
Mutation,
|
||||||
ObjectType,
|
ObjectType,
|
||||||
@@ -125,6 +126,31 @@ class UserInvoiceType implements Partial<UserInvoice> {
|
|||||||
updatedAt!: Date;
|
updatedAt!: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
class CreateCheckoutSessionInput {
|
||||||
|
@Field(() => SubscriptionRecurring, {
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: SubscriptionRecurring.Yearly,
|
||||||
|
})
|
||||||
|
recurring!: SubscriptionRecurring;
|
||||||
|
|
||||||
|
@Field(() => SubscriptionPlan, {
|
||||||
|
nullable: true,
|
||||||
|
defaultValue: SubscriptionPlan.Pro,
|
||||||
|
})
|
||||||
|
plan!: SubscriptionPlan;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
coupon!: string | null;
|
||||||
|
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
successCallbackLink!: string | null;
|
||||||
|
|
||||||
|
// @FIXME(forehalo): we should put this field in the header instead of as a explicity args
|
||||||
|
@Field(() => String)
|
||||||
|
idempotencyKey!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Auth()
|
@Auth()
|
||||||
@Resolver(() => UserSubscriptionType)
|
@Resolver(() => UserSubscriptionType)
|
||||||
export class SubscriptionResolver {
|
export class SubscriptionResolver {
|
||||||
@@ -182,7 +208,11 @@ export class SubscriptionResolver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
@Mutation(() => String, {
|
@Mutation(() => String, {
|
||||||
|
deprecationReason: 'use `createCheckoutSession` instead',
|
||||||
description: 'Create a subscription checkout link of stripe',
|
description: 'Create a subscription checkout link of stripe',
|
||||||
})
|
})
|
||||||
async checkout(
|
async checkout(
|
||||||
@@ -193,6 +223,7 @@ export class SubscriptionResolver {
|
|||||||
) {
|
) {
|
||||||
const session = await this.service.createCheckoutSession({
|
const session = await this.service.createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
|
plan: SubscriptionPlan.Pro,
|
||||||
recurring,
|
recurring,
|
||||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
@@ -210,6 +241,36 @@ export class SubscriptionResolver {
|
|||||||
return session.url;
|
return session.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => String, {
|
||||||
|
description: 'Create a subscription checkout link of stripe',
|
||||||
|
})
|
||||||
|
async createCheckoutSession(
|
||||||
|
@CurrentUser() user: User,
|
||||||
|
@Args({ name: 'input', type: () => CreateCheckoutSessionInput })
|
||||||
|
input: CreateCheckoutSessionInput
|
||||||
|
) {
|
||||||
|
const session = await this.service.createCheckoutSession({
|
||||||
|
user,
|
||||||
|
plan: input.plan,
|
||||||
|
recurring: input.recurring,
|
||||||
|
promotionCode: input.coupon,
|
||||||
|
redirectUrl:
|
||||||
|
input.successCallbackLink ?? `${this.config.baseUrl}/upgrade-success`,
|
||||||
|
idempotencyKey: input.idempotencyKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!session.url) {
|
||||||
|
throw new GraphQLError('Failed to create checkout session', {
|
||||||
|
extensions: {
|
||||||
|
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||||
|
code: HttpStatus.BAD_GATEWAY,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return session.url;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => String, {
|
@Mutation(() => String, {
|
||||||
description: 'Create a stripe customer portal to manage payment methods',
|
description: 'Create a stripe customer portal to manage payment methods',
|
||||||
})
|
})
|
||||||
@@ -276,7 +337,7 @@ export class UserSubscriptionResolver {
|
|||||||
|
|
||||||
// @FIXME(@forehalo): should not mock any api for selfhosted server
|
// @FIXME(@forehalo): should not mock any api for selfhosted server
|
||||||
// the frontend should avoid calling such api if feature is not enabled
|
// the frontend should avoid calling such api if feature is not enabled
|
||||||
if (this.config.flavor.selfhosted) {
|
if (this.config.isSelfhosted) {
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
end.setFullYear(start.getFullYear() + 1);
|
end.setFullYear(start.getFullYear() + 1);
|
||||||
|
|||||||
@@ -69,13 +69,15 @@ export class SubscriptionService {
|
|||||||
async createCheckoutSession({
|
async createCheckoutSession({
|
||||||
user,
|
user,
|
||||||
recurring,
|
recurring,
|
||||||
|
plan,
|
||||||
|
promotionCode,
|
||||||
redirectUrl,
|
redirectUrl,
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
plan = SubscriptionPlan.Pro,
|
|
||||||
}: {
|
}: {
|
||||||
user: User;
|
user: User;
|
||||||
plan?: SubscriptionPlan;
|
|
||||||
recurring: SubscriptionRecurring;
|
recurring: SubscriptionRecurring;
|
||||||
|
plan: SubscriptionPlan;
|
||||||
|
promotionCode?: string | null;
|
||||||
redirectUrl: string;
|
redirectUrl: string;
|
||||||
idempotencyKey: string;
|
idempotencyKey: string;
|
||||||
}) {
|
}) {
|
||||||
@@ -95,7 +97,28 @@ export class SubscriptionService {
|
|||||||
`${idempotencyKey}-getOrCreateCustomer`,
|
`${idempotencyKey}-getOrCreateCustomer`,
|
||||||
user
|
user
|
||||||
);
|
);
|
||||||
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
|
|
||||||
|
let discount: { coupon?: string; promotion_code?: string } | undefined;
|
||||||
|
|
||||||
|
if (promotionCode) {
|
||||||
|
const code = await this.getAvailablePromotionCode(
|
||||||
|
promotionCode,
|
||||||
|
customer.stripeCustomerId
|
||||||
|
);
|
||||||
|
if (code) {
|
||||||
|
discount ??= {};
|
||||||
|
discount.promotion_code = code;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const coupon = await this.getAvailableCoupon(
|
||||||
|
user,
|
||||||
|
CouponType.EarlyAccess
|
||||||
|
);
|
||||||
|
if (coupon) {
|
||||||
|
discount ??= {};
|
||||||
|
discount.coupon = coupon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return await this.stripe.checkout.sessions.create(
|
return await this.stripe.checkout.sessions.create(
|
||||||
{
|
{
|
||||||
@@ -108,13 +131,11 @@ export class SubscriptionService {
|
|||||||
tax_id_collection: {
|
tax_id_collection: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
},
|
},
|
||||||
...(coupon
|
...(discount
|
||||||
? {
|
? {
|
||||||
discounts: [{ coupon }],
|
discounts: [discount],
|
||||||
}
|
}
|
||||||
: {
|
: { allow_promotion_codes: true }),
|
||||||
allow_promotion_codes: true,
|
|
||||||
}),
|
|
||||||
mode: 'subscription',
|
mode: 'subscription',
|
||||||
success_url: redirectUrl,
|
success_url: redirectUrl,
|
||||||
customer: customer.stripeCustomerId,
|
customer: customer.stripeCustomerId,
|
||||||
@@ -643,4 +664,33 @@ export class SubscriptionService {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getAvailablePromotionCode(
|
||||||
|
userFacingPromotionCode: string,
|
||||||
|
customer?: string
|
||||||
|
) {
|
||||||
|
const list = await this.stripe.promotionCodes.list({
|
||||||
|
code: userFacingPromotionCode,
|
||||||
|
active: true,
|
||||||
|
limit: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const code = list.data[0];
|
||||||
|
if (!code) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let available = false;
|
||||||
|
|
||||||
|
if (code.customer) {
|
||||||
|
available =
|
||||||
|
typeof code.customer === 'string'
|
||||||
|
? code.customer === customer
|
||||||
|
: code.customer.id === customer;
|
||||||
|
} else {
|
||||||
|
available = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return available ? code.id : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { config } from 'dotenv';
|
import { config } from 'dotenv';
|
||||||
|
import { omit } from 'lodash-es';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
applyEnvToConfig,
|
applyEnvToConfig,
|
||||||
@@ -43,14 +44,23 @@ async function load() {
|
|||||||
// 3. load env => config map to `globalThis.AFFiNE.ENV_MAP
|
// 3. load env => config map to `globalThis.AFFiNE.ENV_MAP
|
||||||
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.env.js');
|
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.env.js');
|
||||||
|
|
||||||
// 4. apply `process.env` map overriding to `globalThis.AFFiNE`
|
// 4. load `config/affine` to patch custom configs
|
||||||
applyEnvToConfig(globalThis.AFFiNE);
|
|
||||||
|
|
||||||
// 5. load `config/affine` to patch custom configs
|
|
||||||
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.js');
|
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.js');
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'development') {
|
// 5. load `config/affine.self` to patch custom configs
|
||||||
console.log('AFFiNE Config:', JSON.stringify(globalThis.AFFiNE, null, 2));
|
// This is the file only take effect in [AFFiNE Cloud]
|
||||||
|
if (!AFFiNE.isSelfhosted) {
|
||||||
|
await loadRemote(AFFiNE_CONFIG_PATH, 'affine.self.js');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. apply `process.env` map overriding to `globalThis.AFFiNE`
|
||||||
|
applyEnvToConfig(globalThis.AFFiNE);
|
||||||
|
|
||||||
|
if (AFFiNE.node.dev) {
|
||||||
|
console.log(
|
||||||
|
'AFFiNE Config:',
|
||||||
|
JSON.stringify(omit(globalThis.AFFiNE, 'ENV_MAP'), null, 2)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
# THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY)
|
||||||
# ------------------------------------------------------
|
# ------------------------------------------------------
|
||||||
|
|
||||||
|
input CreateCheckoutSessionInput {
|
||||||
|
coupon: String
|
||||||
|
idempotencyKey: String!
|
||||||
|
plan: SubscriptionPlan = Pro
|
||||||
|
recurring: SubscriptionRecurring = Yearly
|
||||||
|
successCallbackLink: String
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format.
|
||||||
"""
|
"""
|
||||||
@@ -107,7 +115,10 @@ type Mutation {
|
|||||||
changePassword(newPassword: String!, token: String!): UserType!
|
changePassword(newPassword: String!, token: String!): UserType!
|
||||||
|
|
||||||
"""Create a subscription checkout link of stripe"""
|
"""Create a subscription checkout link of stripe"""
|
||||||
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String!
|
checkout(idempotencyKey: String!, recurring: SubscriptionRecurring!): String! @deprecated(reason: "use `createCheckoutSession` instead")
|
||||||
|
|
||||||
|
"""Create a subscription checkout link of stripe"""
|
||||||
|
createCheckoutSession(input: CreateCheckoutSessionInput!): String!
|
||||||
|
|
||||||
"""Create a stripe customer portal to manage payment methods"""
|
"""Create a stripe customer portal to manage payment methods"""
|
||||||
createCustomerPortal: String!
|
createCustomerPortal: String!
|
||||||
@@ -229,10 +240,18 @@ type ServerConfigType {
|
|||||||
"""server identical name could be shown as badge on user interface"""
|
"""server identical name could be shown as badge on user interface"""
|
||||||
name: String!
|
name: String!
|
||||||
|
|
||||||
|
"""server type"""
|
||||||
|
type: ServerDeploymentType!
|
||||||
|
|
||||||
"""server version"""
|
"""server version"""
|
||||||
version: String!
|
version: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum ServerDeploymentType {
|
||||||
|
Affine
|
||||||
|
Selfhosted
|
||||||
|
}
|
||||||
|
|
||||||
enum ServerFeature {
|
enum ServerFeature {
|
||||||
Payment
|
Payment
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ test.afterEach.always(async () => {
|
|||||||
|
|
||||||
test('should be able to get config', t => {
|
test('should be able to get config', t => {
|
||||||
t.true(typeof config.host === 'string');
|
t.true(typeof config.host === 'string');
|
||||||
t.is(config.env, 'test');
|
t.is(config.NODE_ENV, 'test');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should be able to override config', async t => {
|
test('should be able to override config', async t => {
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import { TestingModule } from '@nestjs/testing';
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import test from 'ava';
|
import test from 'ava';
|
||||||
import * as Sinon from 'sinon';
|
import * as Sinon from 'sinon';
|
||||||
import {
|
import { applyUpdate, Doc as YDoc, encodeStateAsUpdate } from 'yjs';
|
||||||
applyUpdate,
|
|
||||||
decodeStateVector,
|
|
||||||
Doc as YDoc,
|
|
||||||
encodeStateAsUpdate,
|
|
||||||
} from 'yjs';
|
|
||||||
|
|
||||||
import { DocManager, DocModule } from '../src/core/doc';
|
import { DocManager, DocModule } from '../src/core/doc';
|
||||||
import { QuotaModule } from '../src/core/quota';
|
import { QuotaModule } from '../src/core/quota';
|
||||||
@@ -277,72 +272,120 @@ test('should throw if meet max retry times', async t => {
|
|||||||
t.is(stub.callCount, 5);
|
t.is(stub.callCount, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should not update snapshot if state is outdated', async t => {
|
test('should be able to insert the snapshot if it is new created', async t => {
|
||||||
const db = m.get(PrismaClient);
|
|
||||||
const manager = m.get(DocManager);
|
const manager = m.get(DocManager);
|
||||||
|
|
||||||
await db.snapshot.create({
|
|
||||||
data: {
|
|
||||||
id: '2',
|
|
||||||
workspaceId: '2',
|
|
||||||
blob: Buffer.from([0, 0]),
|
|
||||||
seq: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const doc = new YDoc();
|
const doc = new YDoc();
|
||||||
const text = doc.getText('content');
|
const text = doc.getText('content');
|
||||||
const updates: Buffer[] = [];
|
|
||||||
|
|
||||||
doc.on('update', update => {
|
|
||||||
updates.push(Buffer.from(update));
|
|
||||||
});
|
|
||||||
|
|
||||||
text.insert(0, 'hello');
|
text.insert(0, 'hello');
|
||||||
text.insert(5, 'world');
|
const update = encodeStateAsUpdate(doc);
|
||||||
text.insert(5, ' ');
|
|
||||||
|
|
||||||
await Promise.all(updates.map(update => manager.push('2', '2', update)));
|
await manager.push('1', '1', Buffer.from(update));
|
||||||
|
|
||||||
const updateWith3Records = await manager.getUpdates('2', '2');
|
const updates = await manager.getUpdates('1', '1');
|
||||||
text.insert(11, '!');
|
t.is(updates.length, 1);
|
||||||
await manager.push('2', '2', updates[3]);
|
|
||||||
const updateWith4Records = await manager.getUpdates('2', '2');
|
|
||||||
|
|
||||||
// Simulation:
|
|
||||||
// Node A get 3 updates and squash them at time 1, will finish at time 10
|
|
||||||
// Node B get 4 updates and squash them at time 3, will finish at time 8
|
|
||||||
// Node B finish the squash first, and update the snapshot
|
|
||||||
// Node A finish the squash later, and update the snapshot to an outdated state
|
|
||||||
// Time: ---------------------->
|
|
||||||
// A: ^get ^upsert
|
|
||||||
// B: ^get ^upsert
|
|
||||||
//
|
|
||||||
// We should avoid such situation
|
|
||||||
// @ts-expect-error private
|
// @ts-expect-error private
|
||||||
await manager.squash(updateWith4Records, null);
|
const snapshot = await manager.squash(null, updates);
|
||||||
// @ts-expect-error private
|
|
||||||
await manager.squash(updateWith3Records, null);
|
|
||||||
|
|
||||||
const result = await db.snapshot.findUnique({
|
t.truthy(snapshot);
|
||||||
|
t.is(snapshot.getText('content').toString(), 'hello');
|
||||||
|
|
||||||
|
const restUpdates = await manager.getUpdates('1', '1');
|
||||||
|
|
||||||
|
t.is(restUpdates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should be able to merge updates into snapshot', async t => {
|
||||||
|
const manager = m.get(DocManager);
|
||||||
|
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
|
text.insert(0, 'hello');
|
||||||
|
text.insert(5, 'world');
|
||||||
|
text.insert(5, ' ');
|
||||||
|
text.insert(11, '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await manager.batchPush('1', '1', updates.slice(0, 2));
|
||||||
|
// do the merge
|
||||||
|
const doc = (await manager.get('1', '1'))!;
|
||||||
|
|
||||||
|
t.is(doc.getText('content').toString(), 'helloworld');
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
await manager.batchPush('1', '1', updates.slice(2));
|
||||||
|
const doc = (await manager.get('1', '1'))!;
|
||||||
|
|
||||||
|
t.is(doc.getText('content').toString(), 'hello world!');
|
||||||
|
}
|
||||||
|
|
||||||
|
const restUpdates = await manager.getUpdates('1', '1');
|
||||||
|
|
||||||
|
t.is(restUpdates.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not update snapshot if doc is outdated', async t => {
|
||||||
|
const manager = m.get(DocManager);
|
||||||
|
const db = m.get(PrismaClient);
|
||||||
|
|
||||||
|
const updates: Buffer[] = [];
|
||||||
|
{
|
||||||
|
const doc = new YDoc();
|
||||||
|
doc.on('update', data => {
|
||||||
|
updates.push(Buffer.from(data));
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = doc.getText('content');
|
||||||
|
text.insert(0, 'hello');
|
||||||
|
text.insert(5, 'world');
|
||||||
|
text.insert(5, ' ');
|
||||||
|
text.insert(11, '!');
|
||||||
|
}
|
||||||
|
|
||||||
|
await manager.batchPush('2', '1', updates.slice(0, 2)); // 'helloworld'
|
||||||
|
// merge updates into snapshot
|
||||||
|
await manager.get('2', '1');
|
||||||
|
// fake the snapshot is a lot newer
|
||||||
|
await db.snapshot.update({
|
||||||
where: {
|
where: {
|
||||||
id_workspaceId: {
|
id_workspaceId: {
|
||||||
id: '2',
|
|
||||||
workspaceId: '2',
|
workspaceId: '2',
|
||||||
|
id: '1',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
data: {
|
||||||
|
updatedAt: new Date(Date.now() + 10000),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result) {
|
{
|
||||||
t.fail('snapshot not found');
|
const snapshot = await manager.getSnapshot('2', '1');
|
||||||
return;
|
await manager.batchPush('2', '1', updates.slice(2)); // 'hello world!'
|
||||||
|
const updateRecords = await manager.getUpdates('2', '1');
|
||||||
|
|
||||||
|
// @ts-expect-error private
|
||||||
|
const doc = await manager.squash(snapshot, updateRecords);
|
||||||
|
|
||||||
|
// all updated will merged into doc not matter it's timestamp is outdated or not,
|
||||||
|
// but the snapshot record will not be updated
|
||||||
|
t.is(doc.getText('content').toString(), 'hello world!');
|
||||||
}
|
}
|
||||||
|
|
||||||
const state = decodeStateVector(result.state!);
|
{
|
||||||
t.is(state.get(doc.clientID), 12);
|
const doc = new YDoc();
|
||||||
|
applyUpdate(doc, (await manager.getSnapshot('2', '1'))!.blob);
|
||||||
|
// the snapshot will not get touched if the new doc's timestamp is outdated
|
||||||
|
t.is(doc.getText('content').toString(), 'helloworld');
|
||||||
|
|
||||||
const d = new YDoc();
|
// the updates are known as outdated, so they will be deleted
|
||||||
applyUpdate(d, result.blob!);
|
t.is((await manager.getUpdates('2', '1')).length, 0);
|
||||||
|
}
|
||||||
const dtext = d.getText('content');
|
|
||||||
t.is(dtext.toString(), 'hello world!');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ crate-type = ["cdylib"]
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
jwst-codec = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
|
|
||||||
jwst-core = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
|
|
||||||
jwst-storage = { git = "https://github.com/toeverything/OctoBase.git", rev = "49a6b7a" }
|
|
||||||
napi = { version = "2", default-features = false, features = [
|
napi = { version = "2", default-features = false, features = [
|
||||||
"napi5",
|
"napi5",
|
||||||
"async",
|
"async",
|
||||||
@@ -18,6 +15,7 @@ napi = { version = "2", default-features = false, features = [
|
|||||||
napi-derive = { version = "2", features = ["type-def"] }
|
napi-derive = { version = "2", features = ["type-def"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
sha3 = "0.10"
|
sha3 = "0.10"
|
||||||
|
y-octo = { git = "https://github.com/y-crdt/y-octo.git", branch = "main" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = "1"
|
tokio = "1"
|
||||||
|
|||||||
22
packages/backend/storage/index.d.ts
vendored
22
packages/backend/storage/index.d.ts
vendored
@@ -1,28 +1,6 @@
|
|||||||
/* auto-generated by NAPI-RS */
|
/* auto-generated by NAPI-RS */
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
|
|
||||||
export class Storage {
|
|
||||||
/** Create a storage instance and establish connection to persist store. */
|
|
||||||
static connect(database: string, debugOnlyAutoMigrate?: boolean | undefined | null): Promise<Storage>
|
|
||||||
/** List all blobs in a workspace. */
|
|
||||||
listBlobs(workspaceId?: string | undefined | null): Promise<Array<string>>
|
|
||||||
/** Fetch a workspace blob. */
|
|
||||||
getBlob(workspaceId: string, name: string): Promise<Blob | null>
|
|
||||||
/** Upload a blob into workspace storage. */
|
|
||||||
uploadBlob(workspaceId: string, blob: Buffer): Promise<string>
|
|
||||||
/** Delete a blob from workspace storage. */
|
|
||||||
deleteBlob(workspaceId: string, hash: string): Promise<boolean>
|
|
||||||
/** Workspace size taken by blobs. */
|
|
||||||
blobsSize(workspaces: Array<string>): Promise<number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Blob {
|
|
||||||
contentType: string
|
|
||||||
lastModified: string
|
|
||||||
size: number
|
|
||||||
data: Buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
* Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
||||||
* result binary.
|
* result binary.
|
||||||
|
|||||||
@@ -2,16 +2,10 @@
|
|||||||
|
|
||||||
pub mod hashcash;
|
pub mod hashcash;
|
||||||
|
|
||||||
use std::{
|
use std::fmt::{Debug, Display};
|
||||||
collections::HashMap,
|
|
||||||
fmt::{Debug, Display},
|
|
||||||
path::PathBuf,
|
|
||||||
};
|
|
||||||
|
|
||||||
use jwst_codec::Doc;
|
|
||||||
use jwst_core::BlobStorage;
|
|
||||||
use jwst_storage::{BlobStorageType, JwstStorage, JwstStorageError};
|
|
||||||
use napi::{bindgen_prelude::*, Error, Result, Status};
|
use napi::{bindgen_prelude::*, Error, Result, Status};
|
||||||
|
use y_octo::Doc;
|
||||||
|
|
||||||
#[macro_use]
|
#[macro_use]
|
||||||
extern crate napi_derive;
|
extern crate napi_derive;
|
||||||
@@ -35,132 +29,13 @@ macro_rules! map_err {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
macro_rules! napi_wrap {
|
|
||||||
($( ($name: ident, $target: ident) ),*) => {
|
|
||||||
$(
|
|
||||||
#[napi]
|
|
||||||
pub struct $name($target);
|
|
||||||
|
|
||||||
impl std::ops::Deref for $name {
|
|
||||||
type Target = $target;
|
|
||||||
|
|
||||||
fn deref(&self) -> &Self::Target {
|
|
||||||
&self.0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<$target> for $name {
|
|
||||||
fn from(t: $target) -> Self {
|
|
||||||
Self(t)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)*
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
napi_wrap!((Storage, JwstStorage));
|
|
||||||
|
|
||||||
#[napi(object)]
|
|
||||||
pub struct Blob {
|
|
||||||
pub content_type: String,
|
|
||||||
pub last_modified: String,
|
|
||||||
pub size: i64,
|
|
||||||
pub data: Buffer,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[napi]
|
|
||||||
impl Storage {
|
|
||||||
/// Create a storage instance and establish connection to persist store.
|
|
||||||
#[napi]
|
|
||||||
pub async fn connect(database: String, debug_only_auto_migrate: Option<bool>) -> Result<Storage> {
|
|
||||||
let inner = match if cfg!(debug_assertions) && debug_only_auto_migrate.unwrap_or(false) {
|
|
||||||
JwstStorage::new_with_migration(&database, BlobStorageType::DB).await
|
|
||||||
} else {
|
|
||||||
JwstStorage::new(&database, BlobStorageType::DB).await
|
|
||||||
} {
|
|
||||||
Ok(storage) => storage,
|
|
||||||
Err(JwstStorageError::Db(e)) => {
|
|
||||||
return Err(Error::new(
|
|
||||||
Status::GenericFailure,
|
|
||||||
format!("failed to connect to database: {}", e),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Err(e) => return Err(Error::new(Status::GenericFailure, e.to_string())),
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(inner.into())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all blobs in a workspace.
|
|
||||||
#[napi]
|
|
||||||
pub async fn list_blobs(&self, workspace_id: Option<String>) -> Result<Vec<String>> {
|
|
||||||
map_err!(self.blobs().list_blobs(workspace_id).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch a workspace blob.
|
|
||||||
#[napi]
|
|
||||||
pub async fn get_blob(&self, workspace_id: String, name: String) -> Result<Option<Blob>> {
|
|
||||||
let (id, params) = {
|
|
||||||
let path = PathBuf::from(name.clone());
|
|
||||||
let ext = path
|
|
||||||
.extension()
|
|
||||||
.and_then(|s| s.to_str().map(|s| s.to_string()));
|
|
||||||
let id = path
|
|
||||||
.file_stem()
|
|
||||||
.and_then(|s| s.to_str().map(|s| s.to_string()))
|
|
||||||
.unwrap_or(name);
|
|
||||||
|
|
||||||
(id, ext.map(|ext| HashMap::from([("format".into(), ext)])))
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(meta) = self
|
|
||||||
.blobs()
|
|
||||||
.get_metadata(Some(workspace_id.clone()), id.clone(), params.clone())
|
|
||||||
.await
|
|
||||||
else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
let Ok(file) = self.blobs().get_blob(Some(workspace_id), id, params).await else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some(Blob {
|
|
||||||
content_type: meta.content_type,
|
|
||||||
last_modified: format!("{:?}", meta.last_modified),
|
|
||||||
size: meta.size,
|
|
||||||
data: file.into(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Upload a blob into workspace storage.
|
|
||||||
#[napi]
|
|
||||||
pub async fn upload_blob(&self, workspace_id: String, blob: Buffer) -> Result<String> {
|
|
||||||
// TODO: can optimize, avoid copy
|
|
||||||
let blob = blob.as_ref().to_vec();
|
|
||||||
map_err!(self.blobs().put_blob(Some(workspace_id), blob).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a blob from workspace storage.
|
|
||||||
#[napi]
|
|
||||||
pub async fn delete_blob(&self, workspace_id: String, hash: String) -> Result<bool> {
|
|
||||||
map_err!(self.blobs().delete_blob(Some(workspace_id), hash).await)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Workspace size taken by blobs.
|
|
||||||
#[napi]
|
|
||||||
pub async fn blobs_size(&self, workspaces: Vec<String>) -> Result<i64> {
|
|
||||||
map_err!(self.blobs().get_blobs_size(workspaces).await)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
/// Merge updates in form like `Y.applyUpdate(doc, update)` way and return the
|
||||||
/// result binary.
|
/// result binary.
|
||||||
#[napi(catch_unwind)]
|
#[napi(catch_unwind)]
|
||||||
pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
|
pub fn merge_updates_in_apply_way(updates: Vec<Buffer>) -> Result<Buffer> {
|
||||||
let mut doc = Doc::default();
|
let mut doc = Doc::default();
|
||||||
for update in updates {
|
for update in updates {
|
||||||
map_err!(doc.apply_update_from_binary(update.as_ref().to_vec()))?;
|
map_err!(doc.apply_update_from_binary_v1(update.as_ref()))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let buf = map_err!(doc.encode_update_v1())?;
|
let buf = map_err!(doc.encode_update_v1())?;
|
||||||
|
|||||||
4
packages/common/env/package.json
vendored
4
packages/common/env/package.json
vendored
@@ -3,8 +3,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "18.2.0",
|
"react-dom": "18.2.0",
|
||||||
"vitest": "1.1.3"
|
"vitest": "1.1.3"
|
||||||
|
|||||||
2
packages/common/env/src/global.ts
vendored
2
packages/common/env/src/global.ts
vendored
@@ -7,6 +7,7 @@ import { isDesktop, isServer } from './constant.js';
|
|||||||
import { UaHelper } from './ua-helper.js';
|
import { UaHelper } from './ua-helper.js';
|
||||||
|
|
||||||
export const blockSuiteFeatureFlags = z.object({
|
export const blockSuiteFeatureFlags = z.object({
|
||||||
|
enable_synced_doc_block: z.boolean(),
|
||||||
enable_expand_database_block: z.boolean(),
|
enable_expand_database_block: z.boolean(),
|
||||||
enable_bultin_ledits: z.boolean(),
|
enable_bultin_ledits: z.boolean(),
|
||||||
});
|
});
|
||||||
@@ -15,6 +16,7 @@ export const runtimeFlagsSchema = z.object({
|
|||||||
enableTestProperties: z.boolean(),
|
enableTestProperties: z.boolean(),
|
||||||
enableBroadcastChannelProvider: z.boolean(),
|
enableBroadcastChannelProvider: z.boolean(),
|
||||||
enableDebugPage: z.boolean(),
|
enableDebugPage: z.boolean(),
|
||||||
|
githubUrl: z.string(),
|
||||||
changelogUrl: z.string(),
|
changelogUrl: z.string(),
|
||||||
downloadUrl: z.string(),
|
downloadUrl: z.string(),
|
||||||
// see: tools/workers
|
// see: tools/workers
|
||||||
|
|||||||
@@ -13,9 +13,9 @@
|
|||||||
"@affine/debug": "workspace:*",
|
"@affine/debug": "workspace:*",
|
||||||
"@affine/env": "workspace:*",
|
"@affine/env": "workspace:*",
|
||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"jotai": "^2.5.1",
|
"jotai": "^2.5.1",
|
||||||
"jotai-effect": "^0.2.3",
|
"jotai-effect": "^0.2.3",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.3",
|
||||||
@@ -26,8 +26,8 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@affine-test/fixtures": "workspace:*",
|
"@affine-test/fixtures": "workspace:*",
|
||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"async-call-rpc": "^6.3.1",
|
"async-call-rpc": "^6.3.1",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import { Map as YMap } from 'yjs';
|
|||||||
import { getLatestVersions } from '../migration/blocksuite';
|
import { getLatestVersions } from '../migration/blocksuite';
|
||||||
import { replaceIdMiddleware } from './middleware';
|
import { replaceIdMiddleware } from './middleware';
|
||||||
|
|
||||||
export async function initEmptyPage(page: Page, title?: string) {
|
export function initEmptyPage(page: Page, title?: string) {
|
||||||
await page.load(() => {
|
page.load(() => {
|
||||||
const pageBlockId = page.addBlock('affine:page', {
|
const pageBlockId = page.addBlock('affine:page', {
|
||||||
title: new page.Text(title ?? ''),
|
title: new page.Text(title ?? ''),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
|
import { setupEditorFlags } from '@affine/env/global';
|
||||||
import { WorkspaceFlavour } from '@affine/env/workspace';
|
import { WorkspaceFlavour } from '@affine/env/workspace';
|
||||||
import { assertEquals } from '@blocksuite/global/utils';
|
import { assertEquals } from '@blocksuite/global/utils';
|
||||||
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
import type { Workspace as BlockSuiteWorkspace } from '@blocksuite/store';
|
||||||
@@ -164,6 +165,8 @@ export class WorkspaceManager {
|
|||||||
// apply compatibility fix
|
// apply compatibility fix
|
||||||
fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc);
|
fixWorkspaceVersion(workspace.blockSuiteWorkspace.doc);
|
||||||
|
|
||||||
|
setupEditorFlags(workspace.blockSuiteWorkspace);
|
||||||
|
|
||||||
return workspace;
|
return workspace;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,14 +32,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
"nanoid": "^5.0.3",
|
"nanoid": "^5.0.3",
|
||||||
"y-provider": "workspace:*"
|
"y-provider": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"fake-indexeddb": "^5.0.0",
|
"fake-indexeddb": "^5.0.0",
|
||||||
"vite": "^5.0.6",
|
"vite": "^5.0.6",
|
||||||
"vite-plugin-dts": "3.7.0",
|
"vite-plugin-dts": "3.7.0",
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ describe('indexeddb provider', () => {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||||
page.addBlock('affine:paragraph', {}, frameId);
|
page.addBlock('affine:paragraph', {}, frameId);
|
||||||
@@ -129,7 +129,7 @@ describe('indexeddb provider', () => {
|
|||||||
| WorkspacePersist
|
| WorkspacePersist
|
||||||
| undefined;
|
| undefined;
|
||||||
assertExists(data);
|
assertExists(data);
|
||||||
await testWorkspace.getPage('page0')?.waitForLoaded();
|
testWorkspace.getPage('page0')?.waitForLoaded();
|
||||||
data.updates.forEach(({ update }) => {
|
data.updates.forEach(({ update }) => {
|
||||||
Workspace.Y.applyUpdate(subPage, update);
|
Workspace.Y.applyUpdate(subPage, update);
|
||||||
});
|
});
|
||||||
@@ -148,7 +148,7 @@ describe('indexeddb provider', () => {
|
|||||||
expect(provider.connected).toBe(false);
|
expect(provider.connected).toBe(false);
|
||||||
{
|
{
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||||
page.addBlock('affine:paragraph', {}, frameId);
|
page.addBlock('affine:paragraph', {}, frameId);
|
||||||
@@ -203,7 +203,7 @@ describe('indexeddb provider', () => {
|
|||||||
provider.connect();
|
provider.connect();
|
||||||
{
|
{
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
const pageBlockId = page.addBlock('affine:page', { title: '' });
|
||||||
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
const frameId = page.addBlock('affine:note', {}, pageBlockId);
|
||||||
for (let i = 0; i < 99; i++) {
|
for (let i = 0; i < 99; i++) {
|
||||||
@@ -369,14 +369,14 @@ describe('subDoc', () => {
|
|||||||
const page0 = workspace.createPage({
|
const page0 = workspace.createPage({
|
||||||
id: 'page0',
|
id: 'page0',
|
||||||
});
|
});
|
||||||
await page0.waitForLoaded();
|
page0.waitForLoaded();
|
||||||
const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0);
|
const { paragraphBlockId: paragraphBlockIdPage1 } = initEmptyPage(page0);
|
||||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||||
provider.connect();
|
provider.connect();
|
||||||
const page1 = workspace.createPage({
|
const page1 = workspace.createPage({
|
||||||
id: 'page1',
|
id: 'page1',
|
||||||
});
|
});
|
||||||
await page1.waitForLoaded();
|
page1.waitForLoaded();
|
||||||
const { paragraphBlockId: paragraphBlockIdPage2 } = initEmptyPage(page1);
|
const { paragraphBlockId: paragraphBlockIdPage2 } = initEmptyPage(page1);
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
provider.disconnect();
|
provider.disconnect();
|
||||||
@@ -390,14 +390,14 @@ describe('subDoc', () => {
|
|||||||
provider.connect();
|
provider.connect();
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
const page0 = newWorkspace.getPage('page0') as Page;
|
const page0 = newWorkspace.getPage('page0') as Page;
|
||||||
await page0.waitForLoaded();
|
page0.waitForLoaded();
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
{
|
{
|
||||||
const block = page0.getBlockById(paragraphBlockIdPage1);
|
const block = page0.getBlockById(paragraphBlockIdPage1);
|
||||||
assertExists(block);
|
assertExists(block);
|
||||||
}
|
}
|
||||||
const page1 = newWorkspace.getPage('page1') as Page;
|
const page1 = newWorkspace.getPage('page1') as Page;
|
||||||
await page1.waitForLoaded();
|
page1.waitForLoaded();
|
||||||
await setTimeout(200);
|
await setTimeout(200);
|
||||||
{
|
{
|
||||||
const block = page1.getBlockById(paragraphBlockIdPage2);
|
const block = page1.getBlockById(paragraphBlockIdPage2);
|
||||||
@@ -410,7 +410,7 @@ describe('subDoc', () => {
|
|||||||
describe('utils', () => {
|
describe('utils', () => {
|
||||||
test('download binary', async () => {
|
test('download binary', async () => {
|
||||||
const page = workspace.createPage({ id: 'page0' });
|
const page = workspace.createPage({ id: 'page0' });
|
||||||
await page.waitForLoaded();
|
page.waitForLoaded();
|
||||||
initEmptyPage(page);
|
initEmptyPage(page);
|
||||||
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
const provider = createIndexedDBProvider(workspace.doc, rootDBName);
|
||||||
provider.connect();
|
provider.connect();
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"build": "vite build"
|
"build": "vite build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"vite": "^5.0.6",
|
"vite": "^5.0.6",
|
||||||
"vite-plugin-dts": "3.7.0",
|
"vite-plugin-dts": "3.7.0",
|
||||||
"vitest": "1.1.3",
|
"vitest": "1.1.3",
|
||||||
|
|||||||
@@ -73,12 +73,12 @@
|
|||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/icons": "2.1.44",
|
"@blocksuite/icons": "2.1.44",
|
||||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@storybook/addon-actions": "^7.5.3",
|
"@storybook/addon-actions": "^7.5.3",
|
||||||
"@storybook/addon-essentials": "^7.5.3",
|
"@storybook/addon-essentials": "^7.5.3",
|
||||||
"@storybook/addon-interactions": "^7.5.3",
|
"@storybook/addon-interactions": "^7.5.3",
|
||||||
|
|||||||
Binary file not shown.
@@ -1,195 +0,0 @@
|
|||||||
import { keyframes, style } from '@vanilla-extract/css';
|
|
||||||
|
|
||||||
export const modalStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: 'var(--affine-background-secondary-color)',
|
|
||||||
borderRadius: '16px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const titleContainerStyle = style({
|
|
||||||
width: 'calc(100% - 72px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
height: '60px',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const titleStyle = style({
|
|
||||||
fontSize: 'var(--affine-font-h6)',
|
|
||||||
fontWeight: '600',
|
|
||||||
marginTop: '12px',
|
|
||||||
position: 'absolute',
|
|
||||||
marginBottom: '12px',
|
|
||||||
});
|
|
||||||
const slideToLeft = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(-300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const slideToRight = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const slideFormLeft = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const slideFormRight = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(-300px)',
|
|
||||||
opacity: 0,
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const formSlideToLeftStyle = style({
|
|
||||||
animation: `${slideFormLeft} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
export const formSlideToRightStyle = style({
|
|
||||||
animation: `${slideFormRight} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
export const slideToLeftStyle = style({
|
|
||||||
animation: `${slideToLeft} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
export const slideToRightStyle = style({
|
|
||||||
animation: `${slideToRight} 0.3s ease-in-out forwards`,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const containerStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
});
|
|
||||||
export const videoContainerStyle = style({
|
|
||||||
height: '300px',
|
|
||||||
width: 'calc(100% - 72px)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
export const videoSlideStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
});
|
|
||||||
export const videoStyle = style({
|
|
||||||
position: 'absolute',
|
|
||||||
objectFit: 'fill',
|
|
||||||
height: '300px',
|
|
||||||
border: '1px solid var(--affine-border-color)',
|
|
||||||
transition: 'opacity 0.5s ease-in-out',
|
|
||||||
});
|
|
||||||
const fadeIn = keyframes({
|
|
||||||
'0%': {
|
|
||||||
transform: 'translateX(300px)',
|
|
||||||
},
|
|
||||||
'100%': {
|
|
||||||
transform: 'translateX(0)',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const videoActiveStyle = style({
|
|
||||||
animation: `${fadeIn} 0.5s ease-in-out forwards`,
|
|
||||||
opacity: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
export const arrowStyle = style({
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
wordWrap: 'break-word',
|
|
||||||
width: '36px',
|
|
||||||
fontSize: '32px',
|
|
||||||
display: 'flex',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
height: '240px',
|
|
||||||
flexGrow: 0.2,
|
|
||||||
cursor: 'pointer',
|
|
||||||
});
|
|
||||||
export const descriptionContainerStyle = style({
|
|
||||||
width: 'calc(100% - 112px)',
|
|
||||||
height: '100px',
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
overflow: 'hidden',
|
|
||||||
});
|
|
||||||
|
|
||||||
export const descriptionStyle = style({
|
|
||||||
marginTop: '15px',
|
|
||||||
width: '100%',
|
|
||||||
display: 'flex',
|
|
||||||
fontSize: 'var(--affine-font-sm)',
|
|
||||||
lineHeight: '18px',
|
|
||||||
position: 'absolute',
|
|
||||||
});
|
|
||||||
export const tabStyle = style({
|
|
||||||
width: '40px',
|
|
||||||
height: '40px',
|
|
||||||
content: '""',
|
|
||||||
margin: '40px 10px 40px 0',
|
|
||||||
transition: 'all 0.15s ease-in-out',
|
|
||||||
position: 'relative',
|
|
||||||
cursor: 'pointer',
|
|
||||||
':hover': {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
'::after': {
|
|
||||||
content: '""',
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: '20px',
|
|
||||||
left: '0',
|
|
||||||
width: '100%',
|
|
||||||
height: '2px',
|
|
||||||
background: 'var(--affine-text-primary-color)',
|
|
||||||
transition: 'all 0.15s ease-in-out',
|
|
||||||
opacity: 0.2,
|
|
||||||
cursor: 'pointer',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const tabActiveStyle = style({
|
|
||||||
'::after': {
|
|
||||||
opacity: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
export const tabContainerStyle = style({
|
|
||||||
width: '100%',
|
|
||||||
marginTop: '20px',
|
|
||||||
position: 'relative',
|
|
||||||
height: '2px',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
});
|
|
||||||
export const buttonDisableStyle = style({
|
|
||||||
cursor: 'not-allowed',
|
|
||||||
color: 'var(--affine-text-disable-color)',
|
|
||||||
});
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export * from './tour-modal';
|
|
||||||
Binary file not shown.
@@ -1,160 +0,0 @@
|
|||||||
/// <reference types="../../type.d.ts" />
|
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|
||||||
import { ArrowLeftSmallIcon, ArrowRightSmallIcon } from '@blocksuite/icons';
|
|
||||||
import clsx from 'clsx';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import { Modal, type ModalProps } from '../../ui/modal';
|
|
||||||
import editingVideo from './editingVideo.mp4';
|
|
||||||
import {
|
|
||||||
arrowStyle,
|
|
||||||
buttonDisableStyle,
|
|
||||||
containerStyle,
|
|
||||||
descriptionContainerStyle,
|
|
||||||
descriptionStyle,
|
|
||||||
formSlideToLeftStyle,
|
|
||||||
formSlideToRightStyle,
|
|
||||||
modalStyle,
|
|
||||||
slideToLeftStyle,
|
|
||||||
slideToRightStyle,
|
|
||||||
tabActiveStyle,
|
|
||||||
tabContainerStyle,
|
|
||||||
tabStyle,
|
|
||||||
titleContainerStyle,
|
|
||||||
titleStyle,
|
|
||||||
videoContainerStyle,
|
|
||||||
videoSlideStyle,
|
|
||||||
videoStyle,
|
|
||||||
} from './index.css';
|
|
||||||
import switchVideo from './switchVideo.mp4';
|
|
||||||
|
|
||||||
export const TourModal = (props: ModalProps) => {
|
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const [step, setStep] = useState(-1);
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
width={545}
|
|
||||||
contentOptions={{
|
|
||||||
['data-testid' as string]: 'onboarding-modal',
|
|
||||||
style: {
|
|
||||||
minHeight: '480px',
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
overlayOptions={{
|
|
||||||
style: {
|
|
||||||
background: 'transparent',
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
closeButtonOptions={{
|
|
||||||
// @ts-expect-error - fix upstream type
|
|
||||||
'data-testid': 'onboarding-modal-close-button',
|
|
||||||
}}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<div className={modalStyle}>
|
|
||||||
<div className={titleContainerStyle}>
|
|
||||||
{step !== -1 && (
|
|
||||||
<div
|
|
||||||
className={clsx(titleStyle, {
|
|
||||||
[slideToRightStyle]: step === 0,
|
|
||||||
[formSlideToLeftStyle]: step === 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.title2']()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={clsx(titleStyle, {
|
|
||||||
[slideToLeftStyle]: step === 1,
|
|
||||||
[formSlideToRightStyle]: step === 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.title1']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={containerStyle}>
|
|
||||||
<div
|
|
||||||
className={clsx(arrowStyle, { [buttonDisableStyle]: step !== 1 })}
|
|
||||||
onClick={() => step === 1 && setStep(0)}
|
|
||||||
data-testid="onboarding-modal-pre-button"
|
|
||||||
>
|
|
||||||
<ArrowLeftSmallIcon />
|
|
||||||
</div>
|
|
||||||
<div className={videoContainerStyle}>
|
|
||||||
<div className={videoSlideStyle}>
|
|
||||||
{step !== -1 && (
|
|
||||||
<video
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
className={clsx(videoStyle, {
|
|
||||||
[slideToRightStyle]: step === 0,
|
|
||||||
[formSlideToLeftStyle]: step === 1,
|
|
||||||
})}
|
|
||||||
data-testid="onboarding-modal-editing-video"
|
|
||||||
>
|
|
||||||
<source src={editingVideo} type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
)}
|
|
||||||
<video
|
|
||||||
autoPlay
|
|
||||||
muted
|
|
||||||
loop
|
|
||||||
className={clsx(videoStyle, {
|
|
||||||
[slideToLeftStyle]: step === 1,
|
|
||||||
[formSlideToRightStyle]: step === 0,
|
|
||||||
})}
|
|
||||||
data-testid="onboarding-modal-switch-video"
|
|
||||||
>
|
|
||||||
<source src={switchVideo} type="video/mp4" />
|
|
||||||
</video>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={clsx(arrowStyle, { [buttonDisableStyle]: step === 1 })}
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
data-testid="onboarding-modal-next-button"
|
|
||||||
>
|
|
||||||
<ArrowRightSmallIcon />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ul className={tabContainerStyle}>
|
|
||||||
<li
|
|
||||||
className={clsx(tabStyle, {
|
|
||||||
[tabActiveStyle]: step !== 1,
|
|
||||||
})}
|
|
||||||
onClick={() => setStep(0)}
|
|
||||||
></li>
|
|
||||||
<li
|
|
||||||
className={clsx(tabStyle, { [tabActiveStyle]: step === 1 })}
|
|
||||||
onClick={() => setStep(1)}
|
|
||||||
></li>
|
|
||||||
</ul>
|
|
||||||
<div className={descriptionContainerStyle}>
|
|
||||||
{step !== -1 && (
|
|
||||||
<div
|
|
||||||
className={clsx(descriptionStyle, {
|
|
||||||
[slideToRightStyle]: step === 0,
|
|
||||||
[formSlideToLeftStyle]: step === 1,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.videoDescription2']()}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div
|
|
||||||
className={clsx(descriptionStyle, {
|
|
||||||
[slideToLeftStyle]: step === 1,
|
|
||||||
[formSlideToRightStyle]: step === 0,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{t['com.affine.onboarding.videoDescription1']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TourModal;
|
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
export * from './confirm-modal';
|
export * from './confirm-modal';
|
||||||
export * from './modal';
|
export * from './modal';
|
||||||
|
export * from './overlay-modal';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Button } from '../button';
|
|||||||
import { Input, type InputProps } from '../input';
|
import { Input, type InputProps } from '../input';
|
||||||
import { ConfirmModal, type ConfirmModalProps } from './confirm-modal';
|
import { ConfirmModal, type ConfirmModalProps } from './confirm-modal';
|
||||||
import { Modal, type ModalProps } from './modal';
|
import { Modal, type ModalProps } from './modal';
|
||||||
|
import { OverlayModal, type OverlayModalProps } from './overlay-modal';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'UI/Modal',
|
title: 'UI/Modal',
|
||||||
@@ -65,5 +66,38 @@ const ConfirmModalTemplate: StoryFn<ConfirmModalProps> = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OverlayModalTemplate: StoryFn<OverlayModalProps> = () => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>Open Overlay Modal</Button>
|
||||||
|
<OverlayModal
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
title="Modal Title"
|
||||||
|
description="Modal description"
|
||||||
|
confirmButtonOptions={{
|
||||||
|
type: 'primary',
|
||||||
|
}}
|
||||||
|
topImage={
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: '400px',
|
||||||
|
height: '300px',
|
||||||
|
background: '#66ccff',
|
||||||
|
opacity: 0.1,
|
||||||
|
color: '#fff',
|
||||||
|
}}
|
||||||
|
></div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const Confirm: StoryFn<ModalProps> =
|
export const Confirm: StoryFn<ModalProps> =
|
||||||
ConfirmModalTemplate.bind(undefined);
|
ConfirmModalTemplate.bind(undefined);
|
||||||
|
|
||||||
|
export const Overlay: StoryFn<ModalProps> =
|
||||||
|
OverlayModalTemplate.bind(undefined);
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
|
export const title = style({
|
||||||
|
padding: '20px 24px 8px 24px',
|
||||||
|
fontSize: cssVar('fontH6'),
|
||||||
|
fontFamily: cssVar('fontFamily'),
|
||||||
|
fontWeight: '600',
|
||||||
|
lineHeight: '26px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const content = style({
|
||||||
|
padding: '0px 24px 8px',
|
||||||
|
fontSize: cssVar('fontBase'),
|
||||||
|
lineHeight: '24px',
|
||||||
|
fontWeight: 400,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const footer = style({
|
||||||
|
padding: '20px 24px',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
gap: '20px',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const gotItBtn = style({
|
||||||
|
fontWeight: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const buttonText = style({
|
||||||
|
color: cssVar('pureWhite'),
|
||||||
|
textDecoration: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
':visited': {
|
||||||
|
color: cssVar('pureWhite'),
|
||||||
|
},
|
||||||
|
});
|
||||||
102
packages/frontend/component/src/ui/modal/overlay-modal.tsx
Normal file
102
packages/frontend/component/src/ui/modal/overlay-modal.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { DialogTrigger } from '@radix-ui/react-dialog';
|
||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
|
import { memo, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { Button, type ButtonProps } from '../button';
|
||||||
|
import { Modal, type ModalProps } from './modal';
|
||||||
|
import * as styles from './overlay-modal.css';
|
||||||
|
|
||||||
|
const defaultContentOptions: ModalProps['contentOptions'] = {
|
||||||
|
style: {
|
||||||
|
padding: 0,
|
||||||
|
overflow: 'hidden',
|
||||||
|
boxShadow: cssVar('menuShadow'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const defaultOverlayOptions: ModalProps['overlayOptions'] = {
|
||||||
|
style: {
|
||||||
|
background: cssVar('white80'),
|
||||||
|
backdropFilter: 'blur(2px)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface OverlayModalProps extends ModalProps {
|
||||||
|
to?: string;
|
||||||
|
external?: boolean;
|
||||||
|
topImage?: React.ReactNode;
|
||||||
|
confirmText?: string;
|
||||||
|
confirmButtonOptions?: ButtonProps;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
cancelText?: string;
|
||||||
|
cancelButtonOptions?: ButtonProps;
|
||||||
|
withoutCancelButton?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OverlayModal = memo(function OverlayModal({
|
||||||
|
open,
|
||||||
|
topImage,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
onConfirm,
|
||||||
|
to,
|
||||||
|
external,
|
||||||
|
confirmButtonOptions,
|
||||||
|
cancelButtonOptions,
|
||||||
|
withoutCancelButton,
|
||||||
|
contentOptions = defaultContentOptions,
|
||||||
|
overlayOptions = defaultOverlayOptions,
|
||||||
|
// FIXME: we need i18n
|
||||||
|
cancelText = 'Cancel',
|
||||||
|
confirmText = 'Confirm',
|
||||||
|
width = 400,
|
||||||
|
}: OverlayModalProps) {
|
||||||
|
const handleConfirm = useCallback(() => {
|
||||||
|
onOpenChange?.(false);
|
||||||
|
onConfirm?.();
|
||||||
|
}, [onOpenChange, onConfirm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
contentOptions={contentOptions}
|
||||||
|
overlayOptions={overlayOptions}
|
||||||
|
open={open}
|
||||||
|
width={width}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
withoutCloseButton
|
||||||
|
>
|
||||||
|
{topImage}
|
||||||
|
<div className={styles.title}>{title}</div>
|
||||||
|
<div className={styles.content}>{description}</div>
|
||||||
|
<div className={styles.footer}>
|
||||||
|
{!withoutCancelButton ? (
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button {...cancelButtonOptions}>{cancelText}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{to ? (
|
||||||
|
external ? (
|
||||||
|
//FIXME: we need a more standardized way to implement this link with other click events
|
||||||
|
<a href={to} target="_blank" rel="noreferrer">
|
||||||
|
<Button onClick={handleConfirm} {...confirmButtonOptions}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<Link to={to}>
|
||||||
|
<Button onClick={handleConfirm} {...confirmButtonOptions}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Button onClick={handleConfirm} {...confirmButtonOptions}>
|
||||||
|
{confirmText}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ const require = createRequire(import.meta.url);
|
|||||||
const packageJson = require('../package.json');
|
const packageJson = require('../package.json');
|
||||||
|
|
||||||
const editorFlags: BlockSuiteFeatureFlags = {
|
const editorFlags: BlockSuiteFeatureFlags = {
|
||||||
|
enable_synced_doc_block: false,
|
||||||
enable_expand_database_block: false,
|
enable_expand_database_block: false,
|
||||||
enable_bultin_ledits: false,
|
enable_bultin_ledits: false,
|
||||||
};
|
};
|
||||||
@@ -16,6 +17,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableTestProperties: false,
|
enableTestProperties: false,
|
||||||
enableBroadcastChannelProvider: true,
|
enableBroadcastChannelProvider: true,
|
||||||
enableDebugPage: true,
|
enableDebugPage: true,
|
||||||
|
githubUrl: 'https://github.com/toeverything/AFFiNE',
|
||||||
changelogUrl: 'https://affine.pro/what-is-new',
|
changelogUrl: 'https://affine.pro/what-is-new',
|
||||||
downloadUrl: 'https://affine.pro/download',
|
downloadUrl: 'https://affine.pro/download',
|
||||||
imageProxyUrl: '/api/worker/image-proxy',
|
imageProxyUrl: '/api/worker/image-proxy',
|
||||||
@@ -57,6 +59,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
|||||||
enableTestProperties: true,
|
enableTestProperties: true,
|
||||||
enableBroadcastChannelProvider: true,
|
enableBroadcastChannelProvider: true,
|
||||||
enableDebugPage: true,
|
enableDebugPage: true,
|
||||||
|
githubUrl: 'https://github.com/toeverything/AFFiNE',
|
||||||
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
changelogUrl: 'https://github.com/toeverything/AFFiNE/releases',
|
||||||
downloadUrl: 'https://affine.pro/download',
|
downloadUrl: 'https://affine.pro/download',
|
||||||
imageProxyUrl: '/api/worker/image-proxy',
|
imageProxyUrl: '/api/worker/image-proxy',
|
||||||
|
|||||||
@@ -26,14 +26,14 @@
|
|||||||
"@affine/templates": "workspace:*",
|
"@affine/templates": "workspace:*",
|
||||||
"@affine/workspace": "workspace:*",
|
"@affine/workspace": "workspace:*",
|
||||||
"@affine/workspace-impl": "workspace:*",
|
"@affine/workspace-impl": "workspace:*",
|
||||||
"@blocksuite/block-std": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/block-std": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/blocks": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/blocks": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/global": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/global": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/icons": "2.1.44",
|
"@blocksuite/icons": "2.1.44",
|
||||||
"@blocksuite/inline": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/inline": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/lit": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/lit": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/presets": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/presets": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@blocksuite/store": "0.12.0-nightly-202401290223-b6302df",
|
"@blocksuite/store": "0.12.0-canary-202402220729-0868ac6",
|
||||||
"@dnd-kit/core": "^6.0.8",
|
"@dnd-kit/core": "^6.0.8",
|
||||||
"@dnd-kit/sortable": "^8.0.0",
|
"@dnd-kit/sortable": "^8.0.0",
|
||||||
"@emotion/cache": "^11.11.0",
|
"@emotion/cache": "^11.11.0",
|
||||||
|
|||||||
BIN
packages/frontend/core/public/static/githubStar.mp4
Normal file
BIN
packages/frontend/core/public/static/githubStar.mp4
Normal file
Binary file not shown.
BIN
packages/frontend/core/public/static/newIssue.mp4
Normal file
BIN
packages/frontend/core/public/static/newIssue.mp4
Normal file
Binary file not shown.
@@ -55,21 +55,6 @@ export const guideChangeLogAtom = atom<
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
export const guideOnboardingAtom = atom<
|
|
||||||
Guide['onBoarding'],
|
|
||||||
[open: boolean],
|
|
||||||
void
|
|
||||||
>(
|
|
||||||
get => {
|
|
||||||
return get(guidePrimitiveAtom).onBoarding;
|
|
||||||
},
|
|
||||||
(_, set, open) => {
|
|
||||||
set(guidePrimitiveAtom, tips => ({
|
|
||||||
...tips,
|
|
||||||
onBoarding: open,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export const guideDownloadClientTipAtom = atom<
|
export const guideDownloadClientTipAtom = atom<
|
||||||
Guide['downloadClientTip'],
|
Guide['downloadClientTip'],
|
||||||
|
|||||||
@@ -10,10 +10,11 @@ import type { SettingProps } from '../components/affine/setting-modal';
|
|||||||
export const openWorkspacesModalAtom = atom(false);
|
export const openWorkspacesModalAtom = atom(false);
|
||||||
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
|
||||||
export const openQuickSearchModalAtom = atom(false);
|
export const openQuickSearchModalAtom = atom(false);
|
||||||
export const openOnboardingModalAtom = atom(false);
|
|
||||||
export const openSignOutModalAtom = atom(false);
|
export const openSignOutModalAtom = atom(false);
|
||||||
export const openPaymentDisableAtom = atom(false);
|
export const openPaymentDisableAtom = atom(false);
|
||||||
export const openQuotaModalAtom = atom(false);
|
export const openQuotaModalAtom = atom(false);
|
||||||
|
export const openStarAFFiNEModalAtom = atom(false);
|
||||||
|
export const openIssueFeedbackModalAtom = atom(false);
|
||||||
|
|
||||||
export type SettingAtom = Pick<
|
export type SettingAtom = Pick<
|
||||||
SettingProps,
|
SettingProps,
|
||||||
|
|||||||
4
packages/frontend/core/src/atoms/sync-engine-status.ts
Normal file
4
packages/frontend/core/src/atoms/sync-engine-status.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
import type { SyncEngineStatus } from '@affine/workspace';
|
||||||
|
import { atom } from 'jotai';
|
||||||
|
|
||||||
|
export const syncEngineStatusAtom = atom<SyncEngineStatus | null>(null);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { builtInTemplates } from '@affine/templates/edgeless';
|
||||||
|
import {
|
||||||
|
EdgelessTemplatePanel,
|
||||||
|
type TemplateManager,
|
||||||
|
} from '@blocksuite/blocks';
|
||||||
|
|
||||||
|
EdgelessTemplatePanel.templates.extend(builtInTemplates as TemplateManager);
|
||||||
@@ -33,7 +33,7 @@ export async function createFirstAppData() {
|
|||||||
workspace.setPageMeta(page.id, {
|
workspace.setPageMeta(page.id, {
|
||||||
jumpOnce: true,
|
jumpOnce: true,
|
||||||
});
|
});
|
||||||
await initEmptyPage(page);
|
initEmptyPage(page);
|
||||||
}
|
}
|
||||||
logger.debug('create first workspace');
|
logger.debug('create first workspace');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import './register-blocksuite-components';
|
import './register-blocksuite-components';
|
||||||
|
import './edgeless-template';
|
||||||
|
|
||||||
import { setupGlobal } from '@affine/env/global';
|
import { setupGlobal } from '@affine/env/global';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { ContactWithUsIcon, NewIcon, UserGuideIcon } from '@blocksuite/icons';
|
import { ContactWithUsIcon, NewIcon } from '@blocksuite/icons';
|
||||||
import { registerAffineCommand } from '@toeverything/infra/command';
|
import { registerAffineCommand } from '@toeverything/infra/command';
|
||||||
import type { createStore } from 'jotai';
|
import type { createStore } from 'jotai';
|
||||||
|
|
||||||
import { openOnboardingModalAtom, openSettingModalAtom } from '../atoms';
|
import { openSettingModalAtom } from '../atoms';
|
||||||
|
|
||||||
export function registerAffineHelpCommands({
|
export function registerAffineHelpCommands({
|
||||||
t,
|
t,
|
||||||
@@ -39,18 +39,6 @@ export function registerAffineHelpCommands({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
unsubs.push(
|
|
||||||
registerAffineCommand({
|
|
||||||
id: 'affine:help-getting-started',
|
|
||||||
category: 'affine:help',
|
|
||||||
icon: <UserGuideIcon />,
|
|
||||||
label: t['com.affine.cmdk.affine.getting-started'](),
|
|
||||||
preconditionStrategy: () => environment.isDesktop,
|
|
||||||
run() {
|
|
||||||
store.set(openOnboardingModalAtom, true);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsubs.forEach(unsub => unsub());
|
unsubs.forEach(unsub => unsub());
|
||||||
|
|||||||
@@ -83,11 +83,12 @@ export function registerAffineNavigationCommands({
|
|||||||
category: 'affine:navigation',
|
category: 'affine:navigation',
|
||||||
icon: <ArrowRightBigIcon />,
|
icon: <ArrowRightBigIcon />,
|
||||||
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
|
label: t['com.affine.cmdk.affine.navigation.open-settings'](),
|
||||||
|
keyBinding: '$mod+,',
|
||||||
run() {
|
run() {
|
||||||
store.set(openSettingModalAtom, {
|
store.set(openSettingModalAtom, s => ({
|
||||||
activeTab: 'appearance',
|
activeTab: 'appearance',
|
||||||
open: true,
|
open: !s.open,
|
||||||
});
|
}));
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import type { AuthPanelProps } from './index';
|
|||||||
import * as style from './style.css';
|
import * as style from './style.css';
|
||||||
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
import { INTERNAL_BETA_URL, useAuth } from './use-auth';
|
||||||
import { Captcha, useCaptcha } from './use-captcha';
|
import { Captcha, useCaptcha } from './use-captcha';
|
||||||
|
import { useSubscriptionSearch } from './use-subscription';
|
||||||
|
|
||||||
function validateEmail(email: string) {
|
function validateEmail(email: string) {
|
||||||
return emailRegex.test(email);
|
return emailRegex.test(email);
|
||||||
@@ -34,6 +35,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
const [verifyToken, challenge] = useCaptcha();
|
const [verifyToken, challenge] = useCaptcha();
|
||||||
|
const subscriptionData = useSubscriptionSearch();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isMutating: isSigningIn,
|
isMutating: isSigningIn,
|
||||||
@@ -81,7 +83,8 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
if (verifyToken) {
|
if (verifyToken) {
|
||||||
if (user) {
|
if (user) {
|
||||||
// provider password sign-in if user has by default
|
// provider password sign-in if user has by default
|
||||||
if (user.hasPassword) {
|
// If with payment, onl support email sign in to avoid redirect to affine app
|
||||||
|
if (user.hasPassword && !subscriptionData) {
|
||||||
setAuthState('signInWithPassword');
|
setAuthState('signInWithPassword');
|
||||||
} else {
|
} else {
|
||||||
const res = await signIn(email, verifyToken, challenge);
|
const res = await signIn(email, verifyToken, challenge);
|
||||||
@@ -101,6 +104,7 @@ export const SignIn: FC<AuthPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
subscriptionData,
|
||||||
challenge,
|
challenge,
|
||||||
email,
|
email,
|
||||||
setAuthEmail,
|
setAuthEmail,
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ import { Button } from '@affine/component/ui/button';
|
|||||||
import { Loading } from '@affine/component/ui/loading';
|
import { Loading } from '@affine/component/ui/loading';
|
||||||
import { AffineShapeIcon } from '@affine/core/components/page-list';
|
import { AffineShapeIcon } from '@affine/core/components/page-list';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
import type { SubscriptionRecurring } from '@affine/graphql';
|
import type { SubscriptionPlan, SubscriptionRecurring } from '@affine/graphql';
|
||||||
import {
|
import {
|
||||||
changePasswordMutation,
|
changePasswordMutation,
|
||||||
checkoutMutation,
|
createCheckoutSessionMutation,
|
||||||
subscriptionQuery,
|
subscriptionQuery,
|
||||||
} from '@affine/graphql';
|
} from '@affine/graphql';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
@@ -30,18 +30,25 @@ const usePaymentRedirect = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const recurring = searchData.recurring as SubscriptionRecurring;
|
const recurring = searchData.recurring as SubscriptionRecurring;
|
||||||
|
const plan = searchData.plan as SubscriptionPlan;
|
||||||
|
const coupon = searchData.coupon;
|
||||||
const idempotencyKey = useMemo(() => nanoid(), []);
|
const idempotencyKey = useMemo(() => nanoid(), []);
|
||||||
const { trigger: checkoutSubscription } = useMutation({
|
const { trigger: checkoutSubscription } = useMutation({
|
||||||
mutation: checkoutMutation,
|
mutation: createCheckoutSessionMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
return useAsyncCallback(async () => {
|
return useAsyncCallback(async () => {
|
||||||
const { checkout } = await checkoutSubscription({
|
const { createCheckoutSession: checkoutUrl } = await checkoutSubscription({
|
||||||
recurring,
|
input: {
|
||||||
idempotencyKey,
|
recurring,
|
||||||
|
plan,
|
||||||
|
coupon,
|
||||||
|
idempotencyKey,
|
||||||
|
successCallbackLink: null,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
window.open(checkout, '_self', 'norefferer');
|
window.open(checkoutUrl, '_self', 'norefferer');
|
||||||
}, [recurring, idempotencyKey, checkoutSubscription]);
|
}, [recurring, plan, coupon, idempotencyKey, checkoutSubscription]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const CenterLoading = () => {
|
const CenterLoading = () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useSearchParams } from 'react-router-dom';
|
|||||||
enum SubscriptionKey {
|
enum SubscriptionKey {
|
||||||
Recurring = 'subscription_recurring',
|
Recurring = 'subscription_recurring',
|
||||||
Plan = 'subscription_plan',
|
Plan = 'subscription_plan',
|
||||||
|
Coupon = 'coupon',
|
||||||
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
|
SignUp = 'sign_up', // A new user with subscription journey: signup > set password > pay in stripe > go to app
|
||||||
Token = 'token', // When signup, there should have a token to set password
|
Token = 'token', // When signup, there should have a token to set password
|
||||||
}
|
}
|
||||||
@@ -22,11 +23,13 @@ export function useSubscriptionSearch() {
|
|||||||
|
|
||||||
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
const recurring = searchParams.get(SubscriptionKey.Recurring);
|
||||||
const plan = searchParams.get(SubscriptionKey.Plan);
|
const plan = searchParams.get(SubscriptionKey.Plan);
|
||||||
|
const coupon = searchParams.get(SubscriptionKey.Coupon);
|
||||||
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
const withSignUp = searchParams.get(SubscriptionKey.SignUp) === '1';
|
||||||
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
const passwordToken = searchParams.get(SubscriptionKey.Token);
|
||||||
return {
|
return {
|
||||||
recurring,
|
recurring,
|
||||||
plan,
|
plan,
|
||||||
|
coupon,
|
||||||
withSignUp,
|
withSignUp,
|
||||||
passwordToken,
|
passwordToken,
|
||||||
getRedirectUrl(signUp?: boolean) {
|
getRedirectUrl(signUp?: boolean) {
|
||||||
@@ -35,6 +38,10 @@ export function useSubscriptionSearch() {
|
|||||||
[SubscriptionKey.Plan, plan ?? ''],
|
[SubscriptionKey.Plan, plan ?? ''],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if (coupon) {
|
||||||
|
paymentParams.set(SubscriptionKey.Coupon, coupon);
|
||||||
|
}
|
||||||
|
|
||||||
if (signUp) {
|
if (signUp) {
|
||||||
paymentParams.set(SubscriptionKey.SignUp, '1');
|
paymentParams.set(SubscriptionKey.SignUp, '1');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const CreateWorkspaceModal = ({
|
|||||||
workspace.setPageMeta(page.id, {
|
workspace.setPageMeta(page.id, {
|
||||||
jumpOnce: true,
|
jumpOnce: true,
|
||||||
});
|
});
|
||||||
await initEmptyPage(page);
|
initEmptyPage(page);
|
||||||
}
|
}
|
||||||
logger.debug('create first workspace');
|
logger.debug('create first workspace');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { OverlayModal } from '@affine/component';
|
||||||
|
import { openIssueFeedbackModalAtom } from '@affine/core/atoms';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
|
||||||
|
export const IssueFeedbackModal = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const [open, setOpen] = useAtom(openIssueFeedbackModalAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayModal
|
||||||
|
open={open}
|
||||||
|
topImage={
|
||||||
|
<video
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
src={'/static/newIssue.mp4'}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={t['com.affine.issue-feedback.title']()}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
description={t['com.affine.issue-feedback.description']()}
|
||||||
|
cancelText={t['com.affine.issue-feedback.cancel']()}
|
||||||
|
to={`${runtimeConfig.githubUrl}/issues/new/choose`}
|
||||||
|
confirmText={t['com.affine.issue-feedback.confirm']()}
|
||||||
|
confirmButtonOptions={{
|
||||||
|
type: 'primary',
|
||||||
|
}}
|
||||||
|
external
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
import { TourModal } from '@affine/component/tour-modal';
|
|
||||||
import { useAtom } from 'jotai';
|
|
||||||
import { memo, useCallback } from 'react';
|
|
||||||
|
|
||||||
import { openOnboardingModalAtom } from '../../atoms';
|
|
||||||
import { guideOnboardingAtom } from '../../atoms/guide';
|
|
||||||
|
|
||||||
export const OnboardingModal = memo(function OnboardingModal() {
|
|
||||||
const [open, setOpen] = useAtom(openOnboardingModalAtom);
|
|
||||||
const [guideOpen, setShowOnboarding] = useAtom(guideOnboardingAtom);
|
|
||||||
const onOpenChange = useCallback(
|
|
||||||
(open: boolean) => {
|
|
||||||
if (open) return;
|
|
||||||
setShowOnboarding(false);
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
[setOpen, setShowOnboarding]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TourModal open={!open ? guideOpen : open} onOpenChange={onOpenChange} />
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -17,11 +17,11 @@ const paperLocations = {
|
|||||||
},
|
},
|
||||||
'1': {
|
'1': {
|
||||||
x: -240,
|
x: -240,
|
||||||
y: -100,
|
y: -30,
|
||||||
},
|
},
|
||||||
'2': {
|
'2': {
|
||||||
x: 240,
|
x: 240,
|
||||||
y: -100,
|
y: -35,
|
||||||
},
|
},
|
||||||
'3': {
|
'3': {
|
||||||
x: -480,
|
x: -480,
|
||||||
|
|||||||
@@ -1,14 +1,11 @@
|
|||||||
import { Button, Modal, type ModalProps } from '@affine/component';
|
import { OverlayModal } from '@affine/component';
|
||||||
|
import type { ModalProps } from '@affine/component/ui/modal';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import { memo, useCallback, useEffect, useState } from 'react';
|
import { memo, useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useAppConfigStorage } from '../../../hooks/use-app-config-storage';
|
import { useAppConfigStorage } from '../../../hooks/use-app-config-storage';
|
||||||
import Thumb from './assets/thumb';
|
import Thumb from './assets/thumb';
|
||||||
import * as styles from './workspace-guide-modal.css';
|
|
||||||
|
|
||||||
const contentOptions: ModalProps['contentOptions'] = {
|
|
||||||
style: { padding: 0, overflow: 'hidden' },
|
|
||||||
};
|
|
||||||
const overlayOptions: ModalProps['overlayOptions'] = {
|
const overlayOptions: ModalProps['overlayOptions'] = {
|
||||||
style: {
|
style: {
|
||||||
background:
|
background:
|
||||||
@@ -36,7 +33,6 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
|
|||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
const gotIt = useCallback(() => {
|
const gotIt = useCallback(() => {
|
||||||
setOpen(false);
|
|
||||||
setDismiss(true);
|
setDismiss(true);
|
||||||
}, [setDismiss]);
|
}, [setDismiss]);
|
||||||
|
|
||||||
@@ -47,28 +43,23 @@ export const WorkspaceGuideModal = memo(function WorkspaceGuideModal() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<OverlayModal
|
||||||
withoutCloseButton
|
|
||||||
contentOptions={contentOptions}
|
|
||||||
overlayOptions={overlayOptions}
|
|
||||||
open={open}
|
open={open}
|
||||||
width={400}
|
|
||||||
onOpenChange={onOpenChange}
|
onOpenChange={onOpenChange}
|
||||||
>
|
topImage={<Thumb />}
|
||||||
<Thumb />
|
title={t['com.affine.onboarding.workspace-guide.title']()}
|
||||||
<div className={styles.title}>
|
description={t['com.affine.onboarding.workspace-guide.content']()}
|
||||||
{t['com.affine.onboarding.workspace-guide.title']()}
|
onConfirm={gotIt}
|
||||||
</div>
|
overlayOptions={overlayOptions}
|
||||||
<div className={styles.content}>
|
withoutCancelButton
|
||||||
{t['com.affine.onboarding.workspace-guide.content']()}
|
confirmButtonOptions={{
|
||||||
</div>
|
style: {
|
||||||
<div className={styles.footer}>
|
fontWeight: 500,
|
||||||
<Button type="primary" size="large" onClick={gotIt}>
|
},
|
||||||
<span className={styles.gotItBtn}>
|
type: 'primary',
|
||||||
{t['com.affine.onboarding.workspace-guide.got-it']()}
|
size: 'large',
|
||||||
</span>
|
}}
|
||||||
</Button>
|
confirmText={t['com.affine.onboarding.workspace-guide.got-it']()}
|
||||||
</div>
|
/>
|
||||||
</Modal>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -111,7 +111,6 @@ const getOrCreateShellWorkspace = (workspaceId: string) => {
|
|||||||
const blobStorage = createAffineCloudBlobStorage(workspaceId);
|
const blobStorage = createAffineCloudBlobStorage(workspaceId);
|
||||||
workspace = new Workspace({
|
workspace = new Workspace({
|
||||||
id: workspaceId,
|
id: workspaceId,
|
||||||
providerCreators: [],
|
|
||||||
blobStorages: [
|
blobStorages: [
|
||||||
() => ({
|
() => ({
|
||||||
crud: blobStorage,
|
crud: blobStorage,
|
||||||
@@ -162,12 +161,10 @@ export const useSnapshotPage = (
|
|||||||
});
|
});
|
||||||
page.awarenessStore.setReadonly(page, true);
|
page.awarenessStore.setReadonly(page, true);
|
||||||
const spaceDoc = page.spaceDoc;
|
const spaceDoc = page.spaceDoc;
|
||||||
page
|
page.load(() => {
|
||||||
.load(() => {
|
applyUpdate(spaceDoc, new Uint8Array(snapshot));
|
||||||
applyUpdate(spaceDoc, new Uint8Array(snapshot));
|
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
|
||||||
historyShellWorkspace.schema.upgradePage(0, {}, spaceDoc);
|
}); // must load before applyUpdate
|
||||||
})
|
|
||||||
.catch(console.error); // must load before applyUpdate
|
|
||||||
}
|
}
|
||||||
return page ?? undefined;
|
return page ?? undefined;
|
||||||
}, [pageDocId, snapshot, ts, workspace]);
|
}, [pageDocId, snapshot, ts, workspace]);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
SubscriptionMutator,
|
SubscriptionMutator,
|
||||||
} from '@affine/core/hooks/use-subscription';
|
} from '@affine/core/hooks/use-subscription';
|
||||||
import {
|
import {
|
||||||
checkoutMutation,
|
createCheckoutSessionMutation,
|
||||||
SubscriptionPlan,
|
SubscriptionPlan,
|
||||||
SubscriptionRecurring,
|
SubscriptionRecurring,
|
||||||
SubscriptionStatus,
|
SubscriptionStatus,
|
||||||
@@ -359,7 +359,7 @@ const Upgrade = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const { isMutating, trigger } = useMutation({
|
const { isMutating, trigger } = useMutation({
|
||||||
mutation: checkoutMutation,
|
mutation: createCheckoutSessionMutation,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newTabRef = useRef<Window | null>(null);
|
const newTabRef = useRef<Window | null>(null);
|
||||||
@@ -383,13 +383,21 @@ const Upgrade = ({
|
|||||||
newTabRef.current.focus();
|
newTabRef.current.focus();
|
||||||
} else {
|
} else {
|
||||||
await trigger(
|
await trigger(
|
||||||
{ recurring, idempotencyKey },
|
{
|
||||||
|
input: {
|
||||||
|
recurring,
|
||||||
|
idempotencyKey,
|
||||||
|
plan: SubscriptionPlan.Pro, // Only support prod plan now.
|
||||||
|
coupon: null,
|
||||||
|
successCallbackLink: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
onSuccess: data => {
|
onSuccess: data => {
|
||||||
// FIXME: safari prevents from opening new tab by window api
|
// FIXME: safari prevents from opening new tab by window api
|
||||||
// TODO(@xp): what if electron?
|
// TODO(@xp): what if electron?
|
||||||
const newTab = window.open(
|
const newTab = window.open(
|
||||||
data.checkout,
|
data.createCheckoutSession,
|
||||||
'_blank',
|
'_blank',
|
||||||
'noopener noreferrer'
|
'noopener noreferrer'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
|
import { WorkspaceDetailSkeleton } from '@affine/component/setting-components';
|
||||||
import { Modal, type ModalProps } from '@affine/component/ui/modal';
|
import { Modal, type ModalProps } from '@affine/component/ui/modal';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import {
|
||||||
|
openIssueFeedbackModalAtom,
|
||||||
|
openStarAFFiNEModalAtom,
|
||||||
|
} from '@affine/core/atoms';
|
||||||
|
import { Trans } from '@affine/i18n';
|
||||||
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
|
import type { WorkspaceMetadata } from '@affine/workspace/metadata';
|
||||||
import { ContactWithUsIcon } from '@blocksuite/icons';
|
import { ContactWithUsIcon } from '@blocksuite/icons';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
import { debounce } from 'lodash-es';
|
import { debounce } from 'lodash-es';
|
||||||
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
|
import { Suspense, useCallback, useLayoutEffect, useRef } from 'react';
|
||||||
|
|
||||||
@@ -37,7 +42,6 @@ export const SettingModal = ({
|
|||||||
onSettingClick,
|
onSettingClick,
|
||||||
...modalProps
|
...modalProps
|
||||||
}: SettingProps) => {
|
}: SettingProps) => {
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const loginStatus = useCurrentLoginStatus();
|
const loginStatus = useCurrentLoginStatus();
|
||||||
|
|
||||||
const modalContentRef = useRef<HTMLDivElement>(null);
|
const modalContentRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -79,6 +83,16 @@ export const SettingModal = ({
|
|||||||
},
|
},
|
||||||
[onSettingClick]
|
[onSettingClick]
|
||||||
);
|
);
|
||||||
|
const setOpenIssueFeedbackModal = useSetAtom(openIssueFeedbackModalAtom);
|
||||||
|
const setOpenStarAFFiNEModal = useSetAtom(openStarAFFiNEModalAtom);
|
||||||
|
|
||||||
|
const handleOpenIssueFeedbackModal = useCallback(() => {
|
||||||
|
setOpenIssueFeedbackModal(true);
|
||||||
|
}, [setOpenIssueFeedbackModal]);
|
||||||
|
|
||||||
|
const handleOpenStarAFFiNEModal = useCallback(() => {
|
||||||
|
setOpenStarAFFiNEModal(true);
|
||||||
|
}, [setOpenStarAFFiNEModal]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
@@ -126,17 +140,24 @@ export const SettingModal = ({
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
<div className={style.footer}>
|
<div className={style.footer}>
|
||||||
<a
|
<ContactWithUsIcon fontSize={16} />
|
||||||
href="https://community.affine.pro/home"
|
<Trans
|
||||||
target="_blank"
|
i18nKey={'com.affine.settings.suggestion-2'}
|
||||||
rel="noreferrer"
|
components={{
|
||||||
className={style.suggestionLink}
|
1: (
|
||||||
>
|
<span
|
||||||
<span className={style.suggestionLinkIcon}>
|
className={style.link}
|
||||||
<ContactWithUsIcon width="16" height="16" />
|
onClick={handleOpenStarAFFiNEModal}
|
||||||
</span>
|
/>
|
||||||
{t['com.affine.settings.suggestion']()}
|
),
|
||||||
</a>
|
2: (
|
||||||
|
<span
|
||||||
|
className={style.link}
|
||||||
|
onClick={handleOpenIssueFeedbackModal}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const wrapper = style({
|
export const wrapper = style({
|
||||||
@@ -50,4 +51,12 @@ export const footer = style({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingBottom: '20px',
|
paddingBottom: '20px',
|
||||||
|
gap: '4px',
|
||||||
|
fontSize: cssVar('fontXs'),
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const link = style({
|
||||||
|
color: cssVar('linkColor'),
|
||||||
|
cursor: 'pointer',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { pushNotificationAtom } from '@affine/component/notification-center';
|
|||||||
import { SettingRow } from '@affine/component/setting-components';
|
import { SettingRow } from '@affine/component/setting-components';
|
||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||||
|
import { useSystemOnline } from '@affine/core/hooks/use-system-online';
|
||||||
import { apis } from '@affine/electron-api';
|
import { apis } from '@affine/electron-api';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import type { Workspace, WorkspaceMetadata } from '@affine/workspace';
|
import type { Workspace, WorkspaceMetadata } from '@affine/workspace';
|
||||||
@@ -20,6 +21,7 @@ export const ExportPanel = ({
|
|||||||
const workspaceId = workspaceMetadata.id;
|
const workspaceId = workspaceMetadata.id;
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const isOnline = useSystemOnline();
|
||||||
|
|
||||||
const pushNotification = useSetAtom(pushNotificationAtom);
|
const pushNotification = useSetAtom(pushNotificationAtom);
|
||||||
const onExport = useAsyncCallback(async () => {
|
const onExport = useAsyncCallback(async () => {
|
||||||
@@ -28,8 +30,11 @@ export const ExportPanel = ({
|
|||||||
}
|
}
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await workspace.engine.sync.waitForSynced();
|
if (isOnline) {
|
||||||
await workspace.engine.blob.sync();
|
await workspace.engine.sync.waitForSynced();
|
||||||
|
await workspace.engine.blob.sync();
|
||||||
|
}
|
||||||
|
|
||||||
const result = await apis?.dialog.saveDBFileAs(workspaceId);
|
const result = await apis?.dialog.saveDBFileAs(workspaceId);
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
throw new Error(result.error);
|
throw new Error(result.error);
|
||||||
@@ -48,7 +53,7 @@ export const ExportPanel = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
}, [pushNotification, saving, t, workspace, workspaceId]);
|
}, [isOnline, pushNotification, saving, t, workspace, workspaceId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
<SettingRow name={t['Export']()} desc={t['Export Description']()}>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { OverlayModal } from '@affine/component';
|
||||||
|
import { openStarAFFiNEModalAtom } from '@affine/core/atoms';
|
||||||
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
|
import { useAtom } from 'jotai';
|
||||||
|
|
||||||
|
export const StarAFFiNEModal = () => {
|
||||||
|
const t = useAFFiNEI18N();
|
||||||
|
const [open, setOpen] = useAtom(openStarAFFiNEModalAtom);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<OverlayModal
|
||||||
|
open={open}
|
||||||
|
topImage={
|
||||||
|
<video
|
||||||
|
width={400}
|
||||||
|
height={300}
|
||||||
|
style={{ objectFit: 'cover' }}
|
||||||
|
src={'/static/gitHubStar.mp4'}
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
title={t['com.affine.star-affine.title']()}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
description={t['com.affine.star-affine.description']()}
|
||||||
|
cancelText={t['com.affine.star-affine.cancel']()}
|
||||||
|
to={runtimeConfig.githubUrl}
|
||||||
|
confirmButtonOptions={{
|
||||||
|
type: 'primary',
|
||||||
|
}}
|
||||||
|
confirmText={t['com.affine.star-affine.confirm']()}
|
||||||
|
external
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -48,7 +48,7 @@ interface BlocksuiteEditorContainerProps {
|
|||||||
// mimic the interface of the webcomponent and expose slots & host
|
// mimic the interface of the webcomponent and expose slots & host
|
||||||
type BlocksuiteEditorContainerRef = Pick<
|
type BlocksuiteEditorContainerRef = Pick<
|
||||||
(typeof AffineEditorContainer)['prototype'],
|
(typeof AffineEditorContainer)['prototype'],
|
||||||
'mode' | 'page' | 'model' | 'slots' | 'host'
|
'mode' | 'page' | 'slots' | 'host'
|
||||||
> &
|
> &
|
||||||
HTMLDivElement;
|
HTMLDivElement;
|
||||||
|
|
||||||
|
|||||||
@@ -37,24 +37,14 @@ export type EditorProps = {
|
|||||||
className?: string;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* TODO: Defined async cache to support suspense, instead of reflect symbol to provider persistent error cache.
|
|
||||||
*/
|
|
||||||
const PAGE_LOAD_KEY = Symbol('PAGE_LOAD');
|
|
||||||
const PAGE_ROOT_KEY = Symbol('PAGE_ROOT');
|
|
||||||
|
|
||||||
function usePageRoot(page: Page) {
|
function usePageRoot(page: Page) {
|
||||||
let load$ = Reflect.get(page, PAGE_LOAD_KEY);
|
if (!page.ready) {
|
||||||
if (!load$) {
|
page.load();
|
||||||
load$ = page.load();
|
|
||||||
Reflect.set(page, PAGE_LOAD_KEY, load$);
|
|
||||||
}
|
}
|
||||||
use(load$);
|
|
||||||
|
|
||||||
if (!page.root) {
|
if (!page.root) {
|
||||||
let root$: Promise<void> | undefined = Reflect.get(page, PAGE_ROOT_KEY);
|
use(
|
||||||
if (!root$) {
|
new Promise<void>((resolve, reject) => {
|
||||||
root$ = new Promise((resolve, reject) => {
|
|
||||||
const disposable = page.slots.rootAdded.once(() => {
|
const disposable = page.slots.rootAdded.once(() => {
|
||||||
resolve();
|
resolve();
|
||||||
});
|
});
|
||||||
@@ -62,10 +52,8 @@ function usePageRoot(page: Page) {
|
|||||||
disposable.dispose();
|
disposable.dispose();
|
||||||
reject(new NoPageRootError(page));
|
reject(new NoPageRootError(page));
|
||||||
}, 20 * 1000);
|
}, 20 * 1000);
|
||||||
});
|
})
|
||||||
Reflect.set(page, PAGE_ROOT_KEY, root$);
|
);
|
||||||
}
|
|
||||||
use(root$);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return page.root;
|
return page.root;
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import type { BlockSpec } from '@blocksuite/block-std';
|
import type { BlockSpec } from '@blocksuite/block-std';
|
||||||
import type { ParagraphService } from '@blocksuite/blocks';
|
import type { PageService, ParagraphService } from '@blocksuite/blocks';
|
||||||
import {
|
import {
|
||||||
AttachmentService,
|
AttachmentService,
|
||||||
|
CanvasTextFonts,
|
||||||
DocEditorBlockSpecs,
|
DocEditorBlockSpecs,
|
||||||
|
DocPageService,
|
||||||
EdgelessEditorBlockSpecs,
|
EdgelessEditorBlockSpecs,
|
||||||
|
EdgelessPageService,
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import bytes from 'bytes';
|
import bytes from 'bytes';
|
||||||
import { html, unsafeStatic } from 'lit/static-html.js';
|
import { html, unsafeStatic } from 'lit/static-html.js';
|
||||||
@@ -17,6 +20,31 @@ class CustomAttachmentService extends AttachmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function customLoadFonts(service: PageService): void {
|
||||||
|
const officialDomains = new Set(['app.affine.pro', 'affine.fail']);
|
||||||
|
if (!officialDomains.has(window.location.host)) {
|
||||||
|
const fonts = CanvasTextFonts.map(font => ({
|
||||||
|
...font,
|
||||||
|
// self-hosted fonts are served from /assets
|
||||||
|
url: '/assets/' + new URL(font.url).pathname.split('/').pop(),
|
||||||
|
}));
|
||||||
|
service.fontLoader.load(fonts);
|
||||||
|
} else {
|
||||||
|
service.fontLoader.load(CanvasTextFonts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomDocPageService extends DocPageService {
|
||||||
|
override loadFonts(): void {
|
||||||
|
customLoadFonts(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
class CustomEdgelessPageService extends EdgelessPageService {
|
||||||
|
override loadFonts(): void {
|
||||||
|
customLoadFonts(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
type AffineReference = HTMLElementTagNameMap['affine-reference'];
|
type AffineReference = HTMLElementTagNameMap['affine-reference'];
|
||||||
type PageReferenceRenderer = (reference: AffineReference) => React.ReactElement;
|
type PageReferenceRenderer = (reference: AffineReference) => React.ReactElement;
|
||||||
|
|
||||||
@@ -76,6 +104,12 @@ export const docModeSpecs = DocEditorBlockSpecs.map(spec => {
|
|||||||
service: CustomAttachmentService,
|
service: CustomAttachmentService,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.schema.model.flavour === 'affine:page') {
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
service: CustomDocPageService,
|
||||||
|
};
|
||||||
|
}
|
||||||
return spec;
|
return spec;
|
||||||
});
|
});
|
||||||
export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
||||||
@@ -85,5 +119,11 @@ export const edgelessModeSpecs = EdgelessEditorBlockSpecs.map(spec => {
|
|||||||
service: CustomAttachmentService,
|
service: CustomAttachmentService,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (spec.schema.model.flavour === 'affine:page') {
|
||||||
|
return {
|
||||||
|
...spec,
|
||||||
|
service: CustomEdgelessPageService,
|
||||||
|
};
|
||||||
|
}
|
||||||
return spec;
|
return spec;
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user