Compare commits
14 Commits
v0.4.3
...
fix/featur
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55f916e9cc | ||
|
|
81c4d935cd | ||
|
|
756d93c067 | ||
|
|
c7288cbea8 | ||
|
|
5f9360c927 | ||
|
|
a7a27e7054 | ||
|
|
362381f516 | ||
|
|
fc692cb411 | ||
|
|
adbb25edd7 | ||
|
|
6059fa35a2 | ||
|
|
60355c762f | ||
|
|
b159b48c35 | ||
|
|
7ab30acfc4 | ||
|
|
fed24d1960 |
@@ -1,6 +1,6 @@
|
||||
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
|
||||
|
||||
# Required environment variables
|
||||
# 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)
|
||||
|
||||
@@ -18,6 +18,9 @@ SMTP_PASSWORD=
|
||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||
SMTP_SECURE= # set to "false" to use port 587
|
||||
|
||||
# Switch email features off entirely (optional)
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||
- 💬 **Comments**: Discuss and collaborate with your team
|
||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
||||
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
|
||||
- 🎨 **Templates** : Save time with reusable custom board templates
|
||||
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||
|
||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
@@ -147,6 +147,7 @@ pnpm dev
|
||||
| `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_DISABLE_EMAIL` | To disable all email features | 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` |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru"]
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
|
||||
@@ -3,8 +3,10 @@ checksums:
|
||||
8033ed03a24d7c4898c90a5365297c20:
|
||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
||||
"%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
|
||||
@@ -13,6 +15,7 @@ checksums:
|
||||
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
||||
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
||||
@@ -36,6 +39,7 @@ checksums:
|
||||
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
|
||||
Anyone%20with%20this%20link%20can%20join%20your%20workspace/singular: 2366ed295eb2c03c425559c24cb31606
|
||||
API/singular: 01d9819514e27056dcc69463194b63d2
|
||||
API%20key%20created/singular: 8dbb2b60a719b0d120e774d6666c8c45
|
||||
API%20key%20name/singular: 2d8aeb08b2cce3b750a584bbc5ce6d1d
|
||||
@@ -47,12 +51,14 @@ checksums:
|
||||
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
||||
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%20%7B0%7D%3F/singular: e980ce9469e329ddfed14c7da2cb5e52
|
||||
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%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
|
||||
Assignees/singular: 96fc0e28a783d80decfd4716a440ac08
|
||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||
@@ -66,6 +72,7 @@ checksums:
|
||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
||||
Board%20sharing%20%26%20invites/singular: c9222fbcfac4e99dcaa859b46c163e55
|
||||
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
|
||||
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
||||
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
||||
@@ -73,7 +80,6 @@ checksums:
|
||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
@@ -84,6 +90,7 @@ checksums:
|
||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||
@@ -91,7 +98,9 @@ checksums:
|
||||
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
||||
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
||||
Comments/singular: 502237cd1044491c48494aa60967a985
|
||||
Comments%20%26%20mentions/singular: 1f3a822f4f97a9b07db82d24c4204f75
|
||||
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||
Compare%20our%20features%20to%20see%20why%20Kan%20is%20the%20best%20choice./singular: c186ed37ff27cfc2a1ac11e66b2b30f1
|
||||
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
@@ -106,33 +115,41 @@ checksums:
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
|
||||
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
|
||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
||||
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
|
||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
||||
Create%20new%20%7B0%7D/singular: 163dbd5cf86d512fadd4c3eaf9709a11
|
||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||
Create%20template/singular: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
|
||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||
Deactivate%20invite%20link/singular: 8cf4bf82e153e4dc82d026448cb4dd10
|
||||
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
|
||||
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
|
||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20template/singular: bdf3e8342f581d498b7238cdd4897a4b
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
||||
@@ -165,7 +182,10 @@ checksums:
|
||||
Enter%20your%20name/singular: cd95fbdd0533f2c2e8edf9d9bd9aa8df
|
||||
Enter%20your%20new%20password/singular: c67251e3002b68bc20a7cf5de23e43ac
|
||||
Enter%20your%20password/singular: ea4fdd034522dead21bae0c0abb52eae
|
||||
Error/singular: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
Error%20Changing%20Password/singular: ebecb5c1b72ba4b063117241f5ba4f2d
|
||||
Error%20creating%20invite%20link/singular: cbedc3f3213dfc4fdc8b7503ae1a5cd6
|
||||
Error%20deactivating%20invite%20link/singular: ccf42cd5aa8481692003e87e836de66c
|
||||
Error%20deleting%20account/singular: d42965a9bc9e5ec4ed57890268924643
|
||||
Error%20deleting%20label/singular: 94387e3a45ec768ae7715701ae00136e
|
||||
Error%20deleting%20workspace/singular: 0aec9bd8170bc84f5ea5c9a47c52ed26
|
||||
@@ -182,9 +202,14 @@ checksums:
|
||||
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
|
||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||
Fast%20%26%20lightweight/singular: 4f2a80c555a5427b94eef5124aa10775
|
||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
Feature%20breakdown/singular: cae23c2a3fbb21735289571f2e1f60db
|
||||
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
||||
Features/singular: 341ff316a339b106a178f0b8d362951b
|
||||
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
||||
@@ -199,13 +224,14 @@ checksums:
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
|
||||
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
||||
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
||||
Get%20started%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
|
||||
Get%20started%20on%20Cloud/singular: ed926f526266ad2063283c58e6f4284a
|
||||
Getting%20started/singular: 8e5e7bd026b5bec46bbfdce02ab9e0b8
|
||||
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
|
||||
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
|
||||
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
|
||||
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
|
||||
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
|
||||
@@ -222,24 +248,36 @@ checksums:
|
||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||
Import%20from%20Trello/singular: 9cce591a63763174952ffc11ae829f0c
|
||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
||||
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
|
||||
in/singular: 1f06ad6763244d5e9927853e65cc6274
|
||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||
Intelligent%20search/singular: da688e7cfb2775f719a12df6dd606f6e
|
||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
||||
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
Invite%20another/singular: acb543563dab7edbcf46060a53ade3a3
|
||||
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
|
||||
Invite%20link%20copied%20to%20clipboard/singular: 6fc055a0ea0ed1aa58c5e0c502efe17f
|
||||
Invite%20links/singular: 36ea61a3193245fa1b57d3392f342aaa
|
||||
Invite%20links%20require%20a%20Team%20or%20Pro%20subscription.%20Please%20upgrade%20your%20workspace./singular: 18db646fc55f99789572c8df0539ee69
|
||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
||||
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
|
||||
Join%20workspace%20%7C%20kan.bn/singular: 97855216a0e00214b2dcf917e93164f2
|
||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
||||
Kan/singular: dd1232a5a28bf47e939d3b8a199bc229
|
||||
Kan%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: d13f4467d3dc0a0786c3843a9b50785d
|
||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
@@ -257,6 +295,7 @@ checksums:
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||
Make%20template/singular: 7a1c63b75cea690b3fda6c3f203e31ff
|
||||
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
|
||||
@@ -269,8 +308,8 @@ checksums:
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
||||
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
|
||||
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
|
||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||
@@ -278,23 +317,29 @@ checksums:
|
||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
||||
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
||||
New%20template/singular: 10dca64383235d1596a010e559b2e35a
|
||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
||||
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
|
||||
No%20%7B0%7D/singular: a7a95e1dfdb4396da9e2be0e27f42323
|
||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
||||
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
|
||||
Password%20is%20required%20to%20sign%20up./singular: 3fa1abbf2e6ac8ac4d18c215191bdbcc
|
||||
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
|
||||
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
Paused/singular: edb1f7b7219e1c9b7aa67159090d6991
|
||||
@@ -303,12 +348,15 @@ checksums:
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||
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
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||
Price%20per%20user%2Fmonth/singular: ae4b93ad0f9fad454b40f82010372fc2
|
||||
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||
@@ -335,6 +383,7 @@ checksums:
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
@@ -342,11 +391,13 @@ checksums:
|
||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
|
||||
Search%20boards%20and%20cards.../singular: 137017b2b3e885c4894ca8c899af5bc2
|
||||
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
|
||||
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
|
||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
@@ -356,6 +407,8 @@ checksums:
|
||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
|
||||
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
Sign%20up%20%7C%20kan.bn/singular: f3de2a110c90358e6eac07d0b2f663a6
|
||||
Sign%20up%20disabled/singular: 9581b1f75b404ac0ecb7e603e0d4189c
|
||||
Sign%20up%20is%20currently%20disabled.%20Please%20try%20again%20later./singular: c6cb7c455ec053b351a27029158ff166
|
||||
@@ -365,6 +418,7 @@ checksums:
|
||||
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
|
||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||
Subscription%20Required/singular: 05f2b4abfc36def17756a6969983cf86
|
||||
Success/singular: c43827becada6750f7a25890905f38b9
|
||||
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
|
||||
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
|
||||
@@ -372,6 +426,11 @@ checksums:
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
Template/singular: 87c2704ff2033e3c76bc97a45ae57d5c
|
||||
Template%20created/singular: 2c7a515a5d8a9afb26de6365cdaa0c92
|
||||
Template%20created%20successfully/singular: 2750600da57c5cd3c3c072810c8c9604
|
||||
Template%20name%20cannot%20exceed%20100%20characters/singular: 14527c9246e558a085ceee2e8d4e6560
|
||||
Template%20name%20is%20required/singular: 34d674d59c414c2051ef5bc0a62e3c00
|
||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
@@ -385,6 +444,7 @@ checksums:
|
||||
This%20API%20key%20will%20only%20be%20shown%20once.%20Please%20save%20it%20in%20a%20secure%20location./singular: 7df18d2978d317375f780822f8321c4d
|
||||
This%20board%20is%20private%20or%20does%20not%20exist/singular: a217ff3f04463b4df8c86adb6f83c6bc
|
||||
This%20board%20URL%20has%20already%20been%20taken/singular: 1d8b40332a031b5b77a3658e48dd51ca
|
||||
This%20invitation%20link%20is%20invalid%20or%20has%20expired./singular: 11cc7ef8f1512e7e058e1fbbe5644001
|
||||
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
|
||||
This%20workspace%20URL%20has%20already%20been%20taken/singular: b455329e2a71da677acab91d3a00bad6
|
||||
@@ -402,6 +462,7 @@ checksums:
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20template/singular: 7495f1434c9188849bffd015811d5337
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
||||
@@ -427,6 +488,7 @@ checksums:
|
||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
||||
Unlimited%20workspaces/singular: b504d01997bf908af1d486d9942fed36
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||
@@ -458,6 +520,7 @@ checksums:
|
||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
|
||||
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20time%20(and%20atlassification)%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 60a55e9bd16c3d1beff1a6c7633c5911
|
||||
White%20label/singular: 2570386f73a010b07219e7658aaf9d9b
|
||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
||||
@@ -485,6 +548,8 @@ checksums:
|
||||
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
|
||||
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
|
||||
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru"],
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { SocialProvider } from "better-auth/social-providers";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
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 { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
FaApple,
|
||||
@@ -152,18 +153,29 @@ const availableSocialProviders = {
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
|
||||
const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false);
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const { showPopup } = usePopup();
|
||||
const oidcProviderName = "OIDC";
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
|
||||
// Safely get environment variables on client side to avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
const credentialsAllowed =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const emailSendingEnabled =
|
||||
env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true";
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
setIsCloudEnv(isCloudEnv);
|
||||
setIsEmailSendingEnabled(emailSendingEnabled);
|
||||
setIsCredentialsEnabled(credentialsAllowed);
|
||||
}, []);
|
||||
|
||||
@@ -183,7 +195,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
|
||||
const handleLoginWithEmail = async (
|
||||
email: string,
|
||||
password?: string,
|
||||
password?: string | null,
|
||||
name?: string,
|
||||
) => {
|
||||
setIsLoginWithEmailPending(true);
|
||||
@@ -195,7 +207,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
name,
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
@@ -212,7 +224,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
{
|
||||
email,
|
||||
password,
|
||||
callbackURL: "/boards",
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () =>
|
||||
@@ -226,16 +238,26 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
);
|
||||
}
|
||||
} else {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL: "/boards",
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
// Only allow magic link if email sending is enabled and not in sign up mode
|
||||
if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Provide a clear error feedback when password omitted but magic link unavailable
|
||||
setLoginError(
|
||||
isSignUp
|
||||
? t`Password is required to sign up.`
|
||||
: t`Password is required to login.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
@@ -250,14 +272,14 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
// Use oauth2 signin for OIDC provider
|
||||
const result = await authClient.signIn.oauth2({
|
||||
providerId: "oidc",
|
||||
callbackURL: "/boards",
|
||||
callbackURL,
|
||||
});
|
||||
error = result.error;
|
||||
} else {
|
||||
// Use social signin for traditional social providers
|
||||
const result = await authClient.signIn.social({
|
||||
provider,
|
||||
callbackURL: "/boards",
|
||||
callbackURL,
|
||||
});
|
||||
error = result.error;
|
||||
}
|
||||
@@ -272,11 +294,43 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||
// Treat empty password string as undefined to trigger magic link path
|
||||
const sanitizedPassword = values.password?.trim()
|
||||
? values.password
|
||||
: undefined;
|
||||
await handleLoginWithEmail(values.email, sanitizedPassword, values.name);
|
||||
};
|
||||
|
||||
const password = watch("password");
|
||||
|
||||
// Determine if we should operate in magic link mode for current form state (login only)
|
||||
const isMagicLinkMode = useMemo(() => {
|
||||
// Magic link only viable when email sending enabled AND not sign up.
|
||||
if (!isEmailSendingEnabled || isSignUp) return false;
|
||||
// If credentials disabled we always default to magic link.
|
||||
if (!isCredentialsEnabled) return true;
|
||||
// Credentials enabled: user chooses magic link by leaving password blank.
|
||||
return !password;
|
||||
}, [isEmailSendingEnabled, isSignUp, isCredentialsEnabled, password]);
|
||||
|
||||
// Auto-focus password field when an error indicates it's required
|
||||
useEffect(() => {
|
||||
if (!isCredentialsEnabled) return;
|
||||
// Focus when: sign up and missing password; login error requiring password; validation error on password.
|
||||
const pwdEmpty = (password ?? "").length === 0;
|
||||
let needsPassword = false;
|
||||
if (isSignUp && pwdEmpty) {
|
||||
needsPassword = true;
|
||||
} else if (loginError?.toLowerCase().includes("password")) {
|
||||
needsPassword = true;
|
||||
} else if (errors.password) {
|
||||
needsPassword = true;
|
||||
}
|
||||
if (needsPassword && passwordRef.current) {
|
||||
passwordRef.current.focus();
|
||||
}
|
||||
}, [isSignUp, password, loginError, errors.password, isCredentialsEnabled]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{socialProviders?.length !== 0 && (
|
||||
@@ -347,7 +401,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid password`}
|
||||
{errors.password.message ?? t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -364,9 +418,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
variant="secondary"
|
||||
>
|
||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||
? t`email`
|
||||
: t`magic link`}
|
||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
193
apps/web/src/components/CommandPallette.tsx
Normal file
193
apps/web/src/components/CommandPallette.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/macro";
|
||||
import { useState } from "react";
|
||||
import { HiDocumentText, HiFolder, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type SearchResult =
|
||||
| {
|
||||
publicId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
slug: string;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "board";
|
||||
}
|
||||
| {
|
||||
publicId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
boardPublicId: string;
|
||||
boardName: string;
|
||||
listName: string;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "card";
|
||||
};
|
||||
|
||||
export default function CommandPallette({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { workspace } = useWorkspace();
|
||||
const router = useRouter();
|
||||
|
||||
// Debounce to avoid too many reqs
|
||||
const [debouncedQuery] = useDebounce(query, 300);
|
||||
|
||||
const {
|
||||
data: searchResults,
|
||||
isLoading,
|
||||
isFetched,
|
||||
isPlaceholderData,
|
||||
} = api.workspace.search.useQuery(
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
query: debouncedQuery,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
// Clear results when query is empty, otherwise show search results
|
||||
const results =
|
||||
debouncedQuery.trim().length === 0
|
||||
? []
|
||||
: ((searchResults ?? []) as SearchResult[]);
|
||||
|
||||
const hasSearched = Boolean(debouncedQuery.trim().length > 0);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="relative z-50"
|
||||
open={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
|
||||
>
|
||||
<Combobox>
|
||||
<div className="grid grid-cols-1">
|
||||
<ComboboxInput
|
||||
autoFocus
|
||||
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700"
|
||||
placeholder={t`Search boards and cards...`}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && results.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// Find the active option or fallback to first option
|
||||
const targetOption =
|
||||
document.querySelector(
|
||||
'[data-headlessui-state*="active"][role="option"]',
|
||||
) ?? document.querySelector('[role="option"]');
|
||||
|
||||
if (targetOption) {
|
||||
(targetOption as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<HiMagnifyingGlass
|
||||
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ComboboxOptions
|
||||
static
|
||||
className={`max-h-72 scroll-py-2 overflow-y-auto py-2 ${
|
||||
isPlaceholderData ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
{results.map((result) => {
|
||||
const url =
|
||||
result.type === "board"
|
||||
? `/boards/${result.publicId}`
|
||||
: `/cards/${result.publicId}`;
|
||||
|
||||
return (
|
||||
<ComboboxOption
|
||||
key={`${result.type}-${result.publicId}`}
|
||||
value={result}
|
||||
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
||||
onClick={() => {
|
||||
console.log("clicked", url);
|
||||
void router.push(url);
|
||||
onClose();
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{result.type === "board" ? (
|
||||
<HiFolder className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
||||
) : (
|
||||
<HiDocumentText className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" && (
|
||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||
{`${t`in`} ${result.boardName} → ${result.listName}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxOption>
|
||||
);
|
||||
})}
|
||||
</ComboboxOptions>
|
||||
)}
|
||||
|
||||
{hasSearched &&
|
||||
!isLoading &&
|
||||
searchResults !== undefined &&
|
||||
results.length === 0 && (
|
||||
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
|
||||
{t`No results found for "${debouncedQuery}".`}
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -15,10 +16,11 @@ import membersIconDark from "~/assets/members-dark.json";
|
||||
import membersIconLight from "~/assets/members-light.json";
|
||||
import settingsIconDark from "~/assets/settings-dark.json";
|
||||
import settingsIconLight from "~/assets/settings-light.json";
|
||||
import templatesIconDark from "~/assets/templates-dark.json";
|
||||
import templatesIconLight from "~/assets/templates-light.json";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
@@ -69,6 +71,11 @@ export default function SideNavigation({
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
},
|
||||
{
|
||||
name: t`Templates`,
|
||||
href: "/templates",
|
||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||
},
|
||||
{
|
||||
name: t`Members`,
|
||||
href: "/members",
|
||||
|
||||
@@ -5,10 +5,12 @@ const Toggle = ({
|
||||
isChecked,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
@@ -17,6 +19,7 @@ const Toggle = ({
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={twMerge(
|
||||
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
|
||||
isChecked && "bg-indigo-600 dark:bg-indigo-600",
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment } from "react";
|
||||
import { HiCheck } from "react-icons/hi2";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import CommandPallette from "./CommandPallette";
|
||||
|
||||
export default function WorkspaceMenu({
|
||||
isCollapsed = false,
|
||||
@@ -15,110 +16,143 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span
|
||||
<>
|
||||
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center gap-1",
|
||||
isCollapsed && "md:flex-col-reverse md:items-stretch",
|
||||
)}
|
||||
>
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] flex-1 items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed &&
|
||||
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
<Button
|
||||
className={twMerge(
|
||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||
isCollapsed && "md:mb-2 md:h-9 md:w-9 md:self-center",
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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
@@ -1,4 +1,13 @@
|
||||
export const locales = ["en", "fr", "de", "es", "it", "nl", "ru"] as const;
|
||||
export const locales = [
|
||||
"en",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"it",
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
@@ -12,4 +21,5 @@ export const localeNames: Record<Locale, string> = {
|
||||
it: "Italiano",
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
};
|
||||
|
||||
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
2476
apps/web/src/locales/pl/messages.po
Normal file
2476
apps/web/src/locales/pl/messages.po
Normal file
File diff suppressed because it is too large
Load Diff
1
apps/web/src/locales/pl/messages.ts
Normal file
1
apps/web/src/locales/pl/messages.ts
Normal file
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
5
apps/web/src/pages/invite/[code].tsx
Normal file
5
apps/web/src/pages/invite/[code].tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import InviteView from "~/views/invite";
|
||||
|
||||
export default function InvitePage() {
|
||||
return <InviteView />;
|
||||
}
|
||||
5
apps/web/src/pages/pricing/index.tsx
Normal file
5
apps/web/src/pages/pricing/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import PricingView from "~/views/pricing";
|
||||
|
||||
export default function Pricing() {
|
||||
return <PricingView />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import CardView, { CardRightPanel } from "~/views/card";
|
||||
|
||||
const CardPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<CardView isTemplate />
|
||||
<Popup />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
CardPage.getLayout = (page) =>
|
||||
getDashboardLayout(page, <CardRightPanel isTemplate />, true);
|
||||
|
||||
export default CardPage;
|
||||
17
apps/web/src/pages/templates/[boardId]/index.tsx
Normal file
17
apps/web/src/pages/templates/[boardId]/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import BoardView from "~/views/board";
|
||||
|
||||
const TemplatePage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<BoardView isTemplate />
|
||||
<Popup />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
TemplatePage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default TemplatePage;
|
||||
17
apps/web/src/pages/templates/index.tsx
Normal file
17
apps/web/src/pages/templates/index.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { NextPageWithLayout } from "~/pages/_app";
|
||||
import { getDashboardLayout } from "~/components/Dashboard";
|
||||
import Popup from "~/components/Popup";
|
||||
import BoardsView from "~/views/boards";
|
||||
|
||||
const TemplatesPage: NextPageWithLayout = () => {
|
||||
return (
|
||||
<>
|
||||
<BoardsView isTemplate />
|
||||
<Popup />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
TemplatesPage.getLayout = (page) => getDashboardLayout(page);
|
||||
|
||||
export default TemplatesPage;
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React, { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
import { api } from "~/utils/api";
|
||||
@@ -46,6 +46,8 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
);
|
||||
const [hasLoaded, setHasLoaded] = useState(false);
|
||||
|
||||
const workspacePublicId = useSearchParams().get("workspacePublicId");
|
||||
|
||||
const { data, isLoading } = api.workspace.all.useQuery();
|
||||
const utils = api.useUtils();
|
||||
|
||||
@@ -67,7 +69,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
}
|
||||
|
||||
const storedWorkspaceId: string | null =
|
||||
localStorage.getItem("workspacePublicId");
|
||||
workspacePublicId ?? localStorage.getItem("workspacePublicId");
|
||||
|
||||
if (data.length) {
|
||||
const workspaces = data.map(({ workspace, role }) => ({
|
||||
@@ -99,6 +101,11 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
description: selectedWorkspace.workspace.description,
|
||||
role: selectedWorkspace.role,
|
||||
});
|
||||
|
||||
if (workspacePublicId) {
|
||||
router.push(`/boards`);
|
||||
localStorage.setItem("workspacePublicId", workspacePublicId);
|
||||
}
|
||||
} else {
|
||||
const primaryWorkspace = data[0]?.workspace;
|
||||
const primaryWorkspaceRole = data[0]?.role;
|
||||
@@ -114,7 +121,7 @@ export const WorkspaceProvider: React.FC<{ children: ReactNode }> = ({
|
||||
role: primaryWorkspaceRole,
|
||||
});
|
||||
}
|
||||
}, [data, isLoading]);
|
||||
}, [data, isLoading, workspacePublicId, router]);
|
||||
|
||||
return (
|
||||
<WorkspaceContext.Provider
|
||||
|
||||
@@ -25,3 +25,17 @@
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@supports not (overflow-wrap: anywhere) {
|
||||
.break-anywhere {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
.break-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ const loadMessages = async (locale: Locale) => {
|
||||
return (await import("~/locales/nl/messages")).messages;
|
||||
case "ru":
|
||||
return (await import("~/locales/ru/messages")).messages;
|
||||
case "pl":
|
||||
return (await import("~/locales/pl/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
@@ -17,6 +17,8 @@ export default function LoginPage() {
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
|
||||
const handleMagicLinkSent = (value: boolean, recipient: string) => {
|
||||
setIsMagicLinkSent(value);
|
||||
setMagicLinkRecipient(recipient);
|
||||
@@ -61,7 +63,11 @@ export default function LoginPage() {
|
||||
<Trans>
|
||||
Don't have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/signup">Sign up</Link>
|
||||
<Link
|
||||
href={redirect ? `/signup?next=${redirect}` : "/signup"}
|
||||
>
|
||||
Sign up
|
||||
</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
@@ -17,6 +17,8 @@ export default function SignUpPage() {
|
||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState<boolean>(false);
|
||||
const [magicLinkRecipient, setMagicLinkRecipient] = useState<string>("");
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
|
||||
const { data } = authClient.useSession();
|
||||
|
||||
if (data?.user.id) router.push("/boards");
|
||||
@@ -86,7 +88,9 @@ export default function SignUpPage() {
|
||||
<Trans>
|
||||
Already have an account?{" "}
|
||||
<span className="underline">
|
||||
<Link href="/login">Sign in</Link>
|
||||
<Link href={redirect ? `/login?next=${redirect}` : "/login"}>
|
||||
Sign in
|
||||
</Link>
|
||||
</span>
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
@@ -1,23 +1,51 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
|
||||
export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
boardPublicId,
|
||||
workspacePublicId,
|
||||
}: {
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
const { openModal } = useModal();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
...(isTemplate
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]),
|
||||
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Delete board`,
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -6,8 +7,10 @@ import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteBoardConfirmation({
|
||||
boardPublicId,
|
||||
isTemplate,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
isTemplate: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal } = useModal();
|
||||
@@ -15,7 +18,7 @@ export function DeleteBoardConfirmation({
|
||||
const deleteBoard = api.board.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
closeModal();
|
||||
router.push(`/boards`);
|
||||
router.push(isTemplate ? `/templates` : `/boards`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,18 +33,18 @@ export function DeleteBoardConfirmation({
|
||||
<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 board?
|
||||
{t`Are you sure you want to delete this ${isTemplate ? "template" : "board"}?`}
|
||||
</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 onClick={() => closeModal()} variant="secondary">
|
||||
Cancel
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
||||
Delete
|
||||
{t`Delete`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,11 @@ import {
|
||||
import type { NewCardInput } from "@kan/api/types";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import Editor, { WorkspaceMember } from "~/components/Editor";
|
||||
import Editor from "~/components/Editor";
|
||||
import Input from "~/components/Input";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import Toggle from "~/components/Toggle";
|
||||
@@ -35,12 +36,14 @@ interface QueryParams {
|
||||
}
|
||||
|
||||
interface NewCardFormProps {
|
||||
isTemplate: boolean;
|
||||
boardPublicId: string;
|
||||
listPublicId: string;
|
||||
queryParams: QueryParams;
|
||||
}
|
||||
|
||||
export function NewCardForm({
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
listPublicId,
|
||||
queryParams,
|
||||
@@ -85,14 +88,13 @@ export function NewCardForm({
|
||||
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"];
|
||||
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
||||
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
||||
}
|
||||
@@ -101,23 +103,23 @@ export function NewCardForm({
|
||||
// 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"];
|
||||
|
||||
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
|
||||
|
||||
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"]]);
|
||||
|
||||
}, [boardData?.labels, labelPublicIds, modalStates.NEW_LABEL_CREATED]);
|
||||
|
||||
const createCard = api.card.create.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
@@ -323,15 +325,19 @@ export function NewCardForm({
|
||||
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,
|
||||
})) ?? []
|
||||
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>
|
||||
@@ -347,42 +353,46 @@ export function NewCardForm({
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedMembers}
|
||||
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!memberPublicIds.length ? (
|
||||
t`Members`
|
||||
) : (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{memberPublicIds.map((memberPublicId) => {
|
||||
const member = formattedMembers.find(
|
||||
(member) => member.key === memberPublicId,
|
||||
);
|
||||
{!isTemplate && (
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedMembers}
|
||||
handleSelect={(_groupKey, item) =>
|
||||
handleSelectMembers(item.key)
|
||||
}
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!memberPublicIds.length ? (
|
||||
t`Members`
|
||||
) : (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{memberPublicIds.map((memberPublicId) => {
|
||||
const member = formattedMembers.find(
|
||||
(member) => member.key === memberPublicId,
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={member?.key}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
|
||||
>
|
||||
<span className="text-[8px] font-medium leading-none text-white">
|
||||
{member?.value
|
||||
.split(" ")
|
||||
.map((namePart) =>
|
||||
namePart.charAt(0).toUpperCase(),
|
||||
)
|
||||
.join("")}
|
||||
return (
|
||||
<span
|
||||
key={member?.key}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
|
||||
>
|
||||
<span className="text-[8px] font-medium leading-none text-white">
|
||||
{member?.value
|
||||
.split(" ")
|
||||
.map((namePart) =>
|
||||
namePart.charAt(0).toUpperCase(),
|
||||
)
|
||||
.join("")}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedLabels}
|
||||
|
||||
138
apps/web/src/views/board/components/NewTemplateForm.tsx
Normal file
138
apps/web/src/views/board/components/NewTemplateForm.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
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 schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Template name is required` })
|
||||
.max(100, { message: t`Template name cannot exceed 100 characters` }),
|
||||
workspacePublicId: z.string(),
|
||||
sourceBoardPublicId: z.string(),
|
||||
});
|
||||
|
||||
interface NewBoardInputWithTemplate {
|
||||
name: string;
|
||||
workspacePublicId: string;
|
||||
sourceBoardPublicId: string;
|
||||
}
|
||||
|
||||
export function NewTemplateForm({
|
||||
sourceBoardPublicId,
|
||||
workspacePublicId,
|
||||
sourceBoardName,
|
||||
}: {
|
||||
sourceBoardPublicId: string;
|
||||
workspacePublicId: string;
|
||||
sourceBoardName: string;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<NewBoardInputWithTemplate>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
name: sourceBoardName,
|
||||
workspacePublicId,
|
||||
sourceBoardPublicId,
|
||||
},
|
||||
});
|
||||
|
||||
const createBoard = api.board.create.useMutation({
|
||||
onSuccess: (newTemplate) => {
|
||||
if (!newTemplate) {
|
||||
showPopup({
|
||||
header: t`Unable to create template`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
router.push(`/templates/${newTemplate.publicId}`);
|
||||
showPopup({
|
||||
header: t`Template created`,
|
||||
message: t`Template created successfully`,
|
||||
icon: "success",
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Unable to create template`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||
createBoard.mutate({
|
||||
name: data.name,
|
||||
workspacePublicId: data.workspacePublicId,
|
||||
sourceBoardPublicId: data.sourceBoardPublicId,
|
||||
lists: [],
|
||||
labels: [],
|
||||
type: "template",
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const titleElement: HTMLElement | null =
|
||||
document.querySelector<HTMLElement>("#name");
|
||||
if (titleElement) titleElement.focus();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`New template`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
|
||||
</button>
|
||||
</div>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder={t`Name`}
|
||||
{...register("name", { required: true })}
|
||||
errorMessage={errors.name?.message}
|
||||
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" isLoading={createBoard.isPending}>
|
||||
{t`Create template`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,11 @@ import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiOutlinePlusSmall, HiOutlineSquare3Stack3D } from "react-icons/hi2";
|
||||
import {
|
||||
HiOutlinePlusSmall,
|
||||
HiOutlineRectangleStack,
|
||||
HiOutlineSquare3Stack3D,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
@@ -32,14 +36,15 @@ import Filters from "./components/Filters";
|
||||
import List from "./components/List";
|
||||
import { NewCardForm } from "./components/NewCardForm";
|
||||
import { NewListForm } from "./components/NewListForm";
|
||||
import { NewTemplateForm } from "./components/NewTemplateForm";
|
||||
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
|
||||
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||
import VisibilityButton from "./components/VisibilityButton";
|
||||
|
||||
type PublicListId = string;
|
||||
|
||||
export default function BoardPage() {
|
||||
const params = useParams() as { boardId: string[] } | null;
|
||||
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const params = useParams() as { boardId: string | string[] } | null;
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -49,7 +54,11 @@ export default function BoardPage() {
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const boardId = params?.boardId.length ? params.boardId[0] : null;
|
||||
const boardId = params?.boardId
|
||||
? Array.isArray(params.boardId)
|
||||
? params.boardId[0]
|
||||
: params.boardId
|
||||
: null;
|
||||
|
||||
const updateBoard = api.board.update.useMutation();
|
||||
|
||||
@@ -67,10 +76,16 @@ export default function BoardPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const queryParams = {
|
||||
const queryParams: {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
type: "regular" | "template";
|
||||
} = {
|
||||
boardPublicId: boardId ?? "",
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
type: isTemplate ? "template" : "regular",
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -208,7 +223,7 @@ export default function BoardPage() {
|
||||
};
|
||||
|
||||
const onDragEnd = ({
|
||||
source,
|
||||
source: _source,
|
||||
destination,
|
||||
draggableId,
|
||||
type,
|
||||
@@ -241,7 +256,10 @@ export default function BoardPage() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
||||
>
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
<DeleteBoardConfirmation
|
||||
isTemplate={!!isTemplate}
|
||||
boardPublicId={boardId ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -259,6 +277,7 @@ export default function BoardPage() {
|
||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
||||
>
|
||||
<NewCardForm
|
||||
isTemplate={!!isTemplate}
|
||||
boardPublicId={boardId ?? ""}
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
@@ -321,6 +340,17 @@ export default function BoardPage() {
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
|
||||
>
|
||||
<NewTemplateForm
|
||||
workspacePublicId={workspace.publicId ?? ""}
|
||||
sourceBoardPublicId={boardId ?? ""}
|
||||
sourceBoardName={boardData?.name ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -328,7 +358,7 @@ export default function BoardPage() {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
|
||||
title={`${(boardData?.name ?? isTemplate) ? t`Board` : t`Template`} | ${workspace.name}`}
|
||||
/>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
@@ -354,31 +384,46 @@ export default function BoardPage() {
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
{t`Board not found`}
|
||||
{t`${isTemplate ? "Template" : "Board"} not found`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
||||
<UpdateBoardSlugButton
|
||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
<Filters
|
||||
labels={boardData?.labels ?? []}
|
||||
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
|
||||
position="left"
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
{isTemplate && (
|
||||
<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">
|
||||
<HiOutlineRectangleStack />
|
||||
</span>
|
||||
{t`Template`}
|
||||
</div>
|
||||
)}
|
||||
{!isTemplate && (
|
||||
<>
|
||||
<UpdateBoardSlugButton
|
||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
{boardData && (
|
||||
<Filters
|
||||
labels={boardData.labels}
|
||||
members={boardData.workspace.members.filter(
|
||||
(member) => member.user !== null,
|
||||
)}
|
||||
position="left"
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
@@ -393,7 +438,12 @@ export default function BoardPage() {
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
<BoardDropdown isLoading={!boardData} />
|
||||
<BoardDropdown
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -475,7 +525,11 @@ export default function BoardPage() {
|
||||
e.preventDefault();
|
||||
}}
|
||||
key={card.publicId}
|
||||
href={`/cards/${card.publicId}`}
|
||||
href={
|
||||
isTemplate
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
|
||||
@@ -8,12 +8,15 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function BoardsList() {
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data, isLoading } = api.board.all.useQuery(
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
type: isTemplate ? "template" : "regular",
|
||||
},
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
|
||||
@@ -32,14 +35,14 @@ export function BoardsList() {
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack 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`No boards`}
|
||||
{t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`Get started by creating a new board`}
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||
{t`Create new board`}
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -47,7 +50,10 @@ export function BoardsList() {
|
||||
return (
|
||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||
{data?.map((board) => (
|
||||
<Link key={board.publicId} href={`boards/${board.publicId}`}>
|
||||
<Link
|
||||
key={board.publicId}
|
||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||
>
|
||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -10,9 +11,10 @@ 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";
|
||||
import TemplateBoards, { getTemplates } from "./TemplateBoards";
|
||||
import TemplateBoards from "./TemplateBoards";
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
@@ -29,13 +31,25 @@ interface NewBoardInputWithTemplate {
|
||||
template: Template | null;
|
||||
}
|
||||
|
||||
export function NewBoardForm() {
|
||||
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const utils = api.useUtils();
|
||||
const { closeModal } = useModal();
|
||||
const router = useRouter();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
const { data: templates } = api.board.all.useQuery(
|
||||
{ workspacePublicId: workspace.publicId ?? "", type: "template" },
|
||||
{ enabled: !!workspace.publicId },
|
||||
);
|
||||
|
||||
const templates = getTemplates();
|
||||
const formattedTemplates = templates?.map((template) => ({
|
||||
id: template.publicId,
|
||||
sourceBoardPublicId: template.publicId,
|
||||
name: template.name,
|
||||
lists: template.lists.map((list) => list.name),
|
||||
labels: template.labels.map((label) => label.name),
|
||||
}));
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -57,18 +71,39 @@ export function NewBoardForm() {
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const createBoard = api.board.create.useMutation({
|
||||
onSuccess: async () => {
|
||||
onSuccess: async (board) => {
|
||||
if (!board) {
|
||||
showPopup({
|
||||
header: t`Error`,
|
||||
message: t`Failed to create board`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
router.push(
|
||||
`${isTemplate ? "/templates" : "/boards"}/${board.publicId}`,
|
||||
);
|
||||
}
|
||||
closeModal();
|
||||
|
||||
await refetchBoards();
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error`,
|
||||
message: t`Failed to create board`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||
createBoard.mutate({
|
||||
name: data.name,
|
||||
workspacePublicId: data.workspacePublicId,
|
||||
sourceBoardPublicId: data.template?.sourceBoardPublicId ?? undefined,
|
||||
lists: data.template?.lists ?? [],
|
||||
labels: data.template?.labels ?? [],
|
||||
type: isTemplate ? "template" : "regular",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -82,7 +117,7 @@ export function NewBoardForm() {
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<div className="px-5 pt-5">
|
||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
||||
<h2 className="text-sm font-bold">{t`New board`}</h2>
|
||||
<h2 className="text-sm font-bold">{t`New ${isTemplate ? "template" : "board"}`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
@@ -111,21 +146,24 @@ export function NewBoardForm() {
|
||||
currentBoard={currentTemplate}
|
||||
setCurrentBoard={(t) => setValue("template", t)}
|
||||
showTemplates={showTemplates}
|
||||
customTemplates={formattedTemplates ?? []}
|
||||
/>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
isChecked={showTemplates}
|
||||
onChange={() => {
|
||||
setShowTemplates(!showTemplates);
|
||||
if (!showTemplates && !currentTemplate) {
|
||||
setValue("template", templates[0] ?? null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{!isTemplate && (
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
isChecked={showTemplates}
|
||||
onChange={() => {
|
||||
setShowTemplates(!showTemplates);
|
||||
if (!showTemplates && !currentTemplate) {
|
||||
setValue("template", (templates?.[0] as any) ?? null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
<Button type="submit" isLoading={createBoard.isPending}>
|
||||
{t`Create board`}
|
||||
{t`Create ${isTemplate ? "template" : "board"}`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { HiCheckCircle } from "react-icons/hi2";
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
sourceBoardPublicId?: string;
|
||||
name: string;
|
||||
lists: string[];
|
||||
labels: string[];
|
||||
@@ -88,16 +89,18 @@ export default function TemplateBoards({
|
||||
currentBoard,
|
||||
setCurrentBoard,
|
||||
showTemplates,
|
||||
customTemplates,
|
||||
}: {
|
||||
currentBoard: Template | null;
|
||||
setCurrentBoard: (board: Template | null) => void;
|
||||
showTemplates: boolean;
|
||||
customTemplates: Template[] | null;
|
||||
}) {
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const templates = getTemplates();
|
||||
const templates = [...(customTemplates ?? []), ...getTemplates()];
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BoardsList } from "./components/BoardsList";
|
||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||
import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
export default function BoardsPage() {
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
||||
|
||||
@@ -25,23 +25,27 @@ export default function BoardsPage() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
|
||||
<PageHead
|
||||
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? "Workspace"}`}
|
||||
/>
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`Boards`}
|
||||
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
{!isTemplate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
@@ -67,7 +71,7 @@ export default function BoardsPage() {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
||||
>
|
||||
<NewBoardForm />
|
||||
<NewBoardForm isTemplate={!!isTemplate} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -86,7 +90,7 @@ export default function BoardsPage() {
|
||||
</>
|
||||
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList />
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -138,7 +138,7 @@ const Comment = ({
|
||||
<ContentEditable
|
||||
html={comment ?? ""}
|
||||
disabled={true}
|
||||
className="mt-2 text-sm"
|
||||
className="break-anywhere mt-2 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
@@ -37,7 +37,7 @@ interface FormValues {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function CardRightPanel() {
|
||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
@@ -121,19 +121,21 @@ export function CardRightPanel() {
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CardPage() {
|
||||
export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const {
|
||||
@@ -228,7 +230,7 @@ export default function CardPage() {
|
||||
<>
|
||||
<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}`}
|
||||
href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
@@ -296,9 +298,11 @@ export default function CardPage() {
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ const Cta = ({ theme }: { theme: string }) => {
|
||||
<h2 className="text-balance text-4xl font-bold tracking-tight text-light-1000 dark:text-dark-1000 sm:text-4xl">
|
||||
{t`Get started for free today`}
|
||||
</h2>
|
||||
<p className="text-md/8 mx-auto mt-6 max-w-[375px] text-pretty text-light-900 dark:text-dark-900">
|
||||
<p className="text-md/8 mx-auto mt-6 max-w-[375px] text-pretty text-light-950 dark:text-dark-900">
|
||||
{t`Unlimited boards, unlimited lists, unlimited cards. No credit card required.`}
|
||||
</p>
|
||||
<Link href="/signup">
|
||||
|
||||
@@ -146,7 +146,7 @@ const Faqs = () => {
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Questions?`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
<Trans>
|
||||
Find answers to common questions about the project. Can't find what
|
||||
you're looking for? Feel free to{" "}
|
||||
|
||||
@@ -28,6 +28,7 @@ const FeatureItem = ({
|
||||
description: string;
|
||||
icon: Record<string, unknown>;
|
||||
comingSoon?: boolean;
|
||||
new?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -66,6 +67,12 @@ const FeatureItem = ({
|
||||
{t`Coming soon`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feature.new && (
|
||||
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
|
||||
{t`New`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -108,7 +115,7 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
title: t`Templates`,
|
||||
description: t`Save time with reusable board templates.`,
|
||||
icon: isDark ? templatesIconDark : templatesIconLight,
|
||||
comingSoon: true,
|
||||
new: true,
|
||||
},
|
||||
{
|
||||
title: t`Integrations`,
|
||||
@@ -128,7 +135,7 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Kanban reimagined`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Simple, visual task management that just works. Drag and drop cards, collaborate with your team, and get more done.`}
|
||||
</p>
|
||||
<div className="mx-auto mt-16 w-full max-w-7xl">
|
||||
|
||||
@@ -11,8 +11,8 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
|
||||
const menuItems = [
|
||||
{ label: t`Roadmap`, href: "/kan/roadmap", openInNewTab: true },
|
||||
{ label: t`Features`, href: "#features" },
|
||||
{ label: t`Pricing`, href: "#pricing" },
|
||||
{ label: t`Features`, href: "/#features" },
|
||||
{ label: t`Pricing`, href: "/pricing" },
|
||||
{ label: t`Docs`, href: "https://docs.kan.bn", openInNewTab: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -2,22 +2,19 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||
import { useTheme } from "next-themes";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useTheme } from "next-themes";
|
||||
import Cta from "./components/Cta";
|
||||
import FAQs from "./components/Faqs";
|
||||
import Features from "./components/Features";
|
||||
import Layout from "./components/Layout";
|
||||
import Pricing from "./components/Pricing";
|
||||
|
||||
export default function HomeView() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHead title="Kan.bn | The open source alternative to Trello" />
|
||||
@@ -68,7 +65,7 @@ export default function HomeView() {
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<p className="text-md mt-3 max-w-[450px] text-center text-dark-900 lg:max-w-[600px] lg:text-lg">
|
||||
<p className="text-md mt-3 max-w-[450px] text-center text-light-950 dark:text-dark-900 lg:max-w-[600px] lg:text-lg">
|
||||
{t`A powerful, flexible kanban app that helps you organise work, track progress, and deliver results—all in one place.`}
|
||||
</p>
|
||||
|
||||
@@ -82,7 +79,7 @@ export default function HomeView() {
|
||||
{t`Self host with Github`}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-sm text-dark-900">
|
||||
<p className="mt-4 text-center text-sm text-light-950 dark:text-dark-900">
|
||||
{t`No credit card required`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -111,10 +108,6 @@ export default function HomeView() {
|
||||
<div id="features" className="absolute -top-20" />
|
||||
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} />
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="pricing" className="absolute -top-20" />
|
||||
<Pricing />
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="faq" className="absolute -top-20" />
|
||||
<FAQs />
|
||||
|
||||
171
apps/web/src/views/invite/index.tsx
Normal file
171
apps/web/src/views/invite/index.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { authClient } from "@kan/auth/client";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import LoadingSpinner from "~/components/LoadingSpinner";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import PatternedBackground from "~/components/PatternedBackground";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function InvitePage() {
|
||||
const router = useRouter();
|
||||
const { code } = router.query;
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const { data: session, isPending: isSessionLoading } =
|
||||
authClient.useSession();
|
||||
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
|
||||
const inviteCode = Array.isArray(code) ? code[0] : code;
|
||||
|
||||
const acceptInviteMutation = api.member.acceptInviteLink.useMutation({
|
||||
onSuccess: (result) => {
|
||||
if (result.success) {
|
||||
return router.push(
|
||||
`/boards?workspacePublicId=${result.workspacePublicId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
if (error.data?.code === "CONFLICT") {
|
||||
return router.push(`/boards`);
|
||||
}
|
||||
|
||||
setError(
|
||||
error.message ||
|
||||
t`Failed to accept invitation. Please try again later, or contact customer support.`,
|
||||
);
|
||||
setIsProcessing(false);
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
data: inviteInfo,
|
||||
isLoading: isInviteInfoLoading,
|
||||
isError: isInviteInfoError,
|
||||
} = api.member.getInviteByCode.useQuery(
|
||||
{ inviteCode: inviteCode ?? "" },
|
||||
{
|
||||
enabled: !!inviteCode,
|
||||
retry: false,
|
||||
},
|
||||
);
|
||||
|
||||
// Auto accept invite if user is logged in
|
||||
useEffect(() => {
|
||||
if (session?.user.id && inviteCode && inviteInfo && !error) {
|
||||
setIsProcessing(true);
|
||||
setError(null);
|
||||
|
||||
acceptInviteMutation.mutate({
|
||||
inviteCode,
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [session?.user.id, inviteCode, inviteInfo, error]);
|
||||
|
||||
if (
|
||||
!isInviteInfoError &&
|
||||
!error &&
|
||||
(session?.user.id || isInviteInfoLoading || isSessionLoading)
|
||||
) {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Join workspace`} />
|
||||
<PatternedBackground />
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<LoadingSpinner size="lg" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const PageWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<>
|
||||
<PageHead title={t`Join workspace | kan.bn`} />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isInviteInfoError || (!isInviteInfoLoading && !inviteInfo)) {
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 w-full max-w-md space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{t`Invalid invitation`}
|
||||
</h2>
|
||||
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
|
||||
{t`This invitation link is invalid or has expired.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Button href="/" variant="primary">
|
||||
{t`Go Home`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PageWrapper>
|
||||
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
|
||||
<PatternedBackground />
|
||||
<div className="z-10 w-full max-w-[400px] space-y-8">
|
||||
<div>
|
||||
<h2 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
|
||||
{t`Join workspace`}
|
||||
</h2>
|
||||
{!error ? (
|
||||
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
|
||||
{isCloudEnv
|
||||
? t`You've been invited to join a workspace on kan.bn.`
|
||||
: t`You've been invited to join a workspace.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-center text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-center gap-2">
|
||||
{session?.user.id ? (
|
||||
<Button href={`/boards`} variant="primary" size="md">
|
||||
{t`Go to app`}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
href={`/login?next=/invite/${inviteCode}`}
|
||||
disabled={isProcessing}
|
||||
variant="primary"
|
||||
size="md"
|
||||
>
|
||||
{t`Sign In`}
|
||||
</Button>
|
||||
<Button
|
||||
href={`/signup?next=/invite/${inviteCode}`}
|
||||
disabled={isProcessing}
|
||||
variant="primary"
|
||||
size="md"
|
||||
>
|
||||
{t`Sign Up`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PageWrapper>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,12 @@ import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { HiXMark } from "react-icons/hi2";
|
||||
import {
|
||||
HiInformationCircle,
|
||||
HiMiniCheck,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { InviteMemberInput } from "@kan/api/types";
|
||||
@@ -31,11 +36,17 @@ export function InviteMemberForm({
|
||||
userId: string | undefined;
|
||||
}) {
|
||||
const utils = api.useUtils();
|
||||
const [isCreateAnotherEnabled, setIsCreateAnotherEnabled] = useState(false);
|
||||
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||
useState(false);
|
||||
const [inviteLink, setInviteLink] = useState<string>("");
|
||||
const [_isLoadingInviteLink, setIsLoadingInviteLink] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const isEmailEnabled = env("NEXT_PUBLIC_DISABLE_EMAIL") !== "true";
|
||||
|
||||
const InviteMemberSchema = z.object({
|
||||
email: z.string().email({ message: t`Invalid email address` }),
|
||||
workspacePublicId: z.string(),
|
||||
@@ -56,6 +67,21 @@ export function InviteMemberForm({
|
||||
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
// Fetch active invite link on component mount
|
||||
const { data: activeInviteLink, refetch: _refetchInviteLink } =
|
||||
api.member.getActiveInviteLink.useQuery(
|
||||
{ workspacePublicId: workspace.publicId || "" },
|
||||
{ enabled: !!workspace.publicId },
|
||||
);
|
||||
|
||||
// Set initial state based on active invite link
|
||||
useEffect(() => {
|
||||
if (activeInviteLink) {
|
||||
setIsShareInviteLinkEnabled(activeInviteLink.isActive);
|
||||
setInviteLink(activeInviteLink.inviteLink ?? "");
|
||||
}
|
||||
}, [activeInviteLink]);
|
||||
|
||||
const inviteMember = api.member.invite.useMutation({
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
@@ -64,7 +90,7 @@ export function InviteMemberForm({
|
||||
},
|
||||
onError: (error) => {
|
||||
reset();
|
||||
if (!isCreateAnotherEnabled) closeModal();
|
||||
if (!isShareInviteLinkEnabled) closeModal();
|
||||
|
||||
if (error.data?.code === "CONFLICT") {
|
||||
showPopup({
|
||||
@@ -82,6 +108,46 @@ export function InviteMemberForm({
|
||||
},
|
||||
});
|
||||
|
||||
const createInviteLink = api.member.createInviteLink.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setInviteLink(data.inviteLink);
|
||||
setIsLoadingInviteLink(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsLoadingInviteLink(false);
|
||||
setIsShareInviteLinkEnabled(false);
|
||||
|
||||
if (error.data?.code === "FORBIDDEN") {
|
||||
showPopup({
|
||||
header: t`Subscription Required`,
|
||||
message: t`Invite links require a Team or Pro subscription. Please upgrade your workspace.`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Error creating invite link`,
|
||||
message: t`Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const deactivateInviteLink = api.member.deactivateInviteLink.useMutation({
|
||||
onSuccess: () => {
|
||||
setInviteLink("");
|
||||
setIsLoadingInviteLink(false);
|
||||
},
|
||||
onError: () => {
|
||||
setIsLoadingInviteLink(false);
|
||||
showPopup({
|
||||
header: t`Error deactivating invite link`,
|
||||
message: t`Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const teamSubscription = getSubscriptionByPlan(subscriptions, "team");
|
||||
const proSubscription = getSubscriptionByPlan(subscriptions, "pro");
|
||||
|
||||
@@ -92,7 +158,7 @@ export function InviteMemberForm({
|
||||
let price = t`$10/month`;
|
||||
let billingType = t`monthly billing`;
|
||||
|
||||
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
|
||||
if (teamSubscription?.periodStart && teamSubscription.periodEnd) {
|
||||
const periodStartDate = new Date(teamSubscription.periodStart);
|
||||
const periodEndDate = new Date(teamSubscription.periodEnd);
|
||||
const diffInDays = Math.round(
|
||||
@@ -109,6 +175,43 @@ export function InviteMemberForm({
|
||||
inviteMember.mutate(member);
|
||||
};
|
||||
|
||||
const handleInviteLinkToggle = async () => {
|
||||
setIsLoadingInviteLink(true);
|
||||
|
||||
if (isShareInviteLinkEnabled && workspace.publicId) {
|
||||
// Deactivate invite link
|
||||
await deactivateInviteLink.mutateAsync({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
setIsShareInviteLinkEnabled(false);
|
||||
} else {
|
||||
// Create new invite link
|
||||
await createInviteLink.mutateAsync({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
setIsShareInviteLinkEnabled(true);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteLink);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
showPopup({
|
||||
header: t`Invite link copied`,
|
||||
message: t`Invite link copied to clipboard`,
|
||||
icon: "success",
|
||||
});
|
||||
} catch {
|
||||
showPopup({
|
||||
header: t`Error`,
|
||||
message: t`Failed to copy invite link`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
const { data, error } = await authClient.subscription.upgrade({
|
||||
plan: "team",
|
||||
@@ -156,23 +259,53 @@ export function InviteMemberForm({
|
||||
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
|
||||
</button>
|
||||
</div>
|
||||
<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") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
{isEmailEnabled && (
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
}}
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
)}
|
||||
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
value={inviteLink}
|
||||
className="pr-10 text-sm text-light-900 dark:text-dark-900"
|
||||
readOnly
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 flex items-center pr-3 text-light-900 hover:text-light-950 dark:text-dark-900 dark:hover:text-dark-950"
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
{copied ? (
|
||||
<HiMiniCheck className="h-5 w-5 text-green-600" />
|
||||
) : (
|
||||
<HiOutlineDocumentDuplicate className="h-5 w-5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-start gap-1">
|
||||
<HiInformationCircle className="mt-0.5 h-4 w-4 text-dark-900" />
|
||||
<p className="text-xs text-gray-500 dark:text-dark-900">
|
||||
{t`Anyone with this link can join your workspace`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
@@ -202,33 +335,32 @@ export function InviteMemberForm({
|
||||
</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)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
<div>
|
||||
{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"
|
||||
>
|
||||
<Button type="button" onClick={handleUpgrade}>
|
||||
{t`Upgrade to Team Plan`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inviteMember.isPending}
|
||||
disabled={inviteMember.isPending || !isEmailEnabled}
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
HiBolt,
|
||||
HiEllipsisHorizontal,
|
||||
|
||||
297
apps/web/src/views/pricing/components/FeatureComparisonTable.tsx
Normal file
297
apps/web/src/views/pricing/components/FeatureComparisonTable.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiCheckCircle, HiXCircle } from "react-icons/hi2";
|
||||
|
||||
interface Feature {
|
||||
key: string;
|
||||
label: string;
|
||||
kan: boolean | string;
|
||||
trello: boolean | string;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
name: string;
|
||||
features: Feature[];
|
||||
}
|
||||
|
||||
type FrequencyValue = "monthly" | "annually";
|
||||
|
||||
const CellValue = ({ value }: { value: boolean | string }) => {
|
||||
if (typeof value === "string") {
|
||||
return (
|
||||
<span className="text-sm text-dark-900 dark:text-dark-900">{value}</span>
|
||||
);
|
||||
}
|
||||
return value ? (
|
||||
<HiCheckCircle className="h-5 w-5 text-light-950 dark:text-dark-1000" />
|
||||
) : (
|
||||
<HiXCircle className="h-5 w-5 text-light-700 dark:text-dark-600" />
|
||||
);
|
||||
};
|
||||
|
||||
const FeatureComparisonTable = ({
|
||||
frequencyValue,
|
||||
}: {
|
||||
frequencyValue: FrequencyValue;
|
||||
}) => {
|
||||
const sections: Section[] = [
|
||||
{
|
||||
name: t`Core features`,
|
||||
features: [
|
||||
{
|
||||
key: "ulimited-workspaces",
|
||||
label: t`Unlimited workspaces`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-boards",
|
||||
label: t`Unlimited boards`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "unlimited-lists",
|
||||
label: t`Unlimited lists`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "unlimited-cards",
|
||||
label: t`Unlimited cards`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "activity-log",
|
||||
label: t`Activity log`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "templates",
|
||||
label: t`Custom templates`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{ key: "labels", label: t`Labels & filters`, kan: true, trello: true },
|
||||
{ key: "checklists", label: t`Checklists`, kan: true, trello: true },
|
||||
{
|
||||
key: "import",
|
||||
label: t`Import from Trello`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "visibility",
|
||||
label: t`Board visibility`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
label: t`Intelligent search`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "workspace-url",
|
||||
label: t`Custom workspace link`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
features: [
|
||||
{
|
||||
key: "pricing",
|
||||
label: t`Price per user/month`,
|
||||
kan: frequencyValue === "monthly" ? `$10.00` : `$8.00`,
|
||||
trello: frequencyValue === "monthly" ? `$12.00` : `$10.00`,
|
||||
},
|
||||
{
|
||||
key: "comments",
|
||||
label: t`Comments & mentions`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "assignments",
|
||||
label: t`Assignees`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "sharing",
|
||||
label: t`Board sharing & invites`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "invite-links",
|
||||
label: t`Invite links`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: t`Platform`,
|
||||
features: [
|
||||
{
|
||||
key: "api",
|
||||
label: t`REST API`,
|
||||
kan: true,
|
||||
trello: true,
|
||||
},
|
||||
{
|
||||
key: "open-source",
|
||||
label: t`Open source`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "self-hostable",
|
||||
label: t`Self-hostable`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "white-label",
|
||||
label: t`White label`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "performance",
|
||||
label: t`Fast & lightweight`,
|
||||
kan: true,
|
||||
trello: false,
|
||||
},
|
||||
{
|
||||
key: "this-is-a-joke",
|
||||
label: t`Owned by Atlassian`,
|
||||
kan: false,
|
||||
trello: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const products = [
|
||||
{
|
||||
id: "kan",
|
||||
name: t`Kan`,
|
||||
featured: true,
|
||||
},
|
||||
{
|
||||
id: "trello",
|
||||
name: t`Trello`,
|
||||
featured: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section aria-labelledby="comparison-heading" className="w-full px-4">
|
||||
<div className="mx-auto max-w-6xl py-8 lg:py-10">
|
||||
<div className="grid grid-cols-3 gap-x-8 border-t border-light-500 before:block dark:border-dark-300">
|
||||
{products.map((product) => (
|
||||
<div key={product.id} aria-hidden="true" className="-mt-px">
|
||||
<div
|
||||
className={`${product.featured ? "border-dark-200 dark:border-dark-800" : "border-transparent"} border-t-2 pt-8`}
|
||||
>
|
||||
<p
|
||||
className={`${product.featured ? "text-dark-50 dark:text-dark-1000" : "text-light-1000 dark:text-dark-1000"} text-center text-sm font-semibold`}
|
||||
>
|
||||
{product.name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="-mt-6 space-y-12">
|
||||
{sections.map((section) => (
|
||||
<div key={section.name}>
|
||||
<h4 className="text-sm font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{section.name}
|
||||
</h4>
|
||||
<div className="relative -mx-8 mt-8">
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`size-full rounded-lg ${product.featured ? "bg-white shadow-sm dark:bg-dark-50 dark:shadow-none" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<table className="relative w-full border-separate border-spacing-x-8">
|
||||
<thead>
|
||||
<tr className="text-left">
|
||||
<th scope="col">
|
||||
<span className="sr-only">{t`Feature`}</span>
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<th key={p.id} scope="col">
|
||||
<span className="sr-only">{p.name}</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{section.features.map((feature, featureIdx) => (
|
||||
<tr key={feature.key}>
|
||||
<th
|
||||
scope="row"
|
||||
className="w-1/3 py-3 pr-4 text-left text-sm font-normal text-light-1000 dark:text-dark-1000"
|
||||
>
|
||||
{feature.label}
|
||||
{featureIdx !== section.features.length - 1 ? (
|
||||
<div className="absolute inset-x-8 mt-3 h-px bg-light-500 dark:bg-dark-300" />
|
||||
) : null}
|
||||
</th>
|
||||
{products.map((p) => (
|
||||
<td
|
||||
key={p.id}
|
||||
className="relative w-1/3 px-4 py-0 text-center"
|
||||
>
|
||||
<span className="relative inline-flex w-full items-center justify-center py-3">
|
||||
<CellValue
|
||||
value={
|
||||
p.id === "kan" ? feature.kan : feature.trello
|
||||
}
|
||||
/>
|
||||
</span>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-x-8 inset-y-0 grid grid-cols-3 gap-x-8 before:block"
|
||||
>
|
||||
{products.map((product) => (
|
||||
<div
|
||||
key={product.id}
|
||||
className={`${product.featured ? "ring-1 ring-light-500 dark:ring-dark-800" : "ring-0"} rounded-2xl`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default FeatureComparisonTable;
|
||||
@@ -85,19 +85,17 @@ const Pricing = () => {
|
||||
<p>{t`Pricing`}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-12 flex flex-col items-center justify-center">
|
||||
<div className="mb-4 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-white dark:text-gray-800 lg:text-sm">
|
||||
<div className="mt-14 flex flex-col items-center justify-center">
|
||||
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>
|
||||
{t`Launch offer: unlimited seats for just $29/month with Pro`}
|
||||
</p>
|
||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||
</div>
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
@@ -164,7 +162,7 @@ const Pricing = () => {
|
||||
<p className="mt-6 flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-4xl font-semibold tracking-tight text-light-100",
|
||||
"text-3xl font-semibold tracking-tight text-light-100",
|
||||
tier.highlighted
|
||||
? "text-light-50 dark:text-dark-1000"
|
||||
: "text-gray-900 dark:text-dark-1000",
|
||||
211
apps/web/src/views/pricing/components/PricingTiers.tsx
Normal file
211
apps/web/src/views/pricing/components/PricingTiers.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import Link from "next/link";
|
||||
import { Radio, RadioGroup } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { HiBolt, HiCheckCircle } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type FrequencyValue = "monthly" | "annually";
|
||||
|
||||
interface Frequency {
|
||||
value: FrequencyValue;
|
||||
label: string;
|
||||
priceSuffix: string;
|
||||
}
|
||||
|
||||
const Pricing = ({
|
||||
frequency,
|
||||
frequencies,
|
||||
setFrequency,
|
||||
}: {
|
||||
frequency: Frequency | undefined;
|
||||
frequencies: Frequency[];
|
||||
setFrequency: (frequency: Frequency) => void;
|
||||
}) => {
|
||||
const tiers = [
|
||||
{
|
||||
name: t`Individuals`,
|
||||
id: "tier-individuals",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: t`Free`, annually: t`Free` },
|
||||
description: t`Everything you need, free forever. Unlimited boards, unlimited lists, unlimited cards. Upgrade any time.`,
|
||||
featureHeader: t`Free, forever`,
|
||||
features: [
|
||||
t`1 user`,
|
||||
t`Unlimited boards`,
|
||||
t`Unlimited cards`,
|
||||
t`Custom board templates`,
|
||||
t`Checklists`,
|
||||
],
|
||||
showPrice: true,
|
||||
},
|
||||
{
|
||||
name: t`Teams`,
|
||||
id: "tier-teams",
|
||||
href: "signup",
|
||||
buttonText: t`Get Started`,
|
||||
price: { monthly: "$10.00", annually: "$8.00" },
|
||||
description: t`Kan is better with a team. Perfect for small and growing teams looking to collaborate.`,
|
||||
featureHeader: t`Everything in the free plan, plus:`,
|
||||
features: [
|
||||
t`Workspace members`,
|
||||
t`Admin roles`,
|
||||
t`Priority email support`,
|
||||
t`Support the development of the project`,
|
||||
],
|
||||
highlighted: true,
|
||||
showPrice: true,
|
||||
showPriceSuffix: true,
|
||||
},
|
||||
{
|
||||
name: t`Self Host`,
|
||||
id: "tier-self-host",
|
||||
href: "https://github.com/kanbn/kan",
|
||||
buttonText: t`View docs`,
|
||||
price: { monthly: "-", annually: "-" },
|
||||
description: t`Host Kan on your own infrastructure. Ideal for organisations that need complete control over their data.`,
|
||||
featureHeader: t`Complete control and ownership:`,
|
||||
features: [
|
||||
t`Run on your own infrastructure`,
|
||||
t`Own your data`,
|
||||
t`Custom domain`,
|
||||
],
|
||||
mostPopular: false,
|
||||
showPrice: false,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="mt-14 flex flex-col items-center justify-center">
|
||||
<div className="mb-8 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-dark-1000 dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||
</div>
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
value={frequency}
|
||||
onChange={(value) => setFrequency(value)}
|
||||
className="grid grid-cols-2 gap-x-1 rounded-full p-1 text-center text-xs/5 font-semibold ring-1 ring-inset ring-light-600 dark:ring-dark-600"
|
||||
>
|
||||
{frequencies.map((option) => (
|
||||
<Radio
|
||||
key={option.value}
|
||||
value={option}
|
||||
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>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="isolate mx-auto mb-20 grid max-w-md grid-cols-1 gap-8 px-4 lg:mx-0 lg:max-w-none lg:grid-cols-3">
|
||||
{tiers.map((tier) => (
|
||||
<div
|
||||
key={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-dark-50 ring-1 ring-dark-50 dark:ring-dark-800"
|
||||
: "bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-300",
|
||||
"rounded-3xl p-8 xl:p-10",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-x-4">
|
||||
<h3
|
||||
id={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "text-dark-1000 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
"text-lg/8 font-semibold",
|
||||
)}
|
||||
>
|
||||
{tier.name}
|
||||
</h3>
|
||||
{tier.highlighted && frequency?.value === "annually" ? (
|
||||
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
|
||||
-20%
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-4 text-sm/6 text-dark-950 dark:text-dark-900",
|
||||
tier.highlighted ? "text-light-100" : "text-dark-50",
|
||||
)}
|
||||
>
|
||||
{tier.description}
|
||||
</p>
|
||||
<p className="mt-6 flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-3xl font-semibold tracking-tight text-light-100",
|
||||
tier.highlighted
|
||||
? "text-light-50 dark:text-dark-1000"
|
||||
: "text-gray-900 dark:text-dark-1000",
|
||||
!tier.showPrice && "opacity-0",
|
||||
)}
|
||||
>
|
||||
{tier.price[frequency?.value ?? "monthly"]}
|
||||
</span>
|
||||
{tier.showPriceSuffix && (
|
||||
<span className="text-sm/6 font-semibold text-light-50 dark:text-dark-900">
|
||||
{frequency?.priceSuffix}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<Link
|
||||
href={tier.href}
|
||||
aria-describedby={tier.id}
|
||||
className={twMerge(
|
||||
tier.highlighted
|
||||
? "bg-light-50 text-dark-50 shadow-sm dark:bg-dark-1000 dark:text-dark-50"
|
||||
: "bg-dark-50 text-light-50 dark:bg-dark-200 dark:text-dark-1000",
|
||||
"mt-6 block rounded-md px-3 py-2 text-center text-sm/6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600",
|
||||
)}
|
||||
>
|
||||
{tier.buttonText}
|
||||
</Link>
|
||||
<p
|
||||
className={twMerge(
|
||||
"mt-8 text-sm/6 font-bold",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.featureHeader}
|
||||
</p>
|
||||
<ul
|
||||
role="list"
|
||||
className={twMerge(
|
||||
"mt-2 space-y-3 text-sm/6 text-light-600",
|
||||
tier.highlighted
|
||||
? "text-light-100 dark:text-dark-1000"
|
||||
: "text-dark-50 dark:text-dark-1000",
|
||||
)}
|
||||
>
|
||||
{tier.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-x-3">
|
||||
<HiCheckCircle className="h-5 w-5" />
|
||||
{feature}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pricing;
|
||||
85
apps/web/src/views/pricing/index.tsx
Normal file
85
apps/web/src/views/pricing/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useState } from "react";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import Cta from "../home/components/Cta";
|
||||
import Layout from "../home/components/Layout";
|
||||
import FeatureComparisonTable from "./components/FeatureComparisonTable";
|
||||
import PricingTiers from "./components/PricingTiers";
|
||||
|
||||
type FrequencyValue = "monthly" | "annually";
|
||||
|
||||
export default function PricingView() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const frequencies = [
|
||||
{
|
||||
value: "monthly" as FrequencyValue,
|
||||
label: t`Monthly`,
|
||||
priceSuffix: t`per user/month`,
|
||||
},
|
||||
{
|
||||
value: "annually" as FrequencyValue,
|
||||
label: t`Yearly`,
|
||||
priceSuffix: t`per user/month`,
|
||||
},
|
||||
];
|
||||
|
||||
const [frequency, setFrequency] = useState(frequencies[1]);
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHead title={`${t`Pricing`} | kan.bn`} />
|
||||
|
||||
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
|
||||
<div className="w-full pb-10 pt-32 lg:py-32">
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-10">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Pricing`}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-5xl">
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
</div>
|
||||
<PricingTiers
|
||||
frequency={frequency}
|
||||
frequencies={frequencies}
|
||||
setFrequency={setFrequency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pb-22 flex flex-col items-center justify-center px-4">
|
||||
<div className="flex items-center gap-2 rounded-full border bg-light-50 px-4 py-1 text-center text-xs text-light-1000 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900 lg:text-sm">
|
||||
<p>{t`Features`}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Feature breakdown`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md my-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`Compare our features to see why Kan is the best choice.`}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<Button variant="primary" href="/signup">
|
||||
{t`Get started`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 w-full max-w-[900px]">
|
||||
<FeatureComparisonTable
|
||||
frequencyValue={frequency?.value ?? "annually"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Cta theme={resolvedTheme ?? "light"} />
|
||||
</div>
|
||||
</div>
|
||||
</Layout>
|
||||
);
|
||||
}
|
||||
@@ -16,4 +16,4 @@ export default {
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Config;
|
||||
} satisfies Config;
|
||||
|
||||
@@ -25,6 +25,9 @@ services:
|
||||
- SMTP_PASSWORD=${SMTP_PASSWORD}
|
||||
- EMAIL_FROM=${EMAIL_FROM}
|
||||
|
||||
# Disable email features entirely (optional)
|
||||
- NEXT_PUBLIC_DISABLE_EMAIL=${NEXT_PUBLIC_DISABLE_EMAIL}
|
||||
|
||||
# S3 storage (optional)
|
||||
- S3_ACCESS_KEY_ID=${S3_ACCESS_KEY_ID}
|
||||
- S3_SECRET_ACCESS_KEY=${S3_SECRET_ACCESS_KEY}
|
||||
|
||||
@@ -25,7 +25,12 @@ export const boardRouter = createTRPCRouter({
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||
)
|
||||
@@ -51,7 +56,9 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id);
|
||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
||||
type: input.type,
|
||||
});
|
||||
|
||||
return result;
|
||||
}),
|
||||
@@ -71,6 +78,7 @@ export const boardRouter = createTRPCRouter({
|
||||
boardPublicId: z.string().min(12),
|
||||
members: z.array(z.string().min(12)).optional(),
|
||||
labels: z.array(z.string().min(12)).optional(),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||
@@ -102,6 +110,7 @@ export const boardRouter = createTRPCRouter({
|
||||
{
|
||||
members: input.members ?? [],
|
||||
labels: input.labels ?? [],
|
||||
type: input.type,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -177,6 +186,8 @@ export const boardRouter = createTRPCRouter({
|
||||
workspacePublicId: z.string().min(12),
|
||||
lists: z.array(z.string().min(1)),
|
||||
labels: z.array(z.string().min(1)),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
sourceBoardPublicId: z.string().min(12).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
@@ -202,6 +213,79 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
// If sourceBoardPublicId is provided, clone the source board
|
||||
if (input.sourceBoardPublicId) {
|
||||
// First get the source board info (ID and type)
|
||||
const sourceBoardInfo = await boardRepo.getIdByPublicId(
|
||||
ctx.db,
|
||||
input.sourceBoardPublicId,
|
||||
);
|
||||
|
||||
if (!sourceBoardInfo)
|
||||
throw new TRPCError({
|
||||
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Get the full board data with the correct type
|
||||
const sourceBoard = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.sourceBoardPublicId,
|
||||
{
|
||||
members: [],
|
||||
labels: [],
|
||||
type: sourceBoardInfo.type,
|
||||
},
|
||||
);
|
||||
|
||||
if (!sourceBoard)
|
||||
throw new TRPCError({
|
||||
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Verify the source board belongs to the same workspace
|
||||
const sourceWorkspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
sourceBoard.workspace.publicId,
|
||||
);
|
||||
|
||||
if (!sourceWorkspace || sourceWorkspace.id !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: `Source board does not belong to this workspace`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
let slug = generateSlug(input.name);
|
||||
|
||||
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
||||
slug,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isSlugUnique || input.type === "template")
|
||||
slug = `${slug}-${generateUID()}`;
|
||||
|
||||
const result = await boardRepo.createFromSnapshot(ctx.db, {
|
||||
source: sourceBoard,
|
||||
workspaceId: workspace.id,
|
||||
createdBy: userId,
|
||||
slug,
|
||||
name: input.name,
|
||||
type: input.type ?? "regular",
|
||||
sourceBoardId: sourceBoardInfo.id,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create board from source`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Otherwise, create a new board with provided lists and labels
|
||||
let slug = generateSlug(input.name);
|
||||
|
||||
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
||||
@@ -209,7 +293,8 @@ export const boardRouter = createTRPCRouter({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isSlugUnique) slug = `${slug}-${generateUID()}`;
|
||||
if (!isSlugUnique || input.type === "template")
|
||||
slug = `${slug}-${generateUID()}`;
|
||||
|
||||
const result = await boardRepo.create(ctx.db, {
|
||||
publicId: generateUID(),
|
||||
@@ -217,6 +302,7 @@ export const boardRouter = createTRPCRouter({
|
||||
name: input.name,
|
||||
createdBy: userId,
|
||||
workspaceId: workspace.id,
|
||||
type: input.type,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import * as inviteLinkRepo from "@kan/db/repository/inviteLink.repo";
|
||||
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 {
|
||||
generateUID,
|
||||
getSubscriptionByPlan,
|
||||
hasUnlimitedSeats,
|
||||
} from "@kan/shared/utils";
|
||||
import { updateSubscriptionSeats } from "@kan/stripe";
|
||||
|
||||
import { createTRPCRouter, protectedProcedure } from "../trpc";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../trpc";
|
||||
import { assertUserInWorkspace } from "../utils/auth";
|
||||
|
||||
export const memberRouter = createTRPCRouter({
|
||||
@@ -241,4 +246,403 @@ export const memberRouter = createTRPCRouter({
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
getActiveInviteLink: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get active invite link for workspace",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/invite",
|
||||
description: "Gets the active invite link for a workspace",
|
||||
tags: ["Invites"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
id: z.number().optional(),
|
||||
inviteCode: z.string().optional(),
|
||||
inviteLink: z.string().optional(),
|
||||
isActive: z.boolean(),
|
||||
expiresAt: z.date().optional(),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
// Get active invite link for this workspace
|
||||
const activeInviteLink = await inviteLinkRepo.getActiveForWorkspace(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
if (
|
||||
activeInviteLink &&
|
||||
(!activeInviteLink.expiresAt || new Date() < activeInviteLink.expiresAt)
|
||||
) {
|
||||
return {
|
||||
id: activeInviteLink.id,
|
||||
inviteCode: activeInviteLink.code,
|
||||
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${activeInviteLink.code}`,
|
||||
isActive: true,
|
||||
expiresAt: activeInviteLink.expiresAt ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return { isActive: false };
|
||||
}),
|
||||
createInviteLink: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Create invite link for workspace",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/invites",
|
||||
description: "Create invite link for a workspace",
|
||||
tags: ["Invites"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
publicId: z.string().min(12),
|
||||
inviteCode: z.string(),
|
||||
inviteLink: z.string(),
|
||||
expiresAt: z.date().nullable(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
|
||||
// Check subscription for cloud environment
|
||||
if (process.env.NEXT_PUBLIC_KAN_ENV === "cloud") {
|
||||
const subscriptions = await subscriptionRepo.getByReferenceId(
|
||||
ctx.db,
|
||||
workspace.publicId,
|
||||
);
|
||||
|
||||
const activeTeamSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"team",
|
||||
);
|
||||
const activeProSubscription = getSubscriptionByPlan(
|
||||
subscriptions,
|
||||
"pro",
|
||||
);
|
||||
|
||||
if (!activeTeamSubscription && !activeProSubscription) {
|
||||
throw new TRPCError({
|
||||
message: `Invite links require a Team or Pro subscription`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Deactivate any existing active invite links
|
||||
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
updatedBy: userId,
|
||||
});
|
||||
|
||||
// Generate new invite code
|
||||
const inviteCode = generateUID();
|
||||
const expiresAt = new Date();
|
||||
expiresAt.setDate(expiresAt.getDate() + 7);
|
||||
|
||||
// Create new invite link
|
||||
const inviteLink = await inviteLinkRepo.createInviteLink(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
code: inviteCode,
|
||||
expiresAt,
|
||||
createdBy: userId,
|
||||
});
|
||||
|
||||
if (!inviteLink) {
|
||||
throw new TRPCError({
|
||||
message: `Failed to create invite link`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
publicId: inviteLink.publicId,
|
||||
inviteCode: inviteLink.code,
|
||||
inviteLink: `${process.env.NEXT_PUBLIC_BASE_URL}/invite/${inviteLink.code}`,
|
||||
expiresAt: inviteLink.expiresAt,
|
||||
};
|
||||
}),
|
||||
deactivateInviteLink: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Deactivate invite link for workspace",
|
||||
method: "DELETE",
|
||||
path: "/workspaces/{workspacePublicId}/invites",
|
||||
description: "Deactivates the invite link for a workspace",
|
||||
tags: ["Invites"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(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 workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Check if user is in workspace
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id, "admin");
|
||||
|
||||
// Deactivate all active invite links
|
||||
await inviteLinkRepo.deactivateAllActiveForWorkspace(ctx.db, {
|
||||
workspaceId: workspace.id,
|
||||
updatedBy: userId,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}),
|
||||
getInviteByCode: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Get invite information by code",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/invites/{inviteCode}",
|
||||
description: "Get invite information by invite code",
|
||||
tags: ["Invites"],
|
||||
protect: false,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
inviteCode: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z
|
||||
.object({
|
||||
publicId: z.string().min(12),
|
||||
status: z.string(),
|
||||
expiresAt: z.date().nullable(),
|
||||
})
|
||||
.optional(),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
|
||||
|
||||
if (
|
||||
!invite ||
|
||||
invite.status !== "active" ||
|
||||
(invite.expiresAt && new Date() > invite.expiresAt)
|
||||
) {
|
||||
throw new TRPCError({
|
||||
message: `Invalid or expired invite link`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
publicId: invite.publicId,
|
||||
status: invite.status,
|
||||
expiresAt: invite.expiresAt ?? null,
|
||||
};
|
||||
}),
|
||||
acceptInviteLink: publicProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Accept an invite link",
|
||||
method: "POST",
|
||||
path: "/workspaces/{workspacePublicId}/invites/accept",
|
||||
description: "Accepts an invitation via invite link",
|
||||
tags: ["Invites"],
|
||||
protect: false,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
inviteCode: z.string().min(12),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.object({
|
||||
success: z.boolean(),
|
||||
workspacePublicId: z.string().optional(),
|
||||
workspaceSlug: z.string().optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const invite = await inviteLinkRepo.getByCode(ctx.db, input.inviteCode);
|
||||
|
||||
if (
|
||||
!invite ||
|
||||
invite.status !== "active" ||
|
||||
(invite.expiresAt && new Date() > invite.expiresAt)
|
||||
)
|
||||
throw new TRPCError({
|
||||
message: `Invalid or expired invite link`,
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getById(ctx.db, invite.workspaceId);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
const isMember = await workspaceRepo.isUserInWorkspace(
|
||||
ctx.db,
|
||||
userId,
|
||||
invite.workspaceId,
|
||||
);
|
||||
|
||||
if (isMember) {
|
||||
throw new TRPCError({
|
||||
message: `User is already a member of this workspace`,
|
||||
code: "CONFLICT",
|
||||
});
|
||||
}
|
||||
|
||||
const user = await userRepo.getById(ctx.db, userId);
|
||||
|
||||
if (!user)
|
||||
throw new TRPCError({
|
||||
message: `User not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
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",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await memberRepo.create(ctx.db, {
|
||||
workspaceId: invite.workspaceId,
|
||||
email: user.email,
|
||||
userId: user.id,
|
||||
createdBy: user.id,
|
||||
role: "member",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
workspacePublicId: workspace.publicId,
|
||||
workspaceSlug: workspace.slug,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -409,4 +409,80 @@ export const workspaceRouter = createTRPCRouter({
|
||||
isReserved: workspaceSlug?.type === "reserved",
|
||||
};
|
||||
}),
|
||||
search: protectedProcedure
|
||||
.meta({
|
||||
openapi: {
|
||||
summary: "Search boards and cards in a workspace",
|
||||
method: "GET",
|
||||
path: "/workspaces/{workspacePublicId}/search",
|
||||
description:
|
||||
"Searches for boards and cards by title within a workspace",
|
||||
tags: ["Workspaces"],
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
query: z.string().min(1).max(100),
|
||||
limit: z.number().min(1).max(50).optional().default(20),
|
||||
}),
|
||||
)
|
||||
.output(
|
||||
z.array(
|
||||
z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
slug: z.string(),
|
||||
updatedAt: z.date().nullable(),
|
||||
createdAt: z.date(),
|
||||
type: z.literal("board"),
|
||||
}),
|
||||
z.object({
|
||||
publicId: z.string(),
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
boardPublicId: z.string(),
|
||||
boardName: z.string(),
|
||||
listName: z.string(),
|
||||
updatedAt: z.date().nullable(),
|
||||
createdAt: z.date(),
|
||||
type: z.literal("card"),
|
||||
}),
|
||||
]),
|
||||
),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const userId = ctx.user?.id;
|
||||
|
||||
if (!userId)
|
||||
throw new TRPCError({
|
||||
message: `User not authenticated`,
|
||||
code: "UNAUTHORIZED",
|
||||
});
|
||||
|
||||
const workspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.workspacePublicId,
|
||||
);
|
||||
|
||||
if (!workspace)
|
||||
throw new TRPCError({
|
||||
message: `Workspace not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
const result = await workspaceRepo.searchBoardsAndCards(
|
||||
ctx.db,
|
||||
workspace.id,
|
||||
input.query,
|
||||
input.limit,
|
||||
);
|
||||
|
||||
return result;
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
CREATE TYPE "public"."invite_link_status" AS ENUM('active', 'inactive');--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "workspace_invite_links" (
|
||||
"id" bigserial PRIMARY KEY NOT NULL,
|
||||
"publicId" varchar(12) NOT NULL,
|
||||
"workspaceId" bigint NOT NULL,
|
||||
"code" varchar(12) NOT NULL,
|
||||
"status" "invite_link_status" DEFAULT 'active' NOT NULL,
|
||||
"expiresAt" timestamp,
|
||||
"createdAt" timestamp DEFAULT now() NOT NULL,
|
||||
"createdBy" uuid,
|
||||
"updatedAt" timestamp,
|
||||
"updatedBy" uuid,
|
||||
CONSTRAINT "workspace_invite_links_publicId_unique" UNIQUE("publicId"),
|
||||
CONSTRAINT "workspace_invite_links_code_unique" UNIQUE("code")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "workspace_invite_links" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_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 "workspace_invite_links" ADD CONSTRAINT "workspace_invite_links_updatedBy_user_id_fk" FOREIGN KEY ("updatedBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm; --> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS boards_name_trgm_idx ON board USING gin (name gin_trgm_ops);
|
||||
--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS cards_title_trgm_idx ON card USING gin (title gin_trgm_ops);
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TYPE "public"."board_type" AS ENUM('regular', 'template');--> statement-breakpoint
|
||||
ALTER TABLE "board" ADD COLUMN "type" "board_type" DEFAULT 'regular' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "board" ADD COLUMN "sourceBoardId" bigint;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "board_type_idx" ON "board" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "board_source_idx" ON "board" USING btree ("sourceBoardId");
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE "card_activity" ADD COLUMN "sourceBoardId" bigint;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "card_activity" ADD CONSTRAINT "card_activity_sourceBoardId_board_id_fk" FOREIGN KEY ("sourceBoardId") REFERENCES "public"."board"("id") ON DELETE set null ON UPDATE no action;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN null;
|
||||
END $$;
|
||||
2766
packages/db/migrations/meta/20250923211958_snapshot.json
Normal file
2766
packages/db/migrations/meta/20250923211958_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2766
packages/db/migrations/meta/20251001220136_snapshot.json
Normal file
2766
packages/db/migrations/meta/20251001220136_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2818
packages/db/migrations/meta/20251007204129_snapshot.json
Normal file
2818
packages/db/migrations/meta/20251007204129_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
2837
packages/db/migrations/meta/20251009211316_snapshot.json
Normal file
2837
packages/db/migrations/meta/20251009211316_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,34 @@
|
||||
"when": 1758226671081,
|
||||
"tag": "20250918201751_AddPausedMemberStatus",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "7",
|
||||
"when": 1758662398166,
|
||||
"tag": "20250923211958_AddWorkspaceInviteLinks",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "7",
|
||||
"when": 1759356096392,
|
||||
"tag": "20251001220136_AddFuzzySearchSupport",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1759869689304,
|
||||
"tag": "20251007204129_AddBoardTypeAndSourceIdColumns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1760044396109,
|
||||
"tag": "20251009211316_AddBoardSourceIdToCardActivity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import type { dbClient } from "@kan/db/client";
|
||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||
import {
|
||||
boards,
|
||||
cardActivities,
|
||||
cards,
|
||||
cardsToLabels,
|
||||
cardToWorkspaceMembers,
|
||||
@@ -15,13 +16,38 @@ import {
|
||||
} from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const getAllByWorkspaceId = (db: dbClient, workspaceId: number) => {
|
||||
export const getAllByWorkspaceId = (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
opts?: { type?: "regular" | "template" },
|
||||
) => {
|
||||
return db.query.boards.findMany({
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
},
|
||||
where: and(eq(boards.workspaceId, workspaceId), isNull(boards.deletedAt)),
|
||||
with: {
|
||||
lists: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
index: true,
|
||||
},
|
||||
orderBy: [asc(lists.index)],
|
||||
},
|
||||
labels: {
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
colourCode: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
isNull(boards.deletedAt),
|
||||
opts?.type ? eq(boards.type, opts.type) : undefined,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -29,6 +55,7 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
where: eq(boards.publicId, boardPublicId),
|
||||
});
|
||||
@@ -42,6 +69,7 @@ export const getByPublicId = async (
|
||||
filters: {
|
||||
members: string[];
|
||||
labels: string[];
|
||||
type: "regular" | "template" | undefined;
|
||||
},
|
||||
) => {
|
||||
let cardIds: string[] = [];
|
||||
@@ -199,7 +227,11 @@ export const getByPublicId = async (
|
||||
orderBy: [asc(lists.index)],
|
||||
},
|
||||
},
|
||||
where: and(eq(boards.publicId, boardPublicId), isNull(boards.deletedAt)),
|
||||
where: and(
|
||||
eq(boards.publicId, boardPublicId),
|
||||
isNull(boards.deletedAt),
|
||||
eq(boards.type, filters.type ?? "regular"),
|
||||
),
|
||||
});
|
||||
|
||||
if (!board) return null;
|
||||
@@ -412,6 +444,8 @@ export const create = async (
|
||||
workspaceId: number;
|
||||
importId?: number;
|
||||
slug: string;
|
||||
type?: "regular" | "template";
|
||||
sourceBoardId?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -423,6 +457,8 @@ export const create = async (
|
||||
workspaceId: boardInput.workspaceId,
|
||||
importId: boardInput.importId,
|
||||
slug: boardInput.slug,
|
||||
type: boardInput.type ?? "regular",
|
||||
sourceBoardId: boardInput.sourceBoardId,
|
||||
})
|
||||
.returning({
|
||||
id: boards.id,
|
||||
@@ -542,3 +578,229 @@ export const isBoardSlugAvailable = async (
|
||||
|
||||
return result === undefined;
|
||||
};
|
||||
|
||||
// Create a new board (regular/template) from a full board snapshot
|
||||
export const createFromSnapshot = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
source: {
|
||||
name: string;
|
||||
labels: { publicId: string; name: string; colourCode: string | null }[];
|
||||
lists: {
|
||||
name: string;
|
||||
index: number;
|
||||
cards: {
|
||||
title: string;
|
||||
description: string | null;
|
||||
index: number;
|
||||
labels: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
colourCode: string | null;
|
||||
}[];
|
||||
checklists?: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
index: number;
|
||||
items: {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
index: number;
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
workspaceId: number;
|
||||
createdBy: string;
|
||||
slug: string;
|
||||
name?: string;
|
||||
type: "regular" | "template";
|
||||
sourceBoardId?: number;
|
||||
},
|
||||
) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const [newBoard] = await tx
|
||||
.insert(boards)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
name: args.name ?? args.source.name,
|
||||
slug: args.slug,
|
||||
createdBy: args.createdBy,
|
||||
workspaceId: args.workspaceId,
|
||||
type: args.type,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
})
|
||||
.returning({
|
||||
id: boards.id,
|
||||
publicId: boards.publicId,
|
||||
name: boards.name,
|
||||
});
|
||||
|
||||
if (!newBoard) throw new Error("Failed to create board");
|
||||
|
||||
// Labels
|
||||
const srcLabels = args.source.labels;
|
||||
const labelMap = new Map<string, number>();
|
||||
|
||||
if (srcLabels.length) {
|
||||
const inserted = await tx
|
||||
.insert(labels)
|
||||
.values(
|
||||
srcLabels.map((l) => ({
|
||||
publicId: generateUID(),
|
||||
name: l.name,
|
||||
colourCode: l.colourCode ?? null,
|
||||
createdBy: args.createdBy,
|
||||
boardId: newBoard.id,
|
||||
})),
|
||||
)
|
||||
.returning({ id: labels.id });
|
||||
|
||||
for (let i = 0; i < srcLabels.length; i++) {
|
||||
const src = srcLabels[i];
|
||||
|
||||
if (!src) throw new Error("Source label not found");
|
||||
|
||||
const created = inserted[i];
|
||||
if (created) labelMap.set(src.publicId, created.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
const listIndexToId = new Map<number, number>();
|
||||
const srcLists = [...args.source.lists].sort((a, b) => a.index - b.index);
|
||||
if (srcLists.length) {
|
||||
const insertedLists = await tx
|
||||
.insert(lists)
|
||||
.values(
|
||||
srcLists.map((list) => ({
|
||||
publicId: generateUID(),
|
||||
name: list.name,
|
||||
createdBy: args.createdBy,
|
||||
boardId: newBoard.id,
|
||||
index: list.index,
|
||||
})),
|
||||
)
|
||||
.returning({ id: lists.id, index: lists.index });
|
||||
|
||||
for (const list of insertedLists) listIndexToId.set(list.index, list.id);
|
||||
}
|
||||
|
||||
// Cards, card-labels, checklists
|
||||
for (const list of srcLists) {
|
||||
const newListId = listIndexToId.get(list.index);
|
||||
if (!newListId) continue;
|
||||
const sortedCards = [...list.cards].sort((a, b) => a.index - b.index);
|
||||
|
||||
for (const card of sortedCards) {
|
||||
const [createdCard] = await tx
|
||||
.insert(cards)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
title: card.title,
|
||||
description: card.description ?? "",
|
||||
createdBy: args.createdBy,
|
||||
listId: newListId,
|
||||
index: card.index,
|
||||
})
|
||||
.returning({ id: cards.id });
|
||||
|
||||
if (!createdCard) throw new Error("Failed to create card");
|
||||
|
||||
// Create card.created activity
|
||||
await tx.insert(cardActivities).values({
|
||||
publicId: generateUID(),
|
||||
type: "card.created",
|
||||
cardId: createdCard.id,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
});
|
||||
|
||||
if (card.labels.length) {
|
||||
const cardLabels: { cardId: number; labelId: number }[] = [];
|
||||
for (const label of card.labels) {
|
||||
const newLabelId = labelMap.get(label.publicId);
|
||||
if (newLabelId)
|
||||
cardLabels.push({ cardId: createdCard.id, labelId: newLabelId });
|
||||
}
|
||||
if (cardLabels.length) {
|
||||
await tx.insert(cardsToLabels).values(cardLabels);
|
||||
|
||||
// Create card.updated.label.added activities for each label
|
||||
const labelActivities = cardLabels.map((cardLabel) => ({
|
||||
publicId: generateUID(),
|
||||
type: "card.updated.label.added" as const,
|
||||
cardId: cardLabel.cardId,
|
||||
labelId: cardLabel.labelId,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
}));
|
||||
await tx.insert(cardActivities).values(labelActivities);
|
||||
}
|
||||
}
|
||||
|
||||
if (card.checklists?.length) {
|
||||
const sortedChecklists = [...card.checklists].sort(
|
||||
(a, b) => a.index - b.index,
|
||||
);
|
||||
for (const checklist of sortedChecklists) {
|
||||
const [createdChecklist] = await tx
|
||||
.insert(checklists)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
name: checklist.name,
|
||||
createdBy: args.createdBy,
|
||||
cardId: createdCard.id,
|
||||
index: checklist.index,
|
||||
})
|
||||
.returning({ id: checklists.id });
|
||||
|
||||
if (!createdChecklist) continue;
|
||||
|
||||
// Create card.updated.checklist.added activity
|
||||
await tx.insert(cardActivities).values({
|
||||
publicId: generateUID(),
|
||||
type: "card.updated.checklist.added",
|
||||
cardId: createdCard.id,
|
||||
toTitle: checklist.name,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
});
|
||||
|
||||
if (checklist.items.length) {
|
||||
const itemValues = [...checklist.items]
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((checklistItem) => ({
|
||||
publicId: generateUID(),
|
||||
title: checklistItem.title,
|
||||
createdBy: args.createdBy,
|
||||
checklistId: createdChecklist.id,
|
||||
index: checklistItem.index,
|
||||
completed: !!checklistItem.completed,
|
||||
}));
|
||||
|
||||
if (itemValues.length) {
|
||||
await tx.insert(checklistItems).values(itemValues);
|
||||
|
||||
// Create card.updated.checklist.item.added activities for each item
|
||||
const itemActivities = itemValues.map((item) => ({
|
||||
publicId: generateUID(),
|
||||
type: "card.updated.checklist.item.added" as const,
|
||||
cardId: createdCard.id,
|
||||
toTitle: item.title,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
}));
|
||||
await tx.insert(cardActivities).values(itemActivities);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newBoard;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,9 +95,32 @@ export const create = async (
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (duplicateIndices.length > 0) {
|
||||
throw new Error(
|
||||
`Duplicate indices found after creating card ${result[0].id}`,
|
||||
);
|
||||
// Compact indices for this list to sequential values (0..n-1) preserving order
|
||||
await tx.execute(sql`
|
||||
WITH ordered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||
FROM "card"
|
||||
WHERE "listId" = ${result[0].listId} AND "deletedAt" IS NULL
|
||||
)
|
||||
UPDATE "card" c
|
||||
SET "index" = o.new_index
|
||||
FROM ordered o
|
||||
WHERE c.id = o.id;
|
||||
`);
|
||||
|
||||
// Last resort: verify fix; rollback if duplicates persist
|
||||
const postFixDupes = await tx
|
||||
.select({ index: cards.index, count: countExpr })
|
||||
.from(cards)
|
||||
.where(and(eq(cards.listId, result[0].listId), isNull(cards.deletedAt)))
|
||||
.groupBy(cards.listId, cards.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (postFixDupes.length > 0) {
|
||||
throw new Error(
|
||||
`Invariant violation: duplicate card indices remain after compaction in list ${result[0].listId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result[0];
|
||||
@@ -219,11 +242,95 @@ export const bulkCreate = async (
|
||||
importId?: number;
|
||||
}[],
|
||||
) => {
|
||||
const result = await db.insert(cards).values(cardInput).returning({
|
||||
id: cards.id,
|
||||
});
|
||||
if (cardInput.length === 0) return [];
|
||||
|
||||
return result;
|
||||
return db.transaction(async (tx) => {
|
||||
// Group incoming cards by list to compute safe, sequential indices per list
|
||||
const byList = new Map<number, typeof cardInput>();
|
||||
for (const item of cardInput) {
|
||||
const arr = byList.get(item.listId) ?? [];
|
||||
arr.push(item);
|
||||
byList.set(item.listId, arr);
|
||||
}
|
||||
|
||||
const allValuesToInsert: {
|
||||
publicId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
createdBy: string;
|
||||
listId: number;
|
||||
index: number;
|
||||
importId?: number;
|
||||
}[] = [];
|
||||
|
||||
// For each list, append incoming cards after current max index, preserving incoming order
|
||||
for (const [listId, items] of byList.entries()) {
|
||||
const last = await tx.query.cards.findFirst({
|
||||
columns: { index: true },
|
||||
where: and(eq(cards.listId, listId), isNull(cards.deletedAt)),
|
||||
orderBy: [desc(cards.index)],
|
||||
});
|
||||
|
||||
let nextIndex = last ? last.index + 1 : 0;
|
||||
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||
for (const it of sorted) {
|
||||
allValuesToInsert.push({
|
||||
publicId: it.publicId,
|
||||
title: it.title,
|
||||
description: it.description,
|
||||
createdBy: it.createdBy,
|
||||
listId: it.listId,
|
||||
index: nextIndex++,
|
||||
importId: it.importId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await tx
|
||||
.insert(cards)
|
||||
.values(allValuesToInsert)
|
||||
.returning({ id: cards.id });
|
||||
|
||||
// Post-insert: compact per list if duplicates exist; then verify
|
||||
const countExpr = sql<number>`COUNT(*)`.mapWith(Number);
|
||||
for (const listId of byList.keys()) {
|
||||
const duplicateIndices = await tx
|
||||
.select({ index: cards.index, count: countExpr })
|
||||
.from(cards)
|
||||
.where(and(eq(cards.listId, listId), isNull(cards.deletedAt)))
|
||||
.groupBy(cards.listId, cards.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (duplicateIndices.length > 0) {
|
||||
await tx.execute(sql`
|
||||
WITH ordered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||
FROM "card"
|
||||
WHERE "listId" = ${listId} AND "deletedAt" IS NULL
|
||||
)
|
||||
UPDATE "card" c
|
||||
SET "index" = o.new_index
|
||||
FROM ordered o
|
||||
WHERE c.id = o.id;
|
||||
`);
|
||||
|
||||
const postFixDupes = await tx
|
||||
.select({ index: cards.index, count: countExpr })
|
||||
.from(cards)
|
||||
.where(and(eq(cards.listId, listId), isNull(cards.deletedAt)))
|
||||
.groupBy(cards.listId, cards.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (postFixDupes.length > 0) {
|
||||
throw new Error(
|
||||
`Invariant violation: duplicate card indices remain after compaction in list ${listId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inserted;
|
||||
});
|
||||
};
|
||||
|
||||
export const createCardLabelRelationship = async (
|
||||
@@ -606,9 +713,53 @@ export const reorder = async (
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (duplicateIndices.length > 0) {
|
||||
throw new Error(
|
||||
`Duplicate indices found after reordering card ${card.id}`,
|
||||
// Auto-heal by compacting indices for the affected list(s)
|
||||
const affectedListIds = [currentList.id, newList?.id].filter(
|
||||
(id): id is number => id !== undefined,
|
||||
);
|
||||
|
||||
if (affectedListIds.length === 1) {
|
||||
await tx.execute(sql`
|
||||
WITH ordered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||
FROM "card"
|
||||
WHERE "listId" = ${affectedListIds[0]} AND "deletedAt" IS NULL
|
||||
)
|
||||
UPDATE "card" c
|
||||
SET "index" = o.new_index
|
||||
FROM ordered o
|
||||
WHERE c.id = o.id;
|
||||
`);
|
||||
} else if (affectedListIds.length === 2) {
|
||||
await tx.execute(sql`
|
||||
WITH ordered AS (
|
||||
SELECT id,
|
||||
ROW_NUMBER() OVER (PARTITION BY "listId" ORDER BY "index", id) - 1 AS new_index
|
||||
FROM "card"
|
||||
WHERE "listId" IN (${sql.join(affectedListIds, sql`,`)}) AND "deletedAt" IS NULL
|
||||
)
|
||||
UPDATE "card" c
|
||||
SET "index" = o.new_index
|
||||
FROM ordered o
|
||||
WHERE c.id = o.id;
|
||||
`);
|
||||
}
|
||||
|
||||
// Verify fix and rollback if necessary
|
||||
const postFixDupes = await tx
|
||||
.select({ index: cards.index, count: countExpr })
|
||||
.from(cards)
|
||||
.where(
|
||||
and(inArray(cards.listId, affectedListIds), isNull(cards.deletedAt)),
|
||||
)
|
||||
.groupBy(cards.listId, cards.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (postFixDupes.length > 0) {
|
||||
throw new Error(
|
||||
`Invariant violation: duplicate card indices remain after compaction for card ${card.id}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedCard = await tx.query.cards.findFirst({
|
||||
|
||||
@@ -22,6 +22,7 @@ export const create = async (
|
||||
commentId?: number;
|
||||
fromComment?: string;
|
||||
toComment?: string;
|
||||
sourceBoardId?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -44,6 +45,7 @@ export const create = async (
|
||||
commentId: activityInput.commentId,
|
||||
fromComment: activityInput.fromComment,
|
||||
toComment: activityInput.toComment,
|
||||
sourceBoardId: activityInput.sourceBoardId,
|
||||
})
|
||||
.returning({ id: cardActivities.id });
|
||||
|
||||
@@ -66,6 +68,7 @@ export const bulkCreate = async (
|
||||
fromDescription?: string;
|
||||
toDescription?: string;
|
||||
createdBy: string;
|
||||
sourceBoardId?: number;
|
||||
}[],
|
||||
) => {
|
||||
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
||||
|
||||
71
packages/db/src/repository/inviteLink.repo.ts
Normal file
71
packages/db/src/repository/inviteLink.repo.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { and, eq, gt } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { workspaceInviteLinks } from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const createInviteLink = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
workspaceId: number;
|
||||
code: string;
|
||||
expiresAt: Date | null;
|
||||
createdBy: string;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
.insert(workspaceInviteLinks)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
workspaceId: args.workspaceId,
|
||||
code: args.code,
|
||||
expiresAt: args.expiresAt ?? null,
|
||||
status: "active",
|
||||
createdBy: args.createdBy,
|
||||
})
|
||||
.returning({
|
||||
publicId: workspaceInviteLinks.publicId,
|
||||
code: workspaceInviteLinks.code,
|
||||
status: workspaceInviteLinks.status,
|
||||
expiresAt: workspaceInviteLinks.expiresAt,
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
export const deactivateAllActiveForWorkspace = async (
|
||||
db: dbClient,
|
||||
args: { workspaceId: number; updatedBy: string },
|
||||
) => {
|
||||
await db
|
||||
.update(workspaceInviteLinks)
|
||||
.set({
|
||||
status: "inactive",
|
||||
updatedBy: args.updatedBy,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(workspaceInviteLinks.workspaceId, args.workspaceId),
|
||||
eq(workspaceInviteLinks.status, "active"),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
export const getActiveForWorkspace = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
) => {
|
||||
return db.query.workspaceInviteLinks.findFirst({
|
||||
where: and(
|
||||
eq(workspaceInviteLinks.workspaceId, workspaceId),
|
||||
eq(workspaceInviteLinks.status, "active"),
|
||||
),
|
||||
orderBy: (links, { desc }) => [desc(links.createdAt)],
|
||||
});
|
||||
};
|
||||
|
||||
export const getByCode = async (db: dbClient, code: string) => {
|
||||
return db.query.workspaceInviteLinks.findFirst({
|
||||
where: eq(workspaceInviteLinks.code, code),
|
||||
});
|
||||
};
|
||||
@@ -59,9 +59,32 @@ export const create = async (
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (duplicateIndices.length > 0) {
|
||||
throw new Error(
|
||||
`Duplicate indices found after reordering in board ${result.boardId}`,
|
||||
);
|
||||
// Compact indices to sequential values (0..n-1) to resolve duplicates while preserving order
|
||||
await tx.execute(sql`
|
||||
WITH ordered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||
FROM "list"
|
||||
WHERE "boardId" = ${result.boardId} AND "deletedAt" IS NULL
|
||||
)
|
||||
UPDATE "list" l
|
||||
SET "index" = o.new_index
|
||||
FROM ordered o
|
||||
WHERE l.id = o.id;
|
||||
`);
|
||||
|
||||
// Last resort: verify fix; if duplicates persist (e.g., due to race conditions), rollback
|
||||
const postFixDupes = await tx
|
||||
.select({ index: lists.index, count: countExpr })
|
||||
.from(lists)
|
||||
.where(and(eq(lists.boardId, result.boardId), isNull(lists.deletedAt)))
|
||||
.groupBy(lists.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (postFixDupes.length > 0) {
|
||||
throw new Error(
|
||||
`Invariant violation: duplicate indices remain after compaction in board ${result.boardId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -79,7 +102,98 @@ export const bulkCreate = async (
|
||||
importId?: number;
|
||||
}[],
|
||||
) => {
|
||||
return db.insert(lists).values(listInput).returning();
|
||||
if (listInput.length === 0) return [];
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
// Group incoming rows by board to compute safe, sequential indices per board
|
||||
const byBoard = new Map<number, typeof listInput>();
|
||||
for (const item of listInput) {
|
||||
const arr = byBoard.get(item.boardId) ?? [];
|
||||
arr.push(item);
|
||||
byBoard.set(item.boardId, arr);
|
||||
}
|
||||
|
||||
const allValuesToInsert: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
createdBy: string;
|
||||
boardId: number;
|
||||
index: number;
|
||||
importId?: number;
|
||||
}[] = [];
|
||||
|
||||
// For each board, append incoming lists after the current max index, preserving their relative order
|
||||
for (const [boardId, items] of byBoard.entries()) {
|
||||
// Find current max index for non-deleted lists in this board
|
||||
const last = await tx.query.lists.findFirst({
|
||||
columns: { index: true },
|
||||
where: and(eq(lists.boardId, boardId), isNull(lists.deletedAt)),
|
||||
orderBy: [desc(lists.index)],
|
||||
});
|
||||
|
||||
let nextIndex = last ? last.index + 1 : 0;
|
||||
|
||||
// Sort incoming by their provided index to preserve Trello order, then reassign sequential indices
|
||||
const sorted = [...items].sort((a, b) => a.index - b.index);
|
||||
for (const it of sorted) {
|
||||
allValuesToInsert.push({
|
||||
publicId: it.publicId,
|
||||
name: it.name,
|
||||
createdBy: it.createdBy,
|
||||
boardId: it.boardId,
|
||||
index: nextIndex++,
|
||||
importId: it.importId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Insert all rows in one go
|
||||
const inserted = await tx
|
||||
.insert(lists)
|
||||
.values(allValuesToInsert)
|
||||
.returning();
|
||||
|
||||
// Post-insert check: if duplicates exist, compact indices per board instead of failing
|
||||
const countExpr = sql<number>`COUNT(*)`.mapWith(Number);
|
||||
for (const boardId of byBoard.keys()) {
|
||||
const duplicateIndices = await tx
|
||||
.select({ index: lists.index, count: countExpr })
|
||||
.from(lists)
|
||||
.where(and(eq(lists.boardId, boardId), isNull(lists.deletedAt)))
|
||||
.groupBy(lists.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (duplicateIndices.length > 0) {
|
||||
await tx.execute(sql`
|
||||
WITH ordered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||
FROM "list"
|
||||
WHERE "boardId" = ${boardId} AND "deletedAt" IS NULL
|
||||
)
|
||||
UPDATE "list" l
|
||||
SET "index" = o.new_index
|
||||
FROM ordered o
|
||||
WHERE l.id = o.id;
|
||||
`);
|
||||
|
||||
// Last resort: verify fix; if duplicates persist (e.g., due to race conditions), rollback
|
||||
const postFixDupes = await tx
|
||||
.select({ index: lists.index, count: countExpr })
|
||||
.from(lists)
|
||||
.where(and(eq(lists.boardId, boardId), isNull(lists.deletedAt)))
|
||||
.groupBy(lists.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (postFixDupes.length > 0) {
|
||||
throw new Error(
|
||||
`Invariant violation: duplicate indices remain after compaction in board ${boardId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inserted;
|
||||
});
|
||||
};
|
||||
|
||||
export const getByPublicId = async (db: dbClient, listPublicId: string) => {
|
||||
@@ -180,9 +294,32 @@ export const reorder = async (
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (duplicateIndices.length > 0) {
|
||||
throw new Error(
|
||||
`Duplicate indices found after reordering in board ${list.boardId}`,
|
||||
);
|
||||
// Attempt to auto-heal by compacting indices to sequential values (0..n-1) while preserving order
|
||||
await tx.execute(sql`
|
||||
WITH ordered AS (
|
||||
SELECT id, ROW_NUMBER() OVER (ORDER BY "index", id) - 1 AS new_index
|
||||
FROM "list"
|
||||
WHERE "boardId" = ${list.boardId} AND "deletedAt" IS NULL
|
||||
)
|
||||
UPDATE "list" l
|
||||
SET "index" = o.new_index
|
||||
FROM ordered o
|
||||
WHERE l.id = o.id;
|
||||
`);
|
||||
|
||||
// Last resort verification: if duplicates persist, rollback
|
||||
const postFixDupes = await tx
|
||||
.select({ index: lists.index, count: countExpr })
|
||||
.from(lists)
|
||||
.where(and(eq(lists.boardId, list.boardId), isNull(lists.deletedAt)))
|
||||
.groupBy(lists.index)
|
||||
.having(gt(countExpr, 1));
|
||||
|
||||
if (postFixDupes.length > 0) {
|
||||
throw new Error(
|
||||
`Invariant violation: duplicate indices remain after compaction in board ${list.boardId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const updatedList = await tx.query.lists.findFirst({
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||
import { and, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
||||
|
||||
import type { dbClient } from "@kan/db/client";
|
||||
import { boards, workspaceMembers, workspaces } from "@kan/db/schema";
|
||||
import {
|
||||
boards,
|
||||
cards,
|
||||
lists,
|
||||
workspaceMembers,
|
||||
workspaces,
|
||||
} from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const create = async (
|
||||
@@ -87,11 +93,25 @@ export const getByPublicId = (db: dbClient, workspacePublicId: string) => {
|
||||
publicId: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
slug: true,
|
||||
},
|
||||
where: eq(workspaces.publicId, workspacePublicId),
|
||||
});
|
||||
};
|
||||
|
||||
export const getById = (db: dbClient, workspaceId: number) => {
|
||||
return db.query.workspaces.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
publicId: true,
|
||||
name: true,
|
||||
plan: true,
|
||||
slug: true,
|
||||
},
|
||||
where: eq(workspaces.id, workspaceId),
|
||||
});
|
||||
};
|
||||
|
||||
export const getByPublicIdWithMembers = (
|
||||
db: dbClient,
|
||||
workspacePublicId: string,
|
||||
@@ -260,3 +280,84 @@ export const isUserInWorkspace = async (
|
||||
|
||||
return result?.id !== undefined;
|
||||
};
|
||||
|
||||
export const searchBoardsAndCards = async (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
query: string,
|
||||
limit = 20,
|
||||
) => {
|
||||
const searchQuery = `%${query}%`;
|
||||
|
||||
// Search for boards
|
||||
const boardResults = await db
|
||||
.select({
|
||||
publicId: boards.publicId,
|
||||
title: boards.name,
|
||||
description: boards.description,
|
||||
slug: boards.slug,
|
||||
updatedAt: boards.updatedAt,
|
||||
createdAt: boards.createdAt,
|
||||
})
|
||||
.from(boards)
|
||||
.where(
|
||||
and(
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
// Combine exact and fuzzy matching
|
||||
or(
|
||||
ilike(boards.name, `%${query}%`), // Exact substring match
|
||||
sql`similarity(${boards.name}, ${query}) > 0.2`, // Fuzzy match
|
||||
),
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
sql`CASE WHEN ${boards.name} ILIKE ${`%${query}%`} THEN 1 ELSE 0 END DESC`,
|
||||
sql`similarity(${boards.name}, ${query}) DESC`,
|
||||
desc(boards.updatedAt),
|
||||
)
|
||||
.limit(Math.ceil(limit * 0.4));
|
||||
|
||||
// Search for cards
|
||||
const cardResults = await db
|
||||
.select({
|
||||
publicId: cards.publicId,
|
||||
title: cards.title,
|
||||
description: cards.description,
|
||||
boardPublicId: boards.publicId,
|
||||
boardName: boards.name,
|
||||
listName: lists.name,
|
||||
updatedAt: cards.updatedAt,
|
||||
createdAt: cards.createdAt,
|
||||
})
|
||||
.from(cards)
|
||||
.innerJoin(lists, eq(cards.listId, lists.id))
|
||||
.innerJoin(boards, eq(lists.boardId, boards.id))
|
||||
.where(
|
||||
and(
|
||||
eq(boards.workspaceId, workspaceId),
|
||||
or(
|
||||
ilike(cards.title, searchQuery),
|
||||
sql`similarity(${cards.title}, ${query}) > 0.2`,
|
||||
),
|
||||
isNull(cards.deletedAt),
|
||||
isNull(lists.deletedAt),
|
||||
isNull(boards.deletedAt),
|
||||
),
|
||||
)
|
||||
.orderBy(
|
||||
sql`CASE WHEN ${cards.title} ILIKE ${searchQuery} THEN 1 ELSE 0 END DESC`,
|
||||
sql`similarity(${cards.title}, ${query}) DESC`,
|
||||
desc(cards.updatedAt),
|
||||
)
|
||||
.limit(Math.floor(limit * 0.6));
|
||||
|
||||
// Combine results
|
||||
const allResults = [
|
||||
...boardResults.map((board) => ({ ...board, type: "board" as const })),
|
||||
...cardResults.map((card) => ({ ...card, type: "card" as const })),
|
||||
];
|
||||
|
||||
// Ensure we don't exceed the total limit
|
||||
return allResults.slice(0, limit);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,10 @@ export const boardVisibilityEnum = pgEnum(
|
||||
boardVisibilityStatuses,
|
||||
);
|
||||
|
||||
export const boardTypes = ["regular", "template"] as const;
|
||||
export type BoardType = (typeof boardTypes)[number];
|
||||
export const boardTypeEnum = pgEnum("board_type", boardTypes);
|
||||
|
||||
export const boards = pgTable(
|
||||
"board",
|
||||
{
|
||||
@@ -49,9 +53,13 @@ export const boards = pgTable(
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
|
||||
type: boardTypeEnum("type").notNull().default("regular"),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
|
||||
},
|
||||
(table) => [
|
||||
index("board_visibility_idx").on(table.visibility),
|
||||
index("board_type_idx").on(table.type),
|
||||
index("board_source_idx").on(table.sourceBoardId),
|
||||
uniqueIndex("unique_slug_per_workspace")
|
||||
.on(table.workspaceId, table.slug)
|
||||
.where(sql`${table.deletedAt} IS NULL`),
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { boards } from "./boards";
|
||||
import { checklists } from "./checklists";
|
||||
import { imports } from "./imports";
|
||||
import { labels } from "./labels";
|
||||
@@ -133,6 +134,10 @@ export const cardActivities = pgTable("card_activity", {
|
||||
),
|
||||
fromComment: text("fromComment"),
|
||||
toComment: text("toComment"),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }).references(
|
||||
() => boards.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
}).enableRLS();
|
||||
|
||||
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
|
||||
@@ -11,3 +11,4 @@ export * from "./users";
|
||||
export * from "./integrations";
|
||||
export * from "./workspaces";
|
||||
export * from "./subscriptions";
|
||||
export * from "./workspaceInviteLinks";
|
||||
|
||||
38
packages/db/src/schema/workspaceInviteLinks.ts
Normal file
38
packages/db/src/schema/workspaceInviteLinks.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
bigint,
|
||||
bigserial,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
timestamp,
|
||||
uuid,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { users } from "./users";
|
||||
import { workspaces } from "./workspaces";
|
||||
|
||||
export const inviteLinkStatuses = ["active", "inactive"] as const;
|
||||
export type InviteLinkStatus = (typeof inviteLinkStatuses)[number];
|
||||
export const inviteLinkStatusEnum = pgEnum(
|
||||
"invite_link_status",
|
||||
inviteLinkStatuses,
|
||||
);
|
||||
|
||||
export const workspaceInviteLinks = pgTable("workspace_invite_links", {
|
||||
id: bigserial("id", { mode: "number" }).primaryKey(),
|
||||
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
|
||||
workspaceId: bigint("workspaceId", { mode: "number" })
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
code: varchar("code", { length: 12 }).notNull().unique(),
|
||||
status: inviteLinkStatusEnum("status").notNull().default("active"),
|
||||
expiresAt: timestamp("expiresAt"),
|
||||
createdAt: timestamp("createdAt").defaultNow().notNull(),
|
||||
createdBy: uuid("createdBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
updatedAt: timestamp("updatedAt"),
|
||||
updatedBy: uuid("updatedBy").references(() => users.id, {
|
||||
onDelete: "set null",
|
||||
}),
|
||||
}).enableRLS();
|
||||
@@ -21,13 +21,13 @@ export interface Subscription {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = ["active", "trialing", "past_due"];
|
||||
|
||||
export const getActiveSubscriptions = (
|
||||
subscriptions: Subscription[] | undefined,
|
||||
) => {
|
||||
if (!subscriptions) return [];
|
||||
return subscriptions.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
);
|
||||
return subscriptions.filter((sub) => ACTIVE_STATUSES.includes(sub.status));
|
||||
};
|
||||
|
||||
export const getSubscriptionByPlan = (
|
||||
@@ -36,9 +36,7 @@ export const getSubscriptionByPlan = (
|
||||
) => {
|
||||
if (!subscriptions) return undefined;
|
||||
return subscriptions.find(
|
||||
(sub) =>
|
||||
sub.plan === plan &&
|
||||
(sub.status === "active" || sub.status === "trialing"),
|
||||
(sub) => sub.plan === plan && ACTIVE_STATUSES.includes(sub.status),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"APPLE_CLIENT_ID",
|
||||
"APPLE_CLIENT_SECRET",
|
||||
"APPLE_APP_BUNDLE_IDENTIFIER",
|
||||
"NEXT_PUBLIC_DISABLE_EMAIL",
|
||||
"EMAIL_FROM",
|
||||
"SMTP_HOST",
|
||||
"SMTP_PORT",
|
||||
|
||||
Reference in New Issue
Block a user