feat!: affine cloud support (#3813)

Co-authored-by: Hongtao Lye <codert.sn@gmail.com>
Co-authored-by: liuyi <forehalo@gmail.com>
Co-authored-by: LongYinan <lynweklm@gmail.com>
Co-authored-by: X1a0t <405028157@qq.com>
Co-authored-by: JimmFly <yangjinfei001@gmail.com>
Co-authored-by: Peng Xiao <pengxiao@outlook.com>
Co-authored-by: xiaodong zuo <53252747+zuoxiaodong0815@users.noreply.github.com>
Co-authored-by: DarkSky <25152247+darkskygit@users.noreply.github.com>
Co-authored-by: Qi <474021214@qq.com>
Co-authored-by: danielchim <kahungchim@gmail.com>
This commit is contained in:
Alex Yang 2023-08-29 05:07:05 -05:00 committed by GitHub
parent d0145c6f38
commit 2f6c4e3696
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
414 changed files with 19469 additions and 7591 deletions

View File

@ -9,3 +9,5 @@ ENABLE_NEW_SETTING_UNSTABLE_API=
ENABLE_NOTIFICATION_CENTER=
ENABLE_CLOUD=
ENABLE_MOVE_DATABASE=
SHOULD_REPORT_TRACE=
TRACE_REPORT_ENDPOINT=

View File

@ -31,6 +31,12 @@ const createPattern = packageName => [
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['next-auth/react'],
message: "Import hooks from 'use-current-user.tsx'",
// useSession is type unsafe
importNames: ['useSession'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',
@ -160,6 +166,12 @@ const config = {
message: 'Use `useNavigateHelper` instead',
importNames: ['useNavigate'],
},
{
group: ['next-auth/react'],
message: "Import hooks from 'use-current-user.tsx'",
// useSession is type unsafe
importNames: ['useSession'],
},
{
group: ['yjs'],
message: 'Do not use this API because it has a bug',

View File

@ -4,6 +4,9 @@ inputs:
target:
description: 'Cargo target'
required: true
package:
description: 'Package to build'
required: true
nx_token:
description: 'Nx Cloud access token'
required: false
@ -31,7 +34,7 @@ runs:
if: ${{ inputs.target != 'x86_64-unknown-linux-gnu' && inputs.target != 'aarch64-unknown-linux-gnu' }}
shell: bash
run: |
yarn nx build @affine/native --target ${{ inputs.target }}
yarn nx build ${{ inputs.package }} --target ${{ inputs.target }}
env:
NX_CLOUD_ACCESS_TOKEN: ${{ inputs.nx_token }}
@ -44,7 +47,8 @@ runs:
run: |
export CC=x86_64-unknown-linux-gnu-gcc
export CC_x86_64_unknown_linux_gnu=x86_64-unknown-linux-gnu-gcc
yarn nx build @affine/native --target ${{ inputs.target }}
export RUSTFLAGS="-C debuginfo=1"
yarn nx build ${{ inputs.package }} --target ${{ inputs.target }}
chmod -R 777 node_modules/.cache
chmod -R 777 target
@ -55,6 +59,7 @@ runs:
image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
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: |
yarn nx build @affine/native --target ${{ inputs.target }}
export RUSTFLAGS="-C debuginfo=1"
yarn nx build ${{ inputs.package }} --target ${{ inputs.target }}
chmod -R 777 node_modules/.cache
chmod -R 777 target

50
.github/actions/deploy/action.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: 'Deploy to Cluster'
description: 'Deploy AFFiNE Cloud to cluster'
inputs:
build-type:
description: 'Align with App build type, canary|beta|stable|internal'
default: 'canary'
gcp-project-number:
description: 'GCP project number'
required: true
gcp-project-id:
description: 'GCP project id'
required: true
service-account:
description: 'Service account'
cluster-name:
description: 'Cluster name'
cluster-location:
description: 'Cluster location'
runs:
using: 'composite'
steps:
- name: Setup Git short hash
shell: bash
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
- uses: azure/setup-helm@v3
- id: auth
uses: google-github-actions/auth@v1
with:
workload_identity_provider: 'projects/${{ inputs.gcp-project-number }}/locations/global/workloadIdentityPools/github-actions/providers/github-actions-helm-deploy'
service_account: '${{ inputs.service-account }}'
token_format: 'access_token'
project_id: '${{ inputs.gcp-project-id }}'
- name: 'Setup gcloud cli'
uses: 'google-github-actions/setup-gcloud@v1'
with:
install_components: 'gke-gcloud-auth-plugin'
- id: get-gke-credentials
shell: bash
run: |
gcloud container clusters get-credentials ${{ inputs.cluster-name }} --region ${{ inputs.cluster-location }} --project ${{ inputs.gcp-project-id }}
- name: Deploy
shell: bash
run: node ./.github/actions/deploy/deploy.mjs
env:
BUILD_TYPE: '${{ inputs.build-type }}'

116
.github/actions/deploy/deploy.mjs vendored Normal file
View File

@ -0,0 +1,116 @@
import { execSync } from 'node:child_process';
const {
BUILD_TYPE,
DEPLOY_HOST,
CANARY_DEPLOY_HOST,
GIT_SHORT_HASH,
DATABASE_URL,
DATABASE_USERNAME,
DATABASE_PASSWORD,
DATABASE_NAME,
R2_ACCOUNT_ID,
R2_ACCESS_KEY_ID,
R2_SECRET_ACCESS_KEY,
R2_BUCKET,
OAUTH_EMAIL_SENDER,
OAUTH_EMAIL_LOGIN,
OAUTH_EMAIL_PASSWORD,
AFFINE_GOOGLE_CLIENT_ID,
AFFINE_GOOGLE_CLIENT_SECRET,
CLOUD_SQL_IAM_ACCOUNT,
GCLOUD_CONNECTION_NAME,
GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT,
REDIS_HOST,
REDIS_PASSWORD,
} = process.env;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const buildType = BUILD_TYPE || 'canary';
const isProduction = buildType === 'stable';
const isBeta = buildType === 'beta';
const createHelmCommand = ({ isDryRun }) => {
const flag = isDryRun ? '--dry-run' : '--atomic';
const imageTag = `${buildType}-${GIT_SHORT_HASH}`;
const staticIpName = isProduction
? 'affine-cluster-production'
: isBeta
? 'affine-cluster-beta'
: 'affine-cluster-dev';
const redisAndPostgres =
isProduction || isBeta
? [
`--set-string global.database.url=${DATABASE_URL}`,
`--set-string global.database.user=${DATABASE_USERNAME}`,
`--set-string global.database.password=${DATABASE_PASSWORD}`,
`--set-string global.database.name=${DATABASE_NAME}`,
`--set global.database.gcloud.enabled=true`,
`--set-string global.database.gcloud.connectionName="${GCLOUD_CONNECTION_NAME}"`,
`--set-string global.database.gcloud.cloudSqlInternal="${GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT}"`,
`--set-string global.redis.host="${REDIS_HOST}"`,
`--set-string global.redis.password="${REDIS_PASSWORD}"`,
]
: [];
const serviceAnnotations =
isProduction || isBeta
? [
`--set-json web.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json graphql.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`,
`--set-json graphql.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
`--set-json sync.serviceAccount.annotations=\"{ \\"iam.gke.io/gcp-service-account\\": \\"${CLOUD_SQL_IAM_ACCOUNT}\\" }\"`,
`--set-json sync.service.annotations=\"{ \\"cloud.google.com/neg\\": \\"{\\\\\\"ingress\\\\\\": true}\\" }\"`,
]
: [];
const webReplicaCount = isProduction ? 3 : isBeta ? 2 : 1;
const graphqlReplicaCount = isProduction ? 3 : isBeta ? 2 : 1;
const syncReplicaCount = isProduction ? 6 : isBeta ? 3 : 1;
const namespace = isProduction ? 'production' : isBeta ? 'beta' : 'dev';
const deployCommand = [
`helm upgrade --install affine .github/helm/affine`,
`--namespace ${namespace}`,
`--set global.ingress.enabled=true`,
`--set-json global.ingress.annotations=\"{ \\"kubernetes.io/ingress.class\\": \\"gce\\", \\"kubernetes.io/ingress.allow-http\\": \\"true\\", \\"kubernetes.io/ingress.global-static-ip-name\\": \\"${staticIpName}\\" }\"`,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
`--set-string global.ingress.host="${DEPLOY_HOST || CANARY_DEPLOY_HOST}"`,
...redisAndPostgres,
`--set web.replicaCount=${webReplicaCount}`,
`--set-string web.image.tag="${imageTag}"`,
`--set graphql.replicaCount=${graphqlReplicaCount}`,
`--set-string graphql.image.tag="${imageTag}"`,
`--set graphql.app.objectStorage.r2.enabled=true`,
`--set-string graphql.app.objectStorage.r2.accountId="${R2_ACCOUNT_ID}"`,
`--set-string graphql.app.objectStorage.r2.accessKeyId="${R2_ACCESS_KEY_ID}"`,
`--set-string graphql.app.objectStorage.r2.secretAccessKey="${R2_SECRET_ACCESS_KEY}"`,
`--set-string graphql.app.objectStorage.r2.bucket="${R2_BUCKET}"`,
`--set-string graphql.app.oauth.email.sender="${OAUTH_EMAIL_SENDER}"`,
`--set-string graphql.app.oauth.email.login="${OAUTH_EMAIL_LOGIN}"`,
`--set-string graphql.app.oauth.email.password="${OAUTH_EMAIL_PASSWORD}"`,
`--set-string graphql.app.oauth.google.enabled=true`,
`--set-string graphql.app.oauth.google.clientId="${AFFINE_GOOGLE_CLIENT_ID}"`,
`--set-string graphql.app.oauth.google.clientSecret="${AFFINE_GOOGLE_CLIENT_SECRET}"`,
`--set graphql.app.experimental.enableJwstCodec=true`,
`--set sync.replicaCount=${syncReplicaCount}`,
`--set-string sync.image.tag="${imageTag}"`,
...serviceAnnotations,
`--version "0.0.0-${buildType}.${GIT_SHORT_HASH}" --timeout 10m`,
flag,
].join(' ');
return deployCommand;
};
const output = execSync(createHelmCommand({ isDryRun: true }), {
encoding: 'utf-8',
stdio: ['inherit', 'pipe', 'inherit'],
});
const templates = output
.split('---')
.filter(yml => !yml.split('\n').some(line => line.trim() === 'kind: Secret'))
.join('---');
console.log(templates);
execSync(createHelmCommand({ isDryRun: false }), {
encoding: 'utf-8',
stdio: 'inherit',
});

View File

@ -1,31 +0,0 @@
name: 'AFFiNE Rust setup'
description: 'Rust setup, including cache configuration'
inputs:
target:
description: 'Cargo target'
required: true
toolchain:
description: 'Rustup toolchain'
required: false
default: 'stable'
runs:
using: 'composite'
steps:
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ inputs.toolchain }}
targets: ${{ inputs.target }}
- name: Cache cargo
uses: actions/cache@v3
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: cargo-cache-${{ runner.os }}-${{ inputs.toolchain }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
cargo-cache-${{ runner.os }}-${{ inputs.toolchain }}-

View File

@ -3,7 +3,7 @@ server {
root /app/dist;
location / {
try_files $uri $uri/index.html $uri.html =404;
try_files $uri $uri/ /index.html;
}
error_page 404 /404.html;

View File

@ -40,6 +40,7 @@ helm.sh/chart: {{ include "graphql.chart" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
@ -75,58 +76,3 @@ key: {{ $secret.data.private }}
key: {{ genPrivateKey "ecdsa" | b64enc }}
{{- end -}}
{{- end -}}
{{- define "objectStorage.r2" -}}
{{- $secret := lookup "v1" "Secret" .Release.Namespace .Values.app.objectStorage.r2.secretName -}}
{{- if $secret -}}
{{/*
Reusing existing secret data
*/}}
accountId: {{ $secret.data.accountId }}
accessKeyId: {{ $secret.data.accessKeyId }}
secretAccessKey: {{ $secret.data.secretAccessKey }}
bucket: {{ $secret.data.bucket }}
{{- else -}}
{{/*
Generate new data
*/}}
accountId: {{ .Values.app.objectStorage.r2.accountId | b64enc }}
accessKeyId: {{ .Values.app.objectStorage.r2.accessKeyId | b64enc }}
secretAccessKey: {{ .Values.app.objectStorage.r2.secretAccessKey | b64enc }}
bucket: {{ .Values.app.objectStorage.r2.bucket | b64enc }}
{{- end -}}
{{- end -}}
{{- define "objectStorage.oauth.google" -}}
{{- $secret := lookup "v1" "Secret" .Release.Namespace .Values.app.oauth.google.secretName -}}
{{- if $secret -}}
{{/*
Reusing existing secret data
*/}}
clientId: {{ $secret.data.clientId }}
clientSecret: {{ $secret.data.clientSecret }}
{{- else -}}
{{/*
Generate new data
*/}}
clientId: "{{ .Values.app.oauth.google.clientId | b64enc }}"
clientSecret: "{{ .Values.app.oauth.google.clientSecret | b64enc }}"
{{- end -}}
{{- end -}}
{{- define "objectStorage.oauth.github" -}}
{{- $secret := lookup "v1" "Secret" .Release.Namespace .Values.app.oauth.github.secretName -}}
{{- if $secret -}}
{{/*
Reusing existing secret data
*/}}
clientId: {{ $secret.data.clientId }}
clientSecret: {{ $secret.data.clientSecret }}
{{- else -}}
{{/*
Generate new data
*/}}
clientId: "{{ .Values.app.oauth.github.clientId | b64enc }}"
clientSecret: "{{ .Values.app.oauth.github.clientSecret | b64enc }}"
{{- end -}}
{{- end -}}

View File

@ -35,13 +35,36 @@ spec:
key: key
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: DATABSE_PASSWORD
- name: NO_COLOR
value: "1"
- name: SERVER_FLAVOR
value: "graphql"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: NEXTAUTH_URL
value: "{{ .Values.global.ingress.host }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.database.user }}:$(DATABSE_PASSWORD)@{{ .Values.database.url }}:{{ .Values.database.port }}/{{ .Values.database.name }}
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_SERVER_PORT
value: "{{ .Values.service.port }}"
- name: AFFINE_SERVER_SUB_PATH
@ -50,6 +73,37 @@ spec:
value: "{{ .Values.app.host }}"
- name: ENABLE_R2_OBJECT_STORAGE
value: "{{ .Values.app.objectStorage.r2.enabled }}"
- name: OAUTH_EMAIL_SENDER
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.email.secretName }}"
key: sender
- name: OAUTH_EMAIL_LOGIN
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.email.secretName }}"
key: login
- name: OAUTH_EMAIL_SERVER
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.email.secretName }}"
key: server
- name: OAUTH_EMAIL_PORT
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.email.secretName }}"
key: port
- name: OAUTH_EMAIL_PASSWORD
valueFrom:
secretKeyRef:
name: "{{ .Values.app.oauth.email.secretName }}"
key: password
- name: DOC_MERGE_INTERVAL
value: "{{ .Values.app.doc.mergeInterval }}"
{{ if .Values.app.experimental.enableJwstCodec }}
- name: DOC_MERGE_USE_JWST_CODEC
value: "true"
{{ end }}
{{ if .Values.app.objectStorage.r2.enabled }}
- name: R2_OBJECT_STORAGE_ACCOUNT_ID
valueFrom:
@ -112,6 +166,20 @@ spec:
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{ if .Values.global.database.gcloud.enabled }}
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.6.0
args:
- "--structured-logs"
- "--auto-iam-authn"
- "{{ .Values.global.database.gcloud.connectionName }}"
securityContext:
runAsNonRoot: true
resources:
requests:
memory: "2Gi"
cpu: "1"
{{ end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}

View File

@ -5,13 +5,14 @@ metadata:
labels:
{{- include "graphql.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": pre-install,pre-upgrade
"helm.sh/hook": post-install,pre-upgrade
"helm.sh/hook-weight": "-1"
"helm.sh/hook-delete-policy": before-hook-creation
spec:
template:
spec:
serviceAccountName: {{ include "graphql.serviceAccountName" . }}
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
@ -19,13 +20,21 @@ spec:
env:
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: DATABSE_PASSWORD
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
{{ if not .Values.global.database.gcloud.enabled }}
- name: DATABASE_URL
value: postgres://{{ .Values.database.user }}:$(DATABSE_PASSWORD)@{{ .Values.database.url }}:{{ .Values.database.port }}/{{ .Values.database.name }}
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
{{ end }}
{{ if .Values.global.database.gcloud.enabled }}
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.gcloud.cloudSqlInternal }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
{{ end }}
resources:
requests:
cpu: '100m'

View File

@ -0,0 +1,13 @@
{{- if .Values.global.gke.enabled -}}
apiVersion: monitoring.googleapis.com/v1
kind: PodMonitoring
metadata:
name: "{{ .Chart.Name }}-monitoring"
spec:
selector:
matchLabels:
app.kubernetes.io/name: "{{ include "graphql.name" . }}"
endpoints:
- port: {{ .Values.service.port }}
interval: 30s
{{- end }}

View File

@ -1,10 +0,0 @@
{{- if .Values.app.oauth.github.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.oauth.github.secretName }}"
type: Opaque
data:
{{- ( include "objectStorage.oauth.github" . ) | indent 2 -}}
{{- end }}

View File

@ -1,10 +0,0 @@
{{- if .Values.app.oauth.google.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.oauth.google.secretName }}"
type: Opaque
data:
{{- ( include "objectStorage.oauth.google" . ) | indent 2 -}}
{{- end }}

View File

@ -0,0 +1,33 @@
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.oauth.email.secretName }}"
type: Opaque
data:
sender: "{{ .Values.app.oauth.email.sender | b64enc }}"
login: "{{ .Values.app.oauth.email.login | b64enc }}"
password: "{{ .Values.app.oauth.email.password | b64enc }}"
server: "{{ .Values.app.oauth.email.server | b64enc }}"
port: "{{ .Values.app.oauth.email.port | b64enc }}"
---
{{- if .Values.app.oauth.google.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.oauth.google.secretName }}"
type: Opaque
data:
clientId: "{{ .Values.app.oauth.google.clientId | b64enc }}"
clientSecret: "{{ .Values.app.oauth.google.clientSecret | b64enc }}"
{{- end }}
---
{{- if .Values.app.oauth.github.enabled -}}
apiVersion: v1
kind: Secret
metadata:
name: "{{ .Values.app.oauth.github.secretName }}"
type: Opaque
data:
clientId: "{{ .Values.app.oauth.github.clientId | b64enc }}"
clientSecret: "{{ .Values.app.oauth.github.clientSecret | b64enc }}"
{{- end }}

View File

@ -0,0 +1,9 @@
{{- if .Values.global.database.password -}}
apiVersion: v1
kind: Secret
metadata:
name: pg-postgresql
type: Opaque
data:
postgres-password: {{ .Values.global.database.password | b64enc }}
{{- end }}

View File

@ -5,5 +5,8 @@ metadata:
name: "{{ .Values.app.objectStorage.r2.secretName }}"
type: Opaque
data:
{{- ( include "objectStorage.r2" . ) | indent 2 -}}
accountId: {{ .Values.app.objectStorage.r2.accountId | b64enc }}
accessKeyId: {{ .Values.app.objectStorage.r2.accessKeyId | b64enc }}
secretAccessKey: {{ .Values.app.objectStorage.r2.secretAccessKey | b64enc }}
bucket: {{ .Values.app.objectStorage.r2.bucket | b64enc }}
{{- end }}

