Compare commits
10 Commits
fix/openap
...
feat/invit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b00c47952c | ||
|
|
aa931ddcbe | ||
|
|
73b9ac7cb5 | ||
|
|
01e0140e6a | ||
|
|
f22a0332ab | ||
|
|
bd97362d90 | ||
|
|
3b3b4f89ba | ||
|
|
17f876160d | ||
|
|
dd1bd9b7cc | ||
|
|
7d9746be20 |
@@ -1,6 +1,6 @@
|
||||
# https://github.com/kanbn/kan?tab=readme-ov-file#environment-variables-)
|
||||
|
||||
# Required environment variables
|
||||
# Required environment variables
|
||||
NEXT_PUBLIC_BASE_URL= # e.g. https://kan.bn
|
||||
BETTER_AUTH_SECRET= # Random 32+ char string (can gen with: openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
|
||||
@@ -18,9 +18,6 @@ SMTP_PASSWORD=
|
||||
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
|
||||
SMTP_SECURE= # set to "false" to use port 587
|
||||
|
||||
# Switch email features off entirely (optional)
|
||||
NEXT_PUBLIC_DISABLE_EMAIL=
|
||||
|
||||
# S3 storage (optional)
|
||||
S3_REGION=
|
||||
S3_ENDPOINT=
|
||||
@@ -29,7 +26,6 @@ S3_SECRET_ACCESS_KEY=
|
||||
S3_FORCE_PATH_STYLE=
|
||||
NEXT_PUBLIC_STORAGE_URL=
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME=
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME=
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN=
|
||||
|
||||
# Auth config (optional)
|
||||
|
||||
1
.github/funding.yml
vendored
@@ -1 +0,0 @@
|
||||
github: [kanbn]
|
||||
@@ -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.
|
||||
@@ -147,7 +147,6 @@ pnpm dev
|
||||
| `SMTP_USER` | SMTP username/email | No | `resend` |
|
||||
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
|
||||
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_EMAIL` | To disable all email features | For Email | `true` |
|
||||
| `NEXT_PUBLIC_BASE_URL` | Base URL of your installation | Yes | `http://localhost:3000` |
|
||||
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
|
||||
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
|
||||
@@ -170,7 +169,6 @@ pnpm dev
|
||||
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
|
||||
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
|
||||
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
|
||||
| `NEXT_PUBLIC_ATTATCHMENTS_BUCKET_NAME` | S3 bucket name for attatchments | For file uploads | `attatchments` |
|
||||
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
|
||||
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
|
||||
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
|
||||
|
||||
@@ -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,20 +3,16 @@ 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
|
||||
1%20user/singular: 3b547431ab12f0fba84307e6a81109d8
|
||||
14%20day%20free%20trial/singular: b2f7a80b5184d79ddd56b78d57d28225
|
||||
A%20powerful%2C%20flexible%20kanban%20app%20that%20helps%20you%20organise%20work%2C%20track%20progress%2C%20and%20deliver%20results%E2%80%94all%20in%20one%20place./singular: d405b83b0d631cb72f4347c10bcbb643
|
||||
Account/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
|
||||
@@ -52,15 +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
|
||||
Attachment%20uploaded/singular: c0233f8c2560f95ac0834f35eb07b9e7
|
||||
Awaiting%20Customer/singular: 122c108794f321c10e05a08f09b11457
|
||||
Backlog/singular: 35919fd5b10ad212839a75e72829fe4e
|
||||
Basic%20Kanban/singular: e46ebc406749486f3907436970d4d6c7
|
||||
@@ -74,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
|
||||
@@ -82,19 +74,17 @@ 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
|
||||
Cancel/singular: 2e2a849c2223911717de8caa2c71bade
|
||||
Card/singular: bba0beaced7ea954ceb980f2b022ffee
|
||||
Card%20not%20found/singular: 91509e2f92b0b3b11330b6983139fdbf
|
||||
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
|
||||
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
|
||||
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
|
||||
Check%20out%20some%20of%20our%20favorite%20open%20source%20projects./singular: b0d0be4f63c16552b3aa795af29240d1
|
||||
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
|
||||
@@ -102,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
|
||||
@@ -119,41 +107,33 @@ 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
|
||||
Deactivate%20invite%20link/singular: 8cf4bf82e153e4dc82d026448cb4dd10
|
||||
Delete/singular: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
Delete%20account/singular: a9d11113f1a1d7e20582bdcc9633bcef
|
||||
Delete%20board/singular: e915cd160651ad4b61a67e8da0737c23
|
||||
Delete%20card/singular: 18811ccf76fbb7a2772d648f7c5f0ea7
|
||||
Delete%20comment/singular: a3dae0c9ddbc73677aad9812e3dffd69
|
||||
Delete%20list/singular: b694f58e4c973d8797a78e746b550b1f
|
||||
Delete%20template/singular: bdf3e8342f581d498b7238cdd4897a4b
|
||||
Delete%20workspace/singular: 9017c821209528d9b20d40a488854cdf
|
||||
deleted%20a%20checklist/singular: c54f922dc6c238c9141c700cb3180baf
|
||||
deleted%20a%20checklist%20item/singular: 2b7a861600102ac5c271dd25c1df261a
|
||||
@@ -172,7 +152,6 @@ checksums:
|
||||
Documentation/singular: 1563fcb5ddb5037b0709ccd3dd384a92
|
||||
Don't%20have%20an%20account%3F%20%3C0%3E%3C1%3ESign%20up%3C%2F1%3E%3C%2F0%3E/singular: 6ae8282ab8b4a01b4ce87b119e13714b
|
||||
Done/singular: ffd408fa29d5bc9039ef8ea1b9b699bb
|
||||
Download%20failed/singular: 142b391cca3e05f2af9617092f7358c5
|
||||
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
|
||||
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
|
||||
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
|
||||
@@ -209,21 +188,16 @@ 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
|
||||
Failed%20to%20upload%20attachment.%20Please%20try%20again./singular: f8a50d1c8491404f73d3cf701e11f297
|
||||
FAQ/singular: 47e0ee2eb40b4e7e732e05e2233fc71c
|
||||
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
|
||||
Feedback%20sent/singular: 38a490c7ad5f1aa6013dfa879d2c7818
|
||||
Filter/singular: 626325a05e4c8800f7ede7012b0cadaf
|
||||
Find%20answers%20to%20common%20questions%20about%20Kan.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: b4c05c9174637f4da2a41ead0d65bc01
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 9fa1c942feb40d6350322b71561df789
|
||||
Find%20answers%20to%20common%20questions%20about%20the%20project.%20Can't%20find%20what%20you're%20looking%20for%3F%20Feel%20free%20to%20%3C0%3Econtact%20us%3C%2F0%3E./singular: 4d99230dad703bf2d71ba670c2f79d3d
|
||||
For%20long-term%20sustainability%2C%20we%20recognise%20all%20good%20open%20source%20projects%20need%20a%20source%20of%20revenue.%20Ours%20comes%20from%20the%20paid%20cloud%20offering%20for%20workspaces%20with%20multiple%20users%20(free%20during%20the%20beta)%20and%20for%20custom%20workspace%20slugs.%20There's%20no%20obligation%20to%20use%20it%2C%20and%20you%20can%20self-host%20the%20software%20on%20your%20own%20infrastructure%20free%20of%20charge./singular: 4eedacd4311ff69bf0fd87083c90a268
|
||||
Free/singular: 0326365539c004f6088656f692602078
|
||||
Free%20Plan/singular: c995acccc1e3cba379786892a5b4d4e5
|
||||
Free%2C%20forever/singular: ae4f2f81e88a50b5f250ef8e8e77e74b
|
||||
@@ -231,7 +205,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
|
||||
@@ -255,42 +229,33 @@ checksums:
|
||||
Import/singular: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
Import%20complete/singular: 7e749df6f068b9cdf1055ea38c9a80af
|
||||
Import%20failed/singular: bcec1f271c82ddffc91ebc22021b03cb
|
||||
Import%20from%20Trello/singular: 9cce591a63763174952ffc11ae829f0c
|
||||
Import%20your%20Trello%20boards%20and%20hit%20the%20ground%20running./singular: d66d13649f81342f8b3a4f3b54370c20
|
||||
Important/singular: 4cf0e8fc8e4e7c5c9b9458059a172a2b
|
||||
Importing%20from%20Trello/singular: 1e3a46fb9bd8ce8c7d632f9555b5bd7c
|
||||
Importing%20your%20Trello%20boards%20into%20Kan%20is%20easy.%20You%20can%20follow%20our%20step-by-step%20guide%20%3C0%3Ehere%3C%2F0%3E./singular: ca88dc22801fef08fee1ecfb1bf6914e
|
||||
in/singular: 1f06ad6763244d5e9927853e65cc6274
|
||||
In%20Progress/singular: 3de9afebcb9d4ce8ac42e14995f79ffd
|
||||
Individuals/singular: 83bfc81f20e60556c44ec80c7d0603ec
|
||||
Integrations/singular: 0ccce343287704cd90150c32e2fcad36
|
||||
Intelligent%20search/singular: da688e7cfb2775f719a12df6dd606f6e
|
||||
Interviewing/singular: 4ccdcdc784547e925077c3297bddee95
|
||||
Invalid%20email%20address/singular: b2d9f25626f2d15c7c63e0281bccc247
|
||||
Invalid%20invitation/singular: 4b936a8811a2295a5f58b41473c608f3
|
||||
Invite/singular: 181884cea804cbde665f160811ee7ad0
|
||||
Invite%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
|
||||
Launch%20offer%3A%20Get%20unlimited%20members%20with%20Pro/singular: 8c1be80f5b2fcc112922282afbe30287
|
||||
Launch%20offer%3A%20unlimited%20seats%20for%20just%20%2429%2Fmonth%20with%20Pro/singular: 235d1cda21811c296afdc0dcc438cc24
|
||||
Learn%20more/singular: e598091d132f890c37a6d4ed94f6d794
|
||||
Learning/singular: 90e91da3f870f065f2da9aa9adf53514
|
||||
Legal/singular: 00d3f08da61a21887009819cf93fb414
|
||||
License/singular: ac2bcac75151e2fcd9eb768cea275a77
|
||||
@@ -298,14 +263,11 @@ checksums:
|
||||
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
|
||||
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
|
||||
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
|
||||
Lists/singular: 9f4a73afc8de321175d71935134ef066
|
||||
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
|
||||
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
|
||||
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
|
||||
Loved%20by%20teams%20worldwide/singular: 41de5089fef56bb0bfcf7f8649a0cb93
|
||||
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
|
||||
@@ -318,8 +280,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
|
||||
@@ -327,33 +289,23 @@ 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%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
|
||||
No%20boards/singular: e4397619e69b105e4317dda4560ff4a6
|
||||
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
|
||||
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
|
||||
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
|
||||
No%20lists/singular: cedf633d99c77ff4356e089f2d98c0a6
|
||||
No%20results%20found%20for%20%22%7BdebouncedQuery%7D%22./singular: 5db6294712528cd897b15ae36f4fd834
|
||||
Offer/singular: 82b4e0c9a3f5b4bd93590847de7c32a1
|
||||
Onboarding/singular: 52b23f9c62ff199d4c09920e7641829e
|
||||
Once%20you%20delete%20your%20account%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: 9cf7aa6ef30890e5124e266c081bae1c
|
||||
Once%20you%20delete%20your%20workspace%2C%20there%20is%20no%20going%20back.%20This%20action%20cannot%20be%20undone./singular: c9ce6a516ed1f361653e8f8db3dd5ffd
|
||||
Open%20source/singular: 81a747dc664de1a3389b495c8f84843b
|
||||
Open%20Source%20Friends/singular: ffe3d75e5d03b8469c9dcb019dceb164
|
||||
or/singular: 7b133c38bec0d5ee23cc6bcf9a8de50b
|
||||
Organize%20and%20find%20cards%20quickly%20with%20powerful%20filtering%20tools./singular: 1b9898c4b21e9dff413b4f76dc59db56
|
||||
OSS%20Friends/singular: 706e10666dfe26130c17fedb5366a25d
|
||||
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
|
||||
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
|
||||
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
|
||||
Password%20Changed/singular: 1fcebe9ddb46f722a57f195efddc695d
|
||||
Password%20is%20required%20to%20login./singular: a09c76294ca7b27b38658b34618a7119
|
||||
Password%20is%20required%20to%20sign%20up./singular: 3fa1abbf2e6ac8ac4d18c215191bdbcc
|
||||
Password%20must%20be%20at%20least%208%20characters/singular: 4c30501d085eaccea47af34212bb26a7
|
||||
Passwords%20do%20not%20match/singular: 37ca1f4e0afc9a0b8e9617f767103c92
|
||||
Paused/singular: edb1f7b7219e1c9b7aa67159090d6991
|
||||
@@ -362,7 +314,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
|
||||
@@ -370,7 +321,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
|
||||
@@ -397,7 +347,6 @@ checksums:
|
||||
Research/singular: 3368e9638d1619babd6df9fad592274f
|
||||
Resolution/singular: 6d8bd9e1bd7dae5ae38c93061d32990e
|
||||
Resources/singular: ec7fb05ed963bb6781a35782b3475502
|
||||
REST%20API/singular: 54c9f8d98f45f50399b6b93ba70af0d6
|
||||
Review/singular: 299f75db25382980b2895622d7712927
|
||||
Roadmap/singular: c60f4a1acf30e566861bf130f13b9ae7
|
||||
Role/singular: 53743bbb6ca938f5b893552e839d067f
|
||||
@@ -405,14 +354,11 @@ checksums:
|
||||
Save/singular: f7a2929f33bc420195e59ac5a8bcd454
|
||||
Save%20time%20with%20reusable%20board%20templates./singular: d0f2d7d0fd682ceaf4ca12c6353fd75a
|
||||
Screening/singular: 0327bfb25db87bdacf00d4dc297f8b26
|
||||
Search%20boards%20and%20cards.../singular: 137017b2b3e885c4894ca8c899af5bc2
|
||||
See%20what%20our%20users%20are%20saying%20about%20Kan/singular: e3bf6f95419169f460fb6918a0afc512
|
||||
Select%20all/singular: eedc7cdb02de467c15dc418a066a77f2
|
||||
Select%20source/singular: c7b0ec447415ff62856803a4d7838bd6
|
||||
Self%20Host/singular: 43fbce2027548b712002e489a20a621a
|
||||
Self%20host%20with%20Github/singular: 972155419bcc9f8f9bad9f5ad3538774
|
||||
self-assigned%20the%20card/singular: 24a8d97bcd747643772ebe883be69698
|
||||
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
|
||||
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
|
||||
Senior/singular: 3fff865dc00435f82896fc302ea45630
|
||||
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
|
||||
@@ -421,6 +367,7 @@ checksums:
|
||||
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
|
||||
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
|
||||
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
|
||||
Share%20invite%20link/singular: ec5081a1f4e49fd9d782e770438f703b
|
||||
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
|
||||
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
|
||||
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
|
||||
@@ -433,8 +380,6 @@ checksums:
|
||||
Social%20Media/singular: 2227508afb0d38a027e323207e47d4a2
|
||||
Software%20Development/singular: 67659097ef6bcb194839858b2c4f3a3c
|
||||
Star%20on%20Github/singular: e5cb0428f1ac6485688f120d78742875
|
||||
Start%2014%20day%20free%20trial/singular: 4be2811a8f3455618032ccb59e40658b
|
||||
Subscription%20Required/singular: 05f2b4abfc36def17756a6969983cf86
|
||||
Success/singular: c43827becada6750f7a25890905f38b9
|
||||
Supercharge%20your%20workspace%20for%20just%20%2429%2Fmonth.%20Here's%20what%20you'll%20get%3A/singular: 21af8119141afcce5f12da489f34db6a
|
||||
Support/singular: 55aab5fd0f31a9cb055a2edeeedfaf63
|
||||
@@ -442,14 +387,8 @@ 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
|
||||
Testimonials/singular: e958aa3d3743031e2cbfbfd8f1e381f6
|
||||
Thank%20you%20for%20your%20feedback!/singular: 07edd8c50685a52c0969d711df26d768
|
||||
The%20current%20password%20you%20entered%20is%20incorrect./singular: 67a76bf346ab4b48563269e9d941a64a
|
||||
The%20main%20difference%20between%20Kan%20and%20Trello%20is%20that%20Kan%20is%20open%20source%2C%20allowing%20anyone%20to%20view%2C%20modify%2C%20and%20contribute%20to%20our%20code.%20Our%20cloud%20offering%20also%20offers%20no%20restrictions%20on%20features%20for%20individual%20use%2C%20whereas%20Trello%20locks%20basic%20features%20such%20as%20the%20number%20of%20boards%20you%20can%20create%20behind%20a%20paywall./singular: db6e22cc955c5fbe547feedeb48f8719
|
||||
@@ -474,15 +413,12 @@ checksums:
|
||||
Trello%20disconnected/singular: 54b24a3e6c9a7eedd8c8d1060ab1175d
|
||||
Trello%20imports/singular: 6827eca403faa8f89891d17827e72af9
|
||||
Triaging/singular: 1d40799fcae53a8a27688fdae2a48dee
|
||||
Trusted%20by%20fast-moving%20teams%3C0%2F%3Earound%20the%20world/singular: b065575df380536df9541a91ca9b37bf
|
||||
Unable%20to%20add%20checklist%20item/singular: 4c4c3eaaf10b348b39ae97eb5dc455df
|
||||
Unable%20to%20add%20comment/singular: 49bb435880817434698f31a6069564d6
|
||||
Unable%20to%20create%20card/singular: 90112ea12ec6fa42097a6e022ae048a4
|
||||
Unable%20to%20create%20checklist/singular: 94eed122e42e0951cd08b9ec62f5eeb6
|
||||
Unable%20to%20create%20list/singular: 7fbbf8314f8d08a4123c7daef09fed05
|
||||
Unable%20to%20create%20template/singular: 7495f1434c9188849bffd015811d5337
|
||||
Unable%20to%20create%20workspace/singular: c1c949477e121d73ed6683eddf9cd874
|
||||
Unable%20to%20delete%20attachment/singular: 99d0b17b78d44e15f13d876d155f0efc
|
||||
Unable%20to%20delete%20card/singular: 50ff06aa1265fe464f776aba33f1bef9
|
||||
Unable%20to%20delete%20checklist/singular: d7067e2c009f4919f0cca77df3c228ee
|
||||
Unable%20to%20delete%20checklist%20item/singular: a74144593b625e4d8d7ecf734462c60d
|
||||
@@ -507,16 +443,16 @@ 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
|
||||
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
|
||||
updated%20the%20title/singular: 98cf8f12923ec1a58e767e3a40863b21
|
||||
updated%20the%20title%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: aca196d729401d8adc5c8ca9d558840e
|
||||
Upgrade/singular: 63c3b52882e0d779859307d672c178c2
|
||||
Upgrade%20to%20Pro/singular: 972773025e763ddf53273df6d37a729a
|
||||
Upgrade%20to%20Pro%20(%2429%2Fmonth)/singular: 9a91ea70f5eb55aac96640bf0fd7feff
|
||||
Upload%20failed/singular: 5b7b755f05330164dbfb00096c500ee3
|
||||
Upgrade%20to%20Team%20Plan/singular: 40e2ef151266937f8bbe63b3d361f645
|
||||
Urgent/singular: e79afcd4398e52e10e7e8243a7eecabe
|
||||
URL%20can%20only%20contain%20letters%2C%20numbers%2C%20and%20hyphens/singular: 875d37d2d920f98ef60a314eda8a5d9a
|
||||
URL%20cannot%20exceed%2024%20characters/singular: 395c25732b84ac1f5b63ee310da08e93
|
||||
@@ -537,8 +473,7 @@ checksums:
|
||||
Welcome%20back/singular: 4928884739ba559e6e4b960e80fe1452
|
||||
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%20the%20years%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: 2c0790e6654e5460453ad2e523bbd377
|
||||
White%20label/singular: 2570386f73a010b07219e7658aaf9d9b
|
||||
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
|
||||
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
|
||||
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
|
||||
Workspace%20created%20successfully.%20You%20can%20upgrade%20later%20in%20settings./singular: 8bbaba9f82ff076a08185ada1ee7eb94
|
||||
@@ -549,7 +484,7 @@ checksums:
|
||||
Workspace%20description%20updated/singular: 19ddb6ee9ead26982a90dee426f3ee1f
|
||||
Workspace%20members/singular: 1857292f9567c556c781a23bd7e10544
|
||||
Workspace%20name/singular: 855614bbf862fd6a9f6a3e76943f92d4
|
||||
Workspace%20name%20cannot%20exceed%2064%20characters/singular: 681f86c9ca31699fc5a3ca3790e5972c
|
||||
Workspace%20name%20cannot%20exceed%2024%20characters/singular: c8b8f79fb83abf8329cc34c1f51fcfc9
|
||||
Workspace%20name%20is%20required/singular: b8c5162dd08c4d941bc57f9d0cbee451
|
||||
Workspace%20name%20must%20be%20at%20least%203%20characters%20long/singular: e448ea97418d44b18b4c21c22b8ba779
|
||||
Workspace%20name%20updated/singular: 3206ea410ee1ea4182b27ac0d89f92a1
|
||||
@@ -571,7 +506,6 @@ checksums:
|
||||
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
|
||||
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
|
||||
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
|
||||
Your%20file%20has%20been%20uploaded%20successfully./singular: 19d84e934877efc0fef285ff3d6e7849
|
||||
Your%20password%20has%20been%20changed./singular: 8f730f68d52b7efa727aa0784282ebcb
|
||||
Your%20profile%20image%20has%20been%20updated./singular: 099e1780348cd7bf617721344371a430
|
||||
Your%20Trello%20account%20has%20been%20disconnected./singular: 126aeeb73f7e4cad338056e4c933f39b
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -30,57 +30,23 @@ const config = {
|
||||
typescript: { ignoreBuildErrors: true },
|
||||
|
||||
images: {
|
||||
remotePatterns: (() => {
|
||||
/** @type {Array<{protocol: "http" | "https", hostname: string}>} */
|
||||
const patterns = [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
];
|
||||
|
||||
// Extract root domain from S3_ENDPOINT and add wildcard pattern
|
||||
const s3Endpoint = env("S3_ENDPOINT");
|
||||
if (s3Endpoint) {
|
||||
try {
|
||||
const url = new URL(s3Endpoint);
|
||||
const hostname = url.hostname;
|
||||
const protocol = url.protocol.replace(":", "");
|
||||
|
||||
// Extract root domain (last 2 parts: e.g. cloudflarestorage.com)
|
||||
const parts = hostname.split(".");
|
||||
if (parts.length >= 2) {
|
||||
const rootDomain = parts.slice(-2).join(".");
|
||||
patterns.push({
|
||||
protocol: protocol === "http" ? "http" : "https",
|
||||
hostname: `*.${rootDomain}`,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// If S3_ENDPOINT is not a valid URL, ignore it
|
||||
}
|
||||
}
|
||||
|
||||
return patterns;
|
||||
})(),
|
||||
},
|
||||
webpack(config) {
|
||||
config.module.rules.push({
|
||||
test: /\.svg$/,
|
||||
use: ["@svgr/webpack"],
|
||||
});
|
||||
return config;
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname:
|
||||
env("S3_FORCE_PATH_STYLE") === "true"
|
||||
? `${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`
|
||||
: `*.${env("NEXT_PUBLIC_STORAGE_DOMAIN")}`,
|
||||
},
|
||||
{
|
||||
protocol: "http",
|
||||
hostname: "localhost",
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "*.googleusercontent.com",
|
||||
},
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
// instrumentationHook: true,
|
||||
|
||||
@@ -65,7 +65,6 @@
|
||||
"superjson": "2.2.1",
|
||||
"tailwind-merge": "^2.5.2",
|
||||
"tippy.js": "^6.3.7",
|
||||
"tiptap-markdown": "^0.8",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -77,7 +76,6 @@
|
||||
"@lingui/cli": "^5.3.2",
|
||||
"@lingui/loader": "^5.3.2",
|
||||
"@lingui/swc-plugin": "^5.5.2",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@types/node": "^20.17.7",
|
||||
"@types/react": "catalog:react18",
|
||||
"@types/react-beautiful-dnd": "^13.1.7",
|
||||
|
||||
|
Before Width: | Height: | Size: 398 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 423 KiB After Width: | Height: | Size: 133 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2500" viewBox="0 0 192.756 192.756"><g fill-rule="evenodd" clip-rule="evenodd"><path fill="none" d="M0 0h192.756v192.756H0z"/><path fill="#313f94" d="M33.272 96.299c-.159-7.649-6.374-13.864-14.023-13.864S5.544 88.809 5.544 96.458h-2.71c0-8.446 6.693-15.298 15.139-15.458 8.446 0 15.458 6.853 15.458 15.299z" style="fill:currentColor"/><path fill="#313f94" d="M33.113 96.458c0-6.215-4.94-11.155-11.155-11.155s-10.677 4.94-10.677 11.155H8.253c0-7.012 5.737-12.589 12.749-12.749 6.853 0 12.43 6.374 12.589 12.589z" style="fill:currentColor"/><path fill="#313f94" d="M33.272 96.299c0-4.462-3.824-8.127-8.446-8.127s-7.968 3.665-7.968 8.287H13.99c0-5.418 4.144-9.88 9.562-9.88s9.721 4.462 9.88 9.721h-.16z" style="fill:currentColor"/><path fill="#313f94" d="M3.153 96.458c0 7.649 6.215 13.864 13.864 13.864 7.809 0 13.864-6.375 13.864-14.023h2.709c0 8.445-6.853 15.457-15.298 15.457S2.994 104.904 2.834 96.458z" style="fill:currentColor"/><path fill="#313f94" d="M3.153 96.299c0 6.215 4.94 11.154 11.155 11.154s10.836-4.939 10.677-11.154h3.028c0 7.012-5.737 12.748-12.589 12.748-7.012 0-12.589-6.373-12.589-12.589z" style="fill:currentColor"/><path fill="#313f94" d="M2.994 96.458c.159 4.462 3.984 8.128 8.446 8.128 4.622 0 7.968-3.666 7.968-8.287h2.868c0 5.418-4.143 9.881-9.562 9.881-5.417 0-9.72-4.303-9.72-9.722m54.341 3.507h-5.1l4.144-8.925zm-8.924 8.285 1.434-3.188h7.968l.479 3.188h7.968l-4.462-24.7H53.67l-13.227 24.7zm21.513 0 4.303-24.7h8.127l-4.302 24.7zm24.064-18.645h3.985c1.273 0 2.07.16 2.549.638.318.318.479.956.318 1.593-.158.797-.479 1.435-1.115 1.753-.479.479-1.434.638-2.709.638h-3.825zM90.8 108.25l1.435-8.605h3.187c.956 0 1.754.16 2.23.32.479.318.797.637.957 1.115q.237.716 0 1.912l-.16 1.912c-.318 1.594-.318 2.709-.318 3.346h8.287v-.797c-.16 0-.479 0-.479-.158-.158-.16-.158-.479 0-1.115l.639-3.826c.158-1.752.158-3.027-.32-3.664-.477-.797-1.434-1.436-2.867-2.072.797-.159 1.434-.479 2.07-.796.797-.638 1.436-1.275 1.912-2.072.639-.957.957-1.913 1.275-3.188q.24-1.434 0-2.39a5.3 5.3 0 0 0-.797-1.912 7.5 7.5 0 0 0-1.594-1.594c-.797-.319-1.594-.638-2.709-.797s-2.548-.319-4.302-.319H87.294l-4.303 24.7zm33.147-19.123c1.115 0 1.912.159 2.23.159.479.16.797.479.957.797.318.319.318.797.318 1.275-.16.638-.479 1.116-1.115 1.594-.479.319-1.436.637-2.709.637H120.6l.797-4.462zm-1.275 19.123c1.912 0 3.188-.158 3.984-.318s1.752-.479 2.549-.797c.957-.479 1.594-.797 2.072-1.275.797-.477 1.275-1.273 1.754-2.07a7.2 7.2 0 0 0 1.115-2.869c.158-1.434 0-2.709-.479-3.666-.637-1.115-1.434-1.752-2.709-2.071 2.391-1.434 3.824-3.187 4.143-5.259.479-2.231-.158-3.825-1.912-4.94-1.275-.957-3.346-1.435-6.533-1.435h-12.111l-4.303 24.7zm.478-10.357c1.594 0 2.549.318 3.027.637.479.479.637.957.479 1.754-.16.637-.479 1.273-1.115 1.752-.639.318-1.594.639-3.188.639h-3.346l.797-4.781zm18.645-14.343h7.969l-2.709 15.298c-.16.957-.16 1.754 0 2.232.158.477.477.955 1.115 1.273.637.32 1.275.479 2.07.479 1.117 0 2.232-.318 2.869-.955.797-.639 1.275-1.754 1.594-3.029l2.709-15.298h7.969l-2.551 14.501c-.318 1.754-.637 3.188-1.115 4.303-.479.957-1.115 1.912-1.912 2.709s-1.754 1.594-2.709 2.072c-1.115.637-2.23 1.115-3.506 1.434-1.434.16-2.869.32-4.303.32-1.754 0-3.188-.16-4.463-.32-1.273-.318-2.23-.797-3.027-1.434-.955-.637-1.594-1.275-2.072-2.072-.477-.797-.797-1.912-.797-3.186-.158-.797 0-2.232.32-3.984zm48.127 7.171h-7.809c0-.638-.318-1.275-.639-1.594-.477-.319-1.115-.478-2.07-.478-.957 0-1.754.159-2.23.478-.479.319-.639.638-.797 1.116 0 .478 0 .637.318.956s1.115.637 2.549.956l3.027.797c2.709.797 4.623 1.753 5.578 3.028.957 1.274 1.275 2.709.957 4.461-.32 1.436-.797 2.551-1.594 3.666s-1.754 2.072-2.709 2.709c-.957.637-2.232 1.115-3.666 1.594-1.434.318-3.027.479-4.621.479s-3.188-.16-4.621-.639q-2.153-.717-3.348-1.912c-1.195-1.195-1.434-1.752-1.752-2.867-.16-.797-.16-1.754-.16-2.869h7.809c0 .797.318 1.434.797 1.752.479.479 1.436.639 2.709.639q1.673 0 2.391-.479c.718-.479.639-.637.797-1.115 0-.479 0-.957-.318-1.275s-1.115-.637-2.551-.955l-2.709-.639c-1.273-.318-2.709-.797-3.824-1.434-1.115-.638-2.07-1.435-2.549-2.55-.479-.956-.479-2.071-.318-3.506.318-1.434.797-2.549 1.592-3.665.797-1.115 1.754-1.912 2.869-2.55.957-.797 2.23-1.115 3.506-1.434s2.551-.478 3.824-.478c1.594 0 2.869.159 4.145.478 1.273.319 2.07.797 2.867 1.275.639.478 1.115 1.115 1.436 1.753.477.637.637 1.275.797 1.912.157.637.317 1.434.317 2.39" style="fill:currentColor"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 2000 456.5"><path d="M380.5 268.5c0 13.6-8 26-23.6 28.4-26.8 4.8-82.8 7.6-130 7.6s-103.2-2.8-130-7.6c-15.6-2.8-23.2-15.2-23.2-28.4v-89.2c0-13.6 10.4-26.4 23.2-28.4 8-1.6 26.4-2.8 40.8-2.8 5.6 0 9.6 4.4 10 10.8v62c27.6 0 51.6-1.6 79.6-1.6s51.6 1.6 79.6 1.6v-62c0-6.4 4.4-10.8 9.6-10.8 14.4 0 32.8 1.2 40.8 2.8 12.8 2.4 23.6 15.2 23.6 28.4zM226.4 0C101.6 0 0 102.4 0 228.4s101.6 228 226.4 228 226.4-102.4 226.4-228S352.1 0 226.4 0" class="st0" style="fill:currentColor"/><path d="M543.6 309.9c-70.4 0-105.6-51.2-105.6-108 0-56.4 36.8-107.2 106.4-107.2 26.8 0 46 5.6 61.6 16.4l-20 33.2c-11.2-7.2-24-12-42.4-12-38 0-58 30.4-58 68.4 0 38.8 19.2 71.6 58.8 71.6 22 0 35.6-7.6 46.4-16l18.4 32c-10.8 9.6-35.6 21.6-65.6 21.6M694 309.9c-52.4 0-75.6-40-75.6-79.6s22.8-80.4 75.2-80.4 76.4 39.6 76.4 79.2c0 38.8-23.2 80.8-76 80.8m-1.2-128c-22.8 0-29.6 19.2-29.6 47.2s8.8 48.4 31.2 48.4c22.8 0 30.4-18.8 30.4-47.2s-8.8-48.4-32-48.4M836.9 153.9v97.6c0 15.2 5.2 22.4 19.6 22.4 15.2 0 26.8-14.8 29.6-18.4V153.9h42.4v107.6c0 20 2.4 35.6 4.4 44.8h-41.6c-1.2-4.4-2.8-14-3.2-20-8 10.4-23.6 24-48.4 24-33.2 0-45.2-21.6-45.2-49.6V153.9zM1032.5 309.9c-48.8 0-78.4-32-78.4-80 0-51.2 33.6-80.4 80-80.4 24.4 0 38.8 7.2 46 12l-13.6 29.2c-6.4-4.4-16.4-8.8-31.2-8.8-23.6 0-36.4 18.4-36.4 46.4s12 48 37.2 48c16.8 0 27.6-6 32-8.8l12.8 28.4c-7.2 4.8-20.8 14-48.4 14M1194.5 306.3v-97.6c0-15.2-5.2-22.4-19.2-22.4-15.6 0-27.2 14.4-30 18.4v102h-42.4V81.1h42.4v90.8c7.6-8.4 22-22 46.8-22 33.2 0 45.2 21.2 45.2 49.6v106.4zM1314.1 81.1v84.4c6.8-6.8 20-16 36.8-16 37.6 0 63.6 26 63.6 78.8 0 52.4-34.8 81.2-80.4 81.2-34.4 0-55.2-8.4-62.4-12.4v-216zm0 192c2.8.8 9.2 3.2 20 3.2 22 0 35.2-16.4 35.2-47.6 0-28-9.2-44.8-30.8-44.8-12.8 0-22 8-24.8 11.2zM1519.8 306.3c-1.2-4-2.4-11.6-2.8-16.4-6.4 8-20.8 20-42 20-25.6 0-45.6-15.6-45.6-42.8 0-39.6 40-54 80.4-54h5.2v-8.8c0-12.8-5.2-20.4-24-20.4-19.2 0-32.4 10-38.4 14.4l-18-26.4c9.2-8 28.8-22 61.2-22 41.6 0 61.2 16.4 61.2 56.8v53.6c0 20.8 2.4 36 4.4 46zm-4.8-67.2h-5.2c-23.2 0-39.2 6.8-39.2 24.4 0 10.8 8.4 15.6 17.6 15.6 14.4 0 22.4-8 26.8-12.8zM1633.8 309.9c-27.2 0-44.4-8-53.6-14.4l14.4-30c5.6 4 20.4 12.4 37.2 12.4 15.6 0 24.4-4.4 24.4-13.2 0-10-16.8-12.8-38.4-23.2-20.8-10-32.8-22-32.8-44.8 0-28.8 22.4-47.2 56.8-47.2 26 0 41.6 8 48.8 12l-14.8 29.2c-5.6-4-17.6-9.6-32-9.6s-20.4 5.2-20.4 12.8c0 10 14 12.4 30.8 19.2 23.6 9.6 40.4 20.8 40.4 46.8-.8 33.2-23.6 50-60.8 50M1792.2 277.9c20.4 0 31.6-6.4 38.8-10.4l13.6 27.6c-10 5.6-25.6 14.8-56.4 14.8-50.4 0-79.2-32-79.2-80.8 0-48.4 33.2-79.2 74.8-79.2 47.6 0 70.4 33.2 65.2 90.4h-94c1.6 22.8 12.8 37.6 37.2 37.6m16-66.8c-.4-16.8-6.8-30.4-25.2-30.4-16.8 0-26.4 10.8-28.8 30.4z" class="st1" style="fill:currentColor" transform="translate(150.22 27.826)"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.7 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="892.4" height="170.507" viewBox="-86.2 14.747 1092.4 170.507"><path d="M3054.827 974.386a4.32 4.32 0 1 1 8.637 0 4.32 4.32 0 0 1-8.637 0M2917.055 962.946q0-4.106-1.587-6.09-1.586-1.98-4.818-1.98h-2.294v16.732h1.755q3.592.001 5.267-2.135 1.676-2.134 1.677-6.527m8.18-.285q0 7.478-4.02 11.522-4.02 4.047-11.304 4.046h-9.436v-29.87h10.095q7.023 0 10.845 3.677 3.82 3.677 3.82 10.625M2951.373 978.229h7.509v-29.996h-7.509zM2969.43 967.04q0 2.87.749 4.374.749 1.505 2.515 1.505 1.748 0 2.469-1.505.719-1.505.718-4.374 0-2.853-.728-4.306-.731-1.457-2.498-1.458-1.727.001-2.476 1.447-.749 1.444-.749 4.317m14.094 0q.001 5.466-2.87 8.524-2.871 3.059-8.036 3.059-4.955 0-7.883-3.128-2.93-3.126-2.93-8.455 0-5.448 2.871-8.467t8.058-3.02q3.206 0 5.664 1.398t3.792 4.003q1.334 2.603 1.334 6.086M2986.506 978.23h7.51v-22.282h-7.51zM2986.506 953.253h7.51v-5.022h-7.51zM3009.23 972.546c1.015 0 2.222-.258 3.63-.769v5.606q-1.517.669-2.89.953-1.372.286-3.217.286-3.781 0-5.455-1.899-1.667-1.897-1.668-5.828v-9.185H2997v-5.76h2.63v-5.69l7.567-1.317v7.007h4.791v5.76h-4.791v8.672q0 2.164 2.034 2.164M3026.612 972.546c1.013 0 2.22-.258 3.63-.769v5.606q-1.518.669-2.89.953-1.374.286-3.218.286-3.782 0-5.452-1.899-1.671-1.897-1.672-5.828v-9.185h-2.63v-5.76h2.63v-5.777l7.567-1.23v7.007h4.793v5.76h-4.793v8.672q0 2.164 2.035 2.164M3039.717 964.326q.155-1.83 1.047-2.685.895-.855 2.217-.855 1.445-.001 2.295.965.857.962.893 2.575zm10.59-6.145q-2.678-2.626-7.593-2.626-5.166 0-7.952 3.018-2.784 3.019-2.785 8.663.001 5.467 3.007 8.426 3.005 2.96 8.44 2.96 2.61 0 4.493-.355a13 13 0 0 0 3.627-1.266l-1.154-5.021c-.851.347-1.661.615-2.426.79q-1.658.386-3.638.386-2.171 0-3.427-1.061-1.258-1.065-1.335-2.932h13.422v-3.42q0-4.938-2.679-7.562M2935.2 964.326q.155-1.83 1.047-2.685.894-.855 2.219-.855 1.44-.001 2.294.965.855.962.893 2.575zm10.592-6.145q-2.68-2.626-7.595-2.626-5.167 0-7.95 3.018-2.785 3.019-2.786 8.663 0 5.467 3.007 8.426 3.004 2.96 8.44 2.96 2.61 0 4.493-.355a13 13 0 0 0 3.627-1.266l-1.155-5.021c-.851.347-1.661.615-2.425.79q-1.66.386-3.639.386-2.17 0-3.427-1.061-1.258-1.065-1.335-2.932h13.422v-3.42q0-4.938-2.677-7.562" style="fill:currentColor" transform="translate(-15746.429 -5136.262)scale(5.4348)"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 295 42"><rect width="100%" height="100%" fill="none"/><path d="M259.5 5.5c0-2.9.9-3.9 2.4-3.9s2.7.6 4.1 3.9l8.5 19.3V37c0 2.9-1.2 3.7-4.5 4.1v.8h15.7v-.8c-3.4-.4-4.5-1.3-4.5-4.1V24.8l8.2-18.6c1.6-3.6 3.2-4.4 5.6-4.8V.6h-11.5v.8c3.9.4 5.3 1.5 4.2 4.1L280.5 22l-7.4-16.9c-1-2.3-.4-3 3.9-3.6V.7h-24.7v.8c3.9.6 5.1 1.3 5.1 4.1v22.6L239.3.7h-8.9v.8c2.9.4 3.8 1.3 3.8 4.1v31.5c0 2.9-.8 3.9-1.8 3.9-.9 0-1.4-.4-2.7-3.9L216.3.5h-.8l-12.9 35c-1.4 3.9-3.6 5.2-5.8 5.7v.8h11.8v-.8c-4.1-.4-5.4-2-4.5-4.6l2.9-8h12.2l2.8 7.5c1.2 3.4.5 4.4-3 5v.8h22.4v-.8c-3.9-.6-5.1-1.3-5.1-4.1V8.8L258.2 42h1.3zM101.6 21.2c0-12.8 6.1-19.5 13.2-19.5 4.2 0 6.9.9 8.5 2.6 1.2 1.2 2.1 2.9 3.1 5.6h.8V1.3C123.1.5 119.4 0 115.7 0 105 0 93.9 6.9 93.9 21.2S105 42.4 115 42.4c4.4 0 8.3-.7 12-1.8l1.2-8.1-.7-.2c-.9 1.7-2 3.2-3.2 4.5-2.6 2.6-6.1 4-9.6 4-6.8-.1-13.1-6.6-13.1-19.6m88.7 1.2c-.9 0-2.1-.1-3.3-.3v-20h3.3c5.5 0 7.9 2.9 7.9 10.1 0 7.3-2.5 10.2-7.9 10.2M61.8.6v9.9h.8c1.2-6.3 3.7-8.3 7.9-8.3h2V37c0 2.9-1.2 3.7-4.5 4.1v.8h15.7v-.8c-3.4-.4-4.5-1.3-4.5-4.1V2.1h2c4.2 0 6.7 2 7.9 8.3h.8V.5H61.8zM49.3 42.4c6.8 0 14-3.9 14-11.9 0-4.7-2.5-8.6-6.9-11l-7.8-4.3c-4.1-2.3-5.3-4.4-5.3-7.4 0-3.4 2.5-6.1 7.2-6.1 2.3 0 4 .6 5.4 1.9 1.2 1.2 2.4 3.2 3.5 6.8h.7V1.3C57.3.5 54.4 0 51 0c-6.7 0-11.7 4.2-11.7 11.3 0 4.5 2.5 8.6 6.6 11l7.2 4c4.1 2.3 5.4 4.5 5.4 7.8 0 3.9-4 6.7-8.4 6.7-2.9 0-5.3-1-7-2.7-1.1-1.1-2.3-2.8-3.1-5h4.7v-.8c-1.3-.3-2.1-.5-3.2-3.1L32.3 7.3h-.8l-9 21.4c-1.2 2.9-2 3.4-3.4 3.6v.8h8v-.8c-2.9-.3-3.7-1.7-3-3.5l1.4-3.4h8.1l1.8 4.2c.8 1.9-.4 2.4-3.3 2.6v.8h6.4v7.9c3.2.9 7.3 1.5 10.8 1.5m101.6-.4 11.9-33.1.9 28.2c.1 2.8-1 3.6-4.4 4v.8h32.8v-.8c-3.9-.6-5.1-1.3-5.1-4.1V23.7c1.3.2 2.6.3 3.6.3 7.9 0 14.7-2.9 14.7-11.7S198.5.6 190.6.6h-14.1v.8c2.9.4 3.8 1.3 3.8 4.1V37c0 2.9-1.2 4.1-4.5 4.1h-.1c-3.5 0-5-1.3-5.1-4l-1.2-31.5c-.1-2.9.9-3.8 4-4.2V.6h-9.7l-10.6 29.7L142.5.6h-10.4v.8c3.1.4 4.1 1.4 4 4.2L135 35.8c-.1 3-1.7 4.8-4.6 5.3v.8h11v-.8c-2.8-.4-4.4-2.2-4.3-5.3l.9-27.5 12.1 33.6zM119 33.3c5.3 0 12.1-4.7 12.1-12.1 0-7.3-6.7-12.1-12.1-12.1-5.3 0-12.1 4.7-12.1 12.1 0 7.3 6.7 12.1 12.1 12.1m0-22.5c3.4 0 5.7 2.8 5.7 10.4 0 7.7-2.4 10.4-5.7 10.4-3.4 0-5.7-2.8-5.7-10.4 0-7.7 2.3-10.4 5.7-10.4m99.7 16.1h-11.1l5.5-15.2zM33.1 23.8h-6.8l3.4-8.3zm-13 2.1h.8V13.5h-.8c-1.3 4.4-2.6 5.4-5 5.4h-4.6V2.1h7.3c4.2 0 6.7 2 7.9 8.3h.8V.5H0v.8c2.9.4 3.8 1.3 3.8 4.1v31.5c0 2.9-.9 3.7-3.8 4.1v.8h15.6V41c-3.9-.6-5.1-1.3-5.1-4.1V20.4h4.6c2.3.1 3.7 1.1 5 5.5" class="currentLayer" style="fill:currentColor"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" viewBox="-5 0 24 14"><path d="M11.48 4.37c-.714 0-1.207.284-1.54.678a.9.9 0 0 0-.16-.238c-.273-.308-.712-.452-1.3-.452-.576 0-1.056.18-1.39.462-.172-.28-.585-.467-1.082-.467-.65 0-1.358.092-1.778.769-.01-.244-.032-.373-.169-.518-.166-.176-.436-.222-.698-.222-.478 0-.838.209-1.097.634C1.89 5.616.98 7.59 1 8.546c.014.618.428 1.063 1.17 1.08.579.015 1.034-.14 1.323-.432.26.282.655.447 1.128.447.528 0 1.059-.158 1.372-.504.264.324.68.513 1.214.51a2.19 2.19 0 0 0 1.713-.842c.22.598.777.836 1.386.836 1.335 0 1.92-.918 2.41-2.369.11-.327.256-.873.28-1.33.041-.764-.282-1.571-1.517-1.571M2.598 8.02c.69-.12.864.129.842.36-.065.692-.7.847-1.251.834-.4-.009-.76-.193-.77-.657-.017-.812.811-2.69 1.201-3.314.18-.295.4-.441.75-.441.331 0 .413.17.408.374-.013.544-.892 2.197-1.18 2.843m2.249-.639a7 7 0 0 0-.203.657c.227-.056.397-.096.688-.089.332.008.544.146.544.42 0 .666-.735.86-1.245.86-.56 0-1.052-.318-1.052-.93 0-.718.39-1.807.755-2.532.448-.89.906-1.008 1.695-1.008.347 0 .747.148.747.475 0 .454-.385.627-.766.65a5 5 0 0 1-.563.006s-.127.194-.262.538c.706-.1 1.005.06.886.47-.161.551-.639.587-1.224.483zM8.32 5.665c-.19 0-.316.12-.409.251-.204.288-.648 1.44-.717 1.912-.047.324.093.39.246.39.245 0 .52-.26.614-.688 0 0-.467-.01-.338-.425.125-.402.362-.489.756-.505.775-.032.698.54.636.846-.201.99-.907 1.787-1.904 1.787-.683 0-1.107-.378-1.107-1.075 0-.496.246-1.275.44-1.731.416-.97.852-1.65 1.962-1.65.666 0 1.191.24 1.114.864-.056.457-.286.726-.702.759-.116.009-.593-.003-.435-.454.056-.157.078-.281-.156-.281m4.192.819a6.7 6.7 0 0 1-.668 1.79c-.467.84-1.035.959-1.53.953s-1.055-.19-1.06-.954c-.002-.549.235-1.324.436-1.832.35-.92.708-1.671 1.84-1.658 1.32.016 1.085 1.19.982 1.7m-1.12-.8c-.113-.001-.212.02-.3.158-.2.258-.784 1.854-.774 2.172.004.114.067.214.208.214.161 0 .25-.111.33-.246.184-.311.7-1.721.715-2.074.004-.102-.013-.222-.179-.224" style="fill:currentColor"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-48.214 -18.217 387.865 116.248"><path d="M1.785 65.652h31.62V55.27H13.23V15.665H1.785zm47.629 0v-34.43H37.97v34.43zm-5.721-39.13c3.99 0 6.474-2.644 6.474-5.95-.074-3.378-2.484-5.947-6.398-5.947-3.915 0-6.475 2.57-6.475 5.947 0 3.306 2.484 5.95 6.324 5.95zm11.034 39.13h11.444V46.424c0-1.029.074-2.058.377-2.791.826-2.056 2.709-4.186 5.871-4.186 4.142 0 5.799 3.158 5.799 7.784v18.42H89.66V45.91c0-10.576-5.646-15.497-13.176-15.497-6.173 0-8.884 3.451-10.39 5.802h.077v-4.993H54.727c.151 3.231 0 34.43 0 34.43m51.078-49.987H94.361v49.987h11.444V54.489l2.86-3.601 8.96 14.764h14.078l-15.056-21.373 13.174-14.54h-13.776s-9.411 13.008-10.24 14.552zm56.501 35.625c.151-.884.377-2.58.377-4.498 0-8.9-4.518-17.936-16.413-17.936-12.724 0-18.597 10.063-18.597 19.19 0 11.288 7.153 18.337 19.65 18.337 4.97 0 9.561-.732 13.327-2.275l-1.506-7.558c-3.088 1.024-6.25 1.537-10.164 1.537-5.345 0-10.012-2.195-10.389-6.871l23.715.072zm-23.79-7.742c.301-2.938 2.26-7.273 7.153-7.273 5.194 0 6.4 4.628 6.4 7.273h-13.552zm52.414-27.883v17.304h-.151c-1.657-2.422-5.12-4.038-9.71-4.038-8.81 0-16.564 7.05-16.49 19.094 0 11.164 7.003 18.435 15.735 18.435 4.744 0 9.26-2.058 11.52-6.024h.225l.453 5.216h10.163c-.15-2.424-.302-6.61-.302-10.723V15.664h-11.444zm0 34.05c0 .88-.075 1.763-.227 2.495-.675 3.16-3.386 5.361-6.699 5.361-4.742 0-7.83-3.818-7.83-9.84 0-5.654 2.637-10.208 7.906-10.208 3.538 0 6.022 2.423 6.7 5.433.15.663.15 1.398.15 2.058v4.7z" style="fill:currentColor"/><mask id="a"><rect width="100%" height="100%" fill="#fff"/><path d="M236.85 65.61V31.18h-11.444v34.43h11.445zm-5.72-39.13c3.99 0 6.474-2.644 6.474-5.948-.075-3.379-2.484-5.949-6.398-5.949-3.917 0-6.475 2.57-6.475 5.949 0 3.304 2.483 5.948 6.324 5.948zm12.054 39.13h11.443V46.385c0-1.028.075-2.058.377-2.792.827-2.057 2.71-4.186 5.872-4.186 4.14 0 5.797 3.157 5.797 7.786V65.61h11.443V45.869c0-10.575-5.645-15.496-13.174-15.496-6.173 0-8.884 3.45-10.39 5.8h.076v-4.992h-11.443c.149 3.23-.001 34.43-.001 34.43z"/></mask><path d="M213.882 7.245c0-3.015 2.508-5.462 5.6-5.462h64.568c3.093 0 5.6 2.447 5.6 5.462V72.57c0 3.016-2.507 5.461-5.6 5.461h-64.568c-3.092 0-5.6-2.445-5.6-5.46V7.244z" mask="url(#a)" style="fill:currentColor"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="66" height="24" class="wordmark" viewBox="0 0 66 24"><path d="M0 17.198h3.045c.356 3.077 3.045 4.664 5.927 4.664 3.045 0 5.409-1.814 5.409-4.632 0-2.883-2.332-3.595-6.186-4.47C2.559 11.53.777 9.587.777 6.283.777 2.623 3.984 0 8.486 0c4.178 0 7.806 2.332 8.032 6.057h-3.012c-.227-2.462-2.429-3.919-5.15-3.919-2.947 0-4.761 1.879-4.761 3.951 0 2.462 2.073 3.206 6.121 4.049 5.441 1.134 7.514 3.271 7.514 6.931 0 4.243-3.498 6.931-8.356 6.931C4.081 24 .291 21.636 0 17.198M18.267 19.304c0-3.336 2.818-5.344 10.3-5.538v-.972c0-2.494-1.36-3.725-3.563-3.725-2.267 0-3.401 1.49-3.563 3.077h-2.785c.324-2.721 2.559-4.988 6.348-4.988 4.049 0 6.316 2.202 6.316 5.636v10.721h-2.623v-2.073C27.757 23.093 26.008 24 23.709 24c-2.915 0-5.442-1.717-5.442-4.696m10.3-1.458v-2.3c-5.538.162-7.547 1.458-7.547 3.66 0 1.684 1.36 2.883 3.271 2.883 2.43 0 4.276-1.652 4.276-4.243M33.684 7.676h2.656v2.332c.907-1.684 2.623-2.785 4.923-2.785 3.595 0 5.506 2.364 5.506 5.862v10.429h-2.85v-9.911c0-2.915-1.296-4.275-3.498-4.275s-3.887 1.879-3.887 4.308v9.879h-2.85zM48.389 19.304c0-3.336 2.818-5.344 10.3-5.538v-.972c0-2.494-1.36-3.725-3.563-3.725-2.267 0-3.401 1.49-3.563 3.077h-2.785c.324-2.721 2.559-4.988 6.348-4.988 4.049 0 6.316 2.202 6.316 5.636v10.721h-2.623v-2.073C57.879 23.093 56.13 24 53.83 24c-2.915 0-5.441-1.717-5.441-4.696m10.299-1.458v-2.3c-5.538.162-7.547 1.458-7.547 3.66 0 1.684 1.36 2.883 3.271 2.883 2.43 0 4.276-1.652 4.276-4.243M61.043 1.018h-1.017v3.014h-.602V1.018h-1.017v-.5h2.636zM63.251 3.263l.873-2.745h.917V4.03h-.596V1.137l-.908 2.893h-.581l-.928-2.889V4.03h-.587V.518h.933z" style="fill:currentColor"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-50 -20 288.624 78.268"><g data-name="Calque 2"><g data-name="Groupe 1909" opacity=".35"><path fill="#0540ff" d="M68.234 103.2a4.226 4.226 0 0 1-3.767-6.125L73.8 78.55a4.226 4.226 0 0 1 7.548 3.8l-9.335 18.522a4.23 4.23 0 0 1-3.781 2.323Z" data-name="Tracé 10091" style="fill:currentColor" transform="translate(-54.53 -64.931)"/></g><g data-name="Groupe 1910" opacity=".35"><path fill="#0540ff" d="M1093.234 103.213a4.226 4.226 0 0 1-3.766-6.125l9.335-18.522a4.223 4.223 0 0 1 7.542 3.8l-9.335 18.522a4.23 4.23 0 0 1-3.776 2.325" data-name="Tracé 10092" style="fill:currentColor" transform="translate(-927.653 -64.946)"/></g><path fill="#0540ff" d="M140.7 103.184a4.22 4.22 0 0 1-3.759-2.3l-9.483-18.521a4.223 4.223 0 0 1 7.517-3.853l5.7 11.122 5.594-11.1a4.226 4.226 0 0 1 7.548 3.805l-9.335 18.522a4.22 4.22 0 0 1-3.758 2.322Z" data-name="Tracé 10093" style="fill:currentColor" transform="translate(-108.179 -64.918)"/><path fill="#0540ff" d="M13.708 103.2a4.22 4.22 0 0 1-3.762-2.3L.462 82.382a4.223 4.223 0 0 1 7.517-3.852l9.483 18.522a4.226 4.226 0 0 1-3.755 6.149Z" data-name="Tracé 10094" style="fill:currentColor" transform="translate(.003 -64.936)"/><path fill="#0540ff" d="M966.234 103.219a4.226 4.226 0 0 1-3.766-6.125l9.332-18.522a4.22 4.22 0 0 1 3.758-2.322h.013a4.22 4.22 0 0 1 3.758 2.3l9.483 18.522a4.223 4.223 0 0 1-7.517 3.852L975.6 89.8l-5.588 11.1a4.23 4.23 0 0 1-3.778 2.319" data-name="Tracé 10095" style="fill:currentColor" transform="translate(-819.471 -64.952)"/><path fill="#0540ff" d="M1165.708 103.2a4.22 4.22 0 0 1-3.762-2.3l-9.483-18.522a4.223 4.223 0 1 1 7.517-3.852l9.483 18.522a4.226 4.226 0 0 1-3.755 6.149Z" data-name="Tracé 10096" style="fill:currentColor" transform="translate(-981.302 -64.936)"/><path fill="#0540ff" d="m578.369 30.891-8.541-6.677 8.446-5.666a4 4 0 0 0-4.456-6.647l-11.737 7.869V4.186a4.186 4.186 0 1 0-8.372 0v29.783a4.186 4.186 0 0 0 8.372 0V28.32l11.36 8.871a4 4 0 1 0 4.927-6.3Z" data-name="Tracé 10097" style="fill:currentColor" transform="translate(-471.665)"/><path fill="#0540ff" d="M356.4 90.163a13.4 13.4 0 1 0-7 11.854 3.852 3.852 0 0 0 7-2.223zM343 96.09a5.853 5.853 0 1 1 5.853-5.853A5.853 5.853 0 0 1 343 96.09" data-name="Tracé 10098" style="fill:currentColor" transform="translate(-280.762 -65.455)"/><path fill="#0540ff" d="M782.4 90.163a13.4 13.4 0 1 0-7 11.854 3.853 3.853 0 0 0 7-2.223zM769 96.09a5.853 5.853 0 1 1 5.853-5.853A5.853 5.853 0 0 1 769 96.09" data-name="Tracé 10099" style="fill:currentColor" transform="translate(-643.64 -65.455)"/></g></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 53 KiB |
|
Before Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 18 KiB |
@@ -5,7 +5,7 @@ import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import {
|
||||
FaApple,
|
||||
@@ -153,16 +153,13 @@ const availableSocialProviders = {
|
||||
};
|
||||
|
||||
export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
const [isCloudEnv, setIsCloudEnv] = useState(false);
|
||||
const [isLoginWithProviderPending, setIsLoginWithProviderPending] =
|
||||
useState<null | AuthProvider>(null);
|
||||
const [isCredentialsEnabled, setIsCredentialsEnabled] = useState(false);
|
||||
const [isEmailSendingEnabled, setIsEmailSendingEnabled] = useState(false);
|
||||
const [isLoginWithEmailPending, setIsLoginWithEmailPending] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const { showPopup } = usePopup();
|
||||
const oidcProviderName = "OIDC";
|
||||
const passwordRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const redirect = useSearchParams().get("next");
|
||||
const callbackURL = redirect ?? "/boards";
|
||||
@@ -171,11 +168,6 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
useEffect(() => {
|
||||
const credentialsAllowed =
|
||||
env("NEXT_PUBLIC_ALLOW_CREDENTIALS")?.toLowerCase() === "true";
|
||||
const emailSendingEnabled =
|
||||
env("NEXT_PUBLIC_DISABLE_EMAIL")?.toLowerCase() !== "true";
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
setIsCloudEnv(isCloudEnv);
|
||||
setIsEmailSendingEnabled(emailSendingEnabled);
|
||||
setIsCredentialsEnabled(credentialsAllowed);
|
||||
}, []);
|
||||
|
||||
@@ -195,7 +187,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
|
||||
const handleLoginWithEmail = async (
|
||||
email: string,
|
||||
password?: string | null,
|
||||
password?: string,
|
||||
name?: string,
|
||||
) => {
|
||||
setIsLoginWithEmailPending(true);
|
||||
@@ -238,26 +230,16 @@ 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)) {
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// Provide a clear error feedback when password omitted but magic link unavailable
|
||||
setLoginError(
|
||||
isSignUp
|
||||
? t`Password is required to sign up.`
|
||||
: t`Password is required to login.`,
|
||||
);
|
||||
}
|
||||
await authClient.signIn.magicLink(
|
||||
{
|
||||
email,
|
||||
callbackURL,
|
||||
},
|
||||
{
|
||||
onSuccess: () => setIsMagicLinkSent(true, email),
|
||||
onError: ({ error }) => setLoginError(error.message),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
setIsLoginWithEmailPending(false);
|
||||
@@ -294,47 +276,11 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
};
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
// Treat empty password string as undefined to trigger magic link path
|
||||
const sanitizedPassword = values.password?.trim()
|
||||
? values.password
|
||||
: undefined;
|
||||
await handleLoginWithEmail(values.email, sanitizedPassword, values.name);
|
||||
await handleLoginWithEmail(values.email, values.password, values.name);
|
||||
};
|
||||
|
||||
const password = watch("password");
|
||||
|
||||
const isMagicLinkAvailable = useMemo(() => {
|
||||
return isCloudEnv || (isEmailSendingEnabled && !isSignUp);
|
||||
}, [isCloudEnv, isEmailSendingEnabled, isSignUp]);
|
||||
|
||||
// Determine if we should operate in magic link mode for current form state (login only)
|
||||
const isMagicLinkMode = useMemo(() => {
|
||||
// Magic link only viable when email sending enabled AND not sign up.
|
||||
if (!isEmailSendingEnabled || isSignUp) return false;
|
||||
// If credentials disabled we always default to magic link.
|
||||
if (!isCredentialsEnabled) return true;
|
||||
// Credentials enabled: user chooses magic link by leaving password blank.
|
||||
return !password;
|
||||
}, [isEmailSendingEnabled, isSignUp, isCredentialsEnabled, password]);
|
||||
|
||||
// Auto-focus password field when an error indicates it's required
|
||||
useEffect(() => {
|
||||
if (!isCredentialsEnabled) return;
|
||||
// Focus when: sign up and missing password; login error requiring password; validation error on password.
|
||||
const pwdEmpty = (password ?? "").length === 0;
|
||||
let needsPassword = false;
|
||||
if (isSignUp && pwdEmpty) {
|
||||
needsPassword = true;
|
||||
} else if (loginError?.toLowerCase().includes("password")) {
|
||||
needsPassword = true;
|
||||
} else if (errors.password) {
|
||||
needsPassword = true;
|
||||
}
|
||||
if (needsPassword && passwordRef.current) {
|
||||
passwordRef.current.focus();
|
||||
}
|
||||
}, [isSignUp, password, loginError, errors.password, isCredentialsEnabled]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{socialProviders?.length !== 0 && (
|
||||
@@ -362,16 +308,7 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
{!isCredentialsEnabled && socialProviders?.length === 0 && (
|
||||
<div className="flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-center text-sm text-light-900 dark:text-dark-900">
|
||||
{t`No authentication methods are currently available`}
|
||||
</span>
|
||||
<div className="h-[1px] w-1/3 bg-light-600 dark:bg-dark-600" />
|
||||
</div>
|
||||
)}
|
||||
{!isCredentialsEnabled && socialProviders?.length !== 0 && (
|
||||
{socialProviders?.length !== 0 && (
|
||||
<div className="mb-[1.5rem] flex w-full items-center gap-4">
|
||||
<div className="h-[1px] w-full bg-light-600 dark:bg-dark-600" />
|
||||
<span className="text-sm text-light-900 dark:text-dark-900">
|
||||
@@ -394,54 +331,48 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<>
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{errors.password.message ??
|
||||
t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Input
|
||||
{...register("email", { required: true })}
|
||||
placeholder={t`Enter your email address`}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid email address`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isCredentialsEnabled && (
|
||||
<div>
|
||||
<Input
|
||||
type="password"
|
||||
{...register("password", { required: true })}
|
||||
placeholder={t`Enter your password`}
|
||||
/>
|
||||
{errors.password && (
|
||||
<p className="mt-2 text-xs text-red-400">
|
||||
{t`Please enter a valid password`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{loginError && (
|
||||
<p className="mt-2 text-xs text-red-400">{loginError}</p>
|
||||
)}
|
||||
</div>
|
||||
{(isCredentialsEnabled || isMagicLinkAvailable) && (
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
fullWidth
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||
{isMagicLinkMode ? t`magic link` : t`email`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-[1.5rem] flex items-center gap-4">
|
||||
<Button
|
||||
isLoading={isLoginWithEmailPending}
|
||||
fullWidth
|
||||
size="lg"
|
||||
variant="secondary"
|
||||
>
|
||||
{isSignUp ? t`Sign up with ` : t`Continue with `}
|
||||
{!isCredentialsEnabled || (password && password.length !== 0)
|
||||
? t`email`
|
||||
: t`magic link`}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
href?: string;
|
||||
fullWidth?: boolean;
|
||||
openInNewTab?: boolean;
|
||||
iconOnly?: boolean;
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
@@ -25,7 +24,6 @@ const Button = ({
|
||||
href,
|
||||
fullWidth,
|
||||
openInNewTab,
|
||||
iconOnly,
|
||||
...props
|
||||
}: ButtonProps) => {
|
||||
const classes = twMerge(
|
||||
@@ -34,15 +32,6 @@ const Button = ({
|
||||
size === "sm" && "text-xs",
|
||||
size === "lg" && "py-[0.65rem]",
|
||||
fullWidth && "w-full",
|
||||
iconOnly && "p-0",
|
||||
iconOnly &&
|
||||
(size === "xs"
|
||||
? "h-6 w-6"
|
||||
: size === "sm"
|
||||
? "h-8 w-8"
|
||||
: size === "lg"
|
||||
? "h-10 w-10"
|
||||
: "h-9 w-9"),
|
||||
variant === "primary" &&
|
||||
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
|
||||
variant === "secondary" &&
|
||||
@@ -58,60 +47,19 @@ const Button = ({
|
||||
<span className="relative flex items-center justify-center">
|
||||
{isLoading && (
|
||||
<span className="absolute">
|
||||
<LoadingSpinner size={size === "xs" ? "sm" : size} />
|
||||
<LoadingSpinner size={size} />
|
||||
</span>
|
||||
)}
|
||||
{iconOnly ? (
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{iconLeft ?? iconRight}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={twMerge(
|
||||
fullWidth
|
||||
? "grid w-full grid-cols-[auto_1fr_auto] items-center gap-x-2"
|
||||
: "flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{fullWidth && !iconLeft && iconRight && (
|
||||
<span className="col-start-1 opacity-0">{iconRight}</span>
|
||||
)}
|
||||
{iconLeft && (
|
||||
<span
|
||||
className={twMerge(
|
||||
fullWidth ? "col-start-1 justify-self-start" : "mr-2",
|
||||
)}
|
||||
>
|
||||
{iconLeft}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={twMerge(
|
||||
fullWidth ? "col-start-2 justify-self-center text-center" : "",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
{iconRight && (
|
||||
<span
|
||||
className={twMerge(
|
||||
fullWidth ? "col-start-3 justify-self-end" : "ml-1",
|
||||
)}
|
||||
>
|
||||
{iconRight}
|
||||
</span>
|
||||
)}
|
||||
{fullWidth && !iconRight && iconLeft && (
|
||||
<span className="col-start-3 opacity-0">{iconLeft}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center",
|
||||
isLoading ? "invisible" : "visible",
|
||||
)}
|
||||
>
|
||||
{iconLeft && <span className="mr-2">{iconLeft}</span>}
|
||||
{children}
|
||||
{iconRight && <span className="ml-1">{iconRight}</span>}
|
||||
</div>
|
||||
</span>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
import { useRouter } from "next/router";
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxInput,
|
||||
ComboboxOption,
|
||||
ComboboxOptions,
|
||||
Dialog,
|
||||
DialogBackdrop,
|
||||
DialogPanel,
|
||||
} from "@headlessui/react";
|
||||
import { t } from "@lingui/macro";
|
||||
import { useState } from "react";
|
||||
import { HiDocumentText, HiFolder, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
type SearchResult =
|
||||
| {
|
||||
publicId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
slug: string;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "board";
|
||||
}
|
||||
| {
|
||||
publicId: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
boardPublicId: string;
|
||||
boardName: string;
|
||||
listName: string;
|
||||
updatedAt: Date | null;
|
||||
createdAt: Date;
|
||||
type: "card";
|
||||
};
|
||||
|
||||
export default function CommandPallette({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const { workspace } = useWorkspace();
|
||||
const router = useRouter();
|
||||
|
||||
// Debounce to avoid too many reqs
|
||||
const [debouncedQuery] = useDebounce(query, 300);
|
||||
|
||||
const {
|
||||
data: searchResults,
|
||||
isLoading,
|
||||
isFetched,
|
||||
isPlaceholderData,
|
||||
} = api.workspace.search.useQuery(
|
||||
{
|
||||
workspacePublicId: workspace.publicId,
|
||||
query: debouncedQuery,
|
||||
},
|
||||
{
|
||||
enabled: Boolean(workspace.publicId && debouncedQuery.trim().length > 0),
|
||||
placeholderData: (previousData) => previousData,
|
||||
},
|
||||
);
|
||||
|
||||
// Clear results when query is empty, otherwise show search results
|
||||
const results =
|
||||
debouncedQuery.trim().length === 0
|
||||
? []
|
||||
: ((searchResults ?? []) as SearchResult[]);
|
||||
|
||||
const hasSearched = Boolean(debouncedQuery.trim().length > 0);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className="relative z-50"
|
||||
open={isOpen}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<DialogBackdrop
|
||||
transition
|
||||
className="data-closed:opacity-0 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in fixed inset-0 bg-light-50 bg-opacity-40 transition-opacity dark:bg-dark-50 dark:bg-opacity-40"
|
||||
/>
|
||||
|
||||
<div className="fixed inset-0 z-50 w-screen overflow-y-auto">
|
||||
<div className="flex min-h-full items-start justify-center p-4 text-center sm:items-start sm:p-0">
|
||||
<DialogPanel
|
||||
transition
|
||||
className="data-closed:opacity-0 data-closed:translate-y-4 data-closed:sm:translate-y-0 data-closed:sm:scale-95 data-enter:duration-300 data-enter:ease-out data-leave:duration-200 data-leave:ease-in relative mt-[25vh] w-full max-w-[550px] transform divide-y divide-gray-100 overflow-hidden rounded-lg border border-light-600 bg-white/90 shadow-3xl-light backdrop-blur-[6px] transition-all dark:divide-white/10 dark:border-dark-600 dark:bg-dark-100/90 dark:shadow-3xl-dark"
|
||||
>
|
||||
<Combobox>
|
||||
<div className="grid grid-cols-1">
|
||||
<ComboboxInput
|
||||
autoFocus
|
||||
className="col-start-1 row-start-1 h-12 w-full border-0 bg-transparent pl-11 pr-4 text-sm text-light-900 placeholder:text-light-700 focus:outline-none focus:ring-0 dark:text-dark-900 dark:placeholder:text-dark-700"
|
||||
placeholder={t`Search boards and cards...`}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && results.length > 0) {
|
||||
event.preventDefault();
|
||||
|
||||
// Find the active option or fallback to first option
|
||||
const targetOption =
|
||||
document.querySelector(
|
||||
'[data-headlessui-state*="active"][role="option"]',
|
||||
) ?? document.querySelector('[role="option"]');
|
||||
|
||||
if (targetOption) {
|
||||
(targetOption as HTMLElement).click();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<HiMagnifyingGlass
|
||||
className="pointer-events-none col-start-1 row-start-1 ml-4 size-5 self-center text-light-700 dark:text-dark-700"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<ComboboxOptions
|
||||
static
|
||||
className={`max-h-72 scroll-py-2 overflow-y-auto py-2 ${
|
||||
isPlaceholderData ? "opacity-75" : ""
|
||||
}`}
|
||||
>
|
||||
{results.map((result) => {
|
||||
const url =
|
||||
result.type === "board"
|
||||
? `/boards/${result.publicId}`
|
||||
: `/cards/${result.publicId}`;
|
||||
|
||||
return (
|
||||
<ComboboxOption
|
||||
key={`${result.type}-${result.publicId}`}
|
||||
value={result}
|
||||
className="cursor-pointer select-none px-4 py-3 data-[focus]:bg-light-200 hover:bg-light-200 focus:outline-none dark:data-[focus]:bg-dark-200 dark:hover:bg-dark-200"
|
||||
onClick={() => {
|
||||
console.log("clicked", url);
|
||||
void router.push(url);
|
||||
onClose();
|
||||
setQuery("");
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5 flex-shrink-0">
|
||||
{result.type === "board" ? (
|
||||
<HiFolder className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
||||
) : (
|
||||
<HiDocumentText className="h-4 w-4 text-light-600 dark:text-dark-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
<div className="truncate text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{result.title}
|
||||
</div>
|
||||
{result.type === "card" && (
|
||||
<div className="truncate text-xs text-light-700 dark:text-dark-700">
|
||||
{`${t`in`} ${result.boardName} → ${result.listName}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ComboboxOption>
|
||||
);
|
||||
})}
|
||||
</ComboboxOptions>
|
||||
)}
|
||||
|
||||
{hasSearched &&
|
||||
!isLoading &&
|
||||
searchResults !== undefined &&
|
||||
results.length === 0 && (
|
||||
<div className="p-4 text-sm text-light-950 dark:text-dark-950">
|
||||
{t`No results found for "${debouncedQuery}".`}
|
||||
</div>
|
||||
)}
|
||||
</Combobox>
|
||||
</DialogPanel>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
@@ -11,7 +10,10 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useClickOutside } from "~/hooks/useClickOutside";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace, WorkspaceProvider } from "~/providers/workspace";
|
||||
import { useTheme } from "next-themes";
|
||||
import { WorkspaceProvider } from "~/providers/workspace";
|
||||
import FeedbackModal from "./FeedbackModal";
|
||||
import Modal from "./modal";
|
||||
import SideNavigation from "./SideNavigation";
|
||||
|
||||
interface DashboardProps {
|
||||
@@ -39,9 +41,8 @@ export default function Dashboard({
|
||||
rightPanel,
|
||||
hasRightPanel = false,
|
||||
}: DashboardProps) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { openModal } = useModal();
|
||||
const { availableWorkspaces, hasLoaded } = useWorkspace();
|
||||
const { theme } = useTheme();
|
||||
const { modalContentType } = useModal();
|
||||
|
||||
const { data: session, isPending: sessionLoading } = authClient.useSession();
|
||||
|
||||
@@ -89,13 +90,7 @@ export default function Dashboard({
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
openModal("NEW_WORKSPACE", undefined, undefined, false);
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
const isDarkMode = theme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { Instance as TippyInstance } from "tippy.js";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import {
|
||||
BubbleMenu,
|
||||
@@ -40,10 +39,9 @@ import {
|
||||
} from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import tippy from "tippy.js";
|
||||
import { Markdown } from "tiptap-markdown";
|
||||
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
import Mention from "@tiptap/extension-mention";
|
||||
import Avatar from "./Avatar";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
@@ -79,7 +77,7 @@ export interface RenderSuggestionsProps {
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceMember {
|
||||
export type WorkspaceMember = {
|
||||
publicId: string;
|
||||
user: {
|
||||
id: string;
|
||||
@@ -87,7 +85,7 @@ export interface WorkspaceMember {
|
||||
image: string | null;
|
||||
} | null;
|
||||
email: string;
|
||||
}
|
||||
};
|
||||
|
||||
const CommandsList = forwardRef<
|
||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||
@@ -202,11 +200,7 @@ const RenderSuggestions = () => {
|
||||
};
|
||||
};
|
||||
|
||||
interface MentionItem {
|
||||
id: string;
|
||||
label: string;
|
||||
image: string | null;
|
||||
}
|
||||
type MentionItem = { id: string; label: string; image: string | null };
|
||||
|
||||
const MentionList = forwardRef<
|
||||
{ onKeyDown: (props: SuggestionKeyDownProps) => boolean },
|
||||
@@ -244,32 +238,30 @@ const MentionList = forwardRef<
|
||||
return (
|
||||
<div className="w-56 rounded-md border-[1px] border-light-200 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-500 dark:bg-dark-200">
|
||||
<div className="max-h-[350px] overflow-y-auto p-1 scrollbar-thin scrollbar-thumb-light-200 dark:scrollbar-thumb-dark-300">
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => command(item)}
|
||||
className={twMerge(
|
||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={item.label}
|
||||
imageUrl={item.image ? getAvatarUrl(item.image) : undefined}
|
||||
email={item.label}
|
||||
/>
|
||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="flex items-center justify-start p-2">
|
||||
<span className="text-[12px] text-dark-900 dark:text-dark-1000">
|
||||
No results
|
||||
{items.length > 0 ? items.map((item, index) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => command(item)}
|
||||
className={twMerge(
|
||||
"group flex w-full items-center rounded-[5px] p-2 hover:bg-light-200 dark:hover:bg-dark-300",
|
||||
index === selectedIndex && "bg-light-200 dark:bg-dark-300",
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="xs"
|
||||
name={item.label}
|
||||
imageUrl={
|
||||
item.image ? getAvatarUrl(item.image) : undefined
|
||||
}
|
||||
email={item.label}
|
||||
/>
|
||||
<span className="ml-3 text-[12px] font-medium text-dark-900 dark:text-dark-1000">
|
||||
{item.label}
|
||||
</span>
|
||||
</button>
|
||||
)) : (
|
||||
<div className="flex items-center justify-start p-2">
|
||||
<span className="text-dark-900 text-[12px] dark:text-dark-1000">No results</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -444,7 +436,6 @@ export default function Editor({
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Markdown,
|
||||
Placeholder.configure({
|
||||
placeholder: readOnly
|
||||
? ""
|
||||
@@ -471,37 +462,35 @@ export default function Editor({
|
||||
}),
|
||||
Mention.configure({
|
||||
HTMLAttributes: {
|
||||
class: "mention",
|
||||
class: 'mention',
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
items: ({ query }: { query: string }) => {
|
||||
const all: MentionItem[] = workspaceMembers.map(
|
||||
(member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}),
|
||||
);
|
||||
const all: MentionItem[] = workspaceMembers.map((member: WorkspaceMember) => ({
|
||||
id: member.publicId,
|
||||
label: member?.user?.name ?? member.email,
|
||||
image: member?.user?.image ?? null,
|
||||
}));
|
||||
const q = query.toLowerCase();
|
||||
return all.filter((u) => u.label.toLowerCase().includes(q));
|
||||
},
|
||||
command: ({ editor, range, props }: any) => {
|
||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(mentionHTML)
|
||||
.focus()
|
||||
.run();
|
||||
const mentionHTML = `<span data-type="mention" data-id="${props.id}" data-label="${props.label}">@${props.label}</span> `;
|
||||
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent(mentionHTML)
|
||||
.focus()
|
||||
.run();
|
||||
},
|
||||
render: renderMentionSuggestions,
|
||||
},
|
||||
renderText({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
render: renderMentionSuggestions,
|
||||
},
|
||||
renderText({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
}),
|
||||
],
|
||||
content,
|
||||
|
||||
@@ -24,10 +24,7 @@ import { api } from "~/utils/api";
|
||||
import LoadingSpinner from "./LoadingSpinner";
|
||||
|
||||
const schema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(1, { message: t`Workspace name is required` })
|
||||
.max(64, { message: t`Workspace name cannot exceed 64 characters` }),
|
||||
name: z.string().min(1, { message: t`Workspace name is required` }),
|
||||
slug: z
|
||||
.string()
|
||||
.min(3, {
|
||||
|
||||
@@ -2,34 +2,23 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import { Button } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { env } from "next-runtime-env";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { HiBolt } from "react-icons/hi2";
|
||||
import {
|
||||
TbLayoutSidebarLeftCollapse,
|
||||
TbLayoutSidebarLeftExpand,
|
||||
} from "react-icons/tb";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import type { Subscription } from "@kan/shared/utils";
|
||||
import { hasActiveSubscription } from "@kan/shared/utils";
|
||||
|
||||
import boardsIconDark from "~/assets/boards-dark.json";
|
||||
import boardsIconLight from "~/assets/boards-light.json";
|
||||
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 ButtonComponent from "~/components/Button";
|
||||
import ReactiveButton from "~/components/ReactiveButton";
|
||||
import UserMenu from "~/components/UserMenu";
|
||||
import WorkspaceMenu from "~/components/WorkspaceMenu";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import { api } from "~/utils/api";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
interface SideNavigationProps {
|
||||
user: UserType;
|
||||
@@ -48,23 +37,13 @@ export default function SideNavigation({
|
||||
onCloseSideNav,
|
||||
}: SideNavigationProps) {
|
||||
const router = useRouter();
|
||||
const { workspace } = useWorkspace();
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const [isInitialised, setIsInitialised] = useState(false);
|
||||
const { openModal } = useModal();
|
||||
|
||||
const { data: workspaceData } = api.workspace.byId.useQuery({
|
||||
workspacePublicId: workspace.publicId,
|
||||
});
|
||||
|
||||
const subscriptions = workspaceData?.subscriptions as
|
||||
| Subscription[]
|
||||
| undefined;
|
||||
|
||||
useEffect(() => {
|
||||
const savedState = localStorage.getItem("kan_sidebar-collapsed");
|
||||
if (savedState !== null) {
|
||||
setIsCollapsed(Boolean(JSON.parse(savedState)));
|
||||
setIsCollapsed(JSON.parse(savedState));
|
||||
}
|
||||
setIsInitialised(true);
|
||||
}, []);
|
||||
@@ -80,9 +59,7 @@ export default function SideNavigation({
|
||||
|
||||
const { pathname } = router;
|
||||
|
||||
const { resolvedTheme } = useTheme();
|
||||
|
||||
const isCloudEnv = env("NEXT_PUBLIC_KAN_ENV") === "cloud";
|
||||
const { theme, resolvedTheme } = useTheme();
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
|
||||
@@ -92,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",
|
||||
@@ -122,10 +94,10 @@ export default function SideNavigation({
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<div className="hidden h-[45px] items-center justify-between pb-3 md:flex">
|
||||
<div className="hidden h-14 items-center justify-between pb-[18px] pt-1.5 md:flex">
|
||||
{!isCollapsed && (
|
||||
<Link href="/" className="block">
|
||||
<h1 className="pl-2 text-[16px] font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
<h1 className="pl-2 text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
@@ -169,7 +141,7 @@ export default function SideNavigation({
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<UserMenu
|
||||
email={user.email ?? ""}
|
||||
imageUrl={user.image ?? undefined}
|
||||
@@ -177,33 +149,6 @@ export default function SideNavigation({
|
||||
isCollapsed={isCollapsed}
|
||||
onCloseSideNav={onCloseSideNav}
|
||||
/>
|
||||
{isCloudEnv &&
|
||||
!hasActiveSubscription(subscriptions, "pro") &&
|
||||
!hasActiveSubscription(subscriptions, "team") && (
|
||||
<div className={twMerge(isCollapsed && "flex justify-center")}>
|
||||
{isCollapsed ? (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
aria-label="Upgrade to Pro"
|
||||
title="Upgrade to Pro"
|
||||
iconOnly
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
/>
|
||||
) : (
|
||||
<ButtonComponent
|
||||
iconLeft={<HiBolt />}
|
||||
fullWidth
|
||||
variant="secondary"
|
||||
href="/settings/workspace?upgrade=pro"
|
||||
onClick={() => openModal("UPGRADE_TO_PRO")}
|
||||
>
|
||||
{t`Upgrade to Pro`}
|
||||
</ButtonComponent>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
</>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { CgDarkMode } from "react-icons/cg";
|
||||
import { useTheme } from "next-themes";
|
||||
|
||||
const ThemeToggle = () => {
|
||||
const { theme, resolvedTheme, setTheme } = useTheme();
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === "light" ? "dark" : "light");
|
||||
@@ -12,11 +12,11 @@ const ThemeToggle = () => {
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="rounded p-1.5 transition-all hover:bg-light-200 dark:hover:bg-dark-100"
|
||||
aria-label={`Switch to ${resolvedTheme === "light" ? "dark" : "light"} theme`}
|
||||
aria-label={`Switch to ${theme === "light" ? "dark" : "light"} theme`}
|
||||
>
|
||||
<CgDarkMode
|
||||
className={`h-4 w-4 text-light-900 transition-transform duration-200 dark:text-dark-900 ${
|
||||
resolvedTheme === "dark" ? "rotate-180" : "rotate-0"
|
||||
theme === "dark" ? "rotate-180" : "rotate-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
@@ -5,12 +5,10 @@ const Toggle = ({
|
||||
isChecked,
|
||||
onChange,
|
||||
label,
|
||||
disabled,
|
||||
}: {
|
||||
isChecked: boolean;
|
||||
onChange: () => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<div className="mr-4 flex items-center justify-end">
|
||||
<span className="mr-2 text-xs text-light-900 dark:text-dark-900">
|
||||
@@ -19,7 +17,6 @@ const Toggle = ({
|
||||
<Switch
|
||||
checked={isChecked}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
className={twMerge(
|
||||
"relative inline-flex h-4 w-6 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent bg-light-800 transition-colors duration-200 ease-in-out focus:outline-none dark:bg-dark-800",
|
||||
isChecked && "bg-indigo-600 dark:bg-indigo-600",
|
||||
|
||||
@@ -3,7 +3,6 @@ import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useTheme } from "next-themes";
|
||||
import { Fragment } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
@@ -11,6 +10,7 @@ import { authClient } from "@kan/auth/client";
|
||||
|
||||
import { useIsMobile } from "~/hooks/useMediaQuery";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useTheme } from "next-themes";
|
||||
import { getAvatarUrl } from "~/utils/helpers";
|
||||
|
||||
interface UserMenuProps {
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Button, Menu, Transition } from "@headlessui/react";
|
||||
import { Menu, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
|
||||
import { Fragment } from "react";
|
||||
import { HiCheck } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { useWorkspace } from "~/providers/workspace";
|
||||
import CommandPallette from "./CommandPallette";
|
||||
|
||||
export default function WorkspaceMenu({
|
||||
isCollapsed = false,
|
||||
@@ -16,143 +15,110 @@ export default function WorkspaceMenu({
|
||||
const { workspace, isLoading, availableWorkspaces, switchWorkspace } =
|
||||
useWorkspace();
|
||||
const { openModal } = useModal();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "k") {
|
||||
event.preventDefault();
|
||||
setIsOpen(true);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<CommandPallette isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu as="div" className="relative inline-block w-full pb-3 text-left">
|
||||
<div>
|
||||
{isLoading ? (
|
||||
<div className={twMerge("mb-1 flex", isCollapsed && "md:p-1.5")}>
|
||||
<div className="h-6 w-6 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
|
||||
<div
|
||||
className={twMerge(
|
||||
"flex items-center justify-start gap-1",
|
||||
isCollapsed && "md:flex-col-reverse md:items-center",
|
||||
"ml-2 h-6 w-[150px] animate-pulse rounded-md bg-light-200 dark:bg-dark-200",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Menu.Button
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] w-full items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed && "md:mb-1.5 md:h-9 md:p-1",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
<Menu.Button
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"mb-1 flex h-[34px] min-w-0 flex-1 items-center justify-start rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
|
||||
isCollapsed &&
|
||||
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
title={isCollapsed ? workspace.name : undefined}
|
||||
>
|
||||
<span className="inline-flex h-6 w-6 items-center justify-center rounded-md bg-indigo-700">
|
||||
<span className="text-xs font-bold leading-none text-white">
|
||||
{workspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 min-w-0 flex-1 truncate text-left text-sm font-bold text-neutral-900 dark:text-dark-1000",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
{workspace.name}
|
||||
</span>
|
||||
{workspace.plan === "pro" && (
|
||||
<span
|
||||
className={twMerge(
|
||||
"ml-2 inline-flex items-center rounded-md bg-indigo-100 px-2 py-1 text-[10px] font-medium text-indigo-700",
|
||||
isCollapsed && "md:hidden",
|
||||
)}
|
||||
>
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
</Menu.Button>
|
||||
<Button
|
||||
className={twMerge(
|
||||
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
|
||||
isCollapsed && "md:mb-2 md:h-9 md:w-9",
|
||||
)}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
Pro
|
||||
</span>
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</Menu.Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<Menu.Items
|
||||
className={twMerge(
|
||||
"absolute left-0 z-10 origin-top-left rounded-md border border-light-600 bg-light-50 shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none dark:border-dark-600 dark:bg-dark-300",
|
||||
isCollapsed ? "w-48" : "w-full",
|
||||
)}
|
||||
>
|
||||
<div className="p-1">
|
||||
{availableWorkspaces.map((availableWorkspace) => (
|
||||
<div key={availableWorkspace.publicId} className="flex">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => switchWorkspace(availableWorkspace)}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-sm text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center">
|
||||
<span className="inline-flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-[5px] bg-indigo-700">
|
||||
<span className="text-xs font-medium leading-none text-white">
|
||||
{availableWorkspace.name.charAt(0).toUpperCase()}
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</>
|
||||
</span>
|
||||
<span className="ml-2 truncate text-xs font-medium">
|
||||
{availableWorkspace.name}
|
||||
</span>
|
||||
</div>
|
||||
{workspace.publicId === availableWorkspace.publicId && (
|
||||
<span>
|
||||
<HiCheck className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t-[1px] border-light-600 p-1 dark:border-dark-500">
|
||||
<Menu.Item>
|
||||
<button
|
||||
onClick={() => openModal("NEW_WORKSPACE")}
|
||||
className="flex w-full items-center justify-between rounded-[5px] px-3 py-2 text-left text-xs text-neutral-900 hover:bg-light-200 dark:text-dark-1000 dark:hover:bg-dark-400"
|
||||
>
|
||||
{t`Create workspace`}
|
||||
</button>
|
||||
</Menu.Item>
|
||||
</div>
|
||||
</Menu.Items>
|
||||
</Transition>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ interface Props {
|
||||
modalSize?: "sm" | "md" | "lg";
|
||||
positionFromTop?: "sm" | "md" | "lg";
|
||||
isVisible?: boolean;
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
const Modal: React.FC<Props> = ({
|
||||
@@ -16,17 +15,10 @@ const Modal: React.FC<Props> = ({
|
||||
modalSize = "sm",
|
||||
positionFromTop = "md",
|
||||
isVisible,
|
||||
closeOnClickOutside,
|
||||
}) => {
|
||||
const {
|
||||
isOpen,
|
||||
closeModal,
|
||||
closeOnClickOutside: modalCloseOnClickOutside,
|
||||
} = useModal();
|
||||
const { isOpen, closeModal } = useModal();
|
||||
|
||||
const shouldShow = isVisible ?? isOpen;
|
||||
const shouldCloseOnClickOutside =
|
||||
closeOnClickOutside ?? modalCloseOnClickOutside;
|
||||
const shouldShow = isVisible !== undefined ? isVisible : isOpen;
|
||||
|
||||
const modalSizeMap = {
|
||||
sm: "max-w-[400px]",
|
||||
@@ -42,11 +34,7 @@ const Modal: React.FC<Props> = ({
|
||||
|
||||
return (
|
||||
<Transition.Root show={shouldShow} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={shouldCloseOnClickOutside ? closeModal : () => null}
|
||||
>
|
||||
<Dialog as="div" className="relative z-50" onClose={closeModal}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
|
||||
@@ -90,7 +90,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_BASE_URL: z.string().url(),
|
||||
NEXT_PUBLIC_STORAGE_URL: z.string().url().optional(),
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME: z.string().optional(),
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: z.string().optional(),
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: z
|
||||
.string()
|
||||
@@ -126,8 +125,6 @@ export const env = createEnv({
|
||||
NEXT_PUBLIC_BASE_URL: process.env.NEXT_PUBLIC_BASE_URL,
|
||||
NEXT_PUBLIC_STORAGE_URL: process.env.NEXT_PUBLIC_STORAGE_URL,
|
||||
NEXT_PUBLIC_AVATAR_BUCKET_NAME: process.env.NEXT_PUBLIC_AVATAR_BUCKET_NAME,
|
||||
NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME:
|
||||
process.env.NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME,
|
||||
NEXT_PUBLIC_STORAGE_DOMAIN: process.env.NEXT_PUBLIC_STORAGE_DOMAIN,
|
||||
NEXT_PUBLIC_ALLOW_CREDENTIALS: process.env.NEXT_PUBLIC_ALLOW_CREDENTIALS,
|
||||
NEXT_PUBLIC_DISABLE_SIGN_UP: process.env.NEXT_PUBLIC_DISABLE_SIGN_UP,
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
const { url, filename } = req.query;
|
||||
|
||||
if (!url || typeof url !== "string") {
|
||||
return res.status(400).json({
|
||||
message: "url parameter is required",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const downloadUrl = decodeURIComponent(url);
|
||||
const downloadFilename =
|
||||
(typeof filename === "string" ? decodeURIComponent(filename) : null) ??
|
||||
"attachment";
|
||||
|
||||
const upstream = await fetch(downloadUrl);
|
||||
|
||||
if (!upstream.ok) {
|
||||
return res.status(upstream.status).json({
|
||||
message: "Failed to fetch attachment",
|
||||
});
|
||||
}
|
||||
|
||||
const contentType =
|
||||
upstream.headers.get("Content-Type") ?? "application/octet-stream";
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="${downloadFilename}"`,
|
||||
);
|
||||
|
||||
const buffer = await upstream.arrayBuffer();
|
||||
return res.send(Buffer.from(buffer));
|
||||
} catch (error) {
|
||||
console.error("Error downloading attachment:", error);
|
||||
return res.status(500).json({ message: "Failed to download attachment" });
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
|
||||
export default async function handler(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse,
|
||||
) {
|
||||
if (req.method !== "GET") {
|
||||
return res.status(405).json({ message: "Method not allowed" });
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("https://formbricks.com/api/oss-friends");
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch from Formbricks");
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
return res.status(200).json(data);
|
||||
} catch (error) {
|
||||
console.error("Error fetching OSS friends:", error);
|
||||
return res.status(500).json({ message: "Failed to fetch OSS friends" });
|
||||
}
|
||||
}
|
||||
@@ -94,9 +94,6 @@ export default async function handler(
|
||||
quantity: 1,
|
||||
},
|
||||
],
|
||||
subscription_data: {
|
||||
trial_period_days: 14,
|
||||
},
|
||||
success_url: `${env("NEXT_PUBLIC_BASE_URL")}${successUrl}`,
|
||||
cancel_url: `${env("NEXT_PUBLIC_BASE_URL")}${cancelUrl}`,
|
||||
client_reference_id: workspacePublicId,
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import OSSFriendsView from "~/views/oss-friends";
|
||||
|
||||
export default function OSSFriends() {
|
||||
return <OSSFriendsView />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -4,7 +4,6 @@ interface ModalState {
|
||||
contentType: string;
|
||||
entityId?: string;
|
||||
entityLabel?: string;
|
||||
closeOnClickOutside?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -17,7 +16,6 @@ interface ModalContextType {
|
||||
contentType: string,
|
||||
entityId?: string,
|
||||
entityLabel?: string,
|
||||
closeOnClickOutside?: boolean,
|
||||
) => void;
|
||||
closeModal: () => void;
|
||||
closeModals: (count: number) => void;
|
||||
@@ -25,7 +23,6 @@ interface ModalContextType {
|
||||
modalContentType: string;
|
||||
entityId: string;
|
||||
entityLabel: string;
|
||||
closeOnClickOutside: boolean;
|
||||
modalStates: Record<string, any>;
|
||||
setModalState: (modalType: string, state: any) => void;
|
||||
getModalState: (modalType: string) => any;
|
||||
@@ -44,33 +41,11 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
|
||||
const modalContentType = currentModal?.contentType || "";
|
||||
const entityId = currentModal?.entityId || "";
|
||||
const entityLabel = currentModal?.entityLabel || "";
|
||||
const closeOnClickOutside = currentModal?.closeOnClickOutside ?? true;
|
||||
|
||||
const openModal = useCallback(
|
||||
(
|
||||
contentType: string,
|
||||
entityId?: string,
|
||||
entityLabel?: string,
|
||||
closeOnClickOutside?: boolean,
|
||||
) => {
|
||||
const newModal: ModalState = {
|
||||
contentType,
|
||||
entityId,
|
||||
entityLabel,
|
||||
closeOnClickOutside,
|
||||
};
|
||||
setModalStack((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.contentType === newModal.contentType &&
|
||||
last.entityId === newModal.entityId &&
|
||||
last.entityLabel === newModal.entityLabel
|
||||
) {
|
||||
return prev; // prevent stacking duplicate modal on top
|
||||
}
|
||||
return [...prev, newModal];
|
||||
});
|
||||
(contentType: string, entityId?: string, entityLabel?: string) => {
|
||||
const newModal: ModalState = { contentType, entityId, entityLabel };
|
||||
setModalStack((prev) => [...prev, newModal]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -132,7 +107,6 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
|
||||
modalContentType,
|
||||
entityId,
|
||||
entityLabel,
|
||||
closeOnClickOutside,
|
||||
modalStates,
|
||||
setModalState,
|
||||
getModalState,
|
||||
|
||||
@@ -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,6 +1,3 @@
|
||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
|
||||
|
||||
import Avatar from "~/components/Avatar";
|
||||
import Badge from "~/components/Badge";
|
||||
import CircularProgress from "~/components/CircularProgress";
|
||||
@@ -12,9 +9,6 @@ const Card = ({
|
||||
labels,
|
||||
members,
|
||||
checklists,
|
||||
description,
|
||||
comments,
|
||||
attachments,
|
||||
}: {
|
||||
title: string;
|
||||
labels: { name: string; colourCode: string | null }[];
|
||||
@@ -33,9 +27,6 @@ const Card = ({
|
||||
index: number;
|
||||
}[];
|
||||
}[];
|
||||
description: string | null;
|
||||
comments: { publicId: string }[];
|
||||
attachments?: { publicId: string }[];
|
||||
}) => {
|
||||
const completedItems = checklists.reduce((acc, checklist) => {
|
||||
return acc + checklist.items.filter((item) => item.completed).length;
|
||||
@@ -48,19 +39,10 @@ const Card = ({
|
||||
const progress =
|
||||
totalItems > 0 ? Math.round((completedItems / totalItems) * 100) : 0;
|
||||
|
||||
const hasDescription =
|
||||
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
|
||||
const hasAttachments = attachments && attachments.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col rounded-md border border-light-200 bg-light-50 px-3 py-2 text-sm text-neutral-900 dark:border-dark-200 dark:bg-dark-200 dark:text-dark-1000 dark:hover:bg-dark-300">
|
||||
<span>{title}</span>
|
||||
{labels.length ||
|
||||
members.length ||
|
||||
checklists.length > 0 ||
|
||||
hasDescription ||
|
||||
comments.length > 0 ||
|
||||
hasAttachments ? (
|
||||
{labels.length || members.length || checklists.length > 0 ? (
|
||||
<div className="mt-2 flex flex-col justify-end">
|
||||
<div className="space-x-0.5">
|
||||
{labels.map((label) => (
|
||||
@@ -70,56 +52,37 @@ const Card = ({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{hasDescription && (
|
||||
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
|
||||
<HiBars3BottomLeft className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
{comments.length > 0 && (
|
||||
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
|
||||
<HiChatBubbleLeft className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
{hasAttachments && (
|
||||
<div className="flex items-center gap-1 text-light-700 dark:text-dark-800">
|
||||
<HiOutlinePaperClip className="h-4 w-4" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{checklists.length > 0 && (
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-600">
|
||||
<CircularProgress
|
||||
progress={progress || 2}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[10px] text-light-900 dark:text-dark-950">
|
||||
{completedItems}/{totalItems}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{members.length > 0 && (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{members.map(({ user, email }) => {
|
||||
const avatarUrl = user?.image
|
||||
? getAvatarUrl(user.image)
|
||||
: undefined;
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
{checklists.length > 0 && (
|
||||
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-600">
|
||||
<CircularProgress
|
||||
progress={progress || 2}
|
||||
size="sm"
|
||||
className="flex-shrink-0"
|
||||
/>
|
||||
<span className="text-[10px] text-light-900 dark:text-dark-950">
|
||||
{completedItems}/{totalItems}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{members.length > 0 && (
|
||||
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
|
||||
{members.map(({ user, email }) => {
|
||||
const avatarUrl = user?.image
|
||||
? getAvatarUrl(user.image)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Avatar
|
||||
name={user?.name ?? ""}
|
||||
email={user?.email ?? email}
|
||||
imageUrl={avatarUrl}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
return (
|
||||
<Avatar
|
||||
name={user?.name ?? ""}
|
||||
email={user?.email ?? email}
|
||||
imageUrl={avatarUrl}
|
||||
size="sm"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,6 @@ interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
}
|
||||
|
||||
export function DeleteListConfirmation({
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useRouter } from "next/router";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import {
|
||||
HiMiniXMark,
|
||||
HiOutlineSquare3Stack3D,
|
||||
HiOutlineTag,
|
||||
HiOutlineUserCircle,
|
||||
} from "react-icons/hi2";
|
||||
@@ -33,22 +32,15 @@ interface Label {
|
||||
colourCode: string | null;
|
||||
}
|
||||
|
||||
interface List {
|
||||
publicId: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
const Filters = ({
|
||||
position = "right",
|
||||
labels,
|
||||
members,
|
||||
lists,
|
||||
isLoading,
|
||||
}: {
|
||||
position?: "left" | "right";
|
||||
labels: Label[];
|
||||
members: Member[];
|
||||
lists: List[];
|
||||
isLoading: boolean;
|
||||
}) => {
|
||||
const router = useRouter();
|
||||
@@ -60,7 +52,7 @@ const Filters = ({
|
||||
try {
|
||||
await router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...router.query, members: [], labels: [], lists: [] },
|
||||
query: { ...router.query, members: [], labels: [] },
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -93,12 +85,6 @@ const Filters = ({
|
||||
leftIcon: <LabelIcon colourCode={label.colourCode} />,
|
||||
}));
|
||||
|
||||
const formattedLists = lists.map((list) => ({
|
||||
key: list.publicId,
|
||||
value: list.name,
|
||||
selected: !!router.query.lists?.includes(list.publicId),
|
||||
}));
|
||||
|
||||
const groups = [
|
||||
...(formattedMembers.length
|
||||
? [
|
||||
@@ -116,16 +102,6 @@ const Filters = ({
|
||||
icon: <HiOutlineTag size={16} />,
|
||||
items: formattedLabels,
|
||||
},
|
||||
...(formattedLists.length
|
||||
? [
|
||||
{
|
||||
key: "lists",
|
||||
label: t`Lists`,
|
||||
icon: <HiOutlineSquare3Stack3D size={16} />,
|
||||
items: formattedLists,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
];
|
||||
|
||||
const handleSelect = async (
|
||||
@@ -155,7 +131,6 @@ const Filters = ({
|
||||
const numOfFilters = [
|
||||
...formatToArray(router.query.members),
|
||||
...formatToArray(router.query.labels),
|
||||
...formatToArray(router.query.lists),
|
||||
].length;
|
||||
|
||||
return (
|
||||
|
||||
@@ -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";
|
||||
@@ -33,18 +32,15 @@ interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
}
|
||||
|
||||
interface NewCardFormProps {
|
||||
isTemplate: boolean;
|
||||
boardPublicId: string;
|
||||
listPublicId: string;
|
||||
queryParams: QueryParams;
|
||||
}
|
||||
|
||||
export function NewCardForm({
|
||||
isTemplate,
|
||||
boardPublicId,
|
||||
listPublicId,
|
||||
queryParams,
|
||||
@@ -89,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]);
|
||||
}
|
||||
@@ -104,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();
|
||||
@@ -326,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>
|
||||
@@ -354,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}
|
||||
|
||||
@@ -21,7 +21,6 @@ interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
}
|
||||
|
||||
export function NewListForm({
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,6 @@ interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
}
|
||||
|
||||
export function UpdateBoardSlugForm({
|
||||
|
||||
@@ -11,7 +11,6 @@ interface QueryParams {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
}
|
||||
|
||||
const VisibilityButton = ({
|
||||
|
||||
@@ -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,18 +67,10 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
});
|
||||
};
|
||||
|
||||
const queryParams: {
|
||||
boardPublicId: string;
|
||||
members: string[];
|
||||
labels: string[];
|
||||
lists: string[];
|
||||
type: "regular" | "template";
|
||||
} = {
|
||||
const queryParams = {
|
||||
boardPublicId: boardId ?? "",
|
||||
members: formatToArray(router.query.members),
|
||||
labels: formatToArray(router.query.labels),
|
||||
lists: formatToArray(router.query.lists),
|
||||
type: isTemplate ? "template" : "regular",
|
||||
};
|
||||
|
||||
const {
|
||||
@@ -225,7 +208,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
};
|
||||
|
||||
const onDragEnd = ({
|
||||
source: _source,
|
||||
source,
|
||||
destination,
|
||||
draggableId,
|
||||
type,
|
||||
@@ -258,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
|
||||
@@ -279,7 +259,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
isVisible={isOpen && modalContentType === "NEW_CARD"}
|
||||
>
|
||||
<NewCardForm
|
||||
isTemplate={!!isTemplate}
|
||||
boardPublicId={boardId ?? ""}
|
||||
listPublicId={selectedPublicListId}
|
||||
queryParams={queryParams}
|
||||
@@ -342,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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -360,7 +328,7 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={`${boardData?.name ?? (isTemplate ? t`Board` : t`Template`)} | ${workspace.name ?? t`Workspace`}`}
|
||||
title={`${boardData?.name ?? t`Board`} | ${workspace.name ?? t`Workspace`}`}
|
||||
/>
|
||||
<div className="relative flex h-full flex-col">
|
||||
<PatternedBackground />
|
||||
@@ -386,47 +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,
|
||||
)}
|
||||
lists={boardData.allLists}
|
||||
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
|
||||
@@ -441,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>
|
||||
|
||||
@@ -528,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",
|
||||
@@ -549,11 +492,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
labels={card.labels}
|
||||
members={card.members}
|
||||
checklists={card.checklists ?? []}
|
||||
description={
|
||||
card.description ?? null
|
||||
}
|
||||
comments={card.comments ?? []}
|
||||
attachments={card.attachments}
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useEffect } from "react";
|
||||
import { HiArrowDownTray, HiOutlinePlusSmall } from "react-icons/hi2";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
@@ -12,33 +13,35 @@ 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 { workspace } = useWorkspace();
|
||||
const { availableWorkspaces, workspace, hasLoaded } = useWorkspace();
|
||||
|
||||
useEffect(() => {
|
||||
if (hasLoaded && availableWorkspaces.length === 0) {
|
||||
openModal("NEW_WORKSPACE");
|
||||
}
|
||||
}, [hasLoaded, availableWorkspaces.length, openModal]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={t`${isTemplate ? "Templates" : "Boards"} | ${workspace.name ?? t`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"
|
||||
@@ -64,7 +67,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
modalSize="sm"
|
||||
isVisible={isOpen && modalContentType === "NEW_BOARD"}
|
||||
>
|
||||
<NewBoardForm isTemplate={!!isTemplate} />
|
||||
<NewBoardForm />
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
@@ -83,7 +86,7 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
</>
|
||||
|
||||
<div className="flex h-full flex-row">
|
||||
<BoardsList isTemplate={!!isTemplate} />
|
||||
<BoardsList />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Fragment, useEffect, useState } from "react";
|
||||
import {
|
||||
HiArrowDownTray,
|
||||
HiChevronLeft,
|
||||
HiChevronRight,
|
||||
HiDocumentText,
|
||||
HiOutlineTrash,
|
||||
HiXMark,
|
||||
} from "react-icons/hi2";
|
||||
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
interface Attachment {
|
||||
publicId: string;
|
||||
contentType: string;
|
||||
url: string | null;
|
||||
originalFilename: string | null;
|
||||
s3Key: string;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
export function AttachmentThumbnails({
|
||||
attachments,
|
||||
cardPublicId,
|
||||
isReadOnly = false,
|
||||
}: {
|
||||
attachments?: Attachment[];
|
||||
cardPublicId: string;
|
||||
isReadOnly?: boolean;
|
||||
}) {
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
const imageAttachments =
|
||||
attachments?.filter(
|
||||
(attachment) =>
|
||||
attachment.contentType.startsWith("image/") && attachment.url,
|
||||
) ?? [];
|
||||
|
||||
const nonImageAttachments =
|
||||
attachments?.filter(
|
||||
(attachment) =>
|
||||
!attachment.contentType.startsWith("image/") && attachment.url,
|
||||
) ?? [];
|
||||
|
||||
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
|
||||
|
||||
const deleteAttachment = api.attachment.delete.useMutation({
|
||||
onMutate: async (args) => {
|
||||
if (isReadOnly) return;
|
||||
|
||||
await utils.card.byId.cancel({ cardPublicId });
|
||||
const currentState = utils.card.byId.getData({ cardPublicId });
|
||||
|
||||
utils.card.byId.setData({ cardPublicId }, (oldCard) => {
|
||||
if (!oldCard) return oldCard;
|
||||
const updatedAttachments = oldCard.attachments.filter(
|
||||
(attachment) => attachment.publicId !== args.attachmentPublicId,
|
||||
);
|
||||
return { ...oldCard, attachments: updatedAttachments };
|
||||
});
|
||||
|
||||
return { previousState: currentState };
|
||||
},
|
||||
onError: (_error, _args, context) => {
|
||||
if (isReadOnly) return;
|
||||
utils.card.byId.setData({ cardPublicId }, context?.previousState);
|
||||
showPopup({
|
||||
header: t`Unable to delete attachment`,
|
||||
message: t`Please try again later, or contact customer support.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
if (isReadOnly) return;
|
||||
// Close viewer if the deleted image was being viewed
|
||||
setSelectedIndex(null);
|
||||
},
|
||||
onSettled: async () => {
|
||||
if (isReadOnly) return;
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
},
|
||||
});
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
if (selectedIndex === null) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
setSelectedIndex(null);
|
||||
} else if (e.key === "ArrowLeft") {
|
||||
setSelectedIndex((prev) => {
|
||||
if (prev === null) return null;
|
||||
return prev === 0 ? imageAttachments.length - 1 : prev - 1;
|
||||
});
|
||||
} else if (e.key === "ArrowRight") {
|
||||
setSelectedIndex((prev) => {
|
||||
if (prev === null) return null;
|
||||
return prev === imageAttachments.length - 1 ? 0 : prev + 1;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [selectedIndex, imageAttachments.length]);
|
||||
|
||||
if (imageAttachments.length === 0 && nonImageAttachments.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const openViewer = (index: number) => {
|
||||
setSelectedIndex(index);
|
||||
};
|
||||
|
||||
const closeViewer = () => {
|
||||
setSelectedIndex(null);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
if (selectedIndex === null) return;
|
||||
const newIndex =
|
||||
selectedIndex === 0 ? imageAttachments.length - 1 : selectedIndex - 1;
|
||||
setSelectedIndex(newIndex);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
if (selectedIndex === null) return;
|
||||
const newIndex =
|
||||
selectedIndex === imageAttachments.length - 1 ? 0 : selectedIndex + 1;
|
||||
setSelectedIndex(newIndex);
|
||||
};
|
||||
|
||||
const handleDownload = (attachment: Attachment) => {
|
||||
if (!attachment.url) {
|
||||
showPopup({
|
||||
header: t`Download failed`,
|
||||
message: t`No download URL available for this attachment.`,
|
||||
icon: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadUrl = `/api/download/attatchment?url=${encodeURIComponent(attachment.url)}&filename=${encodeURIComponent(attachment.originalFilename ?? "attachment")}`;
|
||||
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.style.display = "none";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
};
|
||||
|
||||
const selectedAttachment =
|
||||
selectedIndex !== null ? imageAttachments[selectedIndex] : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap gap-2 pt-1">
|
||||
{imageAttachments.map((attachment, index) => {
|
||||
if (!attachment.url) return null;
|
||||
return (
|
||||
<AttachmentThumbnail
|
||||
key={attachment.publicId}
|
||||
attachment={{
|
||||
publicId: attachment.publicId,
|
||||
url: attachment.url,
|
||||
originalFilename: attachment.originalFilename ?? "",
|
||||
contentType: attachment.contentType,
|
||||
}}
|
||||
onClick={() => openViewer(index)}
|
||||
isImage={true}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{nonImageAttachments.length > 0 && (
|
||||
<div className="mb-3 flex flex-col gap-2">
|
||||
{nonImageAttachments.map((attachment) => {
|
||||
if (!attachment.url) return null;
|
||||
return (
|
||||
<FileListItem
|
||||
key={attachment.publicId}
|
||||
attachment={attachment}
|
||||
onDownload={() => handleDownload(attachment)}
|
||||
onDelete={
|
||||
isReadOnly
|
||||
? undefined
|
||||
: () => {
|
||||
deleteAttachment.mutate({
|
||||
attachmentPublicId: attachment.publicId,
|
||||
});
|
||||
}
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Transition.Root show={selectedIndex !== null} as={Fragment}>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-50"
|
||||
onClose={() => {
|
||||
// Dialog closing is handled by the background overlay click
|
||||
}}
|
||||
static
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div
|
||||
className="fixed inset-0 bg-light-50 transition-opacity dark:bg-dark-50"
|
||||
onClick={(e) => {
|
||||
// Only close if clicking directly on the background, not on buttons
|
||||
if (e.target === e.currentTarget) {
|
||||
closeViewer();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-10 overflow-y-auto">
|
||||
{selectedIndex !== null && selectedAttachment && (
|
||||
<div
|
||||
className="fixed left-2 top-2 z-20 flex gap-1"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{!isReadOnly && (
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
deleteAttachment.mutate({
|
||||
attachmentPublicId: selectedAttachment.publicId,
|
||||
});
|
||||
}}
|
||||
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||
aria-label="Delete image"
|
||||
disabled={deleteAttachment.isPending}
|
||||
>
|
||||
<HiOutlineTrash className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDownload(selectedAttachment);
|
||||
}}
|
||||
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||
aria-label="Download image"
|
||||
>
|
||||
<HiArrowDownTray className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed right-2 top-2 z-20 flex gap-1">
|
||||
{imageAttachments.length > 1 && selectedIndex !== null && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToPrevious();
|
||||
}}
|
||||
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||
aria-label="Previous image"
|
||||
>
|
||||
<HiChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{imageAttachments.length > 1 && selectedIndex !== null && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
goToNext();
|
||||
}}
|
||||
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||
aria-label="Next image"
|
||||
>
|
||||
<HiChevronRight className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{selectedIndex !== null && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
closeViewer();
|
||||
}}
|
||||
className="rounded-full bg-light-50 p-1.5 text-light-1000 transition-colors hover:bg-light-100 focus:outline-none dark:bg-dark-50 dark:text-dark-1000 dark:hover:bg-dark-100"
|
||||
aria-label="Close"
|
||||
>
|
||||
<HiXMark className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className="relative w-full max-w-7xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{selectedAttachment?.url && (
|
||||
<div className="relative">
|
||||
<div className="relative mx-auto max-h-[90vh] w-full">
|
||||
<Image
|
||||
src={selectedAttachment.url}
|
||||
alt={
|
||||
selectedAttachment.originalFilename ?? "Attachment"
|
||||
}
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="mx-auto max-h-[90vh] w-auto object-contain"
|
||||
unoptimized
|
||||
/>
|
||||
</div>
|
||||
|
||||
{imageAttachments.length > 1 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-full bg-black/50 px-3 py-1.5 text-[10px] text-white">
|
||||
{selectedIndex !== null && selectedIndex + 1} /{" "}
|
||||
{imageAttachments.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentThumbnail({
|
||||
attachment,
|
||||
onClick,
|
||||
isImage,
|
||||
}: {
|
||||
attachment: {
|
||||
publicId: string;
|
||||
url: string;
|
||||
originalFilename: string;
|
||||
contentType: string;
|
||||
};
|
||||
onClick: () => void;
|
||||
isImage: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="relative h-16 w-16 overflow-hidden rounded-xl border border-light-300 transition-transform hover:scale-105 dark:border-dark-300"
|
||||
aria-label={`View ${attachment.originalFilename}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<Image
|
||||
src={attachment.url}
|
||||
alt={attachment.originalFilename}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="64px"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center bg-light-100 dark:bg-dark-100">
|
||||
<HiDocumentText className="h-6 w-6 text-light-700 dark:text-dark-700" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function formatFileSize(bytes: number | null | undefined): string {
|
||||
if (!bytes || bytes === 0 || isNaN(bytes)) return "0 B";
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i] ?? "B"}`;
|
||||
}
|
||||
|
||||
function FileListItem({
|
||||
attachment,
|
||||
onDownload,
|
||||
onDelete,
|
||||
}: {
|
||||
attachment: Attachment;
|
||||
onDownload: () => void;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="group flex w-full items-center gap-3 rounded-lg border border-light-300 bg-light-50 px-3 py-2 dark:border-dark-200 dark:bg-dark-100">
|
||||
<div className="flex-shrink-0">
|
||||
<HiDocumentText className="h-5 w-5 text-light-700 dark:text-dark-700" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm text-light-1000 dark:text-dark-1000">
|
||||
{attachment.originalFilename ?? "File"}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="text-xs text-light-500 dark:text-dark-900">
|
||||
{attachment.size != null &&
|
||||
!isNaN(attachment.size) &&
|
||||
`${formatFileSize(attachment.size)}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDownload();
|
||||
}}
|
||||
className="flex-shrink-0 rounded-full bg-light-100 p-1.5 text-light-1000 transition-colors hover:bg-light-200 focus:outline-none dark:bg-dark-100 dark:text-dark-950 dark:hover:bg-dark-300"
|
||||
aria-label={`Download ${attachment.originalFilename}`}
|
||||
>
|
||||
<HiArrowDownTray className="h-4 w-4" />
|
||||
</button>
|
||||
{onDelete && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
className="flex-shrink-0 rounded-full bg-light-100 p-1.5 text-light-1000 transition-colors hover:bg-light-200 focus:outline-none dark:bg-dark-100 dark:text-dark-950 dark:hover:bg-dark-300"
|
||||
aria-label={`Delete ${attachment.originalFilename}`}
|
||||
>
|
||||
<HiXMark className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { useRef, useState } from "react";
|
||||
import { HiOutlinePaperClip } from "react-icons/hi";
|
||||
import { HiCheckBadge } from "react-icons/hi2";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import { useModal } from "~/providers/modal";
|
||||
import { usePopup } from "~/providers/popup";
|
||||
import { api } from "~/utils/api";
|
||||
|
||||
export function AttachmentUpload({ cardPublicId }: { cardPublicId: string }) {
|
||||
const { openModal } = useModal();
|
||||
const { showPopup } = usePopup();
|
||||
const utils = api.useUtils();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const generateUploadUrl = api.attachment.generateUploadUrl.useMutation();
|
||||
const confirmAttachment = api.attachment.confirm.useMutation({
|
||||
onSuccess: async () => {
|
||||
await utils.card.byId.invalidate({ cardPublicId });
|
||||
showPopup({
|
||||
header: t`Attachment uploaded`,
|
||||
message: t`Your file has been uploaded successfully.`,
|
||||
icon: "success",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
message: t`Failed to upload attachment. Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
onSettled: () => {
|
||||
setUploading(false);
|
||||
},
|
||||
});
|
||||
|
||||
const uploadFile = async (file: File) => {
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
// Generate presigned URL
|
||||
const { url, key } = await generateUploadUrl.mutateAsync({
|
||||
cardPublicId,
|
||||
filename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
|
||||
// Upload file to S3
|
||||
const uploadResponse = await fetch(url, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type,
|
||||
},
|
||||
});
|
||||
|
||||
if (!uploadResponse.ok) {
|
||||
throw new Error("Upload failed");
|
||||
}
|
||||
|
||||
// Confirm attachment in database
|
||||
await confirmAttachment.mutateAsync({
|
||||
cardPublicId,
|
||||
s3Key: key,
|
||||
filename: file.name,
|
||||
originalFilename: file.name,
|
||||
contentType: file.type,
|
||||
size: file.size,
|
||||
});
|
||||
} catch {
|
||||
showPopup({
|
||||
header: t`Upload failed`,
|
||||
message: t`Failed to upload attachment. Please try again.`,
|
||||
icon: "error",
|
||||
});
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// Reset input
|
||||
event.target.value = "";
|
||||
|
||||
await uploadFile(file);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!uploading) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = async (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (uploading) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Upload the first file (or could upload all files)
|
||||
await uploadFile(files[0] ?? new File([], ""));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-6">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
id="attachment-upload"
|
||||
className="hidden"
|
||||
onChange={handleFileSelect}
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
className={twMerge(
|
||||
"rounded-lg border-2 border-dashed transition-colors",
|
||||
isDragging
|
||||
? "border-light-300 bg-light-100 dark:border-dark-300 dark:bg-dark-100"
|
||||
: "border-transparent",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between p-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
iconLeft={
|
||||
<HiCheckBadge className="h-4 w-4 text-light-950 dark:text-dark-950" />
|
||||
}
|
||||
iconOnly
|
||||
size="sm"
|
||||
onClick={() => openModal("ADD_CHECKLIST")}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
iconLeft={
|
||||
<HiOutlinePaperClip className="h-4 w-4 text-light-950 dark:text-dark-950" />
|
||||
}
|
||||
isLoading={uploading}
|
||||
disabled={uploading}
|
||||
iconOnly
|
||||
size="sm"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
/>
|
||||
</div>
|
||||
</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)}>
|
||||
|
||||
@@ -28,7 +28,7 @@ export default function BoardDropdown() {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<HiEllipsisHorizontal className="h-5 w-5 text-dark-900" />
|
||||
<HiEllipsisHorizontal className="h-6 w-6 text-dark-900" />
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ import { api } from "~/utils/api";
|
||||
import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
|
||||
import { DeleteLabelConfirmation } from "../../components/DeleteLabelConfirmation";
|
||||
import ActivityList from "./components/ActivityList";
|
||||
import { AttachmentThumbnails } from "./components/AttachmentThumbnails";
|
||||
import { AttachmentUpload } from "./components/AttachmentUpload";
|
||||
import Checklists from "./components/Checklists";
|
||||
import { DeleteCardConfirmation } from "./components/DeleteCardConfirmation";
|
||||
import { DeleteChecklistConfirmation } from "./components/DeleteChecklistConfirmation";
|
||||
@@ -39,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]
|
||||
@@ -107,7 +105,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
|
||||
|
||||
return (
|
||||
<div className="h-full w-[360px] border-l-[1px] border-light-300 bg-light-50 p-8 text-light-900 dark:border-dark-300 dark:bg-dark-50 dark:text-dark-900">
|
||||
<div className="mb-4 flex w-full flex-row pt-[18px]">
|
||||
<div className="mb-4 flex w-full flex-row">
|
||||
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`List`}</p>
|
||||
<ListSelector
|
||||
cardPublicId={cardId ?? ""}
|
||||
@@ -123,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 {
|
||||
@@ -210,89 +206,54 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
}
|
||||
}, [card, getModalState, clearModalState]);
|
||||
|
||||
// Auto-resize title textarea
|
||||
useEffect(() => {
|
||||
const titleTextarea = document.getElementById(
|
||||
"title",
|
||||
) as HTMLTextAreaElement;
|
||||
if (titleTextarea) {
|
||||
titleTextarea.style.height = "auto";
|
||||
titleTextarea.style.height = `${titleTextarea.scrollHeight}px`;
|
||||
}
|
||||
}, [card]);
|
||||
|
||||
if (!cardId) return <></>;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
title={t`${card?.title ?? t`Card`} | ${board?.name ?? t`Board`}`}
|
||||
title={t`${card?.title ?? "Card"} | ${board?.name ?? "Board"}`}
|
||||
/>
|
||||
<div className="flex h-full flex-1 flex-col overflow-hidden">
|
||||
{/* Full-width top strip with board link and dropdown */}
|
||||
<div className="flex w-full items-center justify-between border-b-[1px] border-light-300 bg-light-50 px-8 py-2 dark:border-dark-300 dark:bg-dark-50">
|
||||
{!card && isLoading && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[1.5rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
)}
|
||||
{card && (
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link
|
||||
className="whitespace-nowrapleading-[1.5rem] text-sm font-bold text-light-900 dark:text-dark-950"
|
||||
href={`${isTemplate ? "/templates" : "/boards"}`}
|
||||
>
|
||||
{workspace.name}
|
||||
</Link>
|
||||
<IoChevronForwardSharp className="h-[10px] w-[10px] text-light-900 dark:text-dark-900" />
|
||||
<Link
|
||||
className="whitespace-nowrap text-sm font-bold leading-[1.5rem] text-light-900 dark:text-dark-950"
|
||||
href={`${isTemplate ? "/templates" : "/boards"}/${board?.publicId}`}
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Dropdown />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!card && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[1.5rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1rem]">
|
||||
{t`Card not found`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex h-full flex-1 flex-row overflow-hidden">
|
||||
<div className="scrollbar-thumb-rounded-[4px] scrollbar-track-rounded-[4px] w-full flex-1 overflow-y-auto scrollbar scrollbar-track-light-200 scrollbar-thumb-light-400 hover:scrollbar-thumb-light-400 dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-300 dark:hover:scrollbar-thumb-dark-300">
|
||||
<div className="p-auto mx-auto flex h-full w-full max-w-[800px] flex-col">
|
||||
<div className="p-6 md:p-8">
|
||||
<div className="mb-8 md:mt-4">
|
||||
<div className="mb-8 flex w-full items-center justify-between md:mt-6">
|
||||
{!card && isLoading && (
|
||||
<div className="flex space-x-2">
|
||||
<div className="h-[2.3rem] w-[150px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
<div className="h-[2.3rem] w-[300px] animate-pulse rounded-[5px] bg-light-300 dark:bg-dark-300" />
|
||||
</div>
|
||||
)}
|
||||
{card && (
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div>
|
||||
<textarea
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
rows={1}
|
||||
className="block w-full resize-none overflow-hidden border-0 bg-transparent p-0 py-0 font-bold leading-relaxed text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
onInput={(e) => {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
target.style.height = "auto";
|
||||
target.style.height = `${target.scrollHeight}px`;
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<Link
|
||||
className="whitespace-nowrap font-bold leading-[2.3rem] tracking-tight text-light-900 dark:text-dark-900 sm:text-[1.2rem]"
|
||||
href={`/boards/${board?.publicId}`}
|
||||
>
|
||||
{board?.name}
|
||||
</Link>
|
||||
<IoChevronForwardSharp
|
||||
size={18}
|
||||
className="mx-2 text-light-900 dark:text-dark-900"
|
||||
/>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="w-full space-y-6"
|
||||
>
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
id="title"
|
||||
{...register("title")}
|
||||
onBlur={handleSubmit(onSubmit)}
|
||||
className="block w-full border-0 bg-transparent p-0 py-0 font-bold tracking-tight text-neutral-900 focus:ring-0 dark:text-dark-1000 sm:text-[1.2rem]"
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
<div className="flex">
|
||||
<Dropdown />
|
||||
</div>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
{!card && !isLoading && (
|
||||
<p className="block p-0 py-0 font-bold leading-[2.3rem] tracking-tight text-neutral-900 dark:text-dark-1000 sm:text-[1.2rem]">
|
||||
@@ -323,21 +284,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
|
||||
activeChecklistForm={activeChecklistForm}
|
||||
setActiveChecklistForm={setActiveChecklistForm}
|
||||
/>
|
||||
{!isTemplate && (
|
||||
<>
|
||||
{card?.attachments.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<AttachmentThumbnails
|
||||
attachments={card.attachments}
|
||||
cardPublicId={cardId ?? ""}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-6">
|
||||
<AttachmentUpload cardPublicId={cardId} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="border-t-[1px] border-light-300 pt-12 dark:border-dark-300">
|
||||
<h2 className="text-md pb-4 font-medium text-light-1000 dark:text-dark-1000">
|
||||
{t`Activity`}
|
||||
@@ -350,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">
|
||||
|
||||
@@ -10,7 +10,7 @@ import { HiMiniMinusSmall, HiMiniPlusSmall } from "react-icons/hi2";
|
||||
|
||||
const Text = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<p className="text-[14px] leading-[1.7rem] text-light-800 dark:text-dark-800">
|
||||
<p className="text-[15px] leading-[1.7rem] text-light-800 dark:text-dark-700">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -22,7 +22,7 @@ const Faqs = () => {
|
||||
question: t`Why make an open source Trello?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`When Trello launched in 2011, it blew everyone away with its carefully designed simplicity, but over the years it lost its magic and grew into something closer to "Jira Lite". This project is our attempt to recapture the original magic of Trello's simplicity, in open source.`}
|
||||
{t`When Trello launched in 2011, it blew everyone away with its carefully designed simplicity, but over time (and atlassification) it lost its magic and grew into something closer to "Jira Lite". This project is our attempt to recapture the original magic of Trello's simplicity, in open source.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -38,7 +38,7 @@ const Faqs = () => {
|
||||
question: t`How is the project funded?`,
|
||||
answer: (
|
||||
<Text>
|
||||
{t`For long-term sustainability, we recognise all good open source projects need a source of revenue. Ours comes from the paid cloud offering for workspaces with multiple users and for custom workspace slugs. There's no obligation to use it, and you can self-host the software on your own infrastructure free of charge.`}
|
||||
{t`For long-term sustainability, we recognise all good open source projects need a source of revenue. Ours comes from the paid cloud offering for workspaces with multiple users (free during the beta) and for custom workspace slugs. There's no obligation to use it, and you can self-host the software on your own infrastructure free of charge.`}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
@@ -146,10 +146,10 @@ 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 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 Kan. Can't find what you're
|
||||
looking for? Feel free to{" "}
|
||||
Find answers to common questions about the project. Can't find what
|
||||
you're looking for? Feel free to{" "}
|
||||
<Link href="mailto:support@kan.bn" className="underline">
|
||||
contact us
|
||||
</Link>
|
||||
@@ -157,22 +157,22 @@ const Faqs = () => {
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-200">
|
||||
<div className="rounded-2xl bg-light-50 ring-1 ring-light-300 dark:bg-dark-50 dark:ring-dark-400">
|
||||
<div className="mx-auto max-w-7xl px-6 py-20 lg:px-16">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
<dl className="divide-y divide-light-300 dark:divide-dark-200">
|
||||
<dl className="divide-y divide-light-300 dark:divide-dark-600">
|
||||
{faqs.map((faq) => (
|
||||
<Disclosure
|
||||
key={faq.question}
|
||||
as="div"
|
||||
className="py-5 first:pt-0 last:pb-0"
|
||||
className="py-6 first:pt-0 last:pb-0"
|
||||
>
|
||||
<dt>
|
||||
<DisclosureButton className="group flex w-full items-center justify-between text-left text-light-1000 dark:text-dark-1000">
|
||||
<span className="text-[14px] font-semibold">
|
||||
<DisclosureButton className="group flex w-full items-start justify-between text-left text-light-1000 dark:text-dark-1000">
|
||||
<span className="text-base/7 font-semibold">
|
||||
{faq.question}
|
||||
</span>
|
||||
<span className="ml-6 flex h-7 items-center text-light-800 dark:text-dark-800">
|
||||
<span className="ml-6 flex h-7 items-center">
|
||||
<HiMiniPlusSmall
|
||||
aria-hidden="true"
|
||||
className="size-6 group-data-[open]:hidden"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -36,7 +36,6 @@ const Footer = () => {
|
||||
{ name: t`Roadmap`, href: "/kan/roadmap" },
|
||||
{ name: t`GitHub`, href: "https://github.com/kanbn/kan" },
|
||||
{ name: t`Contact`, href: "mailto:support@kan.bn" },
|
||||
{ name: t`OSS Friends`, href: "/oss-friends" },
|
||||
],
|
||||
legal: [
|
||||
{ name: t`Terms of service`, href: "/terms" },
|
||||
|
||||
@@ -6,83 +6,55 @@ import { twMerge } from "tailwind-merge";
|
||||
import Button from "~/components/Button";
|
||||
|
||||
const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
|
||||
const desktopMenuItems = [
|
||||
{
|
||||
label: t`Roadmap`,
|
||||
href: "/kan/roadmap",
|
||||
openInNewTab: true,
|
||||
},
|
||||
{ label: t`Features`, href: "/#features" },
|
||||
{ label: t`Pricing`, href: "/pricing" },
|
||||
{
|
||||
label: t`Docs`,
|
||||
href: "https://docs.kan.bn",
|
||||
openInNewTab: true,
|
||||
},
|
||||
const menuItems = [
|
||||
{ label: t`Roadmap`, href: "/kan/roadmap", openInNewTab: true },
|
||||
{ label: t`Features`, href: "#features" },
|
||||
{ label: t`Pricing`, href: "#pricing" },
|
||||
{ label: t`Docs`, href: "https://docs.kan.bn", openInNewTab: true },
|
||||
];
|
||||
|
||||
const mobileMenuItems = [
|
||||
{
|
||||
label: t`Roadmap`,
|
||||
href: "/kan/roadmap",
|
||||
openInNewTab: true,
|
||||
group: "Product",
|
||||
},
|
||||
{ label: t`Features`, href: "/#features", group: "Product" },
|
||||
{ label: t`Pricing`, href: "/pricing", group: "Product" },
|
||||
{
|
||||
label: t`Documentation`,
|
||||
href: "https://docs.kan.bn",
|
||||
openInNewTab: true,
|
||||
group: "Resources",
|
||||
},
|
||||
{ label: t`FAQ`, href: "/#faq", group: "Resources" },
|
||||
{ label: t`Contact`, href: "mailto:support@kan.bn", group: "Resources" },
|
||||
];
|
||||
|
||||
// Group mobile menu items by their group property
|
||||
const groupedMenuItems = mobileMenuItems.reduce(
|
||||
(acc, item) => {
|
||||
(acc[item.group] ??= []).push(item);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, typeof mobileMenuItems>,
|
||||
);
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
setIsScrolled(window.scrollY > 0);
|
||||
};
|
||||
window.addEventListener("scroll", handleScroll);
|
||||
return () => window.removeEventListener("scroll", handleScroll);
|
||||
}, []);
|
||||
|
||||
const toggleMenu = () => {
|
||||
setIsMenuOpen(!isMenuOpen);
|
||||
document.body.style.overflow = !isMenuOpen ? "hidden" : "";
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("overflow-hidden", isMenuOpen);
|
||||
document.body.classList.toggle("overflow-hidden", isMenuOpen);
|
||||
return () => {
|
||||
document.documentElement.classList.remove("overflow-hidden");
|
||||
document.body.classList.remove("overflow-hidden");
|
||||
};
|
||||
}, [isMenuOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={twMerge(
|
||||
"fixed z-50 flex w-full transition-all duration-500",
|
||||
"fixed z-50 m-auto flex w-full transition-all duration-500 lg:max-w-[1100px] lg:px-4 lg:pt-4",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-[4rem] min-h-[4rem] w-full border-b border-light-300 bg-light-50/80 px-5 py-2 align-middle opacity-100 shadow-sm backdrop-blur-[10px] transition-all duration-500 dark:border-dark-300 dark:bg-dark-50/90">
|
||||
<div className="mx-auto flex w-full max-w-[1100px] items-center justify-between lg:px-4">
|
||||
<div
|
||||
className={twMerge(
|
||||
"m-auto flex h-[4rem] min-h-[4rem] w-full px-5 py-2 align-middle transition-all duration-500 lg:rounded-3xl lg:border lg:border-transparent",
|
||||
isScrolled &&
|
||||
"border-b border-light-300 bg-light-50/80 opacity-100 shadow-sm backdrop-blur-[10px] dark:border-dark-300 dark:bg-dark-50/90 lg:rounded-2xl lg:border",
|
||||
!isScrolled && "bg-transparent opacity-90",
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between lg:px-4">
|
||||
<div className="my-auto flex items-center justify-between">
|
||||
<Link href="/">
|
||||
<h1 className="text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000 lg:w-[200px]">
|
||||
<h1 className="w-[200px] text-lg font-bold tracking-tight text-neutral-900 dark:text-dark-1000">
|
||||
kan.bn
|
||||
</h1>
|
||||
</Link>
|
||||
</div>
|
||||
{/* Desktop Menu */}
|
||||
<div className="hidden justify-center gap-10 dark:text-dark-1000 lg:flex">
|
||||
{desktopMenuItems.map((item) => (
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
@@ -94,38 +66,42 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-4 lg:w-[200px]">
|
||||
<div className="justify-end gap-2">
|
||||
{isLoggedIn ? (
|
||||
<Button href="/boards">{t`Go to app`}</Button>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button href="/login" variant="ghost">
|
||||
{t`Sign in`}
|
||||
</Button>
|
||||
<Button href="/signup">{t`Get started`}</Button>
|
||||
</div>
|
||||
{/* Hamburger Menu Button */}
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="z-50 p-2 lg:hidden"
|
||||
aria-label={t`Toggle menu`}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"my-[5px] h-[2px] w-4 bg-current bg-light-1000 transition-all dark:bg-dark-1000",
|
||||
isMenuOpen ? "translate-y-[7px] rotate-45" : "",
|
||||
)}
|
||||
</div>
|
||||
{/* Hamburger Menu Button */}
|
||||
<button
|
||||
onClick={toggleMenu}
|
||||
className="relative z-50 p-2 lg:hidden"
|
||||
aria-label={t`Toggle menu`}
|
||||
>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute left-1/2 top-1/2 h-[1px] w-4 -translate-x-1/2 -translate-y-[4px] bg-current bg-light-1000 transition-all duration-300 ease-in-out dark:bg-dark-1000",
|
||||
isMenuOpen ? "translate-y-0 rotate-45" : "",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"absolute left-1/2 top-1/2 h-[1px] w-4 -translate-x-1/2 translate-y-[4px] bg-current bg-light-1000 transition-all duration-300 ease-in-out dark:bg-dark-1000",
|
||||
isMenuOpen ? "translate-y-0 -rotate-45" : "",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"my-[5px] ml-[4px] h-[2px] w-3 bg-current bg-light-1000 transition-all dark:bg-dark-1000",
|
||||
isMenuOpen ? "opacity-0" : "",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={twMerge(
|
||||
"my-[5px] h-[2px] w-4 bg-current bg-light-1000 transition-all dark:bg-dark-1000",
|
||||
isMenuOpen ? "-translate-y-[7px] -rotate-45" : "",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
<div className="hidden w-[200px] justify-end gap-2 lg:flex">
|
||||
{isLoggedIn ? (
|
||||
<Button href="/boards">{t`Go to app`}</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button href="/login" variant="ghost">
|
||||
{t`Sign in`}
|
||||
</Button>
|
||||
<Button href="/signup">{t`Get started`}</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -138,27 +114,35 @@ const Header = ({ isLoggedIn }: { isLoggedIn: boolean }) => {
|
||||
: "pointer-events-none translate-x-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
<div className="absolute inset-0 bg-light-50/80 bg-white backdrop-blur-[15px] dark:bg-dark-50 dark:bg-dark-50/90">
|
||||
<div className="mt-[6rem] flex h-full flex-col space-y-8 px-5">
|
||||
{Object.entries(groupedMenuItems).map(([group, items]) => (
|
||||
<div key={group} className="flex flex-col space-y-4">
|
||||
<div className="text-sm font-bold text-light-900 dark:text-dark-900">
|
||||
{group}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
target={item.openInNewTab ? "_blank" : undefined}
|
||||
rel={item.openInNewTab ? "noopener noreferrer" : undefined}
|
||||
className="transform text-lg font-bold text-light-1000 dark:text-dark-1000"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-white dark:bg-dark-100">
|
||||
<div className="flex h-full flex-col items-center justify-center space-y-8">
|
||||
{menuItems.map((item) => (
|
||||
<Link
|
||||
key={item.label}
|
||||
href={item.href}
|
||||
target={item.openInNewTab ? "_blank" : undefined}
|
||||
rel={item.openInNewTab ? "noopener noreferrer" : undefined}
|
||||
className="transform text-xl font-bold text-light-1000 transition-all duration-300 hover:scale-105 dark:text-dark-1000"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="mt-8 flex flex-col gap-4">
|
||||
{isLoggedIn ? (
|
||||
<Button href="/boards" onClick={toggleMenu}>
|
||||
{t`Go to app`}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button href="/login" variant="ghost" onClick={toggleMenu}>
|
||||
{t`Sign in`}
|
||||
</Button>
|
||||
<Button href="/signup" onClick={toggleMenu}>
|
||||
{t`Get started`}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,13 +7,13 @@ import Footer from "./Footer";
|
||||
import Header from "./Header";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { theme } = useTheme();
|
||||
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const isLoggedIn = !!session?.user;
|
||||
|
||||
const isDarkMode = resolvedTheme === "dark";
|
||||
const isDarkMode = theme === "dark";
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -26,8 +26,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
`}</style>
|
||||
<div className="mx-auto flex h-full min-h-screen min-w-[375px] flex-col items-center bg-light-100 dark:bg-dark-50">
|
||||
<PatternedBackground />
|
||||
<Header isLoggedIn={isLoggedIn} />
|
||||
<div className="z-10 mx-auto h-full w-full max-w-[1100px]">
|
||||
<Header isLoggedIn={isLoggedIn} />
|
||||
{children}
|
||||
</div>
|
||||
<Footer />
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
import type { FC, SVGProps } from "react";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
|
||||
import AirbusLogo from "/public/logos/airbus.svg";
|
||||
import BitwardenLogo from "/public/logos/bitwarden.svg";
|
||||
import CouchbaseLogo from "/public/logos/couchbase.svg";
|
||||
import DeloitteLogo from "/public/logos/deloitte.svg";
|
||||
import FastCompanyLogo from "/public/logos/fast_company.svg";
|
||||
import LegoLogo from "/public/logos/lego.svg";
|
||||
import LinkedinLogo from "/public/logos/linkedin.svg";
|
||||
import SanaLogo from "/public/logos/sana.svg";
|
||||
import WakamLogo from "/public/logos/wakam.svg";
|
||||
|
||||
type LogoComponent = FC<SVGProps<SVGSVGElement>>;
|
||||
|
||||
export default function Logos() {
|
||||
const logos: {
|
||||
id: number;
|
||||
component: LogoComponent;
|
||||
alt: string;
|
||||
}[] = [
|
||||
{
|
||||
id: 1,
|
||||
component: SanaLogo as LogoComponent,
|
||||
alt: "Sana Logo",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
component: FastCompanyLogo as LogoComponent,
|
||||
alt: "Fast Company Logo",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
component: BitwardenLogo as LogoComponent,
|
||||
alt: "Bitwarden Logo",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
component: CouchbaseLogo as LogoComponent,
|
||||
alt: "Couchbase Logo",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
component: LegoLogo as LogoComponent,
|
||||
alt: "Lego Logo",
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
component: AirbusLogo as LogoComponent,
|
||||
alt: "Airbus Logo",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
component: DeloitteLogo as LogoComponent,
|
||||
alt: "Deloitte Logo",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
component: WakamLogo as LogoComponent,
|
||||
alt: "Wakam Logo",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
component: LinkedinLogo as LogoComponent,
|
||||
alt: "Linked In Logo",
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="w-full px-4 py-16">
|
||||
<div className="mb-8 text-center">
|
||||
<p className="text-sm font-medium text-light-800 dark:text-dark-800">
|
||||
<Trans>
|
||||
Trusted by fast-moving teams
|
||||
<br />
|
||||
around the world
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden">
|
||||
<div className="absolute left-0 top-0 z-10 h-full w-8 bg-gradient-to-r from-white/60 to-transparent dark:from-dark-50" />
|
||||
<div className="absolute right-0 top-0 z-10 h-full w-8 bg-gradient-to-l from-white/60 to-transparent dark:from-dark-50" />
|
||||
|
||||
<div className="animate-scroll flex" style={{ width: "max-content" }}>
|
||||
<div className="flex flex-shrink-0 items-center space-x-12">
|
||||
{logos.map((logo) => {
|
||||
const LogoComponent: LogoComponent = logo.component;
|
||||
return (
|
||||
<div
|
||||
key={`first-${logo.id}`}
|
||||
className="flex h-12 w-32 items-center justify-center"
|
||||
>
|
||||
<LogoComponent
|
||||
className="text-light-950 transition-all duration-300 hover:text-light-1000 dark:text-dark-950 hover:dark:text-dark-1000"
|
||||
aria-label={logo.alt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="ml-12 flex flex-shrink-0 items-center space-x-12">
|
||||
{logos.map((logo) => {
|
||||
const LogoComponent: LogoComponent = logo.component;
|
||||
return (
|
||||
<div
|
||||
key={`second-${logo.id}`}
|
||||
className="flex h-12 w-32 items-center justify-center"
|
||||
>
|
||||
<LogoComponent
|
||||
className="text-light-950 transition-all duration-300 hover:text-light-1000 dark:text-dark-950 hover:dark:text-dark-1000"
|
||||
aria-label={logo.alt}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
@@ -1,258 +0,0 @@
|
||||
import Image from "next/image";
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
interface Testimonial {
|
||||
name: string;
|
||||
handle: string;
|
||||
image?: string;
|
||||
colour?: string;
|
||||
text: string | React.ReactNode;
|
||||
role?: string;
|
||||
link?: string;
|
||||
rowSpan?: number;
|
||||
}
|
||||
|
||||
const TestimonialCard = ({
|
||||
testimonial,
|
||||
rowSpan,
|
||||
}: {
|
||||
testimonial: Testimonial;
|
||||
rowSpan?: number;
|
||||
}) => {
|
||||
// Generate initials for avatar
|
||||
const initials = testimonial.name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={twMerge(
|
||||
"group relative rounded-2xl border border-light-200 bg-light-50 p-6 transition-all duration-200 hover:shadow-sm dark:border-dark-200 dark:bg-dark-50",
|
||||
rowSpan === 2 ? "md:row-span-2" : "",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{testimonial.image ? (
|
||||
<div className="relative h-10 w-10 shrink-0 overflow-hidden rounded-full">
|
||||
<Image
|
||||
src={testimonial.image}
|
||||
alt={testimonial.name}
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-semibold text-light-50 dark:text-dark-1000"
|
||||
style={
|
||||
testimonial.colour
|
||||
? { backgroundColor: testimonial.colour }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-semibold text-light-1000 dark:text-dark-1000">
|
||||
{testimonial.name}
|
||||
</p>
|
||||
</div>
|
||||
{testimonial.link ? (
|
||||
<a
|
||||
href={testimonial.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="-mt-1 block text-sm text-light-800 transition-colors hover:text-light-900 dark:text-dark-800 dark:hover:text-dark-900"
|
||||
>
|
||||
{testimonial.handle}
|
||||
</a>
|
||||
) : (
|
||||
<p className="-mt-0.5 block text-sm text-light-800 dark:text-dark-800">
|
||||
{testimonial.handle}
|
||||
</p>
|
||||
)}
|
||||
{testimonial.role && (
|
||||
<p className="text-xs text-light-600 dark:text-dark-600">
|
||||
{testimonial.role}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-sm leading-relaxed text-light-950 dark:text-dark-900">
|
||||
<p>{testimonial.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const Testimonials = () => {
|
||||
const testimonials: Testimonial[] = [
|
||||
{
|
||||
name: "Bobby Compüters",
|
||||
handle: "@bobbycomputers",
|
||||
image: "/testimonials/avatars/bobby_computers.jpg",
|
||||
text: (
|
||||
<>
|
||||
Holy crap I love this app. It's brutally minimal but somehow has
|
||||
everything I need.
|
||||
</>
|
||||
),
|
||||
link: "https://x.com/bobbycomputers",
|
||||
},
|
||||
{
|
||||
name: "JR Raphael",
|
||||
handle: "@JRRaphael",
|
||||
image: "/testimonials/avatars/jrraphael.png",
|
||||
link: "https://www.fastcompany.com/91376028/trello-alternative-kan",
|
||||
rowSpan: 2,
|
||||
text: (
|
||||
<>
|
||||
The interesting thing about signing into Kan for the first time is
|
||||
that it feels new and electrifying—and yet simultaneously quite
|
||||
familiar, especially if you’ve spent any time in Trello over the
|
||||
years.
|
||||
<br />
|
||||
<br />
|
||||
As someone who connected more with Trello’s original vision and mostly
|
||||
just tolerated the more recent pivots and additions, it’s a bit of a
|
||||
revelation to use.
|
||||
<br />
|
||||
<br />
|
||||
At its core, Kan gives you a super-minimalist and frills-free
|
||||
Trello-style Kanban board. And the extent to which it has been able to
|
||||
build upon the original Trello vision is staggering.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "singiamtel",
|
||||
handle: "@singiamtel",
|
||||
colour: "#ff6600",
|
||||
text: <>The project seems nice, but how good is that domain name</>,
|
||||
link: "https://news.ycombinator.com/item?id=44157177",
|
||||
},
|
||||
|
||||
{
|
||||
name: "Jan Stgmnn",
|
||||
handle: "@JanStgmnn",
|
||||
image: "/testimonials/avatars/jan_stgmnn.jpg",
|
||||
link: "https://x.com/JanStgmnn",
|
||||
text: (
|
||||
<>
|
||||
Just wanted to say, that I really love the project. It's so easy to
|
||||
use and has a really nice UI!
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Fox",
|
||||
handle: "@dscfox",
|
||||
image: "/testimonials/avatars/fox.png",
|
||||
link: "https://discord.gg/e6ejRb6CmT",
|
||||
rowSpan: 2,
|
||||
text: (
|
||||
<>
|
||||
I've been looking at alternatives for months, but everything came up
|
||||
short.
|
||||
<br />
|
||||
<br />
|
||||
Some were direct clones, but lacked features I was dependent on.
|
||||
Others were rich in features, but strayed too far away from the
|
||||
simplicity of Trello.
|
||||
<br />
|
||||
<br />
|
||||
This seems like the best alternative to Trello for me.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Hanno Braun",
|
||||
handle: "@hannobraun",
|
||||
image: "/testimonials/avatars/hanno_braun.webp",
|
||||
link: "https://discord.gg/e6ejRb6CmT",
|
||||
text: (
|
||||
<>
|
||||
I've been very impressed with the app so far! It's great to have such
|
||||
a nice open source alternative to Trello.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "headlessdev_",
|
||||
handle: "@headlessdev_",
|
||||
image: "/testimonials/avatars/headless_dev.png",
|
||||
link: "https://www.reddit.com/r/selfhosted/comments/1l1f2st/i_made_an_opensource_alternative_to_trello/",
|
||||
text: <>This is the best thing I've seen here in a long time</>,
|
||||
},
|
||||
{
|
||||
name: "EHB",
|
||||
handle: "@ehb3839",
|
||||
image: "/testimonials/avatars/ehb.webp",
|
||||
link: "https://www.reddit.com/r/selfhosted/comments/1l1f2st/i_made_an_opensource_alternative_to_trello/",
|
||||
text: (
|
||||
<>
|
||||
It's exactly what I've been looking for recently. The simplicity of
|
||||
the overall app is a pleasure to use.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "Tasneema Waquar",
|
||||
handle: "@tasneema_waquar",
|
||||
image: "/testimonials/avatars/tasneema_waquar.jpeg",
|
||||
link: "https://www.producthunt.com/products/kan-bn/reviews",
|
||||
text: (
|
||||
<>
|
||||
The design of this project management tool is impressive. The
|
||||
interface is intuitive, free of unnecessary clutter, and comes with a
|
||||
solid selection of templates.
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: "BRAVO68WEB",
|
||||
handle: "@bravo68web",
|
||||
image: "/testimonials/avatars/bravo68web.jpeg",
|
||||
link: "https://discord.gg/e6ejRb6CmT",
|
||||
text: <>I have fallen in love with the project.</>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-center justify-center px-4 pb-24">
|
||||
<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`Testimonials`}</p>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-4xl">
|
||||
{t`Loved by teams worldwide`}
|
||||
</p>
|
||||
<p className="text-md lg:text-md mt-3 max-w-[500px] text-center text-light-950 dark:text-dark-900">
|
||||
{t`See what our users are saying about Kan`}
|
||||
</p>
|
||||
|
||||
<div className="mx-auto mt-16 w-full max-w-7xl">
|
||||
<div className="grid grid-cols-1 gap-4 [mask-image:linear-gradient(to_bottom,black_90%,transparent_98%)] md:auto-rows-fr md:grid-cols-3">
|
||||
{testimonials.map((testimonial, index) => (
|
||||
<TestimonialCard
|
||||
key={`testimonial-${index}`}
|
||||
testimonial={testimonial}
|
||||
rowSpan={testimonial.rowSpan}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Testimonials;
|
||||
@@ -2,21 +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 Logos from "./components/Logos";
|
||||
import Testimonials from "./components/Testimonials";
|
||||
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" />
|
||||
@@ -67,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>
|
||||
|
||||
@@ -81,13 +82,13 @@ 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>
|
||||
</div>
|
||||
<div className="px-4 pb-10">
|
||||
<div className="rounded-[16px] border border-light-300 bg-light-50 p-1 shadow-md dark:border-dark-300 dark:bg-dark-100 lg:rounded-[24px] lg:p-2">
|
||||
<div className="px-4">
|
||||
<div className="mb-24 rounded-[16px] border border-light-300 bg-light-50 p-1 shadow-md dark:border-dark-300 dark:bg-dark-100 lg:rounded-[24px] lg:p-2">
|
||||
<div className="relative overflow-hidden rounded-[12px] border border-light-300 shadow-sm dark:border-dark-300 lg:rounded-[16px]">
|
||||
<Image
|
||||
src={`/hero-light.png`}
|
||||
@@ -106,14 +107,13 @@ export default function HomeView() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Logos />
|
||||
<div className="relative pt-10">
|
||||
<div id="features" className="absolute -top-20" />
|
||||
<Features theme={resolvedTheme === "dark" ? "dark" : "light"} />
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="testimonials" className="absolute -top-20" />
|
||||
<Testimonials />
|
||||
<div id="pricing" className="absolute -top-20" />
|
||||
<Pricing />
|
||||
</div>
|
||||
<div className="relative pt-10">
|
||||
<div id="faq" className="absolute -top-20" />
|
||||
|
||||
@@ -39,14 +39,12 @@ export function InviteMemberForm({
|
||||
const [isShareInviteLinkEnabled, setIsShareInviteLinkEnabled] =
|
||||
useState(false);
|
||||
const [inviteLink, setInviteLink] = useState<string>("");
|
||||
const [_isLoadingInviteLink, setIsLoadingInviteLink] = useState(false);
|
||||
const [isLoadingInviteLink, setIsLoadingInviteLink] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const { closeModal } = useModal();
|
||||
const { workspace } = useWorkspace();
|
||||
const { showPopup } = usePopup();
|
||||
|
||||
const isEmailEnabled = env("NEXT_PUBLIC_DISABLE_EMAIL") !== "true";
|
||||
|
||||
const InviteMemberSchema = z.object({
|
||||
email: z.string().email({ message: t`Invalid email address` }),
|
||||
workspacePublicId: z.string(),
|
||||
@@ -68,7 +66,7 @@ export function InviteMemberForm({
|
||||
const refetchBoards = () => utils.board.all.refetch();
|
||||
|
||||
// Fetch active invite link on component mount
|
||||
const { data: activeInviteLink, refetch: _refetchInviteLink } =
|
||||
const { data: activeInviteLink, refetch: refetchInviteLink } =
|
||||
api.member.getActiveInviteLink.useQuery(
|
||||
{ workspacePublicId: workspace.publicId || "" },
|
||||
{ enabled: !!workspace.publicId },
|
||||
@@ -78,7 +76,7 @@ export function InviteMemberForm({
|
||||
useEffect(() => {
|
||||
if (activeInviteLink) {
|
||||
setIsShareInviteLinkEnabled(activeInviteLink.isActive);
|
||||
setInviteLink(activeInviteLink.inviteLink ?? "");
|
||||
setInviteLink(activeInviteLink.inviteLink || "");
|
||||
}
|
||||
}, [activeInviteLink]);
|
||||
|
||||
@@ -113,23 +111,13 @@ export function InviteMemberForm({
|
||||
setInviteLink(data.inviteLink);
|
||||
setIsLoadingInviteLink(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
onError: () => {
|
||||
setIsLoadingInviteLink(false);
|
||||
setIsShareInviteLinkEnabled(false);
|
||||
|
||||
if (error.data?.code === "FORBIDDEN") {
|
||||
showPopup({
|
||||
header: t`Subscription Required`,
|
||||
message: t`Invite links require a Team or Pro subscription. Please upgrade your workspace.`,
|
||||
icon: "error",
|
||||
});
|
||||
} else {
|
||||
showPopup({
|
||||
header: t`Error creating invite link`,
|
||||
message: t`Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
}
|
||||
showPopup({
|
||||
header: t`Error creating invite link`,
|
||||
message: t`Please try again later.`,
|
||||
icon: "error",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -158,7 +146,7 @@ export function InviteMemberForm({
|
||||
let price = t`$10/month`;
|
||||
let billingType = t`monthly billing`;
|
||||
|
||||
if (teamSubscription?.periodStart && teamSubscription.periodEnd) {
|
||||
if (teamSubscription?.periodStart && teamSubscription?.periodEnd) {
|
||||
const periodStartDate = new Date(teamSubscription.periodStart);
|
||||
const periodEndDate = new Date(teamSubscription.periodEnd);
|
||||
const diffInDays = Math.round(
|
||||
@@ -176,13 +164,6 @@ export function InviteMemberForm({
|
||||
};
|
||||
|
||||
const handleInviteLinkToggle = async () => {
|
||||
if (
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
)
|
||||
return handleUpgrade();
|
||||
|
||||
setIsLoadingInviteLink(true);
|
||||
|
||||
if (isShareInviteLinkEnabled && workspace.publicId) {
|
||||
@@ -210,7 +191,7 @@ export function InviteMemberForm({
|
||||
message: t`Invite link copied to clipboard`,
|
||||
icon: "success",
|
||||
});
|
||||
} catch {
|
||||
} catch (error) {
|
||||
showPopup({
|
||||
header: t`Error`,
|
||||
message: t`Failed to copy invite link`,
|
||||
@@ -266,26 +247,24 @@ export function InviteMemberForm({
|
||||
<HiXMark size={18} className="dark:text-dark-9000 text-light-900" />
|
||||
</button>
|
||||
</div>
|
||||
{isEmailEnabled && (
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
<Input
|
||||
id="email"
|
||||
placeholder={t`Email`}
|
||||
disabled={
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription
|
||||
}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
{...register("email", { required: true })}
|
||||
onKeyDown={async (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
await handleSubmit(onSubmit)();
|
||||
}
|
||||
}}
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
)}
|
||||
{(!isEmailEnabled || (isShareInviteLinkEnabled && inviteLink)) && (
|
||||
}}
|
||||
errorMessage={errors.email?.message}
|
||||
/>
|
||||
{isShareInviteLinkEnabled && inviteLink && (
|
||||
<div className="my-4">
|
||||
<div className="relative">
|
||||
<Input
|
||||
@@ -342,27 +321,31 @@ export function InviteMemberForm({
|
||||
</div>
|
||||
|
||||
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
|
||||
<Toggle
|
||||
label={
|
||||
isShareInviteLinkEnabled
|
||||
? t`Deactivate invite link`
|
||||
: t`Create invite link`
|
||||
}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
{(hasTeamSubscription || hasProSubscription) &&
|
||||
env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
|
||||
<Toggle
|
||||
label={t`Share invite link`}
|
||||
isChecked={isShareInviteLinkEnabled}
|
||||
onChange={handleInviteLinkToggle}
|
||||
/>
|
||||
)}
|
||||
<div>
|
||||
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
|
||||
!hasTeamSubscription &&
|
||||
!hasProSubscription ? (
|
||||
<Button type="button" onClick={handleUpgrade}>
|
||||
{t`Start 14 day free trial`}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleUpgrade}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Upgrade to Team Plan`}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={inviteMember.isPending || !isEmailEnabled}
|
||||
disabled={inviteMember.isPending || isShareInviteLinkEnabled}
|
||||
isLoading={inviteMember.isPending}
|
||||
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50"
|
||||
>
|
||||
{t`Invite member`}
|
||||
</Button>
|
||||
|
||||