Compare commits
41 Commits
v0.3.0
...
feat/pro-f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d811ce9c44 | ||
|
|
7f496adf63 | ||
|
|
38803f6696 | ||
|
|
99459415d5 | ||
|
|
5a4835091f | ||
|
|
061a54dadd | ||
|
|
a532fa1ce2 | ||
|
|
2a355238a4 | ||
|
|
874353e8b8 | ||
|
|
18e60333ae | ||
|
|
d729785802 | ||
|
|
abbbb4b928 | ||
|
|
3cea9377e6 | ||
|
|
06c268bed1 | ||
|
|
e3d7ace8bd | ||
|
|
fecc6cf742 | ||
|
|
f7c2e7979e | ||
|
|
cd70526b93 | ||
|
|
0f92ac0d9b | ||
|
|
eb0ca9c738 | ||
|
|
3233c35836 | ||
|
|
ba2fd7f695 | ||
|
|
c7f7531b11 | ||
|
|
4f57fc45a7 | ||
|
|
ea92dcd2ed | ||
|
|
d51600aba8 | ||
|
|
c3ed2e5e7f | ||
|
|
13b10471ec | ||
|
|
80d1c5f805 | ||
|
|
8079345546 | ||
|
|
4a67afdd3a | ||
|
|
d555546782 | ||
|
|
e53ec868a5 | ||
|
|
872a5fad28 | ||
|
|
400b94a5f2 | ||
|
|
638b8b6b79 | ||
|
|
ca1dd92ab4 | ||
|
|
f8827da73a | ||
|
|
babaa9f1b1 | ||
|
|
0cefd17484 | ||
|
|
974a5ff0f3 |
@@ -3,6 +3,8 @@
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
|
||||
# Fill if you want to use an external database
|
||||
POSTGRES_URL= # e.g. postgresql://kan:your_password@your_host:5432/kan
|
||||
|
||||
# Only required if deploying from compose
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ docker-compose.override.yml
|
||||
# runtime env
|
||||
__ENV.js
|
||||
i18n.cache
|
||||
|
||||
# pgdata
|
||||
/apps/web/pgdata
|
||||
|
||||
64
README.md
64
README.md
@@ -138,36 +138,40 @@ pnpm dev
|
||||
|
||||
## Environment Variables 🔐
|
||||
|
||||
| Variable | Description | Required | Example |
|
||||
| -------------------------------- | -------------------------------------------------------- | ------------------ | --------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | Yes | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| Variable | Description | Required | Example |
|
||||
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
|
||||
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
|
||||
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
|
||||
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
|
||||
| `SMTP_PORT` | SMTP server port | For Email | `465` |
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
|
||||
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
|
||||
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
|
||||
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
|
||||
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
|
||||
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
|
||||
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
|
||||
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
|
||||
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
|
||||
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
|
||||
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
|
||||
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
|
||||
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
|
||||
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
|
||||
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
|
||||
See `.env.example` for a complete list of supported environment variables.
|
||||
|
||||
|
||||
@@ -24,9 +24,11 @@ mintlify dev
|
||||
|
||||
### Publishing Changes
|
||||
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
Install our Github App to auto propagate changes from your repo to your deployment. Changes will be deployed to production automatically after pushing to the default branch. Find the link to install on your dashboard.
|
||||
|
||||
#### Troubleshooting
|
||||
|
||||
- Mintlify dev isn't running - Run `mintlify install` it'll re-install dependencies.
|
||||
- Page loads as a 404 - Make sure you are running in a folder with `mint.json`
|
||||
|
||||
#BUMP
|
||||
|
||||
@@ -81,5 +81,4 @@ ARG PORT=3000
|
||||
ENV PORT=${PORT}
|
||||
EXPOSE ${PORT}
|
||||
|
||||
CMD ["sh", "-c", "cd /app && pnpm db:migrate && cd /app/apps/web && pnpm start"]
|
||||
|
||||
CMD ["sh", "-c", "if [ -n \"$POSTGRES_URL\" ]; then cd /app && pnpm db:migrate && cd /app/apps/web; fi && pnpm start"]
|
||||
|
||||
@@ -6,6 +6,8 @@ checksums:
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
|
||||
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
||||
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
|
||||
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
@@ -13,17 +15,25 @@ checksums:
|
||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
||||
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
||||
Add%20description...%20(type%20'%2F'%20to%20open%20commands)/singular: df99ea599521682e9ad0a90716955dc6
|
||||
Add%20an%20item.../singular: f8e7993980420698700b07f56b3a38af
|
||||
Add%20checklist/singular: 05d73200b56cfea75fb3a0f94c9bfb74
|
||||
Add%20description...%20(type%20'%2F'%20to%20open%20commands%20or%20'%40'%20to%20mention)/singular: c6eb35a796dd7ac0e3dbc66244b71248
|
||||
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
|
||||
Add%20label/singular: 0be732d46df263265935fda342097a08
|
||||
Add%20member/singular: 11979625770516ca287e929381778e02
|
||||
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
|
||||
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
|
||||
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
|
||||
added%20a%20member%20to%20the%20card/singular: 95495e770062e2fd590333d56e6308d2
|
||||
added%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1caba6b6bca4c6fa1ab173d241059dc2
|
||||
added%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: fb6a852c50c1629f47c6f155fc93fdee
|
||||
added%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: bdd202da20b1fffbec21792c5453f90c
|
||||
added%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: b32be052b3d57de0c9120fa7f9fc86ee
|
||||
Adding%20a%20new%20member%20will%20cost%20an%20additional%20%7Bprice%7D%20(%7BbillingType%7D)%20per%20seat./singular: 12e88573028306110fbc15ef1e714892
|
||||
Admin%20roles/singular: 32a5d78073b9bb9a246773afba8831df
|
||||
All%20seats%20are%20free%20during%20the%20beta%20until%20Sept%201st!/singular: 015cec3899cf99e37c3cf231d432e31d
|
||||
All%20systems%20operational/singular: ee943a4046b09e6334cceeea9fda2bfc
|
||||
Already%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20in%3C%2F1%3E%3C%2F0%3E/singular: 2959fd276248208b65cb27ed46b20135
|
||||
An%20error%20occurred%20while%20disconnecting%20your%20Trello%20account./singular: 0aa3973b860c1faf8d9123aebf567e40
|
||||
An%20unexpected%20error%20occurred.%20Please%20try%20again%20later./singular: 1b5749b0cca6a62d75a577bee6804074
|
||||
API%20keys/singular: 07f3620a30e08136f0b072c9af8d1eef
|
||||
API%20Reference/singular: 7dcc877064bfdf889ec55dbc9e06a242
|
||||
Applicants/singular: 4babd8331a1441e91087f855f43e57f8
|
||||
@@ -31,16 +41,21 @@ checksums:
|
||||
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
|
||||
assigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20to%20the%20card/singular: 4fb1c419a78a0f010a3e5790801551fc
|
||||
assigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
|
||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||
billed%20annually/singular: c782a8085ffee456bfc56c34ccc5e71c
|
||||
billed%20monthly/singular: 793f2728acf06e9f06d6cdc26dfcf0cd
|
||||
Billing/singular: b01dbdd049ebbd4a349fa64d6ce65a3b
|
||||
Billing%20portal/singular: dc3afa14ffe19f5920992a0c9d49d525
|
||||
Blog%20Post/singular: 38e027f212be445dc530e427fc642a20
|
||||
Board/singular: 101ff39aab674c033c15ab978d0d8bac
|
||||
Board%20analytics%20(coming%20soon)/singular: 3f648e6fe3849ee2ac5cdf410ac9ea06
|
||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
||||
@@ -58,8 +73,10 @@ checksums:
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
|
||||
Change%20the%20language%20of%20the%20app./singular: fb20db145e28ed44aba89c146ded7bfc
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||
Code%20Review/singular: a2da6c2339301e7c3ddf068c4ff9f7e8
|
||||
@@ -69,7 +86,9 @@ checksums:
|
||||
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
Confirm%20URL%20change/singular: e14b4ce081d1ef6194b37e1814e97b39
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
|
||||
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
|
||||
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
|
||||
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
|
||||
Connect%20your%20Trello%20account%20to%20import%20boards./singular: 84a510a7485f43e43f2191bd161171d7
|
||||
@@ -82,6 +101,7 @@ checksums:
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
|
||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
||||
@@ -91,8 +111,9 @@ checksums:
|
||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20URLs%20are%20a%20premium%20feature.%20You'll%20be%20directed%20to%20upgrade%20your%20account./singular: b52e4f6122cf739c9551d29beb89b25e
|
||||
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
@@ -102,6 +123,10 @@ checksums:
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
||||
deleted%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 60f9107197e756487d3f95a1083846b5
|
||||
deleted%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 8b7e270f612f9e91bda7f5cfb853d843
|
||||
Design/singular: 991b75727b6784c1a063a7462b76186d
|
||||
Disconnect%20Trello/singular: a2dd737d133887d34504728c7e7f05e3
|
||||
Discuss%20and%20collaborate%20on%20cards./singular: f2e87816709f602130831ead50bba457
|
||||
@@ -123,17 +148,24 @@ checksums:
|
||||
email/singular: f31eb214738e037d58e26149797739df
|
||||
Email/singular: e7f34943a0c2fb849db1839ff6ef5cb5
|
||||
Enhancement/singular: 785fe23c0eef0a5b60b5b2a88151de31
|
||||
Enter%20your%20current%20password/singular: bfceabde4c0b6f2cb439015b76549651
|
||||
Enter%20your%20current%20password%20and%20choose%20a%20new%20secure%20password./singular: 9bb88155b18e98ea799c0e939d16af64
|
||||
Enter%20your%20email%20address/singular: 9bc008365ebe3e404e241c8ca876f56e
|
||||
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
||||
Enter%20your%20new%20password/singular: c67251e3002b68bc20a7cf5de23e43ac
|
||||
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
||||
Error%20Changing%20Password/singular: ebecb5c1b72ba4b063117241f5ba4f2d
|
||||
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
|
||||
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
|
||||
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
Error%20disconnecting%20Trello/singular: a8766853864eddc480113778a708871b
|
||||
Error%20inviting%20member/singular: 91553b606a6ebed3534cd2e5635ce004
|
||||
Error%20updating%20display%20name/singular: 0d7979a25d290123ed8b8906bf468f1a
|
||||
Error%20updating%20profile%20image/singular: 462a56a6e574abc9e13cb48f1266adad
|
||||
Error%20updating%20workspace%20description/singular: 7a9fd7c69b90ab848ee1f1d4a4995381
|
||||
Error%20updating%20workspace%20name/singular: 46025f42a7d5eff689e9cb8bed14a1e4
|
||||
Error%20updating%20workspace%20URL/singular: 9da6c012e1b512d8c7fbd812626c4675
|
||||
Error%20upgrading%20subscription/singular: 57cc47581c7faaffb3b1721b6352b576
|
||||
Error%20uploading%20profile%20image/singular: 0ba02753a797a74c6ea7b622c9f69c71
|
||||
Everything%20in%20the%20free%20plan%2C%20plus%3A/singular: 62b44c4973b92b806c69a4b15e0256dc
|
||||
Everything%20you%20need%2C%20free%20forever.%20Unlimited%20boards%2C%20unlimited%20lists%2C%20unlimited%20cards.%20Upgrade%20any%20time./singular: fa21632ab1468edf10acda2fe7b71323
|
||||
@@ -149,6 +181,7 @@ checksums:
|
||||
Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
|
||||
Free/singular: 0326365539c004f6088656f692602078
|
||||
Free%20Plan/singular: c995acccc1e3cba379786892a5b4d4e5
|
||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||
Full-time/singular: fe6e201f6676ae354111435cf6c76910
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
@@ -185,8 +218,11 @@ checksums:
|
||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
|
||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||
@@ -194,6 +230,7 @@ checksums:
|
||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
||||
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
||||
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
||||
@@ -206,20 +243,26 @@ checksums:
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
Members/singular: 0932e80cba1e3e0a7f52bb67ff31da32
|
||||
Members%20%7C%20%7B0%7D/singular: a29e3e9f1076acd178c417d047584e88
|
||||
Monthly/singular: 818f1192e32bb855597f930d3e78806e
|
||||
moved%20the%20card%20from%20%3C0%3E%7BfromList%7D%3C%2F0%3E%20to%3C1%3E%7BtoList%7D%3C%2F1%3E/singular: c2d0d72ad97510344b51154ef15060e0
|
||||
monthly%20billing/singular: e228107df80015377112d41f4f155cb3
|
||||
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
|
||||
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
|
||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||
New%20label/singular: 890040da52a88c16c249a4ac78980f81
|
||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
||||
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||
@@ -236,11 +279,15 @@ checksums:
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
||||
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
|
||||
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
Payment%20frequency/singular: 63ded0e4ffb462ca8bd33d38e4691d86
|
||||
Pending/singular: 030a6f3395d5d4efddd3cc67d6009039
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
Please%20enter%20a%20valid%20password/singular: 4b32c17e19b79bcbf0bb092c06ba310f
|
||||
@@ -250,6 +297,8 @@ checksums:
|
||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||
Private/singular: d740d0660e68c61db1853489356030ae
|
||||
Pro%20Plan/singular: 76a09fd843b496610f8cf1dced417b62
|
||||
Pro%20Plan%20%E2%88%9E/singular: a898be9e4610706b9b8146a209e8ba16
|
||||
Profile%20image%20updated/singular: 1ba6609b50bc217a36ab659563e78dae
|
||||
Profile%20picture/singular: d9b5e595877ffe2557486a987dd28274
|
||||
Public/singular: c834f11dab0881632aded3f01d4f8fa9
|
||||
@@ -263,7 +312,10 @@ checksums:
|
||||
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
|
||||
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
|
||||
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
|
||||
removed%20label%20%3C0%3E%7Blabel%7D%3C%2F0%3E/singular: 1e7ebc1866136352bdc3cd90afc083f0
|
||||
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
|
||||
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
|
||||
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
|
||||
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
@@ -295,19 +347,23 @@ checksums:
|
||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||
Success/singular: c43827becada6750f7a25890905f38b9
|
||||
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
|
||||
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
|
||||
Support%20the%20development%20of%20the%20project/singular: c63bab228ab2b1be4370c668a99cd278
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
||||
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
||||
The%20open%20source%20%3C0%2F%3E%20alternative%20to%20Trello/singular: 692cb2e8a8e610953c996826beed45a2
|
||||
The%20visibility%20of%20your%20board%20has%20been%20set%20to%20%7B0%7D./singular: 970e17a115f7374e60e0a8ded425db8f
|
||||
Theme/singular: 21fe00b7a518089576fb83c08631107a
|
||||
They%20won't%20be%20able%20to%20access%20this%20workspace./singular: 93b740350fe3430319fbca85349e41d9
|
||||
This%20action%20can't%20be%20undone./singular: cb222ff89715d8c971e8c25d121e1dbd
|
||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20this%20workspace./singular: a31141558af793635c1ddd2fa0a33499
|
||||
This%20will%20result%20in%20the%20permanent%20deletion%20of%20all%20data%20associated%20with%20your%20account./singular: b49224632bd6c3b7f5e462912aeb1081
|
||||
@@ -318,22 +374,28 @@ checksums:
|
||||
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
|
||||
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
|
||||
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
|
||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
||||
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
||||
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
|
||||
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
|
||||
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
|
||||
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
|
||||
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
|
||||
Unable%20to%20update%20card/singular: eca6002e57af8ec97324eae64e3b5731
|
||||
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
|
||||
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
|
||||
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
|
||||
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
|
||||
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
|
||||
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
|
||||
unassigned%20%3C0%3E%7BmemberName%7D%3C%2F0%3E%20from%20the%20card/singular: 105d4eeb32044126b8dde243bad11d56
|
||||
unassigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20from%20the%20card/singular: b683cc07092348c1e75dba40b1262b85
|
||||
unassigned%20themselves%20from%20the%20card/singular: 27c6f293c562af6a44348d7f00036d30
|
||||
Unlimited%20activity%20log/singular: 8c993de94cda0deac19ba14ecafce6a5
|
||||
Unlimited%20boards/singular: cb4fd858137ce825c2f1904d142d8042
|
||||
@@ -341,23 +403,30 @@ checksums:
|
||||
Unlimited%20cards/singular: f44c1050c4d6aa39c8cdf5f4a7d12577
|
||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||
updated%20the%20title%20to%20%3C0%3E%7BtoTitle%7D%3C%2F0%3E/singular: 70cdf681ec2b6ef8e14397bc1f93f945
|
||||
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
||||
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
|
||||
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
||||
Upgrade%20to%20Team%20Plan/singular: 40e2ef151266937f8bbe63b3d361f645
|
||||
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
|
||||
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
|
||||
URL%20must%20be%20at%20least%203%20characters%20long/singular: 1b74ac9ec61fd34b16530ed599009ed1
|
||||
Use%20template/singular: 33f0ca0aa8f4b0de99aad267a61a094d
|
||||
User/singular: 61073457a5c3901084b557d065f876be
|
||||
User%20is%20already%20a%20member%20of%20this%20workspace/singular: bc246386f0975f750f2133222bb4735e
|
||||
Video/singular: 8050c90e4289b105a0780f0fdda6ff66
|
||||
View%20and%20manage%20your%20API%20keys./singular: 88e23ee79056846dd9ce63ceee690549
|
||||
View%20and%20manage%20your%20billing%20and%20subscription./singular: 7469502543aac6f02149813546d1cb38
|
||||
View%20docs/singular: 43ce54f27bb1e3f1880b56e03ab38871
|
||||
View%20only/singular: 21f5f7ba3b49050c4f406113479d6a7a
|
||||
View%20our%20roadmap./singular: 1dd7ec80e1635951b1c90d592d1d07dc
|
||||
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
|
||||
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
|
||||
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
|
||||
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
|
||||
@@ -382,14 +451,17 @@ checksums:
|
||||
Writing/singular: 4f5f3ee3320c252921f80c42653c5d49
|
||||
Yearly/singular: 87f43e016c19cb25860f456549a2f431
|
||||
Yes%2C%20we%20offer%20an%20forever%20free%20plan%20for%20individual%20use.%20No%20restrictions%2C%20no%20paywalls%2C%20no%20limits./singular: 963cee303d0ff13c1719f4575e774fe3
|
||||
You%20are%20about%20to%20change%20your%20password./singular: df4783d593505766d78c5bc08d3d48ac
|
||||
You%20can%20get%20a%20custom%20workspace%20URL%2C%20like%20%3C0%3Ekan.bn%2Fkan%3C%2F0%3E%2C%20by%20going%20into%20your%20%3C1%3Eworkspace%20settings%3C%2F1%3E%20and%20purchasing%20a%20pro%20workspace%20subscription.%20All%20subscriptions%20help%20fund%20the%20development%20of%20the%20project!/singular: 41dd145ef56f539e12dc064a9807c660
|
||||
You%20can%20invite%20team%20members%20by%20clicking%20the%20%22Invite%22%20button%20in%20the%20top%20right%20corner%20of%20the%20%3C0%3Emembers%20page%3C%2F0%3E%20and%20entering%20their%20email%20address.%20They%20will%20receive%20an%20email%20with%20a%20link%20to%20join%20the%20workspace./singular: 47e125eb9b4c11cab5a2f4b3ab08e883
|
||||
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
|
||||
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
|
||||
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
|
||||
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||
Your%20password%20has%20been%20changed./singular: 8f730f68d52b7efa727aa0784282ebcb
|
||||
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
||||
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
||||
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
|
||||
|
||||
@@ -31,12 +31,12 @@ const config = {
|
||||
|
||||
images: {
|
||||
remotePatterns: [
|
||||
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: process.env.S3_FORCE_PATH_STYLE === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
@@ -54,4 +54,16 @@ const config = {
|
||||
},
|
||||
};
|
||||
|
||||
// Only allow external images when OIDC is configured (for OIDC provider avatars)
|
||||
if (
|
||||
env("OIDC_CLIENT_ID") &&
|
||||
env("OIDC_CLIENT_SECRET") &&
|
||||
env("OIDC_DISCOVERY_URL")
|
||||
) {
|
||||
config.images?.remotePatterns?.push({
|
||||
protocol: "https",
|
||||
hostname: "**",
|
||||
});
|
||||
}
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"build": "pnpm with-env next build",
|
||||
"clean": "git clean -xdf .cache .next .turbo node_modules",
|
||||
"dev": "pnpm with-env next dev | pino-pretty",
|
||||
"dev": "pnpm with-env next dev",
|
||||
"format": "prettier --check . --ignore-path ../../.gitignore",
|
||||
"lint": "eslint",
|
||||
"start": "pnpm with-env next start",
|
||||
@@ -33,6 +33,7 @@
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/react-query": "catalog:",
|
||||
"@tiptap/extension-link": "^2.22.2",
|
||||
"@tiptap/extension-mention": "^3.0.9",
|
||||
"@tiptap/extension-placeholder": "^2.14.0",
|
||||
"@tiptap/pm": "^2.14.0",
|
||||
"@tiptap/react": "^2.14.0",
|
||||
@@ -48,6 +49,7 @@
|
||||
"next": "^15.3.4",
|
||||
"next-logger": "^5.0.1",
|
||||
"next-runtime-env": "^1.7.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"nextjs-cors": "^2.2.0",
|
||||
"pino": "^9.6.0",
|
||||
"posthog-js": "^1.254.0",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
FaApple,
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FaGoogle,
|
||||
FaLinkedin,
|
||||
FaMicrosoft,
|
||||
FaOpenid,
|
||||
FaReddit,
|
||||
FaSpotify,
|
||||
FaTiktok,
|
||||
@@ -33,6 +34,8 @@ import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
type AuthProvider = SocialProvider | "oidc";
|
||||
|
||||
interface FormValues {
|
||||
name?: string;
|
||||
email: string;
|
||||
@@ -141,16 +144,28 @@ const availableSocialProviders = {
|
||||
name: "Zoom",
|
||||
icon: SiZoom,
|
||||
},
|
||||
oidc: {
|
||||
id: "oidc",
|
||||
name: "OIDC",
|
||||
icon: FaOpenid,
|
||||
},
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | SocialProvider>(null);
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
useState<null | AuthProvider>(null);
|
||||
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const { showPopup } = usePopup();
|
||||
const oidcProviderName = "OIDC";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
const credentialsAllowed =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
setIsCredentialsEnabled(credentialsAllowed);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -226,13 +241,26 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
setIsLoginWithEmailPending(false);
|
||||
};
|
||||
|
||||
const handleLoginWithProvider = async (provider: SocialProvider) => {
|
||||
const handleLoginWithProvider = async (provider: AuthProvider) => {
|
||||
setIsLoginWithProviderPending(provider);
|
||||
setLoginError(null);
|
||||
const { error } = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
|
||||
let error;
|
||||
if (provider === "oidc") {
|
||||
// Use oauth2 signin for OIDC provider
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId: "oidc",
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
error = result.error;
|
||||
} else {
|
||||
// Use social signin for traditional social providers
|
||||
const result = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/boards",
|
||||
});
|
||||
error = result.error;
|
||||
}
|
||||
|
||||
setIsLoginWithProviderPending(null);
|
||||
|
||||
@@ -260,13 +288,16 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
return (
|
||||
<Button
|
||||
key={key}
|
||||
onClick={() => handleLoginWithProvider(key as SocialProvider)}
|
||||
onClick={() => handleLoginWithProvider(key as AuthProvider)}
|
||||
isLoading={isLoginWithProviderPending === key}
|
||||
iconLeft={<provider.icon />}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
<Trans>Continue with {provider.name}</Trans>
|
||||
<Trans>
|
||||
Continue with{" "}
|
||||
{key === "oidc" ? oidcProviderName : provider.name}
|
||||
</Trans>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -5,7 +5,7 @@ import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: "primary" | "secondary" | "danger" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
size?: "xs" | "sm" | "md" | "lg";
|
||||
isLoading?: boolean;
|
||||
iconLeft?: React.ReactNode;
|
||||
iconRight?: React.ReactNode;
|
||||
@@ -28,6 +28,7 @@ const Button = ({
|
||||
}: ButtonProps) => {
|
||||
const classes = twMerge(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none",
|
||||
size === "xs" && "text-xs px-2 py-1",
|
||||
size === "sm" && "text-xs",
|
||||
size === "lg" && "py-[0.65rem]",
|
||||
fullWidth && "w-full",
|
||||
|
||||
@@ -55,53 +55,67 @@ export default function CheckboxDropdown({
|
||||
|
||||
const renderMenuItems = (items: Item[], groupKey: string | null) => (
|
||||
<>
|
||||
{items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() =>
|
||||
handleSelect(groupKey, { key: item.key, value: item.value })
|
||||
}
|
||||
checked={item.selected}
|
||||
/>
|
||||
{item.leftIcon && (
|
||||
<span className="ml-3 flex items-center">{item.leftIcon}</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-[12px] text-dark-900"
|
||||
{items.length > 0 ? (
|
||||
items.map((item) => (
|
||||
<Menu.Item key={item.key}>
|
||||
<div
|
||||
className="group flex items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSelect(groupKey, { key: item.key, value: item.value });
|
||||
}}
|
||||
>
|
||||
{item.value}
|
||||
</label>
|
||||
{handleEdit && (
|
||||
<button
|
||||
className="invisible ml-auto group-hover:visible"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleEdit(item.key);
|
||||
}}
|
||||
<input
|
||||
id={item.key}
|
||||
name={item.key}
|
||||
type="checkbox"
|
||||
className="h-[14px] w-[14px] rounded bg-transparent"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onChange={() =>
|
||||
handleSelect(groupKey, { key: item.key, value: item.value })
|
||||
}
|
||||
checked={item.selected}
|
||||
/>
|
||||
{item.leftIcon && (
|
||||
<span className="ml-3 flex items-center">{item.leftIcon}</span>
|
||||
)}
|
||||
<label
|
||||
htmlFor={item.key}
|
||||
className="ml-3 text-[12px] text-dark-900"
|
||||
>
|
||||
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
||||
</button>
|
||||
)}
|
||||
{item.value}
|
||||
</label>
|
||||
{handleEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="invisible ml-auto group-hover:visible"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleEdit(item.key);
|
||||
}}
|
||||
>
|
||||
<HiEllipsisHorizontal size={20} className="text-dark-900" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))
|
||||
) : (
|
||||
!handleCreate && (
|
||||
<div className="flex items-center p-2 text-[12px] text-dark-900">
|
||||
No items
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
)
|
||||
)}
|
||||
{handleCreate && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center rounded-[5px] p-2 px-2 text-[12px] text-dark-900 hover:bg-light-200 dark:hover:bg-dark-300"
|
||||
onClick={() => handleCreate()}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleCreate();
|
||||
}}
|
||||
>
|
||||
<HiMiniPlus size={20} className="pr-1.5" />
|
||||
{createNewItemLabel}
|
||||
|
||||
58
apps/web/src/components/CircularProgress.tsx
Normal file
58
apps/web/src/components/CircularProgress.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface CircularProgressProps {
|
||||
progress: number; // 0-100
|
||||
size?: "sm" | "md" | "lg";
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CircularProgress = ({
|
||||
progress,
|
||||
size = "md",
|
||||
className,
|
||||
}: CircularProgressProps) => {
|
||||
const radius = 40;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
const strokeDashoffset = circumference - (progress / 100) * circumference;
|
||||
|
||||
return (
|
||||
<div className={twMerge("relative", className)}>
|
||||
<svg
|
||||
className={twMerge(
|
||||
"-rotate-90 transform",
|
||||
size === "sm" && "h-4 w-4",
|
||||
size === "md" && "h-5 w-5",
|
||||
size === "lg" && "h-8 w-8",
|
||||
)}
|
||||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="14"
|
||||
className="text-light-300 dark:text-dark-400"
|
||||
/>
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="14"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={strokeDashoffset}
|
||||
strokeLinecap="round"
|
||||
className={twMerge(
|
||||
"transition-all duration-300 ease-in-out",
|
||||
progress === 100 ? "text-green-500" : "text-blue-500",
|
||||
)}
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CircularProgress;
|
||||
@@ -10,7 +10,7 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useTheme } from "next-themes";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import FeedbackModal from "./FeedbackModal";
|
||||
import Modal from "./modal";
|
||||
@@ -41,7 +41,7 @@ export default function Dashboard({
|
||||
rightPanel,
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const theme = useTheme();
|
||||
const { theme } = useTheme();
|
||||
const { modalContentType } = useModal();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
@@ -90,7 +90,7 @@ export default function Dashboard({
|
||||
}
|
||||
});
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
const isDarkMode = theme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
@@ -10,21 +12,23 @@ export function DeleteLabelConfirmation({
|
||||
labelPublicId: string;
|
||||
refetch: () => void;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { closeModal, closeModals } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const deleteLabelMutation = api.label.delete.useMutation({
|
||||
onSuccess: () => refetch(),
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
closeModals(2);
|
||||
},
|
||||
onError: () =>
|
||||
showPopup({
|
||||
header: "Error deleting label",
|
||||
message: "Please try again later, or contact customer support.",
|
||||
header: t`Error deleting label`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const handleDeleteLabel = () => {
|
||||
closeModal();
|
||||
deleteLabelMutation.mutate({
|
||||
labelPublicId,
|
||||
});
|
||||
@@ -34,17 +38,17 @@ export function DeleteLabelConfirmation({
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
Are you sure you want to delete this label?
|
||||
{t`Are you sure you want to delete this label?`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{"This action can't be undone."}
|
||||
{t`This action can't be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteLabel}>Delete</Button>
|
||||
<Button onClick={handleDeleteLabel}>{t`Delete`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
} from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import { forwardRef, useImperativeHandle, useRef, useState } from "react";
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
HiH1,
|
||||
HiH2,
|
||||
@@ -33,6 +39,9 @@ import {
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import tippy from "tippy.js";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Avatar from "./Avatar";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -68,6 +77,16 @@ export interface RenderSuggestionsProps {
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}
|
||||
|
||||
export type WorkspaceMember = {
|
||||
publicId: string;
|
||||
user: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
image: string | null;
|
||||
} | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
const CommandsList = forwardRef<
|
||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||
{
|
||||
@@ -181,6 +200,125 @@ const RenderSuggestions = () => {
|
||||
};
|
||||
};
|
||||
|
||||
type MentionItem = { id: string; label: string; image: string | null };
|
||||
|
||||
const MentionList = forwardRef<
|
||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||
{
|
||||
items: MentionItem[];
|
||||
command: (item: MentionItem) => void;
|
||||
}
|
||||
>(({ items, command }, ref) => {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: SuggestionKeyDownProps) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
setSelectedIndex((selectedIndex + items.length - 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
setSelectedIndex((selectedIndex + 1) % items.length);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
const item = items[selectedIndex];
|
||||
if (item) {
|
||||
command(item);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
||||
{items.length > 0 ? items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => command(item)}
|
||||
className={twMerge(
|
||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={item.label}
|
||||
imageUrl={
|
||||
item.image ? getAvatarUrl(item.image) : undefined
|
||||
}
|
||||
email={item.label}
|
||||
/>
|
||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="flex items-center justify-start p-2">
|
||||
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
MentionList.displayName = "MentionList";
|
||||
|
||||
const renderMentionSuggestions = () => {
|
||||
let reactRenderer: ReactRenderer;
|
||||
let popup: TippyInstance[];
|
||||
|
||||
return {
|
||||
onStart: (props: any) => {
|
||||
reactRenderer = new ReactRenderer(MentionList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (!props.clientRect) return;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: props.clientRect,
|
||||
appendTo: () => document.body,
|
||||
content: reactRenderer.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
onUpdate(props: any) {
|
||||
reactRenderer.updateProps(props);
|
||||
if (!props.clientRect) return;
|
||||
popup[0]?.setProps({ getReferenceClientRect: props.clientRect });
|
||||
},
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === "Escape") {
|
||||
popup[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
(
|
||||
reactRenderer.ref as {
|
||||
onKeyDown?: (props: SuggestionKeyDownProps) => boolean;
|
||||
}
|
||||
).onKeyDown?.(props) ?? false
|
||||
);
|
||||
},
|
||||
onExit() {
|
||||
popup[0]?.destroy();
|
||||
reactRenderer.destroy();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const SlashCommands = Extension.create<SlashCommandsOptions>({
|
||||
name: "slash-commands",
|
||||
addOptions() {
|
||||
@@ -284,11 +422,13 @@ export default function Editor({
|
||||
onChange,
|
||||
onBlur,
|
||||
readOnly = false,
|
||||
workspaceMembers,
|
||||
}: {
|
||||
content: string | null;
|
||||
onChange?: (value: string) => void;
|
||||
onBlur?: () => void;
|
||||
readOnly?: boolean;
|
||||
workspaceMembers: WorkspaceMember[];
|
||||
}) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -299,7 +439,7 @@ export default function Editor({
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
: t`Add description... (type '/' to open commands)`,
|
||||
: t`Add description... (type '/' to open commands or '@' to mention)`,
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: true,
|
||||
@@ -320,6 +460,38 @@ export default function Editor({
|
||||
char: "/",
|
||||
},
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}));
|
||||
const q = query.toLowerCase();
|
||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||
},
|
||||
command: ({ editor, range, props }: any) => {
|
||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(mentionHTML)
|
||||
.focus()
|
||||
.run();
|
||||
},
|
||||
render: renderMentionSuggestions,
|
||||
},
|
||||
renderText({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => onChange?.(editor.getHTML()),
|
||||
@@ -343,9 +515,19 @@ export default function Editor({
|
||||
editable: !readOnly,
|
||||
injectCSS: false,
|
||||
},
|
||||
[content],
|
||||
[], // creating the editor only once
|
||||
);
|
||||
|
||||
// this will sync external content changes without re-creating the editor instance
|
||||
useEffect(() => {
|
||||
if (!editor) return;
|
||||
const currentHTML = editor.getHTML();
|
||||
const safeContent = content ?? "";
|
||||
if (safeContent !== currentHTML) {
|
||||
editor.commands.setContent(safeContent, false);
|
||||
}
|
||||
}, [content, editor]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<style jsx global>{`
|
||||
@@ -358,11 +540,19 @@ export default function Editor({
|
||||
.tiptap p {
|
||||
margin: 0 0 1rem 0 !important;
|
||||
}
|
||||
.tiptap .mention {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0.125rem 0.25rem;
|
||||
color: rgb(59, 130, 246);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
`}</style>
|
||||
{!readOnly && editor && <EditorBubbleMenu editor={editor} />}
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-black [&_p]:dark:text-white [&_ul]:!text-xs"
|
||||
className="prose dark:prose-invert prose-sm max-w-none overflow-y-auto [&_blockquote]:!text-xs [&_h1]:!text-lg [&_h2]:!text-base [&_h3]:!text-sm [&_ol]:!text-xs [&_p.is-empty::before]:text-light-900 [&_p.is-empty::before]:dark:text-dark-800 [&_p]:!text-sm [&_p]:text-light-950 [&_p]:dark:text-dark-950 [&_ul]:!text-xs"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -32,7 +32,7 @@ export function LabelForm({
|
||||
refetch: () => void;
|
||||
isEdit?: boolean;
|
||||
}) {
|
||||
const { closeModal, entityId, openModal } = useModal();
|
||||
const { closeModal, entityId, openModal, setModalState } = useModal();
|
||||
|
||||
const label = api.label.byPublicId.useQuery(
|
||||
{
|
||||
@@ -57,18 +57,23 @@ export function LabelForm({
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
|
||||
const createLabel = api.label.create.useMutation({
|
||||
onSuccess: () => {
|
||||
onSuccess: (newLabel) => {
|
||||
const currentColourIndex = colours.findIndex(
|
||||
(c) => c.code === watch("colour").code,
|
||||
);
|
||||
try {
|
||||
refetch();
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
colour: colours[(currentColourIndex + 1) % colours.length],
|
||||
isCreateAnotherEnabled,
|
||||
});
|
||||
setModalState("NEW_LABEL_CREATED", newLabel.publicId);
|
||||
if (!isCreateAnotherEnabled) {
|
||||
closeModal();
|
||||
} else {
|
||||
const newFormState = {
|
||||
name: "",
|
||||
colour: colours[(currentColourIndex + 1) % colours.length],
|
||||
isCreateAnotherEnabled,
|
||||
};
|
||||
reset(newFormState);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
@@ -79,10 +84,6 @@ export function LabelForm({
|
||||
onSuccess: () => {
|
||||
refetch();
|
||||
closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
colour: colours[0],
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -220,6 +221,7 @@ export function LabelForm({
|
||||
<div className="space-x-2">
|
||||
{isEdit && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("DELETE_LABEL", entityId)}
|
||||
>
|
||||
@@ -229,6 +231,9 @@ export function LabelForm({
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={updateLabel.isPending || createLabel.isPending}
|
||||
disabled={
|
||||
!watch("name")
|
||||
}
|
||||
>
|
||||
{isEdit ? t`Update label` : t`Create label`}
|
||||
</Button>
|
||||
|
||||
@@ -31,6 +31,7 @@ export function NewWorkspaceForm() {
|
||||
description: values.description,
|
||||
slug: values.slug,
|
||||
plan: values.plan,
|
||||
role: "admin",
|
||||
});
|
||||
closeModal();
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import settingsIconLight from "~/assets/settings-light.json";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
@@ -59,9 +59,9 @@ export default function SideNavigation({
|
||||
|
||||
const { pathname } = router;
|
||||
|
||||
const { activeTheme } = useTheme();
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
|
||||
const isDarkMode = activeTheme === "dark";
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
const navigation = [
|
||||
{
|
||||
|
||||
@@ -1,23 +1,22 @@
|
||||
import { CgDarkMode } from "react-icons/cg";
|
||||
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const { themePreference, switchTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
switchTheme(themePreference === "light" ? "dark" : "light");
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
aria-label={`Switch to ${themePreference === "light" ? "dark" : "light"} theme`}
|
||||
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} theme`}
|
||||
>
|
||||
<CgDarkMode
|
||||
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
|
||||
themePreference === "dark" ? "rotate-180" : "rotate-0"
|
||||
theme === "dark" ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useTheme } from "next-themes";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
@@ -29,7 +29,7 @@ export default function UserMenu({
|
||||
onCloseSideNav,
|
||||
}: UserMenuProps) {
|
||||
const router = useRouter();
|
||||
const { themePreference, switchTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
@@ -127,12 +127,12 @@ export default function UserMenu({
|
||||
</div>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchTheme("system")}
|
||||
onClick={() => setTheme("system")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
themePreference === "system" ? "visible" : "invisible",
|
||||
theme === "system" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
@@ -141,12 +141,12 @@ export default function UserMenu({
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchTheme("dark")}
|
||||
onClick={() => setTheme("dark")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
themePreference === "dark" ? "visible" : "invisible",
|
||||
theme === "dark" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
@@ -155,12 +155,12 @@ export default function UserMenu({
|
||||
</Menu.Item>
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchTheme("light")}
|
||||
onClick={() => setTheme("light")}
|
||||
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
|
||||
>
|
||||
<span
|
||||
className={twMerge(
|
||||
themePreference === "light" ? "visible" : "invisible",
|
||||
theme === "light" ? "visible" : "invisible",
|
||||
"mr-4 h-[6px] w-[6px] rounded-full bg-light-900 dark:bg-dark-900",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -7,15 +7,19 @@ interface Props {
|
||||
children: React.ReactNode;
|
||||
modalSize?: "sm" | "md" | "lg";
|
||||
positionFromTop?: "sm" | "md" | "lg";
|
||||
isVisible?: boolean;
|
||||
}
|
||||
|
||||
const Modal: React.FC<Props> = ({
|
||||
children,
|
||||
modalSize = "sm",
|
||||
positionFromTop = "md",
|
||||
isVisible,
|
||||
}) => {
|
||||
const { isOpen, closeModal } = useModal();
|
||||
|
||||
const shouldShow = isVisible !== undefined ? isVisible : isOpen;
|
||||
|
||||
const modalSizeMap = {
|
||||
sm: "max-w-[400px]",
|
||||
md: "max-w-[550px]",
|
||||
@@ -29,7 +33,7 @@ const Modal: React.FC<Props> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<Transition.Root show={isOpen} as={Fragment}>
|
||||
<Transition.Root show={shouldShow} as={Fragment}>
|
||||
<Dialog as="div" className="relative z-10" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const env = createEnv({
|
||||
s.split(",").every((l) => z.string().url().safeParse(l).success),
|
||||
)
|
||||
.optional(),
|
||||
POSTGRES_URL: z.string().url(),
|
||||
POSTGRES_URL: z.string().url().optional().or(z.literal("")),
|
||||
TRELLO_APP_API_KEY: z.string().optional(),
|
||||
TRELLO_APP_SECRET: z.string().optional(),
|
||||
STRIPE_SECRET_KEY: z.string().optional(),
|
||||
@@ -51,6 +51,10 @@ export const env = createEnv({
|
||||
VK_CLIENT_SECRET: z.string().optional(),
|
||||
LINKEDIN_CLIENT_ID: z.string().optional(),
|
||||
LINKEDIN_CLIENT_SECRET: z.string().optional(),
|
||||
// Generic OIDC Provider
|
||||
OIDC_CLIENT_ID: z.string().optional(),
|
||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||
OIDC_DISCOVERY_URL: z.string().optional(),
|
||||
REDDIT_CLIENT_ID: z.string().optional(),
|
||||
REDDIT_CLIENT_SECRET: z.string().optional(),
|
||||
ROBLOX_CLIENT_ID: z.string().optional(),
|
||||
@@ -101,6 +105,13 @@ export const env = createEnv({
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY: z
|
||||
.string()
|
||||
.transform((s) => (s === "" ? undefined : s))
|
||||
.refine(
|
||||
(s) => !s || s.toLowerCase() === "true" || s.toLowerCase() === "false",
|
||||
)
|
||||
.optional(),
|
||||
},
|
||||
/**
|
||||
* Destructure all variables from `process.env` to make sure they aren't tree-shaken away.
|
||||
@@ -119,6 +130,8 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||
NEXT_PUBLIC_USE_STANDALONE_OUTPUT:
|
||||
process.env.NEXT_PUBLIC_USE_STANDALONE_OUTPUT,
|
||||
NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY:
|
||||
process.env.NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY,
|
||||
},
|
||||
skipValidation:
|
||||
!!process.env.CI || process.env.npm_lifecycle_event === "lint",
|
||||
|
||||
48
apps/web/src/hooks/useModalFormState.ts
Normal file
48
apps/web/src/hooks/useModalFormState.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { useEffect } from "react";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
interface UseModalFormStateOptions<T> {
|
||||
modalType: string;
|
||||
initialValues: T;
|
||||
resetOnClose?: boolean;
|
||||
}
|
||||
|
||||
export function useModalFormState<T extends Record<string, any>>({
|
||||
modalType,
|
||||
initialValues,
|
||||
resetOnClose = false,
|
||||
}: UseModalFormStateOptions<T>) {
|
||||
const { modalContentType, isOpen, getModalState, setModalState, clearModalState } = useModal();
|
||||
|
||||
const isCurrentModal = modalContentType === modalType;
|
||||
const savedState = getModalState(modalType) as T | undefined;
|
||||
|
||||
// get current form state (using the saved values if available, otherwise the initial values)
|
||||
const formState = savedState || initialValues;
|
||||
|
||||
const saveFormState = (state: Partial<T>) => {
|
||||
if (!isCurrentModal) return;
|
||||
|
||||
const currentState = getModalState(modalType) || initialValues;
|
||||
const newState = { ...currentState, ...state };
|
||||
setModalState(modalType, newState);
|
||||
};
|
||||
|
||||
const clearFormState = () => {
|
||||
clearModalState(modalType);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (resetOnClose && !isOpen && savedState) {
|
||||
clearModalState(modalType);
|
||||
}
|
||||
}, [isOpen, resetOnClose, savedState, modalType, clearModalState]);
|
||||
|
||||
return {
|
||||
formState,
|
||||
saveFormState,
|
||||
clearFormState,
|
||||
isCurrentModal,
|
||||
hasSavedState: !!savedState,
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -14,7 +14,7 @@ import { useEffect } from "react";
|
||||
import { LinguiProviderWrapper } from "~/providers/lingui";
|
||||
import { ModalProvider } from "~/providers/modal";
|
||||
import { PopupProvider } from "~/providers/popup";
|
||||
import { ThemeProvider } from "~/providers/theme";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const jakarta = Plus_Jakarta_Sans({
|
||||
@@ -81,9 +81,9 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
<script src="/__ENV.js" />
|
||||
<main className="font-sans">
|
||||
<LinguiProviderWrapper>
|
||||
<ThemeProvider>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ModalProvider>
|
||||
<PopupProvider>
|
||||
{posthogKey ? (
|
||||
<PostHogProvider client={posthog}>
|
||||
{getLayout(<Component {...pageProps} />)}
|
||||
@@ -91,8 +91,8 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
|
||||
) : (
|
||||
getLayout(<Component {...pageProps} />)
|
||||
)}
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</PopupProvider>
|
||||
</ModalProvider>
|
||||
</ThemeProvider>
|
||||
</LinguiProviderWrapper>
|
||||
</main>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { env } from "next-runtime-env";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createNextApiContext } from "@kan/api/trpc";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
@@ -40,17 +41,22 @@ export default async function handler(
|
||||
const body = req.body as CheckoutSessionRequest;
|
||||
const { successUrl, cancelUrl, slug, workspacePublicId } = body;
|
||||
|
||||
if (!successUrl || !cancelUrl || !slug || !workspacePublicId) {
|
||||
if (!successUrl || !cancelUrl || !workspacePublicId) {
|
||||
return res.status(400).json({ error: "Missing required fields" });
|
||||
}
|
||||
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
if (slug) {
|
||||
const slugResult = workspaceSlugSchema.safeParse(slug);
|
||||
|
||||
if (!slugResult.success) {
|
||||
return new Response(JSON.stringify({ error: "Invalid workspace slug" }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
if (!slugResult.success) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "Invalid workspace slug" }),
|
||||
{
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const workspace = await workspaceRepo.getAllByUserId(db, user.id);
|
||||
@@ -66,20 +72,37 @@ export default async function handler(
|
||||
});
|
||||
}
|
||||
|
||||
const subscription = await subscriptionRepo.create(db, {
|
||||
plan: "pro",
|
||||
referenceId: workspacePublicId,
|
||||
userId: user.id,
|
||||
stripeCustomerId: user.stripeCustomerId ?? "",
|
||||
status: "incomplete",
|
||||
});
|
||||
|
||||
const subscriptionId = subscription?.id;
|
||||
|
||||
if (!subscriptionId) {
|
||||
return res.status(500).json({ error: "Error creating subscription" });
|
||||
}
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
mode: "subscription",
|
||||
line_items: [
|
||||
{
|
||||
price: process.env.STRIPE_PRO_PLAN_PRICE_ID,
|
||||
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
client_reference_id: workspacePublicId,
|
||||
customer: user.stripeCustomerId ?? undefined,
|
||||
metadata: {
|
||||
workspaceSlug: slug,
|
||||
...(slug && { workspaceSlug: slug }),
|
||||
workspacePublicId,
|
||||
userId: user.id,
|
||||
subscriptionId,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ export default async function handler(
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
rawBody,
|
||||
sig,
|
||||
process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
process.env.STRIPE_WEBHOOK_SECRET_LEGACY!,
|
||||
);
|
||||
|
||||
const { db } = await createNextApiContext(req);
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
|
||||
type ModalContextType = {
|
||||
interface ModalState {
|
||||
contentType: string;
|
||||
entityId?: string;
|
||||
entityLabel?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
interface ModalContextType {
|
||||
isOpen: boolean;
|
||||
openModal: (
|
||||
contentType: string,
|
||||
@@ -8,36 +18,80 @@ type ModalContextType = {
|
||||
entityLabel?: string,
|
||||
) => void;
|
||||
closeModal: () => void;
|
||||
closeModals: (count: number) => void;
|
||||
clearAllModals: () => void;
|
||||
modalContentType: string;
|
||||
entityId: string;
|
||||
entityLabel: string;
|
||||
};
|
||||
|
||||
interface Props {
|
||||
children: React.ReactNode;
|
||||
modalStates: Record<string, any>;
|
||||
setModalState: (modalType: string, state: any) => void;
|
||||
getModalState: (modalType: string) => any;
|
||||
clearModalState: (modalType: string) => void;
|
||||
clearAllModalStates: () => void;
|
||||
}
|
||||
|
||||
const ModalContext = createContext<ModalContextType | undefined>(undefined);
|
||||
|
||||
export const ModalProvider: React.FC<Props> = ({ children }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [entityId, setEntityId] = useState("");
|
||||
const [entityLabel, setEntityLabel] = useState("");
|
||||
const [modalContentType, setModalContentType] = useState("");
|
||||
const [modalStack, setModalStack] = useState<ModalState[]>([]);
|
||||
const [modalStates, setModalStates] = useState<Record<string, any>>({});
|
||||
|
||||
const isOpen = modalStack.length > 0;
|
||||
const currentModal = modalStack[modalStack.length - 1];
|
||||
const modalContentType = currentModal?.contentType || "";
|
||||
const entityId = currentModal?.entityId || "";
|
||||
const entityLabel = currentModal?.entityLabel || "";
|
||||
|
||||
const openModal = (
|
||||
contentType: string,
|
||||
entityId?: string,
|
||||
entityLabel?: string,
|
||||
) => {
|
||||
setIsOpen(true);
|
||||
setModalContentType(contentType);
|
||||
if (entityId) setEntityId(entityId);
|
||||
if (entityLabel) setEntityLabel(entityLabel);
|
||||
const newModal: ModalState = { contentType, entityId, entityLabel };
|
||||
setModalStack((prev) => [...prev, newModal]);
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsOpen(false);
|
||||
setModalStack((prev) => {
|
||||
if (prev.length <= 1) {
|
||||
return [];
|
||||
}
|
||||
return prev.slice(0, -1);
|
||||
});
|
||||
};
|
||||
|
||||
const closeModals = (count: number) => {
|
||||
setModalStack(prev => {
|
||||
const newLength = Math.max(0, prev.length - count);
|
||||
return prev.slice(0, newLength);
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllModals = () => {
|
||||
setModalStack([]);
|
||||
};
|
||||
|
||||
const setModalState = (modalType: string, state: any) => {
|
||||
setModalStates((prev) => ({
|
||||
...prev,
|
||||
[modalType]: state,
|
||||
}));
|
||||
};
|
||||
|
||||
const getModalState = (modalType: string) => {
|
||||
return modalStates[modalType];
|
||||
};
|
||||
|
||||
const clearModalState = (modalType: string) => {
|
||||
setModalStates((prev) => {
|
||||
const newStates = { ...prev };
|
||||
delete newStates[modalType];
|
||||
return newStates;
|
||||
});
|
||||
};
|
||||
|
||||
const clearAllModalStates = () => {
|
||||
setModalStates({});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -46,9 +100,16 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
|
||||
isOpen,
|
||||
openModal,
|
||||
closeModal,
|
||||
closeModals,
|
||||
clearAllModals,
|
||||
modalContentType,
|
||||
entityId,
|
||||
entityLabel,
|
||||
modalStates,
|
||||
setModalState,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
clearAllModalStates,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -45,10 +45,12 @@ export const formatMemberDisplayName = (
|
||||
return localPart.replace(/[_-]/g, ".");
|
||||
};
|
||||
|
||||
export const getAvatarUrl = (imageOrKey: string) => {
|
||||
export const getAvatarUrl = (imageOrKey: string | null) => {
|
||||
if (!imageOrKey) return "";
|
||||
|
||||
if (imageOrKey.startsWith("http://") || imageOrKey.startsWith("https://")) {
|
||||
return imageOrKey;
|
||||
}
|
||||
|
||||
|
||||
return `${env("NEXT_PUBLIC_STORAGE_URL")}/${env("NEXT_PUBLIC_AVATAR_BUCKET_NAME")}/${imageOrKey}`;
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import CircularProgress from "~/components/CircularProgress";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
@@ -7,6 +8,7 @@ const Card = ({
|
||||
title,
|
||||
labels,
|
||||
members,
|
||||
checklists,
|
||||
}: {
|
||||
title: string;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
@@ -15,11 +17,32 @@ const Card = ({
|
||||
email: string;
|
||||
user: { name: string | null; email: string; image: string | null } | null;
|
||||
}[];
|
||||
checklists: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
items: {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
index: number;
|
||||
}[];
|
||||
}[];
|
||||
}) => {
|
||||
const completedItems = checklists.reduce((acc, checklist) => {
|
||||
return acc + checklist.items.filter((item) => item.completed).length;
|
||||
}, 0);
|
||||
|
||||
const totalItems = checklists.reduce((acc, checklist) => {
|
||||
return acc + checklist.items.length;
|
||||
}, 0);
|
||||
|
||||
const progress =
|
||||
totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span>{title}</span>
|
||||
{labels.length || members.length ? (
|
||||
{labels.length || members.length || checklists.length > 0 ? (
|
||||
<div className="mt-2 flex flex-col justify-end">
|
||||
<div className="space-x-0.5">
|
||||
{labels.map((label) => (
|
||||
@@ -29,21 +52,37 @@ const Card = ({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{members.map(({ user, email }) => {
|
||||
const avatarUrl = user?.image
|
||||
? getAvatarUrl(user.image)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
name={user?.name ?? ""}
|
||||
email={user?.email ?? email}
|
||||
imageUrl={avatarUrl}
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{checklists.length > 0 && (
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-600">
|
||||
<CircularProgress
|
||||
progress={progress || 2}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<span className="text-[10px] text-light-900 dark:text-dark-950">
|
||||
{completedItems}/{totalItems}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{members.length > 0 && (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{members.map(({ user, email }) => {
|
||||
const avatarUrl = user?.image
|
||||
? getAvatarUrl(user.image)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
name={user?.name ?? ""}
|
||||
email={user?.email ?? email}
|
||||
imageUrl={avatarUrl}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -14,10 +14,11 @@ import { generateUID } from "@kan/shared/utils";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import Editor from "~/components/Editor";
|
||||
import Editor, { WorkspaceMember } from "~/components/Editor";
|
||||
import Input from "~/components/Input";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModalFormState } from "~/hooks/useModalFormState";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
@@ -45,21 +46,28 @@ export function NewCardForm({
|
||||
queryParams,
|
||||
}: NewCardFormProps) {
|
||||
const { showPopup } = usePopup();
|
||||
const { closeModal, openModal } = useModal();
|
||||
const { closeModal, openModal, modalStates, clearModalState } = useModal();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
// persists the form values
|
||||
const { formState, saveFormState } = useModalFormState<NewCardFormInput>({
|
||||
modalType: "NEW_CARD",
|
||||
initialValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId,
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled: false,
|
||||
position: "start",
|
||||
},
|
||||
resetOnClose: true,
|
||||
});
|
||||
|
||||
const { register, handleSubmit, reset, setValue, watch } =
|
||||
useForm<NewCardFormInput>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId,
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled: false,
|
||||
position: "start",
|
||||
},
|
||||
values: formState,
|
||||
});
|
||||
|
||||
const labelPublicIds = watch("labelPublicIds") || [];
|
||||
@@ -67,11 +75,49 @@ export function NewCardForm({
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
const position = watch("position");
|
||||
const title = watch("title");
|
||||
const description = watch("description");
|
||||
|
||||
// saving form state whenever form values change
|
||||
useEffect(() => {
|
||||
const subscription = watch((data) => {
|
||||
saveFormState(data as NewCardFormInput);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, saveFormState]);
|
||||
|
||||
|
||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardPublicId,
|
||||
});
|
||||
|
||||
// this adds the new created label to selected labels
|
||||
useEffect(() => {
|
||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
||||
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
||||
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
||||
}
|
||||
}, [modalStates, labelPublicIds]);
|
||||
|
||||
// this removes the deleted label from selected labels if it is selected
|
||||
useEffect(() => {
|
||||
if (boardData?.labels) {
|
||||
const availableLabelIds = boardData.labels.map(label => label.publicId);
|
||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
||||
|
||||
if (newLabelId && availableLabelIds.includes(newLabelId)) {
|
||||
clearModalState("NEW_LABEL_CREATED");
|
||||
}
|
||||
|
||||
const validLabelIds = labelPublicIds.filter(id =>
|
||||
availableLabelIds.includes(id) || id === newLabelId
|
||||
);
|
||||
|
||||
if (validLabelIds.length !== labelPublicIds.length) {
|
||||
setValue("labelPublicIds", validLabelIds);
|
||||
}
|
||||
}
|
||||
}, [boardData?.labels, labelPublicIds, modalStates["NEW_LABEL_CREATED"]]);
|
||||
|
||||
const createCard = api.card.create.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
@@ -131,17 +177,24 @@ export function NewCardForm({
|
||||
},
|
||||
onSuccess: async () => {
|
||||
const isCreateAnotherEnabled = watch("isCreateAnotherEnabled");
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
if (!isCreateAnotherEnabled) {
|
||||
// close modal (state will auto-clear due to resetOnClose: true)
|
||||
closeModal();
|
||||
} else {
|
||||
// reset form for creating another card
|
||||
const newFormState = {
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId: watch("listPublicId"),
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
};
|
||||
reset(newFormState);
|
||||
saveFormState(newFormState);
|
||||
}
|
||||
await utils.board.byId.invalidate(queryParams);
|
||||
reset({
|
||||
title: "",
|
||||
description: "",
|
||||
listPublicId: watch("listPublicId"),
|
||||
labelPublicIds: [],
|
||||
memberPublicIds: [],
|
||||
isCreateAnotherEnabled,
|
||||
position,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -264,8 +317,22 @@ export function NewCardForm({
|
||||
<div className="mt-2">
|
||||
<div className="block max-h-48 min-h-24 w-full overflow-y-auto rounded-md border-0 bg-dark-300 bg-white/5 px-3 py-2 text-sm shadow-sm ring-1 ring-inset ring-light-600 focus-within:ring-2 focus-within:ring-inset focus-within:ring-light-700 dark:ring-dark-700 dark:focus-within:ring-dark-700 sm:leading-6">
|
||||
<Editor
|
||||
content=""
|
||||
onChange={(value) => setValue("description", value)}
|
||||
content={description}
|
||||
onChange={(value) => {
|
||||
setValue("description", value);
|
||||
saveFormState({ ...formState, description: value });
|
||||
}}
|
||||
workspaceMembers={
|
||||
boardData?.workspace.members?.map((member): WorkspaceMember => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user ? {
|
||||
id: member.publicId,
|
||||
name: member.user.name,
|
||||
image: member.user.image ?? null,
|
||||
} : null,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,12 @@ export function NewListForm({
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button type="submit">{t`Create list`}</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createList.isPending || !watch("name")}
|
||||
>
|
||||
{t`Create list`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -167,7 +167,8 @@ export function UpdateBoardSlugForm({
|
||||
!isDirty ||
|
||||
updateBoardSlug.isPending ||
|
||||
errors.slug?.message !== undefined ||
|
||||
isBoardSlugAvailable?.isReserved
|
||||
isBoardSlugAvailable?.isReserved ||
|
||||
checkBoardSlugAvailability.isLoading
|
||||
}
|
||||
>
|
||||
{t`Update`}
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { DeleteLabelConfirmation } from "~/components/DeleteLabelConfirmation";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
import { LabelForm } from "~/components/LabelForm";
|
||||
import Modal from "~/components/modal";
|
||||
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
|
||||
@@ -45,7 +44,7 @@ export default function BoardPage() {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal, modalContentType, entityId } = useModal();
|
||||
const { openModal, modalContentType, entityId, isOpen } = useModal();
|
||||
const [selectedPublicListId, setSelectedPublicListId] =
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
@@ -235,6 +234,97 @@ export default function BoardPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const renderModalContent = () => {
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
||||
>
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_LIST"}
|
||||
>
|
||||
<DeleteListConfirmation
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
||||
>
|
||||
<NewCardForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_LIST"}
|
||||
>
|
||||
<NewListForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_LABEL"}
|
||||
>
|
||||
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
|
||||
>
|
||||
<LabelForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
refetch={refetchBoard}
|
||||
isEdit
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
|
||||
>
|
||||
<DeleteLabelConfirmation
|
||||
refetch={refetchBoard}
|
||||
labelPublicId={entityId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPDATE_BOARD_SLUG"}
|
||||
>
|
||||
<UpdateBoardSlugForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
@@ -285,7 +375,7 @@ export default function BoardPage() {
|
||||
/>
|
||||
<Filters
|
||||
labels={boardData?.labels ?? []}
|
||||
members={boardData?.workspace.members ?? []}
|
||||
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
|
||||
position="left"
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
@@ -401,6 +491,7 @@ export default function BoardPage() {
|
||||
title={card.title}
|
||||
labels={card.labels}
|
||||
members={card.members}
|
||||
checklists={card.checklists ?? []}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
@@ -422,63 +513,7 @@ export default function BoardPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Modal
|
||||
modalSize={
|
||||
modalContentType === "NEW_CARD" ||
|
||||
modalContentType === "NEW_FEEDBACK"
|
||||
? "md"
|
||||
: "sm"
|
||||
}
|
||||
>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "DELETE_BOARD" && (
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
)}
|
||||
{modalContentType === "DELETE_LIST" && (
|
||||
<DeleteListConfirmation
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "NEW_CARD" && (
|
||||
<NewCardForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "NEW_LIST" && (
|
||||
<NewListForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
{modalContentType === "NEW_LABEL" && (
|
||||
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchBoard} />
|
||||
)}
|
||||
{modalContentType === "EDIT_LABEL" && (
|
||||
<LabelForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
refetch={refetchBoard}
|
||||
isEdit
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "DELETE_LABEL" && (
|
||||
<DeleteLabelConfirmation
|
||||
refetch={refetchBoard}
|
||||
labelPublicId={entityId}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "UPDATE_BOARD_SLUG" && (
|
||||
<UpdateBoardSlugForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
{renderModalContent()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||
import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
export default function BoardsPage() {
|
||||
const { openModal, modalContentType } = useModal();
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { workspace, hasLoaded } = useWorkspace();
|
||||
|
||||
if (hasLoaded && !workspace.publicId) openModal("NEW_WORKSPACE");
|
||||
@@ -50,12 +50,35 @@ export default function BoardsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_BOARD" && <NewBoardForm />}
|
||||
{modalContentType === "IMPORT_BOARDS" && <ImportBoardsForm />}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
</Modal>
|
||||
<>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
||||
>
|
||||
<NewBoardForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "IMPORT_BOARDS"}
|
||||
>
|
||||
<ImportBoardsForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
</>
|
||||
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList />
|
||||
|
||||
@@ -5,9 +5,11 @@ import { de, enGB, es, fr, it, nl } from "date-fns/locale";
|
||||
import {
|
||||
HiOutlineArrowLeft,
|
||||
HiOutlineArrowRight,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlinePencil,
|
||||
HiOutlinePlus,
|
||||
HiOutlineTag,
|
||||
HiOutlineTrash,
|
||||
HiOutlineUserMinus,
|
||||
HiOutlineUserPlus,
|
||||
} from "react-icons/hi2";
|
||||
@@ -31,6 +33,11 @@ const dateLocaleMap = {
|
||||
nl: nl,
|
||||
} as const;
|
||||
|
||||
const truncate = (value: string | null, maxLength = 50) => {
|
||||
if (!value) return value;
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}…` : value;
|
||||
};
|
||||
|
||||
const getActivityText = ({
|
||||
type,
|
||||
toTitle,
|
||||
@@ -39,6 +46,7 @@ const getActivityText = ({
|
||||
memberName,
|
||||
isSelf,
|
||||
label,
|
||||
fromTitle,
|
||||
}: {
|
||||
type: ActivityType;
|
||||
toTitle: string | null;
|
||||
@@ -47,6 +55,7 @@ const getActivityText = ({
|
||||
memberName: string | null;
|
||||
isSelf: boolean;
|
||||
label: string | null;
|
||||
fromTitle?: string | null;
|
||||
}) => {
|
||||
const ACTIVITY_TYPE_MAP = {
|
||||
"card.created": t`created the card`,
|
||||
@@ -57,6 +66,14 @@ const getActivityText = ({
|
||||
"card.updated.label.removed": t`removed a label from the card`,
|
||||
"card.updated.member.added": t`added a member to the card`,
|
||||
"card.updated.member.removed": t`removed a member from the card`,
|
||||
"card.updated.checklist.added": t`added a checklist`,
|
||||
"card.updated.checklist.renamed": t`renamed a checklist`,
|
||||
"card.updated.checklist.deleted": t`deleted a checklist`,
|
||||
"card.updated.checklist.item.added": t`added a checklist item`,
|
||||
"card.updated.checklist.item.updated": t`updated a checklist item`,
|
||||
"card.updated.checklist.item.completed": t`completed a checklist item`,
|
||||
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
|
||||
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
|
||||
} as const;
|
||||
|
||||
if (!(type in ACTIVITY_TYPE_MAP)) return null;
|
||||
@@ -71,7 +88,7 @@ const getActivityText = ({
|
||||
if (type === "card.updated.title" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
updated the title to <TextHighlight>{toTitle}</TextHighlight>
|
||||
updated the title to <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -79,8 +96,9 @@ const getActivityText = ({
|
||||
if (type === "card.updated.list" && fromList && toList) {
|
||||
return (
|
||||
<Trans>
|
||||
moved the card from <TextHighlight>{fromList}</TextHighlight> to
|
||||
<TextHighlight>{toList}</TextHighlight>
|
||||
moved the card from <TextHighlight>{truncate(fromList)}</TextHighlight>{" "}
|
||||
to
|
||||
<TextHighlight>{truncate(toList)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -90,7 +108,8 @@ const getActivityText = ({
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
assigned <TextHighlight>{memberName}</TextHighlight> to the card
|
||||
assigned <TextHighlight>{truncate(memberName)}</TextHighlight> to the
|
||||
card
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -100,7 +119,8 @@ const getActivityText = ({
|
||||
|
||||
return (
|
||||
<Trans>
|
||||
unassigned <TextHighlight>{memberName}</TextHighlight> from the card
|
||||
unassigned <TextHighlight>{truncate(memberName)}</TextHighlight> from
|
||||
the card
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -108,7 +128,7 @@ const getActivityText = ({
|
||||
if (type === "card.updated.label.added" && label) {
|
||||
return (
|
||||
<Trans>
|
||||
added label <TextHighlight>{label}</TextHighlight>
|
||||
added label <TextHighlight>{truncate(label)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -116,7 +136,75 @@ const getActivityText = ({
|
||||
if (type === "card.updated.label.removed" && label) {
|
||||
return (
|
||||
<Trans>
|
||||
removed label <TextHighlight>{label}</TextHighlight>
|
||||
removed label <TextHighlight>{truncate(label)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.added" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
added checklist <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.renamed" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
renamed checklist <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.deleted" && fromTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
deleted checklist <TextHighlight>{truncate(fromTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.added" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
added checklist item <TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.updated" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
renamed checklist item to{" "}
|
||||
<TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.completed" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
completed checklist item{" "}
|
||||
<TextHighlight>{truncate(toTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.uncompleted" && toTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
marked checklist item <TextHighlight>{truncate(toTitle)}</TextHighlight>{" "}
|
||||
as incomplete
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "card.updated.checklist.item.deleted" && fromTitle) {
|
||||
return (
|
||||
<Trans>
|
||||
deleted checklist item{" "}
|
||||
<TextHighlight>{truncate(fromTitle)}</TextHighlight>
|
||||
</Trans>
|
||||
);
|
||||
}
|
||||
@@ -133,6 +221,14 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
|
||||
"card.updated.label.removed": <HiOutlineTag />,
|
||||
"card.updated.member.added": <HiOutlineUserPlus />,
|
||||
"card.updated.member.removed": <HiOutlineUserMinus />,
|
||||
"card.updated.checklist.added": <HiOutlinePlus />,
|
||||
"card.updated.checklist.renamed": <HiOutlinePencil />,
|
||||
"card.updated.checklist.deleted": <HiOutlineTrash />,
|
||||
"card.updated.checklist.item.added": <HiOutlinePlus />,
|
||||
"card.updated.checklist.item.updated": <HiOutlinePencil />,
|
||||
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
|
||||
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
|
||||
} as const;
|
||||
|
||||
const getActivityIcon = (
|
||||
@@ -155,11 +251,13 @@ const ActivityList = ({
|
||||
cardPublicId,
|
||||
isLoading,
|
||||
isAdmin,
|
||||
isViewOnly,
|
||||
}: {
|
||||
activities: NonNullable<GetCardByIdOutput>["activities"];
|
||||
cardPublicId: string;
|
||||
isLoading: boolean;
|
||||
isAdmin?: boolean;
|
||||
isViewOnly?: boolean;
|
||||
}) => {
|
||||
const { data } = authClient.useSession();
|
||||
const { locale } = useLocalisation();
|
||||
@@ -177,6 +275,7 @@ const ActivityList = ({
|
||||
memberName: activity.member?.user?.name ?? null,
|
||||
isSelf: activity.member?.user?.id === data?.user.id,
|
||||
label: activity.label?.name ?? null,
|
||||
fromTitle: activity.fromTitle ?? null,
|
||||
});
|
||||
|
||||
if (activity.type === "card.updated.comment.added")
|
||||
@@ -193,6 +292,7 @@ const ActivityList = ({
|
||||
isEdited={!!activity.comment?.updatedAt}
|
||||
isAuthor={activity.comment?.createdBy === data?.user.id}
|
||||
isAdmin={isAdmin ?? false}
|
||||
isViewOnly={!!isViewOnly}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
197
apps/web/src/views/card/components/ChecklistItemRow.tsx
Normal file
197
apps/web/src/views/card/components/ChecklistItemRow.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface ChecklistItemRowProps {
|
||||
item: {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
};
|
||||
cardPublicId: string;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function ChecklistItemRow({
|
||||
item,
|
||||
cardPublicId,
|
||||
viewOnly = false,
|
||||
}: ChecklistItemRowProps) {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const [title, setTitle] = useState("");
|
||||
const [completed, setCompleted] = useState(false);
|
||||
|
||||
const updateItem = api.checklist.updateItem.useMutation({
|
||||
onMutate: async (vars) => {
|
||||
await utils.card.byId.cancel({ cardPublicId });
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old as any;
|
||||
const updatedChecklists = old.checklists.map((cl) => ({
|
||||
...cl,
|
||||
items: cl.items.map((ci) =>
|
||||
ci.publicId === item.publicId
|
||||
? {
|
||||
...ci,
|
||||
...(vars.title !== undefined ? { title: vars.title } : {}),
|
||||
...(vars.completed !== undefined
|
||||
? { completed: vars.completed }
|
||||
: {}),
|
||||
}
|
||||
: ci,
|
||||
),
|
||||
}));
|
||||
return { ...old, checklists: updatedChecklists } as typeof old;
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
showPopup({
|
||||
header: t`Unable to update checklist item`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
const deleteItem = api.checklist.deleteItem.useMutation({
|
||||
onMutate: async () => {
|
||||
await utils.card.byId.cancel({ cardPublicId });
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old as any;
|
||||
const updatedChecklists = old.checklists.map((cl) => ({
|
||||
...cl,
|
||||
items: cl.items.filter((ci) => ci.publicId !== item.publicId),
|
||||
}));
|
||||
return { ...old, checklists: updatedChecklists } as typeof old;
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
showPopup({
|
||||
header: t`Unable to delete checklist item`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
// Only resync from props when switching items to avoid clobbering edits
|
||||
useEffect(() => {
|
||||
setTitle(item.title);
|
||||
setCompleted(item.completed);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [item.publicId]);
|
||||
|
||||
const sanitizeHtmlToPlainText = (html: string): string =>
|
||||
html
|
||||
.replace(/<br\s*\/?>(\n)?/gi, "\n")
|
||||
.replace(/<div><br\s*\/?><\/div>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
|
||||
const handleToggleCompleted = () => {
|
||||
if (viewOnly) return;
|
||||
setCompleted((prev) => !prev);
|
||||
updateItem.mutate({
|
||||
checklistItemPublicId: item.publicId,
|
||||
completed: !completed,
|
||||
});
|
||||
};
|
||||
|
||||
const commitTitle = (rawHtml: string) => {
|
||||
if (viewOnly) return;
|
||||
const plain = sanitizeHtmlToPlainText(rawHtml);
|
||||
if (!plain || plain === item.title) {
|
||||
setTitle(item.title);
|
||||
return;
|
||||
}
|
||||
setTitle(plain);
|
||||
updateItem.mutate({
|
||||
checklistItemPublicId: item.publicId,
|
||||
title: plain,
|
||||
});
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (viewOnly) return;
|
||||
deleteItem.mutate({ checklistItemPublicId: item.publicId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
|
||||
<label
|
||||
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={completed}
|
||||
onChange={(e) => {
|
||||
if (viewOnly) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
handleToggleCompleted();
|
||||
}}
|
||||
className={twMerge(
|
||||
"h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 checked:bg-blue-600 focus:shadow-none focus:ring-0 focus:ring-offset-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500",
|
||||
viewOnly ? "cursor-default" : "cursor-pointer",
|
||||
)}
|
||||
/>
|
||||
</label>
|
||||
<div className="flex-1 pr-7">
|
||||
<ContentEditable
|
||||
html={title}
|
||||
disabled={viewOnly}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
// @ts-expect-error - valid event
|
||||
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)}
|
||||
className={twMerge(
|
||||
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
|
||||
viewOnly && "cursor-default",
|
||||
)}
|
||||
placeholder={t`Add details...`}
|
||||
onKeyDown={(e) => {
|
||||
if (viewOnly) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commitTitle(title);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setTitle(item.title);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!viewOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className="absolute right-1 top-1/2 hidden -translate-y-1/2 rounded-md p-1 text-light-900 group-hover:block hover:bg-light-200 dark:text-dark-700 dark:hover:bg-dark-200"
|
||||
>
|
||||
<HiXMark size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
90
apps/web/src/views/card/components/ChecklistNameInput.tsx
Normal file
90
apps/web/src/views/card/components/ChecklistNameInput.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function ChecklistNameInput({
|
||||
checklistPublicId,
|
||||
initialName,
|
||||
cardPublicId,
|
||||
viewOnly = false,
|
||||
}: {
|
||||
checklistPublicId: string;
|
||||
initialName: string;
|
||||
cardPublicId: string;
|
||||
viewOnly?: boolean;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const [name, setName] = useState(initialName);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setName(initialName);
|
||||
}, [initialName, checklistPublicId]);
|
||||
|
||||
const update = api.checklist.update.useMutation({
|
||||
onMutate: async (vars) => {
|
||||
await utils.card.byId.cancel({ cardPublicId });
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old as any;
|
||||
const updated = old.checklists.map((cl) =>
|
||||
cl.publicId === checklistPublicId ? { ...cl, name: vars.name } : cl,
|
||||
);
|
||||
return { ...old, checklists: updated } as typeof old;
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
showPopup({
|
||||
header: t`Unable to update checklist`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
const commit = () => {
|
||||
if (viewOnly) return;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed || trimmed === initialName) return;
|
||||
update.mutate({ checklistPublicId, name: trimmed });
|
||||
};
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={name}
|
||||
readOnly={viewOnly}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (viewOnly) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
setName(initialName);
|
||||
inputRef.current?.blur();
|
||||
}
|
||||
}}
|
||||
title={name}
|
||||
className={twMerge(
|
||||
"text-md block w-full truncate border-0 bg-transparent p-0 py-0 font-medium text-light-1000 outline-none focus:ring-0 dark:text-dark-1000",
|
||||
viewOnly && "cursor-default",
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
142
apps/web/src/views/card/components/Checklists.tsx
Normal file
142
apps/web/src/views/card/components/Checklists.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { HiPlus, HiXMark } from "react-icons/hi2";
|
||||
|
||||
import CircularProgress from "~/components/CircularProgress";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import ChecklistItemRow from "./ChecklistItemRow";
|
||||
import ChecklistNameInput from "./ChecklistNameInput";
|
||||
import NewChecklistItemForm from "./NewChecklistItemForm";
|
||||
|
||||
interface ChecklistItem {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
}
|
||||
|
||||
interface Checklist {
|
||||
publicId: string;
|
||||
name: string;
|
||||
items: ChecklistItem[];
|
||||
}
|
||||
|
||||
interface ChecklistsProps {
|
||||
checklists: Checklist[];
|
||||
cardPublicId: string;
|
||||
activeChecklistForm?: string | null;
|
||||
setActiveChecklistForm?: (id: string | null) => void;
|
||||
viewOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function Checklists({
|
||||
checklists,
|
||||
cardPublicId,
|
||||
activeChecklistForm,
|
||||
setActiveChecklistForm,
|
||||
viewOnly = false,
|
||||
}: ChecklistsProps) {
|
||||
const { openModal } = useModal();
|
||||
|
||||
if (!checklists || checklists.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="border-light-300 pb-4 dark:border-dark-300">
|
||||
<div>
|
||||
{checklists.map((checklist) => {
|
||||
const completedItems = checklist.items.filter(
|
||||
(item) => item.completed,
|
||||
);
|
||||
const progress =
|
||||
checklist.items.length > 0 && completedItems.length > 0
|
||||
? (completedItems.length / checklist.items.length) * 100
|
||||
: 2;
|
||||
|
||||
return (
|
||||
<div key={checklist.publicId} className="mb-4">
|
||||
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChecklistNameInput
|
||||
checklistPublicId={checklist.publicId}
|
||||
initialName={checklist.name}
|
||||
cardPublicId={cardPublicId}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
{!viewOnly && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||
<CircularProgress
|
||||
progress={progress}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||
{completedItems.length}/{checklist.items.length}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||
onClick={() =>
|
||||
openModal("DELETE_CHECKLIST", checklist.publicId)
|
||||
}
|
||||
>
|
||||
<HiXMark size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setActiveChecklistForm?.(checklist.publicId)
|
||||
}
|
||||
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
|
||||
>
|
||||
<HiPlus size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewOnly && (
|
||||
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
|
||||
<CircularProgress
|
||||
progress={progress}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[11px] text-light-900 dark:text-dark-700">
|
||||
{completedItems.length}/{checklist.items.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="ml-1">
|
||||
{checklist.items.map((item) => (
|
||||
<ChecklistItemRow
|
||||
key={item.publicId}
|
||||
item={{
|
||||
publicId: item.publicId,
|
||||
title: item.title,
|
||||
completed: item.completed,
|
||||
}}
|
||||
cardPublicId={cardPublicId}
|
||||
viewOnly={viewOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{activeChecklistForm === checklist.publicId && !viewOnly && (
|
||||
<div className="ml-1">
|
||||
<NewChecklistItemForm
|
||||
checklistPublicId={checklist.publicId}
|
||||
cardPublicId={cardPublicId}
|
||||
onCancel={() => setActiveChecklistForm?.(null)}
|
||||
readOnly={viewOnly}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const Comment = ({
|
||||
isAuthor,
|
||||
isAdmin,
|
||||
isEdited = false,
|
||||
isViewOnly = false,
|
||||
}: {
|
||||
publicId: string | undefined;
|
||||
cardPublicId: string;
|
||||
@@ -38,6 +39,7 @@ const Comment = ({
|
||||
isAuthor: boolean;
|
||||
isAdmin: boolean;
|
||||
isEdited: boolean;
|
||||
isViewOnly: boolean;
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const utils = api.useUtils();
|
||||
@@ -124,7 +126,7 @@ const Comment = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{dropdownItems.length > 0 && (
|
||||
{dropdownItems.length > 0 && !isViewOnly && (
|
||||
<div className="absolute right-4 top-4">
|
||||
<Dropdown items={dropdownItems}>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-light-900 dark:text-dark-800" />
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteChecklistConfirmation({
|
||||
cardPublicId,
|
||||
checklistPublicId,
|
||||
}: {
|
||||
cardPublicId: string;
|
||||
checklistPublicId: string;
|
||||
}) {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const deleteChecklist = api.checklist.delete.useMutation({
|
||||
onMutate: async () => {
|
||||
await utils.card.byId.cancel({ cardPublicId });
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old as any;
|
||||
const updatedChecklists = old.checklists.filter(
|
||||
(cl) => cl.publicId !== checklistPublicId,
|
||||
);
|
||||
return { ...old, checklists: updatedChecklists } as typeof old;
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
showPopup({
|
||||
header: t`Unable to delete checklist`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
closeModal();
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
const handleDelete = () => {
|
||||
deleteChecklist.mutate({ checklistPublicId });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Are you sure you want to delete this checklist?`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`This action can't be undone.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button variant="secondary" onClick={() => closeModal()}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleDelete} isLoading={deleteChecklist.isPending}>
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiOutlineTrash } from "react-icons/hi2";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiOutlineCheckCircle,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -10,6 +14,13 @@ export default function BoardDropdown() {
|
||||
return (
|
||||
<Dropdown
|
||||
items={[
|
||||
{
|
||||
label: t`Add checklist`,
|
||||
action: () => openModal("ADD_CHECKLIST"),
|
||||
icon: (
|
||||
<HiOutlineCheckCircle className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Delete card`,
|
||||
action: () => openModal("DELETE_CARD"),
|
||||
|
||||
140
apps/web/src/views/card/components/NewChecklistForm.tsx
Normal file
140
apps/web/src/views/card/components/NewChecklistForm.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface NewChecklistFormInput {
|
||||
name: string;
|
||||
cardPublicId: string;
|
||||
}
|
||||
|
||||
export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
|
||||
const { closeModal, setModalState } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const utils = api.useUtils();
|
||||
|
||||
const { register, handleSubmit, reset, watch } =
|
||||
useForm<NewChecklistFormInput>({
|
||||
defaultValues: {
|
||||
name: "Checklist",
|
||||
cardPublicId,
|
||||
},
|
||||
});
|
||||
|
||||
const createChecklist = api.checklist.create.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.card.byId.cancel({ cardPublicId: args.cardPublicId });
|
||||
const previous = utils.card.byId.getData({
|
||||
cardPublicId: args.cardPublicId,
|
||||
});
|
||||
utils.card.byId.setData({ cardPublicId: args.cardPublicId }, (old) => {
|
||||
if (!old) return old as any;
|
||||
const placeholderChecklist = {
|
||||
publicId: `PLACEHOLDER_${generateUID()}`,
|
||||
name: args.name,
|
||||
index: old.checklists.length,
|
||||
items: [] as {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
index: number;
|
||||
}[],
|
||||
};
|
||||
return {
|
||||
...old,
|
||||
checklists: [...old.checklists, placeholderChecklist],
|
||||
} as typeof old;
|
||||
});
|
||||
return { previous };
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
setModalState("ADD_CHECKLIST", { createdChecklistId: data.publicId });
|
||||
},
|
||||
onError: (_error, vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData(
|
||||
{ cardPublicId: vars.cardPublicId },
|
||||
ctx.previous,
|
||||
);
|
||||
showPopup({
|
||||
header: t`Unable to create checklist`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async (_data, _error, vars) => {
|
||||
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const nameElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#checklist-name");
|
||||
if (nameElement) nameElement.focus();
|
||||
}, []);
|
||||
|
||||
const onSubmit = (data: NewChecklistFormInput) => {
|
||||
closeModal();
|
||||
reset({
|
||||
name: "",
|
||||
});
|
||||
|
||||
createChecklist.mutate({
|
||||
name: data.name,
|
||||
cardPublicId: data.cardPublicId,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="flex w-full items-center justify-between pb-4">
|
||||
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`New checklist`}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
id="checklist-name"
|
||||
placeholder={t`Checklist name`}
|
||||
{...register("name")}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createChecklist.isPending || !watch("name")}
|
||||
>
|
||||
{t`Create checklist`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
167
apps/web/src/views/card/components/NewChecklistItemForm.tsx
Normal file
167
apps/web/src/views/card/components/NewChecklistItemForm.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef } from "react";
|
||||
import ContentEditable from "react-contenteditable";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface FormValues {
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface NewChecklistItemFormProps {
|
||||
checklistPublicId: string;
|
||||
cardPublicId: string;
|
||||
onCancel: () => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
const NewChecklistItemForm = ({
|
||||
checklistPublicId,
|
||||
cardPublicId,
|
||||
onCancel,
|
||||
readOnly = false,
|
||||
}: NewChecklistItemFormProps) => {
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { setValue, watch, reset, getValues } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
},
|
||||
});
|
||||
|
||||
const title = watch("title");
|
||||
|
||||
const editableRef = useRef<HTMLElement | null>(null);
|
||||
const keepOpenRef = useRef(false);
|
||||
|
||||
const refocusEditable = () => {
|
||||
const el = editableRef.current;
|
||||
if (!el) return;
|
||||
if (readOnly) return;
|
||||
el.focus();
|
||||
|
||||
// hack to ensure the input is focused after creating new checklist item
|
||||
setTimeout(() => {
|
||||
el.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const addChecklistItemMutation = api.checklist.createItem.useMutation({
|
||||
onMutate: async (vars) => {
|
||||
await utils.card.byId.cancel({ cardPublicId });
|
||||
const previous = utils.card.byId.getData({ cardPublicId });
|
||||
|
||||
utils.card.byId.setData({ cardPublicId }, (old) => {
|
||||
if (!old) return old as any;
|
||||
const placeholder = {
|
||||
publicId: `PLACEHOLDER_${generateUID()}`,
|
||||
title: vars.title,
|
||||
completed: false,
|
||||
};
|
||||
const updatedChecklists = old.checklists.map((cl) =>
|
||||
cl.publicId === checklistPublicId
|
||||
? { ...cl, items: [...cl.items, placeholder] }
|
||||
: cl,
|
||||
);
|
||||
return { ...old, checklists: updatedChecklists } as typeof old;
|
||||
});
|
||||
|
||||
if (keepOpenRef.current) {
|
||||
reset({ title: "" });
|
||||
if (editableRef.current) editableRef.current.innerHTML = "";
|
||||
refocusEditable();
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
|
||||
return { previous };
|
||||
},
|
||||
onError: (_err, _vars, ctx) => {
|
||||
if (ctx?.previous)
|
||||
utils.card.byId.setData({ cardPublicId }, ctx.previous);
|
||||
showPopup({
|
||||
header: t`Unable to add checklist item`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
const sanitizeHtmlToPlainText = (html: string): string => {
|
||||
return html
|
||||
.replace(/<br\s*\/?>(\n)?/gi, "\n")
|
||||
.replace(/<div><br\s*\/?><\/div>/gi, "")
|
||||
.replace(/<[^>]*>/g, "")
|
||||
.replace(/ /g, " ")
|
||||
.trim();
|
||||
};
|
||||
|
||||
const submitIfNotEmpty = (keepOpen: boolean) => {
|
||||
if (readOnly) return;
|
||||
keepOpenRef.current = keepOpen;
|
||||
const currentHtml = getValues("title") ?? "";
|
||||
const plain = sanitizeHtmlToPlainText(currentHtml);
|
||||
if (!plain) {
|
||||
onCancel();
|
||||
return;
|
||||
}
|
||||
addChecklistItemMutation.mutate({
|
||||
checklistPublicId,
|
||||
title: plain,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refocusEditable();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={(e) => e.preventDefault()}>
|
||||
<div className="group relative flex h-9 items-center gap-3 rounded-md pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
|
||||
<label className="relative inline-flex h-[16px] w-[16px] flex-shrink-0 cursor-default items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
disabled
|
||||
className="peer h-[16px] w-[16px] appearance-none rounded-md border border-light-500 bg-transparent outline-none ring-0 hover:border-light-500 hover:bg-transparent focus:outline-none focus:ring-0 focus-visible:outline-none dark:border-dark-500 dark:hover:border-dark-500"
|
||||
/>
|
||||
</label>
|
||||
<div className="flex-1 pr-7">
|
||||
<ContentEditable
|
||||
id={`checklist-item-input-${checklistPublicId}`}
|
||||
tabIndex={readOnly ? -1 : 0}
|
||||
placeholder={t`Add an item...`}
|
||||
html={title}
|
||||
disabled={readOnly}
|
||||
onChange={(e) => setValue("title", e.target.value)}
|
||||
className="m-0 min-h-[20px] w-full p-0 text-sm leading-5 text-light-900 outline-none focus-visible:outline-none dark:text-dark-950"
|
||||
onBlur={() => submitIfNotEmpty(false)}
|
||||
onKeyDown={async (e) => {
|
||||
if (readOnly) return;
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitIfNotEmpty(true);
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onCancel();
|
||||
}
|
||||
}}
|
||||
innerRef={(el) => {
|
||||
editableRef.current = (el as unknown as HTMLElement) ?? null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewChecklistItemForm;
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { IoChevronForwardSharp } from "react-icons/io5";
|
||||
|
||||
@@ -19,12 +20,15 @@ import { api } from "~/utils/api";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
||||
import ActivityList from "./components/ActivityList";
|
||||
import Checklists from "./components/Checklists";
|
||||
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
||||
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
||||
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
|
||||
import Dropdown from "./components/Dropdown";
|
||||
import LabelSelector from "./components/LabelSelector";
|
||||
import ListSelector from "./components/ListSelector";
|
||||
import MemberSelector from "./components/MemberSelector";
|
||||
import { NewChecklistForm } from "./components/NewChecklistForm";
|
||||
import NewCommentForm from "./components/NewCommentForm";
|
||||
|
||||
interface FormValues {
|
||||
@@ -132,9 +136,19 @@ export function CardRightPanel() {
|
||||
export default function CardPage() {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { modalContentType, entityId } = useModal();
|
||||
const {
|
||||
modalContentType,
|
||||
entityId,
|
||||
openModal,
|
||||
getModalState,
|
||||
clearModalState,
|
||||
isOpen,
|
||||
} = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const [activeChecklistForm, setActiveChecklistForm] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
@@ -181,6 +195,17 @@ export default function CardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// Open the new item form after creating a new checklist
|
||||
useEffect(() => {
|
||||
if (!card) return;
|
||||
const state = getModalState("ADD_CHECKLIST");
|
||||
const createdId: string | undefined = state?.createdChecklistId;
|
||||
if (createdId) {
|
||||
setActiveChecklistForm(createdId);
|
||||
clearModalState("ADD_CHECKLIST");
|
||||
}
|
||||
}, [card, getModalState, clearModalState]);
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
return (
|
||||
@@ -188,122 +213,179 @@ export default function CardPage() {
|
||||
<PageHead
|
||||
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
|
||||
/>
|
||||
<div className="flex h-full flex-1 flex-row">
|
||||
<div className="m-auto flex h-full w-full max-w-[800px] flex-col overflow-hidden">
|
||||
<div className="h-full max-h-[calc(100dvh-3rem)] overflow-y-auto p-6 md:max-h-[calc(100dvh-4rem)] md:p-8">
|
||||
<div className="mb-8 flex w-full items-center justify-between md:mt-6">
|
||||
{!card && isLoading && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex h-full flex-1 flex-row overflow-hidden">
|
||||
<div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300">
|
||||
<div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-8 flex w-full items-center justify-between md:mt-6">
|
||||
{!card && isLoading && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
)}
|
||||
{card && (
|
||||
<>
|
||||
<Link
|
||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||
href={`/boards/${board?.publicId}`}
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
<IoChevronForwardSharp
|
||||
size={18}
|
||||
className="mx-2 text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex">
|
||||
<Dropdown />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!card && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Card not found`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card && (
|
||||
<>
|
||||
<Link
|
||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||
href={`/boards/${board?.publicId}`}
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
<IoChevronForwardSharp
|
||||
size={18}
|
||||
className="mx-2 text-light-900 dark:text-dark-900"
|
||||
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
workspaceMembers={board?.workspace.members ?? []}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Checklists
|
||||
checklists={card.checklists}
|
||||
cardPublicId={cardId}
|
||||
activeChecklistForm={activeChecklistForm}
|
||||
setActiveChecklistForm={setActiveChecklistForm}
|
||||
/>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
|
||||
{t`Activity`}
|
||||
</h2>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={!card}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex">
|
||||
<Dropdown />
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!card && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Card not found`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{card && (
|
||||
<>
|
||||
<div className="mb-10 flex w-full max-w-2xl flex-col justify-between">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={card.description}
|
||||
onChange={(e) => setValue("description", e)}
|
||||
onBlur={() => handleSubmit(onSubmit)()}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
{t`Activity`}
|
||||
</h2>
|
||||
<div>
|
||||
<ActivityList
|
||||
cardPublicId={cardId}
|
||||
activities={activities ?? []}
|
||||
isLoading={!card}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_LABEL" && (
|
||||
<>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_LABEL"}
|
||||
>
|
||||
<LabelForm boardPublicId={boardId ?? ""} refetch={refetchCard} />
|
||||
)}
|
||||
{modalContentType === "EDIT_LABEL" && (
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "EDIT_LABEL"}
|
||||
>
|
||||
<LabelForm
|
||||
boardPublicId={boardId ?? ""}
|
||||
refetch={refetchCard}
|
||||
isEdit
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "DELETE_LABEL" && (
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_LABEL"}
|
||||
>
|
||||
<DeleteLabelConfirmation
|
||||
refetch={refetchCard}
|
||||
labelPublicId={entityId}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "DELETE_CARD" && (
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_CARD"}
|
||||
>
|
||||
<DeleteCardConfirmation
|
||||
boardPublicId={boardId ?? ""}
|
||||
cardPublicId={cardId}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "DELETE_COMMENT" && (
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_COMMENT"}
|
||||
>
|
||||
<DeleteCommentConfirmation
|
||||
cardPublicId={cardId}
|
||||
commentPublicId={entityId}
|
||||
/>
|
||||
)}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
</Modal>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "ADD_CHECKLIST"}
|
||||
>
|
||||
<NewChecklistForm cardPublicId={cardId} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_CHECKLIST"}
|
||||
>
|
||||
<DeleteChecklistConfirmation
|
||||
cardPublicId={cardId}
|
||||
checklistPublicId={entityId}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useTheme } from "next-themes";
|
||||
import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const isLoggedIn = !!session?.user;
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
const isDarkMode = theme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -92,11 +92,6 @@ const Pricing = () => {
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
|
||||
{/* Beta free pricing pill */}
|
||||
<div className="mt-4 flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800 dark:bg-green-700 dark:text-green-100">
|
||||
{t`All seats are free during the beta until Sept 1st!`}
|
||||
</div>
|
||||
|
||||
<div className="mt-16 flex justify-center">
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
@@ -108,7 +103,12 @@ const Pricing = () => {
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option}
|
||||
className="cursor-pointer rounded-full px-2.5 py-1 text-xs text-light-900 data-[checked]:bg-dark-50 data-[checked]:text-white dark:data-[checked]:bg-light-50 dark:data-[checked]:text-dark-50 lg:text-sm"
|
||||
className={twMerge(
|
||||
"cursor-pointer rounded-full px-2.5 py-1 text-xs transition-colors lg:text-sm",
|
||||
frequency?.value === option.value
|
||||
? "bg-dark-50 text-white dark:bg-light-50 dark:text-dark-50"
|
||||
: "text-light-900 hover:bg-light-100 dark:hover:bg-dark-100",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Radio>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useTheme } from "~/providers/theme";
|
||||
import { useTheme } from "next-themes";
|
||||
import Cta from "./components/Cta";
|
||||
import FAQs from "./components/Faqs";
|
||||
import Features from "./components/Features";
|
||||
@@ -14,9 +14,10 @@ import Layout from "./components/Layout";
|
||||
import Pricing from "./components/Pricing";
|
||||
|
||||
export default function HomeView() {
|
||||
const theme = useTheme();
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
const isDarkMode = theme.activeTheme === "dark";
|
||||
return (
|
||||
<Layout>
|
||||
<PageHead title="Kan.bn | The open source alternative to Trello" />
|
||||
@@ -88,19 +89,27 @@ export default function HomeView() {
|
||||
</div>
|
||||
<div className="px-4">
|
||||
<div className="mb-24 rounded-[16px] border border-light-300 bg-light-50 p-1 shadow-md dark:border-dark-300 dark:bg-dark-100 lg:rounded-[24px] lg:p-2">
|
||||
<div className="overflow-hidden rounded-[12px] border border-light-300 shadow-sm dark:border-dark-300 lg:rounded-[16px]">
|
||||
<div className="relative overflow-hidden rounded-[12px] border border-light-300 shadow-sm dark:border-dark-300 lg:rounded-[16px]">
|
||||
<Image
|
||||
src={`/hero-${isDarkMode ? "dark" : "light"}.png`}
|
||||
src={`/hero-light.png`}
|
||||
alt="kanban"
|
||||
width={1100}
|
||||
height={1000}
|
||||
className="block dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
src={`/hero-dark.png`}
|
||||
alt="kanban"
|
||||
width={1100}
|
||||
height={1000}
|
||||
className="hidden dark:block"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="features" className="absolute -top-20" />
|
||||
<Features theme={theme.activeTheme} />
|
||||
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} />
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="pricing" className="absolute -top-20" />
|
||||
@@ -111,7 +120,7 @@ export default function HomeView() {
|
||||
<FAQs />
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Cta theme={theme.activeTheme} />
|
||||
<Cta theme={resolvedTheme ?? "light"} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
|
||||
@@ -1,40 +1,137 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { InviteMemberInput } from "@kan/api/types";
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function InviteMemberForm() {
|
||||
export function InviteMemberForm({
|
||||
numberOfMembers,
|
||||
subscriptions,
|
||||
unlimitedSeats,
|
||||
userId,
|
||||
}: {
|
||||
numberOfMembers: number;
|
||||
subscriptions: Subscription[] | undefined;
|
||||
unlimitedSeats: boolean;
|
||||
userId: string | undefined;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const { register, handleSubmit } = useForm<InviteMemberInput>({
|
||||
const InviteMemberSchema = z.object({
|
||||
email: z.string().email({ message: t`Invalid email address` }),
|
||||
workspacePublicId: z.string(),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { errors },
|
||||
} = useForm<InviteMemberInput>({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
workspacePublicId: workspace.publicId || "",
|
||||
},
|
||||
resolver: zodResolver(InviteMemberSchema),
|
||||
});
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const createBoard = api.member.invite.useMutation({
|
||||
const inviteMember = api.member.invite.useMutation({
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
await utils.workspace.byId.refetch();
|
||||
await refetchBoards();
|
||||
},
|
||||
onError: (error) => {
|
||||
reset();
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
|
||||
if (error.data?.code === "CONFLICT") {
|
||||
showPopup({
|
||||
header: t`Error inviting member`,
|
||||
message: t`User is already a member of this workspace`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Error inviting member`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: InviteMemberInput) => {
|
||||
createBoard.mutate(data);
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
||||
|
||||
const hasTeamSubscription = !!teamSubscription;
|
||||
const hasProSubscription = !!proSubscription;
|
||||
|
||||
let isYearly = false;
|
||||
let price = t`$10/month`;
|
||||
let billingType = t`monthly billing`;
|
||||
|
||||
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
|
||||
const periodStartDate = new Date(teamSubscription.periodStart);
|
||||
const periodEndDate = new Date(teamSubscription.periodEnd);
|
||||
const diffInDays = Math.round(
|
||||
(periodEndDate.getTime() - periodStartDate.getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
);
|
||||
|
||||
isYearly = diffInDays > 31;
|
||||
price = isYearly ? t`$8/month` : t`$10/month`;
|
||||
billingType = isYearly ? t`billed annually` : t`billed monthly`;
|
||||
}
|
||||
|
||||
const onSubmit = (member: InviteMemberInput) => {
|
||||
inviteMember.mutate(member);
|
||||
};
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const { data, error } = await authClient.subscription.upgrade({
|
||||
plan: "team",
|
||||
referenceId: workspace.publicId,
|
||||
metadata: { userId },
|
||||
seats: numberOfMembers,
|
||||
successUrl: "/members",
|
||||
cancelUrl: "/members",
|
||||
returnUrl: "/members",
|
||||
disableRedirect: true,
|
||||
});
|
||||
|
||||
if (data?.url) {
|
||||
window.location.href = data.url;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
showPopup({
|
||||
header: t`Error upgrading subscription`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,6 +159,11 @@ export function InviteMemberForm() {
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
@@ -69,18 +171,68 @@ export function InviteMemberForm() {
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<div className="mt-3 rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
{hasTeamSubscription || hasProSubscription ? (
|
||||
<div>
|
||||
<span className="font-medium text-emerald-500 dark:text-emerald-400">
|
||||
{hasTeamSubscription ? t`Team Plan` : t`Pro Plan ∞`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{unlimitedSeats
|
||||
? t`You have unlimited seats with your Pro Plan. There is no additional charge for new members!`
|
||||
: t`Adding a new member will cost an additional ${price} (${billingType}) per seat.`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<span className="font-medium text-light-950 dark:text-dark-950">
|
||||
{t`Free Plan`}
|
||||
</span>
|
||||
<p className="mt-1">
|
||||
{t`Inviting members requires a Team Plan. You'll be redirected to upgrade your workspace.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{(hasTeamSubscription || hasProSubscription) &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<Toggle
|
||||
label={t`Invite another`}
|
||||
isChecked={isCreateAnotherEnabled}
|
||||
onChange={() =>
|
||||
setIsCreateAnotherEnabled(!isCreateAnotherEnabled)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Button
|
||||
type="submit"
|
||||
isLoading={createBoard.isPending}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Invite member`}
|
||||
</Button>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription ? (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Upgrade to Team Plan`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inviteMember.isPending}
|
||||
isLoading={inviteMember.isPending}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Invite member`}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { HiEllipsisHorizontal, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { authClient } from "@kan/auth/client";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
@@ -19,7 +22,7 @@ import { DeleteMemberConfirmation } from "./components/DeleteMemberConfirmation"
|
||||
import { InviteMemberForm } from "./components/InviteMemberForm";
|
||||
|
||||
export default function MembersPage() {
|
||||
const { modalContentType, openModal } = useModal();
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
|
||||
const { data, isLoading } = api.workspace.byId.useQuery(
|
||||
@@ -27,6 +30,15 @@ export default function MembersPage() {
|
||||
// { enabled: workspace?.publicId ? true : false },
|
||||
);
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const subscriptions = data?.subscriptions as Subscription[] | undefined;
|
||||
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
||||
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
const TableRow = ({
|
||||
memberPublicId,
|
||||
memberId,
|
||||
@@ -48,7 +60,6 @@ export default function MembersPage() {
|
||||
isLastRow?: boolean;
|
||||
showSkeleton?: boolean;
|
||||
}) => {
|
||||
const { data: session } = authClient.useSession();
|
||||
return (
|
||||
<tr className="rounded-b-lg">
|
||||
<td
|
||||
@@ -97,7 +108,7 @@ export default function MembersPage() {
|
||||
</td>
|
||||
<td
|
||||
className={twMerge(
|
||||
"w-auto min-w-[120px] sm:w-[35%] sm:min-w-[150px]",
|
||||
"w-auto min-w-[120px] overflow-visible sm:w-[35%] sm:min-w-[150px]",
|
||||
isLastRow && "rounded-br-lg",
|
||||
)}
|
||||
>
|
||||
@@ -121,7 +132,7 @@ export default function MembersPage() {
|
||||
</div>
|
||||
<div
|
||||
className={twMerge(
|
||||
"relative",
|
||||
"relative z-50",
|
||||
(workspace.role !== "admin" || showSkeleton) && "hidden",
|
||||
)}
|
||||
>
|
||||
@@ -160,7 +171,28 @@ export default function MembersPage() {
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Members`}
|
||||
</h1>
|
||||
<div className="flex">
|
||||
<div className="flex items-center gap-3">
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center rounded-full border px-3 py-1 text-center text-xs",
|
||||
teamSubscription || proSubscription
|
||||
? "border-emerald-300 bg-emerald-50 text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400"
|
||||
: "border-light-300 bg-light-50 text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{proSubscription
|
||||
? t`Pro Plan`
|
||||
: teamSubscription
|
||||
? t`Team Plan`
|
||||
: t`Free Plan`}
|
||||
{proSubscription && unlimitedSeats && (
|
||||
<span className="ml-1 text-xs">∞</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
onClick={() => openModal("INVITE_MEMBER")}
|
||||
iconLeft={<HiOutlinePlusSmall className="h-4 w-4" />}
|
||||
@@ -172,10 +204,10 @@ export default function MembersPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flow-root">
|
||||
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full px-4 py-2 align-middle sm:px-6 lg:px-8">
|
||||
<div className="-mx-4 -my-2 sm:-mx-6 lg:-mx-8">
|
||||
<div className="inline-block min-w-full overflow-x-auto px-4 py-2 pb-16 align-middle sm:px-6 lg:px-8">
|
||||
<div className="h-full shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg">
|
||||
<table className="min-w-full divide-y divide-light-600 dark:divide-dark-600">
|
||||
<table className="min-w-full divide-y divide-light-600 overflow-visible dark:divide-dark-600">
|
||||
<thead className="rounded-t-lg bg-light-300 dark:bg-dark-200">
|
||||
<tr>
|
||||
<th
|
||||
@@ -192,7 +224,7 @@ export default function MembersPage() {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-light-600 bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
<tbody className="divide-y divide-light-600 overflow-visible bg-light-50 dark:divide-dark-600 dark:bg-dark-100">
|
||||
{!isLoading &&
|
||||
data?.members.map((member, index) => (
|
||||
<TableRow
|
||||
@@ -222,12 +254,40 @@ export default function MembersPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
{modalContentType === "INVITE_MEMBER" && <InviteMemberForm />}
|
||||
{modalContentType === "REMOVE_MEMBER" && <DeleteMemberConfirmation />}
|
||||
</Modal>
|
||||
<>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "INVITE_MEMBER"}
|
||||
>
|
||||
<InviteMemberForm
|
||||
userId={session?.user.id}
|
||||
numberOfMembers={data?.members.length ?? 1}
|
||||
subscriptions={subscriptions}
|
||||
unlimitedSeats={unlimitedSeats}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "REMOVE_MEMBER"}
|
||||
>
|
||||
<DeleteMemberConfirmation />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Badge from "~/components/Badge";
|
||||
import Editor from "~/components/Editor";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { api } from "~/utils/api";
|
||||
import ActivityList from "~/views/card/components/ActivityList";
|
||||
import Checklists from "~/views/card/components/Checklists";
|
||||
|
||||
export function CardModal({
|
||||
cardPublicId,
|
||||
@@ -20,7 +22,9 @@ export function CardModal({
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal, isOpen } = useModal();
|
||||
const { data: session } = authClient.useSession();
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { data, isLoading } = api.card.byId.useQuery(
|
||||
{
|
||||
@@ -33,85 +37,129 @@ export function CardModal({
|
||||
|
||||
const labels = data?.labels ?? [];
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 5;
|
||||
const isAtTop = scrollTop <= 5;
|
||||
|
||||
setShowFade(!isAtBottom);
|
||||
setShowTopFade(!isAtTop);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const scrollElement = scrollRef.current;
|
||||
if (!scrollElement) return;
|
||||
|
||||
handleScroll();
|
||||
|
||||
scrollElement.addEventListener("scroll", handleScroll);
|
||||
return () => scrollElement.removeEventListener("scroll", handleScroll);
|
||||
}, [data]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-1 flex-row">
|
||||
<div className="flex h-full w-full flex-col overflow-hidden">
|
||||
<div className="h-full max-h-[calc(100vh-4rem)] overflow-y-auto p-8">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
try {
|
||||
await router.replace(
|
||||
`/${workspaceSlug}/${boardSlug}`,
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<HiXMark
|
||||
size={18}
|
||||
className="dark:text-dark-9000 text-light-900"
|
||||
/>
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="font-medium leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full p-8">
|
||||
<div className="mb-6">
|
||||
{labels.map((label) => (
|
||||
<div
|
||||
key={label.publicId}
|
||||
className="my-1 mr-1 inline-flex w-fit items-center gap-x-1.5 rounded-full px-2 py-1 text-[12px] font-medium text-light-800 ring-1 ring-inset ring-light-600 dark:text-dark-1000 dark:ring-dark-800"
|
||||
>
|
||||
<svg
|
||||
fill={label.colourCode ?? "#3730a3"}
|
||||
className="h-2 w-2"
|
||||
viewBox="0 0 6 6"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx={3} cy={3} r={3} />
|
||||
</svg>
|
||||
<div>{label.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<button
|
||||
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={async (e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
|
||||
{data?.description && (
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<div className="mt-2">
|
||||
<Editor content={data.description} readOnly />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t-[1px] border-light-600 pb-4 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
{t`Activity`}
|
||||
</h2>
|
||||
<div>
|
||||
{cardPublicId && (
|
||||
<ActivityList
|
||||
cardPublicId={cardPublicId}
|
||||
activities={data?.activities ?? []}
|
||||
isLoading={isLoading}
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await router.replace(
|
||||
`/${workspaceSlug}/${boardSlug}`,
|
||||
undefined,
|
||||
{
|
||||
shallow: true,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, 400);
|
||||
}}
|
||||
>
|
||||
<HiXMark
|
||||
size={18}
|
||||
className="dark:text-dark-9000 text-light-900"
|
||||
/>
|
||||
</button>
|
||||
{isLoading ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h1 className="pr-8 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{data?.title}
|
||||
</h1>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{labels.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{labels.map((label) => (
|
||||
<Badge
|
||||
key={label.publicId}
|
||||
value={label.name}
|
||||
iconLeft={<LabelIcon colourCode={label.colourCode} />}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full max-h-[425px] overflow-y-auto"
|
||||
>
|
||||
{data?.description && (
|
||||
<div className="mb-10 flex w-full max-w-2xl justify-between">
|
||||
<div className="mt-2">
|
||||
<Editor
|
||||
content={data.description}
|
||||
readOnly
|
||||
workspaceMembers={data?.list.board.workspace.members ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{data?.checklists && data.checklists.length > 0 && (
|
||||
<Checklists
|
||||
checklists={data.checklists}
|
||||
cardPublicId={cardPublicId ?? ""}
|
||||
viewOnly
|
||||
/>
|
||||
)}
|
||||
<div className="border-t-[1px] border-light-600 pb-4 pt-12 dark:border-dark-400">
|
||||
<h2 className="text-md pb-4 font-medium text-light-900 dark:text-dark-1000">
|
||||
{t`Activity`}
|
||||
</h2>
|
||||
<div>
|
||||
{cardPublicId && (
|
||||
<ActivityList
|
||||
cardPublicId={cardPublicId}
|
||||
activities={data?.activities ?? []}
|
||||
isLoading={isLoading}
|
||||
isViewOnly={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showTopFade && (
|
||||
<div className="pointer-events-none absolute left-0 right-0 top-0 h-6 bg-gradient-to-b from-white/80 to-transparent dark:from-dark-100/80" />
|
||||
)}
|
||||
{showFade && (
|
||||
<div className="pointer-events-none absolute bottom-0 left-0 right-0 h-6 bg-gradient-to-t from-white/80 to-transparent dark:from-dark-100/80" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,9 +2,11 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiLink } from "react-icons/hi2";
|
||||
import { HiLink, HiOutlineLockClosed } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Modal from "~/components/modal";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
@@ -18,6 +20,10 @@ import Card from "~/views/board/components/Card";
|
||||
import Filters from "~/views/board/components/Filters";
|
||||
import { CardModal } from "./CardModal";
|
||||
|
||||
const IS_CLOUD = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
const HIDE_POWERED_BY =
|
||||
env("NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY") === "true";
|
||||
|
||||
export default function PublicBoardView() {
|
||||
const router = useRouter();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -28,13 +34,18 @@ export default function PublicBoardView() {
|
||||
? router.query.boardSlug[0]
|
||||
: router.query.boardSlug;
|
||||
|
||||
const workspaceSlug = Array.isArray(router.query.workspaceSlug)
|
||||
? router.query.workspaceSlug[0]
|
||||
: router.query.workspaceSlug;
|
||||
|
||||
const { data, isLoading } = api.board.bySlug.useQuery(
|
||||
{
|
||||
boardSlug: boardSlug ?? "",
|
||||
workspaceSlug: workspaceSlug ?? "",
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
},
|
||||
{ enabled: !!boardSlug, placeholderData: keepPreviousData },
|
||||
{ enabled: router.isReady && !!boardSlug, placeholderData: keepPreviousData },
|
||||
);
|
||||
|
||||
const CopyBoardLink = () => {
|
||||
@@ -96,7 +107,7 @@ export default function PublicBoardView() {
|
||||
<div className="relative h-full overflow-hidden rounded-md border pb-8 dark:border-dark-200">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 flex w-full justify-between p-8">
|
||||
{isLoading ? (
|
||||
{isLoading || !router.isReady ? (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
@@ -106,7 +117,13 @@ export default function PublicBoardView() {
|
||||
</h1>
|
||||
)}
|
||||
{data && (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="z-10 flex items-center space-x-2">
|
||||
<div className="inline-flex cursor-default items-center justify-center whitespace-nowrap rounded-md border-[1px] border-light-300 bg-light-50 px-3 py-2 text-sm font-semibold text-light-950 shadow-sm dark:border-dark-300 dark:bg-dark-50 dark:text-dark-950">
|
||||
<span className="mr-2">
|
||||
<HiOutlineLockClosed />
|
||||
</span>
|
||||
{t`View only`}
|
||||
</div>
|
||||
<Filters
|
||||
labels={data.labels ?? []}
|
||||
members={[]}
|
||||
@@ -116,17 +133,30 @@ export default function PublicBoardView() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
{isLoading ? (
|
||||
<div className="scrollbar-w-none scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-h-[8px] relative h-full flex-1 overflow-y-hidden overflow-x-scroll overscroll-contain scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300">
|
||||
{isLoading || !router.isReady ? (
|
||||
<div className="ml-[2rem] flex">
|
||||
<div className="0 mr-5 h-[500px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="0 mr-5 h-[275px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
<div className="0 mr-5 h-[375px] w-[18rem] animate-pulse rounded-md bg-light-200 dark:bg-dark-100" />
|
||||
</div>
|
||||
) : !data && !isLoading && router.isReady && !!boardSlug ? (
|
||||
<div className="z-10 flex h-full w-full flex-col items-center justify-center space-y-8 pb-[150px]">
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineLockClosed className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{t`Board not found`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`This board is private or does not exist`}
|
||||
</p>
|
||||
</div>
|
||||
<Button href={`/${workspaceSlug}`}>{t`View workspace`}</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex">
|
||||
<div className="min-w-[2rem]" />
|
||||
{data?.lists.map((list) => (
|
||||
{data.lists.map((list) => (
|
||||
<div
|
||||
key={list.publicId}
|
||||
className="dark-text-dark-1000 mr-5 h-fit min-w-[18rem] max-w-[18rem] rounded-md border border-light-400 bg-light-300 py-2 pl-2 pr-1 text-neutral-900 dark:border-dark-300 dark:bg-dark-100"
|
||||
@@ -150,6 +180,7 @@ export default function PublicBoardView() {
|
||||
<Card
|
||||
title={card.title}
|
||||
labels={card.labels}
|
||||
checklists={card.checklists ?? []}
|
||||
members={[]}
|
||||
/>
|
||||
</Link>
|
||||
@@ -168,12 +199,26 @@ export default function PublicBoardView() {
|
||||
<CopyBoardLink />
|
||||
</div>
|
||||
|
||||
<Link
|
||||
className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000"
|
||||
href="/"
|
||||
>
|
||||
kan.bn
|
||||
</Link>
|
||||
{IS_CLOUD && (
|
||||
<Link
|
||||
className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000"
|
||||
href="/"
|
||||
>
|
||||
kan.bn
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{!IS_CLOUD && !HIDE_POWERED_BY && (
|
||||
<a
|
||||
href="https://kan.bn"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="absolute right-[1rem] inline-flex items-center gap-[0.175rem] rounded-full border border-light-300 bg-light-50 px-3 py-1 text-[11px] font-medium text-light-950 shadow-sm transition-colors hover:bg-light-100 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 dark:hover:bg-dark-100"
|
||||
>
|
||||
<span>{`Powered by`}</span>
|
||||
<span className="font-semibold">kan.bn</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Popup />
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
currentPassword: z.string().min(1, t`Current password is required`),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, t`Password must be at least 8 characters`)
|
||||
.min(1, t`New password is required`),
|
||||
confirmPassword: z.string().min(1, t`Please confirm your new password`),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t`Passwords do not match`,
|
||||
path: ["confirmPassword"],
|
||||
})
|
||||
.refine((data) => data.currentPassword !== data.newPassword, {
|
||||
message: t`New password must be different from current password`,
|
||||
path: ["newPassword"],
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof FormSchema>;
|
||||
|
||||
export function ChangePasswordFormConfirmation() {
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isValid },
|
||||
reset,
|
||||
setError,
|
||||
} = useForm<FormValues>({
|
||||
resolver: zodResolver(FormSchema),
|
||||
mode: "onChange",
|
||||
});
|
||||
|
||||
const changePasswordMutation = useMutation({
|
||||
mutationFn: async (data: FormValues) => {
|
||||
const response = await authClient.changePassword({
|
||||
newPassword: data.newPassword,
|
||||
currentPassword: data.currentPassword,
|
||||
revokeOtherSessions: true,
|
||||
});
|
||||
|
||||
if (response?.error) {
|
||||
throw new Error(response?.error.message || "Invalid Password");
|
||||
}
|
||||
},
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Password Changed`,
|
||||
message: t`Your password has been changed.`,
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
utils.invalidate();
|
||||
reset();
|
||||
router.push("/");
|
||||
},
|
||||
onError: async (error: any) => {
|
||||
const errorMessage = error.message.toLowerCase();
|
||||
|
||||
if (errorMessage.includes("invalid password")) {
|
||||
setError("currentPassword", {
|
||||
type: "manual",
|
||||
message: t`The current password you entered is incorrect.`,
|
||||
});
|
||||
} else {
|
||||
closeModal();
|
||||
showPopup({
|
||||
header: t`Error Changing Password`,
|
||||
message: t`An unexpected error occurred. Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
changePasswordMutation.mutate(data);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
reset();
|
||||
closeModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium dark:text-white">{t`Change Password`}</h2>
|
||||
<p className="mb-4 text-sm text-light-900">
|
||||
{t`Enter your current password and choose a new secure password.`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Input
|
||||
id="currentPassword"
|
||||
type="password"
|
||||
{...register("currentPassword")}
|
||||
placeholder={t`Enter your current password`}
|
||||
/>
|
||||
{errors.currentPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.currentPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
id="newPassword"
|
||||
type="password"
|
||||
{...register("newPassword")}
|
||||
placeholder={t`Enter your new password`}
|
||||
/>
|
||||
{errors.newPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.newPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
id="confirmPassword"
|
||||
type="password"
|
||||
{...register("confirmPassword")}
|
||||
placeholder={t`Confirm your new password`}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.confirmPassword.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleCancel}
|
||||
type="button"
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={!isValid || changePasswordMutation.isPending}
|
||||
isLoading={changePasswordMutation.isPending}
|
||||
fullWidth
|
||||
size="lg"
|
||||
>
|
||||
{t`Change Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
|
||||
export function CustomURLConfirmation({
|
||||
workspacePublicId,
|
||||
}: {
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
const { closeModal, entityId } = useModal();
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
slug: entityId,
|
||||
workspacePublicId: workspacePublicId,
|
||||
cancelUrl: "/settings",
|
||||
successUrl: "/settings",
|
||||
}),
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Confirm URL change`}
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Custom URLs are a premium feature. You'll be directed to upgrade your account.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleUpgrade}>{t`Upgrade`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -98,7 +98,7 @@ const UpdateWorkspaceUrlForm = ({
|
||||
if (!isWorkspaceSlugAvailable?.isAvailable) return;
|
||||
|
||||
if (workspacePlan !== "pro" && env("NEXT_PUBLIC_KAN_ENV") === "cloud")
|
||||
return openModal("UPDATE_WORKSPACE_URL", data.slug);
|
||||
return openModal("UPGRADE_TO_PRO", data.slug);
|
||||
|
||||
updateWorkspaceSlug.mutate({
|
||||
workspacePublicId,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiBolt, HiCheckBadge } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
|
||||
export function UpgradeToProConfirmation({
|
||||
workspacePublicId,
|
||||
}: {
|
||||
userId: string;
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
const { closeModal, entityId } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
try {
|
||||
const response = await fetch("/api/stripe/create_checkout_session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...(entityId && { workspaceSlug: entityId }),
|
||||
workspacePublicId: workspacePublicId,
|
||||
cancelUrl: "/settings",
|
||||
successUrl: "/settings",
|
||||
}),
|
||||
});
|
||||
|
||||
const { url } = (await response.json()) as { url: string };
|
||||
|
||||
if (url) {
|
||||
window.location.href = url;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating checkout session:", error);
|
||||
|
||||
showPopup({
|
||||
header: t`Error upgrading subscription`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-bold text-neutral-900 dark:text-dark-1000">
|
||||
{t`Upgrade to Pro`}
|
||||
</h2>
|
||||
<p className="mb-4 text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`Supercharge your workspace for just $29/month. Here's what you'll get:`}
|
||||
</p>
|
||||
|
||||
<div className="rounded-md bg-light-100 p-3 text-xs text-light-900 dark:bg-dark-200 dark:text-dark-900">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{t`Unlimited members`}
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[10px] font-medium text-emerald-600 ring-1 ring-inset ring-emerald-500/20 dark:text-emerald-400 sm:text-[10px]">
|
||||
{t`Launch offer`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{t`Custom workspace URL`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{t`Board analytics (coming soon)`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiCheckBadge className="h-5 w-5 flex-shrink-0 text-light-1000 dark:text-dark-950" />
|
||||
<span className="text-sm text-neutral-900 dark:text-dark-1000">
|
||||
{t`Priority email support`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleUpgrade}
|
||||
iconRight={<HiBolt />}
|
||||
>{t`Upgrade`}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@ import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
import { HiBolt, HiMiniArrowTopRightOnSquare } from "react-icons/hi2";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import FeedbackModal from "~/components/FeedbackModal";
|
||||
@@ -15,26 +18,36 @@ import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import Avatar from "./components/Avatar";
|
||||
import { ChangePasswordFormConfirmation } from "./components/ChangePasswordConfirmation";
|
||||
import CreateAPIKeyForm from "./components/CreateAPIKeyForm";
|
||||
import { CustomURLConfirmation } from "./components/CustomURLConfirmation";
|
||||
import { DeleteAccountConfirmation } from "./components/DeleteAccountConfirmation";
|
||||
import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
|
||||
import UpdateDisplayNameForm from "./components/UpdateDisplayNameForm";
|
||||
import UpdateWorkspaceDescriptionForm from "./components/UpdateWorkspaceDescriptionForm";
|
||||
import UpdateWorkspaceNameForm from "./components/UpdateWorkspaceNameForm";
|
||||
import UpdateWorkspaceUrlForm from "./components/UpdateWorkspaceUrlForm";
|
||||
import { UpgradeToProConfirmation } from "./components/UpgradeToProConfirmation";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { modalContentType, openModal } = useModal();
|
||||
const { modalContentType, openModal, isOpen } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
const router = useRouter();
|
||||
const workspaceUrlSectionRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isCredentialsEnabled =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const { data } = api.user.getUser.useQuery();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
| undefined;
|
||||
|
||||
const {
|
||||
data: integrations,
|
||||
refetch: refetchIntegrations,
|
||||
@@ -178,6 +191,18 @@ export default function SettingsPage() {
|
||||
workspacePublicId={workspace.publicId}
|
||||
workspaceDescription={workspace.description ?? ""}
|
||||
/>
|
||||
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasActiveSubscription(subscriptions, "pro") && (
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
iconRight={<HiBolt />}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
@@ -283,6 +308,25 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Change Password`}
|
||||
</h2>
|
||||
<p className="mb-8 text-sm text-neutral-500 dark:text-dark-900">
|
||||
{t`You are about to change your password.`}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => openModal("CHANGE_PASSWORD")}
|
||||
>
|
||||
{t`Change Password`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mb-8 border-t border-light-300 dark:border-dark-300">
|
||||
<h2 className="mb-4 mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
|
||||
{t`Delete account`}
|
||||
@@ -301,19 +345,52 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal modalSize={modalContentType === "NEW_FEEDBACK" ? "md" : "sm"}>
|
||||
{modalContentType === "NEW_FEEDBACK" && <FeedbackModal />}
|
||||
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />}
|
||||
{modalContentType === "DELETE_WORKSPACE" && (
|
||||
<>
|
||||
<Modal
|
||||
modalSize="md"
|
||||
isVisible={isOpen && modalContentType === "NEW_FEEDBACK"}
|
||||
>
|
||||
<FeedbackModal />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_WORKSPACE"}
|
||||
>
|
||||
<NewWorkspaceForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_WORKSPACE"}
|
||||
>
|
||||
<DeleteWorkspaceConfirmation />
|
||||
)}
|
||||
{modalContentType === "UPDATE_WORKSPACE_URL" && (
|
||||
<CustomURLConfirmation workspacePublicId={workspace.publicId} />
|
||||
)}
|
||||
{modalContentType === "DELETE_ACCOUNT" && (
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "UPGRADE_TO_PRO"}
|
||||
>
|
||||
<UpgradeToProConfirmation
|
||||
userId={data?.id ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_ACCOUNT"}
|
||||
>
|
||||
<DeleteAccountConfirmation />
|
||||
)}
|
||||
</Modal>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CHANGE_PASSWORD"}
|
||||
>
|
||||
<ChangePasswordFormConfirmation />
|
||||
</Modal>
|
||||
</>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fontFamily } from "tailwindcss/defaultTheme";
|
||||
import baseConfig from "@kan/tailwind-config/web";
|
||||
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: [...baseConfig.content],
|
||||
plugins: [require("@tailwindcss/typography")],
|
||||
presets: [baseConfig],
|
||||
|
||||
@@ -22,14 +22,21 @@ services:
|
||||
# Stripe
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- STRIPE_WEBHOOK_SECRET_LEGACY=${STRIPE_WEBHOOK_SECRET_LEGACY}
|
||||
- STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID=${STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID}
|
||||
- STRIPE_TEAM_PLAN_YEARLY_PRICE_ID=${STRIPE_TEAM_PLAN_YEARLY_PRICE_ID}
|
||||
- STRIPE_PRO_PLAN_MONTHLY_PRICE_ID=${STRIPE_PRO_PLAN_MONTHLY_PRICE_ID}
|
||||
- STRIPE_PRO_PLAN_YEARLY_PRICE_ID=${STRIPE_PRO_PLAN_YEARLY_PRICE_ID}
|
||||
|
||||
# SMTP
|
||||
# Email
|
||||
- SMTP_HOST=${SMTP_HOST}
|
||||
- SMTP_PORT=${SMTP_PORT}
|
||||
- SMTP_USER=${SMTP_USER}
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- SMTP_SECURE=${SMTP_SECURE}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
- PLUNK_API_KEY=${PLUNK_API_KEY}
|
||||
- PLUNK_API_URL=${PLUNK_API_URL}
|
||||
|
||||
# S3 storage
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
|
||||
@@ -35,6 +35,9 @@ services:
|
||||
- NEXT_PUBLIC_AVATAR_BUCKET_NAME=${NEXT_PUBLIC_AVATAR_BUCKET_NAME}
|
||||
- NEXT_PUBLIC_STORAGE_DOMAIN=${NEXT_PUBLIC_STORAGE_DOMAIN}
|
||||
|
||||
# White label
|
||||
- NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY=${NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY}
|
||||
|
||||
# Auth config (optional)
|
||||
- NEXT_PUBLIC_ALLOW_CREDENTIALS=${NEXT_PUBLIC_ALLOW_CREDENTIALS}
|
||||
- NEXT_PUBLIC_DISABLE_SIGN_UP=${NEXT_PUBLIC_DISABLE_SIGN_UP}
|
||||
@@ -43,6 +46,11 @@ services:
|
||||
- TRELLO_APP_API_KEY=${TRELLO_APP_API_KEY}
|
||||
- TRELLO_APP_SECRET=${TRELLO_APP_SECRET}
|
||||
|
||||
# Generic OIDC provider (optional)
|
||||
- OIDC_CLIENT_ID=${OIDC_CLIENT_ID}
|
||||
- OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET}
|
||||
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL}
|
||||
|
||||
# OAuth providers (optional)
|
||||
- BETTER_AUTH_TRUSTED_ORIGINS=${BETTER_AUTH_TRUSTED_ORIGINS}
|
||||
- GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}
|
||||
|
||||
@@ -31,5 +31,11 @@
|
||||
"turbo": "^2.3.1",
|
||||
"typescript": "catalog:"
|
||||
},
|
||||
"prettier": "@kan/prettier-config"
|
||||
"prettier": "@kan/prettier-config",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@types/minimatch": "npm:minimatch@*",
|
||||
"@types/glob": "npm:@types/glob@^8.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { boardRouter } from "./routers/board";
|
||||
import { cardRouter } from "./routers/card";
|
||||
import { checklistRouter } from "./routers/checklist";
|
||||
import { feedbackRouter } from "./routers/feedback";
|
||||
import { importRouter } from "./routers/import";
|
||||
import { integrationRouter } from "./routers/integration";
|
||||
@@ -13,6 +14,7 @@ import { createTRPCRouter } from "./trpc";
|
||||
export const appRouter = createTRPCRouter({
|
||||
board: boardRouter,
|
||||
card: cardRouter,
|
||||
checklist: checklistRouter,
|
||||
feedback: feedbackRouter,
|
||||
label: labelRouter,
|
||||
list: listRouter,
|
||||
|
||||
@@ -111,15 +111,21 @@ export const boardRouter = createTRPCRouter({
|
||||
.meta({
|
||||
openapi: {
|
||||
method: "GET",
|
||||
path: "/board/{boardSlug}",
|
||||
path: "/workspaces/{workspaceSlug}/boards/{boardSlug}",
|
||||
summary: "Get board by slug",
|
||||
description: "Retrieves a board by its slug",
|
||||
description:
|
||||
"Retrieves a board by its slug within a specific workspace",
|
||||
tags: ["Boards"],
|
||||
protect: false,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspaceSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
.max(24)
|
||||
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/),
|
||||
boardSlug: z
|
||||
.string()
|
||||
.min(3)
|
||||
@@ -131,10 +137,26 @@ export const boardRouter = createTRPCRouter({
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getBySlug>>>())
|
||||
.query(async ({ ctx, input }) => {
|
||||
const result = await boardRepo.getBySlug(ctx.db, input.boardSlug, {
|
||||
members: input.members ?? [],
|
||||
labels: input.labels ?? [],
|
||||
});
|
||||
const workspace = await workspaceRepo.getBySlugWithBoards(
|
||||
ctx.db,
|
||||
input.workspaceSlug,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace with slug ${input.workspaceSlug} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const result = await boardRepo.getBySlug(
|
||||
ctx.db,
|
||||
input.boardSlug,
|
||||
workspace.id,
|
||||
{
|
||||
members: input.members ?? [],
|
||||
labels: input.labels ?? [],
|
||||
},
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
@@ -276,6 +298,21 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, board.workspaceId);
|
||||
|
||||
if (input.slug) {
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
ctx.db,
|
||||
input.slug,
|
||||
board.workspaceId,
|
||||
);
|
||||
|
||||
if (!isBoardSlugAvailable) {
|
||||
throw new TRPCError({
|
||||
message: `Board slug ${input.slug} is not available`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const result = await boardRepo.update(ctx.db, {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
@@ -407,11 +444,23 @@ export const boardRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const board = await boardRepo.getWorkspaceAndBoardIdByBoardPublicId(
|
||||
ctx.db,
|
||||
input.boardPublicId,
|
||||
);
|
||||
|
||||
if (!board)
|
||||
throw new TRPCError({
|
||||
message: `Board with public ID ${input.boardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const isBoardSlugAvailable = await boardRepo.isBoardSlugAvailable(
|
||||
ctx.db,
|
||||
input.boardSlug,
|
||||
input.boardPublicId,
|
||||
board.workspaceId,
|
||||
);
|
||||
|
||||
return {
|
||||
isReserved: !isBoardSlugAvailable,
|
||||
};
|
||||
|
||||
410
packages/api/src/routers/checklist.ts
Normal file
410
packages/api/src/routers/checklist.ts
Normal file
@@ -0,0 +1,410 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as cardRepo from "@kan/db/repository/card.repo";
|
||||
import * as cardActivityRepo from "@kan/db/repository/cardActivity.repo";
|
||||
import * as checklistRepo from "@kan/db/repository/checklist.repo";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
|
||||
const checklistSchema = z.object({
|
||||
publicId: z.string().length(12),
|
||||
name: z.string().min(1).max(255),
|
||||
});
|
||||
|
||||
const checklistItemSchema = z.object({
|
||||
publicId: z.string().length(12),
|
||||
title: z.string().min(1).max(500),
|
||||
completed: z.boolean(),
|
||||
});
|
||||
|
||||
export const checklistRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Add a checklist to a card",
|
||||
method: "POST",
|
||||
path: "/cards/{cardPublicId}/checklists",
|
||||
description: "Adds a checklist to a card",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
cardPublicId: z.string().length(12),
|
||||
name: z.string().min(1).max(255),
|
||||
}),
|
||||
)
|
||||
.output(checklistSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const card = await cardRepo.getWorkspaceAndCardIdByCardPublicId(
|
||||
ctx.db,
|
||||
input.cardPublicId,
|
||||
);
|
||||
|
||||
if (!card)
|
||||
throw new TRPCError({
|
||||
message: `Card with public ID ${input.cardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, card.workspaceId);
|
||||
|
||||
const newChecklist = await checklistRepo.create(ctx.db, {
|
||||
name: input.name,
|
||||
createdBy: userId,
|
||||
cardId: card.id,
|
||||
});
|
||||
|
||||
if (!newChecklist?.id)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create checklist`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.added",
|
||||
cardId: card.id,
|
||||
toTitle: newChecklist.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return newChecklist;
|
||||
}),
|
||||
update: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
checklistPublicId: z.string().length(12),
|
||||
name: z.string().min(1).max(255),
|
||||
}),
|
||||
)
|
||||
.output(z.object({ publicId: z.string().length(12), name: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const checklist = await checklistRepo.getChecklistByPublicId(
|
||||
ctx.db,
|
||||
input.checklistPublicId,
|
||||
);
|
||||
if (!checklist)
|
||||
throw new TRPCError({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
);
|
||||
|
||||
const previousName = checklist.name;
|
||||
|
||||
const updated = await checklistRepo.updateChecklistById(ctx.db, {
|
||||
id: checklist.id,
|
||||
name: input.name,
|
||||
});
|
||||
|
||||
if (!updated)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update checklist`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.renamed",
|
||||
cardId: checklist.cardId,
|
||||
fromTitle: previousName,
|
||||
toTitle: updated.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return updated;
|
||||
}),
|
||||
delete: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a checklist",
|
||||
method: "DELETE",
|
||||
path: "/checklists/{checklistPublicId}",
|
||||
description: "Deletes a checklist by its public ID",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ checklistPublicId: z.string().length(12) }))
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const checklist = await checklistRepo.getChecklistByPublicId(
|
||||
ctx.db,
|
||||
input.checklistPublicId,
|
||||
);
|
||||
if (!checklist)
|
||||
throw new TRPCError({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
);
|
||||
|
||||
await checklistRepo.softDeleteAllItemsByChecklistId(ctx.db, {
|
||||
checklistId: checklist.id,
|
||||
deletedAt: new Date(),
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
const deleted = await checklistRepo.softDeleteById(ctx.db, {
|
||||
id: checklist.id,
|
||||
deletedAt: new Date(),
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!deleted)
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete checklist`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.deleted",
|
||||
cardId: checklist.cardId,
|
||||
fromTitle: checklist.name,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
createItem: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Add an item to a checklist",
|
||||
method: "POST",
|
||||
path: "/checklists/{checklistPublicId}/items",
|
||||
description: "Adds an item to a checklist",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
checklistPublicId: z.string().length(12),
|
||||
title: z.string().min(1).max(500),
|
||||
}),
|
||||
)
|
||||
.output(checklistItemSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const checklist = await checklistRepo.getChecklistByPublicId(
|
||||
ctx.db,
|
||||
input.checklistPublicId,
|
||||
);
|
||||
|
||||
if (!checklist)
|
||||
throw new TRPCError({
|
||||
message: `Checklist with public ID ${input.checklistPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
checklist.card.list.board.workspace.id,
|
||||
);
|
||||
|
||||
const newChecklistItem = await checklistRepo.createItem(ctx.db, {
|
||||
title: input.title,
|
||||
createdBy: userId,
|
||||
checklistId: checklist.id,
|
||||
});
|
||||
|
||||
if (!newChecklistItem?.id)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create checklist item`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.item.added",
|
||||
cardId: checklist.cardId,
|
||||
toTitle: newChecklistItem.title,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return newChecklistItem;
|
||||
}),
|
||||
updateItem: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Update a checklist item",
|
||||
method: "PUT",
|
||||
path: "/checklists/items/{checklistItemPublicId}",
|
||||
description: "Updates a checklist item (title/completed)",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
checklistItemPublicId: z.string().length(12),
|
||||
title: z.string().min(1).max(500).optional(),
|
||||
completed: z.boolean().optional(),
|
||||
}),
|
||||
)
|
||||
.output(checklistItemSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist(
|
||||
ctx.db,
|
||||
input.checklistItemPublicId,
|
||||
);
|
||||
|
||||
if (!item)
|
||||
throw new TRPCError({
|
||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
item.checklist.card.list.board.workspace.id,
|
||||
);
|
||||
|
||||
const previousTitle = item.title;
|
||||
|
||||
const updated = await checklistRepo.updateItemById(ctx.db, {
|
||||
id: item.id,
|
||||
title: input.title,
|
||||
completed: input.completed,
|
||||
});
|
||||
|
||||
if (!updated)
|
||||
throw new TRPCError({
|
||||
message: `Failed to update checklist item`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
// Log completion toggle
|
||||
if (input.completed !== undefined) {
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: input.completed
|
||||
? "card.updated.checklist.item.completed"
|
||||
: "card.updated.checklist.item.uncompleted",
|
||||
cardId: item.checklist.cardId,
|
||||
toTitle: updated.title,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
// Log title change
|
||||
if (input.title !== undefined && input.title !== previousTitle) {
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.item.updated",
|
||||
cardId: item.checklist.cardId,
|
||||
fromTitle: previousTitle,
|
||||
toTitle: updated.title,
|
||||
createdBy: userId,
|
||||
});
|
||||
}
|
||||
|
||||
return updated;
|
||||
}),
|
||||
deleteItem: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Delete a checklist item",
|
||||
method: "DELETE",
|
||||
path: "/checklists/items/{checklistItemPublicId}",
|
||||
description: "Deletes a checklist item",
|
||||
tags: ["Cards"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ checklistItemPublicId: z.string().length(12) }))
|
||||
.output(z.object({ success: z.boolean() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const item = await checklistRepo.getChecklistItemByPublicIdWithChecklist(
|
||||
ctx.db,
|
||||
input.checklistItemPublicId,
|
||||
);
|
||||
if (!item)
|
||||
throw new TRPCError({
|
||||
message: `Checklist item with public ID ${input.checklistItemPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
item.checklist.card.list.board.workspace.id,
|
||||
);
|
||||
|
||||
const deleted = await checklistRepo.softDeleteItemById(ctx.db, {
|
||||
id: item.id,
|
||||
deletedAt: new Date(),
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
if (!deleted)
|
||||
throw new TRPCError({
|
||||
message: `Failed to delete item`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
await cardActivityRepo.create(ctx.db, {
|
||||
type: "card.updated.checklist.item.deleted",
|
||||
cardId: item.checklist.cardId,
|
||||
fromTitle: item.title,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
@@ -7,7 +7,10 @@ import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
|
||||
export const feedbackRouter = createTRPCRouter({
|
||||
create: protectedProcedure
|
||||
.meta({ enabled: false, openApi: false })
|
||||
.meta({
|
||||
enabled: false,
|
||||
openapi: { enabled: false, method: "POST", path: "/feedback" },
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
feedback: z.string().min(1),
|
||||
|
||||
@@ -2,8 +2,11 @@ import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import { getSubscriptionByPlan, hasUnlimitedSeats } from "@kan/shared/utils";
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
@@ -56,10 +59,51 @@ export const memberRouter = createTRPCRouter({
|
||||
if (isInvitedEmailAlreadyMember) {
|
||||
throw new TRPCError({
|
||||
message: `User with email ${input.email} is already a member of this workspace`,
|
||||
code: "BAD_REQUEST",
|
||||
code: "CONFLICT",
|
||||
});
|
||||
}
|
||||
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
workspace.publicId,
|
||||
);
|
||||
|
||||
// get the active subscriptions
|
||||
const activeTeamSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"team",
|
||||
);
|
||||
const activeProSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"pro",
|
||||
);
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
if (!activeTeamSubscription && !activeProSubscription) {
|
||||
throw new TRPCError({
|
||||
message: `Workspace with public ID ${workspace.publicId} does not have an active subscription`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
// Update the Stripe subscription
|
||||
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
||||
try {
|
||||
await updateSubscriptionSeats(
|
||||
activeTeamSubscription.stripeSubscriptionId,
|
||||
1,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to update Stripe subscription seats:", error);
|
||||
throw new TRPCError({
|
||||
message: `Failed to update subscription for the new member.`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const existingUser = await userRepo.getByEmail(ctx.db, input.email);
|
||||
|
||||
const invite = await memberRepo.create(ctx.db, {
|
||||
@@ -88,6 +132,12 @@ export const memberRouter = createTRPCRouter({
|
||||
callbackURL: `/boards?type=invite&memberPublicId=${invite.publicId}`,
|
||||
});
|
||||
|
||||
await memberRepo.softDelete(ctx.db, {
|
||||
memberId: invite.id,
|
||||
deletedAt: new Date(),
|
||||
deletedBy: userId,
|
||||
});
|
||||
|
||||
throw new TRPCError({
|
||||
message: `Failed to send magic link invitation to user with email ${input.email}.`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
@@ -159,6 +209,36 @@ export const memberRouter = createTRPCRouter({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
// Handle subscription seat decrement for cloud environment
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
workspace.publicId,
|
||||
);
|
||||
|
||||
// get the active subscriptions
|
||||
const activeTeamSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"team",
|
||||
);
|
||||
const unlimitedSeats = hasUnlimitedSeats(subscriptions);
|
||||
|
||||
// Only decrease seats if there's an active subscription and stripeSubscriptionId
|
||||
if (activeTeamSubscription?.stripeSubscriptionId && !unlimitedSeats) {
|
||||
try {
|
||||
await updateSubscriptionSeats(
|
||||
activeTeamSubscription.stripeSubscriptionId,
|
||||
-1,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to decrease Stripe subscription seats:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -32,6 +32,11 @@ const createAuthWithHeaders = (
|
||||
headers,
|
||||
body: { email: input.email, callbackURL: input.callbackURL },
|
||||
}),
|
||||
listActiveSubscriptions: (input: { workspacePublicId: string }) =>
|
||||
auth.api.listActiveSubscriptions({
|
||||
headers,
|
||||
query: { referenceId: input.workspacePublicId },
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
},
|
||||
"prettier": "@kan/prettier-config",
|
||||
"dependencies": {
|
||||
"better-auth": "^1.2.8"
|
||||
"@better-auth/stripe": "^1.3.7",
|
||||
"better-auth": "^1.3.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
||||
import { stripe } from "@better-auth/stripe";
|
||||
import { betterAuth } from "better-auth";
|
||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||
import { createAuthEndpoint, createAuthMiddleware } from "better-auth/api";
|
||||
import { apiKey } from "better-auth/plugins";
|
||||
import { apiKey, genericOAuth } from "better-auth/plugins";
|
||||
import { magicLink } from "better-auth/plugins/magic-link";
|
||||
import { socialProviderList } from "better-auth/social-providers";
|
||||
import { env } from "next-runtime-env";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import * as memberRepo from "@kan/db/repository/member.repo";
|
||||
import * as subscriptionRepo from "@kan/db/repository/subscription.repo";
|
||||
import * as userRepo from "@kan/db/repository/user.repo";
|
||||
import * as workspaceRepo from "@kan/db/repository/workspace.repo";
|
||||
import * as schema from "@kan/db/schema";
|
||||
import { sendEmail } from "@kan/email";
|
||||
import { cloudMailerClient, sendEmail } from "@kan/email";
|
||||
import { createStripeClient } from "@kan/stripe";
|
||||
|
||||
export const configuredProviders = socialProviderList.reduce<
|
||||
@@ -83,8 +86,20 @@ export const socialProvidersPlugin = () => ({
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
async (ctx) =>
|
||||
ctx.json(ctx.context.socialProviders.map((p) => p.id.toLowerCase())),
|
||||
async (ctx) => {
|
||||
const providers = ctx.context.socialProviders.map((p) =>
|
||||
p.id.toLowerCase(),
|
||||
);
|
||||
// Add OIDC provider if configured
|
||||
if (
|
||||
process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_DISCOVERY_URL
|
||||
) {
|
||||
providers.push("oidc");
|
||||
}
|
||||
return ctx.json(providers);
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -100,13 +115,13 @@ async function downloadImage(url: string): Promise<Buffer> {
|
||||
export const initAuth = (db: dbClient) => {
|
||||
return betterAuth({
|
||||
secret: process.env.BETTER_AUTH_SECRET!,
|
||||
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
|
||||
baseURL: env("NEXT_PUBLIC_BASE_URL"),
|
||||
trustedOrigins: process.env.BETTER_AUTH_TRUSTED_ORIGINS
|
||||
? [
|
||||
process.env.NEXT_PUBLIC_BASE_URL!,
|
||||
env("NEXT_PUBLIC_BASE_URL") ?? "",
|
||||
...process.env.BETTER_AUTH_TRUSTED_ORIGINS.split(","),
|
||||
]
|
||||
: [process.env.NEXT_PUBLIC_BASE_URL!],
|
||||
: [env("NEXT_PUBLIC_BASE_URL") ?? ""],
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "pg",
|
||||
schema: {
|
||||
@@ -141,6 +156,76 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
plugins: [
|
||||
socialProvidersPlugin(),
|
||||
...(process.env.NEXT_PUBLIC_KAN_ENV === "cloud"
|
||||
? [
|
||||
stripe({
|
||||
stripeClient: createStripeClient(),
|
||||
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET!,
|
||||
createCustomerOnSignUp: true,
|
||||
subscription: {
|
||||
enabled: true,
|
||||
plans: [
|
||||
{
|
||||
name: "team",
|
||||
priceId: process.env.STRIPE_TEAM_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_TEAM_PLAN_YEARLY_PRICE_ID!,
|
||||
},
|
||||
{
|
||||
name: "pro",
|
||||
priceId: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID!,
|
||||
annualDiscountPriceId:
|
||||
process.env.STRIPE_PRO_PLAN_YEARLY_PRICE_ID!,
|
||||
},
|
||||
],
|
||||
authorizeReference: async (data) => {
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
db,
|
||||
data.referenceId,
|
||||
);
|
||||
|
||||
if (!workspace) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
const isUserInWorkspace =
|
||||
await workspaceRepo.isUserInWorkspace(
|
||||
db,
|
||||
data.user.id,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
return isUserInWorkspace;
|
||||
},
|
||||
getCheckoutSessionParams: () => {
|
||||
return {
|
||||
params: {
|
||||
allow_promotion_codes: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
onSubscriptionComplete: async ({
|
||||
subscription,
|
||||
stripeSubscription,
|
||||
}) => {
|
||||
// Set unlimited seats to true for pro plans
|
||||
if (subscription.plan === "pro") {
|
||||
await subscriptionRepo.updateByStripeSubscriptionId(
|
||||
db,
|
||||
stripeSubscription.id,
|
||||
{
|
||||
unlimitedSeats: true,
|
||||
},
|
||||
);
|
||||
console.log(
|
||||
`Pro subscription ${stripeSubscription.id} activated with unlimited seats`,
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
// @todo: hasing is disabled due to a bug in the api key plugin
|
||||
apiKey({ disableKeyHashing: true }),
|
||||
magicLink({
|
||||
@@ -162,6 +247,25 @@ export const initAuth = (db: dbClient) => {
|
||||
}
|
||||
},
|
||||
}),
|
||||
// Generic OIDC provider
|
||||
...(process.env.OIDC_CLIENT_ID &&
|
||||
process.env.OIDC_CLIENT_SECRET &&
|
||||
process.env.OIDC_DISCOVERY_URL
|
||||
? [
|
||||
genericOAuth({
|
||||
config: [
|
||||
{
|
||||
providerId: "oidc",
|
||||
clientId: process.env.OIDC_CLIENT_ID,
|
||||
clientSecret: process.env.OIDC_CLIENT_SECRET,
|
||||
discoveryUrl: process.env.OIDC_DISCOVERY_URL,
|
||||
scopes: ["openid", "email", "profile"],
|
||||
pkce: true,
|
||||
},
|
||||
],
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
databaseHooks: {
|
||||
user: {
|
||||
@@ -183,6 +287,17 @@ export const initAuth = (db: dbClient) => {
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
async after(user) {
|
||||
if (cloudMailerClient) {
|
||||
await cloudMailerClient.events.track({
|
||||
event: "user-signup",
|
||||
email: user.email,
|
||||
data: {
|
||||
name: user.name,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
user.image &&
|
||||
!user.image.includes(process.env.NEXT_PUBLIC_STORAGE_DOMAIN!)
|
||||
@@ -228,37 +343,17 @@ export const initAuth = (db: dbClient) => {
|
||||
},
|
||||
hooks: {
|
||||
after: createAuthMiddleware(async (ctx) => {
|
||||
if (ctx.path.startsWith("/get-session")) {
|
||||
const user = ctx.context.session?.user;
|
||||
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
user &&
|
||||
!user.stripeCustomerId
|
||||
) {
|
||||
const stripe = createStripeClient();
|
||||
const stripeCustomer = await stripe.customers.create({
|
||||
email: user.email,
|
||||
metadata: {
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
await userRepo.update(db, user.id, {
|
||||
stripeCustomerId: stripeCustomer.id,
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
if (
|
||||
ctx.path === "/magic-link/verify" &&
|
||||
(ctx.query?.callbackURL as string | undefined)?.includes(
|
||||
"type=invite",
|
||||
) &&
|
||||
ctx.query?.memberPublicId
|
||||
)
|
||||
) {
|
||||
const userId = ctx.context.newSession?.session.userId;
|
||||
const memberPublicId = ctx.query.memberPublicId as string;
|
||||
const callbackURL = ctx.query?.callbackURL as string | undefined;
|
||||
const memberPublicId = callbackURL?.split("memberPublicId=")[1];
|
||||
|
||||
if (userId) {
|
||||
if (userId && memberPublicId) {
|
||||
const member = await memberRepo.getByPublicId(db, memberPublicId);
|
||||
|
||||
if (member?.id) {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { BetterAuthClientPlugin } from "better-auth";
|
||||
import { apiKeyClient, magicLinkClient } from "better-auth/client/plugins";
|
||||
import { BetterFetchOption, createAuthClient } from "better-auth/react";
|
||||
import type { BetterAuthClientPlugin } from "better-auth";
|
||||
import type { BetterFetchOption } from "better-auth/react";
|
||||
import { stripeClient } from "@better-auth/stripe/client";
|
||||
import {
|
||||
apiKeyClient,
|
||||
genericOAuthClient,
|
||||
magicLinkClient,
|
||||
} from "better-auth/client/plugins";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
import { socialProvidersPlugin } from "./auth";
|
||||
import type { socialProvidersPlugin } from "./auth";
|
||||
|
||||
const socialProvidersPluginClient = {
|
||||
id: "social-providers-plugin",
|
||||
@@ -21,5 +27,13 @@ const socialProvidersPluginClient = {
|
||||
} satisfies BetterAuthClientPlugin;
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
plugins: [magicLinkClient(), apiKeyClient(), socialProvidersPluginClient],
|
||||
plugins: [
|
||||
stripeClient({
|
||||
subscription: true,
|
||||
}),
|
||||
magicLinkClient(),
|
||||
apiKeyClient(),
|
||||
genericOAuthClient(),
|
||||
socialProvidersPluginClient,
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";--> statement-breakpoint
|
||||
|
||||
CREATE TYPE "public"."board_visibility" AS ENUM('private', 'public');--> statement-breakpoint
|
||||
CREATE TYPE "public"."card_activity_type" AS ENUM('card.created', 'card.updated.title', 'card.updated.description', 'card.updated.index', 'card.updated.list', 'card.updated.label.added', 'card.updated.label.removed', 'card.updated.member.added', 'card.updated.member.removed', 'card.updated.comment.added', 'card.updated.comment.updated', 'card.updated.comment.deleted', 'card.archived');--> statement-breakpoint
|
||||
|
||||
84
packages/db/migrations/20250806202106_glossy_luminals.sql
Normal file
84
packages/db/migrations/20250806202106_glossy_luminals.sql
Normal file
@@ -0,0 +1,84 @@
|
||||
CREATE TABLE IF NOT EXISTS "card_checklist_item" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"title" varchar(500) NOT NULL,
|
||||
"completed" boolean DEFAULT false NOT NULL,
|
||||
"index" integer NOT NULL,
|
||||
"checklistId" bigint NOT NULL,
|
||||
"createdBy" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
"deletedAt" timestamp,
|
||||
"deletedBy" uuid,
|
||||
CONSTRAINT "card_checklist_item_publicId_unique" UNIQUE("publicId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_checklist_item" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "card_checklist" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"name" varchar(255) NOT NULL,
|
||||
"index" integer NOT NULL,
|
||||
"cardId" bigint NOT NULL,
|
||||
"createdBy" uuid,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
"deletedAt" timestamp,
|
||||
"deletedBy" uuid,
|
||||
CONSTRAINT "card_checklist_publicId_unique" UNIQUE("publicId")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "card_checklist" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "integration" (
|
||||
"provider" varchar(255) NOT NULL,
|
||||
"userId" uuid NOT NULL,
|
||||
"accessToken" varchar(255) NOT NULL,
|
||||
"refreshToken" varchar(255),
|
||||
"expiresAt" timestamp NOT NULL,
|
||||
"createdAt" timestamp NOT NULL,
|
||||
"updatedAt" timestamp,
|
||||
CONSTRAINT "integration_pkey" PRIMARY KEY("userId","provider")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "integration" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_checklistId_card_checklist_id_fk" FOREIGN KEY ("checklistId") REFERENCES "public"."card_checklist"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_checklist_item" ADD CONSTRAINT "card_checklist_item_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "public"."card"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_checklist" ADD CONSTRAINT "card_checklist_deletedBy_user_id_fk" FOREIGN KEY ("deletedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "integration" ADD CONSTRAINT "integration_userId_user_id_fk" FOREIGN KEY ("userId") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,8 @@
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.added' BEFORE 'card.archived';--> statement-breakpoint
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.renamed' BEFORE 'card.archived';--> statement-breakpoint
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.deleted' BEFORE 'card.archived';--> statement-breakpoint
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.added' BEFORE 'card.archived';--> statement-breakpoint
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.updated' BEFORE 'card.archived';--> statement-breakpoint
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.completed' BEFORE 'card.archived';--> statement-breakpoint
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.uncompleted' BEFORE 'card.archived';--> statement-breakpoint
|
||||
ALTER TYPE "public"."card_activity_type" ADD VALUE 'card.updated.checklist.item.deleted' BEFORE 'card.archived';
|
||||
23
packages/db/migrations/20250902085406_AddSubscriptions.sql
Normal file
23
packages/db/migrations/20250902085406_AddSubscriptions.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
CREATE TABLE IF NOT EXISTS "subscription" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"plan" varchar(255) NOT NULL,
|
||||
"referenceId" varchar(12) NOT NULL,
|
||||
"stripeCustomerId" varchar(255),
|
||||
"stripeSubscriptionId" varchar(255),
|
||||
"status" varchar(255) NOT NULL,
|
||||
"periodStart" timestamp,
|
||||
"periodEnd" timestamp,
|
||||
"cancelAtPeriodEnd" boolean,
|
||||
"seats" integer,
|
||||
"trialStart" timestamp,
|
||||
"trialEnd" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"updatedAt" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "subscription" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_referenceId_workspace_publicId_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."workspace"("publicId") ON DELETE no action ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "subscription" ADD COLUMN "unlimitedSeats" boolean DEFAULT false NOT NULL;
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "91f9a2fa-31e2-4f3a-bdb9-852292fd7501",
|
||||
"prevId": "058e8521-2814-46f0-a33f-1bbd86f450ce",
|
||||
"prevId": "d170c8e0-bf75-4c71-abd9-0ecbeeb14003",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -92,13 +92,9 @@
|
||||
"account_userId_user_id_fk": {
|
||||
"name": "account_userId_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsFrom": ["userId"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
@@ -245,13 +241,9 @@
|
||||
"apiKey_userId_user_id_fk": {
|
||||
"name": "apiKey_userId_user_id_fk",
|
||||
"tableFrom": "apiKey",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsFrom": ["userId"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
@@ -320,13 +312,9 @@
|
||||
"session_userId_user_id_fk": {
|
||||
"name": "session_userId_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsFrom": ["userId"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
@@ -335,9 +323,7 @@
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"columns": [
|
||||
"token"
|
||||
],
|
||||
"columns": ["token"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -523,52 +509,36 @@
|
||||
"board_createdBy_user_id_fk": {
|
||||
"name": "board_createdBy_user_id_fk",
|
||||
"tableFrom": "board",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"board_deletedBy_user_id_fk": {
|
||||
"name": "board_deletedBy_user_id_fk",
|
||||
"tableFrom": "board",
|
||||
"columnsFrom": [
|
||||
"deletedBy"
|
||||
],
|
||||
"columnsFrom": ["deletedBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"board_importId_import_id_fk": {
|
||||
"name": "board_importId_import_id_fk",
|
||||
"tableFrom": "board",
|
||||
"columnsFrom": [
|
||||
"importId"
|
||||
],
|
||||
"columnsFrom": ["importId"],
|
||||
"tableTo": "import",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "no action"
|
||||
},
|
||||
"board_workspaceId_workspace_id_fk": {
|
||||
"name": "board_workspaceId_workspace_id_fk",
|
||||
"tableFrom": "board",
|
||||
"columnsFrom": [
|
||||
"workspaceId"
|
||||
],
|
||||
"columnsFrom": ["workspaceId"],
|
||||
"tableTo": "workspace",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
@@ -577,9 +547,7 @@
|
||||
"uniqueConstraints": {
|
||||
"board_publicId_unique": {
|
||||
"name": "board_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -713,91 +681,63 @@
|
||||
"card_activity_cardId_card_id_fk": {
|
||||
"name": "card_activity_cardId_card_id_fk",
|
||||
"tableFrom": "card_activity",
|
||||
"columnsFrom": [
|
||||
"cardId"
|
||||
],
|
||||
"columnsFrom": ["cardId"],
|
||||
"tableTo": "card",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"card_activity_fromListId_list_id_fk": {
|
||||
"name": "card_activity_fromListId_list_id_fk",
|
||||
"tableFrom": "card_activity",
|
||||
"columnsFrom": [
|
||||
"fromListId"
|
||||
],
|
||||
"columnsFrom": ["fromListId"],
|
||||
"tableTo": "list",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"card_activity_toListId_list_id_fk": {
|
||||
"name": "card_activity_toListId_list_id_fk",
|
||||
"tableFrom": "card_activity",
|
||||
"columnsFrom": [
|
||||
"toListId"
|
||||
],
|
||||
"columnsFrom": ["toListId"],
|
||||
"tableTo": "list",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"card_activity_labelId_label_id_fk": {
|
||||
"name": "card_activity_labelId_label_id_fk",
|
||||
"tableFrom": "card_activity",
|
||||
"columnsFrom": [
|
||||
"labelId"
|
||||
],
|
||||
"columnsFrom": ["labelId"],
|
||||
"tableTo": "label",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"card_activity_workspaceMemberId_workspace_members_id_fk": {
|
||||
"name": "card_activity_workspaceMemberId_workspace_members_id_fk",
|
||||
"tableFrom": "card_activity",
|
||||
"columnsFrom": [
|
||||
"workspaceMemberId"
|
||||
],
|
||||
"columnsFrom": ["workspaceMemberId"],
|
||||
"tableTo": "workspace_members",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"card_activity_createdBy_user_id_fk": {
|
||||
"name": "card_activity_createdBy_user_id_fk",
|
||||
"tableFrom": "card_activity",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"card_activity_commentId_card_comments_id_fk": {
|
||||
"name": "card_activity_commentId_card_comments_id_fk",
|
||||
"tableFrom": "card_activity",
|
||||
"columnsFrom": [
|
||||
"commentId"
|
||||
],
|
||||
"columnsFrom": ["commentId"],
|
||||
"tableTo": "card_comments",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
@@ -806,9 +746,7 @@
|
||||
"uniqueConstraints": {
|
||||
"card_activity_publicId_unique": {
|
||||
"name": "card_activity_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -838,26 +776,18 @@
|
||||
"_card_workspace_members_cardId_card_id_fk": {
|
||||
"name": "_card_workspace_members_cardId_card_id_fk",
|
||||
"tableFrom": "_card_workspace_members",
|
||||
"columnsFrom": [
|
||||
"cardId"
|
||||
],
|
||||
"columnsFrom": ["cardId"],
|
||||
"tableTo": "card",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"_card_workspace_members_workspaceMemberId_workspace_members_id_fk": {
|
||||
"name": "_card_workspace_members_workspaceMemberId_workspace_members_id_fk",
|
||||
"tableFrom": "_card_workspace_members",
|
||||
"columnsFrom": [
|
||||
"workspaceMemberId"
|
||||
],
|
||||
"columnsFrom": ["workspaceMemberId"],
|
||||
"tableTo": "workspace_members",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
@@ -865,10 +795,7 @@
|
||||
"compositePrimaryKeys": {
|
||||
"_card_workspace_members_cardId_workspaceMemberId_pk": {
|
||||
"name": "_card_workspace_members_cardId_workspaceMemberId_pk",
|
||||
"columns": [
|
||||
"cardId",
|
||||
"workspaceMemberId"
|
||||
]
|
||||
"columns": ["cardId", "workspaceMemberId"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -959,52 +886,36 @@
|
||||
"card_createdBy_user_id_fk": {
|
||||
"name": "card_createdBy_user_id_fk",
|
||||
"tableFrom": "card",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"card_deletedBy_user_id_fk": {
|
||||
"name": "card_deletedBy_user_id_fk",
|
||||
"tableFrom": "card",
|
||||
"columnsFrom": [
|
||||
"deletedBy"
|
||||
],
|
||||
"columnsFrom": ["deletedBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"card_listId_list_id_fk": {
|
||||
"name": "card_listId_list_id_fk",
|
||||
"tableFrom": "card",
|
||||
"columnsFrom": [
|
||||
"listId"
|
||||
],
|
||||
"columnsFrom": ["listId"],
|
||||
"tableTo": "list",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"card_importId_import_id_fk": {
|
||||
"name": "card_importId_import_id_fk",
|
||||
"tableFrom": "card",
|
||||
"columnsFrom": [
|
||||
"importId"
|
||||
],
|
||||
"columnsFrom": ["importId"],
|
||||
"tableTo": "import",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "no action"
|
||||
}
|
||||
@@ -1013,9 +924,7 @@
|
||||
"uniqueConstraints": {
|
||||
"card_publicId_unique": {
|
||||
"name": "card_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1045,26 +954,18 @@
|
||||
"_card_labels_cardId_card_id_fk": {
|
||||
"name": "_card_labels_cardId_card_id_fk",
|
||||
"tableFrom": "_card_labels",
|
||||
"columnsFrom": [
|
||||
"cardId"
|
||||
],
|
||||
"columnsFrom": ["cardId"],
|
||||
"tableTo": "card",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"_card_labels_labelId_label_id_fk": {
|
||||
"name": "_card_labels_labelId_label_id_fk",
|
||||
"tableFrom": "_card_labels",
|
||||
"columnsFrom": [
|
||||
"labelId"
|
||||
],
|
||||
"columnsFrom": ["labelId"],
|
||||
"tableTo": "label",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
@@ -1072,10 +973,7 @@
|
||||
"compositePrimaryKeys": {
|
||||
"_card_labels_cardId_labelId_pk": {
|
||||
"name": "_card_labels_cardId_labelId_pk",
|
||||
"columns": [
|
||||
"cardId",
|
||||
"labelId"
|
||||
]
|
||||
"columns": ["cardId", "labelId"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
@@ -1148,39 +1046,27 @@
|
||||
"card_comments_cardId_card_id_fk": {
|
||||
"name": "card_comments_cardId_card_id_fk",
|
||||
"tableFrom": "card_comments",
|
||||
"columnsFrom": [
|
||||
"cardId"
|
||||
],
|
||||
"columnsFrom": ["cardId"],
|
||||
"tableTo": "card",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"card_comments_createdBy_user_id_fk": {
|
||||
"name": "card_comments_createdBy_user_id_fk",
|
||||
"tableFrom": "card_comments",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"card_comments_deletedBy_user_id_fk": {
|
||||
"name": "card_comments_deletedBy_user_id_fk",
|
||||
"tableFrom": "card_comments",
|
||||
"columnsFrom": [
|
||||
"deletedBy"
|
||||
],
|
||||
"columnsFrom": ["deletedBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
}
|
||||
@@ -1189,9 +1075,7 @@
|
||||
"uniqueConstraints": {
|
||||
"card_comments_publicId_unique": {
|
||||
"name": "card_comments_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1253,13 +1137,9 @@
|
||||
"feedback_createdBy_user_id_fk": {
|
||||
"name": "feedback_createdBy_user_id_fk",
|
||||
"tableFrom": "feedback",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
}
|
||||
@@ -1319,13 +1199,9 @@
|
||||
"import_createdBy_user_id_fk": {
|
||||
"name": "import_createdBy_user_id_fk",
|
||||
"tableFrom": "import",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
}
|
||||
@@ -1334,9 +1210,7 @@
|
||||
"uniqueConstraints": {
|
||||
"import_publicId_unique": {
|
||||
"name": "import_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1421,52 +1295,36 @@
|
||||
"label_createdBy_user_id_fk": {
|
||||
"name": "label_createdBy_user_id_fk",
|
||||
"tableFrom": "label",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"label_boardId_board_id_fk": {
|
||||
"name": "label_boardId_board_id_fk",
|
||||
"tableFrom": "label",
|
||||
"columnsFrom": [
|
||||
"boardId"
|
||||
],
|
||||
"columnsFrom": ["boardId"],
|
||||
"tableTo": "board",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"label_importId_import_id_fk": {
|
||||
"name": "label_importId_import_id_fk",
|
||||
"tableFrom": "label",
|
||||
"columnsFrom": [
|
||||
"importId"
|
||||
],
|
||||
"columnsFrom": ["importId"],
|
||||
"tableTo": "import",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "no action"
|
||||
},
|
||||
"label_deletedBy_user_id_fk": {
|
||||
"name": "label_deletedBy_user_id_fk",
|
||||
"tableFrom": "label",
|
||||
"columnsFrom": [
|
||||
"deletedBy"
|
||||
],
|
||||
"columnsFrom": ["deletedBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
}
|
||||
@@ -1475,9 +1333,7 @@
|
||||
"uniqueConstraints": {
|
||||
"label_publicId_unique": {
|
||||
"name": "label_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1562,52 +1418,36 @@
|
||||
"list_createdBy_user_id_fk": {
|
||||
"name": "list_createdBy_user_id_fk",
|
||||
"tableFrom": "list",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"list_deletedBy_user_id_fk": {
|
||||
"name": "list_deletedBy_user_id_fk",
|
||||
"tableFrom": "list",
|
||||
"columnsFrom": [
|
||||
"deletedBy"
|
||||
],
|
||||
"columnsFrom": ["deletedBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"list_boardId_board_id_fk": {
|
||||
"name": "list_boardId_board_id_fk",
|
||||
"tableFrom": "list",
|
||||
"columnsFrom": [
|
||||
"boardId"
|
||||
],
|
||||
"columnsFrom": ["boardId"],
|
||||
"tableTo": "board",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"list_importId_import_id_fk": {
|
||||
"name": "list_importId_import_id_fk",
|
||||
"tableFrom": "list",
|
||||
"columnsFrom": [
|
||||
"importId"
|
||||
],
|
||||
"columnsFrom": ["importId"],
|
||||
"tableTo": "import",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "no action"
|
||||
}
|
||||
@@ -1616,9 +1456,7 @@
|
||||
"uniqueConstraints": {
|
||||
"list_publicId_unique": {
|
||||
"name": "list_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1688,9 +1526,7 @@
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"columns": [
|
||||
"email"
|
||||
],
|
||||
"columns": ["email"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1722,9 +1558,7 @@
|
||||
"uniqueConstraints": {
|
||||
"workspace_slugs_slug_unique": {
|
||||
"name": "workspace_slugs_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
],
|
||||
"columns": ["slug"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1818,39 +1652,27 @@
|
||||
"workspace_members_userId_user_id_fk": {
|
||||
"name": "workspace_members_userId_user_id_fk",
|
||||
"tableFrom": "workspace_members",
|
||||
"columnsFrom": [
|
||||
"userId"
|
||||
],
|
||||
"columnsFrom": ["userId"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"workspace_members_workspaceId_workspace_id_fk": {
|
||||
"name": "workspace_members_workspaceId_workspace_id_fk",
|
||||
"tableFrom": "workspace_members",
|
||||
"columnsFrom": [
|
||||
"workspaceId"
|
||||
],
|
||||
"columnsFrom": ["workspaceId"],
|
||||
"tableTo": "workspace",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
},
|
||||
"workspace_members_deletedBy_user_id_fk": {
|
||||
"name": "workspace_members_deletedBy_user_id_fk",
|
||||
"tableFrom": "workspace_members",
|
||||
"columnsFrom": [
|
||||
"deletedBy"
|
||||
],
|
||||
"columnsFrom": ["deletedBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
}
|
||||
@@ -1859,9 +1681,7 @@
|
||||
"uniqueConstraints": {
|
||||
"workspace_members_publicId_unique": {
|
||||
"name": "workspace_members_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1948,26 +1768,18 @@
|
||||
"workspace_createdBy_user_id_fk": {
|
||||
"name": "workspace_createdBy_user_id_fk",
|
||||
"tableFrom": "workspace",
|
||||
"columnsFrom": [
|
||||
"createdBy"
|
||||
],
|
||||
"columnsFrom": ["createdBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
},
|
||||
"workspace_deletedBy_user_id_fk": {
|
||||
"name": "workspace_deletedBy_user_id_fk",
|
||||
"tableFrom": "workspace",
|
||||
"columnsFrom": [
|
||||
"deletedBy"
|
||||
],
|
||||
"columnsFrom": ["deletedBy"],
|
||||
"tableTo": "user",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsTo": ["id"],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "set null"
|
||||
}
|
||||
@@ -1976,16 +1788,12 @@
|
||||
"uniqueConstraints": {
|
||||
"workspace_publicId_unique": {
|
||||
"name": "workspace_publicId_unique",
|
||||
"columns": [
|
||||
"publicId"
|
||||
],
|
||||
"columns": ["publicId"],
|
||||
"nullsNotDistinct": false
|
||||
},
|
||||
"workspace_slug_unique": {
|
||||
"name": "workspace_slug_unique",
|
||||
"columns": [
|
||||
"slug"
|
||||
],
|
||||
"columns": ["slug"],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
@@ -1998,10 +1806,7 @@
|
||||
"public.board_visibility": {
|
||||
"name": "board_visibility",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"private",
|
||||
"public"
|
||||
]
|
||||
"values": ["private", "public"]
|
||||
},
|
||||
"public.card_activity_type": {
|
||||
"name": "card_activity_type",
|
||||
@@ -2025,53 +1830,32 @@
|
||||
"public.source": {
|
||||
"name": "source",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"trello"
|
||||
]
|
||||
"values": ["trello"]
|
||||
},
|
||||
"public.status": {
|
||||
"name": "status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"started",
|
||||
"success",
|
||||
"failed"
|
||||
]
|
||||
"values": ["started", "success", "failed"]
|
||||
},
|
||||
"public.role": {
|
||||
"name": "role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"admin",
|
||||
"member",
|
||||
"guest"
|
||||
]
|
||||
"values": ["admin", "member", "guest"]
|
||||
},
|
||||
"public.member_status": {
|
||||
"name": "member_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"invited",
|
||||
"active",
|
||||
"removed"
|
||||
]
|
||||
"values": ["invited", "active", "removed"]
|
||||
},
|
||||
"public.slug_type": {
|
||||
"name": "slug_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"reserved",
|
||||
"premium"
|
||||
]
|
||||
"values": ["reserved", "premium"]
|
||||
},
|
||||
"public.workspace_plan": {
|
||||
"name": "workspace_plan",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"free",
|
||||
"pro",
|
||||
"enterprise"
|
||||
]
|
||||
"values": ["free", "pro", "enterprise"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
@@ -2084,4 +1868,4 @@
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
2415
packages/db/migrations/meta/20250806202106_snapshot.json
Normal file
2415
packages/db/migrations/meta/20250806202106_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2423
packages/db/migrations/meta/20250813141748_snapshot.json
Normal file
2423
packages/db/migrations/meta/20250813141748_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2536
packages/db/migrations/meta/20250902085406_snapshot.json
Normal file
2536
packages/db/migrations/meta/20250902085406_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2543
packages/db/migrations/meta/20250907185512_snapshot.json
Normal file
2543
packages/db/migrations/meta/20250907185512_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -50,6 +50,34 @@
|
||||
"when": 1749585889984,
|
||||
"tag": "20250610200449_ReaddIntegrationTable",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 7,
|
||||
"version": "7",
|
||||
"when": 1754511666265,
|
||||
"tag": "20250806202106_glossy_luminals",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "7",
|
||||
"when": 1755094668761,
|
||||
"tag": "20250813141748_AddChecklistActivityTypes",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1756803246096,
|
||||
"tag": "20250902085406_AddSubscriptions",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 10,
|
||||
"version": "7",
|
||||
"when": 1757271312974,
|
||||
"tag": "20250907185512_AddUnlimitedSeatsToSubscription",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,7 @@
|
||||
"with-env": "dotenv -e ../../.env --"
|
||||
},
|
||||
"dependencies": {
|
||||
"@electric-sql/pglite": "^0.3.7",
|
||||
"@kan/shared": "workspace:^",
|
||||
"drizzle-orm": "^0.42.0",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
||||
import { PGlite } from "@electric-sql/pglite";
|
||||
import { uuid_ossp } from "@electric-sql/pglite/contrib/uuid_ossp";
|
||||
import { drizzle as drizzlePg } from "drizzle-orm/node-postgres";
|
||||
import { drizzle as drizzlePgLite } from "drizzle-orm/pglite";
|
||||
import { migrate } from "drizzle-orm/pglite/migrator";
|
||||
import { Pool } from "pg";
|
||||
|
||||
import * as schema from "./schema";
|
||||
@@ -12,7 +16,17 @@ export const createDrizzleClient = (): dbClient => {
|
||||
const connectionString = process.env.POSTGRES_URL;
|
||||
|
||||
if (!connectionString) {
|
||||
throw new Error("POSTGRES_URL environment variable is not set");
|
||||
console.log("POSTGRES_URL environment variable is not set, using PGLite");
|
||||
|
||||
const client = new PGlite({
|
||||
dataDir: "./pgdata",
|
||||
extensions: { uuid_ossp },
|
||||
});
|
||||
const db = drizzlePgLite(client, { schema });
|
||||
|
||||
migrate(db, { migrationsFolder: "../../packages/db/migrations" });
|
||||
|
||||
return db as unknown as dbClient;
|
||||
}
|
||||
|
||||
const pool = new Pool({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, exists, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, inArray, isNull, or } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
cards,
|
||||
cardsToLabels,
|
||||
cardToWorkspaceMembers,
|
||||
checklistItems,
|
||||
checklists,
|
||||
labels,
|
||||
lists,
|
||||
workspaceMembers,
|
||||
@@ -164,6 +166,27 @@ export const getByPublicId = async (
|
||||
},
|
||||
},
|
||||
},
|
||||
checklists: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
index: true,
|
||||
},
|
||||
where: isNull(checklists.deletedAt),
|
||||
orderBy: asc(checklists.index),
|
||||
with: {
|
||||
items: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
title: true,
|
||||
completed: true,
|
||||
index: true,
|
||||
},
|
||||
where: isNull(checklistItems.deletedAt),
|
||||
orderBy: asc(checklistItems.index),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
||||
@@ -201,6 +224,7 @@ export const getByPublicId = async (
|
||||
export const getBySlug = async (
|
||||
db: dbClient,
|
||||
boardSlug: string,
|
||||
workspaceId: number,
|
||||
filters: {
|
||||
members: string[];
|
||||
labels: string[];
|
||||
@@ -279,6 +303,27 @@ export const getBySlug = async (
|
||||
},
|
||||
},
|
||||
},
|
||||
checklists: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
index: true,
|
||||
},
|
||||
where: isNull(checklists.deletedAt),
|
||||
orderBy: asc(checklists.index),
|
||||
with: {
|
||||
items: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
title: true,
|
||||
completed: true,
|
||||
index: true,
|
||||
},
|
||||
where: isNull(checklistItems.deletedAt),
|
||||
orderBy: asc(checklistItems.index),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
cardIds.length > 0 ? inArray(cards.publicId, cardIds) : undefined,
|
||||
@@ -293,6 +338,7 @@ export const getBySlug = async (
|
||||
},
|
||||
where: and(
|
||||
eq(boards.slug, boardSlug),
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
isNull(boards.deletedAt),
|
||||
eq(boards.visibility, "public"),
|
||||
),
|
||||
@@ -481,30 +527,18 @@ export const getWorkspaceAndBoardIdByBoardPublicId = async (
|
||||
export const isBoardSlugAvailable = async (
|
||||
db: dbClient,
|
||||
boardSlug: string,
|
||||
boardPublicId: string,
|
||||
workspaceId: number,
|
||||
) => {
|
||||
const result = await db
|
||||
.select({ id: boards.id })
|
||||
.from(boards)
|
||||
.where(
|
||||
and(
|
||||
eq(boards.publicId, boardPublicId),
|
||||
exists(
|
||||
db
|
||||
.select({ id: boards.id })
|
||||
.from(boards)
|
||||
.where(
|
||||
and(
|
||||
eq(boards.slug, boardSlug),
|
||||
eq(boards.workspaceId, sql`${boards.workspaceId}`), // Reference outer query's workspaceId
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1),
|
||||
),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
const result = await db.query.boards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
},
|
||||
where: and(
|
||||
eq(boards.slug, boardSlug),
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
});
|
||||
|
||||
return result.length === 0;
|
||||
return result === undefined;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
cards,
|
||||
cardsToLabels,
|
||||
cardToWorkspaceMembers,
|
||||
checklistItems,
|
||||
checklists,
|
||||
labels,
|
||||
lists,
|
||||
workspaceMembers,
|
||||
@@ -300,6 +302,27 @@ export const getWithListAndMembersByPublicId = async (
|
||||
},
|
||||
},
|
||||
},
|
||||
checklists: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
index: true,
|
||||
},
|
||||
where: isNull(checklists.deletedAt),
|
||||
orderBy: asc(checklists.index),
|
||||
with: {
|
||||
items: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
title: true,
|
||||
completed: true,
|
||||
index: true,
|
||||
},
|
||||
where: isNull(checklistItems.deletedAt),
|
||||
orderBy: asc(checklistItems.index),
|
||||
},
|
||||
},
|
||||
},
|
||||
list: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
|
||||
216
packages/db/src/repository/checklist.repo.ts
Normal file
216
packages/db/src/repository/checklist.repo.ts
Normal file
@@ -0,0 +1,216 @@
|
||||
import { and, desc, eq, isNull } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { checklistItems, checklists } from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const create = async (
|
||||
db: dbClient,
|
||||
checklistInput: {
|
||||
cardId: number;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
},
|
||||
) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const card = await tx.query.checklists.findFirst({
|
||||
where: and(
|
||||
eq(checklists.cardId, checklistInput.cardId),
|
||||
isNull(checklists.deletedAt),
|
||||
),
|
||||
orderBy: desc(checklists.index),
|
||||
});
|
||||
|
||||
const [result] = await tx
|
||||
.insert(checklists)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
name: checklistInput.name,
|
||||
createdBy: checklistInput.createdBy,
|
||||
cardId: checklistInput.cardId,
|
||||
index: card ? card.index + 1 : 0,
|
||||
})
|
||||
.returning({
|
||||
id: checklists.id,
|
||||
publicId: checklists.publicId,
|
||||
name: checklists.name,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
export const createItem = async (
|
||||
db: dbClient,
|
||||
checklistItemInput: {
|
||||
checklistId: number;
|
||||
title: string;
|
||||
createdBy: string;
|
||||
},
|
||||
) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const lastItem = await tx.query.checklistItems.findFirst({
|
||||
where: and(
|
||||
eq(checklistItems.checklistId, checklistItemInput.checklistId),
|
||||
isNull(checklistItems.deletedAt),
|
||||
),
|
||||
orderBy: desc(checklistItems.index),
|
||||
});
|
||||
|
||||
const [result] = await tx
|
||||
.insert(checklistItems)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
title: checklistItemInput.title,
|
||||
createdBy: checklistItemInput.createdBy,
|
||||
checklistId: checklistItemInput.checklistId,
|
||||
index: lastItem ? lastItem.index + 1 : 0,
|
||||
completed: false,
|
||||
})
|
||||
.returning({
|
||||
id: checklistItems.id,
|
||||
publicId: checklistItems.publicId,
|
||||
title: checklistItems.title,
|
||||
completed: checklistItems.completed,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
};
|
||||
|
||||
export const getChecklistByPublicId = async (
|
||||
db: dbClient,
|
||||
checklistPublicId: string,
|
||||
) => {
|
||||
const checklist = await db.query.checklists.findFirst({
|
||||
where: and(
|
||||
eq(checklists.publicId, checklistPublicId),
|
||||
isNull(checklists.deletedAt),
|
||||
),
|
||||
with: {
|
||||
card: {
|
||||
with: {
|
||||
list: {
|
||||
with: {
|
||||
board: {
|
||||
with: {
|
||||
workspace: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return checklist;
|
||||
};
|
||||
|
||||
export const getChecklistItemByPublicIdWithChecklist = async (
|
||||
db: dbClient,
|
||||
checklistItemPublicId: string,
|
||||
) => {
|
||||
const item = await db.query.checklistItems.findFirst({
|
||||
where: and(
|
||||
eq(checklistItems.publicId, checklistItemPublicId),
|
||||
isNull(checklistItems.deletedAt),
|
||||
),
|
||||
with: {
|
||||
checklist: {
|
||||
with: {
|
||||
card: {
|
||||
with: {
|
||||
list: {
|
||||
with: {
|
||||
board: {
|
||||
with: { workspace: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return item;
|
||||
};
|
||||
|
||||
export const updateItemById = async (
|
||||
db: dbClient,
|
||||
args: { id: number; title?: string; completed?: boolean },
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(checklistItems)
|
||||
.set({
|
||||
...(args.title !== undefined ? { title: args.title } : {}),
|
||||
...(args.completed !== undefined ? { completed: args.completed } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(checklistItems.id, args.id))
|
||||
.returning({
|
||||
publicId: checklistItems.publicId,
|
||||
title: checklistItems.title,
|
||||
completed: checklistItems.completed,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const softDeleteItemById = async (
|
||||
db: dbClient,
|
||||
args: { id: number; deletedAt: Date; deletedBy: string },
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(checklistItems)
|
||||
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
|
||||
.where(eq(checklistItems.id, args.id))
|
||||
.returning({ id: checklistItems.id });
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const softDeleteAllItemsByChecklistId = async (
|
||||
db: dbClient,
|
||||
args: { checklistId: number; deletedAt: Date; deletedBy: string },
|
||||
) => {
|
||||
const result = await db
|
||||
.update(checklistItems)
|
||||
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
|
||||
.where(
|
||||
and(
|
||||
eq(checklistItems.checklistId, args.checklistId),
|
||||
isNull(checklistItems.deletedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: checklistItems.id });
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const softDeleteById = async (
|
||||
db: dbClient,
|
||||
args: { id: number; deletedAt: Date; deletedBy: string },
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(checklists)
|
||||
.set({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
|
||||
.where(eq(checklists.id, args.id))
|
||||
.returning({ id: checklists.id });
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateChecklistById = async (
|
||||
db: dbClient,
|
||||
args: { id: number; name: string },
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(checklists)
|
||||
.set({ name: args.name, updatedAt: new Date() })
|
||||
.where(eq(checklists.id, args.id))
|
||||
.returning({ publicId: checklists.publicId, name: checklists.name });
|
||||
return result;
|
||||
};
|
||||
91
packages/db/src/repository/subscription.repo.ts
Normal file
91
packages/db/src/repository/subscription.repo.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { subscription } from "@kan/db/schema";
|
||||
|
||||
export const updateById = async (
|
||||
db: dbClient,
|
||||
subscriptionId: number,
|
||||
updates: {
|
||||
unlimitedSeats?: boolean;
|
||||
status?: string;
|
||||
seats?: number | null;
|
||||
periodStart?: Date | null;
|
||||
periodEnd?: Date | null;
|
||||
cancelAtPeriodEnd?: boolean | null;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(subscription)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(subscription.id, subscriptionId))
|
||||
.returning({
|
||||
id: subscription.id,
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
unlimitedSeats: subscription.unlimitedSeats,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const updateByStripeSubscriptionId = async (
|
||||
db: dbClient,
|
||||
stripeSubscriptionId: string,
|
||||
updates: {
|
||||
unlimitedSeats?: boolean;
|
||||
status?: string;
|
||||
seats?: number | null;
|
||||
periodStart?: Date | null;
|
||||
periodEnd?: Date | null;
|
||||
cancelAtPeriodEnd?: boolean | null;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.update(subscription)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(subscription.stripeSubscriptionId, stripeSubscriptionId))
|
||||
.returning({
|
||||
id: subscription.id,
|
||||
plan: subscription.plan,
|
||||
status: subscription.status,
|
||||
unlimitedSeats: subscription.unlimitedSeats,
|
||||
});
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const getByStripeSubscriptionId = async (
|
||||
db: dbClient,
|
||||
stripeSubscriptionId: string,
|
||||
) => {
|
||||
return await db.query.subscription.findFirst({
|
||||
where: eq(subscription.stripeSubscriptionId, stripeSubscriptionId),
|
||||
});
|
||||
};
|
||||
|
||||
export const getByReferenceId = async (db: dbClient, referenceId: string) => {
|
||||
return await db.query.subscription.findMany({
|
||||
where: eq(subscription.referenceId, referenceId),
|
||||
});
|
||||
};
|
||||
|
||||
export const create = async (
|
||||
db: dbClient,
|
||||
data: {
|
||||
plan: string;
|
||||
referenceId: string;
|
||||
userId: string;
|
||||
stripeCustomerId: string;
|
||||
status: string;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db.insert(subscription).values(data).returning();
|
||||
return result;
|
||||
};
|
||||
@@ -121,6 +121,17 @@ export const getByPublicIdWithMembers = (
|
||||
},
|
||||
},
|
||||
},
|
||||
subscriptions: {
|
||||
columns: {
|
||||
id: true,
|
||||
plan: true,
|
||||
status: true,
|
||||
seats: true,
|
||||
unlimitedSeats: true,
|
||||
periodStart: true,
|
||||
periodEnd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
eq(workspaces.publicId, workspacePublicId),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { checklists } from "./checklists";
|
||||
import { imports } from "./imports";
|
||||
import { labels } from "./labels";
|
||||
import { lists } from "./lists";
|
||||
@@ -31,6 +32,15 @@ export const activityTypes = [
|
||||
"card.updated.comment.added",
|
||||
"card.updated.comment.updated",
|
||||
"card.updated.comment.deleted",
|
||||
// Checklist activities
|
||||
"card.updated.checklist.added",
|
||||
"card.updated.checklist.renamed",
|
||||
"card.updated.checklist.deleted",
|
||||
"card.updated.checklist.item.added",
|
||||
"card.updated.checklist.item.updated",
|
||||
"card.updated.checklist.item.completed",
|
||||
"card.updated.checklist.item.uncompleted",
|
||||
"card.updated.checklist.item.deleted",
|
||||
"card.archived",
|
||||
] as const;
|
||||
|
||||
@@ -84,6 +94,7 @@ export const cardsRelations = relations(cards, ({ one, many }) => ({
|
||||
}),
|
||||
comments: many(comments),
|
||||
activities: many(cardActivities),
|
||||
checklists: many(checklists),
|
||||
}));
|
||||
|
||||
export const cardActivities = pgTable("card_activity", {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user