mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-10 19:38:39 +00:00
Compare commits
65 Commits
v0.10.0-ca
...
v0.10.0-ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37ec552f74 | ||
|
|
9e3c79526c | ||
|
|
a015dc42bb | ||
|
|
17afe218fe | ||
|
|
7b204cc611 | ||
|
|
9102f1f9a9 | ||
|
|
f6b53a1167 | ||
|
|
6fcdb05925 | ||
|
|
99b35c7a93 | ||
|
|
fc27a2e906 | ||
|
|
b4d8f1428c | ||
|
|
1b0c604c02 | ||
|
|
581635f40b | ||
|
|
d752086846 | ||
|
|
f23ec9063c | ||
|
|
26b953ce57 | ||
|
|
72babe9157 | ||
|
|
b6ca81821e | ||
|
|
f11cc40ae3 | ||
|
|
95c1a44a0d | ||
|
|
198befb006 | ||
|
|
fc3516acfb | ||
|
|
de9e7f97a4 | ||
|
|
05ad6eb450 | ||
|
|
98d0ac3c90 | ||
|
|
2aa4b4c1f3 | ||
|
|
3798293d3e | ||
|
|
fd76d33421 | ||
|
|
2a4495f7ee | ||
|
|
1775138228 | ||
|
|
8c194ab8b0 | ||
|
|
5ba1c0dbdb | ||
|
|
59ec122940 | ||
|
|
588f63505d | ||
|
|
ef8024c657 | ||
|
|
abbd8235aa | ||
|
|
385de7d33b | ||
|
|
87571a0879 | ||
|
|
af24334264 | ||
|
|
9fc0152cb1 | ||
|
|
35dbbe561a | ||
|
|
50563dcb6e | ||
|
|
edb6e0fd69 | ||
|
|
9334a363c7 | ||
|
|
e0f7ac426c | ||
|
|
1deb6bffd3 | ||
|
|
ae6376edee | ||
|
|
780c164cc8 | ||
|
|
df69c908fe | ||
|
|
eaa90c9fb6 | ||
|
|
e8a88da9e4 | ||
|
|
3749125907 | ||
|
|
97d06432f0 | ||
|
|
df77ffde9a | ||
|
|
ef1228dcb4 | ||
|
|
21604a2cad | ||
|
|
7ecee01d20 | ||
|
|
2e4f6ef2ed | ||
|
|
9b43380b05 | ||
|
|
303dade2ef | ||
|
|
113b20f669 | ||
|
|
95d37fc63f | ||
|
|
858a1da35f | ||
|
|
1d62133f4f | ||
|
|
df054ac7f6 |
4
.github/actions/deploy/deploy.mjs
vendored
4
.github/actions/deploy/deploy.mjs
vendored
@@ -25,6 +25,8 @@ const {
|
||||
GCLOUD_CLOUD_SQL_INTERNAL_ENDPOINT,
|
||||
REDIS_HOST,
|
||||
REDIS_PASSWORD,
|
||||
STRIPE_API_KEY,
|
||||
STRIPE_WEBHOOK_KEY,
|
||||
} = process.env;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
@@ -96,6 +98,8 @@ const createHelmCommand = ({ isDryRun }) => {
|
||||
`--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-string graphql.app.payment.stripe.apiKey="${STRIPE_API_KEY}"`,
|
||||
`--set-string graphql.app.payment.stripe.webhookKey="${STRIPE_WEBHOOK_KEY}"`,
|
||||
`--set graphql.app.experimental.enableJwstCodec=true`,
|
||||
`--set sync.replicaCount=${syncReplicaCount}`,
|
||||
`--set-string sync.image.tag="${imageTag}"`,
|
||||
|
||||
@@ -100,6 +100,16 @@ spec:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.oauth.email.secretName }}"
|
||||
key: password
|
||||
- name: STRIPE_API_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.payment.stripe.secretName }}"
|
||||
key: stripeAPIKey
|
||||
- name: STRIPE_WEBHOOK_KEY
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: "{{ .Values.app.payment.stripe.secretName }}"
|
||||
key: stripeWebhookKey
|
||||
- name: DOC_MERGE_INTERVAL
|
||||
value: "{{ .Values.app.doc.mergeInterval }}"
|
||||
{{ if .Values.app.experimental.enableJwstCodec }}
|
||||
|
||||
@@ -16,7 +16,7 @@ spec:
|
||||
containers:
|
||||
- name: {{ .Chart.Name }}
|
||||
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
|
||||
command: ["yarn", "prisma", "migrate", "deploy"]
|
||||
command: ["yarn", "predeploy"]
|
||||
env:
|
||||
- name: NODE_ENV
|
||||
value: "{{ .Values.env }}"
|
||||
|
||||
8
.github/helm/affine/charts/graphql/templates/payment.yml
vendored
Normal file
8
.github/helm/affine/charts/graphql/templates/payment.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: "{{ .Values.app.payment.stripe.secretName }}"
|
||||
type: Opaque
|
||||
data:
|
||||
stripeAPIKey: "{{ .Values.app.payment.stripe.apiKey | b64enc }}"
|
||||
stripeWebhookKey: "{{ .Values.app.payment.stripe.webhookKey | b64enc }}"
|
||||
@@ -53,6 +53,11 @@ app:
|
||||
secretName: oauth-github
|
||||
clientId: ''
|
||||
clientSecret: ''
|
||||
payment:
|
||||
stripe:
|
||||
secretName: 'stripe'
|
||||
apiKey: ''
|
||||
webhookKey: ''
|
||||
|
||||
serviceAccount:
|
||||
create: true
|
||||
|
||||
8
.github/workflows/deploy.yml
vendored
8
.github/workflows/deploy.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
flavor:
|
||||
description: 'Build type (canary, beta, internal or stable)'
|
||||
description: 'Build type (canary, beta, or stable)'
|
||||
type: string
|
||||
default: canary
|
||||
|
||||
@@ -40,7 +40,6 @@ jobs:
|
||||
build-core:
|
||||
name: Build @affine/core
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -68,7 +67,6 @@ jobs:
|
||||
build-storage:
|
||||
name: Build Storage
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -90,7 +88,6 @@ jobs:
|
||||
build-storage-arm64:
|
||||
name: Build Storage arm64
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -112,7 +109,6 @@ jobs:
|
||||
build-docker:
|
||||
name: Build Docker
|
||||
runs-on: ubuntu-latest
|
||||
environment: ${{ github.event.inputs.flavor }}
|
||||
needs:
|
||||
- build-server
|
||||
- build-core
|
||||
@@ -247,3 +243,5 @@ jobs:
|
||||
REDIS_HOST: ${{ secrets.REDIS_HOST }}
|
||||
REDIS_PASSWORD: ${{ secrets.REDIS_PASSWORD }}
|
||||
CLOUD_SQL_IAM_ACCOUNT: ${{ secrets.CLOUD_SQL_IAM_ACCOUNT }}
|
||||
STRIPE_API_KEY: ${{ secrets.STRIPE_API_KEY }}
|
||||
STRIPE_WEBHOOK_KEY: ${{ secrets.STRIPE_WEBHOOK_KEY }}
|
||||
|
||||
2
.github/workflows/workers.yml
vendored
2
.github/workflows/workers.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Publish
|
||||
uses: cloudflare/wrangler-action@v3.3.1
|
||||
uses: cloudflare/wrangler-action@v3.3.2
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -12,4 +12,4 @@ npmPublishAccess: public
|
||||
|
||||
npmPublishRegistry: 'https://registry.npmjs.org'
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.0.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.0.1.cjs
|
||||
|
||||
8
Cargo.lock
generated
8
Cargo.lock
generated
@@ -2264,18 +2264,18 @@ checksum = "836fa6a3e1e547f9a2c4040802ec865b5d85f4014efe00555d7090a3dcaa1090"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.189"
|
||||
version = "1.0.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e422a44e74ad4001bdc8eede9a4570ab52f71190e9c076d14369f38b9200537"
|
||||
checksum = "91d3c334ca1ee894a2c6f6ad698fe8c435b76d504b13d436f0685d648d6d96f7"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.189"
|
||||
version = "1.0.190"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e48d1f918009ce3145511378cf68d613e3b3d9137d67272562080d68a2b32d5"
|
||||
checksum = "67c5609f394e5c2bd7fc51efda478004ea80ef42fee983d5c67a65e34f32c0e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
10
README.md
10
README.md
@@ -123,11 +123,11 @@ If you have questions, you are welcome to contact us. One of the best places to
|
||||
- [@affine/sdk](./packages/common/sdk) - SDK for developing plugins
|
||||
- [@affine/plugin-cli](./tools/plugin-cli) - CLI for developing plugins
|
||||
|
||||
| Official Plugin | Description | Status |
|
||||
| ----------------------------------------------------- | ----------------------------------------- | ------ |
|
||||
| [@affine/copilot-plugin](plugins/copilot) | AI Copilot that help you document writing | 🚧 |
|
||||
| [@affine/image-preview-plugin](plugins/image-preview) | Component for previewing an image | ✅ |
|
||||
| [@affine/outline](plugins/outline) | Outline for your document | ✅ |
|
||||
| Official Plugin | Description | Status |
|
||||
| ---------------------------------------------------------------- | ----------------------------------------- | ------ |
|
||||
| [@affine/copilot-plugin](./packages/plugins/copilot) | AI Copilot that help you document writing | 🚧 |
|
||||
| [@affine/image-preview-plugin](./packages/plugins/image-preview) | Component for previewing an image | ✅ |
|
||||
| [@affine/outline](./packages/plugins/outline) | Outline for your document | ✅ |
|
||||
|
||||
## Upstreams
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/monorepo",
|
||||
"version": "0.10.0-canary.3",
|
||||
"version": "0.10.0-canary.5",
|
||||
"private": true,
|
||||
"author": "toeverything",
|
||||
"license": "MIT",
|
||||
@@ -115,7 +115,7 @@
|
||||
"vitest-fetch-mock": "^0.2.2",
|
||||
"vitest-mock-extended": "^1.3.1"
|
||||
},
|
||||
"packageManager": "yarn@4.0.0",
|
||||
"packageManager": "yarn@4.0.1",
|
||||
"resolutions": {
|
||||
"vite": "^4.4.11",
|
||||
"array-buffer-byte-length": "npm:@nolyfill/array-buffer-byte-length@latest",
|
||||
|
||||
@@ -3,4 +3,6 @@ NEXTAUTH_URL="http://localhost:8080"
|
||||
OAUTH_EMAIL_SENDER="noreply@toeverything.info"
|
||||
OAUTH_EMAIL_LOGIN=""
|
||||
OAUTH_EMAIL_PASSWORD=""
|
||||
ENABLE_LOCAL_EMAIL="true"
|
||||
ENABLE_LOCAL_EMAIL="true"
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_KEY=
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_stripe_customers" (
|
||||
"user_id" VARCHAR NOT NULL,
|
||||
"stripe_customer_id" VARCHAR NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "user_stripe_customers_pkey" PRIMARY KEY ("user_id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_subscriptions" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"plan" VARCHAR(20) NOT NULL,
|
||||
"recurring" VARCHAR(20) NOT NULL,
|
||||
"stripe_subscription_id" TEXT NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"start" TIMESTAMPTZ(6) NOT NULL,
|
||||
"end" TIMESTAMPTZ(6) NOT NULL,
|
||||
"next_bill_at" TIMESTAMPTZ(6),
|
||||
"canceled_at" TIMESTAMPTZ(6),
|
||||
"trial_start" TIMESTAMPTZ(6),
|
||||
"trial_end" TIMESTAMPTZ(6),
|
||||
"stripe_schedule_id" VARCHAR,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
|
||||
CONSTRAINT "user_subscriptions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "user_invoices" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"user_id" VARCHAR(36) NOT NULL,
|
||||
"stripe_invoice_id" TEXT NOT NULL,
|
||||
"currency" VARCHAR(3) NOT NULL,
|
||||
"amount" INTEGER NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL,
|
||||
"plan" VARCHAR(20) NOT NULL,
|
||||
"recurring" VARCHAR(20) NOT NULL,
|
||||
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ(6) NOT NULL,
|
||||
"reason" VARCHAR NOT NULL,
|
||||
"last_payment_error" TEXT,
|
||||
|
||||
CONSTRAINT "user_invoices_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_stripe_customers_stripe_customer_id_key" ON "user_stripe_customers"("stripe_customer_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_subscriptions_user_id_key" ON "user_subscriptions"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_subscriptions_stripe_subscription_id_key" ON "user_subscriptions"("stripe_subscription_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "user_invoices_stripe_invoice_id_key" ON "user_invoices"("stripe_invoice_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_stripe_customers" ADD CONSTRAINT "user_stripe_customers_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_subscriptions" ADD CONSTRAINT "user_subscriptions_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "user_invoices" ADD CONSTRAINT "user_invoices_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "user_invoices" ADD COLUMN "link" TEXT;
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- The primary key for the `updates` table will be changed. If it partially fails, the table could be left without primary key constraint.
|
||||
- You are about to drop the column `object_id` on the `updates` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- DropIndex
|
||||
DROP INDEX "updates_workspace_id_guid_seq_key";
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "updates" DROP CONSTRAINT "updates_pkey",
|
||||
DROP COLUMN "object_id",
|
||||
ADD CONSTRAINT "updates_pkey" PRIMARY KEY ("workspace_id", "guid", "seq");
|
||||
@@ -0,0 +1,9 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "_data_migrations" (
|
||||
"id" VARCHAR(36) NOT NULL,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"started_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" TIMESTAMPTZ(6),
|
||||
|
||||
CONSTRAINT "_data_migrations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@affine/server",
|
||||
"private": true,
|
||||
"version": "0.10.0-canary.3",
|
||||
"version": "0.10.0-canary.5",
|
||||
"description": "Affine Node.js server",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -13,7 +13,9 @@
|
||||
"dev": "nodemon ./src/index.ts",
|
||||
"test": "ava --concurrency 1 --serial",
|
||||
"test:coverage": "c8 ava --concurrency 1 --serial",
|
||||
"postinstall": "prisma generate"
|
||||
"postinstall": "prisma generate",
|
||||
"data-migration": "node --loader ts-node/esm.mjs --es-module-specifier-resolution node ./src/data/app.ts",
|
||||
"predeploy": "yarn prisma migrate deploy && node --es-module-specifier-resolution node ./dist/data/app.js run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@apollo/server": "^4.9.4",
|
||||
@@ -25,6 +27,7 @@
|
||||
"@nestjs/apollo": "^12.0.9",
|
||||
"@nestjs/common": "^10.2.7",
|
||||
"@nestjs/core": "^10.2.7",
|
||||
"@nestjs/event-emitter": "^2.0.2",
|
||||
"@nestjs/graphql": "^12.0.9",
|
||||
"@nestjs/platform-express": "^10.2.7",
|
||||
"@nestjs/platform-socket.io": "^10.2.7",
|
||||
@@ -59,6 +62,7 @@
|
||||
"keyv": "^4.5.4",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nanoid": "^5.0.1",
|
||||
"nest-commander": "^3.12.0",
|
||||
"nestjs-throttler-storage-redis": "^0.4.1",
|
||||
"next-auth": "^4.23.2",
|
||||
"nodemailer": "^6.9.6",
|
||||
@@ -71,6 +75,7 @@
|
||||
"rxjs": "^7.8.1",
|
||||
"semver": "^7.5.4",
|
||||
"socket.io": "^4.7.2",
|
||||
"stripe": "^14.1.0",
|
||||
"ws": "^8.14.2",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
|
||||
@@ -49,6 +49,9 @@ model User {
|
||||
/// Not available if user signed up through OAuth providers
|
||||
password String? @db.VarChar
|
||||
features UserFeatureGates[]
|
||||
customer UserStripeCustomer?
|
||||
subscription UserSubscription?
|
||||
invoices UserInvoice[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -143,16 +146,14 @@ model Snapshot {
|
||||
@@map("snapshots")
|
||||
}
|
||||
|
||||
// backup during other update operation queue downtime
|
||||
model Update {
|
||||
objectId String @id @default(uuid()) @map("object_id") @db.VarChar
|
||||
workspaceId String @map("workspace_id") @db.VarChar
|
||||
id String @map("guid") @db.VarChar
|
||||
seq Int @db.Integer
|
||||
blob Bytes @db.ByteA
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
@@unique([workspaceId, id, seq])
|
||||
@@id([workspaceId, id, seq])
|
||||
@@map("updates")
|
||||
}
|
||||
|
||||
@@ -164,3 +165,76 @@ model NewFeaturesWaitingList {
|
||||
|
||||
@@map("new_features_waiting_list")
|
||||
}
|
||||
|
||||
model UserStripeCustomer {
|
||||
userId String @id @map("user_id") @db.VarChar
|
||||
stripeCustomerId String @unique @map("stripe_customer_id") @db.VarChar
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_stripe_customers")
|
||||
}
|
||||
|
||||
model UserSubscription {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @unique @map("user_id") @db.VarChar(36)
|
||||
plan String @db.VarChar(20)
|
||||
// yearly/monthly
|
||||
recurring String @db.VarChar(20)
|
||||
// subscription.id
|
||||
stripeSubscriptionId String @unique @map("stripe_subscription_id")
|
||||
// subscription.status, active/past_due/canceled/unpaid...
|
||||
status String @db.VarChar(20)
|
||||
// subscription.current_period_start
|
||||
start DateTime @map("start") @db.Timestamptz(6)
|
||||
// subscription.current_period_end
|
||||
end DateTime @map("end") @db.Timestamptz(6)
|
||||
// subscription.billing_cycle_anchor
|
||||
nextBillAt DateTime? @map("next_bill_at") @db.Timestamptz(6)
|
||||
// subscription.canceled_at
|
||||
canceledAt DateTime? @map("canceled_at") @db.Timestamptz(6)
|
||||
// subscription.trial_start
|
||||
trialStart DateTime? @map("trial_start") @db.Timestamptz(6)
|
||||
// subscription.trial_end
|
||||
trialEnd DateTime? @map("trial_end") @db.Timestamptz(6)
|
||||
stripeScheduleId String? @map("stripe_schedule_id") @db.VarChar
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_subscriptions")
|
||||
}
|
||||
|
||||
model UserInvoice {
|
||||
id Int @id @default(autoincrement()) @db.Integer
|
||||
userId String @map("user_id") @db.VarChar(36)
|
||||
stripeInvoiceId String @unique @map("stripe_invoice_id")
|
||||
currency String @db.VarChar(3)
|
||||
// CNY 12.50 stored as 1250
|
||||
amount Int @db.Integer
|
||||
status String @db.VarChar(20)
|
||||
plan String @db.VarChar(20)
|
||||
recurring String @db.VarChar(20)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
// billing reason
|
||||
reason String @db.VarChar
|
||||
lastPaymentError String? @map("last_payment_error") @db.Text
|
||||
// stripe hosted invoice link
|
||||
link String? @db.Text
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("user_invoices")
|
||||
}
|
||||
|
||||
model DataMigration {
|
||||
id String @id @default(uuid()) @db.VarChar(36)
|
||||
name String @db.VarChar
|
||||
startedAt DateTime @default(now()) @map("started_at") @db.Timestamptz(6)
|
||||
finishedAt DateTime? @map("finished_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("_data_migrations")
|
||||
}
|
||||
|
||||
@@ -363,4 +363,13 @@ export interface AFFiNEConfig {
|
||||
experimentalMergeWithJwstCodec: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
payment: {
|
||||
stripe: {
|
||||
keys: {
|
||||
APIKey: string;
|
||||
webhookKey: string;
|
||||
};
|
||||
} & import('stripe').Stripe.StripeConfig;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -89,6 +89,8 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
'boolean',
|
||||
],
|
||||
ENABLE_LOCAL_EMAIL: ['auth.localEmail', 'boolean'],
|
||||
STRIPE_API_KEY: 'payment.stripe.keys.APIKey',
|
||||
STRIPE_WEBHOOK_KEY: 'payment.stripe.keys.webhookKey',
|
||||
} satisfies AFFiNEConfig['ENV_MAP'],
|
||||
affineEnv: 'dev',
|
||||
get affine() {
|
||||
@@ -207,6 +209,15 @@ export const getDefaultAFFiNEConfig: () => AFFiNEConfig = () => {
|
||||
experimentalMergeWithJwstCodec: false,
|
||||
},
|
||||
},
|
||||
payment: {
|
||||
stripe: {
|
||||
keys: {
|
||||
APIKey: '',
|
||||
webhookKey: '',
|
||||
},
|
||||
apiVersion: '2023-10-16',
|
||||
},
|
||||
},
|
||||
} satisfies AFFiNEConfig;
|
||||
|
||||
applyEnvToConfig(defaultConfig);
|
||||
|
||||
18
packages/backend/server/src/data/app.ts
Normal file
18
packages/backend/server/src/data/app.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Logger, Module } from '@nestjs/common';
|
||||
import { CommandFactory } from 'nest-commander';
|
||||
|
||||
import { PrismaModule } from '../prisma';
|
||||
import { CreateCommand, NameQuestion } from './commands/create';
|
||||
import { RevertCommand, RunCommand } from './commands/run';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
providers: [NameQuestion, CreateCommand, RunCommand, RevertCommand],
|
||||
})
|
||||
class AppModule {}
|
||||
|
||||
async function bootstrap() {
|
||||
await CommandFactory.run(AppModule, new Logger());
|
||||
}
|
||||
|
||||
await bootstrap();
|
||||
73
packages/backend/server/src/data/commands/create.ts
Normal file
73
packages/backend/server/src/data/commands/create.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { camelCase, snakeCase, upperFirst } from 'lodash-es';
|
||||
import {
|
||||
Command,
|
||||
CommandRunner,
|
||||
InquirerService,
|
||||
Question,
|
||||
QuestionSet,
|
||||
} from 'nest-commander';
|
||||
|
||||
@QuestionSet({ name: 'name-questions' })
|
||||
export class NameQuestion {
|
||||
@Question({
|
||||
name: 'name',
|
||||
message: 'Name of the data migration script:',
|
||||
})
|
||||
parseName(val: string) {
|
||||
return val.trim();
|
||||
}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'create',
|
||||
arguments: '[name]',
|
||||
description: 'create a data migration script',
|
||||
})
|
||||
export class CreateCommand extends CommandRunner {
|
||||
logger = new Logger(CreateCommand.name);
|
||||
constructor(private readonly inquirer: InquirerService) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(inputs: string[]): Promise<void> {
|
||||
let name = inputs[0];
|
||||
|
||||
if (!name) {
|
||||
name = (
|
||||
await this.inquirer.ask<{ name: string }>('name-questions', undefined)
|
||||
).name;
|
||||
}
|
||||
|
||||
const timestamp = Date.now();
|
||||
const content = this.createScript(upperFirst(camelCase(name)) + timestamp);
|
||||
const fileName = `${timestamp}-${snakeCase(name)}.ts`;
|
||||
const filePath = join(
|
||||
fileURLToPath(import.meta.url),
|
||||
'../../migrations',
|
||||
fileName
|
||||
);
|
||||
|
||||
this.logger.log(`Creating ${fileName}...`);
|
||||
writeFileSync(filePath, content);
|
||||
this.logger.log('Done');
|
||||
}
|
||||
|
||||
private createScript(name: string) {
|
||||
const contents = ["import { PrismaService } from '../../prisma';", ''];
|
||||
contents.push(`export class ${name} {`);
|
||||
contents.push(' // do the migration');
|
||||
contents.push(' static async up(db: PrismaService) {}');
|
||||
contents.push('');
|
||||
contents.push(' // revert the migration');
|
||||
contents.push(' static async down(db: PrismaService) {}');
|
||||
|
||||
contents.push('}');
|
||||
|
||||
return contents.join('\n');
|
||||
}
|
||||
}
|
||||
151
packages/backend/server/src/data/commands/run.ts
Normal file
151
packages/backend/server/src/data/commands/run.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { Logger } from '@nestjs/common';
|
||||
import { Command, CommandRunner } from 'nest-commander';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
|
||||
interface Migration {
|
||||
file: string;
|
||||
name: string;
|
||||
up: (db: PrismaService) => Promise<void>;
|
||||
down: (db: PrismaService) => Promise<void>;
|
||||
}
|
||||
|
||||
async function collectMigrations(): Promise<Migration[]> {
|
||||
const folder = join(fileURLToPath(import.meta.url), '../../migrations');
|
||||
|
||||
const migrationFiles = readdirSync(folder)
|
||||
.filter(desc =>
|
||||
desc.endsWith(import.meta.url.endsWith('.ts') ? '.ts' : '.js')
|
||||
)
|
||||
.map(desc => join(folder, desc));
|
||||
|
||||
const migrations: Migration[] = await Promise.all(
|
||||
migrationFiles.map(async file => {
|
||||
return import(file).then(mod => {
|
||||
const migration = mod[Object.keys(mod)[0]];
|
||||
|
||||
return {
|
||||
file,
|
||||
name: migration.name,
|
||||
up: migration.up,
|
||||
down: migration.down,
|
||||
};
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return migrations;
|
||||
}
|
||||
@Command({
|
||||
name: 'run',
|
||||
description: 'Run all pending data migrations',
|
||||
})
|
||||
export class RunCommand extends CommandRunner {
|
||||
logger = new Logger(RunCommand.name);
|
||||
constructor(private readonly db: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(): Promise<void> {
|
||||
const migrations = await collectMigrations();
|
||||
const done: Migration[] = [];
|
||||
for (const migration of migrations) {
|
||||
const exists = await this.db.dataMigration.count({
|
||||
where: {
|
||||
name: migration.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Running ${migration.name}...`);
|
||||
const record = await this.db.dataMigration.create({
|
||||
data: {
|
||||
name: migration.name,
|
||||
startedAt: new Date(),
|
||||
},
|
||||
});
|
||||
await migration.up(this.db);
|
||||
await this.db.dataMigration.update({
|
||||
where: {
|
||||
id: record.id,
|
||||
},
|
||||
data: {
|
||||
finishedAt: new Date(),
|
||||
},
|
||||
});
|
||||
done.push(migration);
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to run data migration', e);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`Done ${done.length} migrations`);
|
||||
done.forEach(migration => {
|
||||
this.logger.log(` ✔ ${migration.name}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Command({
|
||||
name: 'revert',
|
||||
arguments: '[name]',
|
||||
description: 'Revert one data migration with given name',
|
||||
})
|
||||
export class RevertCommand extends CommandRunner {
|
||||
logger = new Logger(RevertCommand.name);
|
||||
|
||||
constructor(private readonly db: PrismaService) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async run(inputs: string[]): Promise<void> {
|
||||
const name = inputs[0];
|
||||
if (!name) {
|
||||
throw new Error('A migration name is required');
|
||||
}
|
||||
|
||||
const migrations = await collectMigrations();
|
||||
|
||||
const migration = migrations.find(m => m.name === name);
|
||||
|
||||
if (!migration) {
|
||||
this.logger.error('Available migration names:');
|
||||
migrations.forEach(m => {
|
||||
this.logger.error(` - ${m.name}`);
|
||||
});
|
||||
throw new Error(`Unknown migration name: ${name}.`);
|
||||
}
|
||||
|
||||
const record = await this.db.dataMigration.findFirst({
|
||||
where: {
|
||||
name: migration.name,
|
||||
},
|
||||
});
|
||||
|
||||
if (!record) {
|
||||
throw new Error(`Migration ${name} has not been executed.`);
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Reverting ${name}...`);
|
||||
await migration.down(this.db);
|
||||
this.logger.log('Done reverting');
|
||||
} catch (e) {
|
||||
this.logger.error(`Failed to revert data migration ${name}`, e);
|
||||
}
|
||||
|
||||
await this.db.dataMigration.delete({
|
||||
where: {
|
||||
id: record.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { applyUpdate, Doc, encodeStateAsUpdate } from 'yjs';
|
||||
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { DocID } from '../../utils/doc';
|
||||
|
||||
export class Guid1698398506533 {
|
||||
// do the migration
|
||||
static async up(db: PrismaService) {
|
||||
let turn = 0;
|
||||
let lastTurnCount = 100;
|
||||
while (lastTurnCount === 100) {
|
||||
const docs = await db.snapshot.findMany({
|
||||
select: {
|
||||
workspaceId: true,
|
||||
id: true,
|
||||
},
|
||||
skip: turn * 100,
|
||||
take: 100,
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
lastTurnCount = docs.length;
|
||||
for (const doc of docs) {
|
||||
const docId = new DocID(doc.id, doc.workspaceId);
|
||||
|
||||
// NOTE:
|
||||
// `doc.id` could be 'space:xxx' or 'xxx'
|
||||
// `docId.guid` is always 'xxx'
|
||||
// what we want achieve is:
|
||||
// if both 'space:xxx' and 'xxx' exist, merge 'space:xxx' to 'xxx' and delete it
|
||||
// else just modify 'space:xxx' to 'xxx'
|
||||
|
||||
if (docId && !docId.isWorkspace && docId.guid !== doc.id) {
|
||||
const existingUpdate = await db.snapshot.findFirst({
|
||||
where: {
|
||||
id: docId.guid,
|
||||
workspaceId: doc.workspaceId,
|
||||
},
|
||||
select: {
|
||||
blob: true,
|
||||
},
|
||||
});
|
||||
|
||||
// we have missing update with wrong id used before and need to be recovered
|
||||
if (existingUpdate) {
|
||||
const toBeMergeUpdate = await db.snapshot.findFirst({
|
||||
// id 'space:xxx'
|
||||
where: {
|
||||
id: doc.id,
|
||||
workspaceId: doc.workspaceId,
|
||||
},
|
||||
select: {
|
||||
blob: true,
|
||||
},
|
||||
});
|
||||
|
||||
// no conflict
|
||||
// actually unreachable path
|
||||
if (!toBeMergeUpdate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// recover
|
||||
const yDoc = new Doc();
|
||||
applyUpdate(yDoc, toBeMergeUpdate.blob);
|
||||
applyUpdate(yDoc, existingUpdate.blob);
|
||||
const update = encodeStateAsUpdate(yDoc);
|
||||
|
||||
await db.$transaction([
|
||||
// we already have 'xxx', delete 'space:xxx'
|
||||
db.snapshot.deleteMany({
|
||||
where: {
|
||||
id: doc.id,
|
||||
workspaceId: doc.workspaceId,
|
||||
},
|
||||
}),
|
||||
db.snapshot.update({
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
id: docId.guid,
|
||||
workspaceId: doc.workspaceId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
blob: Buffer.from(update),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
} else {
|
||||
// there is no updates need to be merged
|
||||
// just modify the id the required one
|
||||
await db.snapshot.update({
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
id: doc.id,
|
||||
workspaceId: doc.workspaceId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
id: docId.guid,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
turn++;
|
||||
}
|
||||
}
|
||||
|
||||
// revert the migration
|
||||
static async down() {
|
||||
//
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,7 @@ if (NODE_ENV === 'production') {
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, {
|
||||
cors: true,
|
||||
rawBody: true,
|
||||
bodyParser: true,
|
||||
logger:
|
||||
NODE_ENV !== 'production' || AFFINE_ENV !== 'production'
|
||||
@@ -70,7 +71,8 @@ app.use(serverTimingAndCache);
|
||||
|
||||
app.use(
|
||||
graphqlUploadExpress({
|
||||
maxFileSize: 10 * 1024 * 1024,
|
||||
// TODO: dynamic limit by quota
|
||||
maxFileSize: 100 * 1024 * 1024,
|
||||
maxFiles: 5,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -30,7 +30,7 @@ export class ExceptionLogger implements ExceptionFilter {
|
||||
new Error(
|
||||
`${requestId ? `requestId-${requestId}: ` : ''}${exception.message}${
|
||||
shouldVerboseLog ? '\n' + exception.stack : ''
|
||||
}}`,
|
||||
}`,
|
||||
{ cause: exception }
|
||||
)
|
||||
);
|
||||
|
||||
@@ -135,12 +135,13 @@ export class AuthResolver {
|
||||
@Args('token') token: string,
|
||||
@Args('newPassword') newPassword: string
|
||||
) {
|
||||
const id = await this.session.get(token);
|
||||
if (!id || id !== user.id) {
|
||||
// we only create user account after user sign in with email link
|
||||
const email = await this.session.get(token);
|
||||
if (!email || email !== user.email || !user.emailVerified) {
|
||||
throw new ForbiddenException('Invalid token');
|
||||
}
|
||||
|
||||
await this.auth.changePassword(id, newPassword);
|
||||
await this.auth.changePassword(email, newPassword);
|
||||
await this.session.delete(token);
|
||||
|
||||
return user;
|
||||
|
||||
@@ -233,10 +233,13 @@ export class AuthService {
|
||||
return Boolean(user.password);
|
||||
}
|
||||
|
||||
async changePassword(id: string, newPassword: string): Promise<User> {
|
||||
async changePassword(email: string, newPassword: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: {
|
||||
id,
|
||||
email,
|
||||
emailVerified: {
|
||||
not: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -248,7 +251,7 @@ export class AuthService {
|
||||
|
||||
return this.prisma.user.update({
|
||||
where: {
|
||||
id,
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
password: hashedPassword,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
Inject,
|
||||
Injectable,
|
||||
Logger,
|
||||
OnApplicationBootstrap,
|
||||
OnModuleDestroy,
|
||||
OnModuleInit,
|
||||
} from '@nestjs/common';
|
||||
@@ -14,7 +13,6 @@ import { Config } from '../../config';
|
||||
import { Metrics } from '../../metrics/metrics';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { mergeUpdatesInApplyWay as jwstMergeUpdates } from '../../storage';
|
||||
import { DocID } from '../../utils/doc';
|
||||
|
||||
function compare(yBinary: Buffer, jwstBinary: Buffer, strict = false): boolean {
|
||||
if (yBinary.equals(jwstBinary)) {
|
||||
@@ -44,9 +42,7 @@ const MAX_SEQ_NUM = 0x3fffffff; // u31
|
||||
* along side all the updates that have not been applies to that snapshot(timestamp).
|
||||
*/
|
||||
@Injectable()
|
||||
export class DocManager
|
||||
implements OnModuleInit, OnModuleDestroy, OnApplicationBootstrap
|
||||
{
|
||||
export class DocManager implements OnModuleInit, OnModuleDestroy {
|
||||
protected logger = new Logger(DocManager.name);
|
||||
private job: NodeJS.Timeout | null = null;
|
||||
private seqMap = new Map<string, number>();
|
||||
@@ -60,12 +56,6 @@ export class DocManager
|
||||
protected readonly metrics: Metrics
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (!this.config.node.test) {
|
||||
await this.refreshDocGuid();
|
||||
}
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (this.automation) {
|
||||
this.logger.log('Use Database');
|
||||
@@ -264,15 +254,15 @@ export class DocManager
|
||||
* get pending updates
|
||||
*/
|
||||
async getUpdates(workspaceId: string, guid: string) {
|
||||
return this.db.update.findMany({
|
||||
const updates = await this.db.update.findMany({
|
||||
where: {
|
||||
workspaceId,
|
||||
id: guid,
|
||||
},
|
||||
orderBy: {
|
||||
seq: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
// perf(memory): avoid sorting in db
|
||||
return updates.sort((a, b) => (a.createdAt < b.createdAt ? -1 : 1));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,56 +411,4 @@ export class DocManager
|
||||
return last + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* deal with old records that has wrong guid format
|
||||
* correct guid with `${non-wsId}:${variant}:${subId}` to `${subId}`
|
||||
*
|
||||
* @TODO delete in next release
|
||||
* @deprecated
|
||||
*/
|
||||
private async refreshDocGuid() {
|
||||
let turn = 0;
|
||||
let lastTurnCount = 100;
|
||||
while (lastTurnCount === 100) {
|
||||
const docs = await this.db.snapshot.findMany({
|
||||
select: {
|
||||
workspaceId: true,
|
||||
id: true,
|
||||
},
|
||||
skip: turn * 100,
|
||||
take: 100,
|
||||
orderBy: {
|
||||
createdAt: 'asc',
|
||||
},
|
||||
});
|
||||
|
||||
lastTurnCount = docs.length;
|
||||
for (const doc of docs) {
|
||||
const docId = new DocID(doc.id, doc.workspaceId);
|
||||
|
||||
if (docId && !docId.isWorkspace && docId.guid !== doc.id) {
|
||||
await this.db.snapshot.deleteMany({
|
||||
where: {
|
||||
id: docId.guid,
|
||||
workspaceId: doc.workspaceId,
|
||||
},
|
||||
});
|
||||
await this.db.snapshot.update({
|
||||
where: {
|
||||
id_workspaceId: {
|
||||
id: doc.id,
|
||||
workspaceId: doc.workspaceId,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
id: docId.guid,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
turn++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { DynamicModule, Type } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
import { GqlModule } from '../graphql.module';
|
||||
import { AuthModule } from './auth';
|
||||
import { DocModule } from './doc';
|
||||
import { PaymentModule } from './payment';
|
||||
import { SyncModule } from './sync';
|
||||
import { UsersModule } from './users';
|
||||
import { WorkspaceModule } from './workspaces';
|
||||
@@ -17,22 +19,30 @@ switch (SERVER_FLAVOR) {
|
||||
break;
|
||||
case 'graphql':
|
||||
BusinessModules.push(
|
||||
EventEmitterModule.forRoot({
|
||||
global: true,
|
||||
}),
|
||||
GqlModule,
|
||||
WorkspaceModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
DocModule.forRoot()
|
||||
DocModule.forRoot(),
|
||||
PaymentModule
|
||||
);
|
||||
break;
|
||||
case 'allinone':
|
||||
default:
|
||||
BusinessModules.push(
|
||||
EventEmitterModule.forRoot({
|
||||
global: true,
|
||||
}),
|
||||
GqlModule,
|
||||
WorkspaceModule,
|
||||
UsersModule,
|
||||
AuthModule,
|
||||
SyncModule,
|
||||
DocModule.forRoot()
|
||||
DocModule.forRoot(),
|
||||
PaymentModule
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
21
packages/backend/server/src/modules/payment/index.ts
Normal file
21
packages/backend/server/src/modules/payment/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { UsersModule } from '../users';
|
||||
import { SubscriptionResolver, UserSubscriptionResolver } from './resolver';
|
||||
import { ScheduleManager } from './schedule';
|
||||
import { SubscriptionService } from './service';
|
||||
import { StripeProvider } from './stripe';
|
||||
import { StripeWebhook } from './webhook';
|
||||
|
||||
@Module({
|
||||
imports: [UsersModule],
|
||||
providers: [
|
||||
ScheduleManager,
|
||||
StripeProvider,
|
||||
SubscriptionService,
|
||||
SubscriptionResolver,
|
||||
UserSubscriptionResolver,
|
||||
],
|
||||
controllers: [StripeWebhook],
|
||||
})
|
||||
export class PaymentModule {}
|
||||
305
packages/backend/server/src/modules/payment/resolver.ts
Normal file
305
packages/backend/server/src/modules/payment/resolver.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
import { HttpStatus } from '@nestjs/common';
|
||||
import {
|
||||
Args,
|
||||
Field,
|
||||
Int,
|
||||
Mutation,
|
||||
ObjectType,
|
||||
Parent,
|
||||
Query,
|
||||
registerEnumType,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import type { User, UserInvoice, UserSubscription } from '@prisma/client';
|
||||
import { GraphQLError } from 'graphql';
|
||||
import { groupBy } from 'lodash-es';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { Auth, CurrentUser, Public } from '../auth';
|
||||
import { UserType } from '../users';
|
||||
import {
|
||||
decodeLookupKey,
|
||||
InvoiceStatus,
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionService,
|
||||
SubscriptionStatus,
|
||||
} from './service';
|
||||
|
||||
registerEnumType(SubscriptionStatus, { name: 'SubscriptionStatus' });
|
||||
registerEnumType(SubscriptionRecurring, { name: 'SubscriptionRecurring' });
|
||||
registerEnumType(SubscriptionPlan, { name: 'SubscriptionPlan' });
|
||||
registerEnumType(InvoiceStatus, { name: 'InvoiceStatus' });
|
||||
|
||||
@ObjectType()
|
||||
class SubscriptionPrice {
|
||||
@Field(() => String)
|
||||
type!: 'fixed';
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field()
|
||||
currency!: string;
|
||||
|
||||
@Field()
|
||||
amount!: number;
|
||||
|
||||
@Field()
|
||||
yearlyAmount!: number;
|
||||
}
|
||||
|
||||
@ObjectType('UserSubscription')
|
||||
class UserSubscriptionType implements Partial<UserSubscription> {
|
||||
@Field({ name: 'id' })
|
||||
stripeSubscriptionId!: string;
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field(() => SubscriptionStatus)
|
||||
status!: SubscriptionStatus;
|
||||
|
||||
@Field(() => Date)
|
||||
start!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
end!: Date;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
trialStart?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
trialEnd?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
nextBillAt?: Date | null;
|
||||
|
||||
@Field(() => Date, { nullable: true })
|
||||
canceledAt?: Date | null;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@ObjectType('UserInvoice')
|
||||
class UserInvoiceType implements Partial<UserInvoice> {
|
||||
@Field({ name: 'id' })
|
||||
stripeInvoiceId!: string;
|
||||
|
||||
@Field(() => SubscriptionPlan)
|
||||
plan!: SubscriptionPlan;
|
||||
|
||||
@Field(() => SubscriptionRecurring)
|
||||
recurring!: SubscriptionRecurring;
|
||||
|
||||
@Field()
|
||||
currency!: string;
|
||||
|
||||
@Field()
|
||||
amount!: number;
|
||||
|
||||
@Field(() => InvoiceStatus)
|
||||
status!: InvoiceStatus;
|
||||
|
||||
@Field()
|
||||
reason!: string;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
lastPaymentError?: string | null;
|
||||
|
||||
@Field(() => String, { nullable: true })
|
||||
link?: string | null;
|
||||
|
||||
@Field(() => Date)
|
||||
createdAt!: Date;
|
||||
|
||||
@Field(() => Date)
|
||||
updatedAt!: Date;
|
||||
}
|
||||
|
||||
@Auth()
|
||||
@Resolver(() => UserSubscriptionType)
|
||||
export class SubscriptionResolver {
|
||||
constructor(
|
||||
private readonly service: SubscriptionService,
|
||||
private readonly config: Config
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@Query(() => [SubscriptionPrice])
|
||||
async prices(): Promise<SubscriptionPrice[]> {
|
||||
const prices = await this.service.listPrices();
|
||||
|
||||
const group = groupBy(
|
||||
prices.data.filter(price => !!price.lookup_key),
|
||||
price => {
|
||||
// @ts-expect-error empty lookup key is filtered out
|
||||
const [plan] = decodeLookupKey(price.lookup_key);
|
||||
return plan;
|
||||
}
|
||||
);
|
||||
|
||||
return Object.entries(group).map(([plan, prices]) => {
|
||||
const yearly = prices.find(
|
||||
price =>
|
||||
decodeLookupKey(
|
||||
// @ts-expect-error empty lookup key is filtered out
|
||||
price.lookup_key
|
||||
)[1] === SubscriptionRecurring.Yearly
|
||||
);
|
||||
const monthly = prices.find(
|
||||
price =>
|
||||
decodeLookupKey(
|
||||
// @ts-expect-error empty lookup key is filtered out
|
||||
price.lookup_key
|
||||
)[1] === SubscriptionRecurring.Monthly
|
||||
);
|
||||
|
||||
if (!yearly || !monthly) {
|
||||
throw new GraphQLError('The prices are not configured correctly', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'fixed',
|
||||
plan: plan as SubscriptionPlan,
|
||||
currency: monthly.currency,
|
||||
amount: monthly.unit_amount ?? 0,
|
||||
yearlyAmount: yearly.unit_amount ?? 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a subscription checkout link of stripe',
|
||||
})
|
||||
async checkout(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
|
||||
recurring: SubscriptionRecurring,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
const session = await this.service.createCheckoutSession({
|
||||
user,
|
||||
recurring,
|
||||
redirectUrl: `${this.config.baseUrl}/upgrade-success`,
|
||||
idempotencyKey,
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
throw new GraphQLError('Failed to create checkout session', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.BAD_GATEWAY],
|
||||
code: HttpStatus.BAD_GATEWAY,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return session.url;
|
||||
}
|
||||
|
||||
@Mutation(() => String, {
|
||||
description: 'Create a stripe customer portal to manage payment methods',
|
||||
})
|
||||
async createCustomerPortal(@CurrentUser() user: User) {
|
||||
return this.service.createCustomerPortal(user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async cancelSubscription(
|
||||
@CurrentUser() user: User,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.cancelSubscription(idempotencyKey, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async resumeSubscription(
|
||||
@CurrentUser() user: User,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.resumeCanceledSubscription(idempotencyKey, user.id);
|
||||
}
|
||||
|
||||
@Mutation(() => UserSubscriptionType)
|
||||
async updateSubscriptionRecurring(
|
||||
@CurrentUser() user: User,
|
||||
@Args({ name: 'recurring', type: () => SubscriptionRecurring })
|
||||
recurring: SubscriptionRecurring,
|
||||
@Args('idempotencyKey') idempotencyKey: string
|
||||
) {
|
||||
return this.service.updateSubscriptionRecurring(
|
||||
idempotencyKey,
|
||||
user.id,
|
||||
recurring
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSubscriptionResolver {
|
||||
constructor(private readonly db: PrismaService) {}
|
||||
|
||||
@ResolveField(() => UserSubscriptionType, { nullable: true })
|
||||
async subscription(@CurrentUser() me: User, @Parent() user: User) {
|
||||
if (me.id !== user.id) {
|
||||
throw new GraphQLError(
|
||||
'You are not allowed to access this subscription',
|
||||
{
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ResolveField(() => [UserInvoiceType])
|
||||
async invoices(
|
||||
@CurrentUser() me: User,
|
||||
@Parent() user: User,
|
||||
@Args('take', { type: () => Int, nullable: true, defaultValue: 8 })
|
||||
take: number,
|
||||
@Args('skip', { type: () => Int, nullable: true }) skip?: number
|
||||
) {
|
||||
if (me.id !== user.id) {
|
||||
throw new GraphQLError('You are not allowed to access this invoices', {
|
||||
extensions: {
|
||||
status: HttpStatus[HttpStatus.FORBIDDEN],
|
||||
code: HttpStatus.FORBIDDEN,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return this.db.userInvoice.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
take,
|
||||
skip,
|
||||
orderBy: {
|
||||
id: 'desc',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
238
packages/backend/server/src/modules/payment/schedule.ts
Normal file
238
packages/backend/server/src/modules/payment/schedule.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
@Injectable()
|
||||
export class ScheduleManager {
|
||||
private _schedule: Stripe.SubscriptionSchedule | null = null;
|
||||
private readonly logger = new Logger(ScheduleManager.name);
|
||||
|
||||
constructor(private readonly stripe: Stripe) {}
|
||||
|
||||
static create(stripe: Stripe, schedule?: Stripe.SubscriptionSchedule) {
|
||||
const manager = new ScheduleManager(stripe);
|
||||
if (schedule) {
|
||||
manager._schedule = schedule;
|
||||
}
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
get schedule() {
|
||||
return this._schedule;
|
||||
}
|
||||
|
||||
get currentPhase() {
|
||||
if (!this._schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._schedule.phases.find(
|
||||
phase =>
|
||||
phase.start_date * 1000 < Date.now() &&
|
||||
phase.end_date * 1000 > Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
get nextPhase() {
|
||||
if (!this._schedule) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this._schedule.phases.find(
|
||||
phase => phase.start_date * 1000 > Date.now()
|
||||
);
|
||||
}
|
||||
|
||||
get isActive() {
|
||||
return this._schedule?.status === 'active';
|
||||
}
|
||||
|
||||
async fromSchedule(schedule: string | Stripe.SubscriptionSchedule) {
|
||||
if (typeof schedule === 'string') {
|
||||
const s = await this.stripe.subscriptionSchedules
|
||||
.retrieve(schedule)
|
||||
.catch(e => {
|
||||
this.logger.error('Failed to retrieve subscription schedule', e);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
return ScheduleManager.create(this.stripe, s);
|
||||
} else {
|
||||
return ScheduleManager.create(this.stripe, schedule);
|
||||
}
|
||||
}
|
||||
|
||||
async fromSubscription(
|
||||
idempotencyKey: string,
|
||||
subscription: string | Stripe.Subscription
|
||||
) {
|
||||
if (typeof subscription === 'string') {
|
||||
subscription = await this.stripe.subscriptions.retrieve(subscription, {
|
||||
expand: ['schedule'],
|
||||
});
|
||||
}
|
||||
|
||||
if (subscription.schedule) {
|
||||
return await this.fromSchedule(subscription.schedule);
|
||||
} else {
|
||||
const schedule = await this.stripe.subscriptionSchedules.create(
|
||||
{ from_subscription: subscription.id },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
|
||||
return await this.fromSchedule(schedule);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel a subscription by marking schedule's end behavior to `cancel`.
|
||||
* At the same time, the coming phase's price and coupon will be saved to metadata for later resuming to correction subscription.
|
||||
*/
|
||||
async cancel(idempotencyKey: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
if (!this.isActive || !this.currentPhase) {
|
||||
throw new Error('Unexpected subscription schedule status');
|
||||
}
|
||||
|
||||
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase = {
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.items[0].price as string,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon: (this.currentPhase.coupon as string | null) ?? undefined,
|
||||
start_date: this.currentPhase.start_date,
|
||||
end_date: this.currentPhase.end_date,
|
||||
};
|
||||
|
||||
if (this.nextPhase) {
|
||||
// cancel a subscription with a schedule exiting will delete the upcoming phase,
|
||||
// it's hard to recover the subscription to the original state if user wan't to resume before due.
|
||||
// so we manually save the next phase's key information to metadata for later easy resuming.
|
||||
phases.metadata = {
|
||||
next_coupon: (this.nextPhase.coupon as string | null) || null, // avoid empty string
|
||||
next_price: this.nextPhase.items[0].price as string,
|
||||
};
|
||||
}
|
||||
|
||||
await this.stripe.subscriptionSchedules.update(
|
||||
this._schedule.id,
|
||||
{
|
||||
phases: [phases],
|
||||
end_behavior: 'cancel',
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
async resume(idempotencyKey: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
if (!this.isActive || !this.currentPhase) {
|
||||
throw new Error('Unexpected subscription schedule status');
|
||||
}
|
||||
|
||||
const phases: Stripe.SubscriptionScheduleUpdateParams.Phase[] = [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.items[0].price as string,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon: (this.currentPhase.coupon as string | null) ?? undefined,
|
||||
start_date: this.currentPhase.start_date,
|
||||
end_date: this.currentPhase.end_date,
|
||||
metadata: {
|
||||
next_coupon: null,
|
||||
next_price: null,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (this.currentPhase.metadata && this.currentPhase.metadata.next_price) {
|
||||
phases.push({
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.metadata.next_price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon: this.currentPhase.metadata.next_coupon || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
await this.stripe.subscriptionSchedules.update(
|
||||
this._schedule.id,
|
||||
{
|
||||
phases: phases,
|
||||
end_behavior: 'release',
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
async release(idempotencyKey: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
await this.stripe.subscriptionSchedules.release(this._schedule.id, {
|
||||
idempotencyKey,
|
||||
});
|
||||
}
|
||||
|
||||
async update(idempotencyKey: string, price: string, coupon?: string) {
|
||||
if (!this._schedule) {
|
||||
throw new Error('No schedule');
|
||||
}
|
||||
|
||||
if (!this.isActive || !this.currentPhase) {
|
||||
throw new Error('Unexpected subscription schedule status');
|
||||
}
|
||||
|
||||
// if current phase's plan matches target, and no coupon change, just release the schedule
|
||||
if (
|
||||
this.currentPhase.items[0].price === price &&
|
||||
(!coupon || this.currentPhase.coupon === coupon)
|
||||
) {
|
||||
await this.stripe.subscriptionSchedules.release(this._schedule.id, {
|
||||
idempotencyKey,
|
||||
});
|
||||
this._schedule = null;
|
||||
} else {
|
||||
await this.stripe.subscriptionSchedules.update(
|
||||
this._schedule.id,
|
||||
{
|
||||
phases: [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
price: this.currentPhase.items[0].price as string,
|
||||
},
|
||||
],
|
||||
start_date: this.currentPhase.start_date,
|
||||
end_date: this.currentPhase.end_date,
|
||||
},
|
||||
{
|
||||
items: [
|
||||
{
|
||||
price: price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
coupon,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
669
packages/backend/server/src/modules/payment/service.ts
Normal file
669
packages/backend/server/src/modules/payment/service.ts
Normal file
@@ -0,0 +1,669 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { OnEvent as RawOnEvent } from '@nestjs/event-emitter';
|
||||
import type {
|
||||
Prisma,
|
||||
User,
|
||||
UserInvoice,
|
||||
UserStripeCustomer,
|
||||
UserSubscription,
|
||||
} from '@prisma/client';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config } from '../../config';
|
||||
import { PrismaService } from '../../prisma';
|
||||
import { UsersService } from '../users';
|
||||
import { ScheduleManager } from './schedule';
|
||||
|
||||
const OnEvent = (
|
||||
event: Stripe.Event.Type,
|
||||
opts?: Parameters<typeof RawOnEvent>[1]
|
||||
) => RawOnEvent(event, opts);
|
||||
|
||||
// Plan x Recurring make a stripe price lookup key
|
||||
export enum SubscriptionRecurring {
|
||||
Monthly = 'monthly',
|
||||
Yearly = 'yearly',
|
||||
}
|
||||
|
||||
export enum SubscriptionPlan {
|
||||
Free = 'free',
|
||||
Pro = 'pro',
|
||||
Team = 'team',
|
||||
Enterprise = 'enterprise',
|
||||
}
|
||||
|
||||
export function encodeLookupKey(
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
): string {
|
||||
return plan + '_' + recurring;
|
||||
}
|
||||
|
||||
export function decodeLookupKey(
|
||||
key: string
|
||||
): [SubscriptionPlan, SubscriptionRecurring] {
|
||||
const [plan, recurring] = key.split('_');
|
||||
|
||||
return [plan as SubscriptionPlan, recurring as SubscriptionRecurring];
|
||||
}
|
||||
|
||||
// see https://stripe.com/docs/api/subscriptions/object#subscription_object-status
|
||||
export enum SubscriptionStatus {
|
||||
Active = 'active',
|
||||
PastDue = 'past_due',
|
||||
Unpaid = 'unpaid',
|
||||
Canceled = 'canceled',
|
||||
Incomplete = 'incomplete',
|
||||
Paused = 'paused',
|
||||
IncompleteExpired = 'incomplete_expired',
|
||||
Trialing = 'trialing',
|
||||
}
|
||||
|
||||
export enum InvoiceStatus {
|
||||
Draft = 'draft',
|
||||
Open = 'open',
|
||||
Void = 'void',
|
||||
Paid = 'paid',
|
||||
Uncollectible = 'uncollectible',
|
||||
}
|
||||
|
||||
export enum CouponType {
|
||||
EarlyAccess = 'earlyaccess',
|
||||
EarlyAccessRenew = 'earlyaccessrenew',
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SubscriptionService {
|
||||
private readonly paymentConfig: Config['payment'];
|
||||
private readonly logger = new Logger(SubscriptionService.name);
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly stripe: Stripe,
|
||||
private readonly db: PrismaService,
|
||||
private readonly user: UsersService,
|
||||
private readonly scheduleManager: ScheduleManager
|
||||
) {
|
||||
this.paymentConfig = config.payment;
|
||||
|
||||
if (
|
||||
!this.paymentConfig.stripe.keys.APIKey ||
|
||||
!this.paymentConfig.stripe.keys.webhookKey /* default empty string */
|
||||
) {
|
||||
this.logger.warn('Stripe API key not set, Stripe will be disabled');
|
||||
this.logger.warn('Set STRIPE_API_KEY to enable Stripe');
|
||||
}
|
||||
}
|
||||
|
||||
async listPrices() {
|
||||
return this.stripe.prices.list();
|
||||
}
|
||||
|
||||
async createCheckoutSession({
|
||||
user,
|
||||
recurring,
|
||||
redirectUrl,
|
||||
idempotencyKey,
|
||||
plan = SubscriptionPlan.Pro,
|
||||
}: {
|
||||
user: User;
|
||||
plan?: SubscriptionPlan;
|
||||
recurring: SubscriptionRecurring;
|
||||
redirectUrl: string;
|
||||
idempotencyKey: string;
|
||||
}) {
|
||||
const currentSubscription = await this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentSubscription && currentSubscription.end < new Date()) {
|
||||
throw new Error('You already have a subscription');
|
||||
}
|
||||
|
||||
const price = await this.getPrice(plan, recurring);
|
||||
const customer = await this.getOrCreateCustomer(idempotencyKey, user);
|
||||
const coupon = await this.getAvailableCoupon(user, CouponType.EarlyAccess);
|
||||
|
||||
return await this.stripe.checkout.sessions.create(
|
||||
{
|
||||
line_items: [
|
||||
{
|
||||
price,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
tax_id_collection: {
|
||||
enabled: true,
|
||||
},
|
||||
...(coupon
|
||||
? {
|
||||
discounts: [{ coupon }],
|
||||
}
|
||||
: {
|
||||
allow_promotion_codes: true,
|
||||
}),
|
||||
mode: 'subscription',
|
||||
success_url: redirectUrl,
|
||||
customer: customer.stripeCustomerId,
|
||||
customer_update: {
|
||||
address: 'auto',
|
||||
name: 'auto',
|
||||
},
|
||||
},
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
idempotencyKey: string,
|
||||
userId: string
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
}
|
||||
|
||||
if (user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has already been canceled');
|
||||
}
|
||||
|
||||
// should release the schedule first
|
||||
if (user.subscription.stripeScheduleId) {
|
||||
const manager = await this.scheduleManager.fromSchedule(
|
||||
user.subscription.stripeScheduleId
|
||||
);
|
||||
await manager.cancel(idempotencyKey);
|
||||
return this.saveSubscription(
|
||||
user,
|
||||
await this.stripe.subscriptions.retrieve(
|
||||
user.subscription.stripeSubscriptionId
|
||||
),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
// let customer contact support if they want to cancel immediately
|
||||
// see https://stripe.com/docs/billing/subscriptions/cancel
|
||||
const subscription = await this.stripe.subscriptions.update(
|
||||
user.subscription.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: true },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
return await this.saveSubscription(user, subscription);
|
||||
}
|
||||
}
|
||||
|
||||
async resumeCanceledSubscription(
|
||||
idempotencyKey: string,
|
||||
userId: string
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
}
|
||||
|
||||
if (!user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has not been canceled');
|
||||
}
|
||||
|
||||
if (user.subscription.end < new Date()) {
|
||||
throw new Error('Your subscription is expired, please checkout again.');
|
||||
}
|
||||
|
||||
if (user.subscription.stripeScheduleId) {
|
||||
const manager = await this.scheduleManager.fromSchedule(
|
||||
user.subscription.stripeScheduleId
|
||||
);
|
||||
await manager.resume(idempotencyKey);
|
||||
return this.saveSubscription(
|
||||
user,
|
||||
await this.stripe.subscriptions.retrieve(
|
||||
user.subscription.stripeSubscriptionId
|
||||
),
|
||||
false
|
||||
);
|
||||
} else {
|
||||
const subscription = await this.stripe.subscriptions.update(
|
||||
user.subscription.stripeSubscriptionId,
|
||||
{ cancel_at_period_end: false },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
|
||||
return await this.saveSubscription(user, subscription);
|
||||
}
|
||||
}
|
||||
|
||||
async updateSubscriptionRecurring(
|
||||
idempotencyKey: string,
|
||||
userId: string,
|
||||
recurring: SubscriptionRecurring
|
||||
): Promise<UserSubscription> {
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user?.subscription) {
|
||||
throw new Error('You do not have any subscription');
|
||||
}
|
||||
|
||||
if (user.subscription.canceledAt) {
|
||||
throw new Error('Your subscription has already been canceled ');
|
||||
}
|
||||
|
||||
if (user.subscription.recurring === recurring) {
|
||||
throw new Error('You have already subscribed to this plan');
|
||||
}
|
||||
|
||||
const price = await this.getPrice(
|
||||
user.subscription.plan as SubscriptionPlan,
|
||||
recurring
|
||||
);
|
||||
|
||||
const manager = await this.scheduleManager.fromSubscription(
|
||||
idempotencyKey,
|
||||
user.subscription.stripeSubscriptionId
|
||||
);
|
||||
|
||||
await manager.update(
|
||||
idempotencyKey,
|
||||
price,
|
||||
// if user is early access user, use early access coupon
|
||||
manager.currentPhase?.coupon === CouponType.EarlyAccess ||
|
||||
manager.currentPhase?.coupon === CouponType.EarlyAccessRenew ||
|
||||
manager.nextPhase?.coupon === CouponType.EarlyAccessRenew
|
||||
? CouponType.EarlyAccessRenew
|
||||
: undefined
|
||||
);
|
||||
|
||||
return await this.db.userSubscription.update({
|
||||
where: {
|
||||
id: user.subscription.id,
|
||||
},
|
||||
data: {
|
||||
stripeScheduleId: manager.schedule?.id ?? null, // update schedule id or set to null(undefined means untouched)
|
||||
recurring,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async createCustomerPortal(id: string) {
|
||||
const user = await this.db.userStripeCustomer.findUnique({
|
||||
where: {
|
||||
userId: id,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error('Unknown user');
|
||||
}
|
||||
|
||||
try {
|
||||
const portal = await this.stripe.billingPortal.sessions.create({
|
||||
customer: user.stripeCustomerId,
|
||||
});
|
||||
|
||||
return portal.url;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to create customer portal.', e);
|
||||
throw new Error('Failed to create customer portal');
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('customer.subscription.created')
|
||||
@OnEvent('customer.subscription.updated')
|
||||
async onSubscriptionChanges(subscription: Stripe.Subscription) {
|
||||
const user = await this.retrieveUserFromCustomer(
|
||||
subscription.customer as string
|
||||
);
|
||||
|
||||
await this.saveSubscription(user, subscription);
|
||||
}
|
||||
|
||||
@OnEvent('customer.subscription.deleted')
|
||||
async onSubscriptionDeleted(subscription: Stripe.Subscription) {
|
||||
const user = await this.retrieveUserFromCustomer(
|
||||
subscription.customer as string
|
||||
);
|
||||
|
||||
await this.db.userSubscription.deleteMany({
|
||||
where: {
|
||||
stripeSubscriptionId: subscription.id,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('invoice.paid')
|
||||
async onInvoicePaid(stripeInvoice: Stripe.Invoice) {
|
||||
await this.saveInvoice(stripeInvoice);
|
||||
|
||||
const line = stripeInvoice.lines.data[0];
|
||||
|
||||
if (!line.price || line.price.type !== 'recurring') {
|
||||
throw new Error('Unknown invoice with no recurring price');
|
||||
}
|
||||
|
||||
// deal with early access user
|
||||
if (stripeInvoice.discount?.coupon.id === CouponType.EarlyAccess) {
|
||||
const idempotencyKey = stripeInvoice.id + '_earlyaccess';
|
||||
const manager = await this.scheduleManager.fromSubscription(
|
||||
idempotencyKey,
|
||||
line.subscription as string
|
||||
);
|
||||
await manager.update(
|
||||
idempotencyKey,
|
||||
line.price.id,
|
||||
CouponType.EarlyAccessRenew
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('invoice.created')
|
||||
@OnEvent('invoice.finalization_failed')
|
||||
@OnEvent('invoice.payment_failed')
|
||||
async saveInvoice(stripeInvoice: Stripe.Invoice) {
|
||||
if (!stripeInvoice.customer) {
|
||||
throw new Error('Unexpected invoice with no customer');
|
||||
}
|
||||
|
||||
const user = await this.retrieveUserFromCustomer(
|
||||
typeof stripeInvoice.customer === 'string'
|
||||
? stripeInvoice.customer
|
||||
: stripeInvoice.customer.id
|
||||
);
|
||||
|
||||
const invoice = await this.db.userInvoice.findUnique({
|
||||
where: {
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
},
|
||||
});
|
||||
|
||||
const data: Partial<UserInvoice> = {
|
||||
currency: stripeInvoice.currency,
|
||||
amount: stripeInvoice.total,
|
||||
status: stripeInvoice.status ?? InvoiceStatus.Void,
|
||||
link: stripeInvoice.hosted_invoice_url,
|
||||
};
|
||||
|
||||
// handle payment error
|
||||
if (stripeInvoice.attempt_count > 1) {
|
||||
const paymentIntent = await this.stripe.paymentIntents.retrieve(
|
||||
stripeInvoice.payment_intent as string
|
||||
);
|
||||
|
||||
if (paymentIntent.last_payment_error) {
|
||||
if (paymentIntent.last_payment_error.type === 'card_error') {
|
||||
data.lastPaymentError =
|
||||
paymentIntent.last_payment_error.message ?? 'Failed to pay';
|
||||
} else {
|
||||
data.lastPaymentError = 'Internal Payment error';
|
||||
}
|
||||
}
|
||||
} else if (stripeInvoice.last_finalization_error) {
|
||||
if (stripeInvoice.last_finalization_error.type === 'card_error') {
|
||||
data.lastPaymentError =
|
||||
stripeInvoice.last_finalization_error.message ??
|
||||
'Failed to finalize invoice';
|
||||
} else {
|
||||
data.lastPaymentError = 'Internal Payment error';
|
||||
}
|
||||
}
|
||||
|
||||
// update invoice
|
||||
if (invoice) {
|
||||
await this.db.userInvoice.update({
|
||||
where: {
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
},
|
||||
data,
|
||||
});
|
||||
} else {
|
||||
// create invoice
|
||||
const price = stripeInvoice.lines.data[0].price;
|
||||
|
||||
if (!price || price.type !== 'recurring') {
|
||||
throw new Error('Unexpected invoice with no recurring price');
|
||||
}
|
||||
|
||||
if (!price.lookup_key) {
|
||||
throw new Error('Unexpected subscription with no key');
|
||||
}
|
||||
|
||||
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
||||
|
||||
await this.db.userInvoice.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeInvoiceId: stripeInvoice.id,
|
||||
plan,
|
||||
recurring,
|
||||
reason: stripeInvoice.billing_reason ?? 'contact support',
|
||||
...(data as any),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async saveSubscription(
|
||||
user: User,
|
||||
subscription: Stripe.Subscription,
|
||||
fromWebhook = true
|
||||
): Promise<UserSubscription> {
|
||||
// webhook events may not in sequential order
|
||||
// always fetch the latest subscription and save
|
||||
// see https://stripe.com/docs/webhooks#behaviors
|
||||
if (fromWebhook) {
|
||||
subscription = await this.stripe.subscriptions.retrieve(subscription.id);
|
||||
}
|
||||
|
||||
// get next bill date from upcoming invoice
|
||||
// see https://stripe.com/docs/api/invoices/upcoming
|
||||
let nextBillAt: Date | null = null;
|
||||
if (
|
||||
(subscription.status === SubscriptionStatus.Active ||
|
||||
subscription.status === SubscriptionStatus.Trialing) &&
|
||||
!subscription.canceled_at
|
||||
) {
|
||||
nextBillAt = new Date(subscription.current_period_end * 1000);
|
||||
}
|
||||
|
||||
const price = subscription.items.data[0].price;
|
||||
if (!price.lookup_key) {
|
||||
throw new Error('Unexpected subscription with no key');
|
||||
}
|
||||
|
||||
const [plan, recurring] = decodeLookupKey(price.lookup_key);
|
||||
|
||||
const commonData = {
|
||||
start: new Date(subscription.current_period_start * 1000),
|
||||
end: new Date(subscription.current_period_end * 1000),
|
||||
trialStart: subscription.trial_start
|
||||
? new Date(subscription.trial_start * 1000)
|
||||
: null,
|
||||
trialEnd: subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: null,
|
||||
nextBillAt,
|
||||
canceledAt: subscription.canceled_at
|
||||
? new Date(subscription.canceled_at * 1000)
|
||||
: null,
|
||||
stripeSubscriptionId: subscription.id,
|
||||
plan,
|
||||
recurring,
|
||||
status: subscription.status,
|
||||
stripeScheduleId: subscription.schedule as string | null,
|
||||
};
|
||||
|
||||
const currentSubscription = await this.db.userSubscription.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (currentSubscription) {
|
||||
const update: Prisma.UserSubscriptionUpdateInput = {
|
||||
...commonData,
|
||||
};
|
||||
|
||||
// a schedule exists, update the recurring to scheduled one
|
||||
if (update.stripeScheduleId) {
|
||||
delete update.recurring;
|
||||
}
|
||||
|
||||
return await this.db.userSubscription.update({
|
||||
where: {
|
||||
id: currentSubscription.id,
|
||||
},
|
||||
data: update,
|
||||
});
|
||||
} else {
|
||||
return await this.db.userSubscription.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
...commonData,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateCustomer(
|
||||
idempotencyKey: string,
|
||||
user: User
|
||||
): Promise<UserStripeCustomer> {
|
||||
const customer = await this.db.userStripeCustomer.findUnique({
|
||||
where: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
if (customer) {
|
||||
return customer;
|
||||
}
|
||||
|
||||
const stripeCustomersList = await this.stripe.customers.list({
|
||||
email: user.email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
let stripeCustomer: Stripe.Customer | undefined;
|
||||
if (stripeCustomersList.data.length) {
|
||||
stripeCustomer = stripeCustomersList.data[0];
|
||||
} else {
|
||||
stripeCustomer = await this.stripe.customers.create(
|
||||
{ email: user.email },
|
||||
{ idempotencyKey }
|
||||
);
|
||||
}
|
||||
|
||||
return await this.db.userStripeCustomer.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async retrieveUserFromCustomer(customerId: string) {
|
||||
const customer = await this.db.userStripeCustomer.findUnique({
|
||||
where: {
|
||||
stripeCustomerId: customerId,
|
||||
},
|
||||
include: {
|
||||
user: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (customer?.user) {
|
||||
return customer.user;
|
||||
}
|
||||
|
||||
// customer may not saved is db, check it with stripe
|
||||
const stripeCustomer = await this.stripe.customers.retrieve(customerId);
|
||||
|
||||
if (stripeCustomer.deleted) {
|
||||
throw new Error('Unexpected subscription created with deleted customer');
|
||||
}
|
||||
|
||||
if (!stripeCustomer.email) {
|
||||
throw new Error('Unexpected subscription created with no email customer');
|
||||
}
|
||||
|
||||
const user = await this.db.user.findUnique({
|
||||
where: {
|
||||
email: stripeCustomer.email,
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new Error(
|
||||
`Unexpected subscription created with unknown customer ${stripeCustomer.email}`
|
||||
);
|
||||
}
|
||||
|
||||
await this.db.userStripeCustomer.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
},
|
||||
});
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async getPrice(
|
||||
plan: SubscriptionPlan,
|
||||
recurring: SubscriptionRecurring
|
||||
): Promise<string> {
|
||||
const prices = await this.stripe.prices.list({
|
||||
lookup_keys: [encodeLookupKey(plan, recurring)],
|
||||
});
|
||||
|
||||
if (!prices.data.length) {
|
||||
throw new Error(
|
||||
`Unknown subscription plan ${plan} with recurring ${recurring}`
|
||||
);
|
||||
}
|
||||
|
||||
return prices.data[0].id;
|
||||
}
|
||||
|
||||
private async getAvailableCoupon(
|
||||
user: User,
|
||||
couponType: CouponType
|
||||
): Promise<string | null> {
|
||||
const earlyAccess = await this.user.isEarlyAccessUser(user.email);
|
||||
if (earlyAccess) {
|
||||
try {
|
||||
const coupon = await this.stripe.coupons.retrieve(couponType);
|
||||
return coupon.valid ? coupon.id : null;
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to get early access coupon', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
18
packages/backend/server/src/modules/payment/stripe.ts
Normal file
18
packages/backend/server/src/modules/payment/stripe.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { FactoryProvider } from '@nestjs/common';
|
||||
import { omit } from 'lodash-es';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config } from '../../config';
|
||||
|
||||
export const StripeProvider: FactoryProvider = {
|
||||
provide: Stripe,
|
||||
useFactory: (config: Config) => {
|
||||
const stripeConfig = config.payment.stripe;
|
||||
|
||||
return new Stripe(
|
||||
stripeConfig.keys.APIKey,
|
||||
omit(config.payment.stripe, 'keys', 'prices')
|
||||
);
|
||||
},
|
||||
inject: [Config],
|
||||
};
|
||||
64
packages/backend/server/src/modules/payment/webhook.ts
Normal file
64
packages/backend/server/src/modules/payment/webhook.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { RawBodyRequest } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Logger,
|
||||
NotAcceptableException,
|
||||
Post,
|
||||
Req,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import type { Request } from 'express';
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { Config } from '../../config';
|
||||
|
||||
@Controller('/api/stripe')
|
||||
export class StripeWebhook {
|
||||
private readonly config: Config['payment'];
|
||||
private readonly logger = new Logger(StripeWebhook.name);
|
||||
|
||||
constructor(
|
||||
config: Config,
|
||||
private readonly stripe: Stripe,
|
||||
private readonly event: EventEmitter2
|
||||
) {
|
||||
this.config = config.payment;
|
||||
}
|
||||
|
||||
@Post('/webhook')
|
||||
async handleWebhook(@Req() req: RawBodyRequest<Request>) {
|
||||
// Check if webhook signing is configured.
|
||||
if (!this.config.stripe.keys.webhookKey) {
|
||||
this.logger.error(
|
||||
'Stripe Webhook key is not set, but a webhook was received.'
|
||||
);
|
||||
throw new NotAcceptableException();
|
||||
}
|
||||
|
||||
// Retrieve the event by verifying the signature using the raw body and secret.
|
||||
const signature = req.headers['stripe-signature'];
|
||||
try {
|
||||
const event = this.stripe.webhooks.constructEvent(
|
||||
req.rawBody ?? '',
|
||||
signature ?? '',
|
||||
this.config.stripe.keys.webhookKey
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`[${event.id}] Stripe Webhook {${event.type}} received.`
|
||||
);
|
||||
|
||||
// Stripe requires responseing webhook immediately and handle event asynchronously.
|
||||
setImmediate(() => {
|
||||
// handle duplicated events?
|
||||
// see https://stripe.com/docs/webhooks#handle-duplicate-events
|
||||
this.event.emitAsync(event.type, event.data.object).catch(e => {
|
||||
this.logger.error('Failed to handle Stripe Webhook event.', e);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error('Stripe Webhook error', err);
|
||||
throw new NotAcceptableException();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { UsersService } from './users';
|
||||
@Module({
|
||||
imports: [StorageModule],
|
||||
providers: [UserResolver, UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import GraphQLUpload from 'graphql-upload/GraphQLUpload.mjs';
|
||||
import { PrismaService } from '../../prisma/service';
|
||||
import { CloudThrottlerGuard, Throttle } from '../../throttler';
|
||||
import type { FileUpload } from '../../types';
|
||||
import { Auth, CurrentUser, Public } from '../auth/guard';
|
||||
import { Auth, CurrentUser, Public, Publicable } from '../auth/guard';
|
||||
import { StorageService } from '../storage/storage.service';
|
||||
import { NewFeaturesKind } from './types';
|
||||
import { UsersService } from './users';
|
||||
@@ -97,11 +97,17 @@ export class UserResolver {
|
||||
ttl: 60,
|
||||
},
|
||||
})
|
||||
@Publicable()
|
||||
@Query(() => UserType, {
|
||||
name: 'currentUser',
|
||||
description: 'Get current user',
|
||||
nullable: true,
|
||||
})
|
||||
async currentUser(@CurrentUser() user: UserType) {
|
||||
async currentUser(@CurrentUser() user?: UserType) {
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const storedUser = await this.users.findUserById(user.id);
|
||||
if (!storedUser) {
|
||||
throw new BadRequestException(`User ${user.id} not found in db`);
|
||||
|
||||
@@ -15,16 +15,21 @@ export class UsersService {
|
||||
|
||||
async canEarlyAccess(email: string) {
|
||||
if (this.config.featureFlags.earlyAccessPreview && !isStaff(email)) {
|
||||
return this.prisma.newFeaturesWaitingList
|
||||
.findUnique({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.catch(() => false);
|
||||
return this.isEarlyAccessUser(email);
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async isEarlyAccessUser(email: string) {
|
||||
return this.prisma.newFeaturesWaitingList
|
||||
.count({
|
||||
where: { email, type: NewFeaturesKind.EarlyAccess },
|
||||
})
|
||||
.then(count => count > 0)
|
||||
.catch(() => false);
|
||||
}
|
||||
|
||||
async getStorageQuotaById(id: string) {
|
||||
const features = await this.prisma.user
|
||||
.findUnique({
|
||||
|
||||
@@ -23,6 +23,8 @@ type UserType {
|
||||
"""User password has been set"""
|
||||
hasPassword: Boolean
|
||||
token: TokenType!
|
||||
subscription: UserSubscription
|
||||
invoices(take: Int = 8, skip: Int): [UserInvoice!]!
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -55,6 +57,74 @@ type TokenType {
|
||||
sessionToken: String
|
||||
}
|
||||
|
||||
type SubscriptionPrice {
|
||||
type: String!
|
||||
plan: SubscriptionPlan!
|
||||
currency: String!
|
||||
amount: Int!
|
||||
yearlyAmount: Int!
|
||||
}
|
||||
|
||||
enum SubscriptionPlan {
|
||||
Free
|
||||
Pro
|
||||
Team
|
||||
Enterprise
|
||||
}
|
||||
|
||||
type UserSubscription {
|
||||
id: String!
|
||||
plan: SubscriptionPlan!
|
||||
recurring: SubscriptionRecurring!
|
||||
status: SubscriptionStatus!
|
||||
start: DateTime!
|
||||
end: DateTime!
|
||||
trialStart: DateTime
|
||||
trialEnd: DateTime
|
||||
nextBillAt: DateTime
|
||||
canceledAt: DateTime
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum SubscriptionRecurring {
|
||||
Monthly
|
||||
Yearly
|
||||
}
|
||||
|
||||
enum SubscriptionStatus {
|
||||
Active
|
||||
PastDue
|
||||
Unpaid
|
||||
Canceled
|
||||
Incomplete
|
||||
Paused
|
||||
IncompleteExpired
|
||||
Trialing
|
||||
}
|
||||
|
||||
type UserInvoice {
|
||||
id: String!
|
||||
plan: SubscriptionPlan!
|
||||
recurring: SubscriptionRecurring!
|
||||
currency: String!
|
||||
amount: Int!
|
||||
status: InvoiceStatus!
|
||||
reason: String!
|
||||
lastPaymentError: String
|
||||
link: String
|
||||
createdAt: DateTime!
|
||||
updatedAt: DateTime!
|
||||
}
|
||||
|
||||
enum InvoiceStatus {
|
||||
Draft
|
||||
Open
|
||||
Void
|
||||
Paid
|
||||
Uncollectible
|
||||
}
|
||||
|
||||
type InviteUserType {
|
||||
"""User name"""
|
||||
name: String
|
||||
@@ -166,10 +236,11 @@ type Query {
|
||||
checkBlobSize(workspaceId: String!, size: Float!): WorkspaceBlobSizes!
|
||||
|
||||
"""Get current user"""
|
||||
currentUser: UserType!
|
||||
currentUser: UserType
|
||||
|
||||
"""Get user by email"""
|
||||
user(email: String!): UserType
|
||||
prices: [SubscriptionPrice!]!
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
@@ -205,6 +276,15 @@ type Mutation {
|
||||
removeAvatar: RemoveAvatar!
|
||||
deleteAccount: DeleteAccount!
|
||||
addToNewFeaturesWaitingList(type: NewFeaturesKind!, email: String!): AddToNewFeaturesWaitingList!
|
||||
|
||||
"""Create a subscription checkout link of stripe"""
|
||||
checkout(recurring: SubscriptionRecurring!, idempotencyKey: String!): String!
|
||||
|
||||
"""Create a stripe customer portal to manage payment methods"""
|
||||
createCustomerPortal: String!
|
||||
cancelSubscription(idempotencyKey: String!): UserSubscription!
|
||||
resumeSubscription(idempotencyKey: String!): UserSubscription!
|
||||
updateSubscriptionRecurring(recurring: SubscriptionRecurring!, idempotencyKey: String!): UserSubscription!
|
||||
}
|
||||
|
||||
"""The `Upload` scalar type represents a file upload."""
|
||||
|
||||
@@ -67,6 +67,6 @@ test('should be able to delete user', async t => {
|
||||
`,
|
||||
})
|
||||
.expect(200);
|
||||
await t.throwsAsync(() => currentUser(app, user.token.token));
|
||||
t.is(await currentUser(app, user.token.token), null);
|
||||
t.pass();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/storage",
|
||||
"version": "0.10.0-canary.3",
|
||||
"version": "0.10.0-canary.5",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0 < 11 || >= 11.8.0"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0"
|
||||
},
|
||||
"version": "0.10.0-canary.3"
|
||||
"version": "0.10.0-canary.5"
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"@types/debug": "^4.1.9",
|
||||
"vitest": "0.34.6"
|
||||
},
|
||||
"version": "0.10.0-canary.3"
|
||||
"version": "0.10.0-canary.5"
|
||||
}
|
||||
|
||||
2
packages/common/env/package.json
vendored
2
packages/common/env/package.json
vendored
@@ -27,5 +27,5 @@
|
||||
"dependencies": {
|
||||
"lit": "^2.8.0"
|
||||
},
|
||||
"version": "0.10.0-canary.3"
|
||||
"version": "0.10.0-canary.5"
|
||||
}
|
||||
|
||||
30
packages/common/env/src/filter.ts
vendored
30
packages/common/env/src/filter.ts
vendored
@@ -14,7 +14,9 @@ export type LiteralValue =
|
||||
| number
|
||||
| string
|
||||
| boolean
|
||||
| { [K: string]: LiteralValue }
|
||||
| {
|
||||
[K: string]: LiteralValue;
|
||||
}
|
||||
| Array<LiteralValue>;
|
||||
|
||||
export const refSchema: z.ZodType<Ref, z.ZodTypeDef> = z.object({
|
||||
@@ -48,15 +50,31 @@ export type Filter = z.input<typeof filterSchema>;
|
||||
|
||||
export const collectionSchema = z.object({
|
||||
id: z.string(),
|
||||
workspaceId: z.string(),
|
||||
name: z.string(),
|
||||
pinned: z.boolean().optional(),
|
||||
mode: z.union([z.literal('page'), z.literal('rule')]),
|
||||
filterList: z.array(filterSchema),
|
||||
allowList: z.array(z.string()).optional(),
|
||||
excludeList: z.array(z.string()).optional(),
|
||||
allowList: z.array(z.string()),
|
||||
// page id list
|
||||
pages: z.array(z.string()),
|
||||
});
|
||||
|
||||
export const deletedCollectionSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
userName: z.string(),
|
||||
collection: collectionSchema,
|
||||
});
|
||||
export type DeprecatedCollection = {
|
||||
id: string;
|
||||
name: string;
|
||||
workspaceId: string;
|
||||
filterList: z.infer<typeof filterSchema>[];
|
||||
allowList?: string[];
|
||||
};
|
||||
export type Collection = z.input<typeof collectionSchema>;
|
||||
export type DeleteCollectionInfo = {
|
||||
userId: string;
|
||||
userName: string;
|
||||
} | null;
|
||||
export type DeletedCollection = z.input<typeof deletedCollectionSchema>;
|
||||
|
||||
export const tagSchema = z.object({
|
||||
id: z.string(),
|
||||
|
||||
1
packages/common/env/src/global.ts
vendored
1
packages/common/env/src/global.ts
vendored
@@ -30,6 +30,7 @@ export const runtimeFlagsSchema = z.object({
|
||||
enableCloud: z.boolean(),
|
||||
enableCaptcha: z.boolean(),
|
||||
enableEnhanceShareMode: z.boolean(),
|
||||
enablePayment: z.boolean(),
|
||||
// this is for the electron app
|
||||
serverUrlPrefix: z.string(),
|
||||
enableMoveDatabase: z.boolean(),
|
||||
|
||||
12
packages/common/env/src/workspace.ts
vendored
12
packages/common/env/src/workspace.ts
vendored
@@ -8,10 +8,9 @@ import type {
|
||||
import type { PropsWithChildren, ReactNode } from 'react';
|
||||
import type { DataSourceAdapter } from 'y-provider';
|
||||
|
||||
import type { Collection } from './filter.js';
|
||||
|
||||
export enum WorkspaceSubPath {
|
||||
ALL = 'all',
|
||||
Collection = 'collection',
|
||||
SETTING = 'setting',
|
||||
TRASH = 'trash',
|
||||
SHARED = 'shared',
|
||||
@@ -137,6 +136,7 @@ type UIBaseProps<_Flavour extends keyof WorkspaceRegistry> = {
|
||||
|
||||
export type WorkspaceHeaderProps<Flavour extends keyof WorkspaceRegistry> =
|
||||
UIBaseProps<Flavour> & {
|
||||
rightSlot?: ReactNode;
|
||||
currentEntry:
|
||||
| {
|
||||
subPath: WorkspaceSubPath;
|
||||
@@ -167,20 +167,12 @@ type PageDetailProps<Flavour extends keyof WorkspaceRegistry> =
|
||||
onLoadEditor: (page: Page, editor: EditorContainer) => () => void;
|
||||
};
|
||||
|
||||
type PageListProps<_Flavour extends keyof WorkspaceRegistry> = {
|
||||
blockSuiteWorkspace: BlockSuiteWorkspace;
|
||||
onOpenPage: (pageId: string, newTab?: boolean) => void;
|
||||
collection: Collection;
|
||||
};
|
||||
|
||||
interface FC<P> {
|
||||
(props: P): ReactNode;
|
||||
}
|
||||
|
||||
export interface WorkspaceUISchema<Flavour extends keyof WorkspaceRegistry> {
|
||||
Header: FC<WorkspaceHeaderProps<Flavour>>;
|
||||
PageDetail: FC<PageDetailProps<Flavour>>;
|
||||
PageList: FC<PageListProps<Flavour>>;
|
||||
NewSettingsDetail: FC<NewSettingProps<Flavour>>;
|
||||
Provider: FC<PropsWithChildren>;
|
||||
LoginCard?: FC<object>;
|
||||
|
||||
@@ -111,5 +111,5 @@
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"version": "0.10.0-canary.3"
|
||||
"version": "0.10.0-canary.5"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@affine/sdk",
|
||||
"version": "0.10.0-canary.3",
|
||||
"version": "0.10.0-canary.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@toeverything/y-indexeddb",
|
||||
"type": "module",
|
||||
"version": "0.10.0-canary.3",
|
||||
"version": "0.10.0-canary.5",
|
||||
"description": "IndexedDB database adapter for Yjs",
|
||||
"repository": "toeverything/AFFiNE",
|
||||
"author": "toeverything",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "y-provider",
|
||||
"type": "module",
|
||||
"version": "0.10.0-canary.3",
|
||||
"version": "0.10.0-canary.5",
|
||||
"description": "Yjs provider protocol for multi document support",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
|
||||
@@ -33,13 +33,16 @@
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@radix-ui/react-avatar": "^1.0.4",
|
||||
"@radix-ui/react-collapsible": "^1.0.3",
|
||||
"@radix-ui/react-popover": "^1.0.7",
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-scroll-area": "^1.0.5",
|
||||
"@radix-ui/react-toast": "^1.1.5",
|
||||
"@radix-ui/react-toolbar": "^1.0.4",
|
||||
"@toeverything/hooks": "workspace:*",
|
||||
"@toeverything/infra": "workspace:*",
|
||||
"@toeverything/theme": "^0.7.20",
|
||||
"@vanilla-extract/dynamic": "^2.0.3",
|
||||
"bytes": "^3.1.2",
|
||||
"check-password-strength": "^2.0.7",
|
||||
"clsx": "^2.0.0",
|
||||
"dayjs": "^1.11.10",
|
||||
@@ -47,6 +50,7 @@
|
||||
"jotai": "^2.4.3",
|
||||
"lit": "^2.8.0",
|
||||
"lodash": "^4.17.21",
|
||||
"lodash-es": "^4.17.21",
|
||||
"lottie-react": "^2.4.0",
|
||||
"lottie-web": "^5.12.2",
|
||||
"nanoid": "^5.0.1",
|
||||
@@ -65,12 +69,13 @@
|
||||
"@blocksuite/blocks": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/editor": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/global": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/icons": "2.1.34",
|
||||
"@blocksuite/icons": "2.1.35",
|
||||
"@blocksuite/lit": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@blocksuite/store": "0.0.0-20231024064721-2aee7119-nightly",
|
||||
"@storybook/jest": "^0.2.3",
|
||||
"@storybook/testing-library": "^0.2.2",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@types/bytes": "^3.1.3",
|
||||
"@types/react": "^18.2.28",
|
||||
"@types/react-datepicker": "^4.19.0",
|
||||
"@types/react-dnd": "^3.0.2",
|
||||
@@ -82,5 +87,5 @@
|
||||
"vitest": "0.34.6",
|
||||
"yjs": "^13.6.8"
|
||||
},
|
||||
"version": "0.10.0-canary.3"
|
||||
"version": "0.10.0-canary.5"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as ScrollArea from '@radix-ui/react-scroll-area';
|
||||
import clsx from 'clsx';
|
||||
import { type PropsWithChildren } from 'react';
|
||||
import { type PropsWithChildren, useRef } from 'react';
|
||||
|
||||
import * as styles from './index.css';
|
||||
import { useHasScrollTop } from './use-has-scroll-top';
|
||||
@@ -10,7 +10,8 @@ export function SidebarContainer({ children }: PropsWithChildren) {
|
||||
}
|
||||
|
||||
export function SidebarScrollableContainer({ children }: PropsWithChildren) {
|
||||
const [hasScrollTop, ref] = useHasScrollTop();
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const hasScrollTop = useHasScrollTop(ref);
|
||||
return (
|
||||
<ScrollArea.Root className={styles.scrollableContainerRoot}>
|
||||
<div
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { type RefObject, useEffect, useState } from 'react';
|
||||
|
||||
export function useHasScrollTop() {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
export function useHasScrollTop(ref: RefObject<HTMLElement> | null) {
|
||||
const [hasScrollTop, setHasScrollTop] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current) {
|
||||
if (!ref?.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -13,8 +12,10 @@ export function useHasScrollTop() {
|
||||
|
||||
function updateScrollTop() {
|
||||
if (container) {
|
||||
const hasScrollTop = container.scrollTop > 0;
|
||||
setHasScrollTop(hasScrollTop);
|
||||
setTimeout(() => {
|
||||
const hasScrollTop = container.scrollTop > 0;
|
||||
setHasScrollTop(hasScrollTop);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +24,7 @@ export function useHasScrollTop() {
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateScrollTop);
|
||||
};
|
||||
}, []);
|
||||
}, [ref]);
|
||||
|
||||
return [hasScrollTop, ref] as const;
|
||||
return hasScrollTop;
|
||||
}
|
||||
|
||||
@@ -2,13 +2,14 @@ import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const sidebarSwitch = style({
|
||||
opacity: 0,
|
||||
width: 0,
|
||||
display: 'none !important',
|
||||
overflow: 'hidden',
|
||||
pointerEvents: 'none',
|
||||
transition: 'all .3s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-show=true]': {
|
||||
opacity: 1,
|
||||
display: 'inline-flex !important',
|
||||
width: '32px',
|
||||
flexShrink: 0,
|
||||
fontSize: '24px',
|
||||
|
||||
@@ -6,25 +6,52 @@ import 'fake-indexeddb/auto';
|
||||
import type { Collection } from '@affine/env/filter';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { atom } from 'jotai';
|
||||
import { atomWithObservable } from 'jotai/utils';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { expect, test } from 'vitest';
|
||||
|
||||
import { createDefaultFilter, vars } from '../filter/vars';
|
||||
import {
|
||||
type CollectionsAtom,
|
||||
type CollectionsCRUDAtom,
|
||||
useCollectionManager,
|
||||
} from '../use-collection-manager';
|
||||
|
||||
const defaultMeta = { tags: { options: [] } };
|
||||
|
||||
const baseAtom = atom<Collection[]>([]);
|
||||
|
||||
const mockAtom: CollectionsAtom = atom(
|
||||
get => get(baseAtom),
|
||||
async (_, set, update) => {
|
||||
set(baseAtom, update);
|
||||
const collectionsSubject = new BehaviorSubject<Collection[]>([]);
|
||||
const baseAtom = atomWithObservable<Collection[]>(
|
||||
() => {
|
||||
return collectionsSubject;
|
||||
},
|
||||
{
|
||||
initialValue: [],
|
||||
}
|
||||
);
|
||||
|
||||
const mockAtom: CollectionsCRUDAtom = atom(get => {
|
||||
return {
|
||||
collections: get(baseAtom),
|
||||
addCollection: async (...collections) => {
|
||||
const prev = collectionsSubject.value;
|
||||
collectionsSubject.next([...collections, ...prev]);
|
||||
},
|
||||
deleteCollection: async (...ids) => {
|
||||
const prev = collectionsSubject.value;
|
||||
collectionsSubject.next(prev.filter(v => !ids.includes(v.id)));
|
||||
},
|
||||
updateCollection: async (id, updater) => {
|
||||
const prev = collectionsSubject.value;
|
||||
collectionsSubject.next(
|
||||
prev.map(v => {
|
||||
if (v.id === id) {
|
||||
return updater(v);
|
||||
}
|
||||
return v;
|
||||
})
|
||||
);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
test('useAllPageSetting', async () => {
|
||||
const settingHook = renderHook(() => useCollectionManager(mockAtom));
|
||||
const prevCollection = settingHook.result.current.currentCollection;
|
||||
@@ -32,7 +59,6 @@ test('useAllPageSetting', async () => {
|
||||
await settingHook.result.current.updateCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
filterList: [createDefaultFilter(vars[0], defaultMeta)],
|
||||
workspaceId: 'test',
|
||||
});
|
||||
settingHook.rerender();
|
||||
const nextCollection = settingHook.result.current.currentCollection;
|
||||
@@ -40,8 +66,7 @@ test('useAllPageSetting', async () => {
|
||||
expect(nextCollection.filterList).toEqual([
|
||||
createDefaultFilter(vars[0], defaultMeta),
|
||||
]);
|
||||
settingHook.result.current.backToAll();
|
||||
await settingHook.result.current.saveCollection({
|
||||
await settingHook.result.current.createCollection({
|
||||
...settingHook.result.current.currentCollection,
|
||||
id: '1',
|
||||
});
|
||||
|
||||
@@ -1,321 +0,0 @@
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ArrowDownBigIcon, ArrowUpBigIcon } from '@blocksuite/icons';
|
||||
import { useMediaQuery, useTheme } from '@mui/material';
|
||||
import type React from 'react';
|
||||
import { type CSSProperties, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
ScrollableContainer,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeadRow,
|
||||
} from '../..';
|
||||
import { TableBodyRow } from '../../ui/table';
|
||||
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||
import { AllPagesBody } from './all-pages-body';
|
||||
import { NewPageButton } from './components/new-page-buttton';
|
||||
import { TitleCell } from './components/title-cell';
|
||||
import { AllPageListMobileView, TrashListMobileView } from './mobile';
|
||||
import { TrashOperationCell } from './operation-cell';
|
||||
import { StyledTableContainer } from './styles';
|
||||
import type { ListData, PageListProps, TrashListData } from './type';
|
||||
import type { CollectionsAtom } from './use-collection-manager';
|
||||
import { useSorter } from './use-sorter';
|
||||
import { formatDate, useIsSmallDevices } from './utils';
|
||||
import { CollectionBar } from './view/collection-bar';
|
||||
|
||||
interface AllPagesHeadProps {
|
||||
isPublicWorkspace: boolean;
|
||||
sorter: ReturnType<typeof useSorter<ListData>>;
|
||||
createNewPage: () => void;
|
||||
createNewEdgeless: () => void;
|
||||
importFile: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
collectionsAtom: CollectionsAtom;
|
||||
}
|
||||
|
||||
const AllPagesHead = ({
|
||||
isPublicWorkspace,
|
||||
sorter,
|
||||
createNewPage,
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
collectionsAtom,
|
||||
}: AllPagesHeadProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const titleList = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'title',
|
||||
content: t['Title'](),
|
||||
proportion: 0.5,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: t['Tags'](),
|
||||
proportion: 0.2,
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: t['Created'](),
|
||||
proportion: 0.1,
|
||||
tableCellStyle: {
|
||||
width: '110px',
|
||||
} satisfies CSSProperties,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: t['Updated'](),
|
||||
proportion: 0.1,
|
||||
tableCellStyle: {
|
||||
width: '110px',
|
||||
} satisfies CSSProperties,
|
||||
},
|
||||
{
|
||||
key: 'unsortable_action',
|
||||
content: (
|
||||
<NewPageButton
|
||||
createNewPage={createNewPage}
|
||||
createNewEdgeless={createNewEdgeless}
|
||||
importFile={importFile}
|
||||
/>
|
||||
),
|
||||
showWhen: () => !isPublicWorkspace,
|
||||
sortable: false,
|
||||
tableCellStyle: {
|
||||
width: '140px',
|
||||
} satisfies CSSProperties,
|
||||
styles: {
|
||||
justifyContent: 'flex-end',
|
||||
} satisfies CSSProperties,
|
||||
},
|
||||
],
|
||||
[createNewEdgeless, createNewPage, importFile, isPublicWorkspace, t]
|
||||
);
|
||||
const tableItem = useMemo(
|
||||
() =>
|
||||
titleList
|
||||
.filter(({ showWhen = () => true }) => showWhen())
|
||||
.map(
|
||||
({
|
||||
key,
|
||||
content,
|
||||
proportion,
|
||||
sortable = true,
|
||||
styles,
|
||||
tableCellStyle,
|
||||
}) => (
|
||||
<TableCell
|
||||
key={key}
|
||||
proportion={proportion}
|
||||
active={sorter.key === key}
|
||||
style={tableCellStyle}
|
||||
onClick={
|
||||
sortable
|
||||
? () => sorter.shiftOrder(key as keyof ListData)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
...styles,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
{sorter.key === key &&
|
||||
(sorter.order === 'asc' ? (
|
||||
<ArrowUpBigIcon width={24} height={24} />
|
||||
) : (
|
||||
<ArrowDownBigIcon width={24} height={24} />
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
)
|
||||
),
|
||||
[sorter, titleList]
|
||||
);
|
||||
return (
|
||||
<TableHead>
|
||||
<TableHeadRow>{tableItem}</TableHeadRow>
|
||||
<CollectionBar
|
||||
columnsCount={titleList.length}
|
||||
getPageInfo={getPageInfo}
|
||||
propertiesMeta={propertiesMeta}
|
||||
collectionsAtom={collectionsAtom}
|
||||
/>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageList = ({
|
||||
isPublicWorkspace = false,
|
||||
collectionsAtom,
|
||||
list,
|
||||
onCreateNewPage,
|
||||
onCreateNewEdgeless,
|
||||
onImportFile,
|
||||
fallback,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
}: PageListProps) => {
|
||||
const sorter = useSorter<ListData>({
|
||||
data: list,
|
||||
key: DEFAULT_SORT_KEY,
|
||||
order: 'desc',
|
||||
});
|
||||
const [hasScrollTop, ref] = useHasScrollTop();
|
||||
const isSmallDevices = useIsSmallDevices();
|
||||
if (isSmallDevices) {
|
||||
return (
|
||||
<ScrollableContainer inTableView>
|
||||
<AllPageListMobileView
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
createNewPage={onCreateNewPage}
|
||||
createNewEdgeless={onCreateNewEdgeless}
|
||||
importFile={onImportFile}
|
||||
list={sorter.data}
|
||||
/>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const groupKey =
|
||||
sorter.key === 'createDate' || sorter.key === 'updatedDate'
|
||||
? sorter.key
|
||||
: // default sort
|
||||
!sorter.key
|
||||
? DEFAULT_SORT_KEY
|
||||
: undefined;
|
||||
|
||||
return sorter.data.length === 0 && fallback ? (
|
||||
<StyledTableContainer>{fallback}</StyledTableContainer>
|
||||
) : (
|
||||
<ScrollableContainer inTableView>
|
||||
<StyledTableContainer ref={ref}>
|
||||
<Table showBorder={hasScrollTop} style={{ maxHeight: '100%' }}>
|
||||
<AllPagesHead
|
||||
collectionsAtom={collectionsAtom}
|
||||
propertiesMeta={propertiesMeta}
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
sorter={sorter}
|
||||
createNewPage={onCreateNewPage}
|
||||
createNewEdgeless={onCreateNewEdgeless}
|
||||
importFile={onImportFile}
|
||||
getPageInfo={getPageInfo}
|
||||
/>
|
||||
<AllPagesBody
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
groupKey={groupKey}
|
||||
data={sorter.data}
|
||||
/>
|
||||
</Table>
|
||||
</StyledTableContainer>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
const TrashListHead = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<TableHead>
|
||||
<TableHeadRow>
|
||||
<TableCell proportion={0.5}>{t['Title']()}</TableCell>
|
||||
<TableCell proportion={0.2}>{t['Created']()}</TableCell>
|
||||
<TableCell proportion={0.2}>{t['Moved to Trash']()}</TableCell>
|
||||
<TableCell proportion={0.1}></TableCell>
|
||||
</TableHeadRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageListTrashViewProps {
|
||||
list: TrashListData[];
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PageListTrashView = ({
|
||||
list,
|
||||
fallback,
|
||||
}: PageListTrashViewProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const theme = useTheme();
|
||||
const [hasScrollTop, ref] = useHasScrollTop();
|
||||
const isSmallDevices = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
if (isSmallDevices) {
|
||||
const mobileList = list.map(({ pageId, icon, title, onClickPage }) => ({
|
||||
title,
|
||||
icon,
|
||||
pageId,
|
||||
onClickPage,
|
||||
}));
|
||||
return <TrashListMobileView list={mobileList} />;
|
||||
}
|
||||
const ListItems = list.map(
|
||||
(
|
||||
{
|
||||
pageId,
|
||||
title,
|
||||
preview,
|
||||
icon,
|
||||
createDate,
|
||||
trashDate,
|
||||
onClickPage,
|
||||
onPermanentlyDeletePage,
|
||||
onRestorePage,
|
||||
},
|
||||
index
|
||||
) => {
|
||||
return (
|
||||
<TableBodyRow
|
||||
data-testid={`page-list-item-${pageId}`}
|
||||
key={`${pageId}-${index}`}
|
||||
>
|
||||
<TitleCell
|
||||
icon={icon}
|
||||
text={title || t['Untitled']()}
|
||||
desc={preview}
|
||||
onClick={onClickPage}
|
||||
/>
|
||||
<TableCell onClick={onClickPage}>{formatDate(createDate)}</TableCell>
|
||||
<TableCell onClick={onClickPage}>
|
||||
{trashDate ? formatDate(trashDate) : '--'}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
style={{ padding: 0 }}
|
||||
data-testid={`more-actions-${pageId}`}
|
||||
>
|
||||
<TrashOperationCell
|
||||
onPermanentlyDeletePage={onPermanentlyDeletePage}
|
||||
onRestorePage={onRestorePage}
|
||||
onOpenPage={onClickPage}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableBodyRow>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
return list.length === 0 && fallback ? (
|
||||
<StyledTableContainer>{fallback}</StyledTableContainer>
|
||||
) : (
|
||||
<ScrollableContainer inTableView>
|
||||
<StyledTableContainer ref={ref}>
|
||||
<Table showBorder={hasScrollTop}>
|
||||
<TrashListHead />
|
||||
<TableBody>{ListItems}</TableBody>
|
||||
</Table>
|
||||
</StyledTableContainer>
|
||||
</ScrollableContainer>
|
||||
);
|
||||
};
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Fragment } from 'react';
|
||||
|
||||
import { styled } from '../../styles';
|
||||
import { TableBody, TableCell } from '../../ui/table';
|
||||
import { FavoriteTag } from './components/favorite-tag';
|
||||
import { Tags } from './components/tags';
|
||||
import { TitleCell } from './components/title-cell';
|
||||
import { OperationCell } from './operation-cell';
|
||||
import { StyledTableBodyRow } from './styles';
|
||||
import type { DateKey, DraggableTitleCellData, ListData } from './type';
|
||||
import { useDateGroup } from './use-date-group';
|
||||
import { formatDate, useIsSmallDevices } from './utils';
|
||||
|
||||
export const GroupRow = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<StyledTableBodyRow>
|
||||
<TableCell
|
||||
style={{
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
background: 'initial',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TableCell>
|
||||
</StyledTableBodyRow>
|
||||
);
|
||||
};
|
||||
|
||||
export const AllPagesBody = ({
|
||||
isPublicWorkspace,
|
||||
data,
|
||||
groupKey,
|
||||
}: {
|
||||
isPublicWorkspace: boolean;
|
||||
data: ListData[];
|
||||
groupKey?: DateKey;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const isSmallDevices = useIsSmallDevices();
|
||||
const dataWithGroup = useDateGroup({ data, key: groupKey });
|
||||
return (
|
||||
<TableBody style={{ overflowY: 'auto', height: '100%' }}>
|
||||
{dataWithGroup.map(
|
||||
(
|
||||
{
|
||||
groupName,
|
||||
pageId,
|
||||
title,
|
||||
preview,
|
||||
tags,
|
||||
icon,
|
||||
isPublicPage,
|
||||
favorite,
|
||||
createDate,
|
||||
updatedDate,
|
||||
onClickPage,
|
||||
bookmarkPage,
|
||||
onOpenPageInNewTab,
|
||||
removeToTrash,
|
||||
onDisablePublicSharing,
|
||||
},
|
||||
index
|
||||
) => {
|
||||
const displayTitle = title || t['Untitled']();
|
||||
return (
|
||||
<Fragment key={pageId}>
|
||||
{groupName &&
|
||||
(index === 0 ||
|
||||
dataWithGroup[index - 1].groupName !== groupName) && (
|
||||
<GroupRow>{groupName}</GroupRow>
|
||||
)}
|
||||
<StyledTableBodyRow data-testid={`page-list-item-${pageId}`}>
|
||||
<DraggableTitleCell
|
||||
pageId={pageId}
|
||||
draggableData={{
|
||||
pageId,
|
||||
pageTitle: displayTitle,
|
||||
icon,
|
||||
}}
|
||||
icon={icon}
|
||||
text={displayTitle}
|
||||
desc={preview}
|
||||
data-testid="title"
|
||||
onClick={onClickPage}
|
||||
/>
|
||||
<TableCell
|
||||
data-testid="tags"
|
||||
hidden={isSmallDevices}
|
||||
onClick={onClickPage}
|
||||
style={{ fontSize: 'var(--affine-font-xs)' }}
|
||||
>
|
||||
<Tags value={tags}></Tags>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-testid="created-date"
|
||||
ellipsis={true}
|
||||
hidden={isSmallDevices}
|
||||
onClick={onClickPage}
|
||||
style={{ fontSize: 'var(--affine-font-xs)' }}
|
||||
>
|
||||
{formatDate(createDate)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
data-testid="updated-date"
|
||||
ellipsis={true}
|
||||
hidden={isSmallDevices}
|
||||
onClick={onClickPage}
|
||||
style={{ fontSize: 'var(--affine-font-xs)' }}
|
||||
>
|
||||
{formatDate(updatedDate ?? createDate)}
|
||||
</TableCell>
|
||||
{!isPublicWorkspace && (
|
||||
<TableCell
|
||||
style={{
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
}}
|
||||
data-testid={`more-actions-${pageId}`}
|
||||
>
|
||||
<FavoriteTag
|
||||
className={favorite ? '' : 'favorite-button'}
|
||||
onClick={bookmarkPage}
|
||||
active={!!favorite}
|
||||
/>
|
||||
<OperationCell
|
||||
favorite={favorite}
|
||||
isPublic={isPublicPage}
|
||||
onOpenPageInNewTab={onOpenPageInNewTab}
|
||||
onToggleFavoritePage={bookmarkPage}
|
||||
onRemoveToTrash={removeToTrash}
|
||||
onDisablePublicSharing={onDisablePublicSharing}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
</StyledTableBodyRow>
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</TableBody>
|
||||
);
|
||||
};
|
||||
|
||||
const FullSizeButton = styled('button')(() => ({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'block',
|
||||
}));
|
||||
|
||||
type DraggableTitleCellProps = {
|
||||
pageId: string;
|
||||
draggableData?: DraggableTitleCellData;
|
||||
} & React.ComponentProps<typeof TitleCell>;
|
||||
|
||||
function DraggableTitleCell({
|
||||
pageId,
|
||||
draggableData,
|
||||
...props
|
||||
}: DraggableTitleCellProps) {
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'page-list-item-title-' + pageId,
|
||||
data: draggableData,
|
||||
});
|
||||
|
||||
return (
|
||||
<TitleCell
|
||||
ref={setNodeRef}
|
||||
style={{ opacity: isDragging ? 0.5 : 1 }}
|
||||
{...props}
|
||||
>
|
||||
{/* Use `button` for draggable element */}
|
||||
{/* See https://docs.dndkit.com/api-documentation/draggable/usedraggable#role */}
|
||||
{element => (
|
||||
<FullSizeButton {...listeners} {...attributes}>
|
||||
{element}
|
||||
</FullSizeButton>
|
||||
)}
|
||||
</TitleCell>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const divider = style({
|
||||
width: '0.5px',
|
||||
height: '16px',
|
||||
background: 'var(--affine-divider-color)',
|
||||
// fix dropdown button click area
|
||||
margin: '0 4px',
|
||||
marginRight: 0,
|
||||
});
|
||||
|
||||
export const dropdownWrapper = style({
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingLeft: '4px',
|
||||
paddingRight: '10px',
|
||||
});
|
||||
|
||||
export const dropdownIcon = style({
|
||||
borderRadius: '4px',
|
||||
selectors: {
|
||||
[`${dropdownWrapper}:hover &`]: {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const dropdownBtn = style({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 10px',
|
||||
// fix dropdown button click area
|
||||
paddingRight: 0,
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
fontWeight: 600,
|
||||
background: 'var(--affine-button-gray-color)',
|
||||
boxShadow: 'var(--affine-float-button-shadow)',
|
||||
borderRadius: '8px',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
// width: '100%',
|
||||
height: '32px',
|
||||
userSelect: 'none',
|
||||
whiteSpace: 'nowrap',
|
||||
cursor: 'pointer',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const menuContent = style({
|
||||
backgroundColor: 'var(--affine-background-overlay-panel-color)',
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import { ArrowDownSmallIcon } from '@blocksuite/icons';
|
||||
import {
|
||||
type ButtonHTMLAttributes,
|
||||
forwardRef,
|
||||
type MouseEventHandler,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './dropdown.css';
|
||||
|
||||
type DropdownButtonProps = {
|
||||
onClickDropDown?: MouseEventHandler<HTMLElement>;
|
||||
} & ButtonHTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export const DropdownButton = forwardRef<
|
||||
HTMLButtonElement,
|
||||
DropdownButtonProps
|
||||
>(({ onClickDropDown, children, ...props }, ref) => {
|
||||
const handleClickDropDown: MouseEventHandler<HTMLElement> = e => {
|
||||
e.stopPropagation();
|
||||
onClickDropDown?.(e);
|
||||
};
|
||||
return (
|
||||
<button ref={ref} className={styles.dropdownBtn} {...props}>
|
||||
<span>{children}</span>
|
||||
<span className={styles.divider} />
|
||||
<span className={styles.dropdownWrapper} onClick={handleClickDropDown}>
|
||||
<ArrowDownSmallIcon
|
||||
className={styles.dropdownIcon}
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
DropdownButton.displayName = 'DropdownButton';
|
||||
@@ -21,6 +21,7 @@ export const FavoriteTag = forwardRef<
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
onClick?.(e);
|
||||
setPlayAnimation(!active);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
import * as Popover from '@radix-ui/react-popover';
|
||||
import * as Toolbar from '@radix-ui/react-toolbar';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './floating-toolbar.css';
|
||||
|
||||
interface FloatingToolbarProps {
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
open?: boolean;
|
||||
// if dbclick outside of the panel, close the toolbar
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FloatingToolbarButtonProps {
|
||||
icon: ReactNode;
|
||||
onClick: MouseEventHandler;
|
||||
type?: 'danger' | 'default';
|
||||
label?: ReactNode;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
}
|
||||
|
||||
interface FloatingToolbarItemProps {}
|
||||
|
||||
export function FloatingToolbar({
|
||||
children,
|
||||
style,
|
||||
className,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PropsWithChildren<FloatingToolbarProps>) {
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const animatingRef = useRef(false);
|
||||
|
||||
// todo: move dbclick / esc to close to page list instead
|
||||
useEffect(() => {
|
||||
animatingRef.current = true;
|
||||
const timer = setTimeout(() => {
|
||||
animatingRef.current = false;
|
||||
}, 200);
|
||||
|
||||
if (open) {
|
||||
// when dbclick outside of the panel or typing ESC, close the toolbar
|
||||
const dbcHandler = (e: MouseEvent) => {
|
||||
if (
|
||||
!contentRef.current?.contains(e.target as Node) &&
|
||||
!animatingRef.current
|
||||
) {
|
||||
// close the toolbar
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
const escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !animatingRef.current) {
|
||||
onOpenChange?.(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('dblclick', dbcHandler);
|
||||
document.addEventListener('keydown', escHandler);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('dblclick', dbcHandler);
|
||||
document.removeEventListener('keydown', escHandler);
|
||||
};
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [onOpenChange, open]);
|
||||
|
||||
return (
|
||||
<Popover.Root open={open}>
|
||||
{/* Having Anchor here to let Popover to calculate the position of the place it is being used */}
|
||||
<Popover.Anchor className={className} style={style} />
|
||||
<Popover.Portal>
|
||||
{/* always pop up on top for now */}
|
||||
<Popover.Content side="top" className={styles.popoverContent}>
|
||||
<Toolbar.Root ref={contentRef} className={clsx(styles.root)}>
|
||||
{children}
|
||||
</Toolbar.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Portal>
|
||||
</Popover.Root>
|
||||
);
|
||||
}
|
||||
|
||||
// freestyle item that allows user to do anything
|
||||
export function FloatingToolbarItem({
|
||||
children,
|
||||
}: PropsWithChildren<FloatingToolbarItemProps>) {
|
||||
return <div className={styles.item}>{children}</div>;
|
||||
}
|
||||
|
||||
// a typical button that has icon and label
|
||||
export function FloatingToolbarButton({
|
||||
icon,
|
||||
type,
|
||||
onClick,
|
||||
className,
|
||||
style,
|
||||
label,
|
||||
}: FloatingToolbarButtonProps) {
|
||||
return (
|
||||
<Toolbar.Button
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
styles.button,
|
||||
type === 'danger' && styles.danger,
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<div className={styles.buttonIcon}>{icon}</div>
|
||||
{label}
|
||||
</Toolbar.Button>
|
||||
);
|
||||
}
|
||||
|
||||
export function FloatingToolbarSeparator() {
|
||||
return <Toolbar.Separator className={styles.separator} />;
|
||||
}
|
||||
|
||||
FloatingToolbar.Item = FloatingToolbarItem;
|
||||
FloatingToolbar.Separator = FloatingToolbarSeparator;
|
||||
FloatingToolbar.Button = FloatingToolbarButton;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
const slideDownAndFade = keyframes({
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.95) translateY(20px)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
transform: 'scale(1) translateY(0)',
|
||||
},
|
||||
});
|
||||
|
||||
const slideUpAndFade = keyframes({
|
||||
'0%': {
|
||||
opacity: 1,
|
||||
transform: 'scale(1) translateY(0)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 0,
|
||||
transform: 'scale(0.95) translateY(20px)',
|
||||
},
|
||||
});
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: '10px',
|
||||
padding: '4px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
boxShadow: 'var(--affine-menu-shadow)',
|
||||
gap: 4,
|
||||
minWidth: 'max-content',
|
||||
width: 'fit-content',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const popoverContent = style({
|
||||
willChange: 'transform opacity',
|
||||
selectors: {
|
||||
'&[data-state="open"]': {
|
||||
animation: `${slideDownAndFade} 0.2s ease-in-out`,
|
||||
},
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${slideUpAndFade} 0.2s ease-in-out`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const separator = style({
|
||||
width: '1px',
|
||||
height: '24px',
|
||||
background: 'var(--affine-divider-color)',
|
||||
});
|
||||
|
||||
export const item = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'inherit',
|
||||
gap: 4,
|
||||
height: '32px',
|
||||
padding: '0 6px',
|
||||
});
|
||||
|
||||
export const button = style([
|
||||
item,
|
||||
{
|
||||
borderRadius: '8px',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const danger = style({
|
||||
color: 'inherit',
|
||||
':hover': {
|
||||
background: 'var(--affine-background-error-color)',
|
||||
color: 'var(--affine-error-color)',
|
||||
},
|
||||
});
|
||||
|
||||
export const buttonIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 20,
|
||||
color: 'var(--affine-icon-color)',
|
||||
selectors: {
|
||||
[`${danger}:hover &`]: {
|
||||
color: 'var(--affine-error-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuContent = style({
|
||||
backgroundColor: 'var(--affine-background-overlay-panel-color)',
|
||||
});
|
||||
@@ -1,16 +1,17 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { EdgelessIcon, ImportIcon, PageIcon } from '@blocksuite/icons';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { type PropsWithChildren, useCallback, useState } from 'react';
|
||||
|
||||
import { DropdownButton } from '../../../ui/button';
|
||||
import { BlockCard } from '../../card/block-card';
|
||||
import { DropdownButton } from './dropdown';
|
||||
import { menuContent } from './dropdown.css';
|
||||
import { menuContent } from './new-page-button.css';
|
||||
|
||||
type NewPageButtonProps = {
|
||||
createNewPage: () => void;
|
||||
createNewEdgeless: () => void;
|
||||
importFile: () => void;
|
||||
size?: 'small' | 'default';
|
||||
};
|
||||
|
||||
export const CreateNewPagePopup = ({
|
||||
@@ -58,8 +59,9 @@ export const NewPageButton = ({
|
||||
createNewPage,
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
}: NewPageButtonProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
size,
|
||||
children,
|
||||
}: PropsWithChildren<NewPageButtonProps>) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Menu
|
||||
@@ -92,13 +94,15 @@ export const NewPageButton = ({
|
||||
}}
|
||||
>
|
||||
<DropdownButton
|
||||
size={size}
|
||||
data-testid="new-page-button"
|
||||
onClick={useCallback(() => {
|
||||
createNewPage();
|
||||
setOpen(false);
|
||||
}, [createNewPage])}
|
||||
onClickDropDown={useCallback(() => setOpen(open => !open), [])}
|
||||
>
|
||||
{t['New Page']()}
|
||||
{children}
|
||||
</DropdownButton>
|
||||
</Menu>
|
||||
);
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const tagList = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'nowrap',
|
||||
gap: 10,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const tagListFull = style({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: 10,
|
||||
maxWidth: 300,
|
||||
padding: 10,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const tag = style({
|
||||
flexShrink: 0,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
lineHeight: '16px',
|
||||
fontWeight: 400,
|
||||
maxWidth: '100%',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
|
||||
import * as styles from './tags.css';
|
||||
|
||||
// fixme: This component should use popover instead of menu
|
||||
export const Tags = ({ value }: { value: Tag[] }) => {
|
||||
const list = value.map(tag => {
|
||||
return (
|
||||
<div
|
||||
key={tag.id}
|
||||
className={styles.tag}
|
||||
style={{ backgroundColor: tag.color }}
|
||||
>
|
||||
{tag.value}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return (
|
||||
<Menu items={<div className={styles.tagListFull}>{list}</div>}>
|
||||
<div className={styles.tagList}>{list}</div>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -1,66 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import type { TableCellProps } from '../../..';
|
||||
import { Content, TableCell } from '../../..';
|
||||
import {
|
||||
StyledTitleContentWrapper,
|
||||
StyledTitleLink,
|
||||
StyledTitlePreview,
|
||||
} from '../styles';
|
||||
|
||||
type TitleCellProps = {
|
||||
icon: JSX.Element;
|
||||
text: string;
|
||||
desc?: React.ReactNode;
|
||||
suffix?: JSX.Element;
|
||||
/**
|
||||
* Customize the children of the cell
|
||||
* @param element
|
||||
* @returns
|
||||
*/
|
||||
children?: (element: React.ReactElement) => React.ReactNode;
|
||||
} & Omit<TableCellProps, 'children'>;
|
||||
|
||||
export const TitleCell = React.forwardRef<HTMLTableCellElement, TitleCellProps>(
|
||||
({ icon, text, desc, suffix, children: render, ...props }, ref) => {
|
||||
const renderChildren = useCallback(() => {
|
||||
const childElement = (
|
||||
<>
|
||||
<StyledTitleLink>
|
||||
{icon}
|
||||
<StyledTitleContentWrapper>
|
||||
<Content
|
||||
ellipsis={true}
|
||||
maxWidth="100%"
|
||||
color="inherit"
|
||||
fontSize="var(--affine-font-sm)"
|
||||
weight="600"
|
||||
lineHeight="18px"
|
||||
>
|
||||
{text}
|
||||
</Content>
|
||||
{desc && (
|
||||
<StyledTitlePreview
|
||||
ellipsis={true}
|
||||
color="var(--affine-text-secondary-color)"
|
||||
>
|
||||
{desc}
|
||||
</StyledTitlePreview>
|
||||
)}
|
||||
</StyledTitleContentWrapper>
|
||||
</StyledTitleLink>
|
||||
{suffix}
|
||||
</>
|
||||
);
|
||||
|
||||
return render ? render(childElement) : childElement;
|
||||
}, [desc, icon, render, suffix, text]);
|
||||
|
||||
return (
|
||||
<TableCell ref={ref} {...props}>
|
||||
{renderChildren()}
|
||||
</TableCell>
|
||||
);
|
||||
}
|
||||
);
|
||||
TitleCell.displayName = 'TitleCell';
|
||||
@@ -63,7 +63,7 @@ export const FilterList = ({
|
||||
>
|
||||
{value.length === 0 ? (
|
||||
<Button
|
||||
icon={<PlusIcon />}
|
||||
icon={<PlusIcon style={{ color: 'var(--affine-icon-color)' }} />}
|
||||
iconPosition="end"
|
||||
style={{ fontSize: 'var(--affine-font-xs)', padding: '0 8px' }}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
export * from './all-page';
|
||||
export * from './components/favorite-tag';
|
||||
export * from './components/floating-toobar';
|
||||
export * from './components/new-page-buttton';
|
||||
export * from './components/title-cell';
|
||||
export * from './filter';
|
||||
export * from './operation-cell';
|
||||
export * from './operation-menu-items';
|
||||
export * from './styles';
|
||||
export * from './type';
|
||||
export * from './page-list';
|
||||
export * from './page-list-item';
|
||||
export * from './page-tags';
|
||||
export * from './types';
|
||||
export * from './use-collection-manager';
|
||||
export * from './utils';
|
||||
export * from './view';
|
||||
|
||||
@@ -1,129 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
|
||||
import {
|
||||
Content,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeadRow,
|
||||
} from '../../..';
|
||||
import { AllPagesBody } from './all-pages-body';
|
||||
import { NewPageButton } from './components/new-page-buttton';
|
||||
import {
|
||||
StyledTableBodyRow,
|
||||
StyledTableContainer,
|
||||
StyledTitleLink,
|
||||
} from './styles';
|
||||
import type { ListData } from './type';
|
||||
|
||||
const MobileHead = ({
|
||||
isPublicWorkspace,
|
||||
createNewPage,
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
}: {
|
||||
isPublicWorkspace: boolean;
|
||||
createNewPage: () => void;
|
||||
createNewEdgeless: () => void;
|
||||
importFile: () => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<TableHead>
|
||||
<TableHeadRow>
|
||||
<TableCell proportion={0.8}>{t['Title']()}</TableCell>
|
||||
{!isPublicWorkspace && (
|
||||
<TableCell>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<NewPageButton
|
||||
createNewPage={createNewPage}
|
||||
createNewEdgeless={createNewEdgeless}
|
||||
importFile={importFile}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableHeadRow>
|
||||
</TableHead>
|
||||
);
|
||||
};
|
||||
|
||||
export const AllPageListMobileView = ({
|
||||
list,
|
||||
isPublicWorkspace,
|
||||
createNewPage,
|
||||
createNewEdgeless,
|
||||
importFile,
|
||||
}: {
|
||||
isPublicWorkspace: boolean;
|
||||
list: ListData[];
|
||||
createNewPage: () => void;
|
||||
createNewEdgeless: () => void;
|
||||
importFile: () => void;
|
||||
}) => {
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<Table>
|
||||
<MobileHead
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
createNewPage={createNewPage}
|
||||
createNewEdgeless={createNewEdgeless}
|
||||
importFile={importFile}
|
||||
/>
|
||||
<AllPagesBody
|
||||
isPublicWorkspace={isPublicWorkspace}
|
||||
data={list}
|
||||
// update groupKey after support sort by create date
|
||||
groupKey="updatedDate"
|
||||
/>
|
||||
</Table>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
|
||||
// TODO align to {@link AllPageListMobileView}
|
||||
export const TrashListMobileView = ({
|
||||
list,
|
||||
}: {
|
||||
list: {
|
||||
pageId: string;
|
||||
title: string;
|
||||
icon: JSX.Element;
|
||||
onClickPage: () => void;
|
||||
}[];
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const ListItems = list.map(({ pageId, title, icon, onClickPage }, index) => {
|
||||
return (
|
||||
<StyledTableBodyRow
|
||||
data-testid={`page-list-item-${pageId}`}
|
||||
key={`${pageId}-${index}`}
|
||||
>
|
||||
<TableCell onClick={onClickPage}>
|
||||
<StyledTitleLink>
|
||||
{icon}
|
||||
<Content ellipsis={true} color="inherit">
|
||||
{title || t['Untitled']()}
|
||||
</Content>
|
||||
</StyledTitleLink>
|
||||
</TableCell>
|
||||
</StyledTableBodyRow>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<StyledTableContainer>
|
||||
<Table>
|
||||
<TableBody>{ListItems}</TableBody>
|
||||
</Table>
|
||||
</StyledTableContainer>
|
||||
);
|
||||
};
|
||||
@@ -12,14 +12,17 @@ import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu';
|
||||
import { ConfirmModal } from '@toeverything/components/modal';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { FlexWrapper } from '../../..';
|
||||
import { FavoriteTag } from './components/favorite-tag';
|
||||
import { DisablePublicSharing, MoveToTrash } from './operation-menu-items';
|
||||
import * as styles from './page-list.css';
|
||||
import { ColWrapper, stopPropagationWithoutPrevent } from './utils';
|
||||
|
||||
export interface OperationCellProps {
|
||||
favorite: boolean;
|
||||
isPublic: boolean;
|
||||
onOpenPageInNewTab: () => void;
|
||||
link: string;
|
||||
onToggleFavoritePage: () => void;
|
||||
onRemoveToTrash: () => void;
|
||||
onDisablePublicSharing: () => void;
|
||||
@@ -28,14 +31,13 @@ export interface OperationCellProps {
|
||||
export const OperationCell = ({
|
||||
favorite,
|
||||
isPublic,
|
||||
onOpenPageInNewTab,
|
||||
link,
|
||||
onToggleFavoritePage,
|
||||
onRemoveToTrash,
|
||||
onDisablePublicSharing,
|
||||
}: OperationCellProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [openDisableShared, setOpenDisableShared] = useState(false);
|
||||
|
||||
const OperationMenu = (
|
||||
<>
|
||||
{isPublic && (
|
||||
@@ -63,23 +65,38 @@ export const OperationCell = ({
|
||||
: t['com.affine.favoritePageOperation.add']()}
|
||||
</MenuItem>
|
||||
{!environment.isDesktop && (
|
||||
<MenuItem
|
||||
onClick={onOpenPageInNewTab}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<OpenInNewIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
<Link
|
||||
onClick={stopPropagationWithoutPrevent}
|
||||
to={link}
|
||||
target={'_blank'}
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t['com.affine.openPageOperation.newTab']()}
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
style={{ marginBottom: 4 }}
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<OpenInNewIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
>
|
||||
{t['com.affine.openPageOperation.newTab']()}
|
||||
</MenuItem>
|
||||
</Link>
|
||||
)}
|
||||
<MoveToTrash data-testid="move-to-trash" onSelect={onRemoveToTrash} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<FlexWrapper alignItems="center" justifyContent="center">
|
||||
<ColWrapper
|
||||
hideInSmallContainer
|
||||
data-testid="page-list-item-favorite"
|
||||
data-favorite={favorite ? true : undefined}
|
||||
className={styles.favoriteCell}
|
||||
>
|
||||
<FavoriteTag onClick={onToggleFavoritePage} active={favorite} />
|
||||
</ColWrapper>
|
||||
<ColWrapper alignment="start">
|
||||
<Menu
|
||||
items={OperationMenu}
|
||||
contentOptions={{
|
||||
@@ -90,7 +107,7 @@ export const OperationCell = ({
|
||||
<MoreVerticalIcon />
|
||||
</IconButton>
|
||||
</Menu>
|
||||
</FlexWrapper>
|
||||
</ColWrapper>
|
||||
<DisablePublicSharing.DisablePublicSharingModal
|
||||
onConfirm={onDisablePublicSharing}
|
||||
open={openDisableShared}
|
||||
@@ -103,7 +120,6 @@ export const OperationCell = ({
|
||||
export interface TrashOperationCellProps {
|
||||
onPermanentlyDeletePage: () => void;
|
||||
onRestorePage: () => void;
|
||||
onOpenPage: () => void;
|
||||
}
|
||||
|
||||
export const TrashOperationCell = ({
|
||||
@@ -113,9 +129,10 @@ export const TrashOperationCell = ({
|
||||
const t = useAFFiNEI18N();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<FlexWrapper>
|
||||
<ColWrapper flex={1}>
|
||||
<Tooltip content={t['com.affine.trashOperation.restoreIt']()} side="top">
|
||||
<IconButton
|
||||
data-testid="restore-page-button"
|
||||
style={{ marginRight: '12px' }}
|
||||
onClick={() => {
|
||||
onRestorePage();
|
||||
@@ -130,6 +147,7 @@ export const TrashOperationCell = ({
|
||||
align="end"
|
||||
>
|
||||
<IconButton
|
||||
data-testid="delete-page-button"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
}}
|
||||
@@ -152,6 +170,6 @@ export const TrashOperationCell = ({
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</FlexWrapper>
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DeleteTemporarilyIcon } from '@blocksuite/icons';
|
||||
import { DeleteIcon } from '@blocksuite/icons';
|
||||
import {
|
||||
MenuIcon,
|
||||
MenuItem,
|
||||
@@ -17,7 +17,7 @@ export const MoveToTrash = (props: MenuItemProps) => {
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<DeleteTemporarilyIcon />
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
type="danger"
|
||||
@@ -29,19 +29,29 @@ export const MoveToTrash = (props: MenuItemProps) => {
|
||||
};
|
||||
|
||||
const MoveToTrashConfirm = ({
|
||||
title,
|
||||
titles,
|
||||
...confirmModalProps
|
||||
}: {
|
||||
title: string;
|
||||
titles: string[];
|
||||
} & ConfirmModalProps) => {
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const multiple = titles.length > 1;
|
||||
const title = multiple
|
||||
? t['com.affine.moveToTrash.confirmModal.title.multiple']({
|
||||
number: titles.length.toString(),
|
||||
})
|
||||
: t['com.affine.moveToTrash.confirmModal.title']();
|
||||
const description = multiple
|
||||
? t['com.affine.moveToTrash.confirmModal.description.multiple']({
|
||||
number: titles.length.toString(),
|
||||
})
|
||||
: t['com.affine.moveToTrash.confirmModal.description']({
|
||||
title: titles[0] || t['Untitled'](),
|
||||
});
|
||||
return (
|
||||
<ConfirmModal
|
||||
title={t['com.affine.moveToTrash.confirmModal.title']()}
|
||||
description={t['com.affine.moveToTrash.confirmModal.description']({
|
||||
title: title || 'Untitled',
|
||||
})}
|
||||
title={title}
|
||||
description={description}
|
||||
cancelText={t['com.affine.confirmModal.button.cancel']()}
|
||||
confirmButtonOptions={{
|
||||
['data-testid' as string]: 'confirm-delete-page',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import type { Workspace } from '@blocksuite/store';
|
||||
import { useBlockSuitePagePreview } from '@toeverything/hooks/use-block-suite-page-preview';
|
||||
import { useBlockSuiteWorkspacePage } from '@toeverything/hooks/use-block-suite-workspace-page';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Suspense } from 'react';
|
||||
|
||||
interface PagePreviewInnerProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
const PagePreviewInner = ({ workspace, pageId }: PagePreviewInnerProps) => {
|
||||
const page = useBlockSuiteWorkspacePage(workspace, pageId);
|
||||
assertExists(page);
|
||||
const previewAtom = useBlockSuitePagePreview(page);
|
||||
const preview = useAtomValue(previewAtom);
|
||||
return preview ? preview : null;
|
||||
};
|
||||
|
||||
interface PagePreviewProps {
|
||||
workspace: Workspace;
|
||||
pageId: string;
|
||||
}
|
||||
|
||||
export const PagePreview = ({ workspace, pageId }: PagePreviewProps) => {
|
||||
return (
|
||||
<Suspense>
|
||||
<PagePreviewInner workspace={workspace} pageId={pageId} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
});
|
||||
|
||||
const slideDown = keyframes({
|
||||
'0%': {
|
||||
opacity: 0,
|
||||
height: '0px',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 1,
|
||||
height: 'var(--radix-collapsible-content-height)',
|
||||
},
|
||||
});
|
||||
|
||||
const slideUp = keyframes({
|
||||
'0%': {
|
||||
opacity: 1,
|
||||
height: 'var(--radix-collapsible-content-height)',
|
||||
},
|
||||
'100%': {
|
||||
opacity: 0,
|
||||
height: '0px',
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsibleContent = style({
|
||||
overflow: 'hidden',
|
||||
selectors: {
|
||||
'&[data-state="open"]': {
|
||||
animation: `${slideDown} 0.3s ease-in-out`,
|
||||
},
|
||||
'&[data-state="closed"]': {
|
||||
animation: `${slideUp} 0.3s ease-in-out`,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsibleContentInner = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '0px 16px 0px 6px',
|
||||
gap: 4,
|
||||
height: '28px',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
userSelect: 'none',
|
||||
});
|
||||
|
||||
export const spacer = style({
|
||||
flex: 1,
|
||||
});
|
||||
|
||||
export const headerCollapseIcon = style({
|
||||
cursor: 'pointer',
|
||||
});
|
||||
|
||||
export const headerLabel = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
});
|
||||
|
||||
export const headerCount = style({
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
color: 'var(--affine-text-disable-color)',
|
||||
});
|
||||
|
||||
export const selectAllButton = style({
|
||||
display: 'flex',
|
||||
opacity: 0,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
cursor: 'pointer',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
height: '20px',
|
||||
borderRadius: 4,
|
||||
padding: '0 8px',
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
[`${header}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsedIcon = style({
|
||||
opacity: 0,
|
||||
transition: 'transform 0.2s ease-in-out',
|
||||
selectors: {
|
||||
'&[data-collapsed="false"]': {
|
||||
transform: 'rotate(90deg)',
|
||||
},
|
||||
[`${header}:hover &, &[data-collapsed="true"]`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const collapsedIconContainer = style({
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: '2px',
|
||||
transition: 'transform 0.2s',
|
||||
color: 'inherit',
|
||||
selectors: {
|
||||
'&[data-collapsed="true"]': {
|
||||
transform: 'rotate(-90deg)',
|
||||
},
|
||||
'&[data-disabled="true"]': {
|
||||
opacity: 0.3,
|
||||
pointerEvents: 'none',
|
||||
},
|
||||
'&:hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import { Trans } from '@affine/i18n';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { assertExists } from '@blocksuite/global/utils';
|
||||
import { EdgelessIcon, PageIcon, ToggleCollapseIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import * as Collapsible from '@radix-ui/react-collapsible';
|
||||
import clsx from 'clsx';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import { type MouseEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { PagePreview } from './page-content-preview';
|
||||
import * as styles from './page-group.css';
|
||||
import { PageListItem } from './page-list-item';
|
||||
import { pageListPropsAtom, selectionStateAtom } from './scoped-atoms';
|
||||
import type {
|
||||
PageGroupDefinition,
|
||||
PageGroupProps,
|
||||
PageListItemProps,
|
||||
PageListProps,
|
||||
} from './types';
|
||||
import { type DateKey } from './types';
|
||||
import { betweenDaysAgo, withinDaysAgo } from './utils';
|
||||
|
||||
// todo: optimize date matchers
|
||||
const getDateGroupDefinitions = (key: DateKey): PageGroupDefinition[] => [
|
||||
{
|
||||
id: 'today',
|
||||
label: <Trans i18nKey="com.affine.today" />,
|
||||
match: item => withinDaysAgo(new Date(item[key] ?? item.createDate), 1),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: <Trans i18nKey="com.affine.yesterday" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 1, 2),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: <Trans i18nKey="com.affine.last7Days" />,
|
||||
match: item => betweenDaysAgo(new Date(item[key] ?? item.createDate), 2, 7),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: <Trans i18nKey="com.affine.last30Days" />,
|
||||
match: item =>
|
||||
betweenDaysAgo(new Date(item[key] ?? item.createDate), 7, 30),
|
||||
},
|
||||
{
|
||||
id: 'moreThan30Days',
|
||||
label: <Trans i18nKey="com.affine.moreThan30Days" />,
|
||||
match: item => !withinDaysAgo(new Date(item[key] ?? item.createDate), 30),
|
||||
},
|
||||
];
|
||||
|
||||
const pageGroupDefinitions = {
|
||||
createDate: getDateGroupDefinitions('createDate'),
|
||||
updatedDate: getDateGroupDefinitions('updatedDate'),
|
||||
// add more here later
|
||||
};
|
||||
|
||||
export function pagesToPageGroups(
|
||||
pages: PageMeta[],
|
||||
key?: DateKey
|
||||
): PageGroupProps[] {
|
||||
if (!key) {
|
||||
return [
|
||||
{
|
||||
id: 'all',
|
||||
items: pages,
|
||||
allItems: pages,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// assume pages are already sorted, we will use the page order to determine the group order
|
||||
const groupDefs = pageGroupDefinitions[key];
|
||||
const groups: PageGroupProps[] = [];
|
||||
|
||||
for (const page of pages) {
|
||||
// for a single page, there could be multiple groups that it belongs to
|
||||
const matchedGroups = groupDefs.filter(def => def.match(page));
|
||||
for (const groupDef of matchedGroups) {
|
||||
const group = groups.find(g => g.id === groupDef.id);
|
||||
if (group) {
|
||||
group.items.push(page);
|
||||
} else {
|
||||
const label =
|
||||
typeof groupDef.label === 'function'
|
||||
? groupDef.label()
|
||||
: groupDef.label;
|
||||
groups.push({
|
||||
id: groupDef.id,
|
||||
label: label,
|
||||
items: [page],
|
||||
allItems: pages,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export const PageGroup = ({ id, items, label }: PageGroupProps) => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const onExpandedClicked: MouseEventHandler = useCallback(e => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setCollapsed(v => !v);
|
||||
}, []);
|
||||
const selectionState = useAtomValue(selectionStateAtom);
|
||||
const selectedItems = useMemo(() => {
|
||||
const selectedPageIds = selectionState.selectedPageIds ?? [];
|
||||
return items.filter(item => selectedPageIds.includes(item.id));
|
||||
}, [items, selectionState.selectedPageIds]);
|
||||
const onSelectAll = useCallback(() => {
|
||||
const nonCurrentGroupIds =
|
||||
selectionState.selectedPageIds?.filter(
|
||||
id => !items.map(item => item.id).includes(id)
|
||||
) ?? [];
|
||||
|
||||
selectionState.onSelectedPageIdsChange?.([
|
||||
...nonCurrentGroupIds,
|
||||
...items.map(item => item.id),
|
||||
]);
|
||||
}, [items, selectionState]);
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<Collapsible.Root
|
||||
data-testid="page-list-group"
|
||||
data-group-id={id}
|
||||
open={!collapsed}
|
||||
className={clsx(styles.root)}
|
||||
>
|
||||
{label ? (
|
||||
<div data-testid="page-list-group-header" className={styles.header}>
|
||||
<Collapsible.Trigger
|
||||
role="button"
|
||||
onClick={onExpandedClicked}
|
||||
data-testid="page-list-group-header-collapsed-button"
|
||||
className={styles.collapsedIconContainer}
|
||||
>
|
||||
<ToggleCollapseIcon
|
||||
className={styles.collapsedIcon}
|
||||
data-collapsed={collapsed !== false}
|
||||
/>
|
||||
</Collapsible.Trigger>
|
||||
<div className={styles.headerLabel}>{label}</div>
|
||||
{selectionState.selectionActive ? (
|
||||
<div className={styles.headerCount}>
|
||||
{selectedItems.length}/{items.length}
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer} />
|
||||
{selectionState.selectionActive ? (
|
||||
<button className={styles.selectAllButton} onClick={onSelectAll}>
|
||||
{t['com.affine.page.group-header.select-all']()}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Collapsible.Content className={styles.collapsibleContent}>
|
||||
<div className={styles.collapsibleContentInner}>
|
||||
{items.map(item => (
|
||||
<PageMetaListItemRenderer key={item.id} {...item} />
|
||||
))}
|
||||
</div>
|
||||
</Collapsible.Content>
|
||||
</Collapsible.Root>
|
||||
);
|
||||
};
|
||||
|
||||
// todo: optimize how to render page meta list item
|
||||
const requiredPropNames = [
|
||||
'blockSuiteWorkspace',
|
||||
'clickMode',
|
||||
'isPreferredEdgeless',
|
||||
'pageOperationsRenderer',
|
||||
'selectedPageIds',
|
||||
'onSelectedPageIdsChange',
|
||||
'draggable',
|
||||
] as const;
|
||||
|
||||
type RequiredProps = Pick<PageListProps, (typeof requiredPropNames)[number]> & {
|
||||
selectable: boolean;
|
||||
};
|
||||
|
||||
const listPropsAtom = selectAtom(pageListPropsAtom, props => {
|
||||
return Object.fromEntries(
|
||||
requiredPropNames.map(name => [name, props[name]])
|
||||
) as RequiredProps;
|
||||
});
|
||||
|
||||
const PageMetaListItemRenderer = (pageMeta: PageMeta) => {
|
||||
const props = useAtomValue(listPropsAtom);
|
||||
const { selectionActive } = useAtomValue(selectionStateAtom);
|
||||
return (
|
||||
<PageListItem
|
||||
{...pageMetaToPageItemProp(pageMeta, {
|
||||
...props,
|
||||
selectable: !!selectionActive,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function tagIdToTagOption(
|
||||
tagId: string,
|
||||
blockSuiteWorkspace: Workspace
|
||||
): Tag | undefined {
|
||||
return blockSuiteWorkspace.meta.properties.tags?.options.find(
|
||||
opt => opt.id === tagId
|
||||
);
|
||||
}
|
||||
|
||||
function pageMetaToPageItemProp(
|
||||
pageMeta: PageMeta,
|
||||
props: RequiredProps
|
||||
): PageListItemProps {
|
||||
const toggleSelection = props.onSelectedPageIdsChange
|
||||
? () => {
|
||||
assertExists(props.selectedPageIds);
|
||||
const prevSelected = props.selectedPageIds.includes(pageMeta.id);
|
||||
const shouldAdd = !prevSelected;
|
||||
const shouldRemove = prevSelected;
|
||||
|
||||
if (shouldAdd) {
|
||||
props.onSelectedPageIdsChange?.([
|
||||
...props.selectedPageIds,
|
||||
pageMeta.id,
|
||||
]);
|
||||
} else if (shouldRemove) {
|
||||
props.onSelectedPageIdsChange?.(
|
||||
props.selectedPageIds.filter(id => id !== pageMeta.id)
|
||||
);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
const itemProps: PageListItemProps = {
|
||||
pageId: pageMeta.id,
|
||||
title: pageMeta.title,
|
||||
preview: (
|
||||
<PagePreview workspace={props.blockSuiteWorkspace} pageId={pageMeta.id} />
|
||||
),
|
||||
createDate: new Date(pageMeta.createDate),
|
||||
updatedDate: pageMeta.updatedDate
|
||||
? new Date(pageMeta.updatedDate)
|
||||
: undefined,
|
||||
to:
|
||||
props.clickMode === 'link'
|
||||
? `/workspace/${props.blockSuiteWorkspace.id}/${pageMeta.id}`
|
||||
: undefined,
|
||||
onClick: props.clickMode === 'select' ? toggleSelection : undefined,
|
||||
icon: props.isPreferredEdgeless?.(pageMeta.id) ? (
|
||||
<EdgelessIcon />
|
||||
) : (
|
||||
<PageIcon />
|
||||
),
|
||||
tags:
|
||||
pageMeta.tags
|
||||
?.map(id => tagIdToTagOption(id, props.blockSuiteWorkspace))
|
||||
.filter((v): v is Tag => v != null) ?? [],
|
||||
operations: props.pageOperationsRenderer?.(pageMeta),
|
||||
selectable: props.selectable,
|
||||
selected: props.selectedPageIds?.includes(pageMeta.id),
|
||||
onSelectedChange: toggleSelection,
|
||||
draggable: props.draggable,
|
||||
isPublicPage: !!pageMeta.isPublic,
|
||||
};
|
||||
return itemProps;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
display: 'flex',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
height: '54px', // 42 + 12
|
||||
flexShrink: 0,
|
||||
width: '100%',
|
||||
alignItems: 'stretch',
|
||||
transition: 'background-color 0.2s, opacity 0.2s',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
overflow: 'hidden',
|
||||
cursor: 'default',
|
||||
willChange: 'opacity',
|
||||
selectors: {
|
||||
'&[data-clickable=true]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const dragOverlay = style({
|
||||
display: 'flex',
|
||||
height: '54px', // 42 + 12
|
||||
alignItems: 'center',
|
||||
background: 'var(--affine-hover-color-filled)',
|
||||
boxShadow: 'var(--affine-menu-shadow)',
|
||||
borderRadius: 10,
|
||||
zIndex: 1001,
|
||||
cursor: 'pointer',
|
||||
maxWidth: '360px',
|
||||
transition: 'transform 0.2s',
|
||||
willChange: 'transform',
|
||||
selectors: {
|
||||
'&[data-over=true]': {
|
||||
transform: 'scale(0.8)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const dndCell = style({
|
||||
position: 'relative',
|
||||
marginLeft: -8,
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
paddingLeft: 8,
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true] ${dndCell}:before`, {
|
||||
content: '""',
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
transform: 'translateY(-50%)',
|
||||
left: 0,
|
||||
width: 4,
|
||||
height: 4,
|
||||
transition: 'height 0.2s, opacity 0.2s',
|
||||
backgroundColor: 'var(--affine-placeholder-color)',
|
||||
borderRadius: '2px',
|
||||
opacity: 0,
|
||||
willChange: 'height, opacity',
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true] ${dndCell}:hover:before`, {
|
||||
height: 12,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}`, {
|
||||
opacity: 0.5,
|
||||
});
|
||||
|
||||
globalStyle(`[data-draggable=true][data-dragging=true] ${dndCell}:before`, {
|
||||
height: 32,
|
||||
width: 2,
|
||||
opacity: 1,
|
||||
});
|
||||
|
||||
// todo: remove global style
|
||||
globalStyle(`${root} > :first-child`, {
|
||||
paddingLeft: '16px',
|
||||
});
|
||||
|
||||
globalStyle(`${root} > :last-child`, {
|
||||
paddingRight: '8px',
|
||||
});
|
||||
|
||||
export const titleIconsWrapper = style({
|
||||
padding: '0 5px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
});
|
||||
|
||||
export const selectionCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
});
|
||||
|
||||
export const titleCell = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
padding: '0 16px',
|
||||
maxWidth: 'calc(100% - 64px)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const titleCellMain = style({
|
||||
overflow: 'hidden',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
fontWeight: 600,
|
||||
whiteSpace: 'nowrap',
|
||||
flex: 1,
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const titleCellPreview = style({
|
||||
overflow: 'hidden',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
flex: 1,
|
||||
whiteSpace: 'nowrap',
|
||||
textOverflow: 'ellipsis',
|
||||
alignSelf: 'stretch',
|
||||
});
|
||||
|
||||
export const iconCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-h-3)',
|
||||
color: 'var(--affine-icon-color)',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tagsCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
padding: '0 8px',
|
||||
height: '60px',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const dateCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
flexShrink: 0,
|
||||
flexWrap: 'nowrap',
|
||||
padding: '0 8px',
|
||||
});
|
||||
|
||||
export const actionsCellWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const operationsCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
columnGap: '6px',
|
||||
flexShrink: 0,
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { useDraggable } from '@dnd-kit/core';
|
||||
import { type PropsWithChildren, useCallback, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { Checkbox } from '../../ui/checkbox';
|
||||
import * as styles from './page-list-item.css';
|
||||
import { PageTags } from './page-tags';
|
||||
import type { DraggableTitleCellData, PageListItemProps } from './types';
|
||||
import { ColWrapper, formatDate, stopPropagation } from './utils';
|
||||
|
||||
const PageListTitleCell = ({
|
||||
title,
|
||||
preview,
|
||||
}: Pick<PageListItemProps, 'title' | 'preview'>) => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div data-testid="page-list-item-title" className={styles.titleCell}>
|
||||
<div
|
||||
data-testid="page-list-item-title-text"
|
||||
className={styles.titleCellMain}
|
||||
>
|
||||
{title || t['Untitled']()}
|
||||
</div>
|
||||
{preview ? (
|
||||
<div
|
||||
data-testid="page-list-item-preview-text"
|
||||
className={styles.titleCellPreview}
|
||||
>
|
||||
{preview}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListIconCell = ({ icon }: Pick<PageListItemProps, 'icon'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-icon" className={styles.iconCell}>
|
||||
{icon}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageSelectionCell = ({
|
||||
selectable,
|
||||
onSelectedChange,
|
||||
selected,
|
||||
}: Pick<PageListItemProps, 'selectable' | 'onSelectedChange' | 'selected'>) => {
|
||||
const onSelectionChange = useCallback(
|
||||
(_event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
return onSelectedChange?.();
|
||||
},
|
||||
[onSelectedChange]
|
||||
);
|
||||
if (!selectable) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={styles.selectionCell}>
|
||||
<Checkbox
|
||||
onClick={stopPropagation}
|
||||
checked={!!selected}
|
||||
onChange={onSelectionChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageTagsCell = ({ tags }: Pick<PageListItemProps, 'tags'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-tags" className={styles.tagsCell}>
|
||||
<PageTags
|
||||
tags={tags}
|
||||
hoverExpandDirection="left"
|
||||
widthOnHover="300%"
|
||||
maxItems={5}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageCreateDateCell = ({
|
||||
createDate,
|
||||
}: Pick<PageListItemProps, 'createDate'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
||||
{formatDate(createDate)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageUpdatedDateCell = ({
|
||||
updatedDate,
|
||||
}: Pick<PageListItemProps, 'updatedDate'>) => {
|
||||
return (
|
||||
<div data-testid="page-list-item-date" className={styles.dateCell}>
|
||||
{updatedDate ? formatDate(updatedDate) : '-'}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListOperationsCell = ({
|
||||
operations,
|
||||
}: Pick<PageListItemProps, 'operations'>) => {
|
||||
return operations ? (
|
||||
<div onClick={stopPropagation} className={styles.operationsCell}>
|
||||
{operations}
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export const PageListItem = (props: PageListItemProps) => {
|
||||
const pageTitleElement = useMemo(() => {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<PageSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<PageListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||
</>
|
||||
);
|
||||
}, [
|
||||
props.icon,
|
||||
props.onSelectedChange,
|
||||
props.preview,
|
||||
props.selectable,
|
||||
props.selected,
|
||||
props.title,
|
||||
]);
|
||||
|
||||
const { setNodeRef, attributes, listeners, isDragging } = useDraggable({
|
||||
id: 'page-list-item-title-' + props.pageId,
|
||||
data: {
|
||||
pageId: props.pageId,
|
||||
pageTitle: pageTitleElement,
|
||||
} satisfies DraggableTitleCellData,
|
||||
disabled: !props.draggable,
|
||||
});
|
||||
|
||||
return (
|
||||
<PageListItemWrapper
|
||||
onClick={props.onClick}
|
||||
to={props.to}
|
||||
pageId={props.pageId}
|
||||
draggable={props.draggable}
|
||||
isDragging={isDragging}
|
||||
>
|
||||
<ColWrapper flex={9}>
|
||||
<ColWrapper
|
||||
className={styles.dndCell}
|
||||
flex={8}
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<div className={styles.titleIconsWrapper}>
|
||||
<PageSelectionCell
|
||||
onSelectedChange={props.onSelectedChange}
|
||||
selectable={props.selectable}
|
||||
selected={props.selected}
|
||||
/>
|
||||
<PageListIconCell icon={props.icon} />
|
||||
</div>
|
||||
<PageListTitleCell title={props.title} preview={props.preview} />
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={4} alignment="end" style={{ overflow: 'visible' }}>
|
||||
<PageTagsCell tags={props.tags} />
|
||||
</ColWrapper>
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||
<PageCreateDateCell createDate={props.createDate} />
|
||||
</ColWrapper>
|
||||
<ColWrapper flex={1} alignment="end" hideInSmallContainer>
|
||||
<PageUpdatedDateCell updatedDate={props.updatedDate} />
|
||||
</ColWrapper>
|
||||
{props.operations ? (
|
||||
<ColWrapper
|
||||
className={styles.actionsCellWrapper}
|
||||
flex={1}
|
||||
alignment="end"
|
||||
>
|
||||
<PageListOperationsCell operations={props.operations} />
|
||||
</ColWrapper>
|
||||
) : null}
|
||||
</PageListItemWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type PageListWrapperProps = PropsWithChildren<
|
||||
Pick<PageListItemProps, 'to' | 'pageId' | 'onClick' | 'draggable'> & {
|
||||
isDragging: boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
function PageListItemWrapper({
|
||||
to,
|
||||
isDragging,
|
||||
pageId,
|
||||
onClick,
|
||||
children,
|
||||
draggable,
|
||||
}: PageListWrapperProps) {
|
||||
const handleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (onClick) {
|
||||
stopPropagation(e);
|
||||
onClick();
|
||||
}
|
||||
},
|
||||
[onClick]
|
||||
);
|
||||
|
||||
const commonProps = useMemo(
|
||||
() => ({
|
||||
'data-testid': 'page-list-item',
|
||||
'data-page-id': pageId,
|
||||
'data-draggable': draggable,
|
||||
className: styles.root,
|
||||
'data-clickable': !!onClick || !!to,
|
||||
'data-dragging': isDragging,
|
||||
onClick: handleClick,
|
||||
}),
|
||||
[pageId, draggable, isDragging, onClick, to, handleClick]
|
||||
);
|
||||
|
||||
if (to) {
|
||||
return (
|
||||
<Link {...commonProps} to={to}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
} else {
|
||||
return <div {...commonProps}>{children}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
export const PageListDragOverlay = ({
|
||||
children,
|
||||
over,
|
||||
}: PropsWithChildren<{
|
||||
over?: boolean;
|
||||
}>) => {
|
||||
return (
|
||||
<div data-over={over} className={styles.dragOverlay}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import { createContainer, globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
import * as itemStyles from './page-list-item.css';
|
||||
|
||||
export const listRootContainer = createContainer('list-root-container');
|
||||
|
||||
export const pageListScrollContainer = style({
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const root = style({
|
||||
width: '100%',
|
||||
maxWidth: '100%',
|
||||
containerName: listRootContainer,
|
||||
containerType: 'inline-size',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
});
|
||||
|
||||
export const groupsContainer = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
rowGap: '16px',
|
||||
});
|
||||
|
||||
export const header = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '10px 6px 10px 16px',
|
||||
position: 'sticky',
|
||||
overflow: 'hidden',
|
||||
zIndex: 1,
|
||||
top: 0,
|
||||
left: 0,
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
transition: 'box-shadow 0.2s ease-in-out',
|
||||
transform: 'translateY(-0.5px)', // fix sticky look through issue
|
||||
});
|
||||
|
||||
globalStyle(`[data-has-scroll-top=true] ${header}`, {
|
||||
boxShadow: '0 1px var(--affine-border-color)',
|
||||
});
|
||||
|
||||
export const headerCell = style({
|
||||
padding: '0 8px',
|
||||
userSelect: 'none',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
selectors: {
|
||||
'&[data-sorting], &:hover': {
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
},
|
||||
'&[data-sortable]': {
|
||||
cursor: 'pointer',
|
||||
},
|
||||
'&:not(:last-child)': {
|
||||
borderRight: '1px solid var(--affine-hover-color-filled)',
|
||||
},
|
||||
},
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
columnGap: '4px',
|
||||
position: 'relative',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
export const headerTitleCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
});
|
||||
|
||||
export const headerTitleSelectionIconWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: '16px',
|
||||
});
|
||||
|
||||
export const headerCellSortIcon = style({
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
});
|
||||
|
||||
export const colWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
flexShrink: 0,
|
||||
overflow: 'hidden',
|
||||
});
|
||||
|
||||
export const hideInSmallContainer = style({
|
||||
'@container': {
|
||||
[`${listRootContainer} (max-width: 800px)`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const favoriteCell = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
flexShrink: 0,
|
||||
opacity: 0,
|
||||
selectors: {
|
||||
[`&[data-favorite], &${itemStyles.root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { MultiSelectIcon, SortDownIcon, SortUpIcon } from '@blocksuite/icons';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import clsx from 'clsx';
|
||||
import { Provider, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useHydrateAtoms } from 'jotai/utils';
|
||||
import {
|
||||
type ForwardedRef,
|
||||
forwardRef,
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import { Checkbox, type CheckboxProps } from '../../ui/checkbox';
|
||||
import { useHasScrollTop } from '../app-sidebar/sidebar-containers/use-has-scroll-top';
|
||||
import { PageGroup } from './page-group';
|
||||
import * as styles from './page-list.css';
|
||||
import {
|
||||
pageGroupsAtom,
|
||||
pageListHandlersAtom,
|
||||
pageListPropsAtom,
|
||||
pagesAtom,
|
||||
selectionStateAtom,
|
||||
showOperationsAtom,
|
||||
sorterAtom,
|
||||
} from './scoped-atoms';
|
||||
import type { PageListHandle, PageListProps } from './types';
|
||||
import { ColWrapper, type ColWrapperProps, stopPropagation } from './utils';
|
||||
|
||||
/**
|
||||
* Given a list of pages, render a list of pages
|
||||
*/
|
||||
export const PageList = forwardRef<PageListHandle, PageListProps>(
|
||||
function PageListHandle(props, ref) {
|
||||
return (
|
||||
<Provider>
|
||||
<PageListInner {...props} handleRef={ref} />
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
const PageListInner = ({
|
||||
handleRef,
|
||||
...props
|
||||
}: PageListProps & { handleRef: ForwardedRef<PageListHandle> }) => {
|
||||
// push pageListProps to the atom so that downstream components can consume it
|
||||
useHydrateAtoms([[pageListPropsAtom, props]], {
|
||||
// note: by turning on dangerouslyForceHydrate, downstream component need to use selectAtom to consume the atom
|
||||
// note2: not using it for now because it will cause some other issues
|
||||
// dangerouslyForceHydrate: true,
|
||||
});
|
||||
|
||||
const setPageListPropsAtom = useSetAtom(pageListPropsAtom);
|
||||
const setPageListSelectionState = useSetAtom(selectionStateAtom);
|
||||
|
||||
useEffect(() => {
|
||||
setPageListPropsAtom(props);
|
||||
}, [props, setPageListPropsAtom]);
|
||||
|
||||
useImperativeHandle(
|
||||
handleRef,
|
||||
() => {
|
||||
return {
|
||||
toggleSelectable: () => {
|
||||
setPageListSelectionState(false);
|
||||
},
|
||||
};
|
||||
},
|
||||
[setPageListSelectionState]
|
||||
);
|
||||
|
||||
const groups = useAtomValue(pageGroupsAtom);
|
||||
const hideHeader = props.hideHeader;
|
||||
return (
|
||||
<div className={clsx(props.className, styles.root)}>
|
||||
{!hideHeader ? <PageListHeader /> : null}
|
||||
<div className={styles.groupsContainer}>
|
||||
{groups.map(group => (
|
||||
<PageGroup key={group.id} {...group} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type HeaderCellProps = ColWrapperProps & {
|
||||
sortKey: keyof PageMeta;
|
||||
sortable?: boolean;
|
||||
};
|
||||
|
||||
export const PageListHeaderCell = (props: HeaderCellProps) => {
|
||||
const [sorter, setSorter] = useAtom(sorterAtom);
|
||||
const onClick: MouseEventHandler = useCallback(() => {
|
||||
if (props.sortable && props.sortKey) {
|
||||
setSorter({
|
||||
newSortKey: props.sortKey,
|
||||
});
|
||||
}
|
||||
}, [props.sortKey, props.sortable, setSorter]);
|
||||
|
||||
const sorting = sorter.key === props.sortKey;
|
||||
|
||||
return (
|
||||
<ColWrapper
|
||||
flex={props.flex}
|
||||
alignment={props.alignment}
|
||||
onClick={onClick}
|
||||
className={styles.headerCell}
|
||||
data-sortable={props.sortable ? true : undefined}
|
||||
data-sorting={sorting ? true : undefined}
|
||||
style={props.style}
|
||||
hideInSmallContainer={props.hideInSmallContainer}
|
||||
>
|
||||
{props.children}
|
||||
{sorting ? (
|
||||
<div className={styles.headerCellSortIcon}>
|
||||
{sorter.order === 'asc' ? <SortUpIcon /> : <SortDownIcon />}
|
||||
</div>
|
||||
) : null}
|
||||
</ColWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
type HeaderColDef = {
|
||||
key: string;
|
||||
content: ReactNode;
|
||||
flex: ColWrapperProps['flex'];
|
||||
alignment?: ColWrapperProps['alignment'];
|
||||
sortable?: boolean;
|
||||
hideInSmallContainer?: boolean;
|
||||
};
|
||||
|
||||
// the checkbox on the header has three states:
|
||||
// when list selectable = true, the checkbox will be presented
|
||||
// when internal selection state is not enabled, it is a clickable <ListIcon /> that enables the selection state
|
||||
// when internal selection state is enabled, it is a checkbox that reflects the selection state
|
||||
const PageListHeaderCheckbox = () => {
|
||||
const [selectionState, setSelectionState] = useAtom(selectionStateAtom);
|
||||
const pages = useAtomValue(pagesAtom);
|
||||
const onActivateSelection: MouseEventHandler = useCallback(
|
||||
e => {
|
||||
stopPropagation(e);
|
||||
setSelectionState(true);
|
||||
},
|
||||
[setSelectionState]
|
||||
);
|
||||
const handlers = useAtomValue(pageListHandlersAtom);
|
||||
const onChange: NonNullable<CheckboxProps['onChange']> = useCallback(
|
||||
(e, checked) => {
|
||||
stopPropagation(e);
|
||||
handlers.onSelectedPageIdsChange?.(checked ? pages.map(p => p.id) : []);
|
||||
},
|
||||
[handlers, pages]
|
||||
);
|
||||
|
||||
if (!selectionState.selectable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.headerTitleSelectionIconWrapper}
|
||||
onClick={onActivateSelection}
|
||||
>
|
||||
{!selectionState.selectionActive ? (
|
||||
<MultiSelectIcon />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectionState.selectedPageIds?.length === pages.length}
|
||||
indeterminate={
|
||||
selectionState.selectedPageIds &&
|
||||
selectionState.selectedPageIds.length > 0 &&
|
||||
selectionState.selectedPageIds.length < pages.length
|
||||
}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const PageListHeaderTitleCell = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
return (
|
||||
<div className={styles.headerTitleCell}>
|
||||
<PageListHeaderCheckbox />
|
||||
{t['Title']()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageListHeader = () => {
|
||||
const t = useAFFiNEI18N();
|
||||
const showOperations = useAtomValue(showOperationsAtom);
|
||||
const headerCols = useMemo(() => {
|
||||
const cols: (HeaderColDef | boolean)[] = [
|
||||
{
|
||||
key: 'title',
|
||||
content: <PageListHeaderTitleCell />,
|
||||
flex: 6,
|
||||
alignment: 'start',
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: 'tags',
|
||||
content: t['Tags'](),
|
||||
flex: 3,
|
||||
alignment: 'end',
|
||||
},
|
||||
{
|
||||
key: 'createDate',
|
||||
content: t['Created'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
{
|
||||
key: 'updatedDate',
|
||||
content: t['Updated'](),
|
||||
flex: 1,
|
||||
sortable: true,
|
||||
alignment: 'end',
|
||||
hideInSmallContainer: true,
|
||||
},
|
||||
showOperations && {
|
||||
key: 'actions',
|
||||
content: '',
|
||||
flex: 1,
|
||||
alignment: 'end',
|
||||
},
|
||||
];
|
||||
return cols.filter((def): def is HeaderColDef => !!def);
|
||||
}, [t, showOperations]);
|
||||
return (
|
||||
<div className={clsx(styles.header)}>
|
||||
{headerCols.map(col => {
|
||||
return (
|
||||
<PageListHeaderCell
|
||||
flex={col.flex}
|
||||
alignment={col.alignment}
|
||||
key={col.key}
|
||||
sortKey={col.key as keyof PageMeta}
|
||||
sortable={col.sortable}
|
||||
style={{ overflow: 'visible' }}
|
||||
hideInSmallContainer={col.hideInSmallContainer}
|
||||
>
|
||||
{col.content}
|
||||
</PageListHeaderCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageListScrollContainerProps {
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const PageListScrollContainer = forwardRef<
|
||||
HTMLDivElement,
|
||||
PropsWithChildren<PageListScrollContainerProps>
|
||||
>(({ className, children, style }, ref) => {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const hasScrollTop = useHasScrollTop(containerRef);
|
||||
|
||||
const setNodeRef = useCallback(
|
||||
(r: HTMLDivElement) => {
|
||||
if (ref) {
|
||||
if (typeof ref === 'function') {
|
||||
ref(r);
|
||||
} else {
|
||||
ref.current = r;
|
||||
}
|
||||
}
|
||||
containerRef.current = r;
|
||||
},
|
||||
[ref]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={style}
|
||||
ref={setNodeRef}
|
||||
data-has-scroll-top={hasScrollTop}
|
||||
className={clsx(styles.pageListScrollContainer, className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
PageListScrollContainer.displayName = 'PageListScrollContainer';
|
||||
@@ -0,0 +1,138 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: '32px',
|
||||
});
|
||||
|
||||
export const tagsContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const tagsScrollContainer = style([
|
||||
tagsContainer,
|
||||
{
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
gap: '8px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const tagsListContainer = style([
|
||||
tagsContainer,
|
||||
{
|
||||
flexWrap: 'wrap',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
gap: '4px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const innerContainer = style({
|
||||
display: 'flex',
|
||||
columnGap: '8px',
|
||||
alignItems: 'center',
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
transition: 'all 0.2s 0.3s ease-in-out',
|
||||
selectors: {
|
||||
[`${root}:hover &`]: {
|
||||
maxWidth: 'var(--hover-max-width)',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// background with linear gradient hack
|
||||
export const innerBackdrop = style({
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '100%',
|
||||
opacity: 0,
|
||||
transition: 'all 0.2s',
|
||||
background:
|
||||
'linear-gradient(90deg, transparent 0%, var(--affine-hover-color-filled) 40%)',
|
||||
selectors: {
|
||||
[`${root}:hover &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const range = (start: number, end: number) => {
|
||||
const result = [];
|
||||
for (let i = start; i < end; i++) {
|
||||
result.push(i);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const tag = style({
|
||||
height: '20px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '0 8px',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
});
|
||||
|
||||
export const tagSticky = style([
|
||||
tag,
|
||||
{
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
borderRadius: '10px',
|
||||
columnGap: '4px',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
background: 'var(--affine-background-primary-color)',
|
||||
maxWidth: '128px',
|
||||
position: 'sticky',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
left: 0,
|
||||
selectors: range(0, 20).reduce((selectors, i) => {
|
||||
return {
|
||||
...selectors,
|
||||
[`&:nth-last-child(${i + 1})`]: {
|
||||
right: `${i * 48}px`,
|
||||
},
|
||||
};
|
||||
}, {}),
|
||||
},
|
||||
]);
|
||||
|
||||
export const tagListItem = style([
|
||||
tag,
|
||||
{
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
padding: '4px 12px',
|
||||
columnGap: '8px',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
height: '30px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const showMoreTag = style({
|
||||
fontSize: 'var(--affine-font-h-5)',
|
||||
right: 0,
|
||||
position: 'sticky',
|
||||
display: 'inline-flex',
|
||||
});
|
||||
|
||||
export const tagIndicator = style({
|
||||
width: '8px',
|
||||
height: '8px',
|
||||
borderRadius: '50%',
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const tagLabel = style({
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import { MoreHorizontalIcon } from '@blocksuite/icons';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import clsx from 'clsx';
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import * as styles from './page-tags.css';
|
||||
import { stopPropagation } from './utils';
|
||||
|
||||
export interface PageTagsProps {
|
||||
tags: Tag[];
|
||||
maxItems?: number; // max number to show. if not specified, show all. if specified, show the first n items and add a "..." tag
|
||||
widthOnHover?: number | string; // max width on hover
|
||||
hoverExpandDirection?: 'left' | 'right'; // expansion direction on hover
|
||||
}
|
||||
|
||||
interface TagItemProps {
|
||||
tag: Tag;
|
||||
idx: number;
|
||||
mode: 'sticky' | 'list-item';
|
||||
}
|
||||
|
||||
// hack: map var(--affine-tag-xxx) colors to var(--affine-palette-line-xxx)
|
||||
const tagColorMap = (color: string) => {
|
||||
const mapping: Record<string, string> = {
|
||||
'var(--affine-tag-red)': 'var(--affine-palette-line-red)',
|
||||
'var(--affine-tag-teal)': 'var(--affine-palette-line-green)',
|
||||
'var(--affine-tag-blue)': 'var(--affine-palette-line-blue)',
|
||||
'var(--affine-tag-yellow)': 'var(--affine-palette-line-yellow)',
|
||||
'var(--affine-tag-pink)': 'var(--affine-palette-line-magenta)',
|
||||
'var(--affine-tag-white)': 'var(--affine-palette-line-grey)',
|
||||
};
|
||||
return mapping[color] || color;
|
||||
};
|
||||
|
||||
const TagItem = ({ tag, idx, mode }: TagItemProps) => {
|
||||
return (
|
||||
<div
|
||||
data-testid="page-tag"
|
||||
className={mode === 'sticky' ? styles.tagSticky : styles.tagListItem}
|
||||
data-idx={idx}
|
||||
title={tag.value}
|
||||
>
|
||||
<div
|
||||
className={styles.tagIndicator}
|
||||
style={{
|
||||
backgroundColor: tagColorMap(tag.color),
|
||||
}}
|
||||
/>
|
||||
<div className={styles.tagLabel}>{tag.value}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PageTags = ({
|
||||
tags,
|
||||
widthOnHover,
|
||||
maxItems,
|
||||
hoverExpandDirection,
|
||||
}: PageTagsProps) => {
|
||||
const sanitizedWidthOnHover = widthOnHover
|
||||
? typeof widthOnHover === 'string'
|
||||
? widthOnHover
|
||||
: `${widthOnHover}px`
|
||||
: 'auto';
|
||||
const tagsContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (tagsContainerRef.current) {
|
||||
const tagsContainer = tagsContainerRef.current;
|
||||
const listener = () => {
|
||||
// on mouseleave, reset scroll position to the hoverExpandDirection
|
||||
tagsContainer.scrollTo({
|
||||
left: hoverExpandDirection === 'left' ? Number.MAX_SAFE_INTEGER : 0,
|
||||
behavior: 'smooth',
|
||||
});
|
||||
};
|
||||
listener();
|
||||
tagsContainerRef.current.addEventListener('mouseleave', listener);
|
||||
return () => {
|
||||
tagsContainer.removeEventListener('mouseleave', listener);
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [hoverExpandDirection]);
|
||||
|
||||
const tagsInPopover = useMemo(() => {
|
||||
const lastTags = tags.slice(maxItems);
|
||||
return (
|
||||
<div className={styles.tagsListContainer}>
|
||||
{lastTags.map((tag, idx) => (
|
||||
<TagItem key={tag.id} tag={tag} idx={idx} mode="list-item" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}, [maxItems, tags]);
|
||||
|
||||
const tagsNormal = useMemo(() => {
|
||||
const nTags = maxItems ? tags.slice(0, maxItems) : tags;
|
||||
return nTags.map((tag, idx) => (
|
||||
<TagItem key={tag.id} tag={tag} idx={idx} mode="sticky" />
|
||||
));
|
||||
}, [maxItems, tags]);
|
||||
return (
|
||||
<div
|
||||
data-testid="page-tags"
|
||||
className={styles.root}
|
||||
style={{
|
||||
// @ts-expect-error it's fine
|
||||
'--hover-max-width': sanitizedWidthOnHover,
|
||||
}}
|
||||
onClick={stopPropagation}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
right: hoverExpandDirection === 'left' ? 0 : 'auto',
|
||||
left: hoverExpandDirection === 'right' ? 0 : 'auto',
|
||||
}}
|
||||
className={clsx(styles.innerContainer)}
|
||||
>
|
||||
<div className={styles.innerBackdrop} />
|
||||
<div className={styles.tagsScrollContainer} ref={tagsContainerRef}>
|
||||
{tagsNormal}
|
||||
</div>
|
||||
{maxItems && tags.length > maxItems ? (
|
||||
<Menu items={tagsInPopover}>
|
||||
<div className={styles.showMoreTag}>
|
||||
<MoreHorizontalIcon />
|
||||
</div>
|
||||
</Menu>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
# <PageListTable />
|
||||
|
||||
A new implementation of the list table component for Page. Replace existing `PageList` component.
|
||||
May rename to `PageList` later.
|
||||
@@ -0,0 +1,179 @@
|
||||
import { DEFAULT_SORT_KEY } from '@affine/env/constant';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { atom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
|
||||
import { pagesToPageGroups } from './page-group';
|
||||
import type { PageListProps, PageMetaRecord } from './types';
|
||||
|
||||
// for ease of use in the component tree
|
||||
// note: must use selectAtom to access this atom for efficiency
|
||||
// @ts-expect-error the error is expected but we will assume the default value is always there by using useHydrateAtoms
|
||||
export const pageListPropsAtom = atom<PageListProps>();
|
||||
|
||||
// whether or not the table is in selection mode (showing selection checkbox & selection floating bar)
|
||||
const selectionActiveAtom = atom(false);
|
||||
|
||||
export const selectionStateAtom = atom(
|
||||
get => {
|
||||
const baseAtom = selectAtom(pageListPropsAtom, props => {
|
||||
const { selectable, selectedPageIds, onSelectedPageIdsChange } = props;
|
||||
return {
|
||||
selectable,
|
||||
selectedPageIds,
|
||||
onSelectedPageIdsChange,
|
||||
};
|
||||
});
|
||||
const baseState = get(baseAtom);
|
||||
const selectionActive =
|
||||
baseState.selectable === 'toggle'
|
||||
? get(selectionActiveAtom)
|
||||
: baseState.selectable;
|
||||
return {
|
||||
...baseState,
|
||||
selectionActive,
|
||||
};
|
||||
},
|
||||
(_get, set, active: boolean) => {
|
||||
set(selectionActiveAtom, active);
|
||||
}
|
||||
);
|
||||
|
||||
// get handlers from pageListPropsAtom
|
||||
export const pageListHandlersAtom = selectAtom(pageListPropsAtom, props => {
|
||||
const { onSelectedPageIdsChange, onDragStart, onDragEnd } = props;
|
||||
|
||||
return {
|
||||
onSelectedPageIdsChange,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
};
|
||||
});
|
||||
|
||||
export const pagesAtom = selectAtom(pageListPropsAtom, props => props.pages);
|
||||
|
||||
export const showOperationsAtom = selectAtom(
|
||||
pageListPropsAtom,
|
||||
props => !!props.pageOperationsRenderer
|
||||
);
|
||||
|
||||
type SortingContext<T extends string | number | symbol> = {
|
||||
key: T;
|
||||
order: 'asc' | 'desc';
|
||||
fallbackKey?: T;
|
||||
};
|
||||
|
||||
type SorterConfig<T extends Record<string, unknown> = Record<string, unknown>> =
|
||||
{
|
||||
key?: keyof T;
|
||||
order: 'asc' | 'desc';
|
||||
sortingFn: (ctx: SortingContext<keyof T>, a: T, b: T) => number;
|
||||
};
|
||||
|
||||
const defaultSortingFn: SorterConfig<PageMetaRecord>['sortingFn'] = (
|
||||
ctx,
|
||||
a,
|
||||
b
|
||||
) => {
|
||||
const val = (obj: PageMetaRecord) => {
|
||||
let v = obj[ctx.key];
|
||||
if (v === undefined && ctx.fallbackKey) {
|
||||
v = obj[ctx.fallbackKey];
|
||||
}
|
||||
return v;
|
||||
};
|
||||
const valA = val(a);
|
||||
const valB = val(b);
|
||||
const revert = ctx.order === 'desc';
|
||||
const revertSymbol = revert ? -1 : 1;
|
||||
if (typeof valA === 'string' && typeof valB === 'string') {
|
||||
return valA.localeCompare(valB) * revertSymbol;
|
||||
}
|
||||
if (typeof valA === 'number' && typeof valB === 'number') {
|
||||
return (valA - valB) * revertSymbol;
|
||||
}
|
||||
if (valA instanceof Date && valB instanceof Date) {
|
||||
return (valA.getTime() - valB.getTime()) * revertSymbol;
|
||||
}
|
||||
if (!valA) {
|
||||
return -1 * revertSymbol;
|
||||
}
|
||||
if (!valB) {
|
||||
return 1 * revertSymbol;
|
||||
}
|
||||
|
||||
if (Array.isArray(valA) && Array.isArray(valB)) {
|
||||
return (valA.length - valB.length) * revertSymbol;
|
||||
}
|
||||
console.warn(
|
||||
'Unsupported sorting type! Please use custom sorting function.',
|
||||
valA,
|
||||
valB
|
||||
);
|
||||
return 0;
|
||||
};
|
||||
|
||||
const sorterStateAtom = atom<SorterConfig<PageMetaRecord>>({
|
||||
key: DEFAULT_SORT_KEY,
|
||||
order: 'desc',
|
||||
sortingFn: defaultSortingFn,
|
||||
});
|
||||
|
||||
export const sorterAtom = atom(
|
||||
get => {
|
||||
let pages = get(pagesAtom);
|
||||
const sorterState = get(sorterStateAtom);
|
||||
const sortCtx: SortingContext<keyof PageMetaRecord> | null = sorterState.key
|
||||
? {
|
||||
key: sorterState.key,
|
||||
order: sorterState.order,
|
||||
}
|
||||
: null;
|
||||
if (sortCtx) {
|
||||
if (sorterState.key === 'updatedDate') {
|
||||
sortCtx.fallbackKey = 'createDate';
|
||||
}
|
||||
const compareFn = (a: PageMetaRecord, b: PageMetaRecord) =>
|
||||
sorterState.sortingFn(sortCtx, a, b);
|
||||
pages = [...pages].sort(compareFn);
|
||||
}
|
||||
return {
|
||||
pages,
|
||||
...sortCtx,
|
||||
};
|
||||
},
|
||||
(_get, set, { newSortKey }: { newSortKey: keyof PageMeta }) => {
|
||||
set(sorterStateAtom, sorterState => {
|
||||
if (sorterState.key === newSortKey) {
|
||||
return {
|
||||
...sorterState,
|
||||
order: sorterState.order === 'asc' ? 'desc' : 'asc',
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
key: newSortKey,
|
||||
order: 'desc',
|
||||
sortingFn: sorterState.sortingFn,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const pageGroupsAtom = atom(get => {
|
||||
let groupBy = get(selectAtom(pageListPropsAtom, props => props.groupBy));
|
||||
const sorter = get(sorterAtom);
|
||||
|
||||
if (groupBy === false) {
|
||||
groupBy = undefined;
|
||||
} else if (groupBy === undefined) {
|
||||
groupBy =
|
||||
sorter.key === 'createDate' || sorter.key === 'updatedDate'
|
||||
? sorter.key
|
||||
: // default sort
|
||||
!sorter.key
|
||||
? DEFAULT_SORT_KEY
|
||||
: undefined;
|
||||
}
|
||||
return pagesToPageGroups(sorter.pages, groupBy);
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { displayFlex, styled } from '../../styles';
|
||||
import { Content } from '../../ui/layout/content';
|
||||
import { TableBodyRow } from '../../ui/table/table-row';
|
||||
|
||||
export const StyledTableContainer = styled('div')(({ theme }) => {
|
||||
return {
|
||||
height: '100%',
|
||||
minHeight: '600px',
|
||||
padding: '0 32px 180px 32px',
|
||||
maxWidth: '100%',
|
||||
[theme.breakpoints.down('sm')]: {
|
||||
padding: '52px 0px',
|
||||
'tr > td:first-of-type': {
|
||||
borderTopLeftRadius: '0px',
|
||||
borderBottomLeftRadius: '0px',
|
||||
},
|
||||
'tr > td:last-of-type': {
|
||||
borderTopRightRadius: '0px',
|
||||
borderBottomRightRadius: '0px',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
export const StyledTitleWrapper = styled('div')(() => {
|
||||
return {
|
||||
...displayFlex('flex-start', 'center'),
|
||||
a: {
|
||||
color: 'inherit',
|
||||
},
|
||||
'a:visited': {
|
||||
color: 'unset',
|
||||
},
|
||||
'a:hover': {
|
||||
color: 'var(--affine-primary-color)',
|
||||
},
|
||||
};
|
||||
});
|
||||
export const StyledTitleLink = styled('div')(() => {
|
||||
return {
|
||||
...displayFlex('flex-start', 'center'),
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
'>svg': {
|
||||
fontSize: '24px',
|
||||
marginRight: '12px',
|
||||
color: 'var(--affine-icon-color)',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTitleContentWrapper = styled('div')(() => {
|
||||
return {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTitlePreview = styled(Content)(() => {
|
||||
return {
|
||||
fontWeight: 400,
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
maxWidth: '100%',
|
||||
};
|
||||
});
|
||||
|
||||
export const StyledTableBodyRow = styled(TableBodyRow)(() => {
|
||||
return {
|
||||
cursor: 'pointer',
|
||||
'.favorite-button': {
|
||||
visibility: 'hidden',
|
||||
},
|
||||
'&:hover': {
|
||||
'.favorite-button': {
|
||||
visibility: 'visible',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import type { CollectionsAtom } from '@affine/component/page-list/use-collection-manager';
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* Get the keys of an object type whose values are of a given type
|
||||
*
|
||||
* See https://stackoverflow.com/questions/54520676/in-typescript-how-to-get-the-keys-of-an-object-type-whose-values-are-of-a-given
|
||||
*/
|
||||
export type KeysMatching<T, V> = {
|
||||
[K in keyof T]-?: T[K] extends V ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
export type ListData = {
|
||||
pageId: string;
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
preview?: ReactNode;
|
||||
tags: Tag[];
|
||||
favorite: boolean;
|
||||
createDate: Date;
|
||||
updatedDate: Date;
|
||||
isPublicPage: boolean;
|
||||
onClickPage: () => void;
|
||||
onOpenPageInNewTab: () => void;
|
||||
bookmarkPage: () => void;
|
||||
removeToTrash: () => void;
|
||||
onDisablePublicSharing: () => void;
|
||||
};
|
||||
|
||||
export type DateKey = KeysMatching<ListData, Date>;
|
||||
|
||||
export type TrashListData = {
|
||||
pageId: string;
|
||||
icon: JSX.Element;
|
||||
title: string;
|
||||
preview?: ReactNode;
|
||||
createDate: Date;
|
||||
// TODO remove optional after assert that trashDate is always set
|
||||
trashDate?: Date;
|
||||
onClickPage: () => void;
|
||||
onRestorePage: () => void;
|
||||
onPermanentlyDeletePage: () => void;
|
||||
};
|
||||
|
||||
export type PageListProps = {
|
||||
isPublicWorkspace?: boolean;
|
||||
collectionsAtom: CollectionsAtom;
|
||||
list: ListData[];
|
||||
fallback?: ReactNode;
|
||||
onCreateNewPage: () => void;
|
||||
onCreateNewEdgeless: () => void;
|
||||
onImportFile: () => void;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
};
|
||||
|
||||
export type DraggableTitleCellData = {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
pagePreview?: string;
|
||||
icon: ReactElement;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Tag } from '@affine/env/filter';
|
||||
import type { PageMeta, Workspace } from '@blocksuite/store';
|
||||
import type { ReactNode } from 'react';
|
||||
import type { To } from 'react-router-dom';
|
||||
|
||||
// TODO: consider reducing the number of props here
|
||||
// using type instead of interface to make it Record compatible
|
||||
export type PageListItemProps = {
|
||||
pageId: string;
|
||||
icon: JSX.Element;
|
||||
title: ReactNode; // using ReactNode to allow for rich content rendering
|
||||
preview?: ReactNode; // using ReactNode to allow for rich content rendering
|
||||
tags: Tag[];
|
||||
createDate: Date;
|
||||
updatedDate?: Date;
|
||||
isPublicPage?: boolean;
|
||||
to?: To; // whether or not to render this item as a Link
|
||||
draggable?: boolean; // whether or not to allow dragging this item
|
||||
selectable?: boolean; // show selection checkbox
|
||||
selected?: boolean;
|
||||
operations?: ReactNode; // operations to show on the right side of the item
|
||||
onClick?: () => void;
|
||||
onSelectedChange?: () => void;
|
||||
};
|
||||
|
||||
export interface PageListHeaderProps {}
|
||||
|
||||
// todo: a temporary solution. may need to be refactored later
|
||||
export type PagesGroupByType = 'createDate' | 'updatedDate'; // todo: can add more later
|
||||
|
||||
// todo: a temporary solution. may need to be refactored later
|
||||
export interface SortBy {
|
||||
key: 'createDate' | 'updatedDate';
|
||||
order: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export type DateKey = 'createDate' | 'updatedDate';
|
||||
|
||||
export interface PageListProps {
|
||||
// required data:
|
||||
pages: PageMeta[];
|
||||
blockSuiteWorkspace: Workspace;
|
||||
|
||||
className?: string;
|
||||
hideHeader?: boolean; // whether or not to hide the header. default is false (showing header)
|
||||
groupBy?: PagesGroupByType | false;
|
||||
isPreferredEdgeless: (pageId: string) => boolean;
|
||||
clickMode?: 'select' | 'link'; // select => click to select; link => click to navigate
|
||||
selectable?: 'toggle' | boolean; // show selection checkbox. toggle means showing a toggle selection in header on click; boolean == true means showing a selection checkbox for each item
|
||||
selectedPageIds?: string[]; // selected page ids
|
||||
onSelectedPageIdsChange?: (selected: string[]) => void;
|
||||
draggable?: boolean; // whether or not to allow dragging this page item
|
||||
onDragStart?: (pageId: string) => void;
|
||||
onDragEnd?: (pageId: string) => void;
|
||||
// we also need the following to make sure the page list functions properly
|
||||
// maybe we could also give a function to render PageListItem?
|
||||
pageOperationsRenderer?: (page: PageMeta) => ReactNode;
|
||||
}
|
||||
|
||||
export interface PageListHandle {
|
||||
toggleSelectable: () => void;
|
||||
}
|
||||
|
||||
export interface PageGroupDefinition {
|
||||
id: string;
|
||||
// using a function to render custom group header
|
||||
label: (() => ReactNode) | ReactNode;
|
||||
match: (item: PageMeta) => boolean;
|
||||
}
|
||||
|
||||
export interface PageGroupProps {
|
||||
id: string;
|
||||
label?: ReactNode; // if there is no label, it is a default group (without header)
|
||||
items: PageMeta[];
|
||||
allItems: PageMeta[];
|
||||
}
|
||||
|
||||
type MakeRecord<T> = {
|
||||
[P in keyof T]: T[P];
|
||||
};
|
||||
|
||||
export type PageMetaRecord = MakeRecord<PageMeta>;
|
||||
|
||||
export type DraggableTitleCellData = {
|
||||
pageId: string;
|
||||
pageTitle: ReactNode;
|
||||
};
|
||||
@@ -1,137 +1,124 @@
|
||||
import type { Collection, Filter, VariableMap } from '@affine/env/filter';
|
||||
import { useAtom } from 'jotai';
|
||||
import { atomWithReset, RESET } from 'jotai/utils';
|
||||
import type { WritableAtom } from 'jotai/vanilla';
|
||||
import type {
|
||||
Collection,
|
||||
DeleteCollectionInfo,
|
||||
Filter,
|
||||
VariableMap,
|
||||
} from '@affine/env/filter';
|
||||
import type { PageMeta } from '@blocksuite/store';
|
||||
import { type Atom, useAtom, useAtomValue } from 'jotai';
|
||||
import { atomWithReset } from 'jotai/utils';
|
||||
import { useCallback } from 'react';
|
||||
import { NIL } from 'uuid';
|
||||
|
||||
import { evalFilterList } from './filter';
|
||||
|
||||
const defaultCollection = {
|
||||
id: NIL,
|
||||
name: 'All',
|
||||
filterList: [],
|
||||
workspaceId: 'temporary',
|
||||
export const createEmptyCollection = (
|
||||
id: string,
|
||||
data?: Partial<Omit<Collection, 'id'>>
|
||||
): Collection => {
|
||||
return {
|
||||
id,
|
||||
name: '',
|
||||
mode: 'page',
|
||||
filterList: [],
|
||||
pages: [],
|
||||
allowList: [],
|
||||
...data,
|
||||
};
|
||||
};
|
||||
|
||||
const collectionAtom = atomWithReset<{
|
||||
currentId: string;
|
||||
defaultCollection: Collection;
|
||||
}>({
|
||||
currentId: NIL,
|
||||
defaultCollection: defaultCollection,
|
||||
const defaultCollection: Collection = createEmptyCollection(NIL, {
|
||||
name: 'All',
|
||||
mode: 'rule',
|
||||
});
|
||||
const defaultCollectionAtom = atomWithReset<Collection>(defaultCollection);
|
||||
export const currentCollectionAtom = atomWithReset<string>(NIL);
|
||||
|
||||
export type CollectionsAtom = WritableAtom<
|
||||
Collection[] | Promise<Collection[]>,
|
||||
[Collection[] | ((collection: Collection[]) => Collection[])],
|
||||
Promise<void>
|
||||
>;
|
||||
export type Updater<T> = (value: T) => T;
|
||||
export type CollectionUpdater = Updater<Collection>;
|
||||
export type CollectionsCRUD = {
|
||||
addCollection: (...collections: Collection[]) => Promise<void>;
|
||||
collections: Collection[];
|
||||
updateCollection: (id: string, updater: CollectionUpdater) => Promise<void>;
|
||||
deleteCollection: (
|
||||
info: DeleteCollectionInfo,
|
||||
...ids: string[]
|
||||
) => Promise<void>;
|
||||
};
|
||||
export type CollectionsCRUDAtom = Atom<CollectionsCRUD>;
|
||||
|
||||
export const useSavedCollections = (collectionAtom: CollectionsAtom) => {
|
||||
const [savedCollections, setCollections] = useAtom(collectionAtom);
|
||||
|
||||
const saveCollection = useCallback(
|
||||
async (collection: Collection) => {
|
||||
if (collection.id === NIL) {
|
||||
return;
|
||||
}
|
||||
await setCollections(old => [...old, collection]);
|
||||
},
|
||||
[setCollections]
|
||||
);
|
||||
const deleteCollection = useCallback(
|
||||
async (id: string) => {
|
||||
if (id === NIL) {
|
||||
return;
|
||||
}
|
||||
await setCollections(old => old.filter(v => v.id !== id));
|
||||
},
|
||||
[setCollections]
|
||||
);
|
||||
export const useSavedCollections = (collectionAtom: CollectionsCRUDAtom) => {
|
||||
const [{ collections, addCollection, deleteCollection, updateCollection }] =
|
||||
useAtom(collectionAtom);
|
||||
const addPage = useCallback(
|
||||
async (collectionId: string, pageId: string) => {
|
||||
await setCollections(old => {
|
||||
const collection = old.find(v => v.id === collectionId);
|
||||
if (!collection) {
|
||||
return old;
|
||||
await updateCollection(collectionId, old => {
|
||||
if (old.mode === 'page') {
|
||||
return {
|
||||
...old,
|
||||
pages: [pageId, ...(old.pages ?? [])],
|
||||
};
|
||||
}
|
||||
return [
|
||||
...old.filter(v => v.id !== collectionId),
|
||||
{
|
||||
...collection,
|
||||
allowList: [pageId, ...(collection.allowList ?? [])],
|
||||
},
|
||||
];
|
||||
return {
|
||||
...old,
|
||||
allowList: [pageId, ...(old.allowList ?? [])],
|
||||
};
|
||||
});
|
||||
},
|
||||
[setCollections]
|
||||
[updateCollection]
|
||||
);
|
||||
return {
|
||||
savedCollections,
|
||||
saveCollection,
|
||||
collections,
|
||||
addCollection,
|
||||
updateCollection,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
};
|
||||
};
|
||||
|
||||
export const useCollectionManager = (collectionsAtom: CollectionsAtom) => {
|
||||
const { savedCollections, saveCollection, deleteCollection, addPage } =
|
||||
useSavedCollections(collectionsAtom);
|
||||
const [collectionData, setCollectionData] = useAtom(collectionAtom);
|
||||
|
||||
const updateCollection = useCallback(
|
||||
export const useCollectionManager = (collectionsAtom: CollectionsCRUDAtom) => {
|
||||
const {
|
||||
collections,
|
||||
updateCollection,
|
||||
addCollection,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
} = useSavedCollections(collectionsAtom);
|
||||
const currentCollectionId = useAtomValue(currentCollectionAtom);
|
||||
const [defaultCollection, updateDefaultCollection] = useAtom(
|
||||
defaultCollectionAtom
|
||||
);
|
||||
const update = useCallback(
|
||||
async (collection: Collection) => {
|
||||
if (collection.id === NIL) {
|
||||
setCollectionData({
|
||||
...collectionData,
|
||||
defaultCollection: collection,
|
||||
});
|
||||
updateDefaultCollection(collection);
|
||||
} else {
|
||||
await saveCollection(collection);
|
||||
await updateCollection(collection.id, () => collection);
|
||||
}
|
||||
},
|
||||
[collectionData, saveCollection, setCollectionData]
|
||||
[updateDefaultCollection, updateCollection]
|
||||
);
|
||||
const selectCollection = useCallback(
|
||||
(id: string) => {
|
||||
setCollectionData({
|
||||
...collectionData,
|
||||
currentId: id,
|
||||
});
|
||||
},
|
||||
[collectionData, setCollectionData]
|
||||
);
|
||||
const backToAll = useCallback(() => {
|
||||
setCollectionData(RESET);
|
||||
}, [setCollectionData]);
|
||||
const setTemporaryFilter = useCallback(
|
||||
(filterList: Filter[]) => {
|
||||
setCollectionData({
|
||||
currentId: NIL,
|
||||
defaultCollection: {
|
||||
...defaultCollection,
|
||||
filterList: filterList,
|
||||
},
|
||||
updateDefaultCollection({
|
||||
...defaultCollection,
|
||||
filterList: filterList,
|
||||
});
|
||||
},
|
||||
[setCollectionData]
|
||||
[updateDefaultCollection, defaultCollection]
|
||||
);
|
||||
const currentCollection =
|
||||
collectionData.currentId === NIL
|
||||
? collectionData.defaultCollection
|
||||
: savedCollections.find(v => v.id === collectionData.currentId) ??
|
||||
collectionData.defaultCollection;
|
||||
currentCollectionId === NIL
|
||||
? defaultCollection
|
||||
: collections.find(v => v.id === currentCollectionId) ??
|
||||
defaultCollection;
|
||||
return {
|
||||
currentCollection: currentCollection,
|
||||
savedCollections,
|
||||
isDefault: currentCollection.id === NIL,
|
||||
savedCollections: collections,
|
||||
isDefault: currentCollectionId === NIL,
|
||||
|
||||
// actions
|
||||
saveCollection,
|
||||
updateCollection,
|
||||
selectCollection,
|
||||
backToAll,
|
||||
createCollection: addCollection,
|
||||
updateCollection: update,
|
||||
deleteCollection,
|
||||
addPage,
|
||||
setTemporaryFilter,
|
||||
@@ -139,3 +126,25 @@ export const useCollectionManager = (collectionsAtom: CollectionsAtom) => {
|
||||
};
|
||||
export const filterByFilterList = (filterList: Filter[], varMap: VariableMap) =>
|
||||
evalFilterList(filterList, varMap);
|
||||
|
||||
export const filterPage = (collection: Collection, page: PageMeta) => {
|
||||
if (collection.mode === 'page') {
|
||||
return collection.pages.includes(page.id);
|
||||
}
|
||||
return filterPageByRules(collection.filterList, collection.allowList, page);
|
||||
};
|
||||
export const filterPageByRules = (
|
||||
rules: Filter[],
|
||||
allowList: string[],
|
||||
page: PageMeta
|
||||
) => {
|
||||
if (allowList?.includes(page.id)) {
|
||||
return true;
|
||||
}
|
||||
return filterByFilterList(rules, {
|
||||
'Is Favourited': !!page.favorite,
|
||||
Created: page.createDate,
|
||||
Updated: page.updatedDate ?? page.createDate,
|
||||
Tags: page.tags,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
|
||||
import type { DateKey, ListData } from './type';
|
||||
import {
|
||||
isLastMonth,
|
||||
isLastWeek,
|
||||
isLastYear,
|
||||
isToday,
|
||||
isYesterday,
|
||||
} from './utils';
|
||||
|
||||
export const useDateGroup = ({
|
||||
data,
|
||||
key,
|
||||
}: {
|
||||
data: ListData[];
|
||||
key?: DateKey;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
if (!key) {
|
||||
return data.map(item => ({ ...item, groupName: '' }));
|
||||
}
|
||||
|
||||
const fallbackGroup = {
|
||||
id: 'earlier',
|
||||
label: t['com.affine.earlier'](),
|
||||
match: (_date: Date) => true,
|
||||
};
|
||||
|
||||
const groups = [
|
||||
{
|
||||
id: 'today',
|
||||
label: t['com.affine.today'](),
|
||||
match: (date: Date) => isToday(date),
|
||||
},
|
||||
{
|
||||
id: 'yesterday',
|
||||
label: t['com.affine.yesterday'](),
|
||||
match: (date: Date) => isYesterday(date) && !isToday(date),
|
||||
},
|
||||
{
|
||||
id: 'last7Days',
|
||||
label: t['com.affine.last7Days'](),
|
||||
match: (date: Date) => isLastWeek(date) && !isYesterday(date),
|
||||
},
|
||||
{
|
||||
id: 'last30Days',
|
||||
label: t['com.affine.last30Days'](),
|
||||
match: (date: Date) => isLastMonth(date) && !isLastWeek(date),
|
||||
},
|
||||
{
|
||||
id: 'currentYear',
|
||||
label: t['com.affine.currentYear'](),
|
||||
match: (date: Date) => isLastYear(date) && !isLastMonth(date),
|
||||
},
|
||||
] as const;
|
||||
|
||||
return data.map(item => {
|
||||
const group = groups.find(group => group.match(item[key])) ?? fallbackGroup;
|
||||
return {
|
||||
...item,
|
||||
groupName: group.label,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,4 +1,12 @@
|
||||
import { useMediaQuery, useTheme } from '@mui/material';
|
||||
import clsx from 'clsx';
|
||||
import {
|
||||
type BaseSyntheticEvent,
|
||||
forwardRef,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './page-list.css';
|
||||
|
||||
export const useIsSmallDevices = () => {
|
||||
const theme = useTheme();
|
||||
@@ -69,3 +77,71 @@ export const formatDate = (date: Date): string => {
|
||||
// MM-DD HH:mm
|
||||
return `${month}-${day} ${hours}:${minutes}`;
|
||||
};
|
||||
|
||||
export type ColWrapperProps = PropsWithChildren<{
|
||||
flex?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
|
||||
alignment?: 'start' | 'center' | 'end';
|
||||
styles?: React.CSSProperties;
|
||||
hideInSmallContainer?: boolean;
|
||||
}> &
|
||||
React.HTMLAttributes<Element>;
|
||||
|
||||
export const ColWrapper = forwardRef<HTMLDivElement, ColWrapperProps>(
|
||||
function ColWrapper(
|
||||
{
|
||||
flex,
|
||||
alignment,
|
||||
hideInSmallContainer,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
...rest
|
||||
}: ColWrapperProps,
|
||||
ref
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
{...rest}
|
||||
ref={ref}
|
||||
data-testid="page-list-flex-wrapper"
|
||||
style={{
|
||||
...style,
|
||||
flexGrow: flex,
|
||||
flexBasis: flex ? `${(flex / 12) * 100}%` : 'auto',
|
||||
justifyContent: alignment,
|
||||
}}
|
||||
className={clsx(
|
||||
className,
|
||||
styles.colWrapper,
|
||||
hideInSmallContainer ? styles.hideInSmallContainer : null
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export const withinDaysAgo = (date: Date, days: number): boolean => {
|
||||
const startDate = new Date();
|
||||
const day = startDate.getDay();
|
||||
const month = startDate.getMonth();
|
||||
const year = startDate.getFullYear();
|
||||
return new Date(year, month, day - days) <= date;
|
||||
};
|
||||
|
||||
export const betweenDaysAgo = (
|
||||
date: Date,
|
||||
days0: number,
|
||||
days1: number
|
||||
): boolean => {
|
||||
return !withinDaysAgo(date, days0) && withinDaysAgo(date, days1);
|
||||
};
|
||||
|
||||
export function stopPropagation(event: BaseSyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
}
|
||||
export function stopPropagationWithoutPrevent(event: BaseSyntheticEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import type React from 'react';
|
||||
|
||||
export const AffineShapeIcon = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
width="200"
|
||||
height="174"
|
||||
viewBox="0 0 200 174"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<rect width="200" height="173.475" fill="white" />
|
||||
<rect
|
||||
x="51.7242"
|
||||
y="38.4618"
|
||||
width="96.5517"
|
||||
height="96.5517"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M51.8341 86.7377L100 38.5717L148.166 86.7377L100 134.904L51.8341 86.7377Z"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M99.6055 38.1965C107.662 33.4757 117.043 30.7695 127.056 30.7695C157.087 30.7695 181.432 55.1147 181.432 85.1461C181.432 107.547 167.887 126.783 148.541 135.113"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M148.375 86.4724C153.096 94.5294 155.802 103.91 155.802 113.923C155.802 143.954 131.457 168.299 101.426 168.299C79.0252 168.299 59.7883 154.754 51.4585 135.408"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M100.395 135.113C92.3376 139.834 82.957 142.54 72.9444 142.54C42.913 142.54 18.5677 118.195 18.5677 88.1636C18.5677 65.7632 32.1126 46.5264 51.459 38.1965"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M51.4588 87.1319C46.7379 79.0749 44.0317 69.6944 44.0317 59.6818C44.0317 29.6504 68.377 5.3051 98.4084 5.30509C120.809 5.30509 140.046 18.85 148.375 38.1963"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M51.459 38.1965L148.541 135.279"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M148.541 38.1965L51.459 135.279"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M99.9995 38.1965V135.279"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<path
|
||||
d="M148.541 86.7376L51.4588 86.7376"
|
||||
stroke="#D2D2D2"
|
||||
strokeWidth="0.530504"
|
||||
/>
|
||||
<ellipse
|
||||
cx="148.276"
|
||||
cy="38.4618"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="148.276"
|
||||
cy="135.014"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="148.276"
|
||||
cy="86.7377"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="51.7239"
|
||||
cy="38.4618"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="51.7239"
|
||||
cy="135.014"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="51.7239"
|
||||
cy="86.7377"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="99.9998"
|
||||
cy="38.4618"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
transform="rotate(-90 99.9998 38.4618)"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="99.9998"
|
||||
cy="86.2071"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
transform="rotate(-90 99.9998 86.2071)"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
<ellipse
|
||||
cx="99.9998"
|
||||
cy="135.014"
|
||||
rx="3.97878"
|
||||
ry="3.97878"
|
||||
transform="rotate(-90 99.9998 135.014)"
|
||||
fill="#5B5B5B"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1,7 +1,5 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
import { viewMenu } from './collection-list.css';
|
||||
|
||||
export const view = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -9,7 +7,6 @@ export const view = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
height: '100%',
|
||||
paddingLeft: 16,
|
||||
});
|
||||
|
||||
export const option = style({
|
||||
@@ -29,28 +26,3 @@ export const option = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const pin = style({
|
||||
opacity: 1,
|
||||
});
|
||||
export const pinedIcon = style({
|
||||
display: 'block',
|
||||
selectors: {
|
||||
[`${option}:hover &`]: {
|
||||
display: 'none',
|
||||
},
|
||||
[`${viewMenu}:hover &`]: {
|
||||
display: 'none',
|
||||
},
|
||||
},
|
||||
});
|
||||
export const pinIcon = style({
|
||||
display: 'none',
|
||||
selectors: {
|
||||
[`${option}:hover &`]: {
|
||||
display: 'block',
|
||||
},
|
||||
[`${viewMenu}:hover &`]: {
|
||||
display: 'block',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { DeleteCollectionInfo, PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { ViewLayersIcon } from '@blocksuite/icons';
|
||||
@@ -8,22 +8,24 @@ import clsx from 'clsx';
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
type CollectionsAtom,
|
||||
type CollectionsCRUDAtom,
|
||||
useCollectionManager,
|
||||
} from '../use-collection-manager';
|
||||
import * as styles from './collection-bar.css';
|
||||
import { EditCollectionModal } from './create-collection';
|
||||
import { type AllPageListConfig, EditCollectionModal } from './edit-collection';
|
||||
import { useActions } from './use-action';
|
||||
|
||||
interface CollectionBarProps {
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
collectionsAtom: CollectionsAtom;
|
||||
columnsCount: number;
|
||||
collectionsAtom: CollectionsCRUDAtom;
|
||||
backToAll: () => void;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
info: DeleteCollectionInfo;
|
||||
}
|
||||
|
||||
export const CollectionBar = (props: CollectionBarProps) => {
|
||||
const { getPageInfo, propertiesMeta, columnsCount, collectionsAtom } = props;
|
||||
const { collectionsAtom } = props;
|
||||
const t = useAFFiNEI18N();
|
||||
const setting = useCollectionManager(collectionsAtom);
|
||||
const collection = setting.currentCollection;
|
||||
@@ -31,16 +33,23 @@ export const CollectionBar = (props: CollectionBarProps) => {
|
||||
const actions = useActions({
|
||||
collection,
|
||||
setting,
|
||||
info: props.info,
|
||||
openEdit: () => setOpen(true),
|
||||
});
|
||||
|
||||
return !setting.isDefault ? (
|
||||
<tr style={{ userSelect: 'none' }}>
|
||||
<td>
|
||||
<div
|
||||
style={{
|
||||
userSelect: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '12px 20px',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div className={styles.view}>
|
||||
<EditCollectionModal
|
||||
propertiesMeta={propertiesMeta}
|
||||
getPageInfo={getPageInfo}
|
||||
allPageListConfig={props.allPageListConfig}
|
||||
init={collection}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
@@ -84,11 +93,8 @@ export const CollectionBar = (props: CollectionBarProps) => {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</td>
|
||||
{Array.from({ length: columnsCount - 2 }).map((_, i) => (
|
||||
<td key={i}></td>
|
||||
))}
|
||||
<td
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'end',
|
||||
@@ -96,11 +102,11 @@ export const CollectionBar = (props: CollectionBarProps) => {
|
||||
>
|
||||
<Button
|
||||
style={{ border: 'none', position: 'static' }}
|
||||
onClick={() => setting.backToAll()}
|
||||
onClick={props.backToAll}
|
||||
>
|
||||
{t['com.affine.collectionBar.backToAll']()}
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const menuTitleStyle = style({
|
||||
marginLeft: '12px',
|
||||
@@ -14,30 +14,6 @@ export const menuDividerStyle = style({
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
});
|
||||
export const viewButton = style({
|
||||
borderRadius: '8px',
|
||||
height: '100%',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
background: 'var(--affine-white)',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
maxWidth: '150px',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
transition: 'margin-left 0.2s ease-in-out',
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
marginRight: '20px',
|
||||
});
|
||||
globalStyle(`${viewButton} > span`, {
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
export const viewMenu = style({});
|
||||
export const viewOption = style({
|
||||
borderRadius: 8,
|
||||
@@ -57,171 +33,9 @@ export const viewOption = style({
|
||||
},
|
||||
},
|
||||
});
|
||||
export const deleteOption = style({
|
||||
':hover': {
|
||||
backgroundColor: '#FFEFE9',
|
||||
},
|
||||
});
|
||||
export const filterButton = style({
|
||||
borderRadius: '8px',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
marginRight: '20px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'var(--affine-font-xs)',
|
||||
background: 'var(--affine-white)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
['WebkitAppRegion' as string]: 'no-drag',
|
||||
transition: 'margin-left 0.2s ease-in-out',
|
||||
':hover': {
|
||||
borderColor: 'var(--affine-border-color)',
|
||||
background: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
export const filterButtonCollapse = style({
|
||||
marginLeft: '20px',
|
||||
});
|
||||
export const viewDivider = style({
|
||||
'::after': {
|
||||
content: '""',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
margin: '0 1px',
|
||||
},
|
||||
});
|
||||
export const saveButton = style({
|
||||
marginTop: '4px',
|
||||
borderRadius: '8px',
|
||||
padding: '8px 0',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
},
|
||||
});
|
||||
export const saveButtonContainer = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
cursor: 'pointer',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '8px',
|
||||
});
|
||||
export const saveIcon = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
marginRight: '8px',
|
||||
});
|
||||
export const saveText = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: 'var(--affine-font-sm)',
|
||||
});
|
||||
export const cancelButton = style({
|
||||
background: 'var(--affine-hover-color)',
|
||||
borderRadius: '8px',
|
||||
':hover': {
|
||||
background: 'var(--affine-hover-color)',
|
||||
color: 'var(--affine-text-primary-color)',
|
||||
border: '1px solid var(--affine-border-color)',
|
||||
},
|
||||
});
|
||||
export const saveTitle = style({
|
||||
fontSize: 'var(--affine-font-h-6)',
|
||||
fontWeight: '600',
|
||||
lineHeight: '24px',
|
||||
paddingBottom: 20,
|
||||
});
|
||||
export const allowList = style({});
|
||||
|
||||
export const allowTitle = style({
|
||||
fontSize: 12,
|
||||
margin: '20px 0',
|
||||
});
|
||||
|
||||
export const allowListContent = style({
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const excludeList = style({
|
||||
backgroundColor: 'var(--affine-background-warning-color)',
|
||||
padding: 18,
|
||||
borderRadius: 8,
|
||||
});
|
||||
|
||||
export const excludeListContent = style({
|
||||
margin: '8px 0',
|
||||
});
|
||||
|
||||
export const filterTitle = style({
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginBottom: 10,
|
||||
});
|
||||
|
||||
export const excludeTitle = style({
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
});
|
||||
|
||||
export const excludeTip = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 12,
|
||||
});
|
||||
|
||||
export const scrollContainer = style({
|
||||
maxHeight: '70vh',
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const container = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
export const pageContainer = style({
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingLeft: 8,
|
||||
paddingRight: 5,
|
||||
});
|
||||
|
||||
export const pageIcon = style({
|
||||
marginRight: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
});
|
||||
|
||||
export const pageTitle = style({
|
||||
flex: 1,
|
||||
});
|
||||
export const deleteIcon = style({
|
||||
marginLeft: 20,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
padding: 4,
|
||||
cursor: 'pointer',
|
||||
':hover': {
|
||||
color: 'var(--affine-error-color)',
|
||||
backgroundColor: 'var(--affine-background-error-color)',
|
||||
},
|
||||
});
|
||||
export const filterMenuTrigger = style({
|
||||
padding: '6px 8px',
|
||||
background: 'var(--affine-hover-color)',
|
||||
':hover': {
|
||||
backgroundColor: 'var(--affine-hover-color)',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,114 +1,32 @@
|
||||
import type { Collection, Filter } from '@affine/env/filter';
|
||||
import type {
|
||||
Collection,
|
||||
DeleteCollectionInfo,
|
||||
Filter,
|
||||
} from '@affine/env/filter';
|
||||
import type { PropertiesMeta } from '@affine/env/filter';
|
||||
import type { GetPageInfoById } from '@affine/env/page-info';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { FilteredIcon, FolderIcon, ViewLayersIcon } from '@blocksuite/icons';
|
||||
import { FilteredIcon } from '@blocksuite/icons';
|
||||
import { Button } from '@toeverything/components/button';
|
||||
import { Menu, MenuIcon, MenuItem } from '@toeverything/components/menu';
|
||||
import { Tooltip } from '@toeverything/components/tooltip';
|
||||
import clsx from 'clsx';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { Menu } from '@toeverything/components/menu';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import { FlexWrapper } from '../../../ui/layout';
|
||||
import { CreateFilterMenu } from '../filter/vars';
|
||||
import type { useCollectionManager } from '../use-collection-manager';
|
||||
import * as styles from './collection-list.css';
|
||||
import { EditCollectionModal } from './create-collection';
|
||||
import { useActions } from './use-action';
|
||||
import { CollectionOperations } from './collection-operations';
|
||||
import { type AllPageListConfig, EditCollectionModal } from './edit-collection';
|
||||
|
||||
const CollectionOption = ({
|
||||
collection,
|
||||
setting,
|
||||
updateCollection,
|
||||
}: {
|
||||
collection: Collection;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
updateCollection: (view: Collection) => void;
|
||||
}) => {
|
||||
const actions = useActions({
|
||||
collection,
|
||||
setting,
|
||||
openEdit: updateCollection,
|
||||
});
|
||||
|
||||
const selectCollection = useCallback(
|
||||
() => setting.selectCollection(collection.id),
|
||||
[setting, collection.id]
|
||||
);
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="collection-select-option"
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<ViewLayersIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
onClick={selectCollection}
|
||||
key={collection.id}
|
||||
className={styles.viewMenu}
|
||||
>
|
||||
<Tooltip
|
||||
content={collection.name}
|
||||
side="right"
|
||||
rootOptions={{
|
||||
delayDuration: 1500,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '150px',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{collection.name}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
{actions.map((action, i) => {
|
||||
const onClick = (e: MouseEvent<HTMLDivElement>) => {
|
||||
e.stopPropagation();
|
||||
action.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid={`collection-select-option-${action.name}`}
|
||||
key={i}
|
||||
onClick={onClick}
|
||||
style={{ marginLeft: i === 0 ? 28 : undefined }}
|
||||
className={clsx(styles.viewOption, action.className)}
|
||||
>
|
||||
{action.icon}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</MenuItem>
|
||||
);
|
||||
};
|
||||
export const CollectionList = ({
|
||||
setting,
|
||||
getPageInfo,
|
||||
propertiesMeta,
|
||||
allPageListConfig,
|
||||
userInfo,
|
||||
}: {
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
getPageInfo: GetPageInfoById;
|
||||
propertiesMeta: PropertiesMeta;
|
||||
allPageListConfig: AllPageListConfig;
|
||||
userInfo: DeleteCollectionInfo;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
const [collection, setCollection] = useState<Collection>();
|
||||
@@ -140,83 +58,51 @@ export const CollectionList = ({
|
||||
);
|
||||
return (
|
||||
<FlexWrapper alignItems="center">
|
||||
{setting.savedCollections.length > 0 && (
|
||||
<Menu
|
||||
items={
|
||||
<div style={{ minWidth: 150 }}>
|
||||
<MenuItem
|
||||
preFix={
|
||||
<MenuIcon>
|
||||
<FolderIcon />
|
||||
</MenuIcon>
|
||||
}
|
||||
onClick={setting.backToAll}
|
||||
className={styles.viewMenu}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
}}
|
||||
>
|
||||
<div>All</div>
|
||||
</div>
|
||||
</MenuItem>
|
||||
<div className={styles.menuTitleStyle}>Saved Collection</div>
|
||||
<div className={styles.menuDividerStyle}></div>
|
||||
{setting.savedCollections.map(view => (
|
||||
<CollectionOption
|
||||
key={view.id}
|
||||
collection={view}
|
||||
setting={setting}
|
||||
updateCollection={setCollection}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
{setting.isDefault ? (
|
||||
<>
|
||||
<Menu
|
||||
items={
|
||||
<CreateFilterMenu
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={setting.currentCollection.filterList}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilteredIcon />}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
<EditCollectionModal
|
||||
allPageListConfig={allPageListConfig}
|
||||
init={collection}
|
||||
open={!!collection}
|
||||
onOpenChange={closeUpdateCollectionModal}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<CollectionOperations
|
||||
info={userInfo}
|
||||
collection={setting.currentCollection}
|
||||
config={allPageListConfig}
|
||||
setting={setting}
|
||||
>
|
||||
<Button
|
||||
data-testid="collection-select"
|
||||
style={{ marginRight: '20px' }}
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilteredIcon />}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
<Tooltip
|
||||
content={setting.currentCollection.name}
|
||||
rootOptions={{
|
||||
delayDuration: 1500,
|
||||
}}
|
||||
>
|
||||
<>{setting.currentCollection.name}</>
|
||||
</Tooltip>
|
||||
{t['com.affine.filter']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
</CollectionOperations>
|
||||
)}
|
||||
<Menu
|
||||
items={
|
||||
<CreateFilterMenu
|
||||
propertiesMeta={propertiesMeta}
|
||||
value={setting.currentCollection.filterList}
|
||||
onChange={onChange}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className={styles.filterMenuTrigger}
|
||||
type="default"
|
||||
icon={<FilteredIcon />}
|
||||
data-testid="create-first-filter"
|
||||
>
|
||||
{t['com.affine.filter']()}
|
||||
</Button>
|
||||
</Menu>
|
||||
<EditCollectionModal
|
||||
propertiesMeta={propertiesMeta}
|
||||
getPageInfo={getPageInfo}
|
||||
init={collection}
|
||||
open={!!collection}
|
||||
onOpenChange={closeUpdateCollectionModal}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
</FlexWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const divider = style({
|
||||
marginTop: '2px',
|
||||
marginBottom: '2px',
|
||||
marginLeft: '12px',
|
||||
marginRight: '8px',
|
||||
height: '1px',
|
||||
background: 'var(--affine-border-color)',
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Collection, DeleteCollectionInfo } from '@affine/env/filter';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { DeleteIcon, EditIcon, FilterIcon } from '@blocksuite/icons';
|
||||
import {
|
||||
Menu,
|
||||
MenuIcon,
|
||||
MenuItem,
|
||||
type MenuItemProps,
|
||||
} from '@toeverything/components/menu';
|
||||
import {
|
||||
type PropsWithChildren,
|
||||
type ReactElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
|
||||
import type { useCollectionManager } from '../use-collection-manager';
|
||||
import type { AllPageListConfig } from '.';
|
||||
import * as styles from './collection-operations.css';
|
||||
import {
|
||||
useEditCollection,
|
||||
useEditCollectionName,
|
||||
} from './use-edit-collection';
|
||||
|
||||
export const CollectionOperations = ({
|
||||
collection,
|
||||
config,
|
||||
setting,
|
||||
info,
|
||||
children,
|
||||
}: PropsWithChildren<{
|
||||
info: DeleteCollectionInfo;
|
||||
collection: Collection;
|
||||
config: AllPageListConfig;
|
||||
setting: ReturnType<typeof useCollectionManager>;
|
||||
}>) => {
|
||||
const { open: openEditCollectionModal, node: editModal } =
|
||||
useEditCollection(config);
|
||||
const t = useAFFiNEI18N();
|
||||
const { open: openEditCollectionNameModal, node: editNameModal } =
|
||||
useEditCollectionName({
|
||||
title: t['com.affine.editCollection.renameCollection'](),
|
||||
});
|
||||
const showEditName = useCallback(() => {
|
||||
openEditCollectionNameModal(collection.name)
|
||||
.then(name => {
|
||||
return setting.updateCollection({ ...collection, name });
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [openEditCollectionNameModal, collection, setting]);
|
||||
const showEdit = useCallback(() => {
|
||||
openEditCollectionModal(collection)
|
||||
.then(collection => {
|
||||
return setting.updateCollection(collection);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
}, [setting, collection, openEditCollectionModal]);
|
||||
const actions = useMemo<
|
||||
Array<
|
||||
| {
|
||||
icon: ReactElement;
|
||||
name: string;
|
||||
click: () => void;
|
||||
type?: MenuItemProps['type'];
|
||||
element?: undefined;
|
||||
}
|
||||
| {
|
||||
element: ReactElement;
|
||||
}
|
||||
>
|
||||
>(
|
||||
() => [
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<EditIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.collection.menu.rename'](),
|
||||
click: showEditName,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<FilterIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['com.affine.collection.menu.edit'](),
|
||||
click: showEdit,
|
||||
},
|
||||
{
|
||||
element: <div key="divider" className={styles.divider}></div>,
|
||||
},
|
||||
{
|
||||
icon: (
|
||||
<MenuIcon>
|
||||
<DeleteIcon />
|
||||
</MenuIcon>
|
||||
),
|
||||
name: t['Delete'](),
|
||||
click: () => {
|
||||
setting.deleteCollection(info, collection.id).catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
},
|
||||
type: 'danger',
|
||||
},
|
||||
],
|
||||
[t, showEditName, showEdit, setting, info, collection.id]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{editModal}
|
||||
{editNameModal}
|
||||
<Menu
|
||||
items={
|
||||
<div style={{ minWidth: 150 }}>
|
||||
{actions.map(action => {
|
||||
if (action.element) {
|
||||
return action.element;
|
||||
}
|
||||
return (
|
||||
<MenuItem
|
||||
data-testid="collection-option"
|
||||
key={action.name}
|
||||
type={action.type}
|
||||
preFix={action.icon}
|
||||
onClick={action.click}
|
||||
>
|
||||
{action.name}
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const footer = style({
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
paddingTop: 20,
|
||||
gap: 20,
|
||||
});
|
||||
|
||||
export const createTips = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 12,
|
||||
lineHeight: '20px',
|
||||
});
|
||||
|
||||
export const label = style({
|
||||
color: 'var(--affine-text-secondary-color)',
|
||||
fontSize: 14,
|
||||
lineHeight: '22px',
|
||||
});
|
||||
|
||||
export const content = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
padding: '12px 0px 20px',
|
||||
marginBottom: 8,
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user