mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-17 01:56:27 +08:00
Merge branch 'canary' into stable
This commit is contained in:
@@ -126,6 +126,8 @@ const config = {
|
||||
'no-cond-assign': 'off',
|
||||
'no-constant-binary-expression': 'error',
|
||||
'no-constructor-return': 'error',
|
||||
'no-self-compare': 'error',
|
||||
eqeqeq: ['error', 'always', { null: 'ignore' }],
|
||||
'react/prop-types': 'off',
|
||||
'react/jsx-no-useless-fragment': 'error',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
@@ -133,6 +135,9 @@ const config = {
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/await-thenable': 'error',
|
||||
'@typescript-eslint/require-array-sort-compare': 'error',
|
||||
'@typescript-eslint/unified-signatures': 'error',
|
||||
'@typescript-eslint/prefer-for-of': 'error',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
@@ -204,6 +209,17 @@ const config = {
|
||||
},
|
||||
],
|
||||
'unicorn/no-unnecessary-await': 'error',
|
||||
'unicorn/no-useless-fallback-in-spread': 'error',
|
||||
'unicorn/prefer-dom-node-dataset': 'error',
|
||||
'unicorn/prefer-dom-node-append': 'error',
|
||||
'unicorn/prefer-dom-node-remove': 'error',
|
||||
'unicorn/prefer-array-some': 'error',
|
||||
'unicorn/prefer-date-now': 'error',
|
||||
'unicorn/prefer-blob-reading-methods': 'error',
|
||||
'unicorn/no-typeof-undefined': 'error',
|
||||
'unicorn/no-useless-promise-resolve-reject': 'error',
|
||||
'unicorn/no-new-array': 'error',
|
||||
'unicorn/new-for-builtins': 'error',
|
||||
'sonarjs/no-all-duplicated-branches': 'error',
|
||||
'sonarjs/no-element-overwrite': 'error',
|
||||
'sonarjs/no-empty-collection': 'error',
|
||||
@@ -254,6 +270,7 @@ const config = {
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-misused-promises': ['error'],
|
||||
'@typescript-eslint/prefer-readonly': 'error',
|
||||
'i/no-extraneous-dependencies': ['error'],
|
||||
'react-hooks/exhaustive-deps': [
|
||||
'warn',
|
||||
|
||||
@@ -14,10 +14,17 @@ inputs:
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Print rustup toolchain version
|
||||
shell: bash
|
||||
id: rustup-version
|
||||
run: |
|
||||
export RUST_TOOLCHAIN_VERSION="$(grep 'channel' rust-toolchain.toml | head -1 | awk -F '"' '{print $2}')"
|
||||
echo "Rust toolchain version: $RUST_TOOLCHAIN_VERSION"
|
||||
echo "RUST_TOOLCHAIN_VERSION=$RUST_TOOLCHAIN_VERSION" >> "$GITHUB_OUTPUT"
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: '${{ steps.rustup-version.outputs.RUST_TOOLCHAIN_VERSION }}'
|
||||
targets: ${{ inputs.target }}
|
||||
env:
|
||||
CARGO_INCREMENTAL: '1'
|
||||
@@ -49,6 +56,8 @@ runs:
|
||||
run: |
|
||||
export CC=x86_64-unknown-linux-gnu-gcc
|
||||
export CC_x86_64_unknown_linux_gnu=x86_64-unknown-linux-gnu-gcc
|
||||
rm -rf /usr/local/rustup/downloads/*
|
||||
rustup target add x86_64-unknown-linux-gnu
|
||||
export RUSTFLAGS="-C debuginfo=1"
|
||||
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} --target ${{ inputs.target }}
|
||||
if [ -d "node_modules/.cache" ]; then
|
||||
@@ -66,6 +75,8 @@ runs:
|
||||
options: --user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db -v ${{ github.workspace }}/.cargo/registry/cache:/usr/local/cargo/registry/cache -v ${{ github.workspace }}/.cargo/registry/index:/usr/local/cargo/registry/index -v ${{ github.workspace }}:/build -w /build -e NX_CLOUD_ACCESS_TOKEN=${{ inputs.nx_token }}
|
||||
run: |
|
||||
export RUSTFLAGS="-C debuginfo=1"
|
||||
rm -rf /usr/local/rustup/downloads/*
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
yarn workspace ${{ inputs.package }} nx build ${{ inputs.package }} --target ${{ inputs.target }}
|
||||
if [ -d "node_modules/.cache" ]; then
|
||||
chmod -R 777 node_modules/.cache
|
||||
|
||||
@@ -41,7 +41,7 @@ runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: https://npm.pkg.github.com
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Run oxlint
|
||||
# oxlint is fast, so wrong code will fail quickly
|
||||
run: yarn dlx oxlint@latest .
|
||||
run: yarn dlx $(node -e "console.log(require('./package.json').scripts['lint:ox'])")
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
@@ -58,8 +58,6 @@ jobs:
|
||||
run: |
|
||||
git checkout .yarnrc.yml
|
||||
yarn lint:prettier
|
||||
- name: Run circular
|
||||
run: yarn circular
|
||||
- name: Run Type Check
|
||||
run: yarn typecheck
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ on:
|
||||
default: canary
|
||||
|
||||
env:
|
||||
BUILD_TYPE: canary
|
||||
APP_NAME: affine
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
|
||||
@@ -17,7 +16,6 @@ jobs:
|
||||
build-server:
|
||||
name: Build Server
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
@@ -43,12 +41,12 @@ jobs:
|
||||
- name: Build Plugins
|
||||
run: yarn run build:plugins
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/core
|
||||
run: yarn nx build @affine/core --skip-nx-cache
|
||||
env:
|
||||
R2_ACCOUNT_ID: ${{ secrets.R2_ACCOUNT_ID }}
|
||||
R2_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
R2_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
BUILD_TYPE_OVERRIDE: ${{ github.event.inputs.flavor }}
|
||||
BUILD_TYPE: ${{ github.event.inputs.flavor }}
|
||||
SHOULD_REPORT_TRACE: true
|
||||
TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }}
|
||||
CAPTCHA_SITE_KEY: ${{ secrets.CAPTCHA_SITE_KEY }}
|
||||
@@ -209,7 +207,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Deploy to dev
|
||||
- name: Deploy to ${{ github.event.inputs.flavor }}
|
||||
uses: ./.github/actions/deploy
|
||||
with:
|
||||
build-type: ${{ github.event.inputs.flavor }}
|
||||
|
||||
@@ -65,14 +65,15 @@ jobs:
|
||||
- name: Replace Version
|
||||
run: ./scripts/set-version.sh ${{ needs.set-build-version.outputs.version }}
|
||||
- name: generate-assets
|
||||
working-directory: packages/frontend/electron
|
||||
run: yarn generate-assets
|
||||
run: yarn workspace @affine/electron generate-assets
|
||||
env:
|
||||
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
|
||||
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
|
||||
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
RELEASE_VERSION: ${{ needs.set-build-version.outputs.version }}
|
||||
SKIP_PLUGIN_BUILD: 'true'
|
||||
SKIP_NX_CACHE: 'true'
|
||||
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -19,7 +19,10 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
electron-install: false
|
||||
- run: echo "${{ github.event.pull_request.title }}" | yarn dlx commitlint -g ./.commitlintrc.json
|
||||
cache: 'yarn'
|
||||
node-version-file: '.nvmrc'
|
||||
- name: Install dependencies
|
||||
run: yarn workspaces focus @affine/commitlint-config
|
||||
- run: echo "${{ github.event.pull_request.title }}" | yarn workspace @affine/commitlint-config commitlint -g ./.commitlintrc.json
|
||||
|
||||
@@ -69,6 +69,7 @@ jobs:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
RELEASE_VERSION: ${{ github.event.inputs.version || steps.get-canary-version.outputs.RELEASE_VERSION }}
|
||||
SKIP_PLUGIN_BUILD: 'true'
|
||||
SKIP_NX_CACHE: 'true'
|
||||
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- canary
|
||||
|
||||
env:
|
||||
BUILD_TYPE: stable
|
||||
APP_NAME: affine
|
||||
COVERAGE: false
|
||||
DISTRIBUTION: browser
|
||||
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: Try publishing npm@latest release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Try publishing to NPM
|
||||
run: ./scripts/publish.sh
|
||||
env:
|
||||
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
build-core:
|
||||
name: Build @affine/core
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Build Plugins
|
||||
run: yarn run build:plugins
|
||||
- name: Build Core
|
||||
run: yarn nx build @affine/core
|
||||
- name: Upload core artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-server:
|
||||
name: Build Server
|
||||
runs-on: ubuntu-latest
|
||||
environment: development
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
with:
|
||||
electron-install: false
|
||||
- name: Build Server
|
||||
run: yarn nx build @affine/server
|
||||
- name: Upload server dist
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
if-no-files-found: error
|
||||
|
||||
build-storage:
|
||||
name: Build Storage
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
RUSTFLAGS: '-C debuginfo=1'
|
||||
environment: development
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
- name: Setup Rust
|
||||
uses: ./.github/actions/build-rust
|
||||
with:
|
||||
target: 'x86_64-unknown-linux-gnu'
|
||||
package: '@affine/storage'
|
||||
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
|
||||
- name: Upload storage.node
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/storage/storage.node
|
||||
if-no-files-found: error
|
||||
|
||||
build-docker:
|
||||
if: github.ref == 'refs/heads/canary'
|
||||
name: Build Docker
|
||||
runs-on: ubuntu-latest
|
||||
needs:
|
||||
- build-server
|
||||
- build-core
|
||||
- build-storage
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Download core artifact
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: core
|
||||
path: ./packages/frontend/core/dist
|
||||
- name: Download server dist
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: server-dist
|
||||
path: ./packages/backend/server/dist
|
||||
- name: Download storage.node
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: storage.node
|
||||
path: ./packages/backend/server
|
||||
- name: Setup Git short hash
|
||||
run: |
|
||||
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
logout: false
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Build front Dockerfile
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
pull: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
provenance: true
|
||||
file: .github/deployment/front/Dockerfile
|
||||
tags: ghcr.io/toeverything/affine-front:${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-front:latest
|
||||
|
||||
# setup node without cache configuration
|
||||
# Prisma cache is not compatible with docker build cache
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
registry-url: https://npm.pkg.github.com
|
||||
scope: '@toeverything'
|
||||
|
||||
- name: Install Node.js dependencies
|
||||
run: yarn workspaces focus @affine/server --production
|
||||
|
||||
- name: Generate Prisma client
|
||||
run: yarn workspace @affine/server prisma generate
|
||||
|
||||
- name: Build graphql Dockerfile
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
pull: true
|
||||
platforms: linux/amd64,linux/arm64
|
||||
provenance: true
|
||||
file: .github/deployment/node/Dockerfile
|
||||
tags: ghcr.io/toeverything/affine-graphql:${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-graphql:latest
|
||||
+1
-20
@@ -1,23 +1,4 @@
|
||||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# check lockfile is up to date
|
||||
yarn install --mode=skip-build --inline-builds --immutable
|
||||
|
||||
# build infra code
|
||||
yarn -T run build:infra
|
||||
|
||||
# generate prisma client type
|
||||
yarn workspace @affine/server prisma generate
|
||||
|
||||
# generate i18n
|
||||
yarn i18n-codegen gen
|
||||
|
||||
# lint staged files
|
||||
yarn exec lint-staged
|
||||
|
||||
# type check
|
||||
yarn typecheck
|
||||
|
||||
# circular dependency check
|
||||
yarn circular
|
||||
yarn lint-staged && yarn lint:ox && cargo fmt --all && git add .
|
||||
|
||||
@@ -228,7 +228,7 @@ See [LICENSE] for details.
|
||||
[jobs available]: ./docs/jobs.md
|
||||
[latest packages]: https://github.com/toeverything/AFFiNE/pkgs/container/affine-self-hosted
|
||||
[contributor license agreement]: https://github.com/toeverything/affine/edit/canary/.github/CLA.md
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.71.0-dea584
|
||||
[rust-version-icon]: https://img.shields.io/badge/Rust-1.74.0-dea584
|
||||
[stars-icon]: https://img.shields.io/github/stars/toeverything/AFFiNE.svg?style=flat&logo=github&colorB=red&label=stars
|
||||
[codecov]: https://codecov.io/gh/toeverything/affine/branch/canary/graphs/badge.svg?branch=canary
|
||||
[node-version-icon]: https://img.shields.io/badge/node-%3E=18.16.1-success
|
||||
|
||||
+4
-6
@@ -33,12 +33,12 @@
|
||||
"lint:eslint:fix": "yarn lint:eslint --fix",
|
||||
"lint:prettier": "prettier --ignore-unknown --cache --check .",
|
||||
"lint:prettier:fix": "prettier --ignore-unknown --cache --write .",
|
||||
"lint:ox": "oxlint --deny-warnings --import-plugin -D correctness -D nursery -D prefer-array-some -D no-useless-promise-resolve-reject -A no-undef -A consistent-type-exports -A default -A named -A ban-ts-comment",
|
||||
"lint": "yarn lint:eslint && yarn lint:prettier",
|
||||
"lint:fix": "yarn lint:eslint:fix && yarn lint:prettier:fix",
|
||||
"test": "vitest --run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"circular": "madge --circular --ts-config ./tsconfig.json ./packages/frontend/core/src/pages/**/*.tsx ./packages/frontend/core/src/index.tsx ./packages/frontend/electron/src/*/index.ts",
|
||||
"typecheck": "tsc -b tsconfig.json --diagnostics",
|
||||
"postinstall": "node ./scripts/check-version.mjs && yarn i18n-codegen gen && yarn husky install"
|
||||
},
|
||||
@@ -49,7 +49,6 @@
|
||||
"eslint --cache --fix"
|
||||
],
|
||||
"*.toml": [
|
||||
"prettier --ignore-unknown --write",
|
||||
"taplo format"
|
||||
]
|
||||
},
|
||||
@@ -71,8 +70,8 @@
|
||||
"@types/affine__env": "workspace:*",
|
||||
"@types/eslint": "^8.44.7",
|
||||
"@types/node": "^20.9.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.12.0",
|
||||
"@typescript-eslint/parser": "^6.12.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.13.1",
|
||||
"@typescript-eslint/parser": "^6.13.1",
|
||||
"@vanilla-extract/vite-plugin": "^3.9.2",
|
||||
"@vanilla-extract/webpack-plugin": "^2.3.1",
|
||||
"@vitejs/plugin-react-swc": "^3.5.0",
|
||||
@@ -82,7 +81,6 @@
|
||||
"eslint": "^8.54.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"eslint-plugin-i": "^2.29.0",
|
||||
"eslint-plugin-prettier": "^5.0.1",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-simple-import-sort": "^10.0.0",
|
||||
@@ -94,12 +92,12 @@
|
||||
"happy-dom": "^12.10.3",
|
||||
"husky": "^8.0.3",
|
||||
"lint-staged": "^15.1.0",
|
||||
"madge": "^6.1.0",
|
||||
"msw": "^2.0.8",
|
||||
"nanoid": "^5.0.3",
|
||||
"nx": "^17.1.3",
|
||||
"nx-cloud": "^16.5.2",
|
||||
"nyc": "^15.1.0",
|
||||
"oxlint": "^0.0.18",
|
||||
"prettier": "^3.1.0",
|
||||
"semver": "^7.5.4",
|
||||
"serve": "^14.2.1",
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"@opentelemetry/instrumentation-ioredis": "^0.35.3",
|
||||
"@opentelemetry/instrumentation-nestjs-core": "^0.33.3",
|
||||
"@opentelemetry/instrumentation-socket.io": "^0.34.3",
|
||||
"@opentelemetry/resources": "^1.18.1",
|
||||
"@opentelemetry/sdk-metrics": "^1.18.1",
|
||||
"@opentelemetry/sdk-node": "^0.45.1",
|
||||
"@opentelemetry/sdk-trace-node": "^1.18.1",
|
||||
|
||||
@@ -50,7 +50,7 @@ function boolean(value: string) {
|
||||
}
|
||||
|
||||
export function parseEnvValue(value: string | undefined, type?: EnvConfigType) {
|
||||
if (typeof value === 'undefined') {
|
||||
if (value === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export function applyEnvToConfig(rawConfig: AFFiNEConfig) {
|
||||
? [config, process.env[env]]
|
||||
: [config[0], parseEnvValue(process.env[env], config[1])];
|
||||
|
||||
if (typeof value !== 'undefined') {
|
||||
if (value !== undefined) {
|
||||
set(rawConfig, path, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
|
||||
const res = reqContext.contextValue.req.res as Response;
|
||||
const operation = reqContext.request.operationName;
|
||||
|
||||
metrics().gqlRequest.add(1, { operation });
|
||||
metrics.gql.counter('query_counter').add(1, { operation });
|
||||
const start = Date.now();
|
||||
|
||||
return Promise.resolve({
|
||||
@@ -30,7 +30,9 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
|
||||
'Server-Timing',
|
||||
`gql;dur=${costInMilliseconds};desc="GraphQL"`
|
||||
);
|
||||
metrics().gqlTimer.record(costInMilliseconds, { operation });
|
||||
metrics.gql
|
||||
.histogram('query_duration')
|
||||
.record(costInMilliseconds, { operation });
|
||||
return Promise.resolve();
|
||||
},
|
||||
didEncounterErrors: () => {
|
||||
@@ -39,7 +41,9 @@ export class GQLLoggerPlugin implements ApolloServerPlugin {
|
||||
'Server-Timing',
|
||||
`gql;dur=${costInMilliseconds};desc="GraphQL ${operation}"`
|
||||
);
|
||||
metrics().gqlTimer.record(costInMilliseconds, { operation });
|
||||
metrics.gql
|
||||
.histogram('query_duration')
|
||||
.record(costInMilliseconds, { operation });
|
||||
return Promise.resolve();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,76 +1,129 @@
|
||||
import opentelemetry, { Attributes, Observable } from '@opentelemetry/api';
|
||||
import {
|
||||
Attributes,
|
||||
Counter,
|
||||
Histogram,
|
||||
Meter,
|
||||
MetricOptions,
|
||||
} from '@opentelemetry/api';
|
||||
|
||||
interface AsyncMetric {
|
||||
ob: Observable;
|
||||
get value(): any;
|
||||
get attrs(): Attributes | undefined;
|
||||
}
|
||||
import { getMeter } from './opentelemetry';
|
||||
|
||||
let _metrics: ReturnType<typeof createBusinessMetrics> | undefined = undefined;
|
||||
type MetricType = 'counter' | 'gauge' | 'histogram';
|
||||
type Metric<T extends MetricType> = T extends 'counter'
|
||||
? Counter
|
||||
: T extends 'gauge'
|
||||
? Histogram
|
||||
: T extends 'histogram'
|
||||
? Histogram
|
||||
: never;
|
||||
|
||||
export function getMeter(name = 'business') {
|
||||
return opentelemetry.metrics.getMeter(name);
|
||||
}
|
||||
export type ScopedMetrics = {
|
||||
[T in MetricType]: (name: string, opts?: MetricOptions) => Metric<T>;
|
||||
};
|
||||
type MetricCreators = {
|
||||
[T in MetricType]: (
|
||||
meter: Meter,
|
||||
name: string,
|
||||
opts?: MetricOptions
|
||||
) => Metric<T>;
|
||||
};
|
||||
|
||||
function createBusinessMetrics() {
|
||||
const meter = getMeter();
|
||||
const asyncMetrics: AsyncMetric[] = [];
|
||||
export type KnownMetricScopes =
|
||||
| 'socketio'
|
||||
| 'gql'
|
||||
| 'jwst'
|
||||
| 'auth'
|
||||
| 'controllers'
|
||||
| 'doc';
|
||||
|
||||
function createGauge(name: string) {
|
||||
const metricCreators: MetricCreators = {
|
||||
counter(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
return meter.createCounter(name, opts);
|
||||
},
|
||||
gauge(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
let value: any;
|
||||
let attrs: Attributes | undefined;
|
||||
const ob = meter.createObservableGauge(name);
|
||||
asyncMetrics.push({
|
||||
ob,
|
||||
get value() {
|
||||
return value;
|
||||
},
|
||||
get attrs() {
|
||||
return attrs;
|
||||
},
|
||||
const ob = meter.createObservableGauge(name, opts);
|
||||
|
||||
ob.addCallback(result => {
|
||||
result.observe(value, attrs);
|
||||
});
|
||||
|
||||
return (newValue: any, newAttrs?: Attributes) => {
|
||||
value = newValue;
|
||||
attrs = newAttrs;
|
||||
};
|
||||
return {
|
||||
record: (newValue, newAttrs) => {
|
||||
value = newValue;
|
||||
attrs = newAttrs;
|
||||
},
|
||||
} satisfies Histogram;
|
||||
},
|
||||
histogram(meter: Meter, name: string, opts?: MetricOptions) {
|
||||
return meter.createHistogram(name, opts);
|
||||
},
|
||||
};
|
||||
|
||||
const scopes = new Map<string, ScopedMetrics>();
|
||||
|
||||
function make(scope: string) {
|
||||
const meter = getMeter();
|
||||
const metrics = new Map<string, { type: MetricType; metric: any }>();
|
||||
const prefix = scope + '/';
|
||||
|
||||
function getOrCreate<T extends MetricType>(
|
||||
type: T,
|
||||
name: string,
|
||||
opts?: MetricOptions
|
||||
): Metric<T> {
|
||||
name = prefix + name;
|
||||
const metric = metrics.get(name);
|
||||
if (metric) {
|
||||
if (type !== metric.type) {
|
||||
throw new Error(
|
||||
`Metric ${name} has already been registered as ${metric.type} mode, but get as ${type} again.`
|
||||
);
|
||||
}
|
||||
|
||||
return metric.metric;
|
||||
} else {
|
||||
const metric = metricCreators[type](meter, name, opts);
|
||||
metrics.set(name, { type, metric });
|
||||
return metric;
|
||||
}
|
||||
}
|
||||
|
||||
const metrics = {
|
||||
socketIOConnectionGauge: createGauge('socket_io_connection'),
|
||||
|
||||
gqlRequest: meter.createCounter('gql_request'),
|
||||
gqlError: meter.createCounter('gql_error'),
|
||||
gqlTimer: meter.createHistogram('gql_timer'),
|
||||
|
||||
jwstCodecMerge: meter.createCounter('jwst_codec_merge'),
|
||||
jwstCodecDidnotMatch: meter.createCounter('jwst_codec_didnot_match'),
|
||||
jwstCodecFail: meter.createCounter('jwst_codec_fail'),
|
||||
|
||||
authCounter: meter.createCounter('auth'),
|
||||
authFailCounter: meter.createCounter('auth_fail'),
|
||||
|
||||
docHistoryCounter: meter.createCounter('doc_history_created'),
|
||||
docRecoverCounter: meter.createCounter('doc_history_recovered'),
|
||||
};
|
||||
|
||||
meter.addBatchObservableCallback(
|
||||
result => {
|
||||
asyncMetrics.forEach(metric => {
|
||||
result.observe(metric.ob, metric.value, metric.attrs);
|
||||
});
|
||||
return {
|
||||
counter(name, opts) {
|
||||
return getOrCreate('counter', name, opts);
|
||||
},
|
||||
asyncMetrics.map(({ ob }) => ob)
|
||||
);
|
||||
|
||||
return metrics;
|
||||
gauge(name, opts) {
|
||||
return getOrCreate('gauge', name, opts);
|
||||
},
|
||||
histogram(name, opts) {
|
||||
return getOrCreate('histogram', name, opts);
|
||||
},
|
||||
} satisfies ScopedMetrics;
|
||||
}
|
||||
|
||||
export function registerBusinessMetrics() {
|
||||
if (!_metrics) {
|
||||
_metrics = createBusinessMetrics();
|
||||
/**
|
||||
* @example
|
||||
*
|
||||
* ```
|
||||
* metrics.scope.counter('example_count').add(1, {
|
||||
* attr1: 'example-event'
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const metrics = new Proxy<Record<KnownMetricScopes, ScopedMetrics>>(
|
||||
// @ts-expect-error proxied
|
||||
{},
|
||||
{
|
||||
get(_, scopeName: string) {
|
||||
let scope = scopes.get(scopeName);
|
||||
if (!scope) {
|
||||
scope = make(scopeName);
|
||||
scopes.set(scopeName, scope);
|
||||
}
|
||||
|
||||
return scope;
|
||||
},
|
||||
}
|
||||
|
||||
return _metrics;
|
||||
}
|
||||
export const metrics = registerBusinessMetrics;
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MetricExporter } from '@google-cloud/opentelemetry-cloud-monitoring-exporter';
|
||||
import { TraceExporter } from '@google-cloud/opentelemetry-cloud-trace-exporter';
|
||||
import { metrics } from '@opentelemetry/api';
|
||||
import {
|
||||
CompositePropagator,
|
||||
W3CBaggagePropagator,
|
||||
@@ -16,6 +17,8 @@ import { NestInstrumentation } from '@opentelemetry/instrumentation-nestjs-core'
|
||||
import { SocketIoInstrumentation } from '@opentelemetry/instrumentation-socket.io';
|
||||
import {
|
||||
ConsoleMetricExporter,
|
||||
type MeterProvider,
|
||||
MetricProducer,
|
||||
MetricReader,
|
||||
PeriodicExportingMetricReader,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
@@ -24,10 +27,11 @@ import {
|
||||
BatchSpanProcessor,
|
||||
ConsoleSpanExporter,
|
||||
SpanExporter,
|
||||
TraceIdRatioBasedSampler,
|
||||
} from '@opentelemetry/sdk-trace-node';
|
||||
import { PrismaInstrumentation } from '@prisma/instrumentation';
|
||||
|
||||
import { registerBusinessMetrics } from './metrics';
|
||||
import { PrismaMetricProducer } from './prisma';
|
||||
|
||||
abstract class OpentelemetryFactor {
|
||||
abstract getMetricReader(): MetricReader;
|
||||
@@ -44,9 +48,14 @@ abstract class OpentelemetryFactor {
|
||||
];
|
||||
}
|
||||
|
||||
getMetricsProducers(): MetricProducer[] {
|
||||
return [new PrismaMetricProducer()];
|
||||
}
|
||||
|
||||
create() {
|
||||
const traceExporter = this.getSpanExporter();
|
||||
return new NodeSDK({
|
||||
sampler: new TraceIdRatioBasedSampler(0.1),
|
||||
traceExporter,
|
||||
metricReader: this.getMetricReader(),
|
||||
spanProcessor: new BatchSpanProcessor(traceExporter),
|
||||
@@ -67,7 +76,10 @@ class GCloudOpentelemetryFactor extends OpentelemetryFactor {
|
||||
return new PeriodicExportingMetricReader({
|
||||
exportIntervalMillis: 30000,
|
||||
exportTimeoutMillis: 10000,
|
||||
exporter: new MetricExporter(),
|
||||
exporter: new MetricExporter({
|
||||
prefix: 'custom.googleapis.com',
|
||||
}),
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -78,7 +90,9 @@ class GCloudOpentelemetryFactor extends OpentelemetryFactor {
|
||||
|
||||
class LocalOpentelemetryFactor extends OpentelemetryFactor {
|
||||
override getMetricReader(): MetricReader {
|
||||
return new PrometheusExporter();
|
||||
return new PrometheusExporter({
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
}
|
||||
|
||||
override getSpanExporter(): SpanExporter {
|
||||
@@ -90,6 +104,7 @@ class DebugOpentelemetryFactor extends OpentelemetryFactor {
|
||||
override getMetricReader(): MetricReader {
|
||||
return new PeriodicExportingMetricReader({
|
||||
exporter: new ConsoleMetricExporter(),
|
||||
metricProducers: this.getMetricsProducers(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -111,9 +126,30 @@ function createSDK() {
|
||||
return factor?.create();
|
||||
}
|
||||
|
||||
let OPENTELEMETRY_STARTED = false;
|
||||
|
||||
function ensureStarted() {
|
||||
if (!OPENTELEMETRY_STARTED) {
|
||||
OPENTELEMETRY_STARTED = true;
|
||||
start();
|
||||
}
|
||||
}
|
||||
|
||||
function getMeterProvider() {
|
||||
ensureStarted();
|
||||
return metrics.getMeterProvider();
|
||||
}
|
||||
|
||||
function registerCustomMetrics() {
|
||||
const host = new HostMetrics({ name: 'instance-host-metrics' });
|
||||
host.start();
|
||||
const hostMetricsMonitoring = new HostMetrics({
|
||||
name: 'instance-host-metrics',
|
||||
meterProvider: getMeterProvider() as MeterProvider,
|
||||
});
|
||||
hostMetricsMonitoring.start();
|
||||
}
|
||||
|
||||
export function getMeter(name = 'business') {
|
||||
return getMeterProvider().getMeter(name);
|
||||
}
|
||||
|
||||
export function start() {
|
||||
@@ -122,6 +158,5 @@ export function start() {
|
||||
if (sdk) {
|
||||
sdk.start();
|
||||
registerCustomMetrics();
|
||||
registerBusinessMetrics();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { HrTime, ValueType } from '@opentelemetry/api';
|
||||
import { hrTime } from '@opentelemetry/core';
|
||||
import { Resource } from '@opentelemetry/resources';
|
||||
import {
|
||||
AggregationTemporality,
|
||||
CollectionResult,
|
||||
DataPointType,
|
||||
InstrumentType,
|
||||
MetricProducer,
|
||||
ScopeMetrics,
|
||||
} from '@opentelemetry/sdk-metrics';
|
||||
|
||||
import { PrismaService } from '../prisma';
|
||||
|
||||
function transformPrismaKey(key: string) {
|
||||
// replace first '_' to '/' as a scope prefix
|
||||
// example: prisma_client_query_duration_seconds_sum -> prisma/client_query_duration_seconds_sum
|
||||
return key.replace(/_/, '/');
|
||||
}
|
||||
|
||||
export class PrismaMetricProducer implements MetricProducer {
|
||||
private readonly startTime: HrTime = hrTime();
|
||||
|
||||
async collect(): Promise<CollectionResult> {
|
||||
const result: CollectionResult = {
|
||||
resourceMetrics: {
|
||||
resource: Resource.EMPTY,
|
||||
scopeMetrics: [],
|
||||
},
|
||||
errors: [],
|
||||
};
|
||||
|
||||
if (!PrismaService.INSTANCE) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const prisma = PrismaService.INSTANCE;
|
||||
|
||||
const endTime = hrTime();
|
||||
|
||||
const metrics = await prisma.$metrics.json();
|
||||
const scopeMetrics: ScopeMetrics = {
|
||||
scope: {
|
||||
name: '',
|
||||
},
|
||||
metrics: [],
|
||||
};
|
||||
for (const counter of metrics.counters) {
|
||||
scopeMetrics.metrics.push({
|
||||
descriptor: {
|
||||
name: transformPrismaKey(counter.key),
|
||||
description: counter.description,
|
||||
unit: '1',
|
||||
type: InstrumentType.COUNTER,
|
||||
valueType: ValueType.INT,
|
||||
},
|
||||
dataPointType: DataPointType.SUM,
|
||||
aggregationTemporality: AggregationTemporality.CUMULATIVE,
|
||||
dataPoints: [
|
||||
{
|
||||
startTime: this.startTime,
|
||||
endTime: endTime,
|
||||
value: counter.value,
|
||||
attributes: counter.labels,
|
||||
},
|
||||
],
|
||||
isMonotonic: true,
|
||||
});
|
||||
}
|
||||
|
||||
for (const gauge of metrics.gauges) {
|
||||
scopeMetrics.metrics.push({
|
||||
descriptor: {
|
||||
name: transformPrismaKey(gauge.key),
|
||||
description: gauge.description,
|
||||
unit: '1',
|
||||
type: InstrumentType.UP_DOWN_COUNTER,
|
||||
valueType: ValueType.INT,
|
||||
},
|
||||
dataPointType: DataPointType.GAUGE,
|
||||
aggregationTemporality: AggregationTemporality.CUMULATIVE,
|
||||
dataPoints: [
|
||||
{
|
||||
startTime: this.startTime,
|
||||
endTime: endTime,
|
||||
value: gauge.value,
|
||||
attributes: gauge.labels,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
for (const histogram of metrics.histograms) {
|
||||
const boundaries = [];
|
||||
const counts = [];
|
||||
for (const [boundary, count] of histogram.value.buckets) {
|
||||
boundaries.push(boundary);
|
||||
counts.push(count);
|
||||
}
|
||||
scopeMetrics.metrics.push({
|
||||
descriptor: {
|
||||
name: transformPrismaKey(histogram.key),
|
||||
description: histogram.description,
|
||||
unit: 'ms',
|
||||
type: InstrumentType.HISTOGRAM,
|
||||
valueType: ValueType.DOUBLE,
|
||||
},
|
||||
dataPointType: DataPointType.HISTOGRAM,
|
||||
aggregationTemporality: AggregationTemporality.CUMULATIVE,
|
||||
dataPoints: [
|
||||
{
|
||||
startTime: this.startTime,
|
||||
endTime: endTime,
|
||||
value: {
|
||||
buckets: {
|
||||
boundaries,
|
||||
counts,
|
||||
},
|
||||
count: histogram.value.count,
|
||||
sum: histogram.value.sum,
|
||||
},
|
||||
attributes: histogram.labels,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
result.resourceMetrics.scopeMetrics.push(scopeMetrics);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Attributes } from '@opentelemetry/api';
|
||||
|
||||
import { getMeter } from './metrics';
|
||||
import { KnownMetricScopes, metrics } from './metrics';
|
||||
|
||||
export const CallTimer = (
|
||||
scope: KnownMetricScopes,
|
||||
name: string,
|
||||
attrs?: Attributes
|
||||
): MethodDecorator => {
|
||||
@@ -18,9 +19,11 @@ export const CallTimer = (
|
||||
}
|
||||
|
||||
desc.value = function (...args: any[]) {
|
||||
const timer = getMeter().createHistogram(name, {
|
||||
const timer = metrics[scope].histogram(name, {
|
||||
description: `function call time costs of ${name}`,
|
||||
unit: 'ms',
|
||||
});
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const end = () => {
|
||||
@@ -48,6 +51,7 @@ export const CallTimer = (
|
||||
};
|
||||
|
||||
export const CallCounter = (
|
||||
scope: KnownMetricScopes,
|
||||
name: string,
|
||||
attrs?: Attributes
|
||||
): MethodDecorator => {
|
||||
@@ -63,7 +67,7 @@ export const CallCounter = (
|
||||
}
|
||||
|
||||
desc.value = function (...args: any[]) {
|
||||
const count = getMeter().createCounter(name, {
|
||||
const count = metrics[scope].counter(name, {
|
||||
description: `function call counter of ${name}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const TrivialExceptions = [NotFoundException];
|
||||
|
||||
@Catch()
|
||||
export class ExceptionLogger implements ExceptionFilter {
|
||||
private logger = new Logger('ExceptionLogger');
|
||||
private readonly logger = new Logger('ExceptionLogger');
|
||||
|
||||
catch(exception: Error, host: ArgumentsHost) {
|
||||
// with useGlobalFilters, the context is always HTTP
|
||||
|
||||
@@ -53,8 +53,8 @@ class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
@Inject(NextAuthOptionsProvide)
|
||||
private readonly nextAuthOptions: NextAuthOptions,
|
||||
private auth: AuthService,
|
||||
private prisma: PrismaService,
|
||||
private readonly auth: AuthService,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly reflector: Reflector
|
||||
) {}
|
||||
|
||||
|
||||
@@ -89,12 +89,13 @@ export class NextAuthController {
|
||||
res.redirect(`/signin${query}`);
|
||||
return;
|
||||
}
|
||||
metrics().authCounter.add(1);
|
||||
const [action, providerId] = req.url // start with request url
|
||||
.slice(BASE_URL.length) // make relative to baseUrl
|
||||
.replace(/\?.*/, '') // remove query part, use only path part
|
||||
.split('/') as [AuthAction, string]; // as array of strings;
|
||||
|
||||
metrics.auth.counter('call_counter').add(1, { action, providerId });
|
||||
|
||||
const credentialsSignIn =
|
||||
req.method === 'POST' && providerId === 'credentials';
|
||||
let userId: string | undefined;
|
||||
@@ -126,7 +127,9 @@ export class NextAuthController {
|
||||
const options = this.nextAuthOptions;
|
||||
if (req.method === 'POST' && action === 'session') {
|
||||
if (typeof req.body !== 'object' || typeof req.body.data !== 'object') {
|
||||
metrics().authFailCounter.add(1, { reason: 'invalid_session_data' });
|
||||
metrics.auth
|
||||
.counter('call_fails_counter')
|
||||
.add(1, { reason: 'invalid_session_data' });
|
||||
throw new BadRequestException(`Invalid new session data`);
|
||||
}
|
||||
const user = await this.updateSession(req, req.body.data);
|
||||
@@ -209,9 +212,10 @@ export class NextAuthController {
|
||||
|
||||
if (redirect?.endsWith('api/auth/error?error=AccessDenied')) {
|
||||
this.logger.log(`Early access redirect headers: ${req.headers}`);
|
||||
metrics().authFailCounter.add(1, {
|
||||
reason: 'no_early_access_permission',
|
||||
});
|
||||
metrics.auth
|
||||
.counter('call_fails_counter')
|
||||
.add(1, { reason: 'no_early_access_permission' });
|
||||
|
||||
if (
|
||||
!req.headers?.referer ||
|
||||
checkUrlOrigin(req.headers.referer, 'https://accounts.google.com')
|
||||
|
||||
@@ -23,14 +23,14 @@ export type UserClaim = Pick<
|
||||
hasPassword?: boolean;
|
||||
};
|
||||
|
||||
export const getUtcTimestamp = () => Math.floor(new Date().getTime() / 1000);
|
||||
export const getUtcTimestamp = () => Math.floor(Date.now() / 1000);
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private config: Config,
|
||||
private prisma: PrismaService,
|
||||
private mailer: MailService
|
||||
private readonly config: Config,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly mailer: MailService
|
||||
) {}
|
||||
|
||||
sign(user: UserClaim) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { Field, ObjectType, Query } from '@nestjs/graphql';
|
||||
|
||||
import { SERVER_FLAVOR } from '../modules';
|
||||
export const { SERVER_FLAVOR } = process.env;
|
||||
|
||||
@ObjectType()
|
||||
export class ServerConfigType {
|
||||
|
||||
@@ -68,7 +68,11 @@ export class DocHistoryManager {
|
||||
// safe to ignore
|
||||
// only happens when duplicated history record created in multi processes
|
||||
});
|
||||
metrics().docHistoryCounter.add(1, {});
|
||||
metrics.doc
|
||||
.counter('history_created_counter', {
|
||||
description: 'How many times the snapshot history created',
|
||||
})
|
||||
.add(1);
|
||||
this.logger.log(
|
||||
`History created for ${snapshot.id} in workspace ${snapshot.workspaceId}.`
|
||||
);
|
||||
@@ -182,7 +186,11 @@ export class DocHistoryManager {
|
||||
// which is not the solution in CRDT.
|
||||
// let user revert in client and update the data in sync system
|
||||
// `await this.db.snapshot.update();`
|
||||
metrics().docRecoverCounter.add(1, {});
|
||||
metrics.doc
|
||||
.counter('history_recovered_counter', {
|
||||
description: 'How many times history recovered request happened',
|
||||
})
|
||||
.add(1);
|
||||
|
||||
return history.timestamp;
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||
|
||||
function isEmptyBuffer(buf: Buffer): boolean {
|
||||
return (
|
||||
buf.length == 0 ||
|
||||
buf.length === 0 ||
|
||||
// 0x0000
|
||||
(buf.length === 2 && buf[0] === 0 && buf[1] === 0)
|
||||
);
|
||||
@@ -60,9 +60,9 @@ const MAX_SEQ_NUM = 0x3fffffff; // u31
|
||||
*/
|
||||
@Injectable()
|
||||
export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
private logger = new Logger(DocManager.name);
|
||||
private readonly logger = new Logger(DocManager.name);
|
||||
private job: NodeJS.Timeout | null = null;
|
||||
private seqMap = new Map<string, number>();
|
||||
private readonly seqMap = new Map<string, number>();
|
||||
private busy = false;
|
||||
|
||||
constructor(
|
||||
@@ -125,13 +125,13 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
this.config.doc.manager.experimentalMergeWithJwstCodec &&
|
||||
updates.length < 100 /* avoid overloading */
|
||||
) {
|
||||
metrics().jwstCodecMerge.add(1);
|
||||
metrics.jwst.counter('codec_merge_counter').add(1);
|
||||
const yjsResult = Buffer.from(encodeStateAsUpdate(doc));
|
||||
let log = false;
|
||||
try {
|
||||
const jwstResult = jwstMergeUpdates(updates);
|
||||
if (!compare(yjsResult, jwstResult)) {
|
||||
metrics().jwstCodecDidnotMatch.add(1);
|
||||
metrics.jwst.counter('codec_not_match').add(1);
|
||||
this.logger.warn(
|
||||
`jwst codec result doesn't match yjs codec result for: ${guid}`
|
||||
);
|
||||
@@ -142,7 +142,7 @@ export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
metrics().jwstCodecFail.add(1);
|
||||
metrics.jwst.counter('codec_fails_counter').add(1);
|
||||
this.logger.warn(`jwst apply update failed for ${guid}: ${e}`);
|
||||
log = true;
|
||||
} finally {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
|
||||
import { GqlModule } from '../graphql.module';
|
||||
import { ServerConfigModule } from './config';
|
||||
import { SERVER_FLAVOR, ServerConfigModule } from './config';
|
||||
import { DocModule } from './doc';
|
||||
import { PaymentModule } from './payment';
|
||||
import { SelfHostedModule } from './self-hosted';
|
||||
@@ -11,8 +11,6 @@ import { SyncModule } from './sync';
|
||||
import { UsersModule } from './users';
|
||||
import { WorkspaceModule } from './workspaces';
|
||||
|
||||
const { SERVER_FLAVOR } = process.env;
|
||||
|
||||
const BusinessModules: (Type | DynamicModule)[] = [
|
||||
EventEmitterModule.forRoot({
|
||||
global: true,
|
||||
|
||||
@@ -45,6 +45,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
|
||||
try {
|
||||
result = originalMethod.apply(this, args);
|
||||
} catch (e) {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
return {
|
||||
error: new InternalError(e as Error),
|
||||
};
|
||||
@@ -52,6 +53,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
|
||||
|
||||
if (result instanceof Promise) {
|
||||
return result.catch(e => {
|
||||
metrics.socketio.counter('unhandled_errors').add(1);
|
||||
return {
|
||||
error: new InternalError(e),
|
||||
};
|
||||
@@ -68,7 +70,7 @@ export const GatewayErrorWrapper = (): MethodDecorator => {
|
||||
const SubscribeMessage = (event: string) =>
|
||||
applyDecorators(
|
||||
GatewayErrorWrapper(),
|
||||
CallTimer('socket_io_event_duration', { event }),
|
||||
CallTimer('socketio', 'event_duration', { event }),
|
||||
RawSubscribeMessage(event)
|
||||
);
|
||||
|
||||
@@ -104,12 +106,12 @@ export class EventsGateway implements OnGatewayConnection, OnGatewayDisconnect {
|
||||
|
||||
handleConnection() {
|
||||
this.connectionCount++;
|
||||
metrics().socketIOConnectionGauge(this.connectionCount);
|
||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
||||
}
|
||||
|
||||
handleDisconnect() {
|
||||
this.connectionCount--;
|
||||
metrics().socketIOConnectionGauge(this.connectionCount);
|
||||
metrics.socketio.gauge('realtime_connections').record(this.connectionCount);
|
||||
}
|
||||
|
||||
@Auth()
|
||||
|
||||
@@ -34,7 +34,7 @@ export class WorkspacesController {
|
||||
//
|
||||
// NOTE: because graphql can't represent a File, so we have to use REST API to get blob
|
||||
@Get('/:id/blobs/:name')
|
||||
@CallTimer('doc_controller', { method: 'get_blob' })
|
||||
@CallTimer('controllers', 'workspace_get_blob')
|
||||
async blob(
|
||||
@Param('id') workspaceId: string,
|
||||
@Param('name') name: string,
|
||||
@@ -59,7 +59,7 @@ export class WorkspacesController {
|
||||
@Get('/:id/docs/:guid')
|
||||
@Auth()
|
||||
@Publicable()
|
||||
@CallTimer('doc_controller', { method: 'get_doc' })
|
||||
@CallTimer('controllers', 'workspace_get_doc')
|
||||
async doc(
|
||||
@CurrentUser() user: UserType | undefined,
|
||||
@Param('id') ws: string,
|
||||
@@ -106,7 +106,7 @@ export class WorkspacesController {
|
||||
|
||||
@Get('/:id/docs/:guid/histories/:timestamp')
|
||||
@Auth()
|
||||
@CallTimer('doc_controller', { method: 'get_history' })
|
||||
@CallTimer('controllers', 'workspace_get_history')
|
||||
async history(
|
||||
@CurrentUser() user: UserType,
|
||||
@Param('id') ws: string,
|
||||
|
||||
@@ -7,6 +7,13 @@ export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
static INSTANCE: PrismaService | null = null;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
PrismaService.INSTANCE = this;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export class DocID {
|
||||
raw: string;
|
||||
workspace: string;
|
||||
variant: DocVariant;
|
||||
private sub: string | null;
|
||||
private readonly sub: string | null;
|
||||
|
||||
static parse(raw: string): DocID | null {
|
||||
try {
|
||||
|
||||
@@ -17,7 +17,7 @@ class FakePrisma {
|
||||
get workspace() {
|
||||
return {
|
||||
async findUnique() {
|
||||
throw Error('exception from graphql');
|
||||
throw new Error('exception from graphql');
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -183,26 +183,30 @@ test('should correctly list all history records', async t => {
|
||||
|
||||
// insert expired data
|
||||
await db.snapshotHistory.createMany({
|
||||
data: new Array(10).fill(0).map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp - 10 - i),
|
||||
expiredAt: new Date(timestamp - 1),
|
||||
})),
|
||||
data: Array.from({ length: 10 })
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp - 10 - i),
|
||||
expiredAt: new Date(timestamp - 1),
|
||||
})),
|
||||
});
|
||||
|
||||
// insert available data
|
||||
await db.snapshotHistory.createMany({
|
||||
data: new Array(10).fill(0).map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp + i),
|
||||
expiredAt: new Date(timestamp + 1000),
|
||||
})),
|
||||
data: Array.from({ length: 10 })
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp + i),
|
||||
expiredAt: new Date(timestamp + 1000),
|
||||
})),
|
||||
});
|
||||
|
||||
const list = await manager.list(
|
||||
@@ -243,14 +247,16 @@ test('should be able to get last history record', async t => {
|
||||
|
||||
// insert available data
|
||||
await db.snapshotHistory.createMany({
|
||||
data: new Array(10).fill(0).map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp + i),
|
||||
expiredAt: new Date(timestamp + 1000),
|
||||
})),
|
||||
data: Array.from({ length: 10 })
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp + i),
|
||||
expiredAt: new Date(timestamp + 1000),
|
||||
})),
|
||||
});
|
||||
|
||||
const history = await manager.last(snapshot.workspaceId, snapshot.id);
|
||||
@@ -299,26 +305,30 @@ test('should be able to cleanup expired history', async t => {
|
||||
|
||||
// insert expired data
|
||||
await db.snapshotHistory.createMany({
|
||||
data: new Array(10).fill(0).map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp - 10 - i),
|
||||
expiredAt: new Date(timestamp - 1),
|
||||
})),
|
||||
data: Array.from({ length: 10 })
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp - 10 - i),
|
||||
expiredAt: new Date(timestamp - 1),
|
||||
})),
|
||||
});
|
||||
|
||||
// insert available data
|
||||
await db.snapshotHistory.createMany({
|
||||
data: new Array(10).fill(0).map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp + i),
|
||||
expiredAt: new Date(timestamp + 1000),
|
||||
})),
|
||||
data: Array.from({ length: 10 })
|
||||
.fill(0)
|
||||
.map((_, i) => ({
|
||||
workspaceId: snapshot.workspaceId,
|
||||
id: snapshot.id,
|
||||
blob: snapshot.blob,
|
||||
state: snapshot.state,
|
||||
timestamp: new Date(timestamp + i),
|
||||
expiredAt: new Date(timestamp + 1000),
|
||||
})),
|
||||
});
|
||||
|
||||
let count = await db.snapshotHistory.count();
|
||||
|
||||
@@ -65,9 +65,7 @@ test.afterEach.always(async t => {
|
||||
|
||||
test('should get blob size limit', async t => {
|
||||
const { resolver } = t.context;
|
||||
fakeUserService.getStorageQuotaById.returns(
|
||||
Promise.resolve(100 * 1024 * 1024 * 1024)
|
||||
);
|
||||
fakeUserService.getStorageQuotaById.resolves(100 * 1024 * 1024 * 1024);
|
||||
const res = await resolver.checkBlobSize(new FakePrisma().fakeUser, '', 100);
|
||||
t.not(res, false);
|
||||
// @ts-expect-error
|
||||
|
||||
@@ -24,7 +24,7 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
|
||||
export class DebugLogger {
|
||||
private _debug: debug.Debugger;
|
||||
private readonly _debug: debug.Debugger;
|
||||
|
||||
constructor(namespace: string) {
|
||||
this._debug = debug(namespace);
|
||||
|
||||
Vendored
+6
@@ -39,6 +39,12 @@ export const runtimeFlagsSchema = z.object({
|
||||
editorFlags: blockSuiteFeatureFlags,
|
||||
appVersion: z.string(),
|
||||
editorVersion: z.string(),
|
||||
appBuildType: z.union([
|
||||
z.literal('stable'),
|
||||
z.literal('beta'),
|
||||
z.literal('internal'),
|
||||
z.literal('canary'),
|
||||
]),
|
||||
});
|
||||
|
||||
export type BlockSuiteFeatureFlags = z.infer<typeof blockSuiteFeatureFlags>;
|
||||
|
||||
Vendored
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
|
||||
export class UaHelper {
|
||||
private uaMap;
|
||||
private readonly uaMap;
|
||||
public isLinux = false;
|
||||
public isMacOs = false;
|
||||
public isSafari = false;
|
||||
|
||||
@@ -3,8 +3,11 @@ import type { Page, PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { createStore, WritableAtom } from 'jotai/vanilla';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
import { checkWorkspaceCompatibility, MigrationPoint } from '..';
|
||||
import { migratePages } from '../migration/blocksuite';
|
||||
import {
|
||||
checkWorkspaceCompatibility,
|
||||
MigrationPoint,
|
||||
} from '../migration/workspace';
|
||||
|
||||
export async function initEmptyPage(page: Page, title?: string) {
|
||||
await page.load(() => {
|
||||
|
||||
@@ -50,7 +50,7 @@ function migrateDatabase(data: YMap<unknown>) {
|
||||
update: (cell: { id: string; value: unknown }) => void
|
||||
) => {
|
||||
Object.values(cells).forEach(row => {
|
||||
if (row[id] != null) {
|
||||
if (row[id] !== null && row[id] !== undefined) {
|
||||
update(row[id]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -45,8 +45,8 @@ export abstract class HandlerManager<
|
||||
Handlers extends Record<string, PrimitiveHandlers>,
|
||||
> {
|
||||
static instance: HandlerManager<string, Record<string, PrimitiveHandlers>>;
|
||||
private _app: App<Namespace, Handlers>;
|
||||
private _namespace: Namespace;
|
||||
private readonly _app: App<Namespace, Handlers>;
|
||||
private readonly _namespace: Namespace;
|
||||
private _handlers: Handlers;
|
||||
|
||||
constructor() {
|
||||
@@ -190,9 +190,17 @@ export interface UpdateMeta {
|
||||
allowAutoUpdate: boolean;
|
||||
}
|
||||
|
||||
export type UpdaterConfig = {
|
||||
autoCheckUpdate: boolean;
|
||||
autoDownloadUpdate: boolean;
|
||||
};
|
||||
|
||||
export type UpdaterHandlers = {
|
||||
currentVersion: () => Promise<string>;
|
||||
quitAndInstall: () => Promise<void>;
|
||||
downloadUpdate: () => Promise<void>;
|
||||
getConfig: () => Promise<UpdaterConfig>;
|
||||
setConfig: (newConfig: Partial<UpdaterConfig>) => Promise<void>;
|
||||
checkForUpdatesAndNotify: () => Promise<{ version: string } | null>;
|
||||
};
|
||||
|
||||
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { isBrowser } from '@affine/env/constant';
|
||||
import type { UpdateMeta } from '@toeverything/infra/type';
|
||||
import { atomWithObservable, atomWithStorage } from 'jotai/utils';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
// todo: move to utils?
|
||||
function rpcToObservable<
|
||||
T,
|
||||
H extends () => Promise<T>,
|
||||
E extends (callback: (t: T) => void) => () => void,
|
||||
>(
|
||||
initialValue: T | null,
|
||||
{
|
||||
event,
|
||||
handler,
|
||||
onSubscribe,
|
||||
}: {
|
||||
event?: E;
|
||||
handler?: H;
|
||||
onSubscribe?: () => void;
|
||||
}
|
||||
): Observable<T | null> {
|
||||
return new Observable<T | null>(subscriber => {
|
||||
subscriber.next(initialValue);
|
||||
onSubscribe?.();
|
||||
if (!isBrowser || !environment.isDesktop || !event) {
|
||||
subscriber.complete();
|
||||
return;
|
||||
}
|
||||
handler?.()
|
||||
.then(t => {
|
||||
subscriber.next(t);
|
||||
})
|
||||
.catch(err => {
|
||||
subscriber.error(err);
|
||||
});
|
||||
return event(t => {
|
||||
subscriber.next(t);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const updateReadyAtom = atomWithObservable(() => {
|
||||
return rpcToObservable(null as UpdateMeta | null, {
|
||||
event: window.events?.updater.onUpdateReady,
|
||||
});
|
||||
});
|
||||
|
||||
export const updateAvailableAtom = atomWithObservable(() => {
|
||||
return rpcToObservable(null as UpdateMeta | null, {
|
||||
event: window.events?.updater.onUpdateAvailable,
|
||||
onSubscribe: () => {
|
||||
window.apis?.updater.checkForUpdatesAndNotify().catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const downloadProgressAtom = atomWithObservable(() => {
|
||||
return rpcToObservable(null as number | null, {
|
||||
event: window.events?.updater.onDownloadProgress,
|
||||
});
|
||||
});
|
||||
|
||||
export const changelogCheckedAtom = atomWithStorage<Record<string, boolean>>(
|
||||
'affine:client-changelog-checked',
|
||||
{}
|
||||
);
|
||||
+25
-45
@@ -1,18 +1,21 @@
|
||||
import { isBrowser, Unreachable } from '@affine/env/constant';
|
||||
import { Unreachable } from '@affine/env/constant';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { CloseIcon, NewIcon, ResetIcon } from '@blocksuite/icons';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import clsx from 'clsx';
|
||||
import { atom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { startTransition, useCallback, useState } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import {
|
||||
changelogCheckedAtom,
|
||||
currentChangelogUnreadAtom,
|
||||
currentVersionAtom,
|
||||
downloadProgressAtom,
|
||||
updateAvailableAtom,
|
||||
updateReadyAtom,
|
||||
} from './index.jotai';
|
||||
useAppUpdater,
|
||||
} from '@toeverything/hooks/use-app-updater';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { startTransition, useCallback } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
|
||||
export interface AddPageButtonPureProps {
|
||||
onClickUpdate: () => void;
|
||||
@@ -29,26 +32,6 @@ export interface AddPageButtonPureProps {
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const currentVersionAtom = atom(async () => {
|
||||
if (!isBrowser) {
|
||||
return null;
|
||||
}
|
||||
const currentVersion = await window.apis?.updater.currentVersion();
|
||||
return currentVersion;
|
||||
});
|
||||
|
||||
const currentChangelogUnreadAtom = atom(async get => {
|
||||
if (!isBrowser) {
|
||||
return false;
|
||||
}
|
||||
const mapping = get(changelogCheckedAtom);
|
||||
const currentVersion = await get(currentVersionAtom);
|
||||
if (currentVersion) {
|
||||
return !mapping[currentVersion];
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
export function AppUpdaterButtonPure({
|
||||
updateReady,
|
||||
onClickUpdate,
|
||||
@@ -198,12 +181,12 @@ export function AppUpdaterButton({
|
||||
const currentChangelogUnread = useAtomValue(currentChangelogUnreadAtom);
|
||||
const updateReady = useAtomValue(updateReadyAtom);
|
||||
const updateAvailable = useAtomValue(updateAvailableAtom);
|
||||
const currentVersion = useAtomValue(currentVersionAtom);
|
||||
const downloadProgress = useAtomValue(downloadProgressAtom);
|
||||
const currentVersion = useAtomValue(currentVersionAtom);
|
||||
const { quitAndInstall, appQuitting } = useAppUpdater();
|
||||
const setChangelogCheckAtom = useSetAtom(changelogCheckedAtom);
|
||||
const [appQuitting, setAppQuitting] = useState(false);
|
||||
|
||||
const onDismissCurrentChangelog = useCallback(() => {
|
||||
const dismissCurrentChangelog = useCallback(() => {
|
||||
if (!currentVersion) {
|
||||
return;
|
||||
}
|
||||
@@ -216,13 +199,10 @@ export function AppUpdaterButton({
|
||||
})
|
||||
);
|
||||
}, [currentVersion, setChangelogCheckAtom]);
|
||||
const onClickUpdate = useCallback(() => {
|
||||
|
||||
const handleClickUpdate = useCallback(() => {
|
||||
if (updateReady) {
|
||||
setAppQuitting(true);
|
||||
window.apis?.updater.quitAndInstall().catch(err => {
|
||||
// TODO: add error toast here
|
||||
console.error(err);
|
||||
});
|
||||
quitAndInstall();
|
||||
} else if (updateAvailable) {
|
||||
if (updateAvailable.allowAutoUpdate) {
|
||||
// wait for download to finish
|
||||
@@ -234,23 +214,25 @@ export function AppUpdaterButton({
|
||||
}
|
||||
} else if (currentChangelogUnread) {
|
||||
window.open(runtimeConfig.changelogUrl, '_blank');
|
||||
onDismissCurrentChangelog();
|
||||
dismissCurrentChangelog();
|
||||
} else {
|
||||
throw new Unreachable();
|
||||
}
|
||||
}, [
|
||||
currentChangelogUnread,
|
||||
currentVersion,
|
||||
onDismissCurrentChangelog,
|
||||
updateAvailable,
|
||||
updateReady,
|
||||
quitAndInstall,
|
||||
updateAvailable,
|
||||
currentChangelogUnread,
|
||||
dismissCurrentChangelog,
|
||||
currentVersion,
|
||||
]);
|
||||
|
||||
return (
|
||||
<AppUpdaterButtonPure
|
||||
appQuitting={appQuitting}
|
||||
updateReady={!!updateReady}
|
||||
onClickUpdate={onClickUpdate}
|
||||
onDismissCurrentChangelog={onDismissCurrentChangelog}
|
||||
onClickUpdate={handleClickUpdate}
|
||||
onDismissCurrentChangelog={dismissCurrentChangelog}
|
||||
currentChangelogUnread={currentChangelogUnread}
|
||||
updateAvailable={updateAvailable}
|
||||
downloadProgress={downloadProgress}
|
||||
@@ -259,5 +241,3 @@ export function AppUpdaterButton({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export * from './index.jotai';
|
||||
|
||||
@@ -2,8 +2,10 @@ import { atom } from 'jotai';
|
||||
import { atomWithStorage } from 'jotai/utils';
|
||||
|
||||
export const APP_SIDEBAR_OPEN = 'app-sidebar-open';
|
||||
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, true);
|
||||
export const appSidebarFloatingAtom = atom(false);
|
||||
export const isMobile = window.innerWidth < 768;
|
||||
|
||||
export const appSidebarOpenAtom = atomWithStorage(APP_SIDEBAR_OPEN, !isMobile);
|
||||
export const appSidebarFloatingAtom = atom(isMobile);
|
||||
|
||||
export const appSidebarResizingAtom = atom(false);
|
||||
export const appSidebarWidthAtom = atomWithStorage(
|
||||
|
||||
@@ -10,9 +10,9 @@ export const RootBlockHub = () => {
|
||||
const div = ref.current;
|
||||
if (blockHub) {
|
||||
if (div.hasChildNodes()) {
|
||||
div.removeChild(div.firstChild as ChildNode);
|
||||
(div.firstChild as ChildNode).remove();
|
||||
}
|
||||
div.appendChild(blockHub);
|
||||
div.append(blockHub);
|
||||
}
|
||||
}
|
||||
}, [blockHub]);
|
||||
|
||||
@@ -192,9 +192,9 @@ const BlockSuiteEditorImpl = ({
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.appendChild(editor);
|
||||
container.append(editor);
|
||||
return () => {
|
||||
container.removeChild(editor);
|
||||
editor.remove();
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
|
||||
@@ -4,9 +4,11 @@ import { typesystem } from './typesystem';
|
||||
type MatcherData<Data, Type extends TType = TType> = { type: Type; data: Data };
|
||||
|
||||
export class Matcher<Data, Type extends TType = TType> {
|
||||
private list: MatcherData<Data, Type>[] = [];
|
||||
private readonly list: MatcherData<Data, Type>[] = [];
|
||||
|
||||
constructor(private _match?: (type: Type, target: TType) => boolean) {}
|
||||
constructor(
|
||||
private readonly _match?: (type: Type, target: TType) => boolean
|
||||
) {}
|
||||
|
||||
register(type: Type, data: Data) {
|
||||
this.list.push({ type, data });
|
||||
|
||||
@@ -92,8 +92,8 @@ export type ValueOfData<T extends DataDefine> = T extends DataDefine<infer R>
|
||||
|
||||
export class DataDefine<Data extends DataTypeShape = Record<string, unknown>> {
|
||||
constructor(
|
||||
private config: DataDefineConfig<Data>,
|
||||
private dataMap: Map<string, DataDefine>
|
||||
private readonly config: DataDefineConfig<Data>,
|
||||
private readonly dataMap: Map<string, DataDefine>
|
||||
) {}
|
||||
|
||||
create(data?: Data): TDataType<Data> {
|
||||
@@ -195,7 +195,7 @@ export class Typesystem {
|
||||
): boolean {
|
||||
if (superType.type === 'typeRef') {
|
||||
// TODO both are ref
|
||||
if (context && sub.type != 'typeRef') {
|
||||
if (context && sub.type !== 'typeRef') {
|
||||
context[superType.name] = sub;
|
||||
}
|
||||
// TODO bound
|
||||
|
||||
@@ -141,7 +141,7 @@ filterMatcher.register(
|
||||
name: 'is',
|
||||
defaultArgs: () => [true],
|
||||
impl: (value, target) => {
|
||||
return value == target;
|
||||
return value === target;
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -209,7 +209,7 @@ filterMatcher.register(
|
||||
defaultArgs: () => [],
|
||||
impl: tags => {
|
||||
const safeTags = safeArray(tags);
|
||||
return safeTags.length == 0;
|
||||
return safeTags.length === 0;
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
|
||||
@@ -19,93 +18,8 @@ import {
|
||||
useAtom,
|
||||
useAtomValue,
|
||||
} from './scoped-atoms';
|
||||
import type {
|
||||
PageGroupDefinition,
|
||||
PageGroupProps,
|
||||
PageListItemProps,
|
||||
PageListProps,
|
||||
} from './types';
|
||||
import { type DateKey } from './types';
|
||||
import { betweenDaysAgo, shallowEqual, withinDaysAgo } from './utils';
|
||||
|
||||
// todo: optimize date matchers
|
||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||
{
|
||||
id: 'today',
|
||||
label: <Trans i18nKey="com.affine.today" />,
|
||||
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: <Trans i18nKey="com.affine.yesterday" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: <Trans i18nKey="com.affine.last7Days" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
|
||||
},
|
||||
{
|
||||
id: 'moreThan30Days',
|
||||
label: <Trans i18nKey="com.affine.moreThan30Days" />,
|
||||
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
|
||||
},
|
||||
];
|
||||
|
||||
const pageGroupDefinitions = {
|
||||
createDate: getDateGroupDefinitions('createDate'),
|
||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||
// add more here later
|
||||
// todo: some page group definitions maybe dynamic
|
||||
};
|
||||
|
||||
export function pagesToPageGroups(
|
||||
pages: PageMeta[],
|
||||
key?: DateKey
|
||||
): PageGroupProps[] {
|
||||
if (!key) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
items: pages,
|
||||
allItems: pages,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// assume pages are already sorted, we will use the page order to determine the group order
|
||||
const groupDefs = pageGroupDefinitions[key];
|
||||
const groups: PageGroupProps[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
// for a single page, there could be multiple groups that it belongs to
|
||||
const matchedGroups = groupDefs.filter(def => def.match(page));
|
||||
for (const groupDef of matchedGroups) {
|
||||
const group = groups.find(g => g.id === groupDef.id);
|
||||
if (group) {
|
||||
group.items.push(page);
|
||||
} else {
|
||||
const label =
|
||||
typeof groupDef.label === 'function'
|
||||
? groupDef.label()
|
||||
: groupDef.label;
|
||||
groups.push({
|
||||
id: groupDef.id,
|
||||
label: label,
|
||||
items: [page],
|
||||
allItems: pages,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
import type { PageGroupProps, PageListItemProps, PageListProps } from './types';
|
||||
import { shallowEqual } from './utils';
|
||||
|
||||
export const PageGroupHeader = ({ id, items, label }: PageGroupProps) => {
|
||||
const [collapseState, setCollapseState] = useAtom(pageGroupCollapseStateAtom);
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Trans } from '@affine/i18n';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
|
||||
import type { PageGroupDefinition, PageGroupProps } from './types';
|
||||
import { type DateKey } from './types';
|
||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||
|
||||
// todo: optimize date matchers
|
||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||
{
|
||||
id: 'today',
|
||||
label: <Trans i18nKey="com.affine.today" />,
|
||||
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: <Trans i18nKey="com.affine.yesterday" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: <Trans i18nKey="com.affine.last7Days" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
|
||||
},
|
||||
{
|
||||
id: 'moreThan30Days',
|
||||
label: <Trans i18nKey="com.affine.moreThan30Days" />,
|
||||
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
|
||||
},
|
||||
];
|
||||
|
||||
const pageGroupDefinitions = {
|
||||
createDate: getDateGroupDefinitions('createDate'),
|
||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||
// add more here later
|
||||
// todo: some page group definitions maybe dynamic
|
||||
};
|
||||
|
||||
export function pagesToPageGroups(
|
||||
pages: PageMeta[],
|
||||
key?: DateKey
|
||||
): PageGroupProps[] {
|
||||
if (!key) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
items: pages,
|
||||
allItems: pages,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// assume pages are already sorted, we will use the page order to determine the group order
|
||||
const groupDefs = pageGroupDefinitions[key];
|
||||
const groups: PageGroupProps[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
// for a single page, there could be multiple groups that it belongs to
|
||||
const matchedGroups = groupDefs.filter(def => def.match(page));
|
||||
for (const groupDef of matchedGroups) {
|
||||
const group = groups.find(g => g.id === groupDef.id);
|
||||
if (group) {
|
||||
group.items.push(page);
|
||||
} else {
|
||||
const label =
|
||||
typeof groupDef.label === 'function'
|
||||
? groupDef.label()
|
||||
: groupDef.label;
|
||||
groups.push({
|
||||
id: groupDef.id,
|
||||
label: label,
|
||||
items: [page],
|
||||
allItems: pages,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { createIsolation } from 'jotai-scope';
|
||||
|
||||
import { pagesToPageGroups } from './page-group';
|
||||
import { pagesToPageGroups } from './pages-to-page-group';
|
||||
import type {
|
||||
PageListProps,
|
||||
PageMetaRecord,
|
||||
|
||||
@@ -10,9 +10,9 @@ import * as styles from './page-list.css';
|
||||
export function isToday(date: Date): boolean {
|
||||
const today = new Date();
|
||||
return (
|
||||
date.getDate() == today.getDate() &&
|
||||
date.getMonth() == today.getMonth() &&
|
||||
date.getFullYear() == today.getFullYear()
|
||||
date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -162,10 +162,10 @@ export function shallowEqual(objA: any, objB: any) {
|
||||
}
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (let i = 0; i < keysA.length; i++) {
|
||||
for (const key of keysA) {
|
||||
if (
|
||||
!Object.prototype.hasOwnProperty.call(objB, keysA[i]) ||
|
||||
!Object.is(objA[keysA[i]], objB[keysA[i]])
|
||||
!Object.prototype.hasOwnProperty.call(objB, key) ||
|
||||
!Object.is(objA[key], objB[key])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type {
|
||||
} from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilteredIcon } from '@blocksuite/icons';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import { useCallback, useState } from 'react';
|
||||
@@ -75,7 +75,7 @@ export const CollectionList = ({
|
||||
<Button
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilteredIcon />}
|
||||
icon={<FilterIcon />}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
@@ -99,7 +99,7 @@ export const CollectionList = ({
|
||||
<Button
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilteredIcon />}
|
||||
icon={<FilterIcon />}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import {
|
||||
createEmptyCollection,
|
||||
useEditCollectionName,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SaveIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Modal } from '@toeverything/components/modal';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import Input from '../../../ui/input';
|
||||
@@ -127,40 +120,3 @@ export const CreateCollection = ({
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface SaveAsCollectionButtonProps {
|
||||
onConfirm: (collection: Collection) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SaveAsCollectionButton = ({
|
||||
onConfirm,
|
||||
}: SaveAsCollectionButtonProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { open, node } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.saveCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
const handleClick = useCallback(() => {
|
||||
open('')
|
||||
.then(name => {
|
||||
return onConfirm(createEmptyCollection(nanoid(), { name }));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [open, onConfirm]);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
data-testid="save-as-collection"
|
||||
icon={<SaveIcon />}
|
||||
size="large"
|
||||
style={{ padding: '7px 8px' }}
|
||||
>
|
||||
{t['com.affine.editCollection.saveCollection']()}
|
||||
</Button>
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import { Button } from '@toeverything/components/button';
|
||||
import { Modal } from '@toeverything/components/modal';
|
||||
import { type ReactNode, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { RadioButton, RadioButtonGroup } from '../../../../index';
|
||||
import { RadioButton, RadioButtonGroup } from '../../../../ui/button';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { PagesMode } from './pages-mode';
|
||||
import { RulesMode } from './rules-mode';
|
||||
|
||||
+2
-51
@@ -1,12 +1,7 @@
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
filterPageByRules,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { Modal } from '@toeverything/components/modal';
|
||||
import { type MouseEvent, useCallback, useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import { SelectPage } from './select-page';
|
||||
export const useSelectPage = ({
|
||||
allPageListConfig,
|
||||
@@ -60,47 +55,3 @@ export const useSelectPage = ({
|
||||
}),
|
||||
};
|
||||
};
|
||||
export const useFilter = (list: PageMeta[]) => {
|
||||
const [filters, changeFilters] = useState<Filter[]>([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const clickFilter = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (showFilter || filters.length !== 0) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowFilter(!showFilter);
|
||||
}
|
||||
},
|
||||
[filters.length, showFilter]
|
||||
);
|
||||
const onCreateFilter = useCallback(
|
||||
(filter: Filter) => {
|
||||
changeFilters([...filters, filter]);
|
||||
setShowFilter(true);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
return {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters: changeFilters,
|
||||
clickFilter,
|
||||
createFilter: onCreateFilter,
|
||||
filteredList: list.filter(v => {
|
||||
if (v.trash) {
|
||||
return false;
|
||||
}
|
||||
return filterPageByRules(filters, [], v);
|
||||
}),
|
||||
};
|
||||
};
|
||||
export const useSearch = (list: PageMeta[]) => {
|
||||
const [value, onChange] = useState('');
|
||||
return {
|
||||
searchText: value,
|
||||
updateSearchText: onChange,
|
||||
searchedList: value
|
||||
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
|
||||
: list,
|
||||
};
|
||||
};
|
||||
|
||||
+5
-6
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
FilterList,
|
||||
VirtualizedPageList,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilterIcon } from '@blocksuite/icons';
|
||||
@@ -11,10 +6,14 @@ import { Menu } from '@toeverything/components/menu';
|
||||
import clsx from 'clsx';
|
||||
import { type ReactNode, useCallback } from 'react';
|
||||
|
||||
import { FilterList } from '../../filter/filter-list';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { VirtualizedPageList } from '../../virtualized-page-list';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { useFilter, useSearch } from './hooks';
|
||||
import { EmptyList } from './select-page';
|
||||
import { useFilter } from './use-filter';
|
||||
import { useSearch } from './use-search';
|
||||
|
||||
export const PagesMode = ({
|
||||
switchMode,
|
||||
|
||||
+4
-2
@@ -6,13 +6,15 @@ import { Menu } from '@toeverything/components/menu';
|
||||
import clsx from 'clsx';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { VirtualizedPageList } from '../..';
|
||||
import { FilterList } from '../../filter';
|
||||
import { VariableSelect } from '../../filter/vars';
|
||||
import { VirtualizedPageList } from '../../virtualized-page-list';
|
||||
import { AffineShapeIcon } from '../affine-shape';
|
||||
import type { AllPageListConfig } from './edit-collection';
|
||||
import * as styles from './edit-collection.css';
|
||||
import { useFilter, useSearch } from './hooks';
|
||||
import { useFilter } from './use-filter';
|
||||
import { useSearch } from './use-search';
|
||||
|
||||
export const SelectPage = ({
|
||||
allPageListConfig,
|
||||
init,
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
import type { Filter } from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { type MouseEvent, useCallback, useState } from 'react';
|
||||
|
||||
import { filterPageByRules } from '../../use-collection-manager';
|
||||
|
||||
export const useFilter = (list: PageMeta[]) => {
|
||||
const [filters, changeFilters] = useState<Filter[]>([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const clickFilter = useCallback(
|
||||
(e: MouseEvent) => {
|
||||
if (showFilter || filters.length !== 0) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowFilter(!showFilter);
|
||||
}
|
||||
},
|
||||
[filters.length, showFilter]
|
||||
);
|
||||
const onCreateFilter = useCallback(
|
||||
(filter: Filter) => {
|
||||
changeFilters([...filters, filter]);
|
||||
setShowFilter(true);
|
||||
},
|
||||
[filters]
|
||||
);
|
||||
return {
|
||||
showFilter,
|
||||
filters,
|
||||
updateFilters: changeFilters,
|
||||
clickFilter,
|
||||
createFilter: onCreateFilter,
|
||||
filteredList: list.filter(v => {
|
||||
if (v.trash) {
|
||||
return false;
|
||||
}
|
||||
return filterPageByRules(filters, [], v);
|
||||
}),
|
||||
};
|
||||
};
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useSearch = (list: PageMeta[]) => {
|
||||
const [value, onChange] = useState('');
|
||||
return {
|
||||
searchText: value,
|
||||
updateSearchText: onChange,
|
||||
searchedList: value
|
||||
? list.filter(v => v.title.toLowerCase().includes(value.toLowerCase()))
|
||||
: list,
|
||||
};
|
||||
};
|
||||
@@ -4,4 +4,5 @@ export * from './collection-list';
|
||||
export * from './collection-operations';
|
||||
export * from './create-collection';
|
||||
export * from './edit-collection/edit-collection';
|
||||
export * from './save-as-collection-button';
|
||||
export * from './use-edit-collection';
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SaveIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { createEmptyCollection } from '../use-collection-manager';
|
||||
import { useEditCollectionName } from './use-edit-collection';
|
||||
|
||||
interface SaveAsCollectionButtonProps {
|
||||
onConfirm: (collection: Collection) => Promise<void>;
|
||||
}
|
||||
|
||||
export const SaveAsCollectionButton = ({
|
||||
onConfirm,
|
||||
}: SaveAsCollectionButtonProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { open, node } = useEditCollectionName({
|
||||
title: t['com.affine.editCollection.saveCollection'](),
|
||||
showTips: true,
|
||||
});
|
||||
const handleClick = useCallback(() => {
|
||||
open('')
|
||||
.then(name => {
|
||||
return onConfirm(createEmptyCollection(nanoid(), { name }));
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [open, onConfirm]);
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
onClick={handleClick}
|
||||
data-testid="save-as-collection"
|
||||
icon={<SaveIcon />}
|
||||
size="large"
|
||||
style={{ padding: '7px 8px' }}
|
||||
>
|
||||
{t['com.affine.editCollection.saveCollection']()}
|
||||
</Button>
|
||||
{node}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,12 +1,13 @@
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
CreateCollectionModal,
|
||||
EditCollectionModal,
|
||||
type EditCollectionMode,
|
||||
} from '@affine/component/page-list';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { CreateCollectionModal } from './create-collection';
|
||||
import {
|
||||
type AllPageListConfig,
|
||||
EditCollectionModal,
|
||||
type EditCollectionMode,
|
||||
} from './edit-collection/edit-collection';
|
||||
|
||||
export const useEditCollection = (config: AllPageListConfig) => {
|
||||
const [data, setData] = useState<{
|
||||
collection: Collection;
|
||||
|
||||
+13
-11
@@ -8,17 +8,19 @@ export const WorkspaceDetailSkeleton = () => {
|
||||
<>
|
||||
<SettingHeader title={<Skeleton />} subtitle={<Skeleton />} />
|
||||
|
||||
{new Array(3).fill(0).map((_, index) => {
|
||||
return (
|
||||
<SettingWrapper title={<Skeleton />} key={index}>
|
||||
<SettingRow
|
||||
name={<Skeleton />}
|
||||
desc={<Skeleton />}
|
||||
spreadCol={false}
|
||||
></SettingRow>
|
||||
</SettingWrapper>
|
||||
);
|
||||
})}
|
||||
{Array.from({ length: 3 })
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return (
|
||||
<SettingWrapper title={<Skeleton />} key={index}>
|
||||
<SettingRow
|
||||
name={<Skeleton />}
|
||||
desc={<Skeleton />}
|
||||
spreadCol={false}
|
||||
></SettingRow>
|
||||
</SettingWrapper>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+5
-3
@@ -26,9 +26,11 @@ export const WorkspaceListItemSkeleton = () => {
|
||||
export const WorkspaceListSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
{new Array(5).fill(0).map((_, index) => {
|
||||
return <WorkspaceListItemSkeleton key={index} />;
|
||||
})}
|
||||
{Array.from({ length: 5 })
|
||||
.fill(0)
|
||||
.map((_, index) => {
|
||||
return <WorkspaceListItemSkeleton key={index} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -61,6 +61,7 @@ export const dropdownIcon = style({
|
||||
|
||||
export const radioButton = style({
|
||||
flexGrow: 1,
|
||||
flex: 1,
|
||||
selectors: {
|
||||
'&:not(:last-of-type)': {
|
||||
marginRight: '4px',
|
||||
@@ -72,7 +73,8 @@ export const radioButtonContent = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: '24px',
|
||||
height: '28px',
|
||||
padding: '4px 8px',
|
||||
borderRadius: '8px',
|
||||
filter: 'drop-shadow(0px 0px 4px rgba(0, 0, 0, 0.1))',
|
||||
whiteSpace: 'nowrap',
|
||||
|
||||
@@ -84,7 +84,7 @@ export const playCheckAnimation = async (refElement: Element) => {
|
||||
border-radius: 50%;
|
||||
font-size: inherit;
|
||||
`;
|
||||
refElement.appendChild(sparkingEl);
|
||||
refElement.append(sparkingEl);
|
||||
|
||||
await sparkingEl.animate(
|
||||
[
|
||||
|
||||
@@ -29,7 +29,7 @@ export const Skeleton = ({
|
||||
const style = {
|
||||
width,
|
||||
height,
|
||||
...(_style || {}),
|
||||
..._style,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -46,7 +46,7 @@ const createToastContainer = (portal?: HTMLElement) => {
|
||||
data-testid="affine-toast-container"
|
||||
></div>`;
|
||||
const element = htmlToElement<HTMLDivElement>(template);
|
||||
portal.appendChild(element);
|
||||
portal.append(element);
|
||||
return element;
|
||||
};
|
||||
|
||||
@@ -98,7 +98,7 @@ const createAndShowNewToast = (
|
||||
const toastElement = htmlToElement<HTMLDivElement>(toastTemplate);
|
||||
// message is not trusted
|
||||
toastElement.textContent = message;
|
||||
ToastContainer.appendChild(toastElement);
|
||||
ToastContainer.append(toastElement);
|
||||
logger.debug(`toast with message: "${message}"`);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('affine-toast:emit', { detail: message })
|
||||
|
||||
@@ -38,18 +38,21 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
editorFlags,
|
||||
appVersion: packageJson.version,
|
||||
editorVersion: packageJson.dependencies['@blocksuite/editor'],
|
||||
appBuildType: 'stable',
|
||||
},
|
||||
get beta() {
|
||||
return {
|
||||
...this.stable,
|
||||
enablePageHistory: false,
|
||||
serverUrlPrefix: 'https://insider.affine.pro',
|
||||
appBuildType: 'beta' as const,
|
||||
};
|
||||
},
|
||||
get internal() {
|
||||
return {
|
||||
...this.stable,
|
||||
serverUrlPrefix: 'https://insider.affine.pro',
|
||||
appBuildType: 'internal' as const,
|
||||
};
|
||||
},
|
||||
// canary will be aggressive and enable all features
|
||||
@@ -82,6 +85,7 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
|
||||
editorFlags,
|
||||
appVersion: packageJson.version,
|
||||
editorVersion: packageJson.dependencies['@blocksuite/editor'],
|
||||
appBuildType: 'canary',
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
{
|
||||
"env": "BUILD_TYPE"
|
||||
},
|
||||
{
|
||||
"env": "BUILD_TYPE_OVERRIDE"
|
||||
},
|
||||
{
|
||||
"env": "PERFSEE_TOKEN"
|
||||
},
|
||||
@@ -48,6 +51,24 @@
|
||||
},
|
||||
{
|
||||
"env": "DISABLE_DEV_OVERLAY"
|
||||
},
|
||||
{
|
||||
"env": "CAPTCHA_SITE_KEY"
|
||||
},
|
||||
{
|
||||
"env": "SHOULD_REPORT_TRACE"
|
||||
},
|
||||
{
|
||||
"env": "TRACE_REPORT_ENDPOINT"
|
||||
},
|
||||
{
|
||||
"env": "R2_ACCOUNT_ID"
|
||||
},
|
||||
{
|
||||
"env": "R2_ACCESS_KEY_ID"
|
||||
},
|
||||
{
|
||||
"env": "R2_SECRET_ACCESS_KEY"
|
||||
}
|
||||
],
|
||||
"options": {
|
||||
|
||||
@@ -81,7 +81,7 @@ export async function bootstrapPluginSystem(
|
||||
logger.debug(`registering plugin ${pluginName}`);
|
||||
logger.debug(`package.json: ${packageJson}`);
|
||||
if (!release && !runtimeConfig.enablePlugin) {
|
||||
return Promise.resolve();
|
||||
return;
|
||||
}
|
||||
const baseURL = url;
|
||||
const entryURL = `${baseURL}/${core}`;
|
||||
@@ -95,7 +95,7 @@ export async function bootstrapPluginSystem(
|
||||
const loadedAssetName = `${pluginName}_${asset}`;
|
||||
// todo(himself65): add assets into shadow dom
|
||||
if (loadedAssets.has(loadedAssetName)) {
|
||||
return Promise.resolve();
|
||||
return;
|
||||
}
|
||||
if (asset.endsWith('.css')) {
|
||||
loadedAssets.add(loadedAssetName);
|
||||
@@ -106,12 +106,12 @@ export async function bootstrapPluginSystem(
|
||||
const style = document.createElement('style');
|
||||
style.setAttribute('plugin-id', pluginName);
|
||||
style.textContent = text;
|
||||
document.head.appendChild(style);
|
||||
document.head.append(style);
|
||||
});
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return Promise.resolve();
|
||||
return;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { updateReadyAtom } from '@affine/component/app-sidebar/app-updater-button';
|
||||
import type { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ResetIcon } from '@blocksuite/icons';
|
||||
import { updateReadyAtom } from '@toeverything/hooks/use-app-updater';
|
||||
import { registerAffineCommand } from '@toeverything/infra/command';
|
||||
import type { createStore } from 'jotai';
|
||||
|
||||
|
||||
@@ -33,13 +33,14 @@ export const LanguageMenu = () => {
|
||||
contentOptions={{
|
||||
style: {
|
||||
background: 'var(--affine-white)',
|
||||
width: '250px',
|
||||
},
|
||||
align: 'end',
|
||||
}}
|
||||
>
|
||||
<MenuTrigger
|
||||
data-testid="language-menu-button"
|
||||
style={{ textTransform: 'capitalize', fontWeight: 600 }}
|
||||
style={{ textTransform: 'capitalize', fontWeight: 600, width: '250px' }}
|
||||
block={true}
|
||||
>
|
||||
{currentLanguage?.originalName || ''}
|
||||
|
||||
+10
-4
@@ -96,6 +96,8 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
|
||||
[pushNotification, update]
|
||||
);
|
||||
|
||||
const canAdjustAvatar = workspaceAvatar && isOwner;
|
||||
|
||||
return (
|
||||
<div className={style.profileWrapper}>
|
||||
<Upload
|
||||
@@ -110,11 +112,15 @@ export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
|
||||
name={name}
|
||||
colorfulFallback
|
||||
hoverIcon={isOwner ? <CameraIcon /> : undefined}
|
||||
onRemove={
|
||||
workspaceAvatar && isOwner ? handleRemoveUserAvatar : undefined
|
||||
onRemove={canAdjustAvatar ? handleRemoveUserAvatar : undefined}
|
||||
avatarTooltipOptions={
|
||||
canAdjustAvatar
|
||||
? { content: t['Click to replace photo']() }
|
||||
: undefined
|
||||
}
|
||||
removeTooltipOptions={
|
||||
canAdjustAvatar ? { content: t['Remove photo']() } : undefined
|
||||
}
|
||||
avatarTooltipOptions={{ content: t['Click to replace photo']() }}
|
||||
removeTooltipOptions={{ content: t['Remove photo']() }}
|
||||
data-testid="workspace-setting-avatar"
|
||||
removeButtonProps={{
|
||||
['data-testid' as string]: 'workspace-setting-remove-avatar-button',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Scrollable } from '@affine/component';
|
||||
import {
|
||||
BlockSuiteEditor,
|
||||
BlockSuiteFallback,
|
||||
EditorLoading,
|
||||
} from '@affine/component/block-suite-editor';
|
||||
import type { PageMode } from '@affine/core/atoms';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
@@ -147,7 +147,7 @@ const HistoryEditorPreview = ({
|
||||
onModeChange={onModeChange}
|
||||
/>
|
||||
) : (
|
||||
<BlockSuiteFallback />
|
||||
<EditorLoading />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -410,7 +410,7 @@ export const PageHistoryModal = ({
|
||||
|
||||
return (
|
||||
<ModalContainer onOpenChange={onOpenChange} open={open}>
|
||||
<Suspense fallback={<BlockSuiteFallback />}>
|
||||
<Suspense fallback={<EditorLoading />}>
|
||||
<PageHistoryManager
|
||||
onClose={onClose}
|
||||
pageId={pageId}
|
||||
|
||||
+42
-17
@@ -4,14 +4,39 @@ import { SettingRow } from '@affine/component/setting-components';
|
||||
import { SettingWrapper } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons';
|
||||
import { useAppUpdater } from '@toeverything/hooks/use-app-updater';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
import { useAppSettingHelper } from '../../../../../hooks/affine/use-app-setting-helper';
|
||||
import { appIconMap, appNames } from '../../../../../pages/open-app';
|
||||
import { relatedLinks } from './config';
|
||||
import { communityItem, communityWrapper, link } from './style.css';
|
||||
import * as styles from './style.css';
|
||||
import { UpdateCheckSection } from './update-check-section';
|
||||
|
||||
export const AboutAffine = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { appSettings, updateSettings } = useAppSettingHelper();
|
||||
const { toggleAutoCheck, toggleAutoDownload } = useAppUpdater();
|
||||
const channel = runtimeConfig.appBuildType;
|
||||
const appIcon = appIconMap[channel];
|
||||
const appName = appNames[channel];
|
||||
|
||||
const onSwitchAutoCheck = useCallback(
|
||||
(checked: boolean) => {
|
||||
toggleAutoCheck(checked);
|
||||
updateSettings('autoCheckUpdate', checked);
|
||||
},
|
||||
[toggleAutoCheck, updateSettings]
|
||||
);
|
||||
|
||||
const onSwitchAutoDownload = useCallback(
|
||||
(checked: boolean) => {
|
||||
toggleAutoDownload(checked);
|
||||
updateSettings('autoDownloadUpdate', checked);
|
||||
},
|
||||
[toggleAutoDownload, updateSettings]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingHeader
|
||||
@@ -21,26 +46,26 @@ export const AboutAffine = () => {
|
||||
/>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.version.title']()}>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.version.app']()}
|
||||
name={appName}
|
||||
desc={runtimeConfig.appVersion}
|
||||
/>
|
||||
className={styles.appImageRow}
|
||||
>
|
||||
<img src={appIcon} alt={appName} width={56} height={56} />
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.version.editor.title']()}
|
||||
desc={runtimeConfig.editorVersion}
|
||||
/>
|
||||
{runtimeConfig.enableNewSettingUnstableApi && environment.isDesktop ? (
|
||||
{environment.isDesktop ? (
|
||||
<>
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
|
||||
desc={t['com.affine.aboutAFFiNE.checkUpdate.description']()}
|
||||
/>
|
||||
<UpdateCheckSection />
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.autoCheckUpdate.title']()}
|
||||
desc={t['com.affine.aboutAFFiNE.autoCheckUpdate.description']()}
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.autoCheckUpdate}
|
||||
onChange={checked => updateSettings('autoCheckUpdate', checked)}
|
||||
onChange={onSwitchAutoCheck}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
@@ -50,8 +75,8 @@ export const AboutAffine = () => {
|
||||
]()}
|
||||
>
|
||||
<Switch
|
||||
checked={appSettings.autoCheckUpdate}
|
||||
onChange={checked => updateSettings('autoCheckUpdate', checked)}
|
||||
checked={appSettings.autoDownloadUpdate}
|
||||
onChange={onSwitchAutoDownload}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
@@ -69,7 +94,7 @@ export const AboutAffine = () => {
|
||||
</SettingWrapper>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.contact.title']()}>
|
||||
<a
|
||||
className={link}
|
||||
className={styles.link}
|
||||
rel="noreferrer"
|
||||
href="https://affine.pro"
|
||||
target="_blank"
|
||||
@@ -78,7 +103,7 @@ export const AboutAffine = () => {
|
||||
<OpenInNewIcon className="icon" />
|
||||
</a>
|
||||
<a
|
||||
className={link}
|
||||
className={styles.link}
|
||||
rel="noreferrer"
|
||||
href="https://community.affine.pro"
|
||||
target="_blank"
|
||||
@@ -88,11 +113,11 @@ export const AboutAffine = () => {
|
||||
</a>
|
||||
</SettingWrapper>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.community.title']()}>
|
||||
<div className={communityWrapper}>
|
||||
<div className={styles.communityWrapper}>
|
||||
{relatedLinks.map(({ icon, title, link }) => {
|
||||
return (
|
||||
<div
|
||||
className={communityItem}
|
||||
className={styles.communityItem}
|
||||
onClick={() => {
|
||||
window.open(link, '_blank');
|
||||
}}
|
||||
@@ -107,7 +132,7 @@ export const AboutAffine = () => {
|
||||
</SettingWrapper>
|
||||
<SettingWrapper title={t['com.affine.aboutAFFiNE.legal.title']()}>
|
||||
<a
|
||||
className={link}
|
||||
className={styles.link}
|
||||
rel="noreferrer"
|
||||
href="https://affine.pro/privacy"
|
||||
target="_blank"
|
||||
@@ -116,7 +141,7 @@ export const AboutAffine = () => {
|
||||
<OpenInNewIcon className="icon" />
|
||||
</a>
|
||||
<a
|
||||
className={link}
|
||||
className={styles.link}
|
||||
rel="noreferrer"
|
||||
href="https://affine.pro/terms"
|
||||
target="_blank"
|
||||
|
||||
+34
@@ -43,3 +43,37 @@ globalStyle(`${communityItem} p`, {
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
textAlign: 'center',
|
||||
});
|
||||
|
||||
export const checkUpdateDesc = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-start',
|
||||
selectors: {
|
||||
'&.active': {
|
||||
color: 'var(--affine-text-emphasis-color)',
|
||||
},
|
||||
'&.error': {
|
||||
color: 'var(--affine-error-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${checkUpdateDesc} svg`, {
|
||||
marginRight: '4px',
|
||||
});
|
||||
|
||||
export const appImageRow = style({
|
||||
flexDirection: 'row-reverse',
|
||||
selectors: {
|
||||
'&.two-col': {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${appImageRow} .right-col`, {
|
||||
paddingLeft: '0',
|
||||
paddingRight: '20px',
|
||||
});
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
import { SettingRow } from '@affine/component/setting-components';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
import {
|
||||
downloadProgressAtom,
|
||||
isCheckingForUpdatesAtom,
|
||||
updateAvailableAtom,
|
||||
updateReadyAtom,
|
||||
useAppUpdater,
|
||||
} from '@toeverything/hooks/use-app-updater';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { Loading } from '../../../../pure/workspace-slider-bar/workspace-card/loading-icon';
|
||||
import * as styles from './style.css';
|
||||
|
||||
enum CheckUpdateStatus {
|
||||
UNCHECK = 'uncheck',
|
||||
LATEST = 'latest',
|
||||
UPDATE_AVAILABLE = 'update-available',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
const useUpdateStatusLabels = (checkUpdateStatus: CheckUpdateStatus) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const isCheckingForUpdates = useAtomValue(isCheckingForUpdatesAtom);
|
||||
const updateAvailable = useAtomValue(updateAvailableAtom);
|
||||
const updateReady = useAtomValue(updateReadyAtom);
|
||||
const downloadProgress = useAtomValue(downloadProgressAtom);
|
||||
|
||||
const buttonLabel = useMemo(() => {
|
||||
if (updateAvailable && downloadProgress === null) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.button.download']();
|
||||
}
|
||||
if (updateReady) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.button.restart']();
|
||||
}
|
||||
if (
|
||||
checkUpdateStatus === CheckUpdateStatus.LATEST ||
|
||||
checkUpdateStatus === CheckUpdateStatus.ERROR
|
||||
) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.button.retry']();
|
||||
}
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.button.check']();
|
||||
}, [checkUpdateStatus, downloadProgress, t, updateAvailable, updateReady]);
|
||||
|
||||
const subtitleLabel = useMemo(() => {
|
||||
if (updateAvailable && downloadProgress === null) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.update-available']({
|
||||
version: updateAvailable.version,
|
||||
});
|
||||
} else if (isCheckingForUpdates) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.checking']();
|
||||
} else if (updateAvailable && downloadProgress !== null) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.downloading']();
|
||||
} else if (updateReady) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.restart']();
|
||||
} else if (checkUpdateStatus === CheckUpdateStatus.ERROR) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.error']();
|
||||
} else if (checkUpdateStatus === CheckUpdateStatus.LATEST) {
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.latest']();
|
||||
}
|
||||
return t['com.affine.aboutAFFiNE.checkUpdate.subtitle.check']();
|
||||
}, [
|
||||
checkUpdateStatus,
|
||||
downloadProgress,
|
||||
isCheckingForUpdates,
|
||||
t,
|
||||
updateAvailable,
|
||||
updateReady,
|
||||
]);
|
||||
|
||||
const subtitle = useMemo(() => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(styles.checkUpdateDesc, {
|
||||
active:
|
||||
updateReady ||
|
||||
(updateAvailable && downloadProgress === null) ||
|
||||
checkUpdateStatus === CheckUpdateStatus.LATEST,
|
||||
error: checkUpdateStatus === CheckUpdateStatus.ERROR,
|
||||
})}
|
||||
>
|
||||
{isCheckingForUpdates ? <Loading size={14} /> : null}
|
||||
{subtitleLabel}
|
||||
</span>
|
||||
);
|
||||
}, [
|
||||
checkUpdateStatus,
|
||||
downloadProgress,
|
||||
isCheckingForUpdates,
|
||||
subtitleLabel,
|
||||
updateAvailable,
|
||||
updateReady,
|
||||
]);
|
||||
|
||||
return { subtitle, buttonLabel };
|
||||
};
|
||||
|
||||
export const UpdateCheckSection = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const { checkForUpdates, downloadUpdate, quitAndInstall } = useAppUpdater();
|
||||
const updateAvailable = useAtomValue(updateAvailableAtom);
|
||||
const updateReady = useAtomValue(updateReadyAtom);
|
||||
const downloadProgress = useAtomValue(downloadProgressAtom);
|
||||
const [checkUpdateStatus, setCheckUpdateStatus] = useState<CheckUpdateStatus>(
|
||||
CheckUpdateStatus.UNCHECK
|
||||
);
|
||||
|
||||
const { buttonLabel, subtitle } = useUpdateStatusLabels(checkUpdateStatus);
|
||||
|
||||
const asyncCheckForUpdates = useAsyncCallback(async () => {
|
||||
let statusCheck = CheckUpdateStatus.UNCHECK;
|
||||
try {
|
||||
const status = await checkForUpdates();
|
||||
|
||||
if (status === null) {
|
||||
statusCheck = CheckUpdateStatus.ERROR;
|
||||
} else if (status === false) {
|
||||
statusCheck = CheckUpdateStatus.LATEST;
|
||||
} else if (typeof status === 'string') {
|
||||
statusCheck = CheckUpdateStatus.UPDATE_AVAILABLE;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
statusCheck = CheckUpdateStatus.ERROR;
|
||||
} finally {
|
||||
setCheckUpdateStatus(statusCheck);
|
||||
}
|
||||
}, [checkForUpdates]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (updateAvailable && downloadProgress === null) {
|
||||
return downloadUpdate();
|
||||
}
|
||||
if (updateReady) {
|
||||
return quitAndInstall();
|
||||
}
|
||||
asyncCheckForUpdates();
|
||||
}, [
|
||||
asyncCheckForUpdates,
|
||||
downloadProgress,
|
||||
downloadUpdate,
|
||||
quitAndInstall,
|
||||
updateAvailable,
|
||||
updateReady,
|
||||
]);
|
||||
|
||||
return (
|
||||
<SettingRow
|
||||
name={t['com.affine.aboutAFFiNE.checkUpdate.title']()}
|
||||
desc={subtitle}
|
||||
>
|
||||
<Button
|
||||
data-testid="check-update-button"
|
||||
onClick={handleClick}
|
||||
disabled={downloadProgress !== null && !updateReady}
|
||||
>
|
||||
{buttonLabel}
|
||||
</Button>
|
||||
</SettingRow>
|
||||
);
|
||||
};
|
||||
+1
-2
@@ -84,8 +84,7 @@ const Settings = () => {
|
||||
scrollWrapper.current.getBoundingClientRect().left -
|
||||
parseInt(wrapperComputedStyle.paddingLeft)
|
||||
: 0;
|
||||
const appeared =
|
||||
scrollWrapper.current.getAttribute('data-appeared') === 'true';
|
||||
const appeared = scrollWrapper.current.dataset.appeared === 'true';
|
||||
const animationFrameId = requestAnimationFrame(() => {
|
||||
scrollWrapper.current?.scrollTo({
|
||||
behavior: appeared ? 'smooth' : 'instant',
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ export const PlanCard = (props: PlanCardProps) => {
|
||||
{detail.benefits.map((content, i) => (
|
||||
<div key={i} className={styles.planBenefit}>
|
||||
<div className={styles.planBenefitIcon}>
|
||||
{detail.type == 'dynamic' ? (
|
||||
{detail.type === 'dynamic' ? (
|
||||
<BulledListIcon color="var(--affine-processing-color)" />
|
||||
) : (
|
||||
<DoneIcon
|
||||
|
||||
@@ -130,7 +130,7 @@ export const SettingModal = ({
|
||||
<WorkspaceSetting key={workspaceId} workspaceId={workspaceId} />
|
||||
</Suspense>
|
||||
) : null}
|
||||
{generalSettingList.find(v => v.key === activeTab) ? (
|
||||
{generalSettingList.some(v => v.key === activeTab) ? (
|
||||
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
|
||||
) : null}
|
||||
{activeTab === 'account' && loginStatus === 'authenticated' ? (
|
||||
|
||||
+9
-2
@@ -38,16 +38,20 @@ import {
|
||||
|
||||
export type UserInfoProps = {
|
||||
onAccountSettingClick: () => void;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export const UserInfo = ({
|
||||
onAccountSettingClick,
|
||||
active,
|
||||
}: UserInfoProps): ReactElement => {
|
||||
const user = useCurrentUser();
|
||||
return (
|
||||
<div
|
||||
data-testid="user-info-card"
|
||||
className={accountButton}
|
||||
className={clsx(accountButton, {
|
||||
active: active,
|
||||
})}
|
||||
onClick={onAccountSettingClick}
|
||||
>
|
||||
<Avatar size={28} name={user.name} url={user.image} className="avatar" />
|
||||
@@ -163,7 +167,10 @@ export const SettingSidebar = ({
|
||||
|
||||
{runtimeConfig.enableCloud && loginStatus === 'authenticated' ? (
|
||||
<Suspense>
|
||||
<UserInfo onAccountSettingClick={onAccountSettingClick} />
|
||||
<UserInfo
|
||||
onAccountSettingClick={onAccountSettingClick}
|
||||
active={selectedGeneralKey === 'account'}
|
||||
/>
|
||||
</Suspense>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
+5
@@ -105,6 +105,11 @@ export const accountButton = style({
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
selectors: {
|
||||
'&.active': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
globalStyle(`${accountButton} .avatar`, {
|
||||
|
||||
@@ -112,9 +112,6 @@ export const radioButton = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const spanStyle = style({
|
||||
padding: '4px 20px',
|
||||
});
|
||||
|
||||
export const disableSharePage = style({
|
||||
color: 'var(--affine-error-color)',
|
||||
|
||||
+2
-10
@@ -174,18 +174,10 @@ export const AffineSharePage = (props: ShareMenuProps) => {
|
||||
value={mode}
|
||||
onValueChange={onShareModeChange}
|
||||
>
|
||||
<RadioButton
|
||||
className={styles.radioButton}
|
||||
value={'page'}
|
||||
spanStyle={styles.spanStyle}
|
||||
>
|
||||
<RadioButton className={styles.radioButton} value={'page'}>
|
||||
{t['com.affine.pageMode.page']()}
|
||||
</RadioButton>
|
||||
<RadioButton
|
||||
className={styles.radioButton}
|
||||
value={'edgeless'}
|
||||
spanStyle={styles.spanStyle}
|
||||
>
|
||||
<RadioButton className={styles.radioButton} value={'edgeless'}>
|
||||
{t['com.affine.pageMode.edgeless']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
|
||||
+7
-41
@@ -20,23 +20,15 @@ import {
|
||||
MenuItem,
|
||||
MenuSeparator,
|
||||
} from '@toeverything/components/menu';
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
import {
|
||||
useBlockSuitePageMeta,
|
||||
usePageMetaHelper,
|
||||
} from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useBlockSuitePageMeta } from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { setPageModeAtom } from '../../../atoms';
|
||||
import { currentModeAtom } from '../../../atoms/mode';
|
||||
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
|
||||
import { useExportPage } from '../../../hooks/affine/use-export-page';
|
||||
import { useTrashModalHelper } from '../../../hooks/affine/use-trash-modal-helper';
|
||||
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
|
||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||
import { toast } from '../../../utils';
|
||||
import { PageHistoryModal } from '../../affine/page-history-modal/history-modal';
|
||||
import { HeaderDropDownButton } from '../../pure/header-drop-down-button';
|
||||
@@ -50,7 +42,6 @@ type PageMenuProps = {
|
||||
export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const ref = useRef(null);
|
||||
const { openPage } = useNavigateHelper();
|
||||
|
||||
// fixme(himself65): remove these hooks ASAP
|
||||
const [workspace] = useCurrentWorkspace();
|
||||
@@ -64,11 +55,9 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
||||
const currentMode = useAtomValue(currentModeAtom);
|
||||
const favorite = pageMeta.favorite ?? false;
|
||||
|
||||
const { setPageMeta, setPageTitle } = usePageMetaHelper(blockSuiteWorkspace);
|
||||
const { togglePageMode, toggleFavorite } =
|
||||
const { togglePageMode, toggleFavorite, duplicate } =
|
||||
useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||
const { importFile } = usePageHelper(blockSuiteWorkspace);
|
||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
||||
|
||||
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||
@@ -107,34 +96,11 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
||||
};
|
||||
|
||||
const exportHandler = useExportPage(currentPage);
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
|
||||
const duplicate = useAsyncCallback(async () => {
|
||||
const currentPageMeta = currentPage.meta;
|
||||
const newPage = createPage();
|
||||
await newPage.waitForLoaded();
|
||||
const handleDuplicate = useCallback(() => {
|
||||
duplicate(pageId);
|
||||
}, [duplicate, pageId]);
|
||||
|
||||
const update = encodeStateAsUpdate(currentPage.spaceDoc);
|
||||
applyUpdate(newPage.spaceDoc, update);
|
||||
|
||||
setPageMeta(newPage.id, {
|
||||
tags: currentPageMeta.tags,
|
||||
favorite: currentPageMeta.favorite,
|
||||
});
|
||||
setPageMode(newPage.id, currentMode);
|
||||
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
|
||||
openPage(blockSuiteWorkspace.id, newPage.id);
|
||||
}, [
|
||||
blockSuiteWorkspace.id,
|
||||
createPage,
|
||||
currentMode,
|
||||
currentPage.meta,
|
||||
currentPage.spaceDoc,
|
||||
openPage,
|
||||
setPageMeta,
|
||||
setPageMode,
|
||||
setPageTitle,
|
||||
]);
|
||||
const EditMenu = (
|
||||
<>
|
||||
<MenuItem
|
||||
@@ -199,7 +165,7 @@ export const PageMenu = ({ rename, pageId }: PageMenuProps) => {
|
||||
</MenuIcon>
|
||||
}
|
||||
data-testid="editor-option-menu-duplicate"
|
||||
onSelect={duplicate}
|
||||
onSelect={handleDuplicate}
|
||||
style={menuItemStyle}
|
||||
>
|
||||
{t['com.affine.header.option.duplicate']()}
|
||||
|
||||
+2
@@ -32,6 +32,8 @@ export const titleInput = style({
|
||||
borderRadius: '8px',
|
||||
height: '32px',
|
||||
padding: '6px 8px',
|
||||
borderColor: 'var(--affine-primary-color)',
|
||||
boxShadow: 'var(--affine-active-shadow)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -14,11 +14,14 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
||||
const { openPage, jumpToSubPath } = useNavigateHelper();
|
||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||
const pageSettings = useAtomValue(pageSettingsAtom);
|
||||
|
||||
const isPreferredEdgeless = useCallback(
|
||||
(pageId: string) => pageSettings[pageId]?.mode === 'edgeless',
|
||||
[pageSettings]
|
||||
);
|
||||
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
|
||||
const createPageAndOpen = useCallback(
|
||||
(mode?: 'page' | 'edgeless') => {
|
||||
const page = createPage();
|
||||
@@ -31,9 +34,11 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
||||
},
|
||||
[blockSuiteWorkspace.id, createPage, openPage, setPageMode]
|
||||
);
|
||||
|
||||
const createEdgelessAndOpen = useCallback(() => {
|
||||
return createPageAndOpen('edgeless');
|
||||
}, [createPageAndOpen]);
|
||||
|
||||
const importFileAndOpen = useAsyncCallback(async () => {
|
||||
const { showImportModal } = await import('@blocksuite/blocks');
|
||||
const onSuccess = (pageIds: string[], isWorkspaceFile: boolean) => {
|
||||
@@ -55,6 +60,7 @@ export const usePageHelper = (blockSuiteWorkspace: BlockSuiteWorkspace) => {
|
||||
};
|
||||
showImportModal({ workspace: blockSuiteWorkspace, onSuccess });
|
||||
}, [blockSuiteWorkspace, openPage, jumpToSubPath]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
createPage: createPageAndOpen,
|
||||
|
||||
@@ -174,10 +174,10 @@ const EditorWrapper = memo(function EditorWrapper({
|
||||
div.setAttribute('plugin-id', id);
|
||||
const cleanup = editorItem(div, editor);
|
||||
assertExists(parent);
|
||||
document.body.appendChild(div);
|
||||
document.body.append(div);
|
||||
return () => {
|
||||
cleanup();
|
||||
document.body.removeChild(div);
|
||||
div.remove();
|
||||
};
|
||||
});
|
||||
});
|
||||
@@ -235,14 +235,14 @@ const PluginContentAdapter = memo<PluginContentAdapterProps>(
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
const cleanup = windowItem(div);
|
||||
root.appendChild(div);
|
||||
root.append(div);
|
||||
if (abortController.signal.aborted) {
|
||||
cleanup();
|
||||
root.removeChild(div);
|
||||
div.remove();
|
||||
} else {
|
||||
const cl = () => {
|
||||
cleanup();
|
||||
root.removeChild(div);
|
||||
div.remove();
|
||||
};
|
||||
const dispose = addCleanup(pluginName, cl);
|
||||
abortController.signal.addEventListener('abort', () => {
|
||||
|
||||
@@ -12,6 +12,7 @@ export const searchInput = style({
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
padding: '21px 24px',
|
||||
marginBottom: '8px',
|
||||
width: '100%',
|
||||
borderBottom: '1px solid var(--affine-border-color)',
|
||||
flexShrink: 0,
|
||||
@@ -114,13 +115,6 @@ globalStyle(`${root} [cmdk-group][hidden]`, {
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
globalStyle(
|
||||
`${root} [cmdk-group]:not([hidden]):first-of-type [cmdk-group-heading]`,
|
||||
{
|
||||
paddingTop: 16,
|
||||
}
|
||||
);
|
||||
|
||||
globalStyle(`${root} [cmdk-list]`, {
|
||||
maxHeight: 400,
|
||||
minHeight: 120,
|
||||
@@ -187,3 +181,11 @@ globalStyle(
|
||||
color: 'var(--affine-error-color)',
|
||||
}
|
||||
);
|
||||
|
||||
export const resultGroupHeader = style({
|
||||
padding: '8px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.67',
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Command } from '@affine/cmdk';
|
||||
import { useCommandState } from '@affine/cmdk';
|
||||
import { formatDate } from '@affine/component/page-list';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
import type { CommandCategory } from '@toeverything/infra/command';
|
||||
import clsx from 'clsx';
|
||||
import { useAtom } from 'jotai';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { Suspense, useLayoutEffect, useMemo, useState } from 'react';
|
||||
|
||||
import {
|
||||
@@ -71,7 +72,7 @@ const QuickSearchGroup = ({
|
||||
);
|
||||
|
||||
return (
|
||||
<Command.Group key={category} heading={t[i18nkey]()}>
|
||||
<Command.Group key={category} heading={query ? '' : t[i18nkey]()}>
|
||||
{commands.map(command => {
|
||||
const label =
|
||||
typeof command.label === 'string'
|
||||
@@ -129,18 +130,43 @@ const QuickSearchCommands = ({
|
||||
}: {
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const groups = useCMDKCommandGroups();
|
||||
|
||||
return groups.map(([category, commands]) => {
|
||||
return (
|
||||
<QuickSearchGroup
|
||||
key={category}
|
||||
onOpenChange={onOpenChange}
|
||||
category={category}
|
||||
commands={commands}
|
||||
/>
|
||||
);
|
||||
});
|
||||
const query = useAtomValue(cmdkQueryAtom);
|
||||
const resultCount = useCommandState(state => state.filtered.count);
|
||||
const resultGroupHeader = useMemo(() => {
|
||||
if (query) {
|
||||
return (
|
||||
<div className={styles.resultGroupHeader}>
|
||||
{
|
||||
// hack: use resultCount to determine if it is creation or results
|
||||
// because the creation(as 2 results) is always shown at the top when there is no result
|
||||
resultCount === 2
|
||||
? t['com.affine.cmdk.affine.category.affine.creation']()
|
||||
: t['com.affine.cmdk.affine.category.results']()
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}, [query, resultCount, t]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{resultGroupHeader}
|
||||
{groups.map(([category, commands]) => {
|
||||
return (
|
||||
<QuickSearchGroup
|
||||
key={category}
|
||||
onOpenChange={onOpenChange}
|
||||
category={category}
|
||||
commands={commands}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CMDKContainer = ({
|
||||
|
||||
@@ -25,12 +25,12 @@ export const PluginHeader = () => {
|
||||
div.setAttribute('plugin-id', pluginName);
|
||||
startTransition(() => {
|
||||
const cleanup = create(div);
|
||||
root.appendChild(div);
|
||||
root.append(div);
|
||||
addCleanup(pluginName, () => {
|
||||
pluginsRef.current = pluginsRef.current.filter(
|
||||
name => name !== pluginName
|
||||
);
|
||||
root.removeChild(div);
|
||||
div.remove();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const filterTab = style({
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
fontWeight: 600,
|
||||
textTransform: 'capitalize',
|
||||
minWidth: '91px',
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useAtom } from 'jotai';
|
||||
|
||||
import { allPageModeSelectAtom } from '../../../atoms';
|
||||
import * as styles from './index.css';
|
||||
|
||||
export const WorkspaceModeFilterTab = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
@@ -15,16 +16,14 @@ export const WorkspaceModeFilterTab = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<RadioButtonGroup
|
||||
width={300}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
<RadioButton value="all" style={{ textTransform: 'capitalize' }}>
|
||||
<RadioButtonGroup value={value} onValueChange={handleValueChange}>
|
||||
<RadioButton value="all" spanStyle={styles.filterTab}>
|
||||
{t['com.affine.pageMode.all']()}
|
||||
</RadioButton>
|
||||
<RadioButton value="page">{t['com.affine.pageMode.page']()}</RadioButton>
|
||||
<RadioButton value="edgeless">
|
||||
<RadioButton spanStyle={styles.filterTab} value="page">
|
||||
{t['com.affine.pageMode.page']()}
|
||||
</RadioButton>
|
||||
<RadioButton spanStyle={styles.filterTab} value="edgeless">
|
||||
{t['com.affine.pageMode.edgeless']()}
|
||||
</RadioButton>
|
||||
</RadioButtonGroup>
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ const CollectionRenderer = ({
|
||||
async (id: string) => {
|
||||
await setting.updateCollection({
|
||||
...collection,
|
||||
allowList: collection.allowList?.filter(v => v != id),
|
||||
allowList: collection.allowList?.filter(v => v !== id),
|
||||
});
|
||||
},
|
||||
[collection, setting]
|
||||
|
||||
@@ -20,6 +20,11 @@ import type { Page } from '@blocksuite/store';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
import {
|
||||
isAutoCheckUpdateAtom,
|
||||
isAutoDownloadUpdateAtom,
|
||||
useAppUpdater,
|
||||
} from '@toeverything/hooks/use-app-updater';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import type { HTMLAttributes, ReactElement } from 'react';
|
||||
import { forwardRef, useCallback, useEffect, useMemo } from 'react';
|
||||
@@ -102,6 +107,10 @@ export const RootAppSidebar = ({
|
||||
}: RootAppSidebarProps): ReactElement => {
|
||||
const currentWorkspaceId = currentWorkspace.id;
|
||||
const { appSettings } = useAppSettingHelper();
|
||||
const { toggleAutoCheck, toggleAutoDownload } = useAppUpdater();
|
||||
const { autoCheckUpdate, autoDownloadUpdate } = appSettings;
|
||||
const isAutoDownload = useAtomValue(isAutoDownloadUpdateAtom);
|
||||
const isAutoCheck = useAtomValue(isAutoCheckUpdateAtom);
|
||||
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
|
||||
const t = useAFFiNEI18N();
|
||||
const [openUserWorkspaceList, setOpenUserWorkspaceList] = useAtom(
|
||||
@@ -150,6 +159,26 @@ export const RootAppSidebar = ({
|
||||
}
|
||||
}, [sidebarOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!environment.isDesktop) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isAutoCheck !== autoCheckUpdate) {
|
||||
toggleAutoCheck(autoCheckUpdate);
|
||||
}
|
||||
if (isAutoDownload !== autoDownloadUpdate) {
|
||||
toggleAutoDownload(autoDownloadUpdate);
|
||||
}
|
||||
}, [
|
||||
autoCheckUpdate,
|
||||
autoDownloadUpdate,
|
||||
isAutoCheck,
|
||||
isAutoDownload,
|
||||
toggleAutoCheck,
|
||||
toggleAutoDownload,
|
||||
]);
|
||||
|
||||
const [history, setHistory] = useHistoryAtom();
|
||||
const router = useMemo(() => {
|
||||
return {
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useAsyncCallback } from '@toeverything/hooks/affine-async-hooks';
|
||||
import {
|
||||
useBlockSuitePageMeta,
|
||||
usePageMetaHelper,
|
||||
} from '@toeverything/hooks/use-block-suite-page-meta';
|
||||
import { useBlockSuiteWorkspaceHelper } from '@toeverything/hooks/use-block-suite-workspace-helper';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useCallback } from 'react';
|
||||
import { applyUpdate, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { setPageModeAtom } from '../../atoms';
|
||||
import { currentModeAtom } from '../../atoms/mode';
|
||||
import type { BlockSuiteWorkspace } from '../../shared';
|
||||
import { getWorkspaceSetting } from '../../utils/workspace-setting';
|
||||
import { useNavigateHelper } from '../use-navigate-helper';
|
||||
import { useReferenceLinkHelper } from './use-reference-link-helper';
|
||||
|
||||
export function useBlockSuiteMetaHelper(
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace
|
||||
) {
|
||||
const { setPageMeta, getPageMeta, setPageReadonly } =
|
||||
const { setPageMeta, getPageMeta, setPageReadonly, setPageTitle } =
|
||||
usePageMetaHelper(blockSuiteWorkspace);
|
||||
const { addReferenceLink } = useReferenceLinkHelper(blockSuiteWorkspace);
|
||||
const metas = useBlockSuitePageMeta(blockSuiteWorkspace);
|
||||
const setPageMode = useSetAtom(setPageModeAtom);
|
||||
const currentMode = useAtomValue(currentModeAtom);
|
||||
const { createPage } = useBlockSuiteWorkspaceHelper(blockSuiteWorkspace);
|
||||
const { openPage } = useNavigateHelper();
|
||||
|
||||
const switchToPageMode = useCallback(
|
||||
(pageId: string) => {
|
||||
@@ -79,7 +85,7 @@ export function useBlockSuiteMetaHelper(
|
||||
|
||||
setPageMeta(pageId, {
|
||||
trash: true,
|
||||
trashDate: +new Date(),
|
||||
trashDate: Date.now(),
|
||||
trashRelate: isRoot ? parentMeta?.id : undefined,
|
||||
});
|
||||
setPageReadonly(pageId, true);
|
||||
@@ -140,6 +146,40 @@ export function useBlockSuiteMetaHelper(
|
||||
[setPageMeta]
|
||||
);
|
||||
|
||||
const duplicate = useAsyncCallback(
|
||||
async (pageId: string) => {
|
||||
const currentPageMeta = getPageMeta(pageId);
|
||||
const newPage = createPage();
|
||||
const currentPage = blockSuiteWorkspace.getPage(pageId);
|
||||
|
||||
await newPage.waitForLoaded();
|
||||
if (!currentPageMeta || !currentPage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const update = encodeStateAsUpdate(currentPage.spaceDoc);
|
||||
applyUpdate(newPage.spaceDoc, update);
|
||||
|
||||
setPageMeta(newPage.id, {
|
||||
tags: currentPageMeta.tags,
|
||||
favorite: currentPageMeta.favorite,
|
||||
});
|
||||
setPageMode(newPage.id, currentMode);
|
||||
setPageTitle(newPage.id, `${currentPageMeta.title}(1)`);
|
||||
openPage(blockSuiteWorkspace.id, newPage.id);
|
||||
},
|
||||
[
|
||||
blockSuiteWorkspace,
|
||||
createPage,
|
||||
currentMode,
|
||||
getPageMeta,
|
||||
openPage,
|
||||
setPageMeta,
|
||||
setPageMode,
|
||||
setPageTitle,
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
switchToPageMode,
|
||||
switchToEdgelessMode,
|
||||
@@ -155,5 +195,7 @@ export function useBlockSuiteMetaHelper(
|
||||
removeToTrash,
|
||||
restoreFromTrash,
|
||||
permanentlyDeletePage,
|
||||
|
||||
duplicate,
|
||||
};
|
||||
}
|
||||
|
||||
+15
-1
@@ -41,7 +41,7 @@ export function useRegisterBlocksuiteEditorCommands(
|
||||
}));
|
||||
}, [pageId, setPageHistoryModalState]);
|
||||
|
||||
const { togglePageMode, toggleFavorite, restoreFromTrash } =
|
||||
const { togglePageMode, toggleFavorite, restoreFromTrash, duplicate } =
|
||||
useBlockSuiteMetaHelper(blockSuiteWorkspace);
|
||||
const exportHandler = useExportPage(currentPage);
|
||||
const { setTrashModal } = useTrashModalHelper(blockSuiteWorkspace);
|
||||
@@ -125,6 +125,19 @@ export function useRegisterBlocksuiteEditorCommands(
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: `editor:${mode}-duplicate`,
|
||||
preconditionStrategy,
|
||||
category: `editor:${mode}`,
|
||||
icon: mode === 'page' ? <PageIcon /> : <EdgelessIcon />,
|
||||
label: t['com.affine.header.option.duplicate'](),
|
||||
run() {
|
||||
duplicate(pageId);
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: `editor:${mode}-export-to-pdf`,
|
||||
@@ -234,5 +247,6 @@ export function useRegisterBlocksuiteEditorCommands(
|
||||
trash,
|
||||
isCloudWorkspace,
|
||||
openHistoryModal,
|
||||
duplicate,
|
||||
]);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user