Compare commits
10 Commits
fix/header
...
feat/searc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3636676913 | ||
|
|
9d9b5ed7d1 | ||
|
|
e526ad3bda | ||
|
|
962aba8ccf | ||
|
|
c75dcb2f20 | ||
|
|
f8475b77f1 | ||
|
|
bd691017ad | ||
|
|
468bd8afec | ||
|
|
6904bce96b | ||
|
|
e07a462184 |
@@ -27,7 +27,7 @@
|
||||
- 🔍 **Labels & Filters**: Organise and find cards quickly
|
||||
- 💬 **Comments**: Discuss and collaborate with your team
|
||||
- 📝 **Activity Log**: Track all card changes with detailed activity history
|
||||
- 🎨 **Templates** : Save time with reusable custom board templates
|
||||
- 🎨 **Templates (coming soon)** : Save time with reusable board templates
|
||||
- ⚡️ **Integrations (coming soon)** : Connect your favourite tools
|
||||
|
||||
See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"version": 0,
|
||||
"locale": {
|
||||
"source": "en",
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
|
||||
"targets": ["fr", "de", "es", "it", "nl", "ru"]
|
||||
},
|
||||
"buckets": {
|
||||
"po": {
|
||||
|
||||
@@ -3,10 +3,8 @@ checksums:
|
||||
8033ed03a24d7c4898c90a5365297c20:
|
||||
"%20(edited)/singular": 666ee7e6d86957a87c23fa12b21b773f
|
||||
"%2C%20or%20see%20our/singular": e8a8f2a078fcaea03481710dcaccce5d
|
||||
"%7B0%7D/singular": 18782768c8a0b3c2078747b34fbbcd24
|
||||
"%7B0%7D%20%7C%20%7B1%7D/singular": 4628f9c6ec13c2dc2984a6ab80e19f8b
|
||||
"%7B0%7D%20labels/singular": 785f92bedf786ff3e285adfc07c69d18
|
||||
"%7B0%7D%20not%20found/singular": a2665fc66f128fee1a80ffec61c9f1a1
|
||||
"%7BboardCount%2C%20plural%2C%20one%20%7BImport%20board%20(1)%7D%20other%20%7BImport%20boards%20(%7BboardCount%7D)%7D%7D/singular": 72358c550bd1a99fcb6e8952f0f947ef
|
||||
"%2410%2Fmonth/singular": cab5e752ed83096e34fca780d90e1fb9
|
||||
"%248%2Fmonth/singular": 4667034934bb2bc3d569c70b94205b51
|
||||
@@ -15,7 +13,6 @@ checksums:
|
||||
Account/singular: 01215c12fb1cdb93bd0c84c1382bef56
|
||||
Account%20deleted/singular: a25da96a1579c4491be0a95669ef18a4
|
||||
Activity/singular: 1948763de8e531483a798b68195e297e
|
||||
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
|
||||
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
|
||||
Add%20a%20card/singular: 9d80becc4fe65a74e0004e4fd290735d
|
||||
Add%20a%20comment.../singular: c28cfccc0cc69a2a2487e37eb9988028
|
||||
@@ -51,14 +48,12 @@ checksums:
|
||||
Approval/singular: 2f5426445800ecd5a3102aac940d459c
|
||||
Are%20you%20sure%20want%20to%20remove%20%7BentityLabel%7D%3F/singular: f9b67026c5ef86ac4d5944380d571961
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20the%20workspace%20%7B0%7D%3F/singular: 27ba2854202dd77c51e78c64e5c93946
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20%7B0%7D%3F/singular: e980ce9469e329ddfed14c7da2cb5e52
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20card%3F/singular: 131a5a7cccca7be8b32c987d9fa0b0c0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20checklist%3F/singular: 7c0f540603474b1a30462dda087fd2f0
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20comment%3F/singular: 55fe6f1b52b71b91b2a079cfda3cc7fe
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20this%20label%3F/singular: 037fbaf1d04356937e6afb574714f51f
|
||||
Are%20you%20sure%20you%20want%20to%20delete%20your%20account%3F/singular: 428b74d781063224c3e63980e1094ec3
|
||||
assigned%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%20the%20card/singular: 9df6d89fa83706123dedd8cba2243d04
|
||||
Assignees/singular: 96fc0e28a783d80decfd4716a440ac08
|
||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||
@@ -72,7 +67,6 @@ checksums:
|
||||
Board%20name%20cannot%20exceed%20100%20characters/singular: 985c0177744ec56d8dac84f66ec284ca
|
||||
Board%20name%20is%20required/singular: dbd0c6cc945ab19e702fc27f970a023b
|
||||
Board%20not%20found/singular: 5af9844595fa8aed5985066620435515
|
||||
Board%20sharing%20%26%20invites/singular: c9222fbcfac4e99dcaa859b46c163e55
|
||||
Board%20URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: ba018946ffdf85e55d701a9c27595b4d
|
||||
Board%20URL%20cannot%20exceed%2060%20characters/singular: 8fd3417559a56aa42d297b14d480c382
|
||||
Board%20URL%20copied%20to%20clipboard/singular: 5ef04fee9c8e5299a2fd1ad223eb95c9
|
||||
@@ -80,6 +74,7 @@ checksums:
|
||||
Board%20visibility/singular: b4205f3058297833a626156057c24b26
|
||||
Board%20visibility%20updated/singular: 8e2f5d622fa9c94481e7c1629a154ecf
|
||||
Boards/singular: 8fff51c5872e051beb9b718336665e80
|
||||
Boards%20%7C%20%7B0%7D/singular: 70c349df806f4b2d8bd28136a45496f0
|
||||
Brainstorming/singular: 736332f2e4488609e42d2be8547d296e
|
||||
Bug/singular: 4509fffdb5931f8905063c80cf802d71
|
||||
Bug%20Report/singular: e558d1f100e21230c2f495a8913ac5ec
|
||||
@@ -90,7 +85,6 @@ checksums:
|
||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
||||
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
|
||||
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
|
||||
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
|
||||
Clear%20filters/singular: 8f40ab5af527e4b190da94e7b6221379
|
||||
Click%20on%20the%20link%20we've%20sent%20to%20%7BmagicLinkRecipient%7D%20to%20sign%20in./singular: 210b6ff8727f976182ec3f29ea3c7667
|
||||
Close/singular: 2c2e22f8424a1031de89063bd0022e16
|
||||
@@ -98,9 +92,7 @@ checksums:
|
||||
Collaborate%20seamlessly%20with%20your%20team./singular: c5f10e431aaf8b51519bc009a4a4080c
|
||||
Coming%20soon/singular: ee2b0671e00972773210c5be5a9ccb89
|
||||
Comments/singular: 502237cd1044491c48494aa60967a985
|
||||
Comments%20%26%20mentions/singular: 1f3a822f4f97a9b07db82d24c4204f75
|
||||
Company/singular: 9e36d11fa82d1bd4563eef2abc4fcfba
|
||||
Compare%20our%20features%20to%20see%20why%20Kan%20is%20the%20best%20choice./singular: c186ed37ff27cfc2a1ac11e66b2b30f1
|
||||
Complete/singular: d3e36d723de0cfdea51c5b4171d30b4d
|
||||
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
|
||||
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
|
||||
@@ -115,30 +107,25 @@ checksums:
|
||||
Continue%20with%20/singular: 8ed03cf7c5e60a6edf3470a4558ff058
|
||||
Continue%20with%20%7B0%7D/singular: 2eaf6e1da91e208f7c5fb6bf862fe8a6
|
||||
Control%20who%20can%20view%20and%20edit%20your%20boards./singular: 2a7e0bec29bac26280de707e2fe8bce5
|
||||
Core%20features/singular: da95932e7a1465a5d21aa3b855a46dc2
|
||||
Create%20%7B0%7D/singular: d37c29be6ccc0eb6062237f8508c3178
|
||||
Create%20another/singular: 2de8a82a416eb78c0462aa36278edc9a
|
||||
Create%20API%20key/singular: 70ed8431c6ed5f7fdef122cc34f75b41
|
||||
Create%20board/singular: 155b62818bfab0e34f0089e1b34a32f3
|
||||
Create%20card/singular: 32792935dd5837a9433909b04021c22b
|
||||
Create%20checklist/singular: 5cbca15a7004558c4e6d381f83a34da2
|
||||
Create%20invite%20link/singular: bef23fe786978f7abf78dece49a35a26
|
||||
Create%20label/singular: c64e8180fa956f005edc1fd9b8e65abb
|
||||
Create%20list/singular: 858c3d514aa95765ec82114393199144
|
||||
Create%20new%20%7B0%7D/singular: 163dbd5cf86d512fadd4c3eaf9709a11
|
||||
Create%20new%20board/singular: cbb87b1f4cc46b336ad9510c9f19407c
|
||||
Create%20new%20key/singular: 236bdf75e42b42ec7fb25639bdc035af
|
||||
Create%20new%20label/singular: 6be30980847b577847baa01bb5f29711
|
||||
Create%20new%20list/singular: ce61ca8be7b3f6c26c32b684a42a2c26
|
||||
Create%20template/singular: 57bfe6b58c42958a90f67d605a69ecdc
|
||||
Create%20workspace/singular: 2e6718e79964ea5ce22d76c2189c77ca
|
||||
created%20the%20card/singular: 605475f5aaeb4dbccbf7c4eb9107b43d
|
||||
Critical/singular: eb327cd411b50aee954f8d1d215d003a
|
||||
Crop%20your%20avatar/singular: eb25e2d5972ec36a0b40481c8136ab15
|
||||
Current%20password%20is%20required/singular: 72536bca9598680027f2be8ce80ac280
|
||||
Custom%20board%20templates/singular: c2966b352d76bc53421c01e9c99474bb
|
||||
Custom%20domain/singular: b09e7a9c187b7163b4a6cfc78042fe42
|
||||
Custom%20templates/singular: f8caaad67e168f106a298c8e0a66240c
|
||||
Custom%20URLs%20require%20upgrading%20to%20a%20Pro%20plan/singular: f7275e3b473b8f7b39dab6b37eb26fea
|
||||
Custom%20workspace%20link/singular: 8a19ae46ccea9c54b65ae183cea70b44
|
||||
Custom%20workspace%20URL/singular: 7ba841d0946eb04fa3d17d74365be37b
|
||||
Customer%20Support/singular: 50e3c77e22e41061ca85ea2f02625a2e
|
||||
Dark/singular: 73e6e208ba628b26e90fcf6dce15e1b2
|
||||
@@ -149,7 +136,6 @@ checksums:
|
||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20template/singular: bdf3e8342f581d498b7238cdd4897a4b
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
||||
@@ -204,12 +190,9 @@ checksums:
|
||||
Execution/singular: cbac4a3c721123cbc6a883560bf29800
|
||||
Failed%20to%20accept%20invitation.%20Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: e4505a9df3a81e93a8a8b103c6e3ebc4
|
||||
Failed%20to%20copy%20invite%20link/singular: 635884d5ed8d6ee20b85a003939b4ae7
|
||||
Failed%20to%20create%20board/singular: a746e2afe881c3bf0a8e82a931ca3495
|
||||
Failed%20to%20login%20with%20%7B0%7D.%20Please%20try%20again./singular: 669a4b4247a73f53fb9b8b16e42d166f
|
||||
FAQs/singular: dc36d7992372ccd419b39ef51cf5b16c
|
||||
Fast%20%26%20lightweight/singular: 4f2a80c555a5427b94eef5124aa10775
|
||||
Feature/singular: 58f5f3f37862b6312a2f20ec1a1fd0e8
|
||||
Feature%20breakdown/singular: cae23c2a3fbb21735289571f2e1f60db
|
||||
Feature%20Request/singular: c9de91b9a7020213381977f7d147064f
|
||||
Features/singular: 341ff316a339b106a178f0b8d362951b
|
||||
Feedback/singular: 6fac88806e0c269a30777b283988c61c
|
||||
@@ -224,7 +207,7 @@ checksums:
|
||||
Fun/singular: 395bdc48e943762b6cde649f4a96771d
|
||||
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
|
||||
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
|
||||
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
|
||||
Get%20started%20by%20creating%20a%20new%20board/singular: 8fe63ba47e12bbf950f3eb33d1008993
|
||||
Get%20started%20by%20creating%20a%20new%20list/singular: bd6bf0806c95788a15ab438e2b09f885
|
||||
Get%20started%20for%20free%20today/singular: 41e129eba0f6411337b54a7a08ea1709
|
||||
Get%20started%20for%20free%2C%20with%20no%20usage%20limits.%20For%20collaboration%2C%20upgrade%20to%20a%20plan%20that%20fits%20the%20size%20of%20your%20team./singular: ddea69fa5d7b4023724296e1287872b6
|
||||
@@ -248,7 +231,6 @@ checksums:
|
||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||
Import%20from%20Trello/singular: 9cce591a63763174952ffc11ae829f0c
|
||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
||||
@@ -257,27 +239,22 @@ checksums:
|
||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||
Intelligent%20search/singular: da688e7cfb2775f719a12df6dd606f6e
|
||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
||||
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
Invite%20link%20copied/singular: 4046f23a78e1cd5166671c3fb8a7ea6e
|
||||
Invite%20link%20copied%20to%20clipboard/singular: 6fc055a0ea0ed1aa58c5e0c502efe17f
|
||||
Invite%20links/singular: 36ea61a3193245fa1b57d3392f342aaa
|
||||
Invite%20links%20require%20a%20Team%20or%20Pro%20subscription.%20Please%20upgrade%20your%20workspace./singular: 18db646fc55f99789572c8df0539ee69
|
||||
Invite%20member/singular: ade922db1be6b26bc979565ce5de2bc7
|
||||
Inviting%20members%20requires%20a%20Team%20Plan.%20You'll%20be%20redirected%20to%20upgrade%20your%20workspace./singular: 03eeed2d715e770259f722ba48a61ab3
|
||||
Join%20workspace/singular: f5d035df672b05abd760bd022309c719
|
||||
Join%20workspace%20%7C%20kan.bn/singular: 97855216a0e00214b2dcf917e93164f2
|
||||
Junior/singular: ed1bd2c59a824fdcdd56fc8a0660fe9f
|
||||
Kan/singular: dd1232a5a28bf47e939d3b8a199bc229
|
||||
Kan%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: d13f4467d3dc0a0786c3843a9b50785d
|
||||
Kanban%20is%20better%20with%20a%20team.%20Perfect%20for%20small%20and%20growing%20teams%20looking%20to%20collaborate./singular: a77bee43046b260797c8936ad23e9223
|
||||
Kanban%20reimagined/singular: 613ccfdd9f54c66cbf68cfa313498766
|
||||
Keep%20in%20mind%20that%20this%20action%20is%20irreversible./singular: 79702b2ad3be71cb8b87f1122a60c199
|
||||
Labels/singular: 6f15627a90002323eac018274b6922d6
|
||||
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
|
||||
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
|
||||
Language/singular: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
Launch%20offer/singular: 624592ba2ecf647a3fe60d493c1ee71a
|
||||
@@ -295,7 +272,6 @@ checksums:
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Low%20Priority/singular: 509b5cb7c1e8a185e0aa6d38c64c90e8
|
||||
magic%20link/singular: db9d17a5bd98bba8aa0d8068c68579f3
|
||||
Make%20template/singular: 7a1c63b75cea690b3fda6c3f203e31ff
|
||||
marked%20a%20checklist%20item%20as%20incomplete/singular: 35d0822f65971b97774b962561b02649
|
||||
marked%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E%20as%20incomplete/singular: 4c38799ff25321ea25017bf4cd8e2e4f
|
||||
Medium%20Priority/singular: 1f527cd6d1ed602930bcaa303f503b51
|
||||
@@ -308,8 +284,8 @@ checksums:
|
||||
Name/singular: 9368b5a047572b6051f334af5aa76819
|
||||
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
|
||||
New/singular: 126d036fae5fb6b629728ecb97e6195b
|
||||
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
|
||||
New%20API%20key/singular: db3088aedba6e4a99b46451c5b3d36ed
|
||||
New%20board/singular: 63f4e979e29a7fc2f5c09ff91fa75966
|
||||
New%20card/singular: a33f6219a756127f91c2523cfe845a19
|
||||
New%20checklist/singular: 58252b71e9693ae0f4d2d0b72108a569
|
||||
New%20import/singular: e8fddfe15dd170277df919e5d37efd47
|
||||
@@ -317,12 +293,11 @@ checksums:
|
||||
New%20list/singular: 146c2e642b11056a0b07ed65733e4178
|
||||
New%20password%20is%20required/singular: f88427a4cca058631199a4384cb5622e
|
||||
New%20password%20must%20be%20different%20from%20current%20password/singular: db33013fcc6c40995cf8a37deb3d5667
|
||||
New%20template/singular: 10dca64383235d1596a010e559b2e35a
|
||||
New%20Ticket/singular: 665ff16ec736cba20e8a1b3beb3baa09
|
||||
New%20workspace/singular: 8eecf05b4ce29aa880adfed2e11476af
|
||||
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
|
||||
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
|
||||
No%20%7B0%7D/singular: a7a95e1dfdb4396da9e2be0e27f42323
|
||||
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
|
||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
@@ -331,11 +306,9 @@ checksums:
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
||||
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
|
||||
@@ -348,7 +321,6 @@ checksums:
|
||||
per%20user%2Fmonth/singular: 72af182c1ba6df6732640f4d8a78d360
|
||||
Personal%20Project/singular: d7820b1bf4efecc61ed89234567aaa9c
|
||||
Planning/singular: 353f58c75248275fe091740607501610
|
||||
Platform/singular: c68862170146325333c7f25af11a3fa2
|
||||
Please%20confirm%20your%20new%20password/singular: 43d0307b9307238cc6c199cfa66caff2
|
||||
Please%20enter%20a%20valid%20email%20address/singular: 8de4bc8832b11b380bc4cbcedc16e48b
|
||||
Please%20enter%20a%20valid%20name/singular: f2d741f1b5cae722e35cb5206786f932
|
||||
@@ -356,7 +328,6 @@ checksums:
|
||||
Please%20select%20a%20file%20to%20upload./singular: de315bf594047f8ef9307a7fa9285844
|
||||
Please%20try%20again%20later%2C%20or%20contact%20customer%20support./singular: 21ffcf0b00e7cd7b64f7454a95762e1d
|
||||
Please%20try%20again%20later./singular: 325dea6dd0348a27a6818db2c1340c98
|
||||
Price%20per%20user%2Fmonth/singular: ae4b93ad0f9fad454b40f82010372fc2
|
||||
Pricing/singular: ce27f1aeacccc542a174c4b2bce022b0
|
||||
Priority%20email%20support/singular: 678538c912a770b1e1416ecdb8e299b1
|
||||
Privacy%20policy/singular: 462c6a536b52873e4498785c66dd48c8
|
||||
@@ -383,7 +354,6 @@ checksums:
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
@@ -397,7 +367,6 @@ checksums:
|
||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
@@ -426,11 +395,6 @@ checksums:
|
||||
System/singular: 803281c327a02a2feba182f4a2b38311
|
||||
Team%20Plan/singular: 3f0ae9c914f16ceb9a0d7db868f4aa20
|
||||
Teams/singular: b63448c05270497973ac4407047dae02
|
||||
Template/singular: 87c2704ff2033e3c76bc97a45ae57d5c
|
||||
Template%20created/singular: 2c7a515a5d8a9afb26de6365cdaa0c92
|
||||
Template%20created%20successfully/singular: 2750600da57c5cd3c3c072810c8c9604
|
||||
Template%20name%20cannot%20exceed%20100%20characters/singular: 14527c9246e558a085ceee2e8d4e6560
|
||||
Template%20name%20is%20required/singular: 34d674d59c414c2051ef5bc0a62e3c00
|
||||
Templates/singular: 8ed62f56c81fa96c0f3182acb331bf63
|
||||
Terms%20of%20service/singular: 0b8a8b23aff021a6cde79613fdb39c28
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
@@ -462,7 +426,6 @@ checksums:
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20template/singular: 7495f1434c9188849bffd015811d5337
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
||||
@@ -488,7 +451,6 @@ checksums:
|
||||
Unlimited%20comments/singular: 31dbcf487c49a9997142e96bb3ab0040
|
||||
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
|
||||
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
|
||||
Unlimited%20workspaces/singular: b504d01997bf908af1d486d9942fed36
|
||||
Update/singular: 079fc039262fd31b10532929685c2d1b
|
||||
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
|
||||
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
|
||||
@@ -520,7 +482,6 @@ checksums:
|
||||
What%20license%20are%20you%20using%3F/singular: 59d00e1bf28edc21b77fec94875f57ca
|
||||
What's%20the%20difference%20between%20Kan%20and%20Trello%3F/singular: 7a02f507dd6abbfab1fdae0b172d31b7
|
||||
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20time%20(and%20atlassification)%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 60a55e9bd16c3d1beff1a6c7633c5911
|
||||
White%20label/singular: 2570386f73a010b07219e7658aaf9d9b
|
||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { LinguiConfig } from "@lingui/conf";
|
||||
|
||||
const config: LinguiConfig = {
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
|
||||
locales: ["en", "fr", "de", "es", "it", "nl", "ru"],
|
||||
sourceLocale: "en",
|
||||
catalogs: [
|
||||
{
|
||||
|
||||
@@ -153,7 +153,6 @@ const availableSocialProviders = {
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
|
||||
@@ -173,8 +172,6 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const emailSendingEnabled =
|
||||
env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true";
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
setIsCloudEnv(isCloudEnv);
|
||||
setIsEmailSendingEnabled(emailSendingEnabled);
|
||||
setIsCredentialsEnabled(credentialsAllowed);
|
||||
}, []);
|
||||
@@ -239,7 +236,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
}
|
||||
} else {
|
||||
// Only allow magic link if email sending is enabled and not in sign up mode
|
||||
if (isCloudEnv || (isEmailSendingEnabled && !isSignUp)) {
|
||||
if (isEmailSendingEnabled && !isSignUp) {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
|
||||
@@ -2,7 +2,6 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
@@ -16,11 +15,10 @@ import membersIconDark from "~/assets/members-dark.json";
|
||||
import membersIconLight from "~/assets/members-light.json";
|
||||
import settingsIconDark from "~/assets/settings-dark.json";
|
||||
import settingsIconLight from "~/assets/settings-light.json";
|
||||
import templatesIconDark from "~/assets/templates-dark.json";
|
||||
import templatesIconLight from "~/assets/templates-light.json";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
@@ -71,11 +69,6 @@ export default function SideNavigation({
|
||||
href: "/boards",
|
||||
icon: isDarkMode ? boardsIconDark : boardsIconLight,
|
||||
},
|
||||
{
|
||||
name: t`Templates`,
|
||||
href: "/templates",
|
||||
icon: isDarkMode ? templatesIconDark : templatesIconLight,
|
||||
},
|
||||
{
|
||||
name: t`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,13 +1,4 @@
|
||||
export const locales = [
|
||||
"en",
|
||||
"fr",
|
||||
"de",
|
||||
"es",
|
||||
"it",
|
||||
"nl",
|
||||
"ru",
|
||||
"pl",
|
||||
] as const;
|
||||
export const locales = ["en", "fr", "de", "es", "it", "nl", "ru"] as const;
|
||||
|
||||
export type Locale = (typeof locales)[number];
|
||||
|
||||
@@ -21,5 +12,4 @@ export const localeNames: Record<Locale, string> = {
|
||||
it: "Italiano",
|
||||
nl: "Nederlands",
|
||||
ru: "Русский",
|
||||
pl: "Polski",
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
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,5 +0,0 @@
|
||||
import PricingView from "~/views/pricing";
|
||||
|
||||
export default function Pricing() {
|
||||
return <PricingView />;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
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;
|
||||
@@ -1,17 +0,0 @@
|
||||
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,17 +25,3 @@
|
||||
width: 200%;
|
||||
height: 200%;
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
@supports not (overflow-wrap: anywhere) {
|
||||
.break-anywhere {
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
.break-anywhere {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ const loadMessages = async (locale: Locale) => {
|
||||
return (await import("~/locales/nl/messages")).messages;
|
||||
case "ru":
|
||||
return (await import("~/locales/ru/messages")).messages;
|
||||
case "pl":
|
||||
return (await import("~/locales/pl/messages")).messages;
|
||||
default:
|
||||
return enMessages;
|
||||
}
|
||||
|
||||
@@ -1,51 +1,23 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiEllipsisHorizontal,
|
||||
HiLink,
|
||||
HiOutlineDocumentDuplicate,
|
||||
HiOutlineTrash,
|
||||
} from "react-icons/hi2";
|
||||
import { HiEllipsisHorizontal, HiLink, HiOutlineTrash } from "react-icons/hi2";
|
||||
|
||||
import Dropdown from "~/components/Dropdown";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export default function BoardDropdown({
|
||||
isTemplate,
|
||||
isLoading,
|
||||
boardPublicId,
|
||||
workspacePublicId,
|
||||
}: {
|
||||
isTemplate: boolean;
|
||||
isLoading: boolean;
|
||||
boardPublicId: string;
|
||||
workspacePublicId: string;
|
||||
}) {
|
||||
export default function BoardDropdown({ isLoading }: { isLoading: boolean }) {
|
||||
const { openModal } = useModal();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
disabled={isLoading}
|
||||
items={[
|
||||
...(isTemplate
|
||||
? []
|
||||
: [
|
||||
{
|
||||
label: t`Make template`,
|
||||
action: () => openModal("CREATE_TEMPLATE"),
|
||||
icon: (
|
||||
<HiOutlineDocumentDuplicate className="h-[16px] w-[16px] text-dark-900" />
|
||||
),
|
||||
},
|
||||
{
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
]),
|
||||
|
||||
{
|
||||
label: isTemplate ? t`Delete template` : t`Delete board`,
|
||||
label: t`Edit board URL`,
|
||||
action: () => openModal("UPDATE_BOARD_SLUG"),
|
||||
icon: <HiLink className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
{
|
||||
label: t`Delete board`,
|
||||
action: () => openModal("DELETE_BOARD"),
|
||||
icon: <HiOutlineTrash className="h-[16px] w-[16px] text-dark-900" />,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { t } from "@lingui/core/macro";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
@@ -7,10 +6,8 @@ import { api } from "~/utils/api";
|
||||
|
||||
export function DeleteBoardConfirmation({
|
||||
boardPublicId,
|
||||
isTemplate,
|
||||
}: {
|
||||
boardPublicId: string;
|
||||
isTemplate: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { closeModal } = useModal();
|
||||
@@ -18,7 +15,7 @@ export function DeleteBoardConfirmation({
|
||||
const deleteBoard = api.board.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
closeModal();
|
||||
router.push(isTemplate ? `/templates` : `/boards`);
|
||||
router.push(`/boards`);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,18 +30,18 @@ export function DeleteBoardConfirmation({
|
||||
<div className="p-5">
|
||||
<div className="flex w-full flex-col justify-between pb-4">
|
||||
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
|
||||
{t`Are you sure you want to delete this ${isTemplate ? "template" : "board"}?`}
|
||||
Are you sure you want to delete this board?
|
||||
</h2>
|
||||
<p className="text-sm font-medium text-light-900 dark:text-dark-900">
|
||||
{t`This action can't be undone.`}
|
||||
{"This action can't be undone."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
|
||||
<Button onClick={() => closeModal()} variant="secondary">
|
||||
{t`Cancel`}
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleDeleteBoard} isLoading={deleteBoard.isPending}>
|
||||
{t`Delete`}
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,11 +11,10 @@ import {
|
||||
import type { NewCardInput } from "@kan/api/types";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
import type { WorkspaceMember } from "~/components/Editor";
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Button from "~/components/Button";
|
||||
import CheckboxDropdown from "~/components/CheckboxDropdown";
|
||||
import Editor from "~/components/Editor";
|
||||
import Editor, { WorkspaceMember } from "~/components/Editor";
|
||||
import Input from "~/components/Input";
|
||||
import LabelIcon from "~/components/LabelIcon";
|
||||
import Toggle from "~/components/Toggle";
|
||||
@@ -36,14 +35,12 @@ interface QueryParams {
|
||||
}
|
||||
|
||||
interface NewCardFormProps {
|
||||
isTemplate: boolean;
|
||||
boardPublicId: string;
|
||||
listPublicId: string;
|
||||
queryParams: QueryParams;
|
||||
}
|
||||
|
||||
export function NewCardForm({
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
listPublicId,
|
||||
queryParams,
|
||||
@@ -88,13 +85,14 @@ export function NewCardForm({
|
||||
return () => subscription.unsubscribe();
|
||||
}, [watch, saveFormState]);
|
||||
|
||||
|
||||
const { data: boardData } = api.board.byId.useQuery(queryParams, {
|
||||
enabled: !!boardPublicId,
|
||||
});
|
||||
|
||||
// this adds the new created label to selected labels
|
||||
useEffect(() => {
|
||||
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
||||
if (newLabelId !== undefined && !labelPublicIds.includes(newLabelId)) {
|
||||
setValue("labelPublicIds", [...labelPublicIds, newLabelId]);
|
||||
}
|
||||
@@ -103,23 +101,23 @@ export function NewCardForm({
|
||||
// this removes the deleted label from selected labels if it is selected
|
||||
useEffect(() => {
|
||||
if (boardData?.labels) {
|
||||
const availableLabelIds = boardData.labels.map((label) => label.publicId);
|
||||
const newLabelId = modalStates.NEW_LABEL_CREATED;
|
||||
|
||||
const availableLabelIds = boardData.labels.map(label => label.publicId);
|
||||
const newLabelId = modalStates["NEW_LABEL_CREATED"];
|
||||
|
||||
if (newLabelId && availableLabelIds.includes(newLabelId)) {
|
||||
clearModalState("NEW_LABEL_CREATED");
|
||||
}
|
||||
|
||||
const validLabelIds = labelPublicIds.filter(
|
||||
(id) => availableLabelIds.includes(id) || id === newLabelId,
|
||||
|
||||
const validLabelIds = labelPublicIds.filter(id =>
|
||||
availableLabelIds.includes(id) || id === newLabelId
|
||||
);
|
||||
|
||||
|
||||
if (validLabelIds.length !== labelPublicIds.length) {
|
||||
setValue("labelPublicIds", validLabelIds);
|
||||
}
|
||||
}
|
||||
}, [boardData?.labels, labelPublicIds, modalStates.NEW_LABEL_CREATED]);
|
||||
|
||||
}, [boardData?.labels, labelPublicIds, modalStates["NEW_LABEL_CREATED"]]);
|
||||
|
||||
const createCard = api.card.create.useMutation({
|
||||
onMutate: async (args) => {
|
||||
await utils.board.byId.cancel();
|
||||
@@ -325,19 +323,15 @@ export function NewCardForm({
|
||||
saveFormState({ ...formState, description: value });
|
||||
}}
|
||||
workspaceMembers={
|
||||
boardData?.workspace.members?.map(
|
||||
(member): WorkspaceMember => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user
|
||||
? {
|
||||
id: member.publicId,
|
||||
name: member.user.name,
|
||||
image: member.user.image ?? null,
|
||||
}
|
||||
: null,
|
||||
}),
|
||||
) ?? []
|
||||
boardData?.workspace.members?.map((member): WorkspaceMember => ({
|
||||
publicId: member.publicId,
|
||||
email: member.email,
|
||||
user: member.user ? {
|
||||
id: member.publicId,
|
||||
name: member.user.name,
|
||||
image: member.user.image ?? null,
|
||||
} : null,
|
||||
})) ?? []
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
@@ -353,46 +347,42 @@ export function NewCardForm({
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedMembers}
|
||||
handleSelect={(_groupKey, item) =>
|
||||
handleSelectMembers(item.key)
|
||||
}
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!memberPublicIds.length ? (
|
||||
t`Members`
|
||||
) : (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{memberPublicIds.map((memberPublicId) => {
|
||||
const member = formattedMembers.find(
|
||||
(member) => member.key === memberPublicId,
|
||||
);
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedMembers}
|
||||
handleSelect={(_groupKey, item) => handleSelectMembers(item.key)}
|
||||
>
|
||||
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-600 bg-light-200 px-2 py-1 text-left text-xs text-light-800 hover:bg-light-300 dark:border-dark-600 dark:bg-dark-400 dark:text-dark-1000 dark:hover:bg-dark-500">
|
||||
{!memberPublicIds.length ? (
|
||||
t`Members`
|
||||
) : (
|
||||
<div className="flex -space-x-1 overflow-hidden">
|
||||
{memberPublicIds.map((memberPublicId) => {
|
||||
const member = formattedMembers.find(
|
||||
(member) => member.key === memberPublicId,
|
||||
);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={member?.key}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
|
||||
>
|
||||
<span className="text-[8px] font-medium leading-none text-white">
|
||||
{member?.value
|
||||
.split(" ")
|
||||
.map((namePart) =>
|
||||
namePart.charAt(0).toUpperCase(),
|
||||
)
|
||||
.join("")}
|
||||
</span>
|
||||
return (
|
||||
<span
|
||||
key={member?.key}
|
||||
className="inline-flex h-4 w-4 items-center justify-center rounded-full bg-gray-400 ring-1 ring-light-200 dark:ring-dark-500"
|
||||
>
|
||||
<span className="text-[8px] font-medium leading-none text-white">
|
||||
{member?.value
|
||||
.split(" ")
|
||||
.map((namePart) =>
|
||||
namePart.charAt(0).toUpperCase(),
|
||||
)
|
||||
.join("")}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CheckboxDropdown>
|
||||
</div>
|
||||
<div className="w-fit">
|
||||
<CheckboxDropdown
|
||||
items={formattedLabels}
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
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,11 +7,7 @@ import { keepPreviousData } from "@tanstack/react-query";
|
||||
import { useEffect, useState } from "react";
|
||||
import { DragDropContext, Draggable } from "react-beautiful-dnd";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
HiOutlinePlusSmall,
|
||||
HiOutlineRectangleStack,
|
||||
HiOutlineSquare3Stack3D,
|
||||
} from "react-icons/hi2";
|
||||
import { HiOutlinePlusSmall, HiOutlineSquare3Stack3D } from "react-icons/hi2";
|
||||
|
||||
import type { UpdateBoardInput } from "@kan/api/types";
|
||||
|
||||
@@ -36,15 +32,14 @@ import Filters from "./components/Filters";
|
||||
import List from "./components/List";
|
||||
import { NewCardForm } from "./components/NewCardForm";
|
||||
import { NewListForm } from "./components/NewListForm";
|
||||
import { NewTemplateForm } from "./components/NewTemplateForm";
|
||||
import UpdateBoardSlugButton from "./components/UpdateBoardSlugButton";
|
||||
import { UpdateBoardSlugForm } from "./components/UpdateBoardSlugForm";
|
||||
import VisibilityButton from "./components/VisibilityButton";
|
||||
|
||||
type PublicListId = string;
|
||||
|
||||
export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const params = useParams() as { boardId: string | string[] } | null;
|
||||
export default function BoardPage() {
|
||||
const params = useParams() as { boardId: string[] } | null;
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const { showPopup } = usePopup();
|
||||
@@ -54,11 +49,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
useState<PublicListId>("");
|
||||
const [isInitialLoading, setIsInitialLoading] = useState(true);
|
||||
|
||||
const boardId = params?.boardId
|
||||
? Array.isArray(params.boardId)
|
||||
? params.boardId[0]
|
||||
: params.boardId
|
||||
: null;
|
||||
const boardId = params?.boardId.length ? params.boardId[0] : null;
|
||||
|
||||
const updateBoard = api.board.update.useMutation();
|
||||
|
||||
@@ -76,16 +67,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
});
|
||||
};
|
||||
|
||||
const queryParams: {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
type: "regular" | "template";
|
||||
} = {
|
||||
const queryParams = {
|
||||
boardPublicId: boardId ?? "",
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
type: isTemplate ? "template" : "regular",
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -223,7 +208,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
};
|
||||
|
||||
const onDragEnd = ({
|
||||
source: _source,
|
||||
source,
|
||||
destination,
|
||||
draggableId,
|
||||
type,
|
||||
@@ -256,10 +241,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "DELETE_BOARD"}
|
||||
>
|
||||
<DeleteBoardConfirmation
|
||||
isTemplate={!!isTemplate}
|
||||
boardPublicId={boardId ?? ""}
|
||||
/>
|
||||
<DeleteBoardConfirmation boardPublicId={boardId ?? ""} />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -277,7 +259,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
||||
>
|
||||
<NewCardForm
|
||||
isTemplate={!!isTemplate}
|
||||
boardPublicId={boardId ?? ""}
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
@@ -340,17 +321,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
queryParams={queryParams}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "CREATE_TEMPLATE"}
|
||||
>
|
||||
<NewTemplateForm
|
||||
workspacePublicId={workspace.publicId ?? ""}
|
||||
sourceBoardPublicId={boardId ?? ""}
|
||||
sourceBoardName={boardData?.name ?? ""}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -358,7 +328,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${(boardData?.name ?? isTemplate) ? t`Board` : t`Template`} | ${workspace.name}`}
|
||||
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
|
||||
/>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
@@ -384,46 +354,31 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
)}
|
||||
{!boardData && !isLoading && (
|
||||
<p className="order-2 block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem] md:order-1">
|
||||
{t`${isTemplate ? "Template" : "Board"} not found`}
|
||||
{t`Board not found`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="order-1 mb-4 flex items-center justify-end space-x-2 md:order-2 md:mb-0">
|
||||
{isTemplate && (
|
||||
<div className="inline-flex cursor-default items-center justify-center whitespace-nowrap rounded-md border-[1px] border-light-300 bg-light-50 px-3 py-2 text-sm font-semibold text-light-950 shadow-sm dark:border-dark-300 dark:bg-dark-50 dark:text-dark-950">
|
||||
<span className="mr-2">
|
||||
<HiOutlineRectangleStack />
|
||||
</span>
|
||||
{t`Template`}
|
||||
</div>
|
||||
)}
|
||||
{!isTemplate && (
|
||||
<>
|
||||
<UpdateBoardSlugButton
|
||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
{boardData && (
|
||||
<Filters
|
||||
labels={boardData.labels}
|
||||
members={boardData.workspace.members.filter(
|
||||
(member) => member.user !== null,
|
||||
)}
|
||||
position="left"
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<UpdateBoardSlugButton
|
||||
handleOnClick={() => openModal("UPDATE_BOARD_SLUG")}
|
||||
isLoading={isLoading}
|
||||
workspaceSlug={workspace.slug ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
/>
|
||||
<VisibilityButton
|
||||
visibility={boardData?.visibility ?? "private"}
|
||||
boardPublicId={boardId ?? ""}
|
||||
boardSlug={boardData?.slug ?? ""}
|
||||
queryParams={queryParams}
|
||||
isLoading={!boardData}
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
<Filters
|
||||
labels={boardData?.labels ?? []}
|
||||
members={boardData?.workspace.members?.filter(member => member.user !== null) ?? []}
|
||||
position="left"
|
||||
isLoading={!boardData}
|
||||
/>
|
||||
<Button
|
||||
iconLeft={
|
||||
<HiOutlinePlusSmall
|
||||
@@ -438,12 +393,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
>
|
||||
{t`New list`}
|
||||
</Button>
|
||||
<BoardDropdown
|
||||
isTemplate={!!isTemplate}
|
||||
isLoading={!boardData}
|
||||
boardPublicId={boardId ?? ""}
|
||||
workspacePublicId={workspace.publicId}
|
||||
/>
|
||||
<BoardDropdown isLoading={!boardData} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -525,11 +475,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
e.preventDefault();
|
||||
}}
|
||||
key={card.publicId}
|
||||
href={
|
||||
isTemplate
|
||||
? `/templates/${boardId}/cards/${card.publicId}`
|
||||
: `/cards/${card.publicId}`
|
||||
}
|
||||
href={`/cards/${card.publicId}`}
|
||||
className={`mb-2 flex !cursor-pointer flex-col ${
|
||||
card.publicId.startsWith(
|
||||
"PLACEHOLDER",
|
||||
|
||||
@@ -8,15 +8,12 @@ import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
export function BoardsList() {
|
||||
const { workspace } = useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data, isLoading } = api.board.all.useQuery(
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
type: isTemplate ? "template" : "regular",
|
||||
},
|
||||
{ workspacePublicId: workspace.publicId },
|
||||
{ enabled: workspace.publicId ? true : false },
|
||||
);
|
||||
|
||||
@@ -35,14 +32,14 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<div className="flex flex-col items-center">
|
||||
<HiOutlineRectangleStack className="h-10 w-10 text-light-800 dark:text-dark-800" />
|
||||
<p className="mb-2 mt-4 text-[14px] font-bold text-light-1000 dark:text-dark-950">
|
||||
{t`No ${isTemplate ? "templates" : "boards"}`}
|
||||
{t`No boards`}
|
||||
</p>
|
||||
<p className="text-[14px] text-light-900 dark:text-dark-900">
|
||||
{t`Get started by creating a new ${isTemplate ? "template" : "board"}`}
|
||||
{t`Get started by creating a new board`}
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => openModal("NEW_BOARD")}>
|
||||
{t`Create new ${isTemplate ? "template" : "board"}`}
|
||||
{t`Create new board`}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -50,10 +47,7 @@ export function BoardsList({ isTemplate }: { isTemplate?: boolean }) {
|
||||
return (
|
||||
<div className="3xl:grid-cols-4 grid h-fit w-full grid-cols-1 gap-4 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-3">
|
||||
{data?.map((board) => (
|
||||
<Link
|
||||
key={board.publicId}
|
||||
href={`${isTemplate ? "templates" : "boards"}/${board.publicId}`}
|
||||
>
|
||||
<Link key={board.publicId} href={`boards/${board.publicId}`}>
|
||||
<div className="align-center relative mr-5 flex h-[150px] w-full items-center justify-center rounded-md border border-dashed border-light-400 bg-light-50 shadow-sm hover:bg-light-200 dark:border-dark-600 dark:bg-dark-50 dark:hover:bg-dark-100">
|
||||
<PatternedBackground />
|
||||
<p className="px-4 text-[14px] font-bold text-neutral-700 dark:text-dark-1000">
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useRouter } from "next/navigation";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect, useState } from "react";
|
||||
@@ -11,10 +10,9 @@ import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
import Toggle from "~/components/Toggle";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import TemplateBoards from "./TemplateBoards";
|
||||
import TemplateBoards, { getTemplates } from "./TemplateBoards";
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
@@ -31,25 +29,13 @@ interface NewBoardInputWithTemplate {
|
||||
template: Template | null;
|
||||
}
|
||||
|
||||
export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||
export function NewBoardForm() {
|
||||
const utils = api.useUtils();
|
||||
const { closeModal } = useModal();
|
||||
const router = useRouter();
|
||||
const { showPopup } = usePopup();
|
||||
const { workspace } = useWorkspace();
|
||||
const [showTemplates, setShowTemplates] = useState(false);
|
||||
const { data: templates } = api.board.all.useQuery(
|
||||
{ workspacePublicId: workspace.publicId ?? "", type: "template" },
|
||||
{ enabled: !!workspace.publicId },
|
||||
);
|
||||
|
||||
const 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 templates = getTemplates();
|
||||
|
||||
const {
|
||||
register,
|
||||
@@ -71,39 +57,18 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
const createBoard = api.board.create.useMutation({
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
onSuccess: async () => {
|
||||
closeModal();
|
||||
|
||||
await refetchBoards();
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Error`,
|
||||
message: t`Failed to create board`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (data: NewBoardInputWithTemplate) => {
|
||||
createBoard.mutate({
|
||||
name: data.name,
|
||||
workspacePublicId: data.workspacePublicId,
|
||||
sourceBoardPublicId: data.template?.sourceBoardPublicId ?? undefined,
|
||||
lists: data.template?.lists ?? [],
|
||||
labels: data.template?.labels ?? [],
|
||||
type: isTemplate ? "template" : "regular",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -117,7 +82,7 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<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 ${isTemplate ? "template" : "board"}`}</h2>
|
||||
<h2 className="text-sm font-bold">{t`New board`}</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:bg-li ght-300 rounded p-1 focus:outline-none dark:hover:bg-dark-300"
|
||||
@@ -146,24 +111,21 @@ export function NewBoardForm({ isTemplate }: { isTemplate?: boolean }) {
|
||||
currentBoard={currentTemplate}
|
||||
setCurrentBoard={(t) => setValue("template", t)}
|
||||
showTemplates={showTemplates}
|
||||
customTemplates={formattedTemplates ?? []}
|
||||
/>
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
{!isTemplate && (
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
isChecked={showTemplates}
|
||||
onChange={() => {
|
||||
setShowTemplates(!showTemplates);
|
||||
if (!showTemplates && !currentTemplate) {
|
||||
setValue("template", (templates?.[0] as any) ?? null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Toggle
|
||||
label={t`Use template`}
|
||||
isChecked={showTemplates}
|
||||
onChange={() => {
|
||||
setShowTemplates(!showTemplates);
|
||||
if (!showTemplates && !currentTemplate) {
|
||||
setValue("template", templates[0] ?? null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div>
|
||||
<Button type="submit" isLoading={createBoard.isPending}>
|
||||
{t`Create ${isTemplate ? "template" : "board"}`}
|
||||
{t`Create board`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { HiCheckCircle } from "react-icons/hi2";
|
||||
|
||||
export interface Template {
|
||||
id: string;
|
||||
sourceBoardPublicId?: string;
|
||||
name: string;
|
||||
lists: string[];
|
||||
labels: string[];
|
||||
@@ -89,18 +88,16 @@ export default function TemplateBoards({
|
||||
currentBoard,
|
||||
setCurrentBoard,
|
||||
showTemplates,
|
||||
customTemplates,
|
||||
}: {
|
||||
currentBoard: Template | null;
|
||||
setCurrentBoard: (board: Template | null) => void;
|
||||
showTemplates: boolean;
|
||||
customTemplates: Template[] | null;
|
||||
}) {
|
||||
const [showFade, setShowFade] = useState(false);
|
||||
const [showTopFade, setShowTopFade] = useState(false);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const templates = [...(customTemplates ?? []), ...getTemplates()];
|
||||
const templates = getTemplates();
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollRef.current) return;
|
||||
|
||||
@@ -13,7 +13,7 @@ import { BoardsList } from "./components/BoardsList";
|
||||
import { ImportBoardsForm } from "./components/ImportBoardsForm";
|
||||
import { NewBoardForm } from "./components/NewBoardForm";
|
||||
|
||||
export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
export default function BoardsPage() {
|
||||
const { openModal, modalContentType, isOpen } = useModal();
|
||||
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
||||
|
||||
@@ -25,27 +25,23 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? "Workspace"}`}
|
||||
/>
|
||||
<PageHead title={t`Boards | ${workspace.name ?? "Workspace"}`} />
|
||||
<div className="m-auto h-full max-w-[1100px] p-6 px-5 md:px-28 md:py-12">
|
||||
<div className="relative z-10 mb-8 flex w-full items-center justify-between">
|
||||
<h1 className="font-bold tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
{t`${isTemplate ? "Templates" : "Boards"}`}
|
||||
{t`Boards`}
|
||||
</h1>
|
||||
<div className="flex gap-2">
|
||||
{!isTemplate && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => openModal("IMPORT_BOARDS")}
|
||||
iconLeft={
|
||||
<HiArrowDownTray aria-hidden="true" className="h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{t`Import`}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
@@ -71,7 +67,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
||||
>
|
||||
<NewBoardForm isTemplate={!!isTemplate} />
|
||||
<NewBoardForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -90,7 +86,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</>
|
||||
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
<BoardsList />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -138,7 +138,7 @@ const Comment = ({
|
||||
<ContentEditable
|
||||
html={comment ?? ""}
|
||||
disabled={true}
|
||||
className="break-anywhere mt-2 text-sm"
|
||||
className="mt-2 text-sm"
|
||||
/>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
|
||||
@@ -37,7 +37,7 @@ interface FormValues {
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
export function CardRightPanel() {
|
||||
const router = useRouter();
|
||||
const cardId = Array.isArray(router.query.cardId)
|
||||
? router.query.cardId[0]
|
||||
@@ -121,21 +121,19 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Members`}</p>
|
||||
<MemberSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
members={formattedMembers}
|
||||
isLoading={!card}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
export default function CardPage() {
|
||||
const router = useRouter();
|
||||
const utils = api.useUtils();
|
||||
const {
|
||||
@@ -230,7 +228,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
<>
|
||||
<Link
|
||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||
href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
|
||||
href={`/boards/${board?.publicId}`}
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
@@ -298,11 +296,9 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isAdmin={workspace.role === "admin"}
|
||||
/>
|
||||
</div>
|
||||
{!isTemplate && (
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<NewCommentForm cardPublicId={cardId} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -53,7 +53,7 @@ const Cta = ({ theme }: { theme: string }) => {
|
||||
<h2 className="text-balance text-4xl font-bold tracking-tight text-light-1000 dark:text-dark-1000 sm:text-4xl">
|
||||
{t`Get started for free today`}
|
||||
</h2>
|
||||
<p className="text-md/8 mx-auto mt-6 max-w-[375px] text-pretty text-light-950 dark:text-dark-900">
|
||||
<p className="text-md/8 mx-auto mt-6 max-w-[375px] text-pretty text-light-900 dark:text-dark-900">
|
||||
{t`Unlimited boards, unlimited lists, unlimited cards. No credit card required.`}
|
||||
</p>
|
||||
<Link href="/signup">
|
||||
|
||||
@@ -146,7 +146,7 @@ const Faqs = () => {
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Questions?`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
<Trans>
|
||||
Find answers to common questions about the project. Can't find what
|
||||
you're looking for? Feel free to{" "}
|
||||
|
||||
@@ -28,7 +28,6 @@ const FeatureItem = ({
|
||||
description: string;
|
||||
icon: Record<string, unknown>;
|
||||
comingSoon?: boolean;
|
||||
new?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
@@ -67,12 +66,6 @@ const FeatureItem = ({
|
||||
{t`Coming soon`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{feature.new && (
|
||||
<div className="absolute right-4 top-4 rounded-full border border-light-300 px-2 py-1 text-[10px] text-light-1000 dark:border-dark-600 dark:bg-dark-50 dark:text-dark-900">
|
||||
{t`New`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -115,7 +108,7 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
title: t`Templates`,
|
||||
description: t`Save time with reusable board templates.`,
|
||||
icon: isDark ? templatesIconDark : templatesIconLight,
|
||||
new: true,
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
title: t`Integrations`,
|
||||
@@ -135,7 +128,7 @@ const Features = ({ theme }: { theme: "light" | "dark" }) => {
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Kanban reimagined`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
{t`Simple, visual task management that just works. Drag and drop cards, collaborate with your team, and get more done.`}
|
||||
</p>
|
||||
<div className="mx-auto mt-16 w-full max-w-7xl">
|
||||
|
||||
@@ -11,8 +11,8 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
|
||||
const menuItems = [
|
||||
{ label: t`Roadmap`, href: "/kan/roadmap", openInNewTab: true },
|
||||
{ label: t`Features`, href: "/#features" },
|
||||
{ label: t`Pricing`, href: "/pricing" },
|
||||
{ label: t`Features`, href: "#features" },
|
||||
{ label: t`Pricing`, href: "#pricing" },
|
||||
{ label: t`Docs`, href: "https://docs.kan.bn", openInNewTab: true },
|
||||
];
|
||||
|
||||
|
||||
@@ -85,17 +85,19 @@ const Pricing = () => {
|
||||
<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">
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Simple pricing`}
|
||||
</p>
|
||||
<p className="text:md lg:text-md mt-6 max-w-[500px] text-center text-dark-900">
|
||||
<p className="text:md lg:text-md mt-3 max-w-[500px] text-center text-dark-900">
|
||||
{t`Get started for free, with no usage limits. For collaboration, upgrade to a plan that fits the size of your team.`}
|
||||
</p>
|
||||
|
||||
<div className="mt-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">
|
||||
<div className="mt-12 flex flex-col items-center justify-center">
|
||||
<div className="mb-4 flex items-center gap-2 rounded-full border bg-white px-4 py-1.5 text-center text-xs font-bold text-gray-800 dark:border-dark-300 dark:bg-white dark:text-gray-800 lg:text-sm">
|
||||
<HiBolt />
|
||||
<p>{t`Launch offer: unlimited seats for just $29/month with Pro`}</p>
|
||||
<p>
|
||||
{t`Launch offer: unlimited seats for just $29/month with Pro`}
|
||||
</p>
|
||||
</div>
|
||||
<fieldset aria-label={t`Payment frequency`}>
|
||||
<RadioGroup
|
||||
@@ -162,7 +164,7 @@ const Pricing = () => {
|
||||
<p className="mt-6 flex items-baseline gap-x-1">
|
||||
<span
|
||||
className={twMerge(
|
||||
"text-3xl font-semibold tracking-tight text-light-100",
|
||||
"text-4xl font-semibold tracking-tight text-light-100",
|
||||
tier.highlighted
|
||||
? "text-light-50 dark:text-dark-1000"
|
||||
: "text-gray-900 dark:text-dark-1000",
|
||||
@@ -2,19 +2,22 @@ import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useTheme } from "next-themes";
|
||||
import { IoLogoGithub } from "react-icons/io";
|
||||
import { IoLogoGithub, IoLogoHackernews } from "react-icons/io";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { PageHead } from "~/components/PageHead";
|
||||
import { useTheme } from "next-themes";
|
||||
import Cta from "./components/Cta";
|
||||
import FAQs from "./components/Faqs";
|
||||
import Features from "./components/Features";
|
||||
import Layout from "./components/Layout";
|
||||
import Pricing from "./components/Pricing";
|
||||
|
||||
export default function HomeView() {
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<PageHead title="Kan.bn | The open source alternative to Trello" />
|
||||
@@ -65,7 +68,7 @@ export default function HomeView() {
|
||||
</Trans>
|
||||
</p>
|
||||
|
||||
<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">
|
||||
<p className="text-md mt-3 max-w-[450px] text-center text-dark-900 lg:max-w-[600px] lg:text-lg">
|
||||
{t`A powerful, flexible kanban app that helps you organise work, track progress, and deliver results—all in one place.`}
|
||||
</p>
|
||||
|
||||
@@ -79,7 +82,7 @@ export default function HomeView() {
|
||||
{t`Self host with Github`}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-4 text-center text-sm text-light-950 dark:text-dark-900">
|
||||
<p className="mt-4 text-center text-sm text-dark-900">
|
||||
{t`No credit card required`}
|
||||
</p>
|
||||
</div>
|
||||
@@ -108,6 +111,10 @@ export default function HomeView() {
|
||||
<div id="features" className="absolute -top-20" />
|
||||
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} />
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="pricing" className="absolute -top-20" />
|
||||
<Pricing />
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="faq" className="absolute -top-20" />
|
||||
<FAQs />
|
||||
|
||||
@@ -1,297 +0,0 @@
|
||||
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;
|
||||
@@ -1,211 +0,0 @@
|
||||
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;
|
||||
@@ -1,85 +0,0 @@
|
||||
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,12 +25,7 @@ export const boardRouter = createTRPCRouter({
|
||||
protect: true,
|
||||
},
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
workspacePublicId: z.string().min(12),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
}),
|
||||
)
|
||||
.input(z.object({ workspacePublicId: z.string().min(12) }))
|
||||
.output(
|
||||
z.custom<Awaited<ReturnType<typeof boardRepo.getAllByWorkspaceId>>>(),
|
||||
)
|
||||
@@ -56,9 +51,7 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id, {
|
||||
type: input.type,
|
||||
});
|
||||
const result = boardRepo.getAllByWorkspaceId(ctx.db, workspace.id);
|
||||
|
||||
return result;
|
||||
}),
|
||||
@@ -78,7 +71,6 @@ export const boardRouter = createTRPCRouter({
|
||||
boardPublicId: z.string().min(12),
|
||||
members: z.array(z.string().min(12)).optional(),
|
||||
labels: z.array(z.string().min(12)).optional(),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.getByPublicId>>>())
|
||||
@@ -110,7 +102,6 @@ export const boardRouter = createTRPCRouter({
|
||||
{
|
||||
members: input.members ?? [],
|
||||
labels: input.labels ?? [],
|
||||
type: input.type,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -186,8 +177,6 @@ export const boardRouter = createTRPCRouter({
|
||||
workspacePublicId: z.string().min(12),
|
||||
lists: z.array(z.string().min(1)),
|
||||
labels: z.array(z.string().min(1)),
|
||||
type: z.enum(["regular", "template"]).optional(),
|
||||
sourceBoardPublicId: z.string().min(12).optional(),
|
||||
}),
|
||||
)
|
||||
.output(z.custom<Awaited<ReturnType<typeof boardRepo.create>>>())
|
||||
@@ -213,79 +202,6 @@ export const boardRouter = createTRPCRouter({
|
||||
|
||||
await assertUserInWorkspace(ctx.db, userId, workspace.id);
|
||||
|
||||
// If sourceBoardPublicId is provided, clone the source board
|
||||
if (input.sourceBoardPublicId) {
|
||||
// First get the source board info (ID and type)
|
||||
const sourceBoardInfo = await boardRepo.getIdByPublicId(
|
||||
ctx.db,
|
||||
input.sourceBoardPublicId,
|
||||
);
|
||||
|
||||
if (!sourceBoardInfo)
|
||||
throw new TRPCError({
|
||||
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Get the full board data with the correct type
|
||||
const sourceBoard = await boardRepo.getByPublicId(
|
||||
ctx.db,
|
||||
input.sourceBoardPublicId,
|
||||
{
|
||||
members: [],
|
||||
labels: [],
|
||||
type: sourceBoardInfo.type,
|
||||
},
|
||||
);
|
||||
|
||||
if (!sourceBoard)
|
||||
throw new TRPCError({
|
||||
message: `Source board with public ID ${input.sourceBoardPublicId} not found`,
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
|
||||
// Verify the source board belongs to the same workspace
|
||||
const sourceWorkspace = await workspaceRepo.getByPublicId(
|
||||
ctx.db,
|
||||
sourceBoard.workspace.publicId,
|
||||
);
|
||||
|
||||
if (!sourceWorkspace || sourceWorkspace.id !== workspace.id)
|
||||
throw new TRPCError({
|
||||
message: `Source board does not belong to this workspace`,
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
let slug = generateSlug(input.name);
|
||||
|
||||
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
||||
slug,
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isSlugUnique || input.type === "template")
|
||||
slug = `${slug}-${generateUID()}`;
|
||||
|
||||
const result = await boardRepo.createFromSnapshot(ctx.db, {
|
||||
source: sourceBoard,
|
||||
workspaceId: workspace.id,
|
||||
createdBy: userId,
|
||||
slug,
|
||||
name: input.name,
|
||||
type: input.type ?? "regular",
|
||||
sourceBoardId: sourceBoardInfo.id,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new TRPCError({
|
||||
message: `Failed to create board from source`,
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Otherwise, create a new board with provided lists and labels
|
||||
let slug = generateSlug(input.name);
|
||||
|
||||
const isSlugUnique = await boardRepo.isSlugUnique(ctx.db, {
|
||||
@@ -293,8 +209,7 @@ export const boardRouter = createTRPCRouter({
|
||||
workspaceId: workspace.id,
|
||||
});
|
||||
|
||||
if (!isSlugUnique || input.type === "template")
|
||||
slug = `${slug}-${generateUID()}`;
|
||||
if (!isSlugUnique) slug = `${slug}-${generateUID()}`;
|
||||
|
||||
const result = await boardRepo.create(ctx.db, {
|
||||
publicId: generateUID(),
|
||||
@@ -302,7 +217,6 @@ export const boardRouter = createTRPCRouter({
|
||||
name: input.name,
|
||||
createdBy: userId,
|
||||
workspaceId: workspace.id,
|
||||
type: input.type,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
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");
|
||||
@@ -1,6 +0,0 @@
|
||||
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 $$;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -120,20 +120,6 @@
|
||||
"when": 1759356096392,
|
||||
"tag": "20251001220136_AddFuzzySearchSupport",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 17,
|
||||
"version": "7",
|
||||
"when": 1759869689304,
|
||||
"tag": "20251007204129_AddBoardTypeAndSourceIdColumns",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1760044396109,
|
||||
"tag": "20251009211316_AddBoardSourceIdToCardActivity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import type { dbClient } from "@kan/db/client";
|
||||
import type { BoardVisibilityStatus } from "@kan/db/schema";
|
||||
import {
|
||||
boards,
|
||||
cardActivities,
|
||||
cards,
|
||||
cardsToLabels,
|
||||
cardToWorkspaceMembers,
|
||||
@@ -16,38 +15,13 @@ import {
|
||||
} from "@kan/db/schema";
|
||||
import { generateUID } from "@kan/shared/utils";
|
||||
|
||||
export const getAllByWorkspaceId = (
|
||||
db: dbClient,
|
||||
workspaceId: number,
|
||||
opts?: { type?: "regular" | "template" },
|
||||
) => {
|
||||
export const getAllByWorkspaceId = (db: dbClient, workspaceId: number) => {
|
||||
return db.query.boards.findMany({
|
||||
columns: {
|
||||
publicId: true,
|
||||
name: true,
|
||||
},
|
||||
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,
|
||||
),
|
||||
where: and(eq(boards.workspaceId, workspaceId), isNull(boards.deletedAt)),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -55,7 +29,6 @@ export const getIdByPublicId = async (db: dbClient, boardPublicId: string) => {
|
||||
const board = await db.query.boards.findFirst({
|
||||
columns: {
|
||||
id: true,
|
||||
type: true,
|
||||
},
|
||||
where: eq(boards.publicId, boardPublicId),
|
||||
});
|
||||
@@ -69,7 +42,6 @@ export const getByPublicId = async (
|
||||
filters: {
|
||||
members: string[];
|
||||
labels: string[];
|
||||
type: "regular" | "template" | undefined;
|
||||
},
|
||||
) => {
|
||||
let cardIds: string[] = [];
|
||||
@@ -227,11 +199,7 @@ export const getByPublicId = async (
|
||||
orderBy: [asc(lists.index)],
|
||||
},
|
||||
},
|
||||
where: and(
|
||||
eq(boards.publicId, boardPublicId),
|
||||
isNull(boards.deletedAt),
|
||||
eq(boards.type, filters.type ?? "regular"),
|
||||
),
|
||||
where: and(eq(boards.publicId, boardPublicId), isNull(boards.deletedAt)),
|
||||
});
|
||||
|
||||
if (!board) return null;
|
||||
@@ -444,8 +412,6 @@ export const create = async (
|
||||
workspaceId: number;
|
||||
importId?: number;
|
||||
slug: string;
|
||||
type?: "regular" | "template";
|
||||
sourceBoardId?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -457,8 +423,6 @@ export const create = async (
|
||||
workspaceId: boardInput.workspaceId,
|
||||
importId: boardInput.importId,
|
||||
slug: boardInput.slug,
|
||||
type: boardInput.type ?? "regular",
|
||||
sourceBoardId: boardInput.sourceBoardId,
|
||||
})
|
||||
.returning({
|
||||
id: boards.id,
|
||||
@@ -578,229 +542,3 @@ export const isBoardSlugAvailable = async (
|
||||
|
||||
return result === undefined;
|
||||
};
|
||||
|
||||
// Create a new board (regular/template) from a full board snapshot
|
||||
export const createFromSnapshot = async (
|
||||
db: dbClient,
|
||||
args: {
|
||||
source: {
|
||||
name: string;
|
||||
labels: { publicId: string; name: string; colourCode: string | null }[];
|
||||
lists: {
|
||||
name: string;
|
||||
index: number;
|
||||
cards: {
|
||||
title: string;
|
||||
description: string | null;
|
||||
index: number;
|
||||
labels: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
colourCode: string | null;
|
||||
}[];
|
||||
checklists?: {
|
||||
publicId: string;
|
||||
name: string;
|
||||
index: number;
|
||||
items: {
|
||||
publicId: string;
|
||||
title: string;
|
||||
completed: boolean;
|
||||
index: number;
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
workspaceId: number;
|
||||
createdBy: string;
|
||||
slug: string;
|
||||
name?: string;
|
||||
type: "regular" | "template";
|
||||
sourceBoardId?: number;
|
||||
},
|
||||
) => {
|
||||
return db.transaction(async (tx) => {
|
||||
const [newBoard] = await tx
|
||||
.insert(boards)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
name: args.name ?? args.source.name,
|
||||
slug: args.slug,
|
||||
createdBy: args.createdBy,
|
||||
workspaceId: args.workspaceId,
|
||||
type: args.type,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
})
|
||||
.returning({
|
||||
id: boards.id,
|
||||
publicId: boards.publicId,
|
||||
name: boards.name,
|
||||
});
|
||||
|
||||
if (!newBoard) throw new Error("Failed to create board");
|
||||
|
||||
// Labels
|
||||
const srcLabels = args.source.labels;
|
||||
const labelMap = new Map<string, number>();
|
||||
|
||||
if (srcLabels.length) {
|
||||
const inserted = await tx
|
||||
.insert(labels)
|
||||
.values(
|
||||
srcLabels.map((l) => ({
|
||||
publicId: generateUID(),
|
||||
name: l.name,
|
||||
colourCode: l.colourCode ?? null,
|
||||
createdBy: args.createdBy,
|
||||
boardId: newBoard.id,
|
||||
})),
|
||||
)
|
||||
.returning({ id: labels.id });
|
||||
|
||||
for (let i = 0; i < srcLabels.length; i++) {
|
||||
const src = srcLabels[i];
|
||||
|
||||
if (!src) throw new Error("Source label not found");
|
||||
|
||||
const created = inserted[i];
|
||||
if (created) labelMap.set(src.publicId, created.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Lists
|
||||
const listIndexToId = new Map<number, number>();
|
||||
const srcLists = [...args.source.lists].sort((a, b) => a.index - b.index);
|
||||
if (srcLists.length) {
|
||||
const insertedLists = await tx
|
||||
.insert(lists)
|
||||
.values(
|
||||
srcLists.map((list) => ({
|
||||
publicId: generateUID(),
|
||||
name: list.name,
|
||||
createdBy: args.createdBy,
|
||||
boardId: newBoard.id,
|
||||
index: list.index,
|
||||
})),
|
||||
)
|
||||
.returning({ id: lists.id, index: lists.index });
|
||||
|
||||
for (const list of insertedLists) listIndexToId.set(list.index, list.id);
|
||||
}
|
||||
|
||||
// Cards, card-labels, checklists
|
||||
for (const list of srcLists) {
|
||||
const newListId = listIndexToId.get(list.index);
|
||||
if (!newListId) continue;
|
||||
const sortedCards = [...list.cards].sort((a, b) => a.index - b.index);
|
||||
|
||||
for (const card of sortedCards) {
|
||||
const [createdCard] = await tx
|
||||
.insert(cards)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
title: card.title,
|
||||
description: card.description ?? "",
|
||||
createdBy: args.createdBy,
|
||||
listId: newListId,
|
||||
index: card.index,
|
||||
})
|
||||
.returning({ id: cards.id });
|
||||
|
||||
if (!createdCard) throw new Error("Failed to create card");
|
||||
|
||||
// Create card.created activity
|
||||
await tx.insert(cardActivities).values({
|
||||
publicId: generateUID(),
|
||||
type: "card.created",
|
||||
cardId: createdCard.id,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
});
|
||||
|
||||
if (card.labels.length) {
|
||||
const cardLabels: { cardId: number; labelId: number }[] = [];
|
||||
for (const label of card.labels) {
|
||||
const newLabelId = labelMap.get(label.publicId);
|
||||
if (newLabelId)
|
||||
cardLabels.push({ cardId: createdCard.id, labelId: newLabelId });
|
||||
}
|
||||
if (cardLabels.length) {
|
||||
await tx.insert(cardsToLabels).values(cardLabels);
|
||||
|
||||
// Create card.updated.label.added activities for each label
|
||||
const labelActivities = cardLabels.map((cardLabel) => ({
|
||||
publicId: generateUID(),
|
||||
type: "card.updated.label.added" as const,
|
||||
cardId: cardLabel.cardId,
|
||||
labelId: cardLabel.labelId,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
}));
|
||||
await tx.insert(cardActivities).values(labelActivities);
|
||||
}
|
||||
}
|
||||
|
||||
if (card.checklists?.length) {
|
||||
const sortedChecklists = [...card.checklists].sort(
|
||||
(a, b) => a.index - b.index,
|
||||
);
|
||||
for (const checklist of sortedChecklists) {
|
||||
const [createdChecklist] = await tx
|
||||
.insert(checklists)
|
||||
.values({
|
||||
publicId: generateUID(),
|
||||
name: checklist.name,
|
||||
createdBy: args.createdBy,
|
||||
cardId: createdCard.id,
|
||||
index: checklist.index,
|
||||
})
|
||||
.returning({ id: checklists.id });
|
||||
|
||||
if (!createdChecklist) continue;
|
||||
|
||||
// Create card.updated.checklist.added activity
|
||||
await tx.insert(cardActivities).values({
|
||||
publicId: generateUID(),
|
||||
type: "card.updated.checklist.added",
|
||||
cardId: createdCard.id,
|
||||
toTitle: checklist.name,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
});
|
||||
|
||||
if (checklist.items.length) {
|
||||
const itemValues = [...checklist.items]
|
||||
.sort((a, b) => a.index - b.index)
|
||||
.map((checklistItem) => ({
|
||||
publicId: generateUID(),
|
||||
title: checklistItem.title,
|
||||
createdBy: args.createdBy,
|
||||
checklistId: createdChecklist.id,
|
||||
index: checklistItem.index,
|
||||
completed: !!checklistItem.completed,
|
||||
}));
|
||||
|
||||
if (itemValues.length) {
|
||||
await tx.insert(checklistItems).values(itemValues);
|
||||
|
||||
// Create card.updated.checklist.item.added activities for each item
|
||||
const itemActivities = itemValues.map((item) => ({
|
||||
publicId: generateUID(),
|
||||
type: "card.updated.checklist.item.added" as const,
|
||||
cardId: createdCard.id,
|
||||
toTitle: item.title,
|
||||
createdBy: args.createdBy,
|
||||
sourceBoardId: args.sourceBoardId,
|
||||
}));
|
||||
await tx.insert(cardActivities).values(itemActivities);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newBoard;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -22,7 +22,6 @@ export const create = async (
|
||||
commentId?: number;
|
||||
fromComment?: string;
|
||||
toComment?: string;
|
||||
sourceBoardId?: number;
|
||||
},
|
||||
) => {
|
||||
const [result] = await db
|
||||
@@ -45,7 +44,6 @@ export const create = async (
|
||||
commentId: activityInput.commentId,
|
||||
fromComment: activityInput.fromComment,
|
||||
toComment: activityInput.toComment,
|
||||
sourceBoardId: activityInput.sourceBoardId,
|
||||
})
|
||||
.returning({ id: cardActivities.id });
|
||||
|
||||
@@ -68,7 +66,6 @@ export const bulkCreate = async (
|
||||
fromDescription?: string;
|
||||
toDescription?: string;
|
||||
createdBy: string;
|
||||
sourceBoardId?: number;
|
||||
}[],
|
||||
) => {
|
||||
const activitiesWithPublicIds = activityInputs.map((activity) => ({
|
||||
|
||||
@@ -25,10 +25,6 @@ export const boardVisibilityEnum = pgEnum(
|
||||
boardVisibilityStatuses,
|
||||
);
|
||||
|
||||
export const boardTypes = ["regular", "template"] as const;
|
||||
export type BoardType = (typeof boardTypes)[number];
|
||||
export const boardTypeEnum = pgEnum("board_type", boardTypes);
|
||||
|
||||
export const boards = pgTable(
|
||||
"board",
|
||||
{
|
||||
@@ -53,13 +49,9 @@ export const boards = pgTable(
|
||||
.notNull()
|
||||
.references(() => workspaces.id, { onDelete: "cascade" }),
|
||||
visibility: boardVisibilityEnum("visibility").notNull().default("private"),
|
||||
type: boardTypeEnum("type").notNull().default("regular"),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }),
|
||||
},
|
||||
(table) => [
|
||||
index("board_visibility_idx").on(table.visibility),
|
||||
index("board_type_idx").on(table.type),
|
||||
index("board_source_idx").on(table.sourceBoardId),
|
||||
uniqueIndex("unique_slug_per_workspace")
|
||||
.on(table.workspaceId, table.slug)
|
||||
.where(sql`${table.deletedAt} IS NULL`),
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
|
||||
import { boards } from "./boards";
|
||||
import { checklists } from "./checklists";
|
||||
import { imports } from "./imports";
|
||||
import { labels } from "./labels";
|
||||
@@ -134,10 +133,6 @@ export const cardActivities = pgTable("card_activity", {
|
||||
),
|
||||
fromComment: text("fromComment"),
|
||||
toComment: text("toComment"),
|
||||
sourceBoardId: bigint("sourceBoardId", { mode: "number" }).references(
|
||||
() => boards.id,
|
||||
{ onDelete: "set null" },
|
||||
),
|
||||
}).enableRLS();
|
||||
|
||||
export const cardActivitiesRelations = relations(cardActivities, ({ one }) => ({
|
||||
|
||||
@@ -21,13 +21,13 @@ export interface Subscription {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = ["active", "trialing", "past_due"];
|
||||
|
||||
export const getActiveSubscriptions = (
|
||||
subscriptions: Subscription[] | undefined,
|
||||
) => {
|
||||
if (!subscriptions) return [];
|
||||
return subscriptions.filter((sub) => ACTIVE_STATUSES.includes(sub.status));
|
||||
return subscriptions.filter(
|
||||
(sub) => sub.status === "active" || sub.status === "trialing",
|
||||
);
|
||||
};
|
||||
|
||||
export const getSubscriptionByPlan = (
|
||||
@@ -36,7 +36,9 @@ export const getSubscriptionByPlan = (
|
||||
) => {
|
||||
if (!subscriptions) return undefined;
|
||||
return subscriptions.find(
|
||||
(sub) => sub.plan === plan && ACTIVE_STATUSES.includes(sub.status),
|
||||
(sub) =>
|
||||
sub.plan === plan &&
|
||||
(sub.status === "active" || sub.status === "trialing"),
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user