Compare commits
8 Commits
v0.4.5
...
fix/mobile
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91986d5f12 | ||
|
|
ad7e0e89dd | ||
|
|
b2f3849716 | ||
|
|
756d93c067 | ||
|
|
c7288cbea8 | ||
|
|
5f9360c927 | ||
|
|
a7a27e7054 | ||
|
|
362381f516 |
@@ -27,7 +27,7 @@
|
|||||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||||
- 💬 **Comments**: Discuss and collaborate with your team
|
- 💬 **Comments**: Discuss and collaborate with your team
|
||||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
- 📝 **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
|
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||||
|
|
||||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"version": 0,
|
"version": 0,
|
||||||
"locale": {
|
"locale": {
|
||||||
"source": "en",
|
"source": "en",
|
||||||
"targets": ["fr", "de", "es", "it", "nl", "ru"]
|
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
||||||
},
|
},
|
||||||
"buckets": {
|
"buckets": {
|
||||||
"po": {
|
"po": {
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ checksums:
|
|||||||
8033ed03a24d7c4898c90a5365297c20:
|
8033ed03a24d7c4898c90a5365297c20:
|
||||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||||
|
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
||||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
"%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
|
"%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
|
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
|
||||||
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
||||||
@@ -13,6 +15,7 @@ checksums:
|
|||||||
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
||||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||||
|
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
||||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||||
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
||||||
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
||||||
@@ -48,12 +51,14 @@ checksums:
|
|||||||
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
||||||
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
|
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%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%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
|
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%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
|
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
|
||||||
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
|
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
|
assigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
|
||||||
|
Assignees/singular: 96fc0e28a783d80decfd4716a440ac08
|
||||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||||
@@ -67,6 +72,7 @@ checksums:
|
|||||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
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%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
|
||||||
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
||||||
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
||||||
@@ -74,7 +80,6 @@ checksums:
|
|||||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||||
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
|
||||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||||
@@ -85,6 +90,7 @@ checksums:
|
|||||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
||||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||||
|
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
||||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||||
@@ -92,7 +98,9 @@ checksums:
|
|||||||
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
||||||
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
||||||
Comments/singular: 502237cd1044491c48494aa60967a985
|
Comments/singular: 502237cd1044491c48494aa60967a985
|
||||||
|
Comments%20%26%20mentions/singular: 1f3a822f4f97a9b07db82d24c4204f75
|
||||||
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||||
|
Compare%20our%20features%20to%20see%20why%20Kan%20is%20the%20best%20choice./singular: c186ed37ff27cfc2a1ac11e66b2b30f1
|
||||||
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
||||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||||
@@ -107,25 +115,30 @@ checksums:
|
|||||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
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%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||||
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
|
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
|
||||||
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
|
|
||||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||||
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
||||||
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
|
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
|
||||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||||
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
Create%20new%20%7B0%7D/singular: 163dbd5cf86d512fadd4c3eaf9709a11
|
||||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||||
|
Create%20template/singular: 57bfe6b58c42958a90f67d605a69ecdc
|
||||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||||
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
|
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
|
||||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||||
|
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||||
|
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||||
|
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||||
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
||||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||||
@@ -136,6 +149,7 @@ checksums:
|
|||||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||||
|
Delete%20template/singular: bdf3e8342f581d498b7238cdd4897a4b
|
||||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
||||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
||||||
@@ -190,9 +204,13 @@ checksums:
|
|||||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
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
|
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||||
|
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
||||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||||
|
Fast%20%26%20lightweight/singular: 4f2a80c555a5427b94eef5124aa10775
|
||||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||||
|
Feature%20breakdown/singular: cae23c2a3fbb21735289571f2e1f60db
|
||||||
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
||||||
Features/singular: 341ff316a339b106a178f0b8d362951b
|
Features/singular: 341ff316a339b106a178f0b8d362951b
|
||||||
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
||||||
@@ -207,7 +225,7 @@ checksums:
|
|||||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
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%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
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%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
|
||||||
@@ -231,6 +249,7 @@ checksums:
|
|||||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||||
|
Import%20from%20Trello/singular: 9cce591a63763174952ffc11ae829f0c
|
||||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
||||||
@@ -239,22 +258,27 @@ checksums:
|
|||||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||||
|
Intelligent%20search/singular: da688e7cfb2775f719a12df6dd606f6e
|
||||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||||
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
||||||
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
|
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
|
||||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||||
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
|
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
|
||||||
Invite%20link%20copied%20to%20clipboard/singular: 6fc055a0ea0ed1aa58c5e0c502efe17f
|
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%20links%20require%20a%20Team%20or%20Pro%20subscription.%20Please%20upgrade%20your%20workspace./singular: 18db646fc55f99789572c8df0539ee69
|
||||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||||
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
||||||
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
|
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
|
||||||
Join%20workspace%20%7C%20kan.bn/singular: 97855216a0e00214b2dcf917e93164f2
|
Join%20workspace%20%7C%20kan.bn/singular: 97855216a0e00214b2dcf917e93164f2
|
||||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
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%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||||
|
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
||||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||||
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
||||||
@@ -272,6 +296,7 @@ checksums:
|
|||||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||||
|
Make%20template/singular: 7a1c63b75cea690b3fda6c3f203e31ff
|
||||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||||
@@ -284,8 +309,8 @@ checksums:
|
|||||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||||
|
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
||||||
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
|
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
|
||||||
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
|
|
||||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||||
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
||||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||||
@@ -293,11 +318,12 @@ checksums:
|
|||||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||||
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
||||||
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
||||||
|
New%20template/singular: 10dca64383235d1596a010e559b2e35a
|
||||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||||
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
||||||
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
|
No%20%7B0%7D/singular: a7a95e1dfdb4396da9e2be0e27f42323
|
||||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||||
@@ -306,9 +332,11 @@ checksums:
|
|||||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
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%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
|
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
|
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||||
|
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||||
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
||||||
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
|
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
|
||||||
@@ -321,6 +349,7 @@ checksums:
|
|||||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||||
Planning/singular: 353f58c75248275fe091740607501610
|
Planning/singular: 353f58c75248275fe091740607501610
|
||||||
|
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||||
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
|
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
|
||||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||||
@@ -328,6 +357,7 @@ checksums:
|
|||||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||||
|
Price%20per%20user%2Fmonth/singular: ae4b93ad0f9fad454b40f82010372fc2
|
||||||
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
||||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||||
@@ -354,6 +384,7 @@ checksums:
|
|||||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||||
|
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||||
Review/singular: 299f75db25382980b2895622d7712927
|
Review/singular: 299f75db25382980b2895622d7712927
|
||||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||||
@@ -367,6 +398,7 @@ checksums:
|
|||||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||||
|
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
||||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||||
@@ -395,6 +427,11 @@ checksums:
|
|||||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||||
Teams/singular: b63448c05270497973ac4407047dae02
|
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
|
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||||
@@ -426,6 +463,7 @@ checksums:
|
|||||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||||
|
Unable%20to%20create%20template/singular: 7495f1434c9188849bffd015811d5337
|
||||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
||||||
@@ -451,6 +489,7 @@ checksums:
|
|||||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
||||||
|
Unlimited%20workspaces/singular: b504d01997bf908af1d486d9942fed36
|
||||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||||
@@ -482,6 +521,7 @@ checksums:
|
|||||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||||
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
|
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
|
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
|
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||||
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { LinguiConfig } from "@lingui/conf";
|
import type { LinguiConfig } from "@lingui/conf";
|
||||||
|
|
||||||
const config: LinguiConfig = {
|
const config: LinguiConfig = {
|
||||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru"],
|
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
||||||
sourceLocale: "en",
|
sourceLocale: "en",
|
||||||
catalogs: [
|
catalogs: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Link from "next/link";
|
|||||||
import { useRouter } from "next/router";
|
import { useRouter } from "next/router";
|
||||||
import { Button } from "@headlessui/react";
|
import { Button } from "@headlessui/react";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { useTheme } from "next-themes";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
TbLayoutSidebarLeftCollapse,
|
TbLayoutSidebarLeftCollapse,
|
||||||
@@ -15,10 +16,11 @@ import membersIconDark from "~/assets/members-dark.json";
|
|||||||
import membersIconLight from "~/assets/members-light.json";
|
import membersIconLight from "~/assets/members-light.json";
|
||||||
import settingsIconDark from "~/assets/settings-dark.json";
|
import settingsIconDark from "~/assets/settings-dark.json";
|
||||||
import settingsIconLight from "~/assets/settings-light.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 ReactiveButton from "~/components/ReactiveButton";
|
||||||
import UserMenu from "~/components/UserMenu";
|
import UserMenu from "~/components/UserMenu";
|
||||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
|
|
||||||
interface SideNavigationProps {
|
interface SideNavigationProps {
|
||||||
user: UserType;
|
user: UserType;
|
||||||
@@ -69,6 +71,11 @@ export default function SideNavigation({
|
|||||||
href: "/boards",
|
href: "/boards",
|
||||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: t`Templates`,
|
||||||
|
href: "/templates",
|
||||||
|
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: t`Members`,
|
name: t`Members`,
|
||||||
href: "/members",
|
href: "/members",
|
||||||
|
|||||||
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];
|
export type Locale = (typeof locales)[number];
|
||||||
|
|
||||||
@@ -12,4 +21,5 @@ export const localeNames: Record<Locale, string> = {
|
|||||||
it: "Italiano",
|
it: "Italiano",
|
||||||
nl: "Nederlands",
|
nl: "Nederlands",
|
||||||
ru: "Русский",
|
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
2488
apps/web/src/locales/pl/messages.po
Normal file
2488
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/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;
|
||||||
@@ -25,3 +25,17 @@
|
|||||||
width: 200%;
|
width: 200%;
|
||||||
height: 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;
|
return (await import("~/locales/nl/messages")).messages;
|
||||||
case "ru":
|
case "ru":
|
||||||
return (await import("~/locales/ru/messages")).messages;
|
return (await import("~/locales/ru/messages")).messages;
|
||||||
|
case "pl":
|
||||||
|
return (await import("~/locales/pl/messages")).messages;
|
||||||
default:
|
default:
|
||||||
return enMessages;
|
return enMessages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,51 @@
|
|||||||
import { t } from "@lingui/core/macro";
|
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 Dropdown from "~/components/Dropdown";
|
||||||
import { useModal } from "~/providers/modal";
|
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();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
items={[
|
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`,
|
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
|
||||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: t`Delete board`,
|
|
||||||
action: () => openModal("DELETE_BOARD"),
|
action: () => openModal("DELETE_BOARD"),
|
||||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
@@ -6,8 +7,10 @@ import { api } from "~/utils/api";
|
|||||||
|
|
||||||
export function DeleteBoardConfirmation({
|
export function DeleteBoardConfirmation({
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
|
isTemplate,
|
||||||
}: {
|
}: {
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
|
isTemplate: boolean;
|
||||||
}) {
|
}) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
@@ -15,7 +18,7 @@ export function DeleteBoardConfirmation({
|
|||||||
const deleteBoard = api.board.delete.useMutation({
|
const deleteBoard = api.board.delete.useMutation({
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
closeModal();
|
closeModal();
|
||||||
router.push(`/boards`);
|
router.push(isTemplate ? `/templates` : `/boards`);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -30,18 +33,18 @@ export function DeleteBoardConfirmation({
|
|||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex w-full flex-col justify-between pb-4">
|
<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">
|
<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>
|
</h2>
|
||||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||||
<Button onClick={() => closeModal()} variant="secondary">
|
<Button onClick={() => closeModal()} variant="secondary">
|
||||||
Cancel
|
{t`Cancel`}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
||||||
Delete
|
{t`Delete`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
import type { NewCardInput } from "@kan/api/types";
|
import type { NewCardInput } from "@kan/api/types";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
import { generateUID } from "@kan/shared/utils";
|
||||||
|
|
||||||
|
import type { WorkspaceMember } from "~/components/Editor";
|
||||||
import Avatar from "~/components/Avatar";
|
import Avatar from "~/components/Avatar";
|
||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||||
import Editor, { WorkspaceMember } from "~/components/Editor";
|
import Editor from "~/components/Editor";
|
||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import LabelIcon from "~/components/LabelIcon";
|
import LabelIcon from "~/components/LabelIcon";
|
||||||
import Toggle from "~/components/Toggle";
|
import Toggle from "~/components/Toggle";
|
||||||
@@ -35,12 +36,14 @@ interface QueryParams {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface NewCardFormProps {
|
interface NewCardFormProps {
|
||||||
|
isTemplate: boolean;
|
||||||
boardPublicId: string;
|
boardPublicId: string;
|
||||||
listPublicId: string;
|
listPublicId: string;
|
||||||
queryParams: QueryParams;
|
queryParams: QueryParams;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewCardForm({
|
export function NewCardForm({
|
||||||
|
isTemplate,
|
||||||
boardPublicId,
|
boardPublicId,
|
||||||
listPublicId,
|
listPublicId,
|
||||||
queryParams,
|
queryParams,
|
||||||
@@ -85,14 +88,13 @@ export function NewCardForm({
|
|||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [watch, saveFormState]);
|
}, [watch, saveFormState]);
|
||||||
|
|
||||||
|
|
||||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||||
enabled: !!boardPublicId,
|
enabled: !!boardPublicId,
|
||||||
});
|
});
|
||||||
|
|
||||||
// this adds the new created label to selected labels
|
// this adds the new created label to selected labels
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||||
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
||||||
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
||||||
}
|
}
|
||||||
@@ -101,23 +103,23 @@ export function NewCardForm({
|
|||||||
// this removes the deleted label from selected labels if it is selected
|
// this removes the deleted label from selected labels if it is selected
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (boardData?.labels) {
|
if (boardData?.labels) {
|
||||||
const availableLabelIds = boardData.labels.map(label => label.publicId);
|
const availableLabelIds = boardData.labels.map((label) => label.publicId);
|
||||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||||
|
|
||||||
if (newLabelId && availableLabelIds.includes(newLabelId)) {
|
if (newLabelId && availableLabelIds.includes(newLabelId)) {
|
||||||
clearModalState("NEW_LABEL_CREATED");
|
clearModalState("NEW_LABEL_CREATED");
|
||||||
}
|
}
|
||||||
|
|
||||||
const validLabelIds = labelPublicIds.filter(id =>
|
const validLabelIds = labelPublicIds.filter(
|
||||||
availableLabelIds.includes(id) || id === newLabelId
|
(id) => availableLabelIds.includes(id) || id === newLabelId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (validLabelIds.length !== labelPublicIds.length) {
|
if (validLabelIds.length !== labelPublicIds.length) {
|
||||||
setValue("labelPublicIds", validLabelIds);
|
setValue("labelPublicIds", validLabelIds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [boardData?.labels, labelPublicIds, modalStates["NEW_LABEL_CREATED"]]);
|
}, [boardData?.labels, labelPublicIds, modalStates.NEW_LABEL_CREATED]);
|
||||||
|
|
||||||
const createCard = api.card.create.useMutation({
|
const createCard = api.card.create.useMutation({
|
||||||
onMutate: async (args) => {
|
onMutate: async (args) => {
|
||||||
await utils.board.byId.cancel();
|
await utils.board.byId.cancel();
|
||||||
@@ -323,15 +325,19 @@ export function NewCardForm({
|
|||||||
saveFormState({ ...formState, description: value });
|
saveFormState({ ...formState, description: value });
|
||||||
}}
|
}}
|
||||||
workspaceMembers={
|
workspaceMembers={
|
||||||
boardData?.workspace.members?.map((member): WorkspaceMember => ({
|
boardData?.workspace.members?.map(
|
||||||
publicId: member.publicId,
|
(member): WorkspaceMember => ({
|
||||||
email: member.email,
|
publicId: member.publicId,
|
||||||
user: member.user ? {
|
email: member.email,
|
||||||
id: member.publicId,
|
user: member.user
|
||||||
name: member.user.name,
|
? {
|
||||||
image: member.user.image ?? null,
|
id: member.publicId,
|
||||||
} : null,
|
name: member.user.name,
|
||||||
})) ?? []
|
image: member.user.image ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
}),
|
||||||
|
) ?? []
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -347,42 +353,46 @@ export function NewCardForm({
|
|||||||
</div>
|
</div>
|
||||||
</CheckboxDropdown>
|
</CheckboxDropdown>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-fit">
|
{!isTemplate && (
|
||||||
<CheckboxDropdown
|
<div className="w-fit">
|
||||||
items={formattedMembers}
|
<CheckboxDropdown
|
||||||
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
|
items={formattedMembers}
|
||||||
>
|
handleSelect={(_groupKey, item) =>
|
||||||
<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">
|
handleSelectMembers(item.key)
|
||||||
{!memberPublicIds.length ? (
|
}
|
||||||
t`Members`
|
>
|
||||||
) : (
|
<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">
|
||||||
<div className="flex -space-x-1 overflow-hidden">
|
{!memberPublicIds.length ? (
|
||||||
{memberPublicIds.map((memberPublicId) => {
|
t`Members`
|
||||||
const member = formattedMembers.find(
|
) : (
|
||||||
(member) => member.key === memberPublicId,
|
<div className="flex -space-x-1 overflow-hidden">
|
||||||
);
|
{memberPublicIds.map((memberPublicId) => {
|
||||||
|
const member = formattedMembers.find(
|
||||||
|
(member) => member.key === memberPublicId,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
key={member?.key}
|
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"
|
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">
|
<span className="text-[8px] font-medium leading-none text-white">
|
||||||
{member?.value
|
{member?.value
|
||||||
.split(" ")
|
.split(" ")
|
||||||
.map((namePart) =>
|
.map((namePart) =>
|
||||||
namePart.charAt(0).toUpperCase(),
|
namePart.charAt(0).toUpperCase(),
|
||||||
)
|
)
|
||||||
.join("")}
|
.join("")}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</CheckboxDropdown>
|
||||||
</CheckboxDropdown>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="w-fit">
|
<div className="w-fit">
|
||||||
<CheckboxDropdown
|
<CheckboxDropdown
|
||||||
items={formattedLabels}
|
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 { useEffect, useState } from "react";
|
||||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||||
import { useForm } from "react-hook-form";
|
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";
|
import type { UpdateBoardInput } from "@kan/api/types";
|
||||||
|
|
||||||
@@ -32,14 +36,15 @@ import Filters from "./components/Filters";
|
|||||||
import List from "./components/List";
|
import List from "./components/List";
|
||||||
import { NewCardForm } from "./components/NewCardForm";
|
import { NewCardForm } from "./components/NewCardForm";
|
||||||
import { NewListForm } from "./components/NewListForm";
|
import { NewListForm } from "./components/NewListForm";
|
||||||
|
import { NewTemplateForm } from "./components/NewTemplateForm";
|
||||||
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
|
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
|
||||||
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||||
import VisibilityButton from "./components/VisibilityButton";
|
import VisibilityButton from "./components/VisibilityButton";
|
||||||
|
|
||||||
type PublicListId = string;
|
type PublicListId = string;
|
||||||
|
|
||||||
export default function BoardPage() {
|
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const params = useParams() as { boardId: string[] } | null;
|
const params = useParams() as { boardId: string | string[] } | null;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { showPopup } = usePopup();
|
const { showPopup } = usePopup();
|
||||||
@@ -49,7 +54,11 @@ export default function BoardPage() {
|
|||||||
useState<PublicListId>("");
|
useState<PublicListId>("");
|
||||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
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();
|
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 ?? "",
|
boardPublicId: boardId ?? "",
|
||||||
members: formatToArray(router.query.members),
|
members: formatToArray(router.query.members),
|
||||||
labels: formatToArray(router.query.labels),
|
labels: formatToArray(router.query.labels),
|
||||||
|
type: isTemplate ? "template" : "regular",
|
||||||
};
|
};
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -208,7 +223,7 @@ export default function BoardPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onDragEnd = ({
|
const onDragEnd = ({
|
||||||
source,
|
source: _source,
|
||||||
destination,
|
destination,
|
||||||
draggableId,
|
draggableId,
|
||||||
type,
|
type,
|
||||||
@@ -241,7 +256,10 @@ export default function BoardPage() {
|
|||||||
modalSize="sm"
|
modalSize="sm"
|
||||||
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
||||||
>
|
>
|
||||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
<DeleteBoardConfirmation
|
||||||
|
isTemplate={!!isTemplate}
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -259,6 +277,7 @@ export default function BoardPage() {
|
|||||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
||||||
>
|
>
|
||||||
<NewCardForm
|
<NewCardForm
|
||||||
|
isTemplate={!!isTemplate}
|
||||||
boardPublicId={boardId ?? ""}
|
boardPublicId={boardId ?? ""}
|
||||||
listPublicId={selectedPublicListId}
|
listPublicId={selectedPublicListId}
|
||||||
queryParams={queryParams}
|
queryParams={queryParams}
|
||||||
@@ -321,6 +340,17 @@ export default function BoardPage() {
|
|||||||
queryParams={queryParams}
|
queryParams={queryParams}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<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">
|
<div className="relative flex h-full flex-col">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
@@ -354,31 +384,46 @@ export default function BoardPage() {
|
|||||||
)}
|
)}
|
||||||
{!boardData && !isLoading && (
|
{!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">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
||||||
<UpdateBoardSlugButton
|
{isTemplate && (
|
||||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
<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">
|
||||||
isLoading={isLoading}
|
<span className="mr-2">
|
||||||
workspaceSlug={workspace.slug ?? ""}
|
<HiOutlineRectangleStack />
|
||||||
boardSlug={boardData?.slug ?? ""}
|
</span>
|
||||||
/>
|
{t`Template`}
|
||||||
<VisibilityButton
|
</div>
|
||||||
visibility={boardData?.visibility ?? "private"}
|
)}
|
||||||
boardPublicId={boardId ?? ""}
|
{!isTemplate && (
|
||||||
boardSlug={boardData?.slug ?? ""}
|
<>
|
||||||
queryParams={queryParams}
|
<UpdateBoardSlugButton
|
||||||
isLoading={!boardData}
|
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
||||||
isAdmin={workspace.role === "admin"}
|
isLoading={isLoading}
|
||||||
/>
|
workspaceSlug={workspace.slug ?? ""}
|
||||||
<Filters
|
boardSlug={boardData?.slug ?? ""}
|
||||||
labels={boardData?.labels ?? []}
|
/>
|
||||||
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
|
<VisibilityButton
|
||||||
position="left"
|
visibility={boardData?.visibility ?? "private"}
|
||||||
isLoading={!boardData}
|
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
|
<Button
|
||||||
iconLeft={
|
iconLeft={
|
||||||
<HiOutlinePlusSmall
|
<HiOutlinePlusSmall
|
||||||
@@ -393,7 +438,12 @@ export default function BoardPage() {
|
|||||||
>
|
>
|
||||||
{t`New list`}
|
{t`New list`}
|
||||||
</Button>
|
</Button>
|
||||||
<BoardDropdown isLoading={!boardData} />
|
<BoardDropdown
|
||||||
|
isTemplate={!!isTemplate}
|
||||||
|
isLoading={!boardData}
|
||||||
|
boardPublicId={boardId ?? ""}
|
||||||
|
workspacePublicId={workspace.publicId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -475,7 +525,11 @@ export default function BoardPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
}}
|
}}
|
||||||
key={card.publicId}
|
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 ${
|
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||||
card.publicId.startsWith(
|
card.publicId.startsWith(
|
||||||
"PLACEHOLDER",
|
"PLACEHOLDER",
|
||||||
|
|||||||
@@ -8,12 +8,15 @@ import { useModal } from "~/providers/modal";
|
|||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
|
||||||
export function BoardsList() {
|
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const { openModal } = useModal();
|
const { openModal } = useModal();
|
||||||
|
|
||||||
const { data, isLoading } = api.board.all.useQuery(
|
const { data, isLoading } = api.board.all.useQuery(
|
||||||
{ workspacePublicId: workspace.publicId },
|
{
|
||||||
|
workspacePublicId: workspace.publicId,
|
||||||
|
type: isTemplate ? "template" : "regular",
|
||||||
|
},
|
||||||
{ enabled: workspace.publicId ? true : false },
|
{ enabled: workspace.publicId ? true : false },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -32,14 +35,14 @@ export function BoardsList() {
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
<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">
|
<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>
|
||||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||||
{t`Create new board`}
|
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -47,7 +50,10 @@ export function BoardsList() {
|
|||||||
return (
|
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">
|
<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) => (
|
{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">
|
<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 />
|
<PatternedBackground />
|
||||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
<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 { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
@@ -10,9 +11,10 @@ import Button from "~/components/Button";
|
|||||||
import Input from "~/components/Input";
|
import Input from "~/components/Input";
|
||||||
import Toggle from "~/components/Toggle";
|
import Toggle from "~/components/Toggle";
|
||||||
import { useModal } from "~/providers/modal";
|
import { useModal } from "~/providers/modal";
|
||||||
|
import { usePopup } from "~/providers/popup";
|
||||||
import { useWorkspace } from "~/providers/workspace";
|
import { useWorkspace } from "~/providers/workspace";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
import TemplateBoards, { getTemplates } from "./TemplateBoards";
|
import TemplateBoards from "./TemplateBoards";
|
||||||
|
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
name: z
|
name: z
|
||||||
@@ -29,13 +31,25 @@ interface NewBoardInputWithTemplate {
|
|||||||
template: Template | null;
|
template: Template | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NewBoardForm() {
|
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const { closeModal } = useModal();
|
const { closeModal } = useModal();
|
||||||
|
const router = useRouter();
|
||||||
|
const { showPopup } = usePopup();
|
||||||
const { workspace } = useWorkspace();
|
const { workspace } = useWorkspace();
|
||||||
const [showTemplates, setShowTemplates] = useState(false);
|
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 {
|
const {
|
||||||
register,
|
register,
|
||||||
@@ -57,18 +71,39 @@ export function NewBoardForm() {
|
|||||||
const refetchBoards = () => utils.board.all.refetch();
|
const refetchBoards = () => utils.board.all.refetch();
|
||||||
|
|
||||||
const createBoard = api.board.create.useMutation({
|
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();
|
closeModal();
|
||||||
|
|
||||||
await refetchBoards();
|
await refetchBoards();
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
showPopup({
|
||||||
|
header: t`Error`,
|
||||||
|
message: t`Failed to create board`,
|
||||||
|
icon: "error",
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||||
createBoard.mutate({
|
createBoard.mutate({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
workspacePublicId: data.workspacePublicId,
|
workspacePublicId: data.workspacePublicId,
|
||||||
|
sourceBoardPublicId: data.template?.sourceBoardPublicId ?? undefined,
|
||||||
lists: data.template?.lists ?? [],
|
lists: data.template?.lists ?? [],
|
||||||
labels: data.template?.labels ?? [],
|
labels: data.template?.labels ?? [],
|
||||||
|
type: isTemplate ? "template" : "regular",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -82,7 +117,7 @@ export function NewBoardForm() {
|
|||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
<div className="px-5 pt-5">
|
<div className="px-5 pt-5">
|
||||||
<div className="text-neutral-9000 flex w-full items-center justify-between pb-4 dark:text-dark-1000">
|
<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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
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}
|
currentBoard={currentTemplate}
|
||||||
setCurrentBoard={(t) => setValue("template", t)}
|
setCurrentBoard={(t) => setValue("template", t)}
|
||||||
showTemplates={showTemplates}
|
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">
|
<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
|
{!isTemplate && (
|
||||||
label={t`Use template`}
|
<Toggle
|
||||||
isChecked={showTemplates}
|
label={t`Use template`}
|
||||||
onChange={() => {
|
isChecked={showTemplates}
|
||||||
setShowTemplates(!showTemplates);
|
onChange={() => {
|
||||||
if (!showTemplates && !currentTemplate) {
|
setShowTemplates(!showTemplates);
|
||||||
setValue("template", templates[0] ?? null);
|
if (!showTemplates && !currentTemplate) {
|
||||||
}
|
setValue("template", (templates?.[0] as any) ?? null);
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" isLoading={createBoard.isPending}>
|
<Button type="submit" isLoading={createBoard.isPending}>
|
||||||
{t`Create board`}
|
{t`Create ${isTemplate ? "template" : "board"}`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { HiCheckCircle } from "react-icons/hi2";
|
|||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
id: string;
|
id: string;
|
||||||
|
sourceBoardPublicId?: string;
|
||||||
name: string;
|
name: string;
|
||||||
lists: string[];
|
lists: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
@@ -88,16 +89,18 @@ export default function TemplateBoards({
|
|||||||
currentBoard,
|
currentBoard,
|
||||||
setCurrentBoard,
|
setCurrentBoard,
|
||||||
showTemplates,
|
showTemplates,
|
||||||
|
customTemplates,
|
||||||
}: {
|
}: {
|
||||||
currentBoard: Template | null;
|
currentBoard: Template | null;
|
||||||
setCurrentBoard: (board: Template | null) => void;
|
setCurrentBoard: (board: Template | null) => void;
|
||||||
showTemplates: boolean;
|
showTemplates: boolean;
|
||||||
|
customTemplates: Template[] | null;
|
||||||
}) {
|
}) {
|
||||||
const [showFade, setShowFade] = useState(false);
|
const [showFade, setShowFade] = useState(false);
|
||||||
const [showTopFade, setShowTopFade] = useState(false);
|
const [showTopFade, setShowTopFade] = useState(false);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const templates = getTemplates();
|
const templates = [...(customTemplates ?? []), ...getTemplates()];
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
if (!scrollRef.current) return;
|
if (!scrollRef.current) return;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { BoardsList } from "./components/BoardsList";
|
|||||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||||
import { NewBoardForm } from "./components/NewBoardForm";
|
import { NewBoardForm } from "./components/NewBoardForm";
|
||||||
|
|
||||||
export default function BoardsPage() {
|
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const { openModal, modalContentType, isOpen } = useModal();
|
const { openModal, modalContentType, isOpen } = useModal();
|
||||||
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
||||||
|
|
||||||
@@ -25,23 +25,27 @@ export default function BoardsPage() {
|
|||||||
|
|
||||||
return (
|
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="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">
|
<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]">
|
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||||
{t`Boards`}
|
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||||
</h1>
|
</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
{!isTemplate && (
|
||||||
type="button"
|
<Button
|
||||||
variant="secondary"
|
type="button"
|
||||||
onClick={() => openModal("IMPORT_BOARDS")}
|
variant="secondary"
|
||||||
iconLeft={
|
onClick={() => openModal("IMPORT_BOARDS")}
|
||||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
iconLeft={
|
||||||
}
|
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||||
>
|
}
|
||||||
{t`Import`}
|
>
|
||||||
</Button>
|
{t`Import`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -67,7 +71,7 @@ export default function BoardsPage() {
|
|||||||
modalSize="sm"
|
modalSize="sm"
|
||||||
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
||||||
>
|
>
|
||||||
<NewBoardForm />
|
<NewBoardForm isTemplate={!!isTemplate} />
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -86,7 +90,7 @@ export default function BoardsPage() {
|
|||||||
</>
|
</>
|
||||||
|
|
||||||
<div className="flex h-full flex-row">
|
<div className="flex h-full flex-row">
|
||||||
<BoardsList />
|
<BoardsList isTemplate={!!isTemplate} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ const Comment = ({
|
|||||||
<ContentEditable
|
<ContentEditable
|
||||||
html={comment ?? ""}
|
html={comment ?? ""}
|
||||||
disabled={true}
|
disabled={true}
|
||||||
className="mt-2 text-sm"
|
className="break-anywhere mt-2 text-sm"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<form onSubmit={handleSubmit(onSubmit)}>
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ interface FormValues {
|
|||||||
description: string;
|
description: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CardRightPanel() {
|
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const cardId = Array.isArray(router.query.cardId)
|
const cardId = Array.isArray(router.query.cardId)
|
||||||
? router.query.cardId[0]
|
? router.query.cardId[0]
|
||||||
@@ -121,19 +121,21 @@ export function CardRightPanel() {
|
|||||||
isLoading={!card}
|
isLoading={!card}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-row">
|
{!isTemplate && (
|
||||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
<div className="flex w-full flex-row">
|
||||||
<MemberSelector
|
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||||
cardPublicId={cardId ?? ""}
|
<MemberSelector
|
||||||
members={formattedMembers}
|
cardPublicId={cardId ?? ""}
|
||||||
isLoading={!card}
|
members={formattedMembers}
|
||||||
/>
|
isLoading={!card}
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CardPage() {
|
export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const utils = api.useUtils();
|
const utils = api.useUtils();
|
||||||
const {
|
const {
|
||||||
@@ -228,7 +230,7 @@ export default function CardPage() {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
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}
|
{board?.name}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -296,9 +298,11 @@ export default function CardPage() {
|
|||||||
isAdmin={workspace.role === "admin"}
|
isAdmin={workspace.role === "admin"}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-6">
|
{!isTemplate && (
|
||||||
<NewCommentForm cardPublicId={cardId} />
|
<div className="mt-6">
|
||||||
</div>
|
<NewCommentForm cardPublicId={cardId} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</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">
|
<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`}
|
{t`Get started for free today`}
|
||||||
</h2>
|
</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.`}
|
{t`Unlimited boards, unlimited lists, unlimited cards. No credit card required.`}
|
||||||
</p>
|
</p>
|
||||||
<Link href="/signup">
|
<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">
|
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||||
{t`Questions?`}
|
{t`Questions?`}
|
||||||
</p>
|
</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>
|
<Trans>
|
||||||
Find answers to common questions about the project. Can't find what
|
Find answers to common questions about the project. Can't find what
|
||||||
you're looking for? Feel free to{" "}
|
you're looking for? Feel free to{" "}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ const FeatureItem = ({
|
|||||||
description: string;
|
description: string;
|
||||||
icon: Record<string, unknown>;
|
icon: Record<string, unknown>;
|
||||||
comingSoon?: boolean;
|
comingSoon?: boolean;
|
||||||
|
new?: boolean;
|
||||||
};
|
};
|
||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
@@ -66,6 +67,12 @@ const FeatureItem = ({
|
|||||||
{t`Coming soon`}
|
{t`Coming soon`}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -108,7 +115,7 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
|||||||
title: t`Templates`,
|
title: t`Templates`,
|
||||||
description: t`Save time with reusable board templates.`,
|
description: t`Save time with reusable board templates.`,
|
||||||
icon: isDark ? templatesIconDark : templatesIconLight,
|
icon: isDark ? templatesIconDark : templatesIconLight,
|
||||||
comingSoon: true,
|
new: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t`Integrations`,
|
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">
|
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||||
{t`Kanban reimagined`}
|
{t`Kanban reimagined`}
|
||||||
</p>
|
</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.`}
|
{t`Simple, visual task management that just works. Drag and drop cards, collaborate with your team, and get more done.`}
|
||||||
</p>
|
</p>
|
||||||
<div className="mx-auto mt-16 w-full max-w-7xl">
|
<div className="mx-auto mt-16 w-full max-w-7xl">
|
||||||
|
|||||||
@@ -6,55 +6,83 @@ import { twMerge } from "tailwind-merge";
|
|||||||
import Button from "~/components/Button";
|
import Button from "~/components/Button";
|
||||||
|
|
||||||
const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
|
||||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||||
|
|
||||||
const menuItems = [
|
const desktopMenuItems = [
|
||||||
{ label: t`Roadmap`, href: "/kan/roadmap", openInNewTab: true },
|
{
|
||||||
{ label: t`Features`, href: "#features" },
|
label: t`Roadmap`,
|
||||||
{ label: t`Pricing`, href: "#pricing" },
|
href: "/kan/roadmap",
|
||||||
{ label: t`Docs`, href: "https://docs.kan.bn", openInNewTab: true },
|
openInNewTab: true,
|
||||||
|
},
|
||||||
|
{ label: t`Features`, href: "/#features" },
|
||||||
|
{ label: t`Pricing`, href: "/pricing" },
|
||||||
|
{
|
||||||
|
label: t`Docs`,
|
||||||
|
href: "https://docs.kan.bn",
|
||||||
|
openInNewTab: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
useEffect(() => {
|
const mobileMenuItems = [
|
||||||
const handleScroll = () => {
|
{
|
||||||
setIsScrolled(window.scrollY > 0);
|
label: t`Roadmap`,
|
||||||
};
|
href: "/kan/roadmap",
|
||||||
window.addEventListener("scroll", handleScroll);
|
openInNewTab: true,
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
group: "Product",
|
||||||
}, []);
|
},
|
||||||
|
{ label: t`Features`, href: "/#features", group: "Product" },
|
||||||
|
{ label: t`Pricing`, href: "/pricing", group: "Product" },
|
||||||
|
{
|
||||||
|
label: t`Documentation`,
|
||||||
|
href: "https://docs.kan.bn",
|
||||||
|
openInNewTab: true,
|
||||||
|
group: "Resources",
|
||||||
|
},
|
||||||
|
{ label: t`FAQ`, href: "/#faq", group: "Resources" },
|
||||||
|
{ label: t`Contact`, href: "mailto:support@kan.bn", group: "Resources" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Group mobile menu items by their group property
|
||||||
|
const groupedMenuItems = mobileMenuItems.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
(acc[item.group] ??= []).push(item);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, typeof mobileMenuItems>,
|
||||||
|
);
|
||||||
|
|
||||||
const toggleMenu = () => {
|
const toggleMenu = () => {
|
||||||
setIsMenuOpen(!isMenuOpen);
|
setIsMenuOpen(!isMenuOpen);
|
||||||
document.body.style.overflow = !isMenuOpen ? "hidden" : "";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.classList.toggle("overflow-hidden", isMenuOpen);
|
||||||
|
document.body.classList.toggle("overflow-hidden", isMenuOpen);
|
||||||
|
return () => {
|
||||||
|
document.documentElement.classList.remove("overflow-hidden");
|
||||||
|
document.body.classList.remove("overflow-hidden");
|
||||||
|
};
|
||||||
|
}, [isMenuOpen]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"fixed z-50 m-auto flex w-full transition-all duration-500 lg:max-w-[1100px] lg:px-4 lg:pt-4",
|
"fixed z-50 flex w-full transition-all duration-500",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div className="flex h-[4rem] min-h-[4rem] w-full border-b border-light-300 bg-light-50/80 px-5 py-2 align-middle opacity-100 shadow-sm backdrop-blur-[10px] transition-all duration-500 dark:border-dark-300 dark:bg-dark-50/90">
|
||||||
className={twMerge(
|
<div className="mx-auto flex w-full max-w-[1100px] items-center justify-between lg:px-4">
|
||||||
"m-auto flex h-[4rem] min-h-[4rem] w-full px-5 py-2 align-middle transition-all duration-500 lg:rounded-3xl lg:border lg:border-transparent",
|
|
||||||
isScrolled &&
|
|
||||||
"border-b border-light-300 bg-light-50/80 opacity-100 shadow-sm backdrop-blur-[10px] dark:border-dark-300 dark:bg-dark-50/90 lg:rounded-2xl lg:border",
|
|
||||||
!isScrolled && "bg-transparent opacity-90",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex w-full items-center justify-between lg:px-4">
|
|
||||||
<div className="my-auto flex items-center justify-between">
|
<div className="my-auto flex items-center justify-between">
|
||||||
<Link href="/">
|
<Link href="/">
|
||||||
<h1 className="w-[200px] text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000 lg:w-[200px]">
|
||||||
kan.bn
|
kan.bn
|
||||||
</h1>
|
</h1>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
{/* Desktop Menu */}
|
{/* Desktop Menu */}
|
||||||
<div className="hidden justify-center gap-10 dark:text-dark-1000 lg:flex">
|
<div className="hidden justify-center gap-10 dark:text-dark-1000 lg:flex">
|
||||||
{menuItems.map((item) => (
|
{desktopMenuItems.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.label}
|
key={item.label}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
@@ -66,42 +94,38 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
|||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{/* Hamburger Menu Button */}
|
<div className="flex items-center justify-end gap-4 lg:w-[200px]">
|
||||||
<button
|
<div className="justify-end gap-2">
|
||||||
onClick={toggleMenu}
|
{isLoggedIn ? (
|
||||||
className="z-50 p-2 lg:hidden"
|
<Button href="/boards">{t`Go to app`}</Button>
|
||||||
aria-label={t`Toggle menu`}
|
) : (
|
||||||
>
|
<div className="flex items-center justify-end gap-2">
|
||||||
<div
|
<Button href="/login" variant="ghost">
|
||||||
className={twMerge(
|
{t`Sign in`}
|
||||||
"my-[5px] h-[2px] w-4 bg-current bg-light-1000 transition-all dark:bg-dark-1000",
|
</Button>
|
||||||
isMenuOpen ? "translate-y-[7px] rotate-45" : "",
|
<Button href="/signup">{t`Get started`}</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
/>
|
</div>
|
||||||
<div
|
{/* Hamburger Menu Button */}
|
||||||
className={twMerge(
|
<button
|
||||||
"my-[5px] ml-[4px] h-[2px] w-3 bg-current bg-light-1000 transition-all dark:bg-dark-1000",
|
onClick={toggleMenu}
|
||||||
isMenuOpen ? "opacity-0" : "",
|
className="relative z-50 p-2 lg:hidden"
|
||||||
)}
|
aria-label={t`Toggle menu`}
|
||||||
/>
|
>
|
||||||
<div
|
<div
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"my-[5px] h-[2px] w-4 bg-current bg-light-1000 transition-all dark:bg-dark-1000",
|
"absolute left-1/2 top-1/2 h-[1px] w-4 -translate-x-1/2 -translate-y-[4px] bg-current bg-light-1000 transition-all duration-300 ease-in-out dark:bg-dark-1000",
|
||||||
isMenuOpen ? "-translate-y-[7px] -rotate-45" : "",
|
isMenuOpen ? "translate-y-0 rotate-45" : "",
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
<div
|
||||||
<div className="hidden w-[200px] justify-end gap-2 lg:flex">
|
className={twMerge(
|
||||||
{isLoggedIn ? (
|
"absolute left-1/2 top-1/2 h-[1px] w-4 -translate-x-1/2 translate-y-[4px] bg-current bg-light-1000 transition-all duration-300 ease-in-out dark:bg-dark-1000",
|
||||||
<Button href="/boards">{t`Go to app`}</Button>
|
isMenuOpen ? "translate-y-0 -rotate-45" : "",
|
||||||
) : (
|
)}
|
||||||
<>
|
/>
|
||||||
<Button href="/login" variant="ghost">
|
</button>
|
||||||
{t`Sign in`}
|
|
||||||
</Button>
|
|
||||||
<Button href="/signup">{t`Get started`}</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,35 +138,27 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
|||||||
: "pointer-events-none translate-x-0 opacity-0"
|
: "pointer-events-none translate-x-0 opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="absolute inset-0 bg-white dark:bg-dark-100">
|
<div className="absolute inset-0 bg-light-50/80 bg-white backdrop-blur-[15px] dark:bg-dark-50 dark:bg-dark-50/90">
|
||||||
<div className="flex h-full flex-col items-center justify-center space-y-8">
|
<div className="mt-[6rem] flex h-full flex-col space-y-8 px-5">
|
||||||
{menuItems.map((item) => (
|
{Object.entries(groupedMenuItems).map(([group, items]) => (
|
||||||
<Link
|
<div key={group} className="flex flex-col space-y-4">
|
||||||
key={item.label}
|
<div className="text-sm font-bold text-light-900 dark:text-dark-900">
|
||||||
href={item.href}
|
{group}
|
||||||
target={item.openInNewTab ? "_blank" : undefined}
|
</div>
|
||||||
rel={item.openInNewTab ? "noopener noreferrer" : undefined}
|
{items.map((item) => (
|
||||||
className="transform text-xl font-bold text-light-1000 transition-all duration-300 hover:scale-105 dark:text-dark-1000"
|
<Link
|
||||||
>
|
key={item.label}
|
||||||
{item.label}
|
href={item.href}
|
||||||
</Link>
|
target={item.openInNewTab ? "_blank" : undefined}
|
||||||
|
rel={item.openInNewTab ? "noopener noreferrer" : undefined}
|
||||||
|
className="transform text-lg font-bold text-light-1000 dark:text-dark-1000"
|
||||||
|
onClick={() => setIsMenuOpen(false)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="mt-8 flex flex-col gap-4">
|
|
||||||
{isLoggedIn ? (
|
|
||||||
<Button href="/boards" onClick={toggleMenu}>
|
|
||||||
{t`Go to app`}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Button href="/login" variant="ghost" onClick={toggleMenu}>
|
|
||||||
{t`Sign in`}
|
|
||||||
</Button>
|
|
||||||
<Button href="/signup" onClick={toggleMenu}>
|
|
||||||
{t`Get started`}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||||||
`}</style>
|
`}</style>
|
||||||
<div className="mx-auto flex h-full min-h-screen min-w-[375px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
<div className="mx-auto flex h-full min-h-screen min-w-[375px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
||||||
<PatternedBackground />
|
<PatternedBackground />
|
||||||
|
<Header isLoggedIn={isLoggedIn} />
|
||||||
<div className="z-10 mx-auto h-full w-full max-w-[1100px]">
|
<div className="z-10 mx-auto h-full w-full max-w-[1100px]">
|
||||||
<Header isLoggedIn={isLoggedIn} />
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
<Footer />
|
<Footer />
|
||||||
|
|||||||
@@ -2,22 +2,19 @@ import Image from "next/image";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { t } from "@lingui/core/macro";
|
import { t } from "@lingui/core/macro";
|
||||||
import { Trans } from "@lingui/react/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 Button from "~/components/Button";
|
||||||
import { PageHead } from "~/components/PageHead";
|
import { PageHead } from "~/components/PageHead";
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import Cta from "./components/Cta";
|
import Cta from "./components/Cta";
|
||||||
import FAQs from "./components/Faqs";
|
import FAQs from "./components/Faqs";
|
||||||
import Features from "./components/Features";
|
import Features from "./components/Features";
|
||||||
import Layout from "./components/Layout";
|
import Layout from "./components/Layout";
|
||||||
import Pricing from "./components/Pricing";
|
|
||||||
|
|
||||||
export default function HomeView() {
|
export default function HomeView() {
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
|
|
||||||
const isDarkMode = resolvedTheme === "dark";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout>
|
<Layout>
|
||||||
<PageHead title="Kan.bn | The open source alternative to Trello" />
|
<PageHead title="Kan.bn | The open source alternative to Trello" />
|
||||||
@@ -68,7 +65,7 @@ export default function HomeView() {
|
|||||||
</Trans>
|
</Trans>
|
||||||
</p>
|
</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.`}
|
{t`A powerful, flexible kanban app that helps you organise work, track progress, and deliver results—all in one place.`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
@@ -82,7 +79,7 @@ export default function HomeView() {
|
|||||||
{t`Self host with Github`}
|
{t`Self host with Github`}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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`}
|
{t`No credit card required`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,10 +108,6 @@ export default function HomeView() {
|
|||||||
<div id="features" className="absolute -top-20" />
|
<div id="features" className="absolute -top-20" />
|
||||||
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} />
|
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative pt-10">
|
|
||||||
<div id="pricing" className="absolute -top-20" />
|
|
||||||
<Pricing />
|
|
||||||
</div>
|
|
||||||
<div className="relative pt-10">
|
<div className="relative pt-10">
|
||||||
<div id="faq" className="absolute -top-20" />
|
<div id="faq" className="absolute -top-20" />
|
||||||
<FAQs />
|
<FAQs />
|
||||||
|
|||||||
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>
|
<p>{t`Pricing`}</p>
|
||||||
</div>
|
</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`}
|
{t`Simple pricing`}
|
||||||
</p>
|
</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.`}
|
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-12 flex flex-col items-center justify-center">
|
<div className="mt-14 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="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 />
|
<HiBolt />
|
||||||
<p>
|
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||||
{t`Launch offer: unlimited seats for just $29/month with Pro`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<fieldset aria-label={t`Payment frequency`}>
|
<fieldset aria-label={t`Payment frequency`}>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
@@ -164,7 +162,7 @@ const Pricing = () => {
|
|||||||
<p className="mt-6 flex items-baseline gap-x-1">
|
<p className="mt-6 flex items-baseline gap-x-1">
|
||||||
<span
|
<span
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
"text-4xl font-semibold tracking-tight text-light-100",
|
"text-3xl font-semibold tracking-tight text-light-100",
|
||||||
tier.highlighted
|
tier.highlighted
|
||||||
? "text-light-50 dark:text-dark-1000"
|
? "text-light-50 dark:text-dark-1000"
|
||||||
: "text-gray-900 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,7 +25,12 @@ export const boardRouter = createTRPCRouter({
|
|||||||
protect: true,
|
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(
|
.output(
|
||||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||||
)
|
)
|
||||||
@@ -51,7 +56,9 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
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;
|
return result;
|
||||||
}),
|
}),
|
||||||
@@ -71,6 +78,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
boardPublicId: z.string().min(12),
|
boardPublicId: z.string().min(12),
|
||||||
members: z.array(z.string().min(12)).optional(),
|
members: z.array(z.string().min(12)).optional(),
|
||||||
labels: 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>>>())
|
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||||
@@ -102,6 +110,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
{
|
{
|
||||||
members: input.members ?? [],
|
members: input.members ?? [],
|
||||||
labels: input.labels ?? [],
|
labels: input.labels ?? [],
|
||||||
|
type: input.type,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -177,6 +186,8 @@ export const boardRouter = createTRPCRouter({
|
|||||||
workspacePublicId: z.string().min(12),
|
workspacePublicId: z.string().min(12),
|
||||||
lists: z.array(z.string().min(1)),
|
lists: z.array(z.string().min(1)),
|
||||||
labels: 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>>>())
|
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||||
@@ -202,6 +213,79 @@ export const boardRouter = createTRPCRouter({
|
|||||||
|
|
||||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
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);
|
let slug = generateSlug(input.name);
|
||||||
|
|
||||||
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
||||||
@@ -209,7 +293,8 @@ export const boardRouter = createTRPCRouter({
|
|||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!isSlugUnique) slug = `${slug}-${generateUID()}`;
|
if (!isSlugUnique || input.type === "template")
|
||||||
|
slug = `${slug}-${generateUID()}`;
|
||||||
|
|
||||||
const result = await boardRepo.create(ctx.db, {
|
const result = await boardRepo.create(ctx.db, {
|
||||||
publicId: generateUID(),
|
publicId: generateUID(),
|
||||||
@@ -217,6 +302,7 @@ export const boardRouter = createTRPCRouter({
|
|||||||
name: input.name,
|
name: input.name,
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
workspaceId: workspace.id,
|
workspaceId: workspace.id,
|
||||||
|
type: input.type,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!result)
|
if (!result)
|
||||||
|
|||||||
@@ -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 $$;
|
||||||
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
@@ -120,6 +120,20 @@
|
|||||||
"when": 1759356096392,
|
"when": 1759356096392,
|
||||||
"tag": "20251001220136_AddFuzzySearchSupport",
|
"tag": "20251001220136_AddFuzzySearchSupport",
|
||||||
"breakpoints": true
|
"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 type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||||
import {
|
import {
|
||||||
boards,
|
boards,
|
||||||
|
cardActivities,
|
||||||
cards,
|
cards,
|
||||||
cardsToLabels,
|
cardsToLabels,
|
||||||
cardToWorkspaceMembers,
|
cardToWorkspaceMembers,
|
||||||
@@ -15,13 +16,38 @@ import {
|
|||||||
} from "@kan/db/schema";
|
} from "@kan/db/schema";
|
||||||
import { generateUID } from "@kan/shared/utils";
|
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({
|
return db.query.boards.findMany({
|
||||||
columns: {
|
columns: {
|
||||||
publicId: true,
|
publicId: true,
|
||||||
name: 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({
|
const board = await db.query.boards.findFirst({
|
||||||
columns: {
|
columns: {
|
||||||
id: true,
|
id: true,
|
||||||
|
type: true,
|
||||||
},
|
},
|
||||||
where: eq(boards.publicId, boardPublicId),
|
where: eq(boards.publicId, boardPublicId),
|
||||||
});
|
});
|
||||||
@@ -42,6 +69,7 @@ export const getByPublicId = async (
|
|||||||
filters: {
|
filters: {
|
||||||
members: string[];
|
members: string[];
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
type: "regular" | "template" | undefined;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
let cardIds: string[] = [];
|
let cardIds: string[] = [];
|
||||||
@@ -199,7 +227,11 @@ export const getByPublicId = async (
|
|||||||
orderBy: [asc(lists.index)],
|
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;
|
if (!board) return null;
|
||||||
@@ -412,6 +444,8 @@ export const create = async (
|
|||||||
workspaceId: number;
|
workspaceId: number;
|
||||||
importId?: number;
|
importId?: number;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
type?: "regular" | "template";
|
||||||
|
sourceBoardId?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
@@ -423,6 +457,8 @@ export const create = async (
|
|||||||
workspaceId: boardInput.workspaceId,
|
workspaceId: boardInput.workspaceId,
|
||||||
importId: boardInput.importId,
|
importId: boardInput.importId,
|
||||||
slug: boardInput.slug,
|
slug: boardInput.slug,
|
||||||
|
type: boardInput.type ?? "regular",
|
||||||
|
sourceBoardId: boardInput.sourceBoardId,
|
||||||
})
|
})
|
||||||
.returning({
|
.returning({
|
||||||
id: boards.id,
|
id: boards.id,
|
||||||
@@ -542,3 +578,229 @@ export const isBoardSlugAvailable = async (
|
|||||||
|
|
||||||
return result === undefined;
|
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;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export const create = async (
|
|||||||
commentId?: number;
|
commentId?: number;
|
||||||
fromComment?: string;
|
fromComment?: string;
|
||||||
toComment?: string;
|
toComment?: string;
|
||||||
|
sourceBoardId?: number;
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const [result] = await db
|
const [result] = await db
|
||||||
@@ -44,6 +45,7 @@ export const create = async (
|
|||||||
commentId: activityInput.commentId,
|
commentId: activityInput.commentId,
|
||||||
fromComment: activityInput.fromComment,
|
fromComment: activityInput.fromComment,
|
||||||
toComment: activityInput.toComment,
|
toComment: activityInput.toComment,
|
||||||
|
sourceBoardId: activityInput.sourceBoardId,
|
||||||
})
|
})
|
||||||
.returning({ id: cardActivities.id });
|
.returning({ id: cardActivities.id });
|
||||||
|
|
||||||
@@ -66,6 +68,7 @@ export const bulkCreate = async (
|
|||||||
fromDescription?: string;
|
fromDescription?: string;
|
||||||
toDescription?: string;
|
toDescription?: string;
|
||||||
createdBy: string;
|
createdBy: string;
|
||||||
|
sourceBoardId?: number;
|
||||||
}[],
|
}[],
|
||||||
) => {
|
) => {
|
||||||
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ export const boardVisibilityEnum = pgEnum(
|
|||||||
boardVisibilityStatuses,
|
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(
|
export const boards = pgTable(
|
||||||
"board",
|
"board",
|
||||||
{
|
{
|
||||||
@@ -49,9 +53,13 @@ export const boards = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||||
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
|
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
|
||||||
|
type: boardTypeEnum("type").notNull().default("regular"),
|
||||||
|
sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
index("board_visibility_idx").on(table.visibility),
|
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")
|
uniqueIndex("unique_slug_per_workspace")
|
||||||
.on(table.workspaceId, table.slug)
|
.on(table.workspaceId, table.slug)
|
||||||
.where(sql`${table.deletedAt} IS NULL`),
|
.where(sql`${table.deletedAt} IS NULL`),
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
varchar,
|
varchar,
|
||||||
} from "drizzle-orm/pg-core";
|
} from "drizzle-orm/pg-core";
|
||||||
|
|
||||||
|
import { boards } from "./boards";
|
||||||
import { checklists } from "./checklists";
|
import { checklists } from "./checklists";
|
||||||
import { imports } from "./imports";
|
import { imports } from "./imports";
|
||||||
import { labels } from "./labels";
|
import { labels } from "./labels";
|
||||||
@@ -133,6 +134,10 @@ export const cardActivities = pgTable("card_activity", {
|
|||||||
),
|
),
|
||||||
fromComment: text("fromComment"),
|
fromComment: text("fromComment"),
|
||||||
toComment: text("toComment"),
|
toComment: text("toComment"),
|
||||||
|
sourceBoardId: bigint("sourceBoardId", { mode: "number" }).references(
|
||||||
|
() => boards.id,
|
||||||
|
{ onDelete: "set null" },
|
||||||
|
),
|
||||||
}).enableRLS();
|
}).enableRLS();
|
||||||
|
|
||||||
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ export interface Subscription {
|
|||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ACTIVE_STATUSES = ["active", "trialing", "past_due"];
|
||||||
|
|
||||||
export const getActiveSubscriptions = (
|
export const getActiveSubscriptions = (
|
||||||
subscriptions: Subscription[] | undefined,
|
subscriptions: Subscription[] | undefined,
|
||||||
) => {
|
) => {
|
||||||
if (!subscriptions) return [];
|
if (!subscriptions) return [];
|
||||||
return subscriptions.filter(
|
return subscriptions.filter((sub) => ACTIVE_STATUSES.includes(sub.status));
|
||||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSubscriptionByPlan = (
|
export const getSubscriptionByPlan = (
|
||||||
@@ -36,9 +36,7 @@ export const getSubscriptionByPlan = (
|
|||||||
) => {
|
) => {
|
||||||
if (!subscriptions) return undefined;
|
if (!subscriptions) return undefined;
|
||||||
return subscriptions.find(
|
return subscriptions.find(
|
||||||
(sub) =>
|
(sub) => sub.plan === plan && ACTIVE_STATUSES.includes(sub.status),
|
||||||
sub.plan === plan &&
|
|
||||||
(sub.status === "active" || sub.status === "trialing"),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user