View File

@ -0,0 +1,9 @@
{{- if .Values.global.redis.password -}}
apiVersion: v1
kind: Secret
metadata:
name: redis
type: Opaque
data:
redis-password: {{ .Values.global.redis.password | b64enc }}
{{- end }}

View File

@ -9,16 +9,15 @@ nameOverride: ''
fullnameOverride: ''
# map to NODE_ENV environment variable
env: 'production'
database:
user: 'postgres'
url: 'pg-postgresql'
port: '5432'
name: 'affine'
app:
experimental:
enableJwstCodec: true
# AFFINE_SERVER_SUB_PATH
path: ''
# AFFINE_SERVER_HOST
host: '0.0.0.0'
doc:
mergeInterval: "3000"
jwt:
secretName: jwt-private-key
# base64 encoded ecdsa private key
@ -32,6 +31,13 @@ app:
secretAccessKey: ''
bucket: ''
oauth:
email:
secretName: 'oauth-email'
sender: 'noreply@toeverything.info'
login: ''
password: ''
server: 'smtp.gmail.com'
port: '465'
google:
enabled: false
secretName: oauth-google
@ -55,11 +61,11 @@ podSecurityContext:
resources:
limits:
cpu: '2000m'
memory: 4Gi
cpu: '4'
memory: 8Gi
requests:
cpu: '1000m'
memory: 2Gi
cpu: '2'
memory: 4Gi
probe:
initialDelaySeconds: 20

View File

@ -0,0 +1,23 @@
# Patterns to ignore when building packages.
# This supports shell glob matching, relative path matching, and
# negation (prefixed with !). Only one pattern per line.
.DS_Store
# Common VCS dirs
.git/
.gitignore
.bzr/
.bzrignore
.hg/
.hgignore
.svn/
# Common backup files
*.swp
*.bak
*.tmp
*.orig
*~
# Various IDEs
.project
.idea/
*.tmproj
.vscode/

View File

@ -0,0 +1,6 @@
apiVersion: v2
name: sync
description: A Helm chart for Kubernetes
type: application
version: 0.0.0
appVersion: "0.7.0-canary.18"

View File

@ -0,0 +1,16 @@
1. Get the application URL by running these commands:
{{- if contains "NodePort" .Values.service.type }}
export NODE_PORT=$(kubectl get --namespace {{ .Release.Namespace }} -o jsonpath="{.spec.ports[0].nodePort}" services {{ include "sync.fullname" . }})
export NODE_IP=$(kubectl get nodes --namespace {{ .Release.Namespace }} -o jsonpath="{.items[0].status.addresses[0].address}")
echo http://$NODE_IP:$NODE_PORT
{{- else if contains "LoadBalancer" .Values.service.type }}
NOTE: It may take a few minutes for the LoadBalancer IP to be available.
You can watch the status of by running 'kubectl get --namespace {{ .Release.Namespace }} svc -w {{ include "sync.fullname" . }}'
export SERVICE_IP=$(kubectl get svc --namespace {{ .Release.Namespace }} {{ include "sync.fullname" . }} --template "{{"{{ range (index .status.loadBalancer.ingress 0) }}{{.}}{{ end }}"}}")
echo http://$SERVICE_IP:{{ .Values.service.port }}
{{- else if contains "ClusterIP" .Values.service.type }}
export POD_NAME=$(kubectl get pods --namespace {{ .Release.Namespace }} -l "app.kubernetes.io/name={{ include "sync.name" . }},app.kubernetes.io/instance={{ .Release.Name }}" -o jsonpath="{.items[0].metadata.name}")
export CONTAINER_PORT=$(kubectl get pod --namespace {{ .Release.Namespace }} $POD_NAME -o jsonpath="{.spec.containers[0].ports[0].containerPort}")
echo "Visit http://127.0.0.1:8080 to use your application"
kubectl --namespace {{ .Release.Namespace }} port-forward $POD_NAME 8080:$CONTAINER_PORT
{{- end }}

View File

@ -0,0 +1,63 @@
{{/*
Expand the name of the chart.
*/}}
{{- define "sync.name" -}}
{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Create a default fully qualified app name.
We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec).
If release name contains chart name it will be used as a full name.
*/}}
{{- define "sync.fullname" -}}
{{- if .Values.fullnameOverride }}
{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- $name := default .Chart.Name .Values.nameOverride }}
{{- if contains $name .Release.Name }}
{{- .Release.Name | trunc 63 | trimSuffix "-" }}
{{- else }}
{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }}
{{- end }}
{{- end }}
{{- end }}
{{/*
Create chart name and version as used by the chart label.
*/}}
{{- define "sync.chart" -}}
{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }}
{{- end }}
{{/*
Common labels
*/}}
{{- define "sync.labels" -}}
helm.sh/chart: {{ include "sync.chart" . }}
{{ include "sync.selectorLabels" . }}
{{- if .Chart.AppVersion }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*
Selector labels
*/}}
{{- define "sync.selectorLabels" -}}
app.kubernetes.io/name: {{ include "sync.name" . }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end }}
{{/*
Create the name of the service account to use
*/}}
{{- define "sync.serviceAccountName" -}}
{{- if .Values.serviceAccount.create }}
{{- default (include "sync.fullname" .) .Values.serviceAccount.name }}
{{- else }}
{{- default "default" .Values.serviceAccount.name }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,110 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "sync.fullname" . }}
labels:
{{- include "sync.labels" . | nindent 4 }}
spec:
replicas: {{ .Values.replicaCount }}
selector:
matchLabels:
{{- include "sync.selectorLabels" . | nindent 6 }}
template:
metadata:
{{- with .Values.podAnnotations }}
annotations:
{{- toYaml . | nindent 8 }}
{{- end }}
labels:
{{- include "sync.selectorLabels" . | nindent 8 }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
serviceAccountName: {{ include "sync.serviceAccountName" . }}
securityContext:
{{- toYaml .Values.podSecurityContext | nindent 8 }}
containers:
- name: {{ .Chart.Name }}
securityContext:
{{- toYaml .Values.securityContext | nindent 12 }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
env:
- name: NODE_ENV
value: "{{ .Values.env }}"
- name: NO_COLOR
value: "1"
- name: SERVER_FLAVOR
value: "sync"
- name: NEXTAUTH_URL
value: "{{ .Values.global.ingress.host }}"
- name: AFFINE_ENV
value: "{{ .Release.Namespace }}"
- name: DATABASE_PASSWORD
valueFrom:
secretKeyRef:
name: pg-postgresql
key: postgres-password
- name: DATABASE_URL
value: postgres://{{ .Values.global.database.user }}:$(DATABASE_PASSWORD)@{{ .Values.global.database.url }}:{{ .Values.global.database.port }}/{{ .Values.global.database.name }}
- name: REDIS_SERVER_ENABLED
value: "true"
- name: REDIS_SERVER_HOST
value: "{{ .Values.global.redis.host }}"
- name: REDIS_SERVER_PORT
value: "{{ .Values.global.redis.port }}"
- name: REDIS_SERVER_USER
value: "{{ .Values.global.redis.username }}"
- name: REDIS_SERVER_PASSWORD
valueFrom:
secretKeyRef:
name: redis
key: redis-password
- name: REDIS_SERVER_DATABASE
value: "{{ .Values.global.redis.database }}"
- name: AFFINE_SERVER_PORT
value: "{{ .Values.service.port }}"
- name: AFFINE_SERVER_HOST
value: "{{ .Values.app.host }}"
ports:
- name: http
containerPort: {{ .Values.service.port }}
protocol: TCP
livenessProbe:
tcpSocket:
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
readinessProbe:
tcpSocket:
port: http
initialDelaySeconds: {{ .Values.probe.initialDelaySeconds }}
resources:
{{- toYaml .Values.resources | nindent 12 }}
{{ if .Values.global.database.gcloud.enabled }}
- name: cloud-sql-proxy
image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:2.6.0
args:
- "--structured-logs"
- "--auto-iam-authn"
- "{{ .Values.global.database.gcloud.connectionName }}"
securityContext:
runAsNonRoot: true
resources:
requests:
memory: "2Gi"
cpu: "1"
{{ end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}

View File

@ -0,0 +1,13 @@
{{- if .Values.global.gke.enabled -}}
apiVersion: monitoring.googleapis.com/v1
kind: PodMonitoring
metadata:
name: "{{ .Chart.Name }}-monitoring"
spec:
selector:
matchLabels:
app.kubernetes.io/name: "{{ include "sync.name" . }}"
endpoints:
- port: {{ .Values.service.port }}
interval: 30s
{{- end }}

View File

@ -0,0 +1,19 @@
apiVersion: v1
kind: Service
metadata:
name: {{ include "sync.fullname" . }}
labels:
{{- include "sync.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "sync.selectorLabels" . | nindent 4 }}

View File

@ -0,0 +1,12 @@
{{- if .Values.serviceAccount.create -}}
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ include "sync.serviceAccountName" . }}
labels:
{{- include "sync.labels" . | nindent 4 }}
{{- with .Values.serviceAccount.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- end }}

View File

@ -0,0 +1,15 @@
apiVersion: v1
kind: Pod
metadata:
name: "{{ include "sync.fullname" . }}-test-connection"
labels:
{{- include "sync.labels" . | nindent 4 }}
annotations:
"helm.sh/hook": test
spec:
containers:
- name: wget
image: busybox
command: ['wget']
args: ['{{ include "sync.fullname" . }}:{{ .Values.service.port }}']
restartPolicy: Never

View File

@ -0,0 +1,39 @@
replicaCount: 1
image:
repository: ghcr.io/toeverything/affine-graphql
pullPolicy: IfNotPresent
tag: ''
imagePullSecrets: []
nameOverride: ''
fullnameOverride: ''
# map to NODE_ENV environment variable
env: 'production'
app:
# AFFINE_SERVER_HOST
host: '0.0.0.0'
serviceAccount:
create: true
annotations: {}
name: 'affine-sync'
podAnnotations: {}
podSecurityContext:
fsGroup: 2000
resources:
limits:
cpu: '4'
memory: 8Gi
requests:
cpu: '2'
memory: 4Gi
probe:
initialDelaySeconds: 20
nodeSelector: {}
tolerations: []
affinity: {}

View File

@ -40,6 +40,7 @@ helm.sh/chart: {{ include "web.chart" . }}
app.kubernetes.io/version: {{ .Chart.AppVersion | quote }}
{{- end }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
monitoring: enabled
{{- end }}
{{/*

View File

@ -1,8 +1,8 @@
{{- if .Values.ingress.enabled -}}
{{- if .Values.global.ingress.enabled -}}
{{- $fullName := include "affine.fullname" . -}}
{{- if and .Values.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- if and .Values.global.ingress.className (not (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion)) }}
{{- if not (hasKey .Values.global.ingress.annotations "kubernetes.io/ingress.class") }}
{{- $_ := set .Values.global.ingress.annotations "kubernetes.io/ingress.class" .Values.ingress.className}}
{{- end }}
{{- end }}
{{- if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}}
@ -17,17 +17,17 @@ metadata:
name: {{ $fullName }}
labels:
{{- include "affine.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
{{- with .Values.global.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- if and .Values.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.ingress.className }}
{{- if and .Values.global.ingress.className (semverCompare ">=1.18-0" .Capabilities.KubeVersion.GitVersion) }}
ingressClassName: {{ .Values.global.ingress.className }}
{{- end }}
{{- if .Values.ingress.tls }}
{{- if .Values.global.ingress.tls }}
tls:
{{- range .Values.ingress.tls }}
{{- range .Values.global.ingress.tls }}
- hosts:
{{- range .hosts }}
- {{ . | quote }}
@ -36,9 +36,16 @@ spec:
{{- end }}
{{- end }}
rules:
- host: "{{ .Values.ingress.host }}"
- host: "{{ .Values.global.ingress.host }}"
http:
paths:
- path: /socket.io
pathType: Prefix
backend:
service:
name: affine-sync
port:
number: {{ .Values.sync.service.port }}
- path: /graphql
pathType: Prefix
backend:
@ -60,5 +67,4 @@ spec:
name: affine-web
port:
number: {{ .Values.web.service.port }}
{{- end }}

View File

@ -1,16 +1,43 @@
global:
ingress:
enabled: false
className: ''
annotations:
kubernetes.io/ingress.class: nginx
host: affine.pro
tls: []
database:
user: 'postgres'
url: 'pg-postgresql'
port: '5432'
name: 'affine'
password: ''
gcloud:
enabled: false
# use for migration
cloudSqlInternal: ''
connectionName: ''
serviceAccount: ''
redis:
enabled: true
host: 'redis-master'
port: '6379'
username: ''
password: ''
database: 0
gke:
enabled: true
graphql:
service:
type: ClusterIP
port: 3000
sync:
service:
type: ClusterIP
port: 3010
annotations:
cloud.google.com/backend-config: '{"default": "affine-backendconfig"}'
web:
service:
type: ClusterIP

View File

@ -105,6 +105,7 @@ jobs:
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.spec.target }}
package: '@affine/native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Run unit tests
if: ${{ matrix.spec.test }}

View File

@ -29,6 +29,7 @@ env:
DEBUG: napi:*
BUILD_TYPE: canary
APP_NAME: affine
AFFINE_ENV: dev
COVERAGE: true
DISTRIBUTION: browser
MACOSX_DEPLOYMENT_TARGET: '10.13'
@ -113,30 +114,11 @@ jobs:
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
build-storybook:
name: Build Storybook
runs-on: ubuntu-latest
environment: development
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
- run: yarn nx build @affine/storybook
env:
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload storybook artifact
uses: actions/upload-artifact@v3
with:
name: storybook
path: ./apps/storybook/storybook-static
if-no-files-found: error
build-storage:
name: Build Storage
runs-on: ubuntu-latest
env:
RUSTFLAGS: '-C debuginfo=1'
environment: development
steps:
@ -144,11 +126,11 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Setup Rust
uses: ./.github/actions/setup-rust
uses: ./.github/actions/build-rust
with:
target: 'x86_64-unknown-linux-gnu'
- name: Build Storage
run: yarn build:storage
package: '@affine/storage'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload storage.node
uses: actions/upload-artifact@v3
with:
@ -216,6 +198,81 @@ jobs:
name: affine
fail_ci_if_error: false
server-e2e-test:
name: Server E2E Test
runs-on: ubuntu-latest
environment: development
needs: build-storage
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: affine
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 5432:5432
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
playwright-install: true
- name: Initialize database
run: |
psql -h localhost -U postgres -c "CREATE DATABASE affine;"
psql -h localhost -U postgres -c "CREATE USER affine WITH PASSWORD 'affine';"
psql -h localhost -U postgres -c "ALTER USER affine WITH SUPERUSER;"
env:
PGPASSWORD: affine
- name: Generate prisma client
run: |
yarn exec prisma generate
yarn exec prisma db push
working-directory: apps/server
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Run init-db script
run: yarn exec ts-node-esm ./scripts/init-db.ts
working-directory: apps/server
env:
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Download storage.node
uses: actions/download-artifact@v3
with:
name: storage.node
path: ./apps/server
- name: Run playwright tests
run: yarn e2e --forbid-only
working-directory: tests/affine-cloud
env:
COVERAGE: true
DATABASE_URL: postgresql://affine:affine@localhost:5432/affine
- name: Collect code coverage report
run: yarn exec nyc report -t .nyc_output --report-dir .coverage --reporter=lcov
- name: Upload e2e test coverage results
uses: codecov/codecov-action@v3
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./.coverage/lcov.info
flags: server-e2etest
name: affine
fail_ci_if_error: false
- name: Upload test results
if: ${{ failure() }}
uses: actions/upload-artifact@v3
with:
name: test-results-e2e-server
path: ./tests/affine-cloud/test-results
if-no-files-found: ignore
e2e-plugin-test:
name: E2E Plugin Test
runs-on: ubuntu-latest

211
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,211 @@
name: Deploy
on:
push:
branches:
- master
workflow_dispatch:
inputs:
flavor:
description: 'Build type (canary, beta, internal or stable)'
type: string
default: canary
env:
BUILD_TYPE: canary
APP_NAME: affine
NX_CLOUD_ACCESS_TOKEN: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
jobs:
build-server:
name: Build Server
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: ./.github/actions/setup-node
with:
electron-install: false
- name: Build Server
run: yarn workspace @affine/server build
- name: Upload server dist
uses: actions/upload-artifact@v3
with:
name: server-dist
path: ./apps/server/dist
if-no-files-found: error
build-core:
name: Build @affine/core
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v3
- 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
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 }}
SHOULD_REPORT_TRACE: true
TRACE_REPORT_ENDPOINT: ${{ secrets.TRACE_REPORT_ENDPOINT }}
- name: Upload core artifact
uses: actions/upload-artifact@v3
with:
name: core
path: ./apps/core/dist
if-no-files-found: error
build-storage:
name: Build Storage
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
steps:
- uses: actions/checkout@v3
- 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/storage/storage.node
if-no-files-found: error
build-docker:
name: Build Docker
runs-on: ubuntu-latest
environment: ${{ github.event.inputs.flavor }}
needs:
- build-server
- build-core
- build-storage
steps:
- uses: actions/checkout@v3
- name: Download core artifact
uses: actions/download-artifact@v3
with:
name: core
path: ./apps/core/dist
- name: Download server dist
uses: actions/download-artifact@v3
with:
name: server-dist
path: ./apps/server/dist
- name: Download storage.node
uses: actions/download-artifact@v3
with:
name: storage.node
path: ./apps/server
- name: Setup env
run: |
echo "GIT_SHORT_HASH=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
if [ -z "${{ inputs.flavor }}" ]
then
echo "RELEASE_FLAVOR=canary" >> "$GITHUB_ENV"
else
echo "RELEASE_FLAVOR=${{ inputs.flavor }}" >> "$GITHUB_ENV"
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
logout: false
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Build front Dockerfile
uses: docker/build-push-action@v4
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.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-front:${{env.RELEASE_FLAVOR}}
# setup node without cache configuration
# Prisma cache is not compatible with docker build cache
- name: Setup Node.js
uses: actions/setup-node@v3
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@v4
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.RELEASE_FLAVOR}}-${{ env.GIT_SHORT_HASH }},ghcr.io/toeverything/affine-front:${{env.RELEASE_FLAVOR}}
deploy:
name: Deploy to cluster
environment: ${{ github.event.inputs.flavor }}
permissions:
contents: 'write'
id-token: 'write'
needs:
- build-docker
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Deploy to dev
uses: ./.github/actions/deploy
with:
build-type: ${{ github.event.inputs.flavor }}
gcp-project-number: ${{ secrets.GCP_PROJECT_NUMBER }}
gcp-project-id: ${{ secrets.GCP_PROJECT_ID }}
service-account: ${{ secrets.GCP_HELM_DEPLOY_SERVICE_ACCOUNT }}
cluster-name: ${{ secrets.GCP_CLUSTER_NAME }}
cluster-location: ${{ secrets.GCP_CLUSTER_LOCATION }}
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
CANARY_DEPLOY_HOST: ${{ secrets.CANARY_DEPLOY_HOST }}
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 }}
R2_BUCKET: ${{ secrets.R2_BUCKET }}
OAUTH_EMAIL_SENDER: ${{ secrets.OAUTH_EMAIL_SENDER }}
OAUTH_EMAIL_LOGIN: ${{ secrets.OAUTH_EMAIL_LOGIN }}
OAUTH_EMAIL_PASSWORD: ${{ secrets.OAUTH_EMAIL_PASSWORD }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
AFFINE_GOOGLE_CLIENT_ID: ${{ secrets.AFFINE_GOOGLE_CLIENT_ID }}
AFFINE_GOOGLE_CLIENT_SECRET: ${{ secrets.AFFINE_GOOGLE_CLIENT_SECRET }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
DATABASE_USERNAME: ${{ secrets.DATABASE_USERNAME }}
DATABASE_PASSWORD: ${{ secrets.DATABASE_PASSWORD }}
DATABASE_NAME: ${{ secrets.DATABASE_NAME }}
GCLOUD_CONNECTION_NAME: ${{ secrets.GCLOUD_CONNECTION_NAME }}
GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT: ${{ secrets.GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT }}
REDIS_HOST: ${{ secrets.REDIS_HOST }}
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }}

View File

@ -12,7 +12,6 @@ on:
- .github/**
- '!.github/workflows/nightly-build.yml'
- '!.github/actions/build-rust/action.yml'
- '!.github/actions/setup-rust/action.yml'
- '!.github/actions/setup-node/action.yml'
permissions:
@ -114,6 +113,7 @@ jobs:
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.spec.target }}
package: '@affine/native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Replace Version
run: ./scripts/set-version.sh ${{ needs.set-build-version.outputs.version }}

View File

@ -1,5 +1,8 @@
name: Publish Storybook
env:
NODE_OPTIONS: --max-old-space-size=4096
on:
push:
branches:
@ -34,5 +37,7 @@ jobs:
with:
workingDir: apps/storybook
buildScriptName: build
onlyChanged: true
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
zip: true
env:
NODE_OPTIONS: ${{ env.NODE_OPTIONS }}

View File

@ -112,6 +112,7 @@ jobs:
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.spec.target }}
package: '@affine/native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- uses: actions/download-artifact@v3
with:
@ -182,6 +183,7 @@ jobs:
uses: ./.github/actions/build-rust
with:
target: ${{ matrix.spec.target }}
package: '@affine/native'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- uses: actions/download-artifact@v3
with:

View File

@ -76,11 +76,11 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-node
- name: Setup Rust
uses: ./.github/actions/setup-rust
uses: ./.github/actions/build-rust
with:
target: 'x86_64-unknown-linux-gnu'
- name: Build Storage
run: yarn build:storage
package: '@affine/storage'
nx_token: ${{ secrets.NX_CLOUD_ACCESS_TOKEN }}
- name: Upload storage.node
uses: actions/upload-artifact@v3
with:

3
.gitignore vendored
View File

@ -19,7 +19,7 @@
node_modules
# IDEs and editors
/.idea
**/.idea
.project
.classpath
.c9/
@ -77,3 +77,4 @@ target
tsconfig.node.tsbuildinfo
lib
affine.db
apps/web/next-routes.conf

View File

@ -14,3 +14,4 @@ _next
storybook-static
web-static
public
apps/server/src/schema.gql

View File

@ -2,16 +2,24 @@
"version": "0.2.0",
"configurations": [
{
"command": "yarn run dev",
"name": "Run Dev",
"type": "node-terminal",
"request": "launch",
"type": "node-terminal"
"command": "yarn run dev"
},
{
"command": "yarn run dev:local",
"name": "Run Dev Locally",
"type": "node-terminal",
"request": "launch",
"type": "node-terminal"
"command": "yarn run dev:local"
},
{
"name": "Launch AFFiNE Cloud",
"type": "node",
"request": "launch",
"runtimeExecutable": "yarn",
"cwd": "${workspaceFolder}",
"runtimeArgs": ["workspace", "@affine/server", "dev"]
}
]
}

3261
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,10 @@
[workspace]
members = ["./packages/native", "./packages/native/schema"]
resolver = "2"
members = [
"./packages/native",
"./packages/native/schema",
"./packages/storage",
]
[profile.dev.package.sqlx-macros]
opt-level = 3
@ -9,3 +14,7 @@ lto = true
codegen-units = 1
opt-level = 3
strip = "symbols"
[patch.crates-io]
lib0 = { git = "https://github.com/toeverything/y-crdt", rev = "a700f09" }
yrs = { git = "https://github.com/toeverything/y-crdt", rev = "a700f09" }

409
LICENSE
View File

@ -1,384 +1,25 @@
# Mozilla Public License Version 2.0
Copyright (c) TOEVERYTHING PTE. LTD. and its affiliates.
1. Definitions
---
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
---
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
---
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
---
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
---
- *
- 6. Disclaimer of Warranty \*
- ------------------------- \*
- *
- Covered Software is provided under this License on an "as is" \*
- basis, without warranty of any kind, either expressed, implied, or \*
- statutory, including, without limitation, warranties that the \*
- Covered Software is free of defects, merchantable, fit for a \*
- particular purpose or non-infringing. The entire risk as to the \*
- quality and performance of the Covered Software is with You. \*
- Should any Covered Software prove defective in any respect, You \*
- (not any Contributor) assume the cost of any necessary servicing, \*
- repair, or correction. This disclaimer of warranty constitutes an \*
- essential part of this License. No use of any Covered Software is \*
- authorized under this License except under this disclaimer. \*
- *
---
---
- *
- 7. Limitation of Liability \*
- -------------------------- \*
- *
- Under no circumstances and under no legal theory, whether tort \*
- (including negligence), contract, or otherwise, shall any \*
- Contributor, or anyone who distributes Covered Software as \*
- permitted above, be liable to You for any direct, indirect, \*
- special, incidental, or consequential damages of any character \*
- including, without limitation, damages for lost profits, loss of \*
- goodwill, work stoppage, computer failure or malfunction, or any \*
- and all other commercial damages or losses, even if such party \*
- shall have been informed of the possibility of such damages. This \*
- limitation of liability shall not apply to liability for death or \*
- personal injury resulting from such party's negligence to the \*
- extent applicable law prohibits such limitation. Some \*
- jurisdictions do not allow the exclusion or limitation of \*
- incidental or consequential damages, so this exclusion and \*
- limitation may not apply to You. \*
- *
---
8. Litigation
---
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
---
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
## Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
## Exhibit B - "Incompatible With Secondary Licenses" Notice
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
Copyright (c) 2022-present TOEVERYTHING PTE. LTD. and its affiliates.
Portions of this software are licensed as follows:
* All content that resides under the "apps/server" directory of this repository, if that directory exists, is licensed under the license defined in "apps/server/LICENSE".
* All third party components incorporated into the AFFiNE Software are licensed under the original license provided by the owner of the applicable component.
* Content outside of the above mentioned directories or restrictions above is available under the "MPL2.0" license as defined in "LICENSE-MPL2.0".
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

384
LICENSE-MPL2.0 Normal file
View File

@ -0,0 +1,384 @@
# Mozilla Public License Version 2.0
Copyright (c) TOEVERYTHING PTE. LTD. and its affiliates.
1. Definitions
---
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
---
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
---
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
---
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
---
- *
- 6. Disclaimer of Warranty \*
- ------------------------- \*
- *
- Covered Software is provided under this License on an "as is" \*
- basis, without warranty of any kind, either expressed, implied, or \*
- statutory, including, without limitation, warranties that the \*
- Covered Software is free of defects, merchantable, fit for a \*
- particular purpose or non-infringing. The entire risk as to the \*
- quality and performance of the Covered Software is with You. \*
- Should any Covered Software prove defective in any respect, You \*
- (not any Contributor) assume the cost of any necessary servicing, \*
- repair, or correction. This disclaimer of warranty constitutes an \*
- essential part of this License. No use of any Covered Software is \*
- authorized under this License except under this disclaimer. \*
- *
---
---
- *
- 7. Limitation of Liability \*
- -------------------------- \*
- *
- Under no circumstances and under no legal theory, whether tort \*
- (including negligence), contract, or otherwise, shall any \*
- Contributor, or anyone who distributes Covered Software as \*
- permitted above, be liable to You for any direct, indirect, \*
- special, incidental, or consequential damages of any character \*
- including, without limitation, damages for lost profits, loss of \*
- goodwill, work stoppage, computer failure or malfunction, or any \*
- and all other commercial damages or losses, even if such party \*
- shall have been informed of the possibility of such damages. This \*
- limitation of liability shall not apply to liability for death or \*
- personal injury resulting from such party's negligence to the \*
- extent applicable law prohibits such limitation. Some \*
- jurisdictions do not allow the exclusion or limitation of \*
- incidental or consequential damages, so this exclusion and \*
- limitation may not apply to You. \*
- *
---
8. Litigation
---
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
---
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
## Exhibit A - Source Code Form License Notice
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
## Exhibit B - "Incompatible With Secondary Licenses" Notice
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

View File

@ -3,11 +3,15 @@ function testPackageName(regexp: RegExp): (module: any) => boolean {
module.nameForCondition && regexp.test(module.nameForCondition());
}
// https://hackernoon.com/the-100-correct-way-to-split-your-chunks-with-webpack-f8a9df5b7758
export const productionCacheGroups = {
asyncVendor: {
test: /[\\/]node_modules[\\/]/,
name(module: any) {
// https://hackernoon.com/the-100-correct-way-to-split-your-chunks-with-webpack-f8a9df5b7758
// monorepo linked in node_modules, so it's not a npm package
if (!module.context.includes('node_modules')) {
return `app-async`;
}
const name = module.context.match(
/[\\/]node_modules[\\/](.*?)([\\/]|$)/
)?.[1];

View File

@ -1,6 +1,7 @@
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import type { Configuration as DevServerConfiguration } from 'webpack-dev-server';
import { PerfseePlugin } from '@perfsee/webpack';
import { sentryWebpackPlugin } from '@sentry/webpack-plugin';
@ -10,13 +11,14 @@ import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import TerserPlugin from 'terser-webpack-plugin';
import webpack from 'webpack';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { compact } from 'lodash-es';
import { productionCacheGroups } from './cache-group.js';
import type { BuildFlags } from '@affine/cli/config';
import { projectRoot } from '@affine/cli/config';
import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin';
import { computeCacheKey } from './utils.js';
import type { RuntimeConfig } from '@affine/env/global';
import { WebpackS3Plugin, gitShortHash } from './s3-plugin.js';
const IN_CI = !!process.env.CI;
@ -67,16 +69,26 @@ const OptimizeOptionOptions: (
},
});
export const publicPath = (function () {
const { BUILD_TYPE } = process.env;
const publicPath = process.env.PUBLIC_PATH ?? '/';
if (process.env.COVERAGE) {
return publicPath;
}
if (BUILD_TYPE === 'canary') {
return `https://dev.affineassets.com/${gitShortHash()}/`;
} else if (BUILD_TYPE === 'beta' || BUILD_TYPE === 'stable') {
return `https://prod.affineassets.com/${gitShortHash()}/`;
}
return publicPath;
})();
export const createConfiguration: (
buildFlags: BuildFlags,
runtimeConfig: RuntimeConfig
) => webpack.Configuration = (buildFlags, runtimeConfig) => {
let publicPath = process.env.PUBLIC_PATH ?? '/';
const blocksuiteBaseDir = buildFlags.localBlockSuite;
const cacheKey = computeCacheKey(buildFlags);
const config = {
name: 'affine',
// to set a correct base path for the source map
@ -96,8 +108,11 @@ export const createConfiguration: (
? 'js/[name]-[contenthash:8].js'
: 'js/[name].js',
// In some cases webpack will emit files starts with "_" which is reserved in web extension.
chunkFilename: 'js/chunk.[name].js',
assetModuleFilename: 'assets/[contenthash:8][ext][query]',
chunkFilename:
buildFlags.mode === 'production'
? 'js/chunk.[name]-[contenthash:8].js'
: 'js/chunk.[name].js',
assetModuleFilename: 'assets/[name]-[contenthash:8][ext][query]',
devtoolModuleFilenameTemplate: 'webpack://[namespace]/[resource-path]',
hotUpdateChunkFilename: 'hot/[id].[fullhash].js',
hotUpdateMainFilename: 'hot/[runtime].[fullhash].json',
@ -192,14 +207,6 @@ export const createConfiguration: (
},
},
cache: {
type: 'filesystem',
buildDependencies: {
config: [fileURLToPath(import.meta.url)],
},
version: cacheKey,
},
module: {
parser: {
javascript: {
@ -308,7 +315,7 @@ export const createConfiguration: (
{
loader: 'css-loader',
options: {
url: false,
url: true,
sourceMap: false,
modules: false,
import: true,
@ -333,22 +340,23 @@ export const createConfiguration: (
},
],
},
plugins: [
...(IN_CI ? [] : [new webpack.ProgressPlugin({ percentBy: 'entries' })]),
...(buildFlags.mode === 'development'
? [new ReactRefreshWebpackPlugin({ overlay: false, esModule: true })]
: [
new MiniCssExtractPlugin({
plugins: compact([
IN_CI ? null : new webpack.ProgressPlugin({ percentBy: 'entries' }),
buildFlags.mode === 'development'
? new ReactRefreshWebpackPlugin({ overlay: false, esModule: true })
: new MiniCssExtractPlugin({
filename: `[name].[contenthash:8].css`,
ignoreOrder: true,
}),
]),
new VanillaExtractPlugin(),
new webpack.DefinePlugin({
'process.env': JSON.stringify({}),
'process.env.COVERAGE': JSON.stringify(!!buildFlags.coverage),
'process.env.NODE_ENV': JSON.stringify(buildFlags.mode),
'process.env.SHOULD_REPORT_TRACE': `${Boolean(
process.env.SHOULD_REPORT_TRACE
)}`,
'process.env.TRACE_REPORT_ENDPOINT': `"${process.env.TRACE_REPORT_ENDPOINT}"`,
runtimeConfig: JSON.stringify(runtimeConfig),
}),
new CopyPlugin({
@ -359,7 +367,10 @@ export const createConfiguration: (
},
],
}),
],
buildFlags.mode === 'production' && process.env.R2_SECRET_ACCESS_KEY
? new WebpackS3Plugin()
: null,
]),
optimization: OptimizeOptionOptions(buildFlags),
@ -373,6 +384,14 @@ export const createConfiguration: (
publicPath: '/',
watch: true,
},
proxy: {
'/api': 'http://localhost:3010',
'/socket.io': {
target: 'http://localhost:3010',
ws: true,
},
'/graphql': 'http://localhost:3010',
},
} as DevServerConfiguration,
} satisfies webpack.Configuration;

View File

@ -24,9 +24,9 @@ const editorFlags: BlockSuiteFeatureFlags = {
};
export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
const buildPreset: Record<string, RuntimeConfig> = {
const buildPreset: Record<BuildFlags['channel'], RuntimeConfig> = {
stable: {
enablePlugin: false,
enablePlugin: true,
enableTestProperties: false,
enableBroadcastChannelProvider: true,
enableDebugPage: true,
@ -37,13 +37,26 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableNewSettingUnstableApi: false,
enableSQLiteProvider: true,
enableMoveDatabase: false,
enableNotificationCenter: false,
enableCloud: false,
serverAPI: 'https://localhost:3010',
enableNotificationCenter: true,
enableCloud: true,
enableEnhanceShareMode: false,
serverUrlPrefix: 'https://app.affine.pro',
editorFlags,
appVersion: packageJson.version,
editorVersion: packageJson.dependencies['@blocksuite/editor'],
},
get beta() {
return {
...this.stable,
serverUrlPrefix: 'https://ambassador.affine.pro',
};
},
get internal() {
return {
...this.stable,
serverUrlPrefix: 'https://affine.fail',
};
},
// canary will be aggressive and enable all features
canary: {
enablePlugin: true,
@ -58,18 +71,15 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableSQLiteProvider: true,
enableMoveDatabase: false,
enableNotificationCenter: true,
enableCloud: false,
serverAPI: 'https://localhost:3010',
enableCloud: true,
enableEnhanceShareMode: false,
serverUrlPrefix: 'https://affine.fail',
editorFlags,
appVersion: packageJson.version,
editorVersion: packageJson.dependencies['@blocksuite/editor'],
},
};
// beta and internal versions are the same as stable
buildPreset.beta = buildPreset.stable;
buildPreset.internal = buildPreset.stable;
const currentBuild = buildFlags.channel;
if (!(currentBuild in buildPreset)) {
@ -107,11 +117,18 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableCloud: process.env.ENABLE_CLOUD
? process.env.ENABLE_CLOUD === 'true'
: currentBuildPreset.enableCloud,
enableEnhanceShareMode: process.env.ENABLE_ENHANCE_SHARE_MODE
? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true'
: currentBuildPreset.enableEnhanceShareMode,
enableMoveDatabase: process.env.ENABLE_MOVE_DATABASE
? process.env.ENABLE_MOVE_DATABASE === 'true'
: currentBuildPreset.enableMoveDatabase,
};
if (buildFlags.mode === 'development') {
currentBuildPreset.serverUrlPrefix = 'http://localhost:8080';
}
return {
...currentBuildPreset,
// environment preset will overwrite current build preset

View File

@ -0,0 +1,58 @@
import { join } from 'node:path';
import { execSync } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import type { PutObjectCommandInput } from '@aws-sdk/client-s3';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { once } from 'lodash-es';
import { lookup } from 'mime-types';
import type { Compiler, WebpackPluginInstance } from 'webpack';
export const gitShortHash = once(() => {
const { GITHUB_SHA } = process.env;
if (GITHUB_SHA) {
return GITHUB_SHA.substring(0, 9);
}
const sha = execSync(`git rev-parse --short HEAD`, {
encoding: 'utf-8',
}).trim();
return sha;
});
export const R2_BUCKET =
process.env.R2_BUCKET! ??
(process.env.BUILD_TYPE === 'canary' ? 'assets-dev' : 'assets-prod');
export class WebpackS3Plugin implements WebpackPluginInstance {
private readonly s3 = new S3Client({
region: 'auto',
endpoint: `https://${process.env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.R2_ACCESS_KEY_ID!,
secretAccessKey: process.env.R2_SECRET_ACCESS_KEY!,
},
});
apply(compiler: Compiler) {
compiler.hooks.assetEmitted.tapPromise(
'WebpackS3Plugin',
async (asset, { outputPath }) => {
if (asset === 'index.html') {
return;
}
const assetPath = join(outputPath, asset);
const assetSource = await readFile(assetPath);
const putObjectCommandOptions: PutObjectCommandInput = {
Body: assetSource,
Bucket: R2_BUCKET,
Key: join(gitShortHash(), asset),
};
const contentType = lookup(asset);
if (contentType) {
putObjectCommandOptions.ContentType = contentType;
}
await this.s3.send(new PutObjectCommand(putObjectCommandOptions));
}
);
}
}

View File

@ -17,14 +17,14 @@
<meta name="twitter:url" content="https://app.affine.pro/" />
<meta
name="twitter:title"
content="AFFiNEThere can be more than Notion and Miro."
content="AFFiNE: There can be more than Notion and Miro."
/>
<meta name="twitter:description" content="{description}" />
<meta name="twitter:site" content="@AffineOfficial" />
<meta name="twitter:image" content="https://affine.pro/og.jpeg" />
<meta
property="og:title"
content="AFFiNEThere can be more than Notion and Miro."
content="AFFiNE: There can be more than Notion and Miro."
/>
<meta property="og:type" content="website" />
<meta
@ -39,7 +39,8 @@
href="https://affine.pro/favicon.ico"
/>
</head>
<body>
<div id="app"></div>
<div id="app" data-version="<%= GIT_SHORT_SHA %>"></div>
</body>
</html>

View File

@ -1,10 +1,12 @@
import { createConfiguration, rootPath } from './config.js';
import { createConfiguration, rootPath, publicPath } from './config.js';
import { merge } from 'webpack-merge';
import { join, resolve } from 'node:path';
import type { BuildFlags } from '@affine/cli/config';
import { getRuntimeConfig } from './runtime-config.js';
import HTMLPlugin from 'html-webpack-plugin';
import { gitShortHash } from './s3-plugin.js';
export default async function (cli_env: any, _: any) {
const flags: BuildFlags = JSON.parse(
Buffer.from(cli_env.flags, 'hex').toString('utf-8')
@ -44,12 +46,16 @@ export default async function (cli_env: any, _: any) {
minify: false,
chunks: ['app', 'plugin', 'polyfill/intl-segmenter', 'polyfill/ses'],
filename: 'index.html',
templateParameters: {
GIT_SHORT_SHA: gitShortHash(),
},
}),
new HTMLPlugin({
template: join(rootPath, '.webpack', 'template.html'),
inject: 'body',
scriptLoading: 'module',
minify: false,
publicPath,
chunks: [
'_plugin/index.test',
'plugin',
@ -57,6 +63,9 @@ export default async function (cli_env: any, _: any) {
'polyfill/ses',
],
filename: '_plugin/index.html',
templateParameters: {
GIT_SHORT_SHA: gitShortHash(),
},
}),
],
});

View File

@ -38,6 +38,7 @@
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.14.6",
"@radix-ui/react-select": "^1.2.2",
"@react-hookz/web": "^23.1.0",
"@toeverything/components": "^0.0.19",
"async-call-rpc": "^6.3.1",
@ -52,6 +53,7 @@
"lodash.debounce": "^4.0.8",
"lottie-web": "^5.12.2",
"mini-css-extract-plugin": "^2.7.6",
"next-auth": "^4.22.1",
"next-themes": "^0.2.1",
"postcss-loader": "^7.3.3",
"react": "18.2.0",
@ -62,22 +64,27 @@
"rxjs": "^7.8.1",
"ses": "^0.18.7",
"swr": "2.2.1",
"valtio": "^1.10.6",
"y-protocols": "^1.0.5",
"yjs": "^13.6.7",
"zod": "^3.22.2"
},
"devDependencies": {
"@aws-sdk/client-s3": "3.400.0",
"@perfsee/webpack": "^1.8.4",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@sentry/webpack-plugin": "^2.7.0",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.3.80",
"@types/lodash-es": "^4.17.8",
"@types/lodash.debounce": "^4.0.7",
"@types/webpack-env": "^1.18.1",
"copy-webpack-plugin": "^11.0.0",
"css-loader": "^6.8.1",
"express": "^4.18.2",
"html-webpack-plugin": "^5.5.3",
"lodash-es": "^4.17.21",
"mime-types": "^2.1.35",
"raw-loader": "^4.0.2",
"source-map-loader": "^4.0.1",
"style-loader": "^3.3.3",

View File

@ -10,20 +10,18 @@
"target": "build",
"params": "ignore"
},
{
"projects": ["tag:infra"],
"target": "build",
"params": "ignore"
},
"^build"
],
"inputs": [
"{projectRoot}/.webpack/**/*",
"{projectRoot}/**/*",
"{projectRoot}/public/**/*",
"{workspaceRoot}/packages/env/src/**/*",
"{workspaceRoot}/packages/component/src/**/*",
"{workspaceRoot}/packages/debug/src/**/*",
"{workspaceRoot}/packages/graphql/src/**/*",
"{workspaceRoot}/packages/hooks/src/**/*",
"{workspaceRoot}/packages/jotai/src/**/*",
"{workspaceRoot}/packages/templates/src/**/*",
"{workspaceRoot}/packages/workspace/src/**/*",
"{workspaceRoot}/packages/**/*",
{
"env": "BUILD_TYPE"
},

View File

@ -0,0 +1,164 @@
import type {
AffineCloudWorkspace,
WorkspaceCRUD,
} from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import {
createWorkspaceMutation,
deleteWorkspaceMutation,
getWorkspaceQuery,
getWorkspacesQuery,
} from '@affine/graphql';
import { fetcher } from '@affine/workspace/affine/gql';
import { getOrCreateWorkspace } from '@affine/workspace/manager';
import { createIndexeddbStorage, Workspace } from '@blocksuite/store';
import { migrateLocalBlobStorage } from '@toeverything/infra/blocksuite';
import {
createIndexedDBProvider,
DEFAULT_DB_NAME,
} from '@toeverything/y-indexeddb';
import { getSession } from 'next-auth/react';
import { proxy } from 'valtio/vanilla';
const Y = Workspace.Y;
async function deleteLocalBlobStorage(id: string) {
const storage = createIndexeddbStorage(id);
const keys = await storage.crud.list();
for (const key of keys) {
await storage.crud.delete(key);
}
}
// we don't need to persistence the state into local storage
// because if a user clicks create multiple time and nothing happened
// because of the server delay or something, he/she will wait.
// and also the user journey of creating workspace is long.
const createdWorkspaces = proxy<string[]>([]);
export const CRUD: WorkspaceCRUD<WorkspaceFlavour.AFFINE_CLOUD> = {
create: async blockSuiteWorkspace => {
if (createdWorkspaces.some(id => id === blockSuiteWorkspace.id)) {
throw new Error('workspace already created');
}
const { createWorkspace } = await fetcher({
query: createWorkspaceMutation,
variables: {
init: new File(
[Y.encodeStateAsUpdate(blockSuiteWorkspace.doc)],
'initBinary.yDoc'
),
},
});
createdWorkspaces.push(blockSuiteWorkspace.id);
const newBLockSuiteWorkspace = getOrCreateWorkspace(
createWorkspace.id,
WorkspaceFlavour.AFFINE_CLOUD
);
Y.applyUpdate(
newBLockSuiteWorkspace.doc,
Y.encodeStateAsUpdate(blockSuiteWorkspace.doc)
);
await Promise.all(
[...blockSuiteWorkspace.doc.subdocs].map(async subdoc => {
subdoc.load();
return subdoc.whenLoaded.then(() => {
newBLockSuiteWorkspace.doc.subdocs.forEach(newSubdoc => {
if (newSubdoc.guid === subdoc.guid) {
Y.applyUpdate(newSubdoc, Y.encodeStateAsUpdate(subdoc));
}
});
});
})
);
const provider = createIndexedDBProvider(
newBLockSuiteWorkspace.doc,
DEFAULT_DB_NAME
);
provider.connect();
migrateLocalBlobStorage(blockSuiteWorkspace.id, createWorkspace.id)
.then(() => deleteLocalBlobStorage(blockSuiteWorkspace.id))
.catch(e => {
console.error('error when moving blob storage:', e);
});
// todo(himself65): delete old workspace in the future
return createWorkspace.id;
},
delete: async workspace => {
await fetcher({
query: deleteWorkspaceMutation,
variables: {
id: workspace.id,
},
});
},
get: async id => {
if (!environment.isServer && !navigator.onLine) {
// no network
return null;
}
if (
!(await getSession()
.then(() => true)
.catch(() => false))
) {
return null;
}
try {
await fetcher({
query: getWorkspaceQuery,
variables: {
id,
},
});
return {
id,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
blockSuiteWorkspace: getOrCreateWorkspace(
id,
WorkspaceFlavour.AFFINE_CLOUD
),
} satisfies AffineCloudWorkspace;
} catch (e) {
console.error('error when fetching cloud workspace:', e);
return null;
}
},
list: async () => {
if (!environment.isServer && !navigator.onLine) {
// no network
return [];
}
if (
!(await getSession()
.then(() => true)
.catch(() => false))
) {
return [];
}
try {
const { workspaces } = await fetcher({
query: getWorkspacesQuery,
});
const ids = workspaces.map(({ id }) => id);
return ids.map(
id =>
({
id,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
blockSuiteWorkspace: getOrCreateWorkspace(
id,
WorkspaceFlavour.AFFINE_CLOUD
),
}) satisfies AffineCloudWorkspace
);
} catch (e) {
console.error('error when fetching cloud workspaces:', e);
return [];
}
},
};

View File

@ -0,0 +1,75 @@
import { initEmptyPage } from '@affine/env/blocksuite';
import { PageNotFoundError } from '@affine/env/constant';
import type {
WorkspaceFlavour,
WorkspaceUISchema,
} from '@affine/env/workspace';
import { lazy, useCallback } from 'react';
import { useIsWorkspaceOwner } from '../../hooks/affine/use-is-workspace-owner';
import { useWorkspace } from '../../hooks/use-workspace';
import {
BlockSuitePageList,
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
WorkspaceHeader,
} from '../shared';
const LoginCard = lazy(() =>
import('../../components/cloud/login-card').then(({ LoginCard }) => ({
default: LoginCard,
}))
);
export const UI = {
Provider,
LoginCard,
Header: WorkspaceHeader,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
if (!page) {
throw new PageNotFoundError(workspace.blockSuiteWorkspace, currentPageId);
}
return (
<>
<PageDetailEditor
pageId={currentPageId}
onInit={useCallback(async page => initEmptyPage(page), [])}
onLoad={onLoadEditor}
workspace={workspace.blockSuiteWorkspace}
/>
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
return (
<BlockSuitePageList
listType="all"
collection={collection}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: ({
currentWorkspaceId,
onTransformWorkspace,
onDeleteLocalWorkspace,
onDeleteCloudWorkspace,
onLeaveWorkspace,
}) => {
const isOwner = useIsWorkspaceOwner(currentWorkspaceId);
return (
<NewWorkspaceSettingDetail
onDeleteLocalWorkspace={onDeleteLocalWorkspace}
onDeleteCloudWorkspace={onDeleteCloudWorkspace}
onLeaveWorkspace={onLeaveWorkspace}
workspaceId={currentWorkspaceId}
onTransferWorkspace={onTransformWorkspace}
isOwner={isOwner}
/>
);
},
} satisfies WorkspaceUISchema<WorkspaceFlavour.AFFINE_CLOUD>;

View File

@ -29,6 +29,7 @@ import {
BlockSuitePageList,
NewWorkspaceSettingDetail,
PageDetailEditor,
Provider,
WorkspaceHeader,
} from '../shared';
@ -39,6 +40,7 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
flavour: WorkspaceFlavour.LOCAL,
loadPriority: LoadPriority.LOW,
Events: {
'app:access': async () => true,
'app:init': () => {
const blockSuiteWorkspace = getOrCreateWorkspace(
nanoid(),
@ -79,9 +81,7 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
CRUD,
UI: {
Header: WorkspaceHeader,
Provider: ({ children }) => {
return <>{children}</>;
},
Provider,
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useStaticBlockSuiteWorkspace(currentWorkspaceId);
const page = workspace.getPage(currentPageId);
@ -111,14 +111,19 @@ export const LocalAdapter: WorkspaceAdapter<WorkspaceFlavour.LOCAL> = {
},
NewSettingsDetail: ({
currentWorkspaceId,
onDeleteWorkspace,
onTransformWorkspace,
onDeleteLocalWorkspace,
onDeleteCloudWorkspace,
onLeaveWorkspace,
}) => {
return (
<NewWorkspaceSettingDetail
onDeleteWorkspace={onDeleteWorkspace}
onDeleteLocalWorkspace={onDeleteLocalWorkspace}
onDeleteCloudWorkspace={onDeleteCloudWorkspace}
onLeaveWorkspace={onLeaveWorkspace}
workspaceId={currentWorkspaceId}
onTransferWorkspace={onTransformWorkspace}
isOwner={true}
/>
);
},

View File

@ -0,0 +1,45 @@
import { initEmptyPage } from '@affine/env/blocksuite';
import { PageNotFoundError } from '@affine/env/constant';
import type { WorkspaceFlavour } from '@affine/env/workspace';
import { type WorkspaceUISchema } from '@affine/env/workspace';
import { useCallback } from 'react';
import { useWorkspace } from '../../hooks/use-workspace';
import { BlockSuitePageList, PageDetailEditor, Provider } from '../shared';
export const UI = {
Provider,
Header: () => {
return null;
},
PageDetail: ({ currentWorkspaceId, currentPageId, onLoadEditor }) => {
const workspace = useWorkspace(currentWorkspaceId);
const page = workspace.blockSuiteWorkspace.getPage(currentPageId);
if (!page) {
throw new PageNotFoundError(workspace.blockSuiteWorkspace, currentPageId);
}
return (
<>
<PageDetailEditor
pageId={currentPageId}
onInit={useCallback(async page => initEmptyPage(page), [])}
onLoad={onLoadEditor}
workspace={workspace.blockSuiteWorkspace}
/>
</>
);
},
PageList: ({ blockSuiteWorkspace, onOpenPage, collection }) => {
return (
<BlockSuitePageList
listType="all"
collection={collection}
onOpenPage={onOpenPage}
blockSuiteWorkspace={blockSuiteWorkspace}
/>
);
},
NewSettingsDetail: () => {
throw new Error('Not implemented');
},
} satisfies WorkspaceUISchema<WorkspaceFlavour.AFFINE_PUBLIC>;

View File

@ -1,5 +1,11 @@
import { lazy } from 'react';
export const Provider = lazy(() =>
import('../components/cloud/provider').then(({ Provider }) => ({
default: Provider,
}))
);
export const NewWorkspaceSettingDetail = lazy(() =>
import('../components/affine/new-workspace-setting-detail').then(
({ WorkspaceSettingDetail }) => ({

View File

@ -10,7 +10,10 @@ import {
WorkspaceFlavour,
} from '@affine/env/workspace';
import { CRUD as CloudCRUD } from './cloud/crud';
import { UI as CloudUI } from './cloud/ui';
import { LocalAdapter } from './local';
import { UI as PublicCloudUI } from './public-cloud/ui';
const unimplemented = () => {
throw new Error('Not implemented');
@ -26,26 +29,24 @@ export const WorkspaceAdapters = {
releaseType: ReleaseType.UNRELEASED,
flavour: WorkspaceFlavour.AFFINE_CLOUD,
loadPriority: LoadPriority.HIGH,
Events: {} as Partial<AppEvents>,
// todo: implement this
CRUD: {
get: unimplemented,
list: bypassList,
delete: unimplemented,
create: unimplemented,
Events: {
'app:access': async () => {
try {
const { getSession } = await import('next-auth/react');
const session = await getSession();
return !!session;
} catch (e) {
console.error('failed to get session', e);
return false;
}
},
// todo: implement this
UI: {
Provider: unimplemented,
Header: unimplemented,
PageDetail: unimplemented,
PageList: unimplemented,
NewSettingsDetail: unimplemented,
} as Partial<AppEvents>,
CRUD: CloudCRUD,
UI: CloudUI,
},
},
[WorkspaceFlavour.PUBLIC]: {
[WorkspaceFlavour.AFFINE_PUBLIC]: {
releaseType: ReleaseType.UNRELEASED,
flavour: WorkspaceFlavour.PUBLIC,
flavour: WorkspaceFlavour.AFFINE_PUBLIC,
loadPriority: LoadPriority.LOW,
Events: {} as Partial<AppEvents>,
// todo: implement this
@ -55,14 +56,7 @@ export const WorkspaceAdapters = {
delete: unimplemented,
create: unimplemented,
},
// todo: implement this
UI: {
Provider: unimplemented,
Header: unimplemented,
PageDetail: unimplemented,
PageList: unimplemented,
NewSettingsDetail: unimplemented,
},
UI: PublicCloudUI,
},
} satisfies {
[Key in WorkspaceFlavour]: WorkspaceAdapter<Key>;

View File

@ -7,6 +7,7 @@ import { WorkspaceFallback } from '@affine/component/workspace';
import { CacheProvider } from '@emotion/react';
import { getCurrentStore } from '@toeverything/infra/atom';
import { use } from 'foxact/use';
import { SessionProvider } from 'next-auth/react';
import type { PropsWithChildren, ReactElement } from 'react';
import { lazy, memo, Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
@ -47,6 +48,7 @@ const languageLoadingPromise = loadLanguage().catch(console.error);
export const App = memo(function App() {
use(languageLoadingPromise);
return (
<SessionProvider refetchOnWindowFocus>
<CacheProvider value={cache}>
<AffineContext store={getCurrentStore()}>
<DebugProvider>
@ -58,5 +60,6 @@ export const App = memo(function App() {
</DebugProvider>
</AffineContext>
</CacheProvider>
</SessionProvider>
);
});

View File

@ -3,9 +3,9 @@ import { atom } from 'jotai';
import { atomFamily, atomWithStorage } from 'jotai/utils';
import type { AtomFamily } from 'jotai/vanilla/utils/atomFamily';
import type { AuthProps } from '../components/affine/auth';
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
import type { SettingProps } from '../components/affine/setting-modal';
// modal atoms
export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
@ -22,6 +22,22 @@ export const openSettingModalAtom = atom<SettingAtom>({
open: false,
});
export type AuthAtom = {
openModal: boolean;
state: AuthProps['state'];
email?: string;
emailType?: AuthProps['emailType'];
// Only used for sign in page callback, after called, it will be set to undefined
onceSignedIn?: () => void;
};
export const authAtom = atom<AuthAtom>({
openModal: false,
state: 'signIn',
email: '',
emailType: 'changeEmail',
});
export const openDisableCloudAlertModalAtom = atom(false);
type PageMode = 'page' | 'edgeless';

View File

@ -0,0 +1,13 @@
import { assertExists } from '@blocksuite/global/utils';
import type { FC, PropsWithChildren } from 'react';
import { WorkspaceAdapters } from '../adapters/workspace';
import { useCurrentWorkspace } from '../hooks/current/use-current-workspace';
export const AdapterProviderWrapper: FC<PropsWithChildren> = ({ children }) => {
const [currentWorkspace] = useCurrentWorkspace();
const Provider = WorkspaceAdapters[currentWorkspace.flavour].UI.Provider;
assertExists(Provider);
return <Provider>{children}</Provider>;
};

View File

@ -0,0 +1,11 @@
import type { ReactElement } from 'react';
import type { FallbackProps } from 'react-error-boundary';
export const AnyErrorBoundary = (props: FallbackProps): ReactElement => {
return (
<div>
<p>Something went wrong:</p>
<p>{props.error.toString()}</p>
</div>
);
};

View File

@ -0,0 +1,67 @@
import {
AuthContent,
BackButton,
ModalHeader,
ResendButton,
} from '@affine/component/auth-components';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { signIn } from 'next-auth/react';
import { type FC, useCallback } from 'react';
import { buildCallbackUrl } from './callback-url';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
export const AfterSignInSendEmail: FC<AuthPanelProps> = ({
setAuthState,
email,
}) => {
const t = useAFFiNEI18N();
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['com.affine.auth.sign.in.sent.email.subtitle']()}
/>
<AuthContent style={{ height: 162 }}>
{t['com.affine.auth.sign.sent.email.message.start']()}
<a href={`mailto:${email}`}>{email}</a>
{t['com.affine.auth.sign.sent.email.message.end']()}
</AuthContent>
<ResendButton
onClick={useCallback(() => {
signIn('email', {
email,
callbackUrl: buildCallbackUrl('signIn'),
redirect: true,
}).catch(console.error);
}, [email])}
/>
<div className={style.authMessage} style={{ marginTop: 20 }}>
{/*prettier-ignore*/}
<Trans i18nKey="com.affine.auth.sign.auth.code.message.password">
If you haven&apos;t received the email, please check your spam folder.
Or <span
className="link"
data-testid='sign-in-with-password'
onClick={useCallback(() => {
setAuthState('signInWithPassword');
}, [setAuthState])}
>
sign in with password
</span> instead.
</Trans>
</div>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};

View File

@ -0,0 +1,54 @@
import {
AuthContent,
BackButton,
ModalHeader,
ResendButton,
} from '@affine/component/auth-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { signIn } from 'next-auth/react';
import { type FC, useCallback } from 'react';
import { buildCallbackUrl } from './callback-url';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
export const AfterSignUpSendEmail: FC<AuthPanelProps> = ({
setAuthState,
email,
}) => {
const t = useAFFiNEI18N();
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.up']()}
subTitle={t['com.affine.auth.sign.up.sent.email.subtitle']()}
/>
<AuthContent style={{ height: 162 }}>
{t['com.affine.auth.sign.sent.email.message.start']()}
<a href={`mailto:${email}`}>{email}</a>
{t['com.affine.auth.sign.sent.email.message.end']()}
</AuthContent>
<ResendButton
onClick={useCallback(() => {
signIn('email', {
email: email,
callbackUrl: buildCallbackUrl('signUp'),
redirect: true,
}).catch(console.error);
}, [email])}
/>
<div className={style.authMessage} style={{ marginTop: 20 }}>
{t['com.affine.auth.sign.auth.code.message']()}
</div>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};

View File

@ -0,0 +1,16 @@
import { isDesktop } from '@affine/env/constant';
type Action = 'signUp' | 'changePassword' | 'signIn' | 'signUp';
export function buildCallbackUrl(action: Action) {
const callbackUrl = `/auth/${action}`;
const params: string[][] = [];
if (isDesktop && window.appInfo.schema) {
params.push(['schema', window.appInfo.schema]);
}
const query =
params.length > 0
? '?' + params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
: '';
return callbackUrl + query;
}

View File

@ -0,0 +1,155 @@
import {
AuthModal as AuthModalBase,
type AuthModalProps as AuthModalBaseProps,
} from '@affine/component/auth-components';
import { isDesktop } from '@affine/env/constant';
import { atom, useAtom } from 'jotai';
import { type FC, useCallback, useEffect, useMemo } from 'react';
import { AfterSignInSendEmail } from './after-sign-in-send-email';
import { AfterSignUpSendEmail } from './after-sign-up-send-email';
import { SendEmail } from './send-email';
import { SignIn } from './sign-in';
import { SignInWithPassword } from './sign-in-with-password';
export type AuthProps = {
state:
| 'signIn'
| 'afterSignUpSendEmail'
| 'afterSignInSendEmail'
// throw away
| 'signInWithPassword'
| 'sendEmail';
setAuthState: (state: AuthProps['state']) => void;
setAuthEmail: (state: AuthProps['email']) => void;
setEmailType: (state: AuthProps['emailType']) => void;
email: string;
emailType: 'setPassword' | 'changePassword' | 'changeEmail';
onSignedIn?: () => void;
};
export type AuthPanelProps = {
email: string;
setAuthState: AuthProps['setAuthState'];
setAuthEmail: AuthProps['setAuthEmail'];
setEmailType: AuthProps['setEmailType'];
emailType: AuthProps['emailType'];
onSignedIn?: () => void;
authStore: AuthStoreAtom;
setAuthStore: (data: Partial<AuthStoreAtom>) => void;
};
const config: {
[k in AuthProps['state']]: FC<AuthPanelProps>;
} = {
signIn: SignIn,
afterSignUpSendEmail: AfterSignUpSendEmail,
afterSignInSendEmail: AfterSignInSendEmail,
signInWithPassword: SignInWithPassword,
sendEmail: SendEmail,
};
type AuthStoreAtom = {
hasSentEmail: boolean;
resendCountDown: number;
};
export const authStoreAtom = atom<AuthStoreAtom>({
hasSentEmail: false,
resendCountDown: 60,
});
export const AuthModal: FC<AuthModalBaseProps & AuthProps> = ({
open,
state,
setOpen,
email,
setAuthEmail,
setAuthState,
setEmailType,
emailType,
}) => {
const [, setAuthStore] = useAtom(authStoreAtom);
useEffect(() => {
if (!open) {
setAuthStore({
hasSentEmail: false,
resendCountDown: 60,
});
setAuthEmail('');
}
}, [open, setAuthEmail, setAuthStore]);
useEffect(() => {
if (isDesktop) {
return window.events?.ui.onFinishLogin(() => {
setOpen(false);
});
}
return;
}, [setOpen]);
const onSignedIn = useCallback(() => {
setOpen(false);
}, [setOpen]);
return (
<AuthModalBase open={open} setOpen={setOpen}>
<AuthPanel
state={state}
email={email}
setAuthEmail={setAuthEmail}
setAuthState={setAuthState}
setEmailType={setEmailType}
emailType={emailType}
onSignedIn={onSignedIn}
/>
</AuthModalBase>
);
};
export const AuthPanel: FC<AuthProps> = ({
state,
email,
setAuthEmail,
setAuthState,
setEmailType,
emailType,
onSignedIn,
}) => {
const [authStore, setAuthStore] = useAtom(authStoreAtom);
const CurrentPanel = useMemo(() => {
return config[state];
}, [state]);
useEffect(() => {
return () => {
setAuthStore({
hasSentEmail: false,
resendCountDown: 60,
});
};
}, [setAuthEmail, setAuthStore]);
return (
<CurrentPanel
email={email}
setAuthState={setAuthState}
setAuthEmail={setAuthEmail}
setEmailType={setEmailType}
authStore={authStore}
emailType={emailType}
onSignedIn={onSignedIn}
setAuthStore={useCallback(
(data: Partial<AuthStoreAtom>) => {
setAuthStore(prev => ({
...prev,
...data,
}));
},
[setAuthStore]
)}
/>
);
};

View File

@ -0,0 +1,185 @@
import { Wrapper } from '@affine/component';
import {
AuthContent,
AuthInput,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { isDesktop } from '@affine/env/constant';
import {
sendChangeEmailMutation,
sendChangePasswordEmailMutation,
sendSetPasswordEmailMutation,
} from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai/react';
import { type FC, useCallback } from 'react';
import type { AuthPanelProps } from './index';
const useEmailTitle = (emailType: AuthPanelProps['emailType']) => {
const t = useAFFiNEI18N();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.set.password']();
case 'changePassword':
return t['com.affine.auth.reset.password']();
case 'changeEmail':
return t['com.affine.settings.email.action']();
}
};
const useNotificationHint = (emailType: AuthPanelProps['emailType']) => {
const t = useAFFiNEI18N();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.sent.set.password.hint']();
case 'changePassword':
return t['com.affine.auth.sent.change.password.hint']();
case 'changeEmail':
return t['com.affine.auth.sent.change.email.hint']();
}
};
const useButtonContent = (emailType: AuthPanelProps['emailType']) => {
const t = useAFFiNEI18N();
switch (emailType) {
case 'setPassword':
return t['com.affine.auth.send.set.password.link']();
case 'changePassword':
return t['com.affine.auth.send.reset.password.link']();
case 'changeEmail':
return t['com.affine.auth.send.change.email.link']();
}
};
const useSendEmail = (emailType: AuthPanelProps['emailType']) => {
const {
trigger: sendChangePasswordEmail,
isMutating: isChangePasswordMutating,
} = useMutation({
mutation: sendChangePasswordEmailMutation,
});
const { trigger: sendSetPasswordEmail, isMutating: isSetPasswordMutating } =
useMutation({
mutation: sendSetPasswordEmailMutation,
});
const { trigger: sendChangeEmail, isMutating: isChangeEmailMutating } =
useMutation({
mutation: sendChangeEmailMutation,
});
return {
loading:
isChangePasswordMutating ||
isSetPasswordMutating ||
isChangeEmailMutating,
sendEmail: useCallback(
(email: string) => {
let trigger: (args: {
email: string;
callbackUrl: string;
}) => Promise<unknown>;
let callbackUrl;
switch (emailType) {
case 'setPassword':
trigger = sendSetPasswordEmail;
callbackUrl = 'setPassword';
break;
case 'changePassword':
trigger = sendChangePasswordEmail;
callbackUrl = 'changePassword';
break;
case 'changeEmail':
trigger = sendChangeEmail;
callbackUrl = 'changeEmail';
break;
}
// TODO: add error handler
return trigger({
email,
callbackUrl: `/auth/${callbackUrl}?isClient=${
isDesktop ? 'true' : 'false'
}`,
});
},
[
emailType,
sendChangeEmail,
sendChangePasswordEmail,
sendSetPasswordEmail,
]
),
};
};
export const SendEmail: FC<AuthPanelProps> = ({
setAuthState,
setAuthStore,
email,
authStore: { hasSentEmail },
emailType,
}) => {
const t = useAFFiNEI18N();
const pushNotification = useSetAtom(pushNotificationAtom);
const title = useEmailTitle(emailType);
const hint = useNotificationHint(emailType);
const buttonContent = useButtonContent(emailType);
const { loading, sendEmail } = useSendEmail(emailType);
const onSendEmail = useCallback(async () => {
// TODO: add error handler
await sendEmail(email);
pushNotification({
title: hint,
message: '',
key: Date.now().toString(),
type: 'success',
});
setAuthStore({ hasSentEmail: true });
}, [email, hint, pushNotification, sendEmail, setAuthStore]);
return (
<>
<ModalHeader title={t['AFFiNE Cloud']()} subTitle={title} />
<AuthContent>{t['com.affine.auth.reset.password.message']()}</AuthContent>
<Wrapper
marginTop={30}
marginBottom={50}
style={{
position: 'relative',
}}
>
<AuthInput
label={t['com.affine.settings.email']()}
disabled={true}
value={email}
/>
</Wrapper>
<Button
type="primary"
size="extraLarge"
style={{ width: '100%' }}
disabled={hasSentEmail}
loading={loading}
onClick={onSendEmail}
>
{hasSentEmail ? t['com.affine.auth.sent']() : buttonContent}
</Button>
<BackButton
onClick={useCallback(() => {
setAuthState('signIn');
}, [setAuthState])}
/>
</>
);
};

View File

@ -0,0 +1,111 @@
import { Wrapper } from '@affine/component';
import {
AuthInput,
BackButton,
ModalHeader,
} from '@affine/component/auth-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signIn, useSession } from 'next-auth/react';
import type { FC } from 'react';
import { useCallback, useState } from 'react';
import type { AuthPanelProps } from './index';
import { forgetPasswordButton } from './style.css';
export const SignInWithPassword: FC<AuthPanelProps> = ({
setAuthState,
email,
onSignedIn,
}) => {
const t = useAFFiNEI18N();
const { update } = useSession();
const pushNotification = useSetAtom(pushNotificationAtom);
const [password, setPassword] = useState('');
const [passwordError, setPasswordError] = useState(false);
const onSignIn = useCallback(async () => {
const res = await signIn('credentials', {
redirect: false,
email,
password,
}).catch(console.error);
if (!res?.ok) {
return setPasswordError(true);
}
await update();
onSignedIn?.();
pushNotification({
title: `${email}${t['com.affine.auth.has.signed']()}`,
message: '',
key: Date.now().toString(),
type: 'success',
});
}, [email, password, pushNotification, onSignedIn, t, update]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['AFFiNE Cloud']()}
/>
<Wrapper
marginTop={30}
marginBottom={50}
style={{
position: 'relative',
}}
>
<AuthInput
label={t['com.affine.settings.email']()}
disabled={true}
value={email}
/>
<AuthInput
data-testid="password-input"
label={t['com.affine.auth.password']()}
value={password}
type="password"
onChange={useCallback((value: string) => {
setPassword(value);
}, [])}
error={passwordError}
errorHint={t['com.affine.auth.password.error']()}
onEnter={onSignIn}
/>
<span></span>
<button
className={forgetPasswordButton}
// onClick={useCallback(() => {
// setAuthState('sendPasswordEmail');
// }, [setAuthState])}
>
{t['com.affine.auth.forget']()}
</button>
</Wrapper>
<Button
data-testid="sign-in-button"
type="primary"
size="extraLarge"
style={{ width: '100%' }}
onClick={onSignIn}
>
{t['com.affine.auth.sign.in']()}
</Button>
<BackButton
onClick={useCallback(() => {
setAuthState('afterSignInSendEmail');
}, [setAuthState])}
/>
</>
);
};

View File

@ -0,0 +1,151 @@
import { AuthInput, ModalHeader } from '@affine/component/auth-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import type { Notification } from '@affine/component/notification-center/index.jotai';
import { getUserQuery } from '@affine/graphql';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { ArrowDownBigIcon, GoogleDuotoneIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import { signIn, type SignInResponse } from 'next-auth/react';
import { type FC, useState } from 'react';
import { useCallback } from 'react';
import { emailRegex } from '../../../utils/email-regex';
import { buildCallbackUrl } from './callback-url';
import type { AuthPanelProps } from './index';
import * as style from './style.css';
function validateEmail(email: string) {
return emailRegex.test(email);
}
function handleSendEmailError(
res: SignInResponse | undefined,
pushNotification: (notification: Notification) => void
) {
if (res?.error) {
pushNotification({
title: 'Send email error',
message: 'Please back to home and try again',
type: 'error',
});
}
}
export const SignIn: FC<AuthPanelProps> = ({
setAuthState,
setAuthEmail,
email,
}) => {
const t = useAFFiNEI18N();
const { trigger: verifyUser, isMutating } = useMutation({
mutation: getUserQuery,
});
const [isValidEmail, setIsValidEmail] = useState(true);
const pushNotification = useSetAtom(pushNotificationAtom);
const onContinue = useCallback(async () => {
if (!validateEmail(email)) {
setIsValidEmail(false);
return;
}
setIsValidEmail(true);
const { user } = await verifyUser({ email });
setAuthEmail(email);
if (user) {
signIn('email', {
email: email,
callbackUrl: buildCallbackUrl('signIn'),
redirect: false,
})
.then(res => handleSendEmailError(res, pushNotification))
.catch(console.error);
setAuthState('afterSignInSendEmail');
} else {
signIn('email', {
email: email,
callbackUrl: buildCallbackUrl('signUp'),
redirect: false,
})
.then(res => handleSendEmailError(res, pushNotification))
.catch(console.error);
setAuthState('afterSignUpSendEmail');
}
}, [email, setAuthEmail, setAuthState, verifyUser, pushNotification]);
return (
<>
<ModalHeader
title={t['com.affine.auth.sign.in']()}
subTitle={t['AFFiNE Cloud']()}
/>
<Button
type="primary"
block
size="extraLarge"
style={{
marginTop: 30,
}}
icon={<GoogleDuotoneIcon />}
onClick={useCallback(() => {
signIn('google').catch(console.error);
}, [])}
>
{t['Continue with Google']()}
</Button>
<div className={style.authModalContent}>
<AuthInput
label={t['com.affine.settings.email']()}
placeholder={t['com.affine.auth.sign.email.placeholder']()}
value={email}
onChange={useCallback(
(value: string) => {
setAuthEmail(value);
},
[setAuthEmail]
)}
error={!isValidEmail}
errorHint={
isValidEmail ? '' : t['com.affine.auth.sign.email.error']()
}
onEnter={onContinue}
/>
<Button
size="extraLarge"
data-testid="continue-login-button"
block
loading={isMutating}
icon={
<ArrowDownBigIcon
width={20}
height={20}
style={{
transform: 'rotate(-90deg)',
color: 'var(--affine-blue)',
}}
/>
}
iconPosition="end"
onClick={onContinue}
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
<div className={style.authMessage}>
{/*prettier-ignore*/}
<Trans i18nKey="com.affine.auth.sign.message">
By clicking &quot;Continue with Google/Email&quot; above, you acknowledge that
you agree to AFFiNE&apos;s <a href="https://affine.pro/terms" target="_blank" rel="noreferrer">Terms of Conditions</a> and <a href="https://affine.pro/privacy" target="_blank" rel="noreferrer">Privacy Policy</a>.
</Trans>
</div>
</div>
</>
);
};

View File

@ -0,0 +1,28 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const authModalContent = style({
marginTop: '30px',
});
export const authMessage = style({
marginTop: '30px',
color: 'var(--affine-text-secondary-color)',
fontSize: 'var(--affine-font-xs)',
lineHeight: 1.5,
});
globalStyle(`${authMessage} a`, {
color: 'var(--affine-link-color)',
});
globalStyle(`${authMessage} .link`, {
cursor: 'pointer',
color: 'var(--affine-link-color)',
});
export const forgetPasswordButton = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-secondary-color)',
position: 'absolute',
right: 0,
bottom: 0,
display: 'none',
});

View File

@ -1,9 +1,9 @@
import { Modal, ModalWrapper, Wrapper } from '@affine/component';
import { Modal, ModalWrapper } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloseIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { Content, ContentTitle, Header, StyleTips } from './style';
import { ButtonContainer, Content, Header, StyleTips, Title } from './style';
interface EnableAffineCloudModalProps {
open: boolean;
@ -20,32 +20,32 @@ export const EnableAffineCloudModal = ({
return (
<Modal open={open} onClose={onClose} data-testid="logout-modal">
<ModalWrapper width={560} height={292}>
<ModalWrapper width={480}>
<Header>
<Title>{t['Enable AFFiNE Cloud']()}</Title>
<IconButton onClick={onClose}>
<CloseIcon />
</IconButton>
</Header>
<Content>
<ContentTitle>{t['Enable AFFiNE Cloud']()}?</ContentTitle>
<StyleTips>{t['Enable AFFiNE Cloud Description']()}</StyleTips>
{/* <StyleTips>{t('Retain cached cloud data')}</StyleTips> */}
<Wrapper width={284} margin="auto">
<ButtonContainer>
<div>
<Button onClick={onClose} block>
{t['Cancel']()}
</Button>
</div>
<div>
<Button
data-testid="confirm-enable-affine-cloud-button"
type="primary"
block
onClick={onConfirm}
style={{
marginBottom: '16px',
}}
>
{t['Sign in and Enable']()}
</Button>
<Button onClick={onClose} block>
{t['Not now']()}
</Button>
</Wrapper>
</div>
</ButtonContainer>
</Content>
</ModalWrapper>
</Modal>

View File

@ -1,31 +1,35 @@
import { styled } from '@affine/component';
export const Header = styled('div')({
height: '44px',
display: 'flex',
flexDirection: 'row-reverse',
paddingRight: '10px',
paddingTop: '10px',
flexShrink: 0,
justifyContent: 'space-between',
paddingRight: '20px',
paddingTop: '20px',
paddingLeft: '24px',
alignItems: 'center',
});
export const Content = styled('div')({
textAlign: 'center',
padding: '12px 24px 20px 24px',
});
export const ContentTitle = styled('h1')({
fontSize: '20px',
lineHeight: '28px',
export const Title = styled('div')({
fontSize: 'var(--affine-font-h6)',
lineHeight: '26px',
fontWeight: 600,
textAlign: 'center',
});
export const StyleTips = styled('div')(() => {
return {
userSelect: 'none',
width: '400px',
margin: 'auto',
marginBottom: '32px',
marginTop: '12px',
marginBottom: '20px',
};
});
export const ButtonContainer = styled('div')(() => {
return {
display: 'flex',
justifyContent: 'flex-end',
gap: '20px',
paddingTop: '20px',
};
});

View File

@ -1,13 +1,12 @@
import { Input, Modal, ModalCloseButton } from '@affine/component';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { useCallback, useState } from 'react';
import { useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../../../shared';
import { toast } from '../../../../../utils';
import {
StyledButtonContent,
StyledInputContent,
@ -21,14 +20,14 @@ interface WorkspaceDeleteProps {
open: boolean;
onClose: () => void;
workspace: AffineOfficialWorkspace;
onDeleteWorkspace: (id: string) => Promise<void>;
onConfirm: () => void;
}
export const WorkspaceDeleteModal = ({
open,
onClose,
onConfirm,
workspace,
onDeleteWorkspace,
}: WorkspaceDeleteProps) => {
const [workspaceName] = useBlockSuiteWorkspaceName(
workspace.blockSuiteWorkspace
@ -37,19 +36,6 @@ export const WorkspaceDeleteModal = ({
const allowDelete = deleteStr === workspaceName;
const t = useAFFiNEI18N();
const handleDelete = useCallback(() => {
onDeleteWorkspace(workspace.id)
.then(() => {
toast(t['Successfully deleted'](), {
portal: document.body,
});
onClose();
})
.catch(() => {
// ignore error
});
}, [onClose, onDeleteWorkspace, t, workspace.id]);
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
@ -99,7 +85,7 @@ export const WorkspaceDeleteModal = ({
<Button
data-testid="delete-workspace-confirm-button"
disabled={!allowDelete}
onClick={handleDelete}
onClick={onConfirm}
size="large"
type="error"
style={{ marginLeft: '24px' }}

View File

@ -1,29 +1,55 @@
import { ConfirmModal } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon } from '@blocksuite/icons';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../../shared';
import type { WorkspaceSettingDetailProps } from '../index';
import { WorkspaceDeleteModal } from './delete';
import { WorkspaceLeave } from './leave';
interface DeleteLeaveWorkspaceProps {
export interface DeleteLeaveWorkspaceProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
onDeleteWorkspace: WorkspaceSettingDetailProps['onDeleteWorkspace'];
}
export const DeleteLeaveWorkspace = ({
workspace,
onDeleteWorkspace,
onDeleteCloudWorkspace,
onDeleteLocalWorkspace,
onLeaveWorkspace,
isOwner,
}: DeleteLeaveWorkspaceProps) => {
const t = useAFFiNEI18N();
// fixme: cloud regression
const isOwner = true;
const [showDelete, setShowDelete] = useState(false);
const [showLeave, setShowLeave] = useState(false);
const onLeaveOrDelete = useCallback(() => {
if (isOwner) {
setShowDelete(true);
} else {
setShowLeave(true);
}
}, [isOwner]);
const onCloseLeaveModal = useCallback(() => {
setShowLeave(false);
}, []);
const onLeaveConfirm = useCallback(() => {
return onLeaveWorkspace();
}, [onLeaveWorkspace]);
const onDeleteConfirm = useCallback(() => {
if (workspace.flavour === WorkspaceFlavour.LOCAL) {
return onDeleteLocalWorkspace();
}
if (workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
return onDeleteCloudWorkspace();
}
}, [onDeleteCloudWorkspace, onDeleteLocalWorkspace, workspace.flavour]);
return (
<>
<SettingRow
@ -36,16 +62,14 @@ export const DeleteLeaveWorkspace = ({
}
desc={t['com.affine.settings.remove-workspace-description']()}
style={{ cursor: 'pointer' }}
onClick={() => {
setShowDelete(true);
}}
onClick={onLeaveOrDelete}
data-testid="delete-workspace-button"
>
<ArrowRightSmallIcon />
</SettingRow>
{isOwner ? (
<WorkspaceDeleteModal
onDeleteWorkspace={onDeleteWorkspace}
onConfirm={onDeleteConfirm}
open={showDelete}
onClose={() => {
setShowDelete(false);
@ -53,11 +77,15 @@ export const DeleteLeaveWorkspace = ({
workspace={workspace}
/>
) : (
<WorkspaceLeave
<ConfirmModal
open={showLeave}
onClose={() => {
setShowLeave(false);
}}
onConfirm={onLeaveConfirm}
onCancel={onCloseLeaveModal}
onClose={onCloseLeaveModal}
title={`${t['Leave Workspace']()}?`}
content={t['Leave Workspace hint']()}
confirmType="warning"
confirmText={t['Leave']()}
/>
)}
</>

View File

@ -1,49 +0,0 @@
import { Modal } from '@affine/component';
import { ModalCloseButton } from '@affine/component';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import {
StyledButtonContent,
StyledModalHeader,
StyledModalWrapper,
StyledTextContent,
} from './style';
interface WorkspaceDeleteProps {
open: boolean;
onClose: () => void;
}
export const WorkspaceLeave = ({ open, onClose }: WorkspaceDeleteProps) => {
// const { leaveWorkSpace } = useWorkspaceHelper();
const t = useAFFiNEI18N();
const handleLeave = async () => {
// await leaveWorkSpace();
onClose();
};
return (
<Modal open={open} onClose={onClose}>
<StyledModalWrapper>
<ModalCloseButton onClick={onClose} />
<StyledModalHeader>{t['Leave Workspace']()}</StyledModalHeader>
<StyledTextContent>
{t['Leave Workspace Description']()}
</StyledTextContent>
<StyledButtonContent>
<Button shape="circle" onClick={onClose}>
{t['Cancel']()}
</Button>
<Button
onClick={handleLeave}
type="error"
style={{ marginLeft: '24px' }}
>
{t['Leave']()}
</Button>
</StyledButtonContent>
</StyledModalWrapper>
</Modal>
);
};

View File

@ -1,44 +0,0 @@
import { styled } from '@affine/component';
export const StyledModalWrapper = styled('div')(() => {
return {
position: 'relative',
padding: '0px',
width: '460px',
background: 'var(--affine-white)',
borderRadius: '12px',
};
});
export const StyledModalHeader = styled('div')(() => {
return {
margin: '44px 0px 12px 0px',
width: '460px',
fontWeight: '600',
fontSize: '20px;',
textAlign: 'center',
};
});
// export const StyledModalContent = styled('div')(({ theme }) => {});
export const StyledTextContent = styled('div')(() => {
return {
margin: 'auto',
width: '425px',
fontStyle: 'normal',
fontWeight: '400',
fontSize: '18px',
lineHeight: '26px',
textAlign: 'center',
};
});
export const StyledButtonContent = styled('div')(() => {
return {
display: 'flex',
flexDirection: 'row',
justifyContent: 'center',
margin: '0px 0 32px 0',
};
});

View File

@ -1,13 +1,12 @@
import { toast } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { isDesktop } from '@affine/env/constant';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import type { SaveDBFileResult } from '@toeverything/infra/type';
import { useCallback } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
async function syncBlobsToSqliteDb(workspace: AffineOfficialWorkspace) {
if (window.apis && isDesktop) {
const bs = workspace.blockSuiteWorkspace.blobs;
@ -41,7 +40,7 @@ export const ExportPanel = ({ workspace }: ExportPanelProps) => {
const result: SaveDBFileResult =
await window.apis?.dialog.saveDBFileAs(workspaceId);
if (result?.error) {
toast(t[result.error]());
toast(result.error);
} else if (!result?.canceled) {
toast(t['Export success']());
}

View File

@ -14,13 +14,17 @@ import { useMemo } from 'react';
import { useWorkspace } from '../../../hooks/use-workspace';
import { DeleteLeaveWorkspace } from './delete-leave-workspace';
import { ExportPanel } from './export';
import { MembersPanel } from './members';
import { ProfilePanel } from './profile';
import { PublishPanel } from './publish';
import { StoragePanel } from './storage';
export interface WorkspaceSettingDetailProps {
workspaceId: string;
onDeleteWorkspace: (id: string) => Promise<void>;
isOwner: boolean;
onDeleteLocalWorkspace: () => void;
onDeleteCloudWorkspace: () => void;
onLeaveWorkspace: () => void;
onTransferWorkspace: <
From extends WorkspaceFlavour,
To extends WorkspaceFlavour,
@ -31,11 +35,8 @@ export interface WorkspaceSettingDetailProps {
) => void;
}
export const WorkspaceSettingDetail = ({
workspaceId,
onDeleteWorkspace,
...props
}: WorkspaceSettingDetailProps) => {
export const WorkspaceSettingDetail = (props: WorkspaceSettingDetailProps) => {
const { workspaceId } = props;
const t = useAFFiNEI18N();
const workspace = useWorkspace(workspaceId);
const [name] = useBlockSuiteWorkspaceName(workspace.blockSuiteWorkspace);
@ -67,22 +68,16 @@ export const WorkspaceSettingDetail = ({
desc={t['com.affine.settings.workspace.not-owner']()}
spreadCol={false}
>
<ProfilePanel workspace={workspace} />
<ProfilePanel workspace={workspace} {...props} />
</SettingRow>
</SettingWrapper>
<SettingWrapper title={t['AFFiNE Cloud']()}>
<PublishPanel
workspace={workspace}
onDeleteWorkspace={onDeleteWorkspace}
{...props}
/>
<PublishPanel workspace={workspace} {...props} />
<MembersPanel workspace={workspace} {...props} />
</SettingWrapper>
{storageAndExportSetting}
<SettingWrapper>
<DeleteLeaveWorkspace
workspace={workspace}
onDeleteWorkspace={onDeleteWorkspace}
/>
<DeleteLeaveWorkspace workspace={workspace} {...props} />
</SettingWrapper>
</>
);

View File

@ -0,0 +1,228 @@
import { Menu, MenuItem } from '@affine/component';
import {
InviteModal,
type InviteModalProps,
} from '@affine/component/member-components';
import { pushNotificationAtom } from '@affine/component/notification-center';
import { SettingRow } from '@affine/component/setting-components';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { Permission } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { MoreVerticalIcon } from '@blocksuite/icons';
import { Avatar } from '@toeverything/components/avatar';
import { Button, IconButton } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import clsx from 'clsx';
import { useSetAtom } from 'jotai/react';
import type { ReactElement } from 'react';
import { Suspense, useCallback, useMemo, useState } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import type { CheckedUser } from '../../../hooks/affine/use-current-user';
import { useCurrentUser } from '../../../hooks/affine/use-current-user';
import { useInviteMember } from '../../../hooks/affine/use-invite-member';
import { type Member, useMembers } from '../../../hooks/affine/use-members';
import { useRevokeMemberPermission } from '../../../hooks/affine/use-revoke-member-permission';
import { AnyErrorBoundary } from '../any-error-boundary';
import { type WorkspaceSettingDetailProps } from './index';
import * as style from './style.css';
export interface MembersPanelProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
}
const MembersPanelLocal = () => {
const t = useAFFiNEI18N();
return (
<Tooltip content={t['com.affine.settings.member-tooltip']()}>
<div className={style.fakeWrapper}>
<SettingRow name={`${t['Members']()} (0)`} desc={t['Members hint']()}>
<Button size="large">{t['Invite Members']()}</Button>
</SettingRow>
</div>
</Tooltip>
);
};
export const CloudWorkspaceMembersPanel = ({
workspace,
isOwner,
}: MembersPanelProps): ReactElement => {
const workspaceId = workspace.id;
const members = useMembers(workspaceId);
const t = useAFFiNEI18N();
const currentUser = useCurrentUser();
const { invite, isMutating } = useInviteMember(workspaceId);
const [open, setOpen] = useState(false);
const pushNotification = useSetAtom(pushNotificationAtom);
const revokeMemberPermission = useRevokeMemberPermission(workspaceId);
const memberCount = members.length;
const memberList = useMemo(
() =>
members.sort((a, b) => {
if (
a.permission === Permission.Owner &&
b.permission !== Permission.Owner
) {
return -1;
}
if (
a.permission !== Permission.Owner &&
b.permission === Permission.Owner
) {
return 1;
}
return 0;
}),
[members]
);
const openModal = useCallback(() => {
setOpen(true);
}, []);
const onInviteConfirm = useCallback<InviteModalProps['onConfirm']>(
async ({ email, permission }) => {
const success = await invite(
email,
permission,
// send invite email
true
);
if (success) {
pushNotification({
title: t['Invitation sent'](),
message: t['Invitation sent hint'](),
type: 'success',
});
setOpen(false);
}
},
[invite, pushNotification, t]
);
return (
<>
<SettingRow
name={`${t['Members']()} (${memberCount})`}
desc={t['Members hint']()}
>
{isOwner ? (
<>
<Button onClick={openModal}>{t['Invite Members']()}</Button>
<InviteModal
open={open}
setOpen={setOpen}
onConfirm={onInviteConfirm}
isMutating={isMutating}
/>
</>
) : null}
</SettingRow>
<div className={style.membersList}>
{memberList.map(member => (
<MemberItem
key={member.id}
member={member}
isOwner={isOwner}
currentUser={currentUser}
onRevoke={revokeMemberPermission}
/>
))}
</div>
</>
);
};
const MemberItem = ({
member,
isOwner,
currentUser,
onRevoke,
}: {
member: Member;
isOwner: boolean;
currentUser: CheckedUser;
onRevoke: (memberId: string) => void;
}) => {
const t = useAFFiNEI18N();
const handleRevoke = useCallback(() => {
onRevoke(member.id);
}, [onRevoke, member.id]);
const operationButtonInfo = useMemo(() => {
return {
show: isOwner && currentUser.id !== member.id,
leaveOrRevokeText: t['Remove from workspace'](),
};
}, [currentUser.id, isOwner, member.id, t]);
return (
<>
<div key={member.id} className={style.listItem}>
<Avatar
size={36}
url={member.avatarUrl}
name={(member.emailVerified ? member.name : member.email) as string}
/>
<div className={style.memberContainer}>
{member.emailVerified ? (
<>
<div className={style.memberName}>{member.name}</div>
<div className={style.memberEmail}>{member.email}</div>
</>
) : (
<div className={style.memberName}>{member.email}</div>
)}
</div>
<div
className={clsx(style.roleOrStatus, {
pending: !member.accepted,
})}
>
{member.accepted
? member.permission === Permission.Owner
? 'Workspace Owner'
: 'Member'
: 'Pending'}
</div>
<Menu
content={
<MenuItem data-member-id={member.id} onClick={handleRevoke}>
{operationButtonInfo.leaveOrRevokeText}
</MenuItem>
}
placement="bottom"
disablePortal={true}
trigger="click"
>
<IconButton
disabled={!operationButtonInfo.show}
style={{
visibility: operationButtonInfo.show ? 'visible' : 'hidden',
flexShrink: 0,
}}
>
<MoreVerticalIcon />
</IconButton>
</Menu>
</div>
</>
);
};
export const MembersPanel = (props: MembersPanelProps): ReactElement | null => {
if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return <MembersPanelLocal />;
}
return (
<ErrorBoundary FallbackComponent={AnyErrorBoundary}>
<Suspense>
<CloudWorkspaceMembersPanel {...props} />
</Suspense>
</ErrorBoundary>
);
};

View File

@ -1,21 +1,23 @@
import { FlexWrapper, Input, toast, Wrapper } from '@affine/component';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CameraIcon, DoneIcon } from '@blocksuite/icons';
import { IconButton } from '@toeverything/components/button';
import { useBlockSuiteWorkspaceAvatarUrl } from '@toeverything/hooks/use-block-suite-workspace-avatar-url';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import { Upload } from '../../pure/file-upload';
import { type WorkspaceSettingDetailProps } from './index';
import * as style from './style.css';
interface ProfilePanelProps {
export interface ProfilePanelProps extends WorkspaceSettingDetailProps {
workspace: AffineOfficialWorkspace;
}
export const ProfilePanel = ({ workspace }: ProfilePanelProps) => {
export const ProfilePanel = ({ workspace, isOwner }: ProfilePanelProps) => {
const t = useAFFiNEI18N();
const [, update] = useBlockSuiteWorkspaceAvatarUrl(
@ -38,7 +40,7 @@ export const ProfilePanel = ({ workspace }: ProfilePanelProps) => {
return (
<div className={style.profileWrapper}>
<div className={style.avatarWrapper}>
<div className={clsx(style.avatarWrapper, { disable: !isOwner })}>
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={update}
@ -59,6 +61,7 @@ export const ProfilePanel = ({ workspace }: ProfilePanelProps) => {
<div className={style.label}>{t['Workspace Name']()}</div>
<FlexWrapper alignItems="center" flexGrow="1">
<Input
disabled={!isOwner}
width={280}
height={32}
defaultValue={input}

View File

@ -1,18 +1,19 @@
import { FlexWrapper, Switch } from '@affine/component';
import { FlexWrapper, Input, Switch } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import { Unreachable } from '@affine/env/constant';
import type {
AffineCloudWorkspace,
AffinePublicWorkspace,
LocalWorkspace,
} from '@affine/env/workspace';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import { toast } from '../../../utils';
import { EnableAffineCloudModal } from '../enable-affine-cloud-modal';
import { TmpDisableAffineCloudModal } from '../tmp-disable-affine-cloud-modal';
@ -29,14 +30,16 @@ export interface PublishPanelLocalProps
}
export interface PublishPanelAffineProps
extends Omit<WorkspaceSettingDetailProps, 'workspaceId'> {
workspace: AffineCloudWorkspace;
workspace: AffineCloudWorkspace | AffinePublicWorkspace;
}
const PublishPanelAffine = (props: PublishPanelAffineProps) => {
const { workspace } = props;
const t = useAFFiNEI18N();
// const toggleWorkspacePublish = useToggleWorkspacePublish(workspace);
const isPublic = useMemo(() => {
return workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC;
}, [workspace]);
const [origin, setOrigin] = useState('');
const shareUrl = origin + '/public-workspace/' + workspace.id;
@ -54,35 +57,35 @@ const PublishPanelAffine = (props: PublishPanelAffineProps) => {
}, [shareUrl, t]);
return (
<>
<div style={{ display: 'none' }}>
<SettingRow
name={t['Publish']()}
desc={
// workspace.public ? t['Unpublished hint']() : t['Published hint']()
'UNFINISHED'
}
desc={isPublic ? t['Unpublished hint']() : t['Published hint']()}
style={{
marginBottom: isPublic ? '12px' : '25px',
}}
>
{/* <Switch
checked={workspace.public}
onChange={checked => toggleWorkspacePublish(checked)}
/> */}
<Switch
checked={isPublic}
// onChange={useCallback(value => {
// console.log('onChange', value);
// }, [])}
/>
</SettingRow>
<FlexWrapper justifyContent="space-between">
{isPublic ? (
<FlexWrapper justifyContent="space-between" marginBottom={25}>
<Input value={shareUrl} disabled />
<Button
className={style.urlButton}
size="large"
onClick={useCallback(() => {
window.open(shareUrl, '_blank');
}, [shareUrl])}
title={shareUrl}
onClick={copyUrl}
style={{
marginLeft: '20px',
}}
>
{shareUrl}
</Button>
<Button size="large" onClick={copyUrl}>
{t['Copy']()}
</Button>
</FlexWrapper>
</>
) : null}
</div>
);
};
@ -164,7 +167,10 @@ const PublishPanelLocal = ({
};
export const PublishPanel = (props: PublishPanelProps) => {
if (props.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD) {
if (
props.workspace.flavour === WorkspaceFlavour.AFFINE_CLOUD ||
props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC
) {
return <PublishPanelAffine {...props} workspace={props.workspace} />;
} else if (props.workspace.flavour === WorkspaceFlavour.LOCAL) {
return <PublishPanelLocal {...props} workspace={props.workspace} />;

View File

@ -1,5 +1,6 @@
import { FlexWrapper, toast } from '@affine/component';
import { SettingRow } from '@affine/component/setting-components';
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { Button } from '@toeverything/components/button';
import { Tooltip } from '@toeverything/components/tooltip';
@ -7,9 +8,6 @@ import type { MoveDBFileResult } from '@toeverything/infra/type';
import { useMemo } from 'react';
import { useCallback, useEffect, useState } from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import * as style from './style.css';
const useDBFileSecondaryPath = (workspaceId: string) => {
const [path, setPath] = useState<string | undefined>(undefined);
useEffect(() => {
@ -83,7 +81,7 @@ export const StoragePanel = ({ workspace }: StoragePanelProps) => {
>
<Button
data-testid="move-folder"
className={style.urlButton}
// className={style.urlButton}
size="large"
onClick={handleMoveTo}
>

View File

@ -5,6 +5,12 @@ export const profileWrapper = style({
alignItems: 'flex-end',
marginTop: '12px',
});
export const profileHandlerWrapper = style({
flexGrow: '1',
display: 'flex',
alignItems: 'center',
marginLeft: '20px',
});
export const avatarWrapper = style({
width: '56px',
@ -24,6 +30,9 @@ export const avatarWrapper = style({
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
display: 'flex',
});
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
display: 'flex',
});
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
width: '100%',
height: '100%',
@ -68,6 +77,70 @@ export const fakeWrapper = style({
},
});
export const membersList = style({
marginTop: '24px',
padding: '4px',
borderRadius: '12px',
background: 'var(--affine-background-primary-color)',
maxHeight: '464px',
overflow: 'hidden',
});
export const listItem = style({
padding: '0 4px 0 16px',
height: '58px',
display: 'flex',
width: '100%',
alignItems: 'center',
':hover': {
background: 'var(--affine-hover-color)',
borderRadius: '8px',
},
});
export const memberContainer = style({
width: '250px',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
marginLeft: '12px',
marginRight: '20px',
});
export const roleOrStatus = style({
// width: '20%',
flexGrow: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize: 'var(--affine-font-sm)',
selectors: {
'&.pending': {
color: 'var(--affine-primary-color)',
},
},
});
export const memberName = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-primary-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '22px',
});
export const memberEmail = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
lineHeight: '20px',
});
export const iconButton = style({});
globalStyle(`${listItem}:hover ${iconButton}`, {
opacity: 1,
pointerEvents: 'all',
});
export const label = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',

View File

@ -1,3 +1,185 @@
export const AccountSetting = () => {
return <div>AccountSetting</div>;
import { FlexWrapper, Input } from '@affine/component';
import {
SettingHeader,
SettingRow,
} from '@affine/component/setting-components';
import { UserAvatar } from '@affine/component/user-avatar';
import { uploadAvatarMutation } from '@affine/graphql';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { useMutation } from '@affine/workspace/affine/gql';
import { ArrowRightSmallIcon, CameraIcon, DoneIcon } from '@blocksuite/icons';
import { Button, IconButton } from '@toeverything/components/button';
import { useAtom } from 'jotai/index';
import { signOut } from 'next-auth/react';
import { type FC, useCallback, useState } from 'react';
import { authAtom } from '../../../../atoms';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { Upload } from '../../../pure/file-upload';
import * as style from './style.css';
export const AvatarAndName = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const [input, setInput] = useState<string>(user.name);
const { trigger: avatarTrigger } = useMutation({
mutation: uploadAvatarMutation,
});
const handleUpdateUserName = useCallback(
(newName: string) => {
user.update({ name: newName }).catch(console.error);
},
[user]
);
const handleUpdateUserAvatar = useCallback(
async (file: File) => {
await avatarTrigger({
id: user.id,
avatar: file,
});
// XXX: This is a hack to force the user to update, since next-auth can not only use update function without params
user.update({ name: user.name }).catch(console.error);
},
[avatarTrigger, user]
);
return (
<>
<SettingRow
name={t['com.affine.settings.profile']()}
desc={t['com.affine.settings.profile.message']()}
spreadCol={false}
>
<FlexWrapper style={{ margin: '12px 0 24px 0' }} alignItems="center">
<div className={style.avatarWrapper}>
<Upload
accept="image/gif,image/jpeg,image/jpg,image/png,image/svg"
fileChange={handleUpdateUserAvatar}
data-testid="upload-user-avatar"
>
<>
<div className="camera-icon-wrapper">
<CameraIcon />
</div>
<UserAvatar
size={56}
name={user.name}
url={user.image}
className="avatar"
/>
</>
</Upload>
</div>
<div className={style.profileInputWrapper}>
<label>{t['com.affine.settings.profile.name']()}</label>
<FlexWrapper alignItems="center">
<Input
defaultValue={input}
data-testid="user-name-input"
placeholder={t['com.affine.settings.profile.placeholder']()}
maxLength={64}
minLength={0}
width={280}
height={28}
onChange={setInput}
/>
{input && input === user.name ? null : (
<IconButton
data-testid="save-user-name"
onClick={() => {
handleUpdateUserName(input);
}}
style={{
color: 'var(--affine-primary-color)',
marginLeft: '12px',
}}
>
<DoneIcon />
</IconButton>
)}
</FlexWrapper>
</div>
</FlexWrapper>
</SettingRow>
</>
);
};
export const AccountSetting: FC = () => {
const t = useAFFiNEI18N();
const user = useCurrentUser();
const [, setAuthModal] = useAtom(authAtom);
const onChangeEmail = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: 'changeEmail',
});
}, [setAuthModal, user.email]);
const onChangePassword = useCallback(() => {
setAuthModal({
openModal: true,
state: 'sendEmail',
email: user.email,
emailType: 'changePassword',
});
}, [setAuthModal, user.email]);
return (
<>
<SettingHeader
title={t['com.affine.setting.account']()}
subtitle={t['com.affine.setting.account.message']()}
data-testid="account-title"
/>
<AvatarAndName />
<SettingRow name={t['com.affine.settings.email']()} desc={user.email}>
<Button onClick={onChangeEmail}>
{t['com.affine.settings.email.action']()}
</Button>
</SettingRow>
<SettingRow
name={t['com.affine.settings.password']()}
desc={t['com.affine.settings.password.message']()}
>
<Button onClick={onChangePassword}>
{user.hasPassword
? t['com.affine.settings.password.action.change']()
: t['com.affine.settings.password.action.set']()}
</Button>
</SettingRow>
<SettingRow
name={t[`Sign out`]()}
desc={t['com.affine.setting.sign.out.message']()}
style={{ cursor: 'pointer' }}
onClick={useCallback(() => {
signOut().catch(console.error);
}, [])}
>
<ArrowRightSmallIcon />
</SettingRow>
{/*<SettingRow*/}
{/* name={*/}
{/* <span style={{ color: 'var(--affine-warning-color)' }}>*/}
{/* {t['com.affine.setting.account.delete']()}*/}
{/* </span>*/}
{/* }*/}
{/* desc={t['com.affine.setting.account.delete.message']()}*/}
{/* style={{ cursor: 'pointer' }}*/}
{/* onClick={useCallback(() => {*/}
{/* toast('Function coming soon');*/}
{/* }, [])}*/}
{/* testId="delete-account-button"*/}
{/*>*/}
{/* <ArrowRightSmallIcon />*/}
{/*</SettingRow>*/}
</>
);
};

View File

@ -0,0 +1,41 @@
import { globalStyle, style } from '@vanilla-extract/css';
export const profileInputWrapper = style({
marginLeft: '20px',
});
globalStyle(`${profileInputWrapper} label`, {
display: 'block',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
marginBottom: '4px',
});
export const avatarWrapper = style({
width: '56px',
height: '56px',
borderRadius: '50%',
position: 'relative',
overflow: 'hidden',
cursor: 'pointer',
flexShrink: '0',
selectors: {
'&.disable': {
cursor: 'default',
pointerEvents: 'none',
},
},
});
globalStyle(`${avatarWrapper}:hover .camera-icon-wrapper`, {
display: 'flex',
});
globalStyle(`${avatarWrapper} .camera-icon-wrapper`, {
width: '100%',
height: '100%',
position: 'absolute',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'rgba(60, 61, 63, 0.5)',
zIndex: '1',
color: 'var(--affine-white)',
fontSize: 'var(--affine-font-h-4)',
});

View File

@ -7,6 +7,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ContactWithUsIcon } from '@blocksuite/icons';
import { Suspense, useCallback } from 'react';
import { useCurrenLoginStatus } from '../../../hooks/affine/use-curren-login-status';
import { AccountSetting } from './account-setting';
import {
GeneralSetting,
@ -38,6 +39,7 @@ export const SettingModal = ({
onSettingClick,
}: SettingModalProps) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus();
const generalSettingList = useGeneralSettingList();
@ -85,7 +87,9 @@ export const SettingModal = ({
{generalSettingList.find(v => v.key === activeTab) ? (
<GeneralSetting generalKey={activeTab as GeneralSettingKeys} />
) : null}
{activeTab === 'account' ? <AccountSetting /> : null}
{activeTab === 'account' && loginStatus === 'authenticated' ? (
<AccountSetting />
) : null}
</div>
<div className="footer">
<div className={footerIconWrapper}>

View File

@ -1,41 +1,101 @@
import { ScrollableContainer } from '@affine/component';
import {
WorkspaceListItemSkeleton,
WorkspaceListSkeleton,
} from '@affine/component/setting-components';
import { UserAvatar } from '@affine/component/user-avatar';
import { WorkspaceAvatar } from '@affine/component/workspace-avatar';
import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { Logo1Icon } from '@blocksuite/icons';
import { Tooltip } from '@toeverything/components/tooltip';
import { useBlockSuiteWorkspaceName } from '@toeverything/hooks/use-block-suite-workspace-name';
import { useStaticBlockSuiteWorkspace } from '@toeverything/infra/__internal__/react';
import clsx from 'clsx';
import { useAtomValue } from 'jotai';
import { Suspense, useRef } from 'react';
import { useAtom, useAtomValue } from 'jotai/react';
import {
type ReactElement,
Suspense,
useCallback,
useMemo,
useRef,
} from 'react';
import { authAtom } from '../../../../atoms';
import { useCurrenLoginStatus } from '../../../../hooks/affine/use-curren-login-status';
import { useCurrentUser } from '../../../../hooks/affine/use-current-user';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import type {
GeneralSettingKeys,
GeneralSettingList,
} from '../general-setting';
import {
accountButton,
currentWorkspaceLabel,
settingSlideBar,
sidebarFooter,
sidebarItemsWrapper,
sidebarSelectItem,
sidebarSubtitle,
sidebarTitle,
} from './style.css';
interface SettingSidebarProps {
generalSettingList: GeneralSettingList;
onGeneralSettingClick: (key: GeneralSettingKeys) => void;
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
selectedGeneralKey: string | null;
export type UserInfoProps = {
onAccountSettingClick: () => void;
}
};
export const UserInfo = ({
onAccountSettingClick,
}: UserInfoProps): ReactElement => {
const user = useCurrentUser();
return (
<div className={accountButton} onClick={onAccountSettingClick}>
<UserAvatar
size={28}
name={user.name}
url={user.image}
className="avatar"
/>
<div className="content">
<div className="name" title="xxx">
{user.name}
</div>
<div className="email" title="xxx">
{user.email}
</div>
</div>
</div>
);
};
export const SignInButton = () => {
const t = useAFFiNEI18N();
const [, setAuthModal] = useAtom(authAtom);
return (
<div
className={accountButton}
onClick={useCallback(() => {
setAuthModal({ openModal: true, state: 'signIn' });
}, [setAuthModal])}
>
<div className="avatar not-sign">
<Logo1Icon />
</div>
<div className="content">
<div className="name" title={t['com.affine.settings.sign']()}>
{t['com.affine.settings.sign']()}
</div>
<div className="email" title={t['com.affine.setting.sign.message']()}>
{t['com.affine.setting.sign.message']()}
</div>
</div>
</div>
);
};
export const SettingSidebar = ({
generalSettingList,
@ -43,9 +103,17 @@ export const SettingSidebar = ({
onWorkspaceSettingClick,
selectedWorkspaceId,
selectedGeneralKey,
}: SettingSidebarProps) => {
onAccountSettingClick,
}: {
generalSettingList: GeneralSettingList;
onGeneralSettingClick: (key: GeneralSettingKeys) => void;
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
selectedGeneralKey: string | null;
onAccountSettingClick: () => void;
}) => {
const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus();
return (
<div className={settingSlideBar} data-testid="settings-sidebar">
<div className={sidebarTitle}>{t['Settings']()}</div>
@ -79,32 +147,43 @@ export const SettingSidebar = ({
</div>
<div className={clsx(sidebarItemsWrapper, 'scroll')}>
<Suspense fallback={<WorkspaceListSkeleton />}>
<ScrollableContainer>
<WorkspaceList
onWorkspaceSettingClick={onWorkspaceSettingClick}
selectedWorkspaceId={selectedWorkspaceId}
/>
</ScrollableContainer>
</Suspense>
</div>
<div className={sidebarFooter}>
{runtimeConfig.enableCloud && loginStatus === 'unauthenticated' ? (
<SignInButton />
) : null}
{runtimeConfig.enableCloud && loginStatus === 'authenticated' ? (
<UserInfo onAccountSettingClick={onAccountSettingClick} />
) : null}
</div>
</div>
);
};
interface WorkspaceListProps {
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
}
export const WorkspaceList = ({
onWorkspaceSettingClick,
selectedWorkspaceId,
}: WorkspaceListProps) => {
}: {
onWorkspaceSettingClick: (workspaceId: string) => void;
selectedWorkspaceId: string | null;
}) => {
const workspaces = useAtomValue(rootWorkspacesMetadataAtom);
const [currentWorkspace] = useCurrentWorkspace();
const workspaceList = useMemo(() => {
return workspaces.filter(
({ flavour }) => flavour !== WorkspaceFlavour.AFFINE_PUBLIC
);
}, [workspaces]);
return (
<>
{workspaces.map(workspace => {
{workspaceList.map(workspace => {
return (
<Suspense key={workspace.id} fallback={<WorkspaceListItemSkeleton />}>
<WorkspaceListItem
@ -122,19 +201,17 @@ export const WorkspaceList = ({
);
};
interface WorkspaceListItemProps {
meta: RootWorkspaceMetadata;
onClick: () => void;
isCurrent: boolean;
isActive: boolean;
}
const WorkspaceListItem = ({
meta,
onClick,
isCurrent,
isActive,
}: WorkspaceListItemProps) => {
}: {
meta: RootWorkspaceMetadata;
onClick: () => void;
isCurrent: boolean;
isActive: boolean;
}) => {
const workspace = useStaticBlockSuiteWorkspace(meta.id);
const [workspaceName] = useBlockSuiteWorkspaceName(workspace);
const ref = useRef(null);

View File

@ -34,7 +34,7 @@ export const sidebarItemsWrapper = style({
selectors: {
'&.scroll': {
flexGrow: 1,
overflowY: 'hidden',
overflowY: 'auto',
},
},
});
@ -92,6 +92,8 @@ export const currentWorkspaceLabel = style({
},
});
export const sidebarFooter = style({ padding: '0 16px' });
export const accountButton = style({
height: '42px',
padding: '4px 8px',
@ -110,6 +112,20 @@ globalStyle(`${accountButton} .avatar`, {
border: '1px solid',
borderColor: 'var(--affine-white)',
marginRight: '10px',
flexShrink: 0,
});
globalStyle(`${accountButton} .avatar.not-sign`, {
width: '28px',
height: '28px',
borderRadius: '50%',
fontSize: '22px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
borderColor: 'var(--affine-border-color)',
color: 'var(--affine-border-color)',
background: 'var(--affine-white)',
});
globalStyle(`${accountButton} .content`, {
flexGrow: '1',

View File

@ -3,16 +3,17 @@ import { globalStyle, style } from '@vanilla-extract/css';
export const settingContent = style({
flexGrow: '1',
height: '100%',
padding: '40px 15px 20px',
overflow: 'auto',
padding: '40px 15px',
overflow: 'hidden',
});
globalStyle(`${settingContent} .wrapper`, {
width: '66%',
minWidth: '450px',
width: '60%',
padding: '0 15px',
height: '100%',
maxWidth: '560px',
minWidth: '560px',
margin: '0 auto',
overflowY: 'auto',
});
globalStyle(`${settingContent} .wrapper::-webkit-scrollbar`, {

View File

@ -1,9 +1,16 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { rootWorkspacesMetadataAtom } from '@affine/workspace/atom';
import { usePassiveWorkspaceEffect } from '@toeverything/infra/__internal__/react';
import { useSetAtom } from 'jotai';
import { useAtomValue } from 'jotai';
import { useCallback } from 'react';
import { getUIAdapter } from '../../../../adapters/workspace';
import { openSettingModalAtom } from '../../../../atoms';
import { useLeaveWorkspace } from '../../../../hooks/affine/use-leave-workspace';
import { useCurrentWorkspace } from '../../../../hooks/current/use-current-workspace';
import { useOnTransformWorkspace } from '../../../../hooks/root/use-on-transform-workspace';
import {
RouteLogic,
@ -13,28 +20,83 @@ import { useWorkspace } from '../../../../hooks/use-workspace';
import { useAppHelper } from '../../../../hooks/use-workspaces';
export const WorkspaceSetting = ({ workspaceId }: { workspaceId: string }) => {
const t = useAFFiNEI18N();
const { jumpToSubPath, jumpToIndex } = useNavigateHelper();
const [currentWorkspace] = useCurrentWorkspace();
const workspace = useWorkspace(workspaceId);
const workspaces = useAtomValue(rootWorkspacesMetadataAtom);
const pushNotification = useSetAtom(pushNotificationAtom);
const leaveWorkspace = useLeaveWorkspace();
usePassiveWorkspaceEffect(workspace.blockSuiteWorkspace);
const setSettingModal = useSetAtom(openSettingModalAtom);
const helper = useAppHelper();
const { jumpToIndex } = useNavigateHelper();
const { deleteWorkspace } = useAppHelper();
const { NewSettingsDetail } = getUIAdapter(workspace.flavour);
const onDeleteWorkspace = useCallback(
async (id: string) => {
await helper.deleteWorkspace(id);
const closeAndJumpOut = useCallback(() => {
setSettingModal(prev => ({ ...prev, open: false, workspaceId: null }));
jumpToIndex(RouteLogic.REPLACE);
},
[helper, jumpToIndex, setSettingModal]
if (currentWorkspace.id === workspaceId) {
const backWorkspace = workspaces.find(ws => ws.id !== workspaceId);
// TODO: if there is no workspace, jump to a new page(wait for design)
if (backWorkspace) {
jumpToSubPath(
backWorkspace?.id || '',
WorkspaceSubPath.ALL,
RouteLogic.REPLACE
);
} else {
setTimeout(() => {
jumpToIndex(RouteLogic.REPLACE);
}, 100);
}
}
}, [
currentWorkspace.id,
jumpToIndex,
jumpToSubPath,
setSettingModal,
workspaceId,
workspaces,
]);
const handleDeleteWorkspace = useCallback(async () => {
closeAndJumpOut();
await deleteWorkspace(workspaceId);
pushNotification({
title: t['Successfully deleted'](),
type: 'success',
});
}, [closeAndJumpOut, deleteWorkspace, pushNotification, t, workspaceId]);
const handleLeaveWorkspace = useCallback(async () => {
closeAndJumpOut();
await leaveWorkspace(workspaceId);
pushNotification({
title: 'Successfully leave',
type: 'success',
});
}, [closeAndJumpOut, leaveWorkspace, pushNotification, workspaceId]);
const onTransformWorkspace = useOnTransformWorkspace();
// const handleDelete = useCallback(async () => {
// await onDeleteWorkspace();
// toast(t['Successfully deleted'](), {
// portal: document.body,
// });
// onClose();
// }, [onClose, onDeleteWorkspace, t, workspace.id]);
return (
<NewSettingsDetail
onDeleteCloudWorkspace={handleDeleteWorkspace}
onDeleteLocalWorkspace={handleDeleteWorkspace}
onLeaveWorkspace={handleLeaveWorkspace}
onTransformWorkspace={onTransformWorkspace}
onDeleteWorkspace={onDeleteWorkspace}
currentWorkspaceId={workspaceId}
/>
);

View File

@ -0,0 +1,48 @@
import { ShareMenu } from '@affine/component/share-menu';
import {
type AffineOfficialWorkspace,
WorkspaceFlavour,
} from '@affine/env/workspace';
import type { Page } from '@blocksuite/store';
import { useState } from 'react';
import { useIsSharedPage } from '../../../hooks/affine/use-is-shared-page';
import { useOnTransformWorkspace } from '../../../hooks/root/use-on-transform-workspace';
import { EnableAffineCloudModal } from '../enable-affine-cloud-modal';
type SharePageModalProps = {
workspace: AffineOfficialWorkspace;
page: Page;
};
export const SharePageModal = ({ workspace, page }: SharePageModalProps) => {
const onTransformWorkspace = useOnTransformWorkspace();
const [open, setOpen] = useState(false);
return (
<>
<ShareMenu
workspace={workspace}
currentPage={page}
useIsSharedPage={useIsSharedPage}
onEnableAffineCloud={() => setOpen(true)}
togglePagePublic={async () => {}}
/>
{workspace.flavour === WorkspaceFlavour.LOCAL ? (
<EnableAffineCloudModal
open={open}
onClose={() => {
setOpen(false);
}}
onConfirm={() => {
onTransformWorkspace(
WorkspaceFlavour.LOCAL,
WorkspaceFlavour.AFFINE_CLOUD,
workspace
);
setOpen(false);
}}
/>
) : null}
</>
);
};

View File

@ -1,3 +1,4 @@
import type { AffineOfficialWorkspace } from '@affine/env/workspace';
import { WorkspaceFlavour } from '@affine/env/workspace';
import {
useBlockSuitePageMeta,
@ -12,7 +13,6 @@ import {
useState,
} from 'react';
import type { AffineOfficialWorkspace } from '../../../shared';
import { EditorModeSwitch } from '../block-suite-mode-switch';
import { PageMenu } from './operation-menu';
import * as styles from './styles.css';
@ -139,7 +139,7 @@ const BlockSuiteTitleWithRename = (props: BlockSuiteHeaderTitleProps) => {
};
export const BlockSuiteHeaderTitle = (props: BlockSuiteHeaderTitleProps) => {
if (props.workspace.flavour === WorkspaceFlavour.PUBLIC) {
if (props.workspace.flavour === WorkspaceFlavour.AFFINE_PUBLIC) {
return <StableTitle {...props} />;
}
return <BlockSuiteTitleWithRename {...props} />;

View File

@ -0,0 +1,53 @@
import { UserAvatar } from '@affine/component/user-avatar';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { signIn } from 'next-auth/react';
import { useCurrenLoginStatus } from '../../hooks/affine/use-curren-login-status';
import { useCurrentUser } from '../../hooks/affine/use-current-user';
import { StyledSignInButton } from '../pure/footer/styles';
export const LoginCard = () => {
const t = useAFFiNEI18N();
const loginStatus = useCurrenLoginStatus();
if (loginStatus === 'authenticated') {
return <UserCard />;
}
return (
<StyledSignInButton
data-testid="sign-in-button"
onClick={async () => {
// jump to login page
signIn().catch(console.error);
}}
>
<div className="circle">
<CloudWorkspaceIcon />
</div>{' '}
{t['Sign in']()}
</StyledSignInButton>
);
};
const UserCard = () => {
const user = useCurrentUser();
return (
<div
style={{
display: 'flex',
alignItems: 'center',
}}
>
<UserAvatar
size={28}
name={user.name}
url={user.image}
className="avatar"
/>
<div style={{ marginLeft: '15px' }}>
<div>{user.name}</div>
<div>{user.email}</div>
</div>
</div>
);
};

View File

@ -0,0 +1,58 @@
import { pushNotificationAtom } from '@affine/component/notification-center';
import { assertExists } from '@blocksuite/global/utils';
import { GraphQLError } from 'graphql';
import { useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactElement } from 'react';
import { useCallback } from 'react';
import type { SWRConfiguration } from 'swr';
import { SWRConfig } from 'swr';
const cloudConfig: SWRConfiguration = {
suspense: true,
use: [
useSWRNext => (key, fetcher, config) => {
const pushNotification = useSetAtom(pushNotificationAtom);
const fetcherWrapper = useCallback(
async (...args: any[]) => {
assertExists(fetcher);
const d = fetcher(...args);
if (d instanceof Promise) {
return d.catch(e => {
if (
e instanceof GraphQLError ||
(Array.isArray(e) && e[0] instanceof GraphQLError)
) {
const graphQLError = e instanceof GraphQLError ? e : e[0];
pushNotification({
title: 'GraphQL Error',
message: graphQLError.toString(),
key: Date.now().toString(),
type: 'error',
});
} else {
pushNotification({
title: 'Error',
message: e.toString(),
key: Date.now().toString(),
type: 'error',
});
}
throw e;
});
}
return d;
},
[fetcher, pushNotification]
);
return useSWRNext(key, fetcher ? fetcherWrapper : fetcher, config);
},
],
};
export const Provider = (props: PropsWithChildren): ReactElement => {
if (!runtimeConfig.enableCloud) {
return <>{props.children}</>;
}
return <SWRConfig value={cloudConfig}>{props.children}</SWRConfig>;
};

View File

@ -1,39 +1,42 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { CloudWorkspaceIcon } from '@blocksuite/icons';
import { Button } from '@toeverything/components/button';
import { useSetAtom } from 'jotai';
import { type CSSProperties, forwardRef } from 'react';
import { signIn } from 'next-auth/react';
import { type CSSProperties, type FC, forwardRef, useCallback } from 'react';
import { openDisableCloudAlertModalAtom } from '../../../atoms';
import { useCurrenLoginStatus } from '../../../hooks/affine/use-curren-login-status';
// import { openDisableCloudAlertModalAtom } from '../../../atoms';
import { stringToColour } from '../../../utils';
import { StyledFooter } from './styles';
export const Footer = () => {
const t = useAFFiNEI18N();
const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
import { StyledFooter, StyledSignInButton } from './styles';
export const Footer: FC = () => {
const loginStatus = useCurrenLoginStatus();
// const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
return (
<StyledFooter data-testid="workspace-list-modal-footer">
<Button
data-testid="sign-in-button"
type="plain"
icon={
<CloudWorkspaceIcon
style={{ color: 'var(--affine-primary-color)' }}
/>
}
onClick={async () => {
if (!runtimeConfig.enableCloud) {
setOpen(true);
}
}}
>
{t['Sign in']()}
</Button>
{loginStatus === 'authenticated' ? null : <SignInButton />}
</StyledFooter>
);
};
const SignInButton = () => {
const t = useAFFiNEI18N();
return (
<StyledSignInButton
data-testid="sign-in-button"
onClick={useCallback(() => {
signIn().catch(console.error);
}, [])}
>
<div className="circle">
<CloudWorkspaceIcon />
</div>
{t['Sign in']()}
</StyledSignInButton>
);
};
interface WorkspaceAvatarProps {
size: number;
name: string | undefined;

View File

@ -1,4 +1,19 @@
import { displayFlex, styled, textEllipsis } from '@affine/component';
import {
displayFlex,
displayInlineFlex,
styled,
textEllipsis,
} from '@affine/component';
export const StyledSplitLine = styled('div')(() => {
return {
width: '1px',
height: '20px',
background: 'var(--affine-border-color)',
marginRight: '24px',
};
});
export const StyleWorkspaceInfo = styled('div')(() => {
return {
marginLeft: '15px',
@ -110,3 +125,28 @@ export const StyledModalHeader = styled('div')(() => {
...displayFlex('space-between', 'center'),
};
});
export const StyledSignInButton = styled('button')(() => {
return {
fontWeight: 600,
paddingLeft: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingRight: '15px',
borderRadius: '8px',
'&:hover': {
backgroundColor: 'var(--affine-hover-color)',
},
'.circle': {
width: '40px',
height: '40px',
borderRadius: '20px',
color: 'var(--affine-primary-color)',
fontSize: '24px',
flexShrink: 0,
marginRight: '16px',
...displayInlineFlex('center', 'center'),
},
};
});

View File

@ -8,19 +8,27 @@ import { WorkspaceFlavour } from '@affine/env/workspace';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { RootWorkspaceMetadata } from '@affine/workspace/atom';
import {
AccountIcon,
CloudWorkspaceIcon,
ImportIcon,
MoreHorizontalIcon,
PlusIcon,
SignOutIcon,
} from '@blocksuite/icons';
import type { DragEndEvent } from '@dnd-kit/core';
import { Popover } from '@mui/material';
import { IconButton } from '@toeverything/components/button';
import { Divider } from '@toeverything/components/divider';
import { useSetAtom } from 'jotai';
// eslint-disable-next-line @typescript-eslint/no-restricted-imports
import { signOut, useSession } from 'next-auth/react';
import { useCallback } from 'react';
import { openDisableCloudAlertModalAtom } from '../../../atoms';
import {
authAtom,
openDisableCloudAlertModalAtom,
openSettingModalAtom,
} from '../../../atoms';
import type { AllWorkspace } from '../../../shared';
import {
StyledCreateWorkspaceCardPill,
@ -39,6 +47,7 @@ import {
StyledSignInCardPillTextCotainer,
StyledSignInCardPillTextPrimary,
StyledSignInCardPillTextSecondary,
StyledWorkspaceFlavourTitle,
} from './styles';
interface WorkspaceModalProps {
@ -56,18 +65,31 @@ interface WorkspaceModalProps {
const AccountMenu = () => {
const t = useAFFiNEI18N();
const setOpen = useSetAtom(openSettingModalAtom);
return (
<div>
<div>Unlimted</div>
{/* <div>Unlimted</div>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
<MenuItem icon={<ImportIcon />} data-testid="editor-option-menu-import">
{t['com.affine.workspace.cloud.join']()}
</MenuItem>
<MenuItem icon={<ImportIcon />} data-testid="editor-option-menu-import">
</MenuItem> */}
<MenuItem
icon={<AccountIcon />}
data-testid="editor-option-menu-import"
onClick={useCallback(() => {
setOpen(prev => ({ ...prev, open: true, activeTab: 'account' }));
}, [setOpen])}
>
{t['com.affine.workspace.cloud.account.settings']()}
</MenuItem>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
<MenuItem icon={<ImportIcon />} data-testid="editor-option-menu-import">
<Divider />
<MenuItem
icon={<SignOutIcon />}
data-testid="editor-option-menu-import"
onClick={useCallback(() => {
signOut().catch(console.error);
}, [])}
>
{t['com.affine.workspace.cloud.account.logout']()}
</MenuItem>
</div>
@ -89,31 +111,16 @@ const CloudWorkSpaceList = ({
<StyledModalHeader>
<StyledModalHeaderLeft>
<StyledModalTitle>
{t['com.affine.workspace.cloud.sync']()}
{t['com.affine.workspace.cloud']()}
</StyledModalTitle>
</StyledModalHeaderLeft>
<StyledOperationWrapper>
<Menu
placement="bottom-end"
trigger={['click']}
content={<AccountMenu />}
zIndex={1000}
>
<IconButton
data-testid="previous-image-button"
icon={<MoreHorizontalIcon />}
type="plain"
/>
</Menu>
</StyledOperationWrapper>
</StyledModalHeader>
<StyledModalContent>
<WorkspaceList
disabled={disabled}
items={
workspaces.filter(
({ flavour }) => flavour !== WorkspaceFlavour.PUBLIC
({ flavour }) => flavour === WorkspaceFlavour.AFFINE_CLOUD
) as (AffineCloudWorkspace | LocalWorkspace)[]
}
currentWorkspaceId={currentWorkspaceId}
@ -129,7 +136,6 @@ const CloudWorkSpaceList = ({
[onMoveWorkspace]
)}
/>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
</StyledModalContent>
</>
);
@ -148,11 +154,18 @@ export const WorkspaceListModal = ({
onMoveWorkspace,
}: WorkspaceModalProps) => {
const t = useAFFiNEI18N();
const setOpen = useSetAtom(openDisableCloudAlertModalAtom);
const setOpen = useSetAtom(authAtom);
const setDisableCloudOpen = useSetAtom(openDisableCloudAlertModalAtom);
// TODO: AFFiNE Cloud support
const isLoggedIn = false;
const { data: session, status } = useSession();
const isLoggedIn = status === 'authenticated' ? true : false;
const anchorEl = document.getElementById('current-workspace');
const cloudWorkspaces = workspaces.filter(
({ flavour }) => flavour === WorkspaceFlavour.AFFINE_CLOUD
) as (AffineCloudWorkspace | LocalWorkspace)[];
const localWorkspaces = workspaces.filter(
({ flavour }) => flavour === WorkspaceFlavour.LOCAL
) as (AffineCloudWorkspace | LocalWorkspace)[];
return (
<Popover
sx={{
@ -164,6 +177,7 @@ export const WorkspaceListModal = ({
flexDirection: 'column',
boxShadow: 'var(--affine-shadow-2)',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
padding: '16px 12px',
},
maxHeight: '90vh',
}}
@ -171,16 +185,22 @@ export const WorkspaceListModal = ({
anchorEl={anchorEl}
onClose={onClose}
>
{!isLoggedIn ? (
<StyledModalHeaderContent>
<StyledSignInCardPill>
<MenuItem
style={{
height: 'auto',
padding: '8px 12px',
padding: '0px 12px',
}}
onClick={async () => {
if (!runtimeConfig.enableCloud) {
setOpen(true);
setDisableCloudOpen(true);
} else {
setOpen(state => ({
...state,
openModal: true,
}));
}
}}
data-testid="cloud-signin-button"
@ -194,16 +214,39 @@ export const WorkspaceListModal = ({
{t['com.affine.workspace.cloud.auth']()}
</StyledSignInCardPillTextPrimary>
<StyledSignInCardPillTextSecondary>
Sync with AFFiNE Cloud
{t['com.affine.workspace.cloud.description']()}
</StyledSignInCardPillTextSecondary>
</StyledSignInCardPillTextCotainer>
</StyledCreateWorkspaceCardPillContent>
</MenuItem>
</StyledSignInCardPill>
<Divider size="thinner" dividerColor="var(--affine-border-color)" />
<Divider style={{ margin: '12px 0px' }} />
</StyledModalHeaderContent>
) : (
<StyledModalHeaderContent>
<StyledModalHeader>
<StyledModalTitle>{session?.user.email}</StyledModalTitle>
<StyledOperationWrapper>
<Menu
placement="bottom-end"
trigger={['click']}
content={<AccountMenu />}
zIndex={1000}
>
<IconButton
data-testid="more-button"
icon={<MoreHorizontalIcon />}
type="plain"
/>
</Menu>
</StyledOperationWrapper>
</StyledModalHeader>
<Divider style={{ margin: '12px 0px' }} />
</StyledModalHeaderContent>
)}
<StyledModalBody>
{isLoggedIn ? (
{isLoggedIn && cloudWorkspaces.length !== 0 ? (
<>
<CloudWorkSpaceList
disabled={disabled}
open={open}
@ -216,18 +259,18 @@ export const WorkspaceListModal = ({
currentWorkspaceId={currentWorkspaceId}
onMoveWorkspace={onMoveWorkspace}
/>
<Divider style={{ margin: '12px 0px', minHeight: '1px' }} />
</>
) : null}
<StyledModalHeader>
<StyledModalTitle>{t['Local Workspace']()}</StyledModalTitle>
<StyledWorkspaceFlavourTitle>
{t['com.affine.workspace.local']()}
</StyledWorkspaceFlavourTitle>
</StyledModalHeader>
<StyledModalContent>
<WorkspaceList
disabled={disabled}
items={
workspaces.filter(
({ flavour }) => flavour !== WorkspaceFlavour.PUBLIC
) as (AffineCloudWorkspace | LocalWorkspace)[]
}
items={localWorkspaces}
currentWorkspaceId={currentWorkspaceId}
onClick={onClickWorkspace}
onSettingClick={onClickWorkspaceSetting}

View File

@ -70,7 +70,6 @@ export const StyledCreateWorkspaceCard = styled('div')(() => {
});
export const StyledCreateWorkspaceCardPillContainer = styled('div')(() => {
return {
padding: '12px',
borderRadius: '10px',
display: 'flex',
margin: '-8px -4px',
@ -173,6 +172,7 @@ export const StyledModalContent = styled('div')({
flexWrap: 'wrap',
flexDirection: 'column',
width: '100%',
gap: '4px',
});
export const StyledModalFooterContent = styled('div')({
@ -180,7 +180,7 @@ export const StyledModalFooterContent = styled('div')({
flexWrap: 'wrap',
flexDirection: 'column',
width: '100%',
padding: '12px',
marginTop: '12px',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
});
@ -189,7 +189,6 @@ export const StyledModalHeaderContent = styled('div')({
flexWrap: 'wrap',
flexDirection: 'column',
width: '100%',
padding: '12px 12px 0px 12px',
backgroundColor: 'var(--affine-background-overlay-panel-color)',
});
@ -219,19 +218,27 @@ export const StyledModalHeader = styled('div')(() => {
left: 0,
top: 0,
borderRadius: '24px 24px 0 0',
padding: '12px 14px',
padding: '0px 14px',
...displayFlex('space-between', 'center'),
};
});
export const StyledModalBody = styled('div')(() => {
return {
padding: '0px 12px',
display: 'inline-flex',
flexDirection: 'column',
alignItems: 'flex-start',
gap: '12px',
gap: '4px',
flex: 1,
overflowY: 'auto',
};
});
export const StyledWorkspaceFlavourTitle = styled('div')(() => {
return {
fontSize: '12px',
fontWeight: 600,
color: 'var(--affine-text-secondary-color)',
lineHeight: '20px',
};
});

View File

@ -6,15 +6,16 @@ import {
} from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import type { PropertiesMeta } from '@affine/env/filter';
import type {
import {
WorkspaceFlavour,
WorkspaceHeaderProps,
type WorkspaceHeaderProps,
} from '@affine/env/workspace';
import { WorkspaceSubPath } from '@affine/env/workspace';
import { useCallback } from 'react';
import { useGetPageInfoById } from '../hooks/use-get-page-info';
import { useWorkspace } from '../hooks/use-workspace';
import { SharePageModal } from './affine/share-page-modal';
import { BlockSuiteHeaderTitle } from './blocksuite/block-suite-header-title';
import { filterContainerStyle } from './filter-container.css';
import { Header } from './pure/header';
@ -77,7 +78,6 @@ export function WorkspaceHeader({
const setting = useCollectionManager(currentWorkspaceId);
const currentWorkspace = useWorkspace(currentWorkspaceId);
const getPageInfoById = useGetPageInfoById(
currentWorkspace.blockSuiteWorkspace
);
@ -117,6 +117,15 @@ export function WorkspaceHeader({
// route in edit page
if ('pageId' in currentEntry) {
const isCloudWorkspace =
currentWorkspace.flavour === WorkspaceFlavour.AFFINE_CLOUD;
const currentPage = currentWorkspace.blockSuiteWorkspace.getPage(
currentEntry.pageId
);
const sharePageModal =
isCloudWorkspace && currentPage ? (
<SharePageModal workspace={currentWorkspace} page={currentPage} />
) : null;
return (
<Header
center={
@ -125,7 +134,12 @@ export function WorkspaceHeader({
pageId={currentEntry.pageId}
/>
}
right={<PluginHeader />}
right={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{sharePageModal}
<PluginHeader />
</div>
}
/>
);
}

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