Compare commits

..

2 Commits

Author SHA1 Message Date
Henry
857a329db5 chore: add translations 2025-10-27 22:19:22 +00:00
Henry
15c608d0d1 feat: add testimonials 2025-10-27 22:17:07 +00:00
156 changed files with 3954 additions and 22859 deletions

View File

@@ -17,7 +17,6 @@ SMTP_USER=
SMTP_PASSWORD=
EMAIL_FROM= # e.g. "Kan <hello@mail.kan.bn>"
SMTP_SECURE= # set to "false" to use port 587
SMTP_REJECT_UNAUTHORIZED= # set to "false" to accept invalid certs
# Switch email features off entirely (optional)
NEXT_PUBLIC_DISABLE_EMAIL=
@@ -30,7 +29,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)
@@ -43,8 +41,6 @@ TRELLO_APP_SECRET=
# OAuth providers (optional)
BETTER_AUTH_TRUSTED_ORIGINS=
# Optional: Restrict OIDC/Social sign-ins to specific email domains (comma-separated)
BETTER_AUTH_ALLOWED_DOMAINS=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
DISCORD_CLIENT_ID=
@@ -82,3 +78,4 @@ TWITCH_CLIENT_SECRET=
APPLE_CLIENT_ID=
APPLE_CLIENT_SECRET=
APPLE_APP_BUNDLE_IDENTIFIER=

1
.gitignore vendored
View File

@@ -34,7 +34,6 @@ yarn-error.log*
.env
.env*.local
# vercel
.vercel

View File

@@ -34,7 +34,7 @@ See our [roadmap](https://kan.bn/kan/roadmap) for upcoming features.
## Screenshot 👁️
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/8490104a-cd5d-49de-afc2-152fd8a93119" />
<img width="1507" alt="hero-dark" src="https://github.com/user-attachments/assets/5f7b6ad3-f31d-4b45-93dc-0132b3f2afd4" />
## Made With 🛠️
@@ -138,45 +138,41 @@ pnpm dev
## Environment Variables 🔐
| Variable | Description | Required | Example |
| ----------------------------------------- | --------------------------------------------------------- | ------------------------------------- | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `SMTP_REJECT_UNAUTHORIZED` | Reject invalid certificates (defaults to true if not set) | For Email | `false` |
| `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_ALLOWED_DOMAINS` | Comma-separated list of allowed domains for OIDC logins | For OIDC/Social login | `example.com,subsidiary.com` |
| `BETTER_AUTH_SECRET` | Auth encryption secret | Yes | Random 32+ char string |
| `BETTER_AUTH_TRUSTED_ORIGINS` | Allowed callback origins | No | `http://localhost:3000,http://localhost:3001` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads (optional with IRSA) | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads (optional with IRSA) | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ATTACHMENTS_BUCKET_NAME` | S3 bucket name for attachments | For file uploads | `attachments` |
| `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` |
| `KAN_ADMIN_API_KEY` | Admin API key for stats and admin endpoints | For admin/monitoring | `your-secret-admin-key` |
| Variable | Description | Required | Example |
| ----------------------------------------- | -------------------------------------------------------- | ------------------------ | ----------------------------------------------------------- |
| `POSTGRES_URL` | PostgreSQL connection URL | To use external database | `postgres://user:pass@localhost:5432/db` |
| `EMAIL_FROM` | Sender email address | For Email | `"Kan <hello@mail.kan.bn>"` |
| `SMTP_HOST` | SMTP server hostname | For Email | `smtp.resend.com` |
| `SMTP_PORT` | SMTP server port | For Email | `465` |
| `SMTP_USER` | SMTP username/email | No | `resend` |
| `SMTP_PASSWORD` | SMTP password/token | No | `re_xxxx` |
| `SMTP_SECURE` | Use secure SMTP connection (defaults to true if not set) | For Email | `true` |
| `NEXT_PUBLIC_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` |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | For Google login | `xxx.apps.googleusercontent.com` |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | For Google login | `xxx` |
| `DISCORD_CLIENT_ID` | Discord OAuth client ID | For Discord login | `xxx` |
| `DISCORD_CLIENT_SECRET` | Discord OAuth client secret | For Discord login | `xxx` |
| `GITHUB_CLIENT_ID` | GitHub OAuth client ID | For GitHub login | `xxx` |
| `GITHUB_CLIENT_SECRET` | GitHub OAuth client secret | For GitHub login | `xxx` |
| `OIDC_CLIENT_ID` | Generic OIDC client ID | For OIDC login | `xxx` |
| `OIDC_CLIENT_SECRET` | Generic OIDC client secret | For OIDC login | `xxx` |
| `OIDC_DISCOVERY_URL` | OIDC discovery URL | For OIDC login | `https://auth.example.com/.well-known/openid-configuration` |
| `TRELLO_APP_API_KEY` | Trello app API key | For Trello import | `xxx` |
| `TRELLO_APP_API_SECRET` | Trello app API secret | For Trello import | `xxx` |
| `S3_REGION` | S3 storage region | For file uploads | `WEUR` |
| `S3_ENDPOINT` | S3 endpoint URL | For file uploads | `https://xxx.r2.cloudflarestorage.com` |
| `S3_ACCESS_KEY_ID` | S3 access key | For file uploads | `xxx` |
| `S3_SECRET_ACCESS_KEY` | S3 secret key | For file uploads | `xxx` |
| `S3_FORCE_PATH_STYLE` | Use path-style URLs for S3 | For file uploads | `true` |
| `NEXT_PUBLIC_STORAGE_URL` | Storage service URL | For file uploads | `https://storage.kanbn.com` |
| `NEXT_PUBLIC_STORAGE_DOMAIN` | Storage domain name | For file uploads | `kanbn.com` |
| `NEXT_PUBLIC_AVATAR_BUCKET_NAME` | S3 bucket name for avatars | For file uploads | `avatars` |
| `NEXT_PUBLIC_ALLOW_CREDENTIALS` | Allow email & password login | For authentication | `true` |
| `NEXT_PUBLIC_DISABLE_SIGN_UP` | Disable sign up | For authentication | `false` |
| `NEXT_PUBLIC_WHITE_LABEL_HIDE_POWERED_BY` | Hide “Powered by kan.bn” on public boards (self-host) | For white labelling | `true` |
See `.env.example` for a complete list of supported environment variables.

View File

@@ -4,9 +4,8 @@ ARG PROJECT=@kan/web
# 1. Alpine image
FROM node:${NODE_VERSION}-alpine AS alpine
RUN apk update && \
apk add --no-cache --virtual .build-deps libc6-compat python3 make g++ && \
rm -rf /var/cache/apk/* /tmp/* || true
RUN apk update
RUN apk add --no-cache libc6-compat python3 make g++
# Setup pnpm and turbo on the alpine base
FROM alpine AS base

View File

@@ -2,7 +2,7 @@
"version": 0,
"locale": {
"source": "en",
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl", "ptbr"]
"targets": ["fr", "de", "es", "it", "nl", "ru", "pl"]
},
"buckets": {
"po": {

View File

@@ -11,11 +11,9 @@ checksums:
"%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
Actions/singular: c46571856723b03262fd33f511116298
Activity/singular: 1948763de8e531483a798b68195e297e
Activity%20log/singular: 3ec6755040306fdc9ce801bcf6ab28e1
Activity%20logs/singular: 8b1f0bb96a905646ecfad1cfdfa42168
@@ -27,7 +25,6 @@ checksums:
Add%20details.../singular: 2f42547fd5d199f173aa7a88c8a9b19a
Add%20label/singular: 0be732d46df263265935fda342097a08
Add%20member/singular: 11979625770516ca287e929381778e02
added%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 42b0488d570626dc887c58ff669c953c
added%20a%20checklist/singular: 44304f4a5ef3a46378eea243b5d2df79
added%20a%20checklist%20item/singular: 20bc0330dba5a9ec978e04bec174365a
added%20a%20label%20to%20the%20card/singular: 04c5e4ee9b7b7e77ef089d2ac473d235
@@ -62,7 +59,6 @@ checksums:
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
@@ -93,8 +89,6 @@ checksums:
Card%20title/singular: 7c34f59f4005e6cb3a6ff546ea0b96e3
Change%20Password/singular: a552fc5c4189ebc3e2e6018edda7d18f
Change%20your%20language%20preferences./singular: 293d49fc3c75e9c425b64bd7126e6b46
changed%20the%20due%20date%20to%20%3C0%3E%7BformattedDate%7D%3C%2F0%3E/singular: 32189aa87452ab18f90f02742bfa9910
Check%20out%20some%20of%20our%20favorite%20open%20source%20projects./singular: b0d0be4f63c16552b3aa795af29240d1
Check%20your%20inbox/singular: e9a430fcd298def74212238df0f680d6
Checklist%20name/singular: 5eb5de823f7ca5a4d97bb41e6a3f675a
Checklists/singular: 6f79129c8f08ee54d858a2af57d16dd9
@@ -112,7 +106,6 @@ checksums:
Complete%20control%20and%20ownership%3A/singular: 0d8b682ba873272217425ccfc96aa9cd
completed%20a%20checklist%20item/singular: 757b04c6c80cc927e1c597c0ad4fda33
completed%20checklist%20item%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 71ec18acf051fc909a48a07ca3578673
Confirm%20your%20email%20preferences%3A/singular: 043c161dd9866231ae2418fdd9f61b9c
Confirm%20your%20new%20password/singular: a0d2935d7b63f8dd19d7c0de47524416
Connect%20Trello/singular: 4440a0b9e387ef7136e3958e7a089213
Connect%20your%20favorite%20tools%20to%20streamline%20your%20workflow./singular: 033c5dcdb059ccb634aef7fe63f2f45d
@@ -171,18 +164,11 @@ checksums:
Display%20name%20must%20be%20at%20least%203%20characters%20long/singular: 84178b79845a70937af5e26020403052
Display%20name%20updated/singular: 4e27503ca24ef5f009713951ca9e5b97
Do%20you%20offer%20a%20free%20plan%3F/singular: 3ef16b0994f0d9ae17067782ca9bed1b
Do%20you%20want%20to%20unsubscribe%3F/singular: 8173b82c731ba885939d223eeef2e40b
docs/singular: 307bff5678ada918da1f1ec3edc76cac
Docs/singular: 55fea190a0c0fb32acd3dc986cd3cc91
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
Due%20date/singular: b098b52aff38516c5838c2ac4419f958
Due%20next%20month/singular: 223ab6f1211e1dfdf39ea68d25fe380b
Due%20next%20week/singular: 2f8fac5719df18d25a466ee913dc6487
Due%20today/singular: a14cb9dd0003485894d328bb803c80e5
Due%20tomorrow/singular: 0b6c8ad7aba0873b7e212d5f1949ca97
Edit%20board%20URL/singular: d8276dfc0189f371ec7d80a2047c07aa
Edit%20comment/singular: 7e4b46525fcb6b47b71798e31c46e374
Edit%20label/singular: 0309e0be1512b1e0b0ceb87c69a53d03
@@ -221,7 +207,6 @@ checksums:
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
@@ -232,14 +217,13 @@ checksums:
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
Full-time/singular: fe6e201f6676ae354111435cf6c76910
Fun/singular: 395bdc48e943762b6cde649f4a96771d
General/singular: b891e8f15579fc5d97bcaf3637f5ae59
Get%20started/singular: 5c783951b0100a168bdd2161ff294833
Get%20Started/singular: 1d5f030c4ec9c869e647ae060518b948
Get%20started%20by%20creating%20a%20new%20%7B0%7D/singular: 4ca5ef637c08104e9dbb5ff9f49f0680
@@ -251,10 +235,6 @@ checksums:
GitHub/singular: 6e1cf3c00fa6fbe24afcc78ea3b5f3e4
Go%20Home/singular: 6251589da1964d55afabdfd64c84c335
Go%20to%20app/singular: 896d0441384dcd2bfb3b23d61ff1944d
Go%20to%20boards/singular: dc1362de207d67fe84fb0caf60262e73
Go%20to%20members/singular: 445f4efbc4b1e7509f4fd79ebfbb1476
Go%20to%20settings/singular: 24a7f96880650c9b37099d69f4b7e2a9
Go%20to%20templates/singular: e4e58e33d637282d141df466d729bc7c
High%20Priority/singular: 5d231ff8254aabc875f194c4b4f49c97
Hired/singular: e5a9b1bd409b007141fe3d7890022f9a
Host%20Kan%20on%20your%20own%20infrastructure.%20Ideal%20for%20organisations%20that%20need%20complete%20control%20over%20their%20data./singular: 8e7ae0783d60ef4624d3caf9bfc3747f
@@ -298,7 +278,6 @@ checksums:
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
Keyboard%20Shortcuts/singular: ef00d7494b69def6841620bd6554d040
Labels/singular: 6f15627a90002323eac018274b6922d6
Labels%20%26%20filters/singular: e1ceda0c6cc32fb04cb9423582ad8fe4
Labels%20%26%20Filters/singular: 8f0bdd6084516f8241cb613178114390
@@ -306,7 +285,6 @@ checksums:
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
@@ -314,9 +292,6 @@ checksums:
Link%20copied/singular: 4a4805700b31ec65bcb3ca962bccfd1b
List/singular: 94f13e7ef909a4de9db7abaa1f9f0b61
List%20name/singular: e925e2e6ccaf0eb4064a888aaea8d3c2
Lists/singular: 9f4a73afc8de321175d71935134ef066
Load%20more%20activities/singular: f32d40a739ffaa700051c4c7d70055cf
Loading.../singular: 82b4ea7ed1439094d7c4be13aaba9a66
Login%20%7C%20kan.bn/singular: 42a6c8dcd73e0d46e652646dc86871eb
Logout/singular: 07948fdf20705e04a7bf68ab197512bf
Long-term/singular: 51cb6f0b112250c5a091ca5f0866904b
@@ -334,7 +309,6 @@ checksums:
moved%20the%20card%20from%20%3C0%3E%7B0%7D%3C%2F0%3E%20to%3C1%3E%7B1%7D%3C%2F1%3E/singular: 11edf0427766c26db541d46379dc3c16
moved%20the%20card%20to%20another%20list/singular: 2e8c41bfafb42fa299ce56e4300205ff
Name/singular: 9368b5a047572b6051f334af5aa76819
Navigation/singular: 0373afd8238db1c49f4be4fa6cdf5cd3
Need%20help%3F/singular: 04e7322f2d3ffb2d73ff2f64b71637c8
New/singular: 126d036fae5fb6b629728ecb97e6195b
New%20%7B0%7D/singular: 67b6a22a65e0956f2091d00e6966652b
@@ -352,26 +326,17 @@ checksums:
Newsletter/singular: dcfb9666301924431ac8ae88e5b6f9d7
Next%20Steps/singular: 12392fe73ae60b6e5a9d851256b8477e
No%20%7B0%7D/singular: a7a95e1dfdb4396da9e2be0e27f42323
No%20authentication%20methods%20are%20currently%20available/singular: f718c896810da3612202887f9a4374fa
No%20boards%20found/singular: e044088d2c51b6bdf660fafa86ee1e17
No%20credit%20card%20required/singular: 2090aa4171dc0b60735069f89b592883
No%20dates/singular: bd0ec82b3b1f4a5fa89d362b71f8141d
No%20download%20URL%20available%20for%20this%20attachment./singular: e367d39420b2242f9d2fd749c87f446a
No%20keyboard%20shortcuts%20registered./singular: 7f1ed5d777cade7d62303e9e591bbf63
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%20command%20menu/singular: cf00563ea1a994e7c36a761d56664acc
Open%20keyboard%20shortcuts/singular: 638ae0999fd03d2c611064274859422e
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
Overdue/singular: 24caaa2b5d7a2447ab7664e3771cf98c
Own%20your%20data/singular: cc2178dac4bdf6b07f030cfc2a7510e6
Owned%20by%20Atlassian/singular: ace4ed076a5318ad48c296fa09afed69
Part-time/singular: 213d63da450f35dabb3ab0e35e29feed
@@ -412,11 +377,9 @@ checksums:
Remote/singular: dc3e4280dfe5c455b38ba6c8884999cf
Remove/singular: dba2fe5fe9f83f8078c687f28cba4b52
Remove%20member/singular: 1d77c2ca3768e486fd1fb5df30cb60d9
removed%20%7B0%7D%20labels%3A%20%3C0%3E%7BlabelList%7D%3C%2F0%3E/singular: 612fcfd4aca6818fc870f81d9687d63f
removed%20a%20label%20from%20the%20card/singular: f83ec18850a2a8a3f8242a740a04df06
removed%20a%20member%20from%20the%20card/singular: 0bd7561bb79358fde3d595c3e60a315a
removed%20label%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 7e0936b15831e65754588b8512f62fcb
removed%20the%20due%20date/singular: 000c084844718540fcad542dd8caf8b4
renamed%20a%20checklist/singular: 4d208de1857740e63469007c5b8b491a
renamed%20checklist%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: e95d119ef65b0af6c0a96bef182be671
renamed%20checklist%20item%20to%20%3C0%3E%7B0%7D%3C%2F0%3E/singular: 50f55f71f9245890afee434d7adc2140
@@ -441,15 +404,12 @@ checksums:
Self-hostable/singular: 3e3597145cc17f03b8b5646b80d59592
Send%20feedback/singular: 9631cc08d49da04475b30a0d320ce97c
Senior/singular: 3fff865dc00435f82896fc302ea45630
Set%20due%20date/singular: 3cb81c4829857556f5d117c9a23f7bcc
set%20the%20due%20date/singular: b5d9a1edd988f455041aee9fa7fb3d63
Settings/singular: 8df6777277469c1fd88cc18dde2f1cc3
Settings%20%7C%20Account/singular: 050e18406849ec057edac877c297c3e1
Settings%20%7C%20API/singular: 85101e4b802a09ad9e3f01ff116f0894
Settings%20%7C%20Billing/singular: e44cba741d5414035a0b499c5766c203
Settings%20%7C%20Integrations/singular: d04992e28016452f6d3d7dcc0b592415
Settings%20%7C%20Workspace/singular: 5d0bacf7ff696da940f232df45edfd39
Shortcuts/singular: db3330ed3240c398054f3be23c52851f
Sign%20in/singular: cb8757c7450e17de1e226e82fb0fa4a2
Sign%20In/singular: ec7b8f314fe9bc6591006707484ede61
Sign%20Up/singular: 0dd2ae69be4618c1f9e615774a4509ca
@@ -462,7 +422,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
@@ -506,19 +465,16 @@ checksums:
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%20add%20label/singular: b09fda6420ea1dc10dbea0b1dc373f29
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
Unable%20to%20delete%20comment/singular: 550198b2c87f06726a843c79c1026ed9
Unable%20to%20remove%20member/singular: 39025a0c53818438829603d213baef06
Unable%20to%20reorder%20checklist%20item/singular: dcc238c72b85daf74ebd46adcd914473
Unable%20to%20send%20feedback/singular: 656c93265d7e2bef1245b17d85f85ae5
Unable%20to%20update%20board%20URL/singular: 080746884059142358b58d9a44ff7d93
Unable%20to%20update%20board%20visibility/singular: a76a21d561b8943e9276e027a1e3f70d
@@ -526,7 +482,6 @@ checksums:
Unable%20to%20update%20checklist/singular: 44c10f49f448909ceca481ed9b4b07e7
Unable%20to%20update%20checklist%20item/singular: 5c86b3523531c30d58856e3ecde0cb55
Unable%20to%20update%20comment/singular: 05e798ed6b0f3fee41f3e8832eb699ea
Unable%20to%20update%20due%20date/singular: af20d7482cb639a8c8264c681a18abed
Unable%20to%20update%20labels/singular: dca2bdc3dcf74bc9d95e05156039a291
Unable%20to%20update%20list/singular: 14aa802f91b9b4c05236c8c75afb33da
Unable%20to%20update%20members/singular: 9a851a6b0c75ee16d25cf2ff4b67b255
@@ -540,17 +495,16 @@ checksums:
Unlimited%20lists/singular: 5c418701b27b9acbc5dd199719007362
Unlimited%20members/singular: f2949ca2dc18b0063af92e60779abb65
Unlimited%20workspaces/singular: b504d01997bf908af1d486d9942fed36
Unsubscribe/singular: d1a0b07295687d1f08d4df1c411072dd
Update/singular: 079fc039262fd31b10532929685c2d1b
Update%20label/singular: 97880b503dfe956941c5f23025a1c102
updated%20a%20checklist%20item/singular: 198e1d1d503f4b69b752dd84d0cc0e9d
updated%20the%20description/singular: 70fd604b6dc957a75be90ea77572d76d
updated%20the%20due%20date/singular: 7636c200d50fed58a4295ed1e9b5c1e0
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
@@ -567,12 +521,11 @@ checksums:
View%20workspace/singular: c9e4b789330b08c18ca8db7044409e5f
Visibility/singular: 1ff412f8fcde0f460acbf11153649646
We%20are%20using%20the%20%3C0%3EAGPL-3.0%20license%3C%2F0%3E./singular: 08c5ddd938a2a70f61d684e41e18697a
We%20couldn't%20update%20your%20preferences.%20Please%20try%20again./singular: 69d1d2575f2e73b08ac622bde0a45b4d
We're%20just%20getting%20started.%20/singular: 2dfa121e0ff1914e08068308a81973c0
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
When%20Trello%20launched%20in%202011%2C%20it%20blew%20everyone%20away%20with%20its%20carefully%20designed%20simplicity%2C%20but%20over%20time%20(and%20atlassification)%20it%20lost%20its%20magic%20and%20grew%20into%20something%20closer%20to%20%22Jira%20Lite%22.%20This%20project%20is%20our%20attempt%20to%20recapture%20the%20original%20magic%20of%20Trello's%20simplicity%2C%20in%20open%20source./singular: 60a55e9bd16c3d1beff1a6c7633c5911
White%20label/singular: 2570386f73a010b07219e7658aaf9d9b
Why%20make%20an%20open%20source%20Trello%3F/singular: 090833338ba8c64ef7a75e0f2615b34b
Workspace/singular: b63ef0e99ee6f7fef6cbe4971ca6cf0f
@@ -584,7 +537,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
@@ -600,19 +553,16 @@ checksums:
You%20can%20self-host%20by%20following%20the%20instructions%20in%20our%20%3C0%3Erepo%3C%2F0%3E./singular: a6152a41b2d8f64d5a657e5b8bc808a9
You%20have%20been%20logged%20in%20successfully./singular: ef8fad1dce13ae4112f17c5258655fea
You%20have%20been%20signed%20up%20successfully./singular: f614a6e3b45f5ffb9a3b0fb420fef84b
You%20have%20been%20unsubscribed!/singular: e0b9985faa4e7f25b71acc77750923a6
You%20have%20unlimited%20seats%20with%20your%20Pro%20Plan.%20There%20is%20no%20additional%20charge%20for%20new%20members!/singular: e3dc59a5ba7211cd3d8516b3a79d85ca
You've%20been%20invited%20to%20join%20a%20workspace%20on%20kan.bn./singular: 257b840726f972f384243a72767f880f
You've%20been%20invited%20to%20join%20a%20workspace./singular: 24fc6cdc8740f37a83df85f582f03293
Your%20account%20has%20been%20deleted./singular: 8c8d944e07388c5877effdb2c2803dcf
Your%20boards%20have%20been%20imported./singular: 403972e7a25afc2415762c1c2b1ec868
Your%20display%20name%20has%20been%20updated./singular: 15e5fff36c554c16ec5214427fae1bf4
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
Your%20Trello%20account%20is%20connected./singular: 0499d193001fce2fbec445ae034ce512
Your%20unsubscribe%20link%20is%20missing%20a%20token.%20Please%20open%20the%20latest%20email%20and%20try%20again./singular: 3e4cce96bc1197a1caa2b7fb4d3c7e53
Your%20workspace%20description%20has%20been%20updated./singular: 98de6f65c567c6654cc0adce3c9d5656
Your%20workspace%20has%20been%20deleted./singular: e7a3efcfc7dd18cb3e917acb67498292
Your%20workspace%20name%20has%20been%20updated./singular: a87ea3b0d71e6dc5dd525d77114a9322

View File

@@ -1,7 +1,7 @@
import type { LinguiConfig } from "@lingui/conf";
const config: LinguiConfig = {
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl","ptbr"],
locales: ["en", "fr", "de", "es", "it", "nl", "ru", "pl"],
sourceLocale: "en",
catalogs: [
{

View File

@@ -26,64 +26,34 @@ const config = {
],
/** We already do linting and typechecking as separate tasks in CI */
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
// temporarily ignore eslint errors during build until we fix all the errors sigh
eslint: { ignoreDuringBuilds: 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;
})(),
},
turbopack: {
rules: {
"*.svg": {
loaders: ["@svgr/webpack"],
as: "*.js",
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",
},
],
},
webpack(config) {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});
return config;
},
experimental: {
// instrumentationHook: true,

View File

@@ -4,9 +4,9 @@
"private": true,
"type": "module",
"scripts": {
"build": "pnpm with-env next build --turbo",
"build": "pnpm with-env next build",
"clean": "git clean -xdf .cache .next .turbo node_modules",
"dev": "pnpm with-env next dev --turbo",
"dev": "pnpm with-env next dev",
"format": "prettier --check . --ignore-path ../../.gitignore",
"lint": "eslint",
"start": "pnpm with-env next start",
@@ -29,7 +29,6 @@
"@lingui/conf": "^5.3.2",
"@lingui/macro": "^5.3.2",
"@lingui/react": "^5.3.2",
"@novu/api": "^3.11.0",
"@t3-oss/env-nextjs": "^0.11.1",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/react-query": "catalog:",
@@ -44,13 +43,15 @@
"@trpc/next": "^11.0.0-rc.660",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"aws-sdk": "^2.1692.0",
"date-fns": "^4.1.0",
"geist": "^1.3.1",
"jose": "^6.1.2",
"next": "15.5.9",
"next": "^15.3.4",
"next-logger": "^5.0.1",
"next-runtime-env": "^1.7.2",
"next-themes": "^0.4.6",
"nextjs-cors": "^2.2.0",
"pino": "^9.6.0",
"posthog-js": "^1.254.0",
"react": "catalog:react18",
"react-beautiful-dnd": "^13.1.1",
@@ -64,7 +65,6 @@
"superjson": "2.2.1",
"tailwind-merge": "^2.5.2",
"tippy.js": "^6.3.7",
"tiptap-markdown": "^0.8",
"zod": "catalog:"
},
"devDependencies": {
@@ -84,6 +84,7 @@
"dotenv-cli": "^7.4.4",
"eslint": "catalog:",
"jiti": "^1.21.6",
"pino-pretty": "^13.0.0",
"prettier": "catalog:",
"tailwindcss": "catalog:",
"typescript": "catalog:"

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -303,10 +303,6 @@ export function Auth({ setIsMagicLinkSent, isSignUp }: AuthProps) {
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.
@@ -362,16 +358,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 +381,46 @@ 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">
{errors.password.message ?? 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 `}
{isMagicLinkMode ? t`magic link` : t`email`}
</Button>
</div>
</form>
</div>
);

View File

@@ -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>
);

View File

@@ -1,136 +0,0 @@
import {
addMonths,
eachDayOfInterval,
endOfMonth,
endOfWeek,
format,
isSameDay,
isToday,
startOfMonth,
startOfWeek,
subMonths,
} from "date-fns";
import { useMemo, useState } from "react";
import { HiChevronLeft, HiChevronRight } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
interface DateSelectorProps {
selectedDate?: Date | null;
onDateSelect?: (date: Date | undefined) => void;
}
const DateSelector = ({ selectedDate, onDateSelect }: DateSelectorProps) => {
const [currentMonth, setCurrentMonth] = useState(() => {
return selectedDate ? startOfMonth(selectedDate) : startOfMonth(new Date());
});
const monthName = format(currentMonth, "MMMM");
const year = format(currentMonth, "yyyy");
const dayHeaders = useMemo(() => {
const weekStart = startOfWeek(new Date(), { weekStartsOn: 1 }); // Monday
return eachDayOfInterval({
start: weekStart,
end: new Date(weekStart.getTime() + 6 * 24 * 60 * 60 * 1000),
}).map((date) => format(date, "EEEEEE")); // Shortest localized day name
}, []);
const days = useMemo(() => {
const monthStart = startOfMonth(currentMonth);
const monthEnd = endOfMonth(currentMonth);
const calendarStart = startOfWeek(monthStart, { weekStartsOn: 1 }); // Monday
const calendarEnd = endOfWeek(monthEnd, { weekStartsOn: 1 }); // Monday
return eachDayOfInterval({ start: calendarStart, end: calendarEnd }).map(
(date) => {
const dateString = format(date, "yyyy-MM-dd");
return {
date: dateString,
isToday: isToday(date),
isSelected: selectedDate ? isSameDay(date, selectedDate) : false,
isCurrentMonth: date >= monthStart && date <= monthEnd,
dateObj: date,
};
},
);
}, [currentMonth, selectedDate]);
const handlePreviousMonth = () => {
setCurrentMonth(subMonths(currentMonth, 1));
};
const handleNextMonth = () => {
setCurrentMonth(addMonths(currentMonth, 1));
};
const handleDateClick = (date: Date, e: React.MouseEvent) => {
e.stopPropagation();
// If clicking the same date that's already selected, unselect it
if (selectedDate && isSameDay(date, selectedDate)) {
onDateSelect?.(undefined);
} else {
onDateSelect?.(date);
}
};
return (
<div className="w-[250px] p-4">
<div className="flex items-center text-light-1000 dark:text-dark-1000">
<button
type="button"
onClick={handlePreviousMonth}
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
>
<span className="sr-only">Previous month</span>
<HiChevronLeft aria-hidden="true" className="h-4 w-4" />
</button>
<div className="flex-1 text-center text-sm font-semibold">
{monthName} {year}
</div>
<button
type="button"
onClick={handleNextMonth}
className="flex flex-none items-center justify-center p-1.5 text-light-700 hover:text-light-900 dark:text-dark-700 dark:hover:text-dark-1000"
>
<span className="sr-only">Next month</span>
<HiChevronRight aria-hidden="true" className="h-4 w-4" />
</button>
</div>
<div className="mt-6 grid grid-cols-7 text-center text-xs/6 text-light-950 dark:text-dark-950">
{dayHeaders.map((day, index) => (
<div key={index}>{day}</div>
))}
</div>
<div className="isolate mt-2 grid grid-cols-7 text-sm">
{days.map((day) => (
<button
key={day.date}
type="button"
onClick={(e) => handleDateClick(day.dateObj, e)}
className={twMerge(
"flex aspect-square items-center justify-center rounded-lg focus:z-10",
day.isSelected
? "bg-light-1000 hover:bg-light-1000 dark:bg-dark-1000 dark:hover:bg-dark-1000"
: "bg-transparent hover:bg-light-200 dark:bg-transparent dark:hover:bg-dark-200",
)}
>
<time
dateTime={day.date}
className={twMerge(
"mx-auto flex size-7 items-center justify-center rounded-full text-light-900 dark:text-dark-900",
day.isCurrentMonth
? "text-light-900 dark:text-dark-900"
: "text-light-700 dark:text-dark-600",
day.isSelected && "text-light-50 dark:text-dark-50",
)}
>
{day.date.split("-").pop()?.replace(/^0/, "")}
</time>
</button>
))}
</div>
</div>
);
};
export default DateSelector;

View File

@@ -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>&nbsp;`;
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>&nbsp;`;
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,

View File

@@ -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, {

View File

@@ -2,10 +2,8 @@ import Link from "next/link";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
import type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
import LottieIcon from "~/components/LottieIcon";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
const Button: React.FC<{
href: string;
@@ -14,20 +12,10 @@ const Button: React.FC<{
json: object;
isCollapsed?: boolean;
onCloseSideNav?: () => void;
keyboardShortcut: KeyboardShortcut;
}> = ({
href,
current,
name,
json,
isCollapsed = false,
keyboardShortcut,
onCloseSideNav,
}) => {
}> = ({ href, current, name, json, isCollapsed = false, onCloseSideNav }) => {
const [isHovered, setIsHovered] = useState(false);
const [index, setIndex] = useState(0);
const isMobile = useIsMobile();
const { keys: shortcutKeys } = useKeyboardShortcut(keyboardShortcut);
const handleMouseEnter = () => {
setIsHovered(true);
@@ -47,27 +35,17 @@ const Button: React.FC<{
onClick={handleClick}
className={twMerge(
"group flex h-[34px] items-center rounded-md p-1.5 text-sm font-normal leading-6 hover:bg-light-200 hover:text-light-1000 dark:hover:bg-dark-200 dark:hover:text-dark-1000",
isCollapsed ? "md:justify-center" : "justify-between",
current
? "bg-light-200 text-light-1000 dark:bg-dark-200 dark:text-dark-1000"
: "text-neutral-600 dark:bg-dark-100 dark:text-dark-900",
isCollapsed
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
: "gap-x-3",
)}
title={isCollapsed ? name : undefined}
>
<div
className={twMerge(
"flex items-center",
isCollapsed
? "justify-start gap-x-3 md:justify-center md:gap-x-0"
: "gap-x-3",
)}
>
<LottieIcon index={index} json={json} isPlaying={isHovered} />
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
</div>
{!isCollapsed && (
<div className="hidden md:group-hover:inline-flex">{shortcutKeys}</div>
)}
<LottieIcon index={index} json={json} isPlaying={isHovered} />
<span className={twMerge(isCollapsed && "md:hidden")}>{name}</span>
</Link>
);
};

View File

@@ -2,20 +2,14 @@ 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 type { KeyboardShortcut } from "~/providers/keyboard-shortcuts";
import boardsIconDark from "~/assets/boards-dark.json";
import boardsIconLight from "~/assets/boards-light.json";
import membersIconDark from "~/assets/members-dark.json";
@@ -24,13 +18,9 @@ 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";
interface SideNavigationProps {
user: UserType;
@@ -49,23 +39,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);
}, []);
@@ -81,65 +61,30 @@ 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";
const navigation: {
name: string;
href: string;
icon: object;
keyboardShortcut: KeyboardShortcut;
}[] = [
const navigation = [
{
name: t`Boards`,
href: "/boards",
icon: isDarkMode ? boardsIconDark : boardsIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "B" }],
action: () => router.push("/boards"),
group: "NAVIGATION",
description: t`Go to boards`,
},
},
{
name: t`Templates`,
href: "/templates",
icon: isDarkMode ? templatesIconDark : templatesIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "T" }],
action: () => router.push("/templates"),
group: "NAVIGATION",
description: t`Go to templates`,
},
},
{
name: t`Members`,
href: "/members",
icon: isDarkMode ? membersIconDark : membersIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "M" }],
action: () => router.push("/members"),
group: "NAVIGATION",
description: t`Go to members`,
},
},
{
name: t`Settings`,
href: "/settings",
icon: isDarkMode ? settingsIconDark : settingsIconLight,
keyboardShortcut: {
type: "SEQUENCE",
strokes: [{ key: "G" }, { key: "S" }],
action: () => router.push("/settings"),
group: "NAVIGATION",
description: t`Go to settings`,
},
},
];
@@ -156,10 +101,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>
@@ -197,14 +142,13 @@ export default function SideNavigation({
json={item.icon}
isCollapsed={isCollapsed}
onCloseSideNav={onCloseSideNav}
keyboardShortcut={item.keyboardShortcut}
/>
</li>
))}
</ul>
</div>
<div className="space-y-2">
<div className="space-y-3">
<UserMenu
email={user.email ?? ""}
imageUrl={user.image ?? undefined}
@@ -212,33 +156,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>
</>

View File

@@ -1,51 +0,0 @@
import type { ReactNode } from "react";
import type { Root } from "react-dom/client";
import type { Placement } from "tippy.js";
import { useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
import tippy from "tippy.js";
interface TooltipProps {
children: ReactNode;
content: ReactNode;
placement?: Placement;
delay?: number | [number, number];
}
export function Tooltip({
children,
content,
placement = "bottom",
delay = [500, 0],
}: TooltipProps) {
const triggerRef = useRef<HTMLDivElement>(null);
const rootRef = useRef<Root | null>(null);
useEffect(() => {
if (!triggerRef.current) return;
const container = document.createElement("div");
const root = createRoot(container);
rootRef.current = root;
root.render(content);
const instance = tippy(triggerRef.current, {
content: container,
placement,
delay,
interactive: false,
theme: "tooltip",
});
return () => {
instance.destroy();
rootRef.current?.unmount();
};
}, [content, placement, delay]);
return (
<div ref={triggerRef} className="inline-flex">
{children}
</div>
);
}

View File

@@ -10,7 +10,6 @@ import { twMerge } from "tailwind-merge";
import { authClient } from "@kan/auth/client";
import { useIsMobile } from "~/hooks/useMediaQuery";
import { useKeyboardShortcuts } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { getAvatarUrl } from "~/utils/helpers";
@@ -32,7 +31,6 @@ export default function UserMenu({
const router = useRouter();
const { theme, setTheme } = useTheme();
const { openModal } = useModal();
const { openLegend } = useKeyboardShortcuts();
const isMobile = useIsMobile();
const handleLogout = async () => {
@@ -171,19 +169,6 @@ export default function UserMenu({
</Menu.Item>
</div>
<div className="light-border-600 border-t-[1px] p-1 dark:border-dark-600">
<Menu.Item>
<button
onClick={() => {
if (onCloseSideNav && isMobile) {
onCloseSideNav();
}
openLegend();
}}
className="flex w-full items-center rounded-[5px] px-3 py-2 text-left text-xs hover:bg-light-200 dark:hover:bg-dark-400"
>
{t`Shortcuts`}
</button>
</Menu.Item>
<Menu.Item>
<Link
href="mailto:support@kan.bn"

View File

@@ -1,14 +1,12 @@
import { Button, Menu, Transition } from "@headlessui/react";
import { t } from "@lingui/core/macro";
import { Fragment, useState } from "react";
import { Fragment, useEffect, useState } from "react";
import { HiCheck, HiMagnifyingGlass } from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import CommandPallette from "./CommandPallette";
import { Tooltip } from "./Tooltip";
export default function WorkspaceMenu({
isCollapsed = false,
@@ -20,17 +18,17 @@ export default function WorkspaceMenu({
const { openModal } = useModal();
const [isOpen, setIsOpen] = useState(false);
const { tooltipContent: commandPaletteShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: {
key: "k",
modifiers: ["META"],
},
action: () => setIsOpen(true),
description: t`Open command menu`,
group: "GENERAL",
});
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 (
<>
@@ -50,13 +48,13 @@ export default function WorkspaceMenu({
) : (
<div
className={twMerge(
"flex items-center justify-start gap-1",
isCollapsed && "md:flex-col-reverse md:items-center",
"flex items-center gap-1",
isCollapsed && "md:flex-col-reverse md:items-stretch",
)}
>
<Menu.Button
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",
"mb-1 flex h-[34px] flex-1 items-center rounded-md p-1.5 hover:bg-light-200 dark:hover:bg-dark-200",
isCollapsed &&
"md:mb-1.5 md:h-9 md:w-9 md:flex-none md:justify-center md:p-0",
)}
@@ -69,7 +67,7 @@ export default function WorkspaceMenu({
</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",
"ml-2 truncate text-sm font-bold text-neutral-900 dark:text-dark-1000",
isCollapsed && "md:hidden",
)}
>
@@ -86,17 +84,15 @@ export default function WorkspaceMenu({
</span>
)}
</Menu.Button>
<Tooltip content={commandPaletteShortcutTooltipContent}>
<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>
</Tooltip>
<Button
className={twMerge(
"mb-1 h-[34px] w-[34px] flex-shrink-0 rounded-lg bg-light-200 p-2 hover:bg-light-300 focus:outline-none dark:bg-dark-200 dark:hover:bg-dark-300",
isCollapsed && "md:mb-2 md:h-9 md:w-9 md:self-center",
)}
onClick={() => setIsOpen(true)}
>
<HiMagnifyingGlass className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
)}
</div>

View File

@@ -14,7 +14,6 @@ export const env = createEnv({
* This way you can ensure the app isn't built with invalid env vars.
*/
server: {
KAN_ADMIN_API_KEY: z.string().optional(),
BETTER_AUTH_SECRET: z.string(),
BETTER_AUTH_TRUSTED_ORIGINS: z
.string()
@@ -52,8 +51,6 @@ export const env = createEnv({
VK_CLIENT_SECRET: z.string().optional(),
LINKEDIN_CLIENT_ID: z.string().optional(),
LINKEDIN_CLIENT_SECRET: z.string().optional(),
NOVU_API_KEY: z.string().optional(),
EMAIL_UNSUBSCRIBE_SECRET: z.string().optional(),
// Generic OIDC Provider
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
@@ -90,10 +87,9 @@ export const env = createEnv({
NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
NEXT_PUBLIC_USE_STANDALONE_OUTPUT: z.string().optional(),
NEXT_PUBLIC_BASE_URL: z.string().url().optional(),
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()
@@ -129,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,

View File

@@ -1,6 +1,4 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { useLingui } from "@lingui/react";
import { de, enGB, es, fr, it, nl, pl, ru, ptBR} from "date-fns/locale";
import type { Locale } from "~/locales";
import { useLinguiContext } from "~/providers/lingui";
@@ -10,20 +8,6 @@ export function useLocalisation() {
const { i18n } = useLingui();
const { locale, setLocale, availableLocales } = useLinguiContext();
const dateLocaleMap: Partial<Record<Locale, DateFnsLocale>> = {
en: enGB,
fr,
de,
es,
it,
nl,
ru,
pl,
ptbr: ptBR,
};
const currentDateLocale = dateLocaleMap[locale] ?? enGB;
const handleSetLocale = async (newLocale: Locale) => {
await activateLocale(newLocale);
setLocale(newLocale);
@@ -31,9 +15,9 @@ export function useLocalisation() {
return {
locale,
dateLocale: currentDateLocale,
setLocale: handleSetLocale,
availableLocales,
formatDate: i18n.date,
formatNumber: i18n.number,
};
}

View File

@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
await require("pino");
await require("next-logger");
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -7,7 +7,6 @@ export const locales = [
"nl",
"ru",
"pl",
"ptbr"
] as const;
export type Locale = (typeof locales)[number];
@@ -23,5 +22,4 @@ export const localeNames: Record<Locale, string> = {
nl: "Nederlands",
ru: "Русский",
pl: "Polski",
ptbr: "Português",
};

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -12,7 +12,6 @@ import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
import { KeyboardShortcutProvider } from "~/providers/keyboard-shortcuts";
import { LinguiProviderWrapper } from "~/providers/lingui";
import { ModalProvider } from "~/providers/modal";
import { PopupProvider } from "~/providers/popup";
@@ -81,23 +80,21 @@ const MyApp: AppType = ({ Component, pageProps }: AppPropsWithLayout) => {
)}
<script src="/__ENV.js" />
<main className="font-sans">
<KeyboardShortcutProvider>
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</KeyboardShortcutProvider>
<LinguiProviderWrapper>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<ModalProvider>
<PopupProvider>
{posthogKey ? (
<PostHogProvider client={posthog}>
{getLayout(<Component {...pageProps} />)}
</PostHogProvider>
) : (
getLayout(<Component {...pageProps} />)
)}
</PopupProvider>
</ModalProvider>
</ThemeProvider>
</LinguiProviderWrapper>
</main>
</>
);

View File

@@ -1,47 +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 downloadFilename = typeof filename === "string"
? encodeURIComponent(filename)
: "attachment";
const upstream = await fetch(url);
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}"; filename*=UTF-8''${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" });
}
}

View File

@@ -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" });
}
}

View File

@@ -88,16 +88,12 @@ export default async function handler(
const session = await stripe.checkout.sessions.create({
mode: "subscription",
payment_method_collection: "always",
line_items: [
{
price: process.env.STRIPE_PRO_PLAN_MONTHLY_PRICE_ID,
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,

View File

@@ -1,104 +0,0 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { Novu } from "@novu/api";
import { jwtVerify } from "jose";
import { z } from "zod";
import { env } from "~/env";
const requestSchema = z.object({
token: z.string().min(1),
});
const tokenPayloadSchema = z.object({
subscriberId: z.string(),
});
type ResponseData =
| { success: true }
| { success: false; error: string; code?: string };
const textEncoder = new TextEncoder();
export default async function handler(
req: NextApiRequest,
res: NextApiResponse<ResponseData>,
) {
if (process.env.NEXT_PUBLIC_KAN_ENV !== "cloud") {
return res.status(404).json({
success: false,
error: "Unsubscribe endpoint is not available.",
code: "UNAVAILABLE",
});
}
if (req.method !== "POST") {
res.setHeader("Allow", "POST");
return res.status(405).json({
success: false,
error: "Method not allowed.",
code: "METHOD_NOT_ALLOWED",
});
}
const parsedBody = requestSchema.safeParse(req.body);
if (!parsedBody.success) {
return res.status(400).json({
success: false,
error: "Invalid request payload.",
code: "BAD_REQUEST",
});
}
if (!env.EMAIL_UNSUBSCRIBE_SECRET || !env.NOVU_API_KEY) {
return res.status(500).json({
success: false,
error: "Unsubscribe service is not configured.",
code: "NOT_CONFIGURED",
});
}
let payload: z.infer<typeof tokenPayloadSchema>;
try {
const verified = await jwtVerify(
parsedBody.data.token,
textEncoder.encode(env.EMAIL_UNSUBSCRIBE_SECRET),
{
// We intentionally do not use exp/iat claims
// tokens are long-lived and validated only by signature + payload.
clockTolerance: "0s",
},
);
payload = tokenPayloadSchema.parse(verified.payload);
} catch {
return res.status(401).json({
success: false,
error: "Your unsubscribe link is invalid or has expired.",
code: "INVALID_TOKEN",
});
}
const novu = new Novu({ secretKey: env.NOVU_API_KEY });
try {
await novu.subscribers.preferences.update(
{
channels: {
email: false,
},
},
payload.subscriberId,
);
} catch (error) {
console.error("Failed to update Novu preferences", error);
return res.status(502).json({
success: false,
error:
"We could not update your email preferences right now. Please try again later.",
code: "NOVU_ERROR",
});
}
return res.status(200).json({ success: true });
}

View File

@@ -43,22 +43,18 @@ export default async function handler(
return res.status(400).json({ error: "Invalid content type" });
}
const credentials =
env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: env.S3_ACCESS_KEY_ID,
secretAccessKey: env.S3_SECRET_ACCESS_KEY,
}
: undefined;
const client = new S3Client({
region: env.S3_REGION ?? "",
endpoint: env.S3_ENDPOINT ?? "",
forcePathStyle: env.S3_FORCE_PATH_STYLE === "true",
credentials,
credentials: {
accessKeyId: env.S3_ACCESS_KEY_ID ?? "",
secretAccessKey: env.S3_SECRET_ACCESS_KEY ?? "",
},
});
const signedUrl = await getSignedUrl(
// @ts-ignore
client,
new PutObjectCommand({
Bucket: nextRuntimeEnv("NEXT_PUBLIC_AVATAR_BUCKET_NAME") ?? "",

View File

@@ -1,5 +0,0 @@
import OSSFriendsView from "~/views/oss-friends";
export default function OSSFriends() {
return <OSSFriendsView />;
}

View File

@@ -1,110 +0,0 @@
import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import Button from "~/components/Button";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
type UnsubscribeStatus = "idle" | "processing" | "success" | "error";
export default function UnsubscribePage() {
const router = useRouter();
const [token, setToken] = useState("");
const [status, setStatus] = useState<UnsubscribeStatus>("idle");
const [errorMessage, setErrorMessage] = useState<string | null>(null);
useEffect(() => {
if (!router.isReady) return;
const value = router.query.token;
if (typeof value === "string") {
setToken(value);
} else if (Array.isArray(value)) {
setToken(value[0] ?? "");
} else {
setToken("");
}
}, [router.isReady, router.query.token]);
const handleUnsubscribe = async () => {
if (!token) {
setStatus("error");
setErrorMessage(
t`Your unsubscribe link is missing a token. Please open the latest email and try again.`,
);
return;
}
setStatus("processing");
setErrorMessage(null);
try {
const response = await fetch("/api/unsubscribe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as {
error?: string;
} | null;
throw new Error(
payload?.error ??
"We couldn't update your preferences. Please try again.",
);
}
setStatus("success");
} catch (error) {
setStatus("error");
setErrorMessage(
t`We couldn't update your preferences. Please try again.`,
);
}
};
const title = t`Unsubscribe`;
return (
<>
<PageHead title={`${title} | kan.bn`} />
<div className="relative flex min-h-screen items-center justify-center px-4 py-12 sm:px-6 lg:px-8">
<PatternedBackground />
<div className="z-10 w-full max-w-md space-y-6">
<div>
<h1 className="mt-6 text-center text-3xl font-bold tracking-tight text-light-1000 dark:text-dark-1000">
{t`Do you want to unsubscribe?`}
</h1>
<p className="mt-4 text-center text-sm text-light-900 dark:text-dark-800">
{t`Confirm your email preferences:`}
</p>
</div>
<div className="flex justify-center">
<Button
onClick={handleUnsubscribe}
disabled={status === "success"}
isLoading={status === "processing"}
variant="primary"
size="md"
>
{t`Unsubscribe`}
</Button>
</div>
{status === "success" && (
<p className="text-center text-sm text-light-900 dark:text-dark-800">
{t`You have been unsubscribed!`}
</p>
)}
{status === "error" && (
<p className="mx-auto max-w-[300px] text-center text-sm font-medium text-red-600 dark:text-red-400">
{errorMessage}
</p>
)}
</div>
</div>
</>
);
}

View File

@@ -1,592 +0,0 @@
import type { ReactNode } from "react";
import {
Dialog,
DialogBackdrop,
DialogPanel,
DialogTitle,
} from "@headlessui/react";
import { t } from "@lingui/core/macro";
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { HiXMark } from "react-icons/hi2";
import { env } from "~/env";
import { useEventListener } from "~/hooks/useEventListener";
const ModifierKey = {
CONTROL: "CONTROL",
META: "META",
ALT: "ALT",
SHIFT: "SHIFT",
} as const;
type ModifierKey = (typeof ModifierKey)[keyof typeof ModifierKey];
const ModifierKeyInfo: Record<
ModifierKey,
{
macSymbol: string;
winName: string;
linuxName: string;
}
> = {
CONTROL: { macSymbol: "⌃", winName: "Ctrl", linuxName: "Ctrl" },
META: { macSymbol: "⌘", winName: "Win", linuxName: "Super" },
ALT: { macSymbol: "⌥", winName: "Alt", linuxName: "Alt" },
SHIFT: { macSymbol: "⇧", winName: "Shift", linuxName: "Shift" },
};
const ShortcutGroup = {
GENERAL: "GENERAL",
NAVIGATION: "NAVIGATION",
ACTIONS: "ACTIONS",
} as const;
type ShortcutGroup = (typeof ShortcutGroup)[keyof typeof ShortcutGroup];
const getShortcutGroupInfo = (): Record<ShortcutGroup, { label: string }> => ({
GENERAL: { label: t`General` },
NAVIGATION: { label: t`Navigation` },
ACTIONS: { label: t`Actions` },
});
interface KeyStroke {
key: string;
modifiers?: ModifierKey[];
}
interface Press {
type: "PRESS";
stroke: KeyStroke;
}
interface Sequence {
type: "SEQUENCE";
strokes: KeyStroke[];
}
export type KeyboardShortcut = {
action: () => void;
description: string;
group: ShortcutGroup;
} & (Press | Sequence);
interface ShortcutTreeStepNode {
type: "STEP";
children: ShortcutTreeLevel;
}
interface ShortcutTreeActionNode {
type: "ACTION";
shortcut: KeyboardShortcut;
}
type ShortcutTreeNode = ShortcutTreeStepNode | ShortcutTreeActionNode;
type ShortcutTreeLevel = Record<string, ShortcutTreeNode>;
const SEQUENCE_TIMEOUT_MS = 1000;
const ShortcutConflictCode = {
ACTION_ON_SEQUENCE: "ACTION_ON_SEQUENCE",
DUPLICATE_ACTION: "DUPLICATE_ACTION",
SEQUENCE_ON_ACTION: "SEQUENCE_ON_ACTION",
} as const;
type ShortcutConflictCode = keyof typeof ShortcutConflictCode;
interface ShortcutConflictErrorOptions {
code: ShortcutConflictCode;
shortcut: KeyboardShortcut;
conflictPath: string;
existingShortcut?: KeyboardShortcut;
}
class ShortcutConflictError extends Error {
public readonly code: ShortcutConflictCode;
public readonly shortcut: KeyboardShortcut;
public readonly conflictPath: string;
public readonly existingShortcut?: KeyboardShortcut;
constructor(options: ShortcutConflictErrorOptions) {
super(ShortcutConflictError.formatMessage(options));
this.name = "ShortcutConflictError";
this.code = options.code;
this.shortcut = options.shortcut;
this.conflictPath = options.conflictPath;
this.existingShortcut = options.existingShortcut;
}
private static stringifyShortcut(shortcut: KeyboardShortcut): string {
if (shortcut.type === "SEQUENCE") {
return shortcut.strokes.map(serializeKeyStroke).join(" → ");
}
return serializeKeyStroke(shortcut.stroke);
}
private static formatMessage(options: ShortcutConflictErrorOptions): string {
const formatted = ShortcutConflictError.stringifyShortcut(options.shortcut);
switch (options.code) {
case ShortcutConflictCode.ACTION_ON_SEQUENCE:
return `Cannot register "${formatted}": desired action conflicts with existing sequence at "${options.conflictPath}"`;
case ShortcutConflictCode.DUPLICATE_ACTION:
return `Cannot register "${formatted}": action already registered`;
case ShortcutConflictCode.SEQUENCE_ON_ACTION:
return `Cannot register "${formatted}": desired sequence conflicts with existing action at "${options.conflictPath}"`;
}
}
}
interface KeyboardShortcutContextType {
registerShortcut: (shortcut: KeyboardShortcut) => () => void;
openLegend: () => void;
openLegendKeys: ReactNode;
}
const KeyboardShortcutContext = createContext<
KeyboardShortcutContextType | undefined
>(undefined);
export function KeyboardShortcutProvider({
children,
}: {
children: ReactNode;
}) {
const treeRootRef = useRef<ShortcutTreeLevel>({});
const currentNodeRef = useRef<ShortcutTreeLevel>(treeRootRef.current);
const shortcutsRef = useRef<Map<string, KeyboardShortcut>>(new Map());
const sequenceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLegendOpen, setIsLegendOpen] = useState(false);
const openLegendShortcut: KeyboardShortcut = useMemo(
() => ({
type: "PRESS",
stroke: {
key: "/",
modifiers: ["META"],
},
action: () => setIsLegendOpen(true),
description: t`Open keyboard shortcuts`,
group: ShortcutGroup.GENERAL,
}),
[setIsLegendOpen],
);
const handleKeyDown = useCallback((event: KeyboardEvent) => {
if (isTypingInInput(event)) {
return;
}
if (sequenceTimeoutRef.current) {
clearTimeout(sequenceTimeoutRef.current);
sequenceTimeoutRef.current = null;
}
const serializedKey = serializeEvent(event);
const node = currentNodeRef.current[serializedKey];
if (!node) {
currentNodeRef.current = treeRootRef.current;
return;
}
switch (node.type) {
case "STEP":
event.preventDefault();
currentNodeRef.current = node.children;
sequenceTimeoutRef.current = setTimeout(() => {
currentNodeRef.current = treeRootRef.current;
}, SEQUENCE_TIMEOUT_MS);
return;
case "ACTION":
event.preventDefault();
node.shortcut.action();
currentNodeRef.current = treeRootRef.current;
return;
default:
return;
}
}, []);
const registerShortcut = useCallback(
(shortcut: KeyboardShortcut): (() => void) => {
const strokes =
shortcut.type === "SEQUENCE" ? shortcut.strokes : [shortcut.stroke];
const path = strokes.map(serializeKeyStroke);
const pathKey = path.join(" → ");
path.reduce((currentLevel, key, i) => {
const isLast = i === path.length - 1;
const existingNode = currentLevel[key];
if (env.NODE_ENV === "development" && existingNode) {
const conflictPath = path.slice(0, i + 1).join(" → ");
validateNoConflict(existingNode, isLast, shortcut, conflictPath);
}
if (isLast) {
currentLevel[key] = {
type: "ACTION",
shortcut,
};
return currentLevel;
}
if (existingNode?.type === "STEP") {
return existingNode.children;
}
const newNode: ShortcutTreeStepNode = {
type: "STEP",
children: {},
};
currentLevel[key] = newNode;
return newNode.children;
}, treeRootRef.current);
shortcutsRef.current.set(pathKey, shortcut);
return () => {
removePathAndPrune(treeRootRef.current, path);
shortcutsRef.current.delete(pathKey);
};
},
[],
);
useEventListener("keydown", handleKeyDown);
// Register built-in shortcut to open legend
useEffect(() => {
const cleanup = registerShortcut(openLegendShortcut);
return cleanup;
}, [registerShortcut, openLegendShortcut]);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (sequenceTimeoutRef.current) {
clearTimeout(sequenceTimeoutRef.current);
}
};
}, []);
const shortcutsArray = Array.from(shortcutsRef.current.values());
const groupedShortcuts = shortcutsArray.reduce<
Partial<Record<ShortcutGroup, KeyboardShortcut[]>>
>((acc, shortcut) => {
const group = shortcut.group;
acc[group] ??= [];
acc[group].push(shortcut);
return acc;
}, {});
const openLegend = useCallback(() => {
setIsLegendOpen(true);
}, []);
const openLegendKeys = useMemo(
() => <FormattedShortcut shortcut={openLegendShortcut} />,
[openLegendShortcut],
);
return (
<KeyboardShortcutContext.Provider
value={{ registerShortcut, openLegend, openLegendKeys }}
>
{children}
{/* Shortcut Legend */}
<Dialog
className="relative z-50"
open={isLegendOpen}
onClose={() => setIsLegendOpen(false)}
>
<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 flex min-h-full w-screen items-center justify-center overflow-y-auto p-4">
<DialogPanel
transition
className="relative w-full max-w-sm transform overflow-hidden rounded-lg border border-light-600 bg-white shadow-3xl-light dark:border-dark-600 dark:bg-dark-100 dark:shadow-3xl-dark"
>
<div className="flex items-center justify-between border-b border-light-300 px-6 py-4 dark:border-dark-300">
<DialogTitle className="text-[14px] font-semibold text-neutral-900 dark:text-dark-1000">
{t`Keyboard Shortcuts`}
</DialogTitle>
<button
onClick={() => setIsLegendOpen(false)}
className="rounded p-1 hover:bg-light-200 dark:hover:bg-dark-200"
>
<HiXMark className="h-5 w-5 text-neutral-700 dark:text-dark-700" />
</button>
</div>
<div className="max-h-[60vh] overflow-y-auto p-6">
{shortcutsArray.length === 0 ? (
<p className="text-center text-sm text-neutral-600 dark:text-dark-600">
{t`No keyboard shortcuts registered.`}
</p>
) : (
<div className="space-y-6">
{Object.values(ShortcutGroup).map((group, idx) => {
const shortcuts = groupedShortcuts[group];
if (!shortcuts?.length) return null;
const groupInfo = getShortcutGroupInfo();
return (
<div key={`${group}-${idx}`}>
<h3 className="mb-3 text-xs font-semibold uppercase tracking-wide text-light-1000 dark:text-dark-1000">
{groupInfo[group].label}
</h3>
<div className="flex flex-col gap-y-2">
{shortcuts.map((shortcut) => (
<ShortcutListItem
key={shortcut.description}
shortcut={shortcut}
/>
))}
</div>
</div>
);
})}
</div>
)}
</div>
</DialogPanel>
</div>
</Dialog>
</KeyboardShortcutContext.Provider>
);
}
/**
* Hook to register a keyboard shortcut. Cleanup is handled automatically.
* Returns both formatted keys and pre-built tooltip content.
*
* IMPORTANT: The shortcut object reference must be stable. If it changes
* on every render, the shortcut will be continuously registered and unregistered.
*
* @example
* ```tsx
* const { keys, tooltipContent } = useKeyboardShortcut({
* type: "PRESS",
* stroke: { key: "k", modifiers: ["META"] },
* action: () => openCommandPalette(),
* description: "Open command palette",
* group: "GENERAL"
* });
*
* // Use tooltip for Tooltip component
* <Tooltip content={tooltipContent}>
* <button>Search</button>
* </Tooltip>
*
* // Or use keys directly for custom formatting
* <span>Press {keys} to search</span>
* ```
*/
export function useKeyboardShortcuts(): KeyboardShortcutContextType {
const context = useContext(KeyboardShortcutContext);
if (!context) {
throw new Error(
"useKeyboardShortcuts must be used within KeyboardShortcutProvider",
);
}
return context;
}
export function useKeyboardShortcut(shortcut: KeyboardShortcut): {
keys: ReactNode;
tooltipContent: ReactNode;
} {
const context = useContext(KeyboardShortcutContext);
if (!context) {
throw new Error(
"useKeyboardShortcut must be used within KeyboardShortcutProvider",
);
}
const { registerShortcut } = context;
useEffect(() => {
const cleanup = registerShortcut(shortcut);
return cleanup;
}, [shortcut, registerShortcut]);
const keys = <FormattedShortcut shortcut={shortcut} />;
const tooltipContent = (
<div className="flex flex-row items-center gap-2 text-[11px]">
{shortcut.description} {keys}
</div>
);
return { keys, tooltipContent };
}
function ShortcutListItem({ shortcut }: { shortcut: KeyboardShortcut }) {
return (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-dark-50 dark:text-dark-900">
{shortcut.description}
</span>
<FormattedShortcut shortcut={shortcut} />
</div>
);
}
function FormattedShortcut({ shortcut }: { shortcut: KeyboardShortcut }) {
const kbdClassName =
"inline-flex h-5 w-5 items-center justify-center rounded border border-light-400 bg-light-200 px-1.5 py-0.5 font-mono text-[8px] font-semibold text-center text-neutral-900 dark:border-dark-400 dark:bg-dark-200 dark:text-dark-950";
const stringifyModifier = (modifier: ModifierKey): string => {
const isMac =
typeof navigator !== "undefined" && navigator.userAgent.includes("Mac");
const isLinux =
typeof navigator !== "undefined" && navigator.userAgent.includes("Linux");
const info = ModifierKeyInfo[modifier];
if (isMac) return info.macSymbol;
if (isLinux) return info.linuxName;
return info.winName;
};
const formatStroke = (stroke: KeyStroke): ReactNode[] => {
const parts: ReactNode[] = [];
const modifierStrings = stroke.modifiers
? stroke.modifiers.map(stringifyModifier)
: [];
modifierStrings.forEach((mod) => {
parts.push(<kbd className={kbdClassName}>{mod}</kbd>);
});
parts.push(<kbd className={kbdClassName}>{stroke.key.toUpperCase()}</kbd>);
return parts;
};
if (shortcut.type === "SEQUENCE") {
const parts: ReactNode[] = [];
shortcut.strokes.forEach((stroke) => {
parts.push(...formatStroke(stroke));
});
return <span className="flex items-center gap-1 text-[11px]">{parts}</span>;
}
return (
<span className="inline-flex flex-shrink-0 items-center gap-1 text-[11px]">
{formatStroke(shortcut.stroke)}
</span>
);
}
/** Checks for conflicts given existing nodes and current path
* Throws an error if conflict is found
*
*/
function validateNoConflict(
existingNode: ShortcutTreeNode,
isLastKey: boolean,
shortcut: KeyboardShortcut,
conflictPath: string,
): void {
if (isLastKey) {
if (existingNode.type === "STEP") {
throw new ShortcutConflictError({
code: ShortcutConflictCode.ACTION_ON_SEQUENCE,
shortcut,
conflictPath,
});
} else {
throw new ShortcutConflictError({
code: ShortcutConflictCode.DUPLICATE_ACTION,
shortcut,
conflictPath,
existingShortcut: existingNode.shortcut,
});
}
} else if (existingNode.type === "ACTION") {
throw new ShortcutConflictError({
code: ShortcutConflictCode.SEQUENCE_ON_ACTION,
shortcut,
conflictPath,
existingShortcut: existingNode.shortcut,
});
}
}
/**
* Checks if the user is currently typing in an input field
*/
function isTypingInInput(event: KeyboardEvent): boolean {
if (!event.target) return false;
const target = event.target as HTMLElement;
const tagName = target.tagName.toLowerCase();
const isInput = tagName === "input" || tagName === "textarea";
const isContentEditable = target.isContentEditable;
return isInput || isContentEditable;
}
/**
* Serializes a KeyStroke to a consistent string format
* Returns lowercase string like "ctrl+shift+k" with alphabetically sorted modifiers
*/
function serializeKeyStroke(stroke: KeyStroke): string {
const key = stroke.key.toLowerCase();
if (!stroke.modifiers || stroke.modifiers.length === 0) return key;
const sorted = [...stroke.modifiers].sort();
return `${sorted.join("+")}+${key}`;
}
/**
* Converts a keyboard event to a KeyStroke
*/
function eventToKeyStroke(event: KeyboardEvent): KeyStroke {
const modifiers: ModifierKey[] = [];
if (event.altKey) modifiers.push(ModifierKey.ALT);
if (event.ctrlKey) modifiers.push(ModifierKey.CONTROL);
if (event.metaKey) modifiers.push(ModifierKey.META);
// Only include shift if the key is a letter (shift wasn't consumed to produce the character)
if (event.shiftKey && /^[a-zA-Z]$/.test(event.key))
modifiers.push(ModifierKey.SHIFT);
return { key: event.key, modifiers };
}
/**
* Serializes a keyboard event to a consistent string format
*/
function serializeEvent(event: KeyboardEvent): string {
return serializeKeyStroke(eventToKeyStroke(event));
}
/**
* Removes a path from the tree and prunes empty branches
*/
function removePathAndPrune(tree: ShortcutTreeLevel, path: string[]): void {
const [first, ...rest] = path;
if (!first) return;
if (rest.length === 0) {
delete tree[first];
return;
}
const node = tree[first];
if (!node || node.type !== "STEP") return;
removePathAndPrune(node.children, rest);
if (Object.keys(node.children).length === 0) {
delete tree[first];
}
}

View File

@@ -59,18 +59,7 @@ export const ModalProvider: React.FC<Props> = ({ children }) => {
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];
});
setModalStack((prev) => [...prev, newModal]);
},
[],
);

View File

@@ -39,17 +39,3 @@
}
}
}
/* Tippy.js tooltip theme */
.tippy-box[data-theme~="tooltip"] {
@apply rounded-md border border-light-600 bg-white px-2 py-1 text-sm text-neutral-900 shadow-lg dark:border-dark-600 dark:bg-dark-100 dark:text-dark-1000;
}
.tippy-box[data-theme~="tooltip"] > .tippy-arrow::before {
@apply border-t-light-600 dark:border-t-dark-600;
}
.tippy-box[data-theme~="tooltip"][data-placement^="bottom"]
> .tippy-arrow::before {
@apply border-b-light-600 dark:border-b-dark-600;
}

View File

@@ -1,16 +0,0 @@
import type { api } from "~/utils/api";
/**
* Invalidates all card-related queries for a given card.
* Use this after any mutation that affects card data or activities.
*/
export async function invalidateCard(
utils: ReturnType<typeof api.useUtils>,
cardPublicId: string,
) {
await Promise.all([
utils.card.byId.invalidate({ cardPublicId }),
utils.card.getActivities.invalidate({ cardPublicId }),
]);
}

View File

@@ -22,8 +22,6 @@ const loadMessages = async (locale: Locale) => {
return (await import("~/locales/ru/messages")).messages;
case "pl":
return (await import("~/locales/pl/messages")).messages;
case "ptbr":
return (await import("~/locales/ptbr/messages")).messages;
default:
return enMessages;
}

View File

@@ -1,17 +1,9 @@
import { format, isBefore, isSameYear, startOfDay } from "date-fns";
import { HiOutlinePaperClip } from "react-icons/hi";
import {
HiBars3BottomLeft,
HiChatBubbleLeft,
HiOutlineClock,
} from "react-icons/hi2";
import { twMerge } from "tailwind-merge";
import { HiBars3BottomLeft, HiChatBubbleLeft } from "react-icons/hi2";
import Avatar from "~/components/Avatar";
import Badge from "~/components/Badge";
import CircularProgress from "~/components/CircularProgress";
import LabelIcon from "~/components/LabelIcon";
import { useLocalisation } from "~/hooks/useLocalisation";
import { getAvatarUrl } from "~/utils/helpers";
const Card = ({
@@ -21,8 +13,6 @@ const Card = ({
checklists,
description,
comments,
attachments,
dueDate,
}: {
title: string;
labels: { name: string; colourCode: string | null }[];
@@ -43,12 +33,7 @@ const Card = ({
}[];
description: string | null;
comments: { publicId: string }[];
attachments?: { publicId: string }[];
dueDate?: Date | null;
}) => {
const { dateLocale } = useLocalisation();
const showYear = dueDate ? !isSameYear(dueDate, new Date()) : false;
const isOverdue = dueDate ? isBefore(dueDate, startOfDay(new Date())) : false;
const completedItems = checklists.reduce((acc, checklist) => {
return acc + checklist.items.filter((item) => item.completed).length;
}, 0);
@@ -62,8 +47,6 @@ const Card = ({
const hasDescription =
description && description.replace(/<[^>]*>/g, "").trim().length > 0;
const hasAttachments = attachments && attachments.length > 0;
const hasDueDate = !!dueDate;
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">
@@ -72,9 +55,7 @@ const Card = ({
members.length ||
checklists.length > 0 ||
hasDescription ||
comments.length > 0 ||
hasDueDate ||
hasAttachments ? (
comments.length > 0 ? (
<div className="mt-2 flex flex-col justify-end">
<div className="space-x-0.5">
{labels.map((label) => (
@@ -91,33 +72,11 @@ const Card = ({
<HiBars3BottomLeft className="h-4 w-4" />
</div>
)}
{hasDueDate && dueDate && (
<div
className={twMerge(
"flex items-center gap-1",
isOverdue
? "text-red-600 dark:text-red-400"
: "text-light-800 dark:text-dark-800",
)}
>
<HiOutlineClock className="h-4 w-4" />
<span className="text-[11px]">
{format(dueDate, showYear ? "do MMM yyyy" : "do MMM", {
locale: dateLocale,
})}
</span>
</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 && (

View File

@@ -12,7 +12,6 @@ interface QueryParams {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
}
export function DeleteListConfirmation({

View File

@@ -2,8 +2,6 @@ import { useRouter } from "next/router";
import { t } from "@lingui/core/macro";
import {
HiMiniXMark,
HiOutlineClock,
HiOutlineSquare3Stack3D,
HiOutlineTag,
HiOutlineUserCircle,
} from "react-icons/hi2";
@@ -34,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();
@@ -61,13 +52,7 @@ const Filters = ({
try {
await router.push({
pathname: router.pathname,
query: {
...router.query,
members: [],
labels: [],
lists: [],
dueDate: [],
},
query: { ...router.query, members: [], labels: [] },
});
} catch (error) {
console.error(error);
@@ -100,45 +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 dueDateItems = [
{
key: "overdue",
value: t`Overdue`,
selected: !!router.query.dueDate?.includes("overdue"),
},
{
key: "today",
value: t`Due today`,
selected: !!router.query.dueDate?.includes("today"),
},
{
key: "tomorrow",
value: t`Due tomorrow`,
selected: !!router.query.dueDate?.includes("tomorrow"),
},
{
key: "next-week",
value: t`Due next week`,
selected: !!router.query.dueDate?.includes("next-week"),
},
{
key: "next-month",
value: t`Due next month`,
selected: !!router.query.dueDate?.includes("next-month"),
},
{
key: "no-due-date",
value: t`No dates`,
selected: !!router.query.dueDate?.includes("no-due-date"),
},
];
const groups = [
...(formattedMembers.length
? [
@@ -156,22 +102,6 @@ const Filters = ({
icon: <HiOutlineTag size={16} />,
items: formattedLabels,
},
...(formattedLists.length
? [
{
key: "lists",
label: t`Lists`,
icon: <HiOutlineSquare3Stack3D size={16} />,
items: formattedLists,
},
]
: []),
{
key: "dueDate",
label: t`Due date`,
icon: <HiOutlineClock size={16} />,
items: dueDateItems,
},
];
const handleSelect = async (
@@ -201,8 +131,6 @@ const Filters = ({
const numOfFilters = [
...formatToArray(router.query.members),
...formatToArray(router.query.labels),
...formatToArray(router.query.lists),
...formatToArray(router.query.dueDate),
].length;
return (

View File

@@ -1,7 +1,6 @@
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useForm } from "react-hook-form";
import {
HiOutlineBarsArrowDown,
@@ -16,7 +15,6 @@ import type { WorkspaceMember } from "~/components/Editor";
import Avatar from "~/components/Avatar";
import Button from "~/components/Button";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import DateSelector from "~/components/DateSelector";
import Editor from "~/components/Editor";
import Input from "~/components/Input";
import LabelIcon from "~/components/LabelIcon";
@@ -29,14 +27,12 @@ import { formatMemberDisplayName, getAvatarUrl } from "~/utils/helpers";
type NewCardFormInput = NewCardInput & {
isCreateAnotherEnabled: boolean;
dueDate?: Date | null;
};
interface QueryParams {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
}
interface NewCardFormProps {
@@ -68,7 +64,6 @@ export function NewCardForm({
memberPublicIds: [],
isCreateAnotherEnabled: false,
position: "start",
dueDate: null,
},
resetOnClose: true,
});
@@ -84,8 +79,6 @@ export function NewCardForm({
const position = watch("position");
const title = watch("title");
const description = watch("description");
const dueDate = watch("dueDate");
const [isDateSelectorOpen, setIsDateSelectorOpen] = useState(false);
// saving form state whenever form values change
useEffect(() => {
@@ -143,7 +136,6 @@ export function NewCardForm({
title: args.title,
listId: 2,
description: "",
dueDate: args.dueDate ?? null,
labels: oldBoard.labels.filter((label) =>
args.labelPublicIds.includes(label.publicId),
),
@@ -200,7 +192,6 @@ export function NewCardForm({
memberPublicIds: [],
isCreateAnotherEnabled,
position,
dueDate: null,
};
reset(newFormState);
saveFormState(newFormState);
@@ -250,7 +241,7 @@ export function NewCardForm({
),
})) ?? [];
const onSubmit = (data: NewCardFormInput) => {
const onSubmit = (data: NewCardInput) => {
createCard.mutate({
title: data.title,
description: data.description,
@@ -258,7 +249,6 @@ export function NewCardForm({
labelPublicIds: data.labelPublicIds,
memberPublicIds: data.memberPublicIds,
position: data.position,
dueDate: data.dueDate ?? null,
});
};
@@ -457,44 +447,6 @@ export function NewCardForm({
</div>
</CheckboxDropdown>
</div>
<div className="relative w-fit">
<button
type="button"
onClick={() => setIsDateSelectorOpen(!isDateSelectorOpen)}
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"
>
{dueDate ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
) : (
<>{t`Due date`}</>
)}
</button>
{isDateSelectorOpen && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setIsDateSelectorOpen(false)}
/>
<div
className="absolute left-0 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
onClick={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<DateSelector
selectedDate={dueDate ?? undefined}
onDateSelect={(date) => {
setValue("dueDate", date ?? null);
setIsDateSelectorOpen(false);
}}
/>
</div>
</>
)}
</div>
<button
onClick={(e) => {
e.preventDefault();

View File

@@ -21,7 +21,6 @@ interface QueryParams {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
}
export function NewListForm({

View File

@@ -17,7 +17,6 @@ interface QueryParams {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
}
export function UpdateBoardSlugForm({

View File

@@ -11,7 +11,6 @@ interface QueryParams {
boardPublicId: string;
members: string[];
labels: string[];
lists: string[];
}
const VisibilityButton = ({

View File

@@ -23,8 +23,6 @@ import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import PatternedBackground from "~/components/PatternedBackground";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { Tooltip } from "~/components/Tooltip";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
@@ -56,15 +54,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
useState<PublicListId>("");
const [isInitialLoading, setIsInitialLoading] = useState(true);
const { tooltipContent: createListShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => boardId && openNewListForm(boardId),
description: t`Create new list`,
group: "ACTIONS",
});
const boardId = params?.boardId
? Array.isArray(params.boardId)
? params.boardId[0]
@@ -87,26 +76,16 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
});
};
const semanticFilters = formatToArray(router.query.dueDate) as (
| "overdue"
| "today"
| "tomorrow"
| "next-week"
| "next-month"
| "no-due-date"
)[];
const boardType: "regular" | "template" = isTemplate ? "template" : "regular";
const queryParams = {
const queryParams: {
boardPublicId: string;
members: string[];
labels: string[];
type: "regular" | "template";
} = {
boardPublicId: boardId ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
lists: formatToArray(router.query.lists),
...(semanticFilters.length > 0 && {
dueDateFilters: semanticFilters,
}),
type: boardType,
type: isTemplate ? "template" : "regular",
};
const {
@@ -439,29 +418,26 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
members={boardData.workspace.members.filter(
(member) => member.user !== null,
)}
lists={boardData.allLists}
position="left"
isLoading={!boardData}
/>
)}
</>
)}
<Tooltip content={createListShortcutTooltipContent}>
<Button
iconLeft={
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
disabled={!boardData}
>
{t`New list`}
</Button>
</Tooltip>
<Button
iconLeft={
<HiOutlinePlusSmall
className="-mr-0.5 h-5 w-5"
aria-hidden="true"
/>
}
onClick={() => {
if (boardId) openNewListForm(boardId);
}}
disabled={!boardData}
>
{t`New list`}
</Button>
<BoardDropdown
isTemplate={!!isTemplate}
isLoading={!boardData}
@@ -574,8 +550,6 @@ export default function BoardPage({ isTemplate }: { isTemplate?: boolean }) {
card.description ?? null
}
comments={card.comments ?? []}
attachments={card.attachments}
dueDate={card.dueDate ?? null}
/>
</Link>
)}

View File

@@ -6,8 +6,6 @@ import FeedbackModal from "~/components/FeedbackModal";
import Modal from "~/components/modal";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm";
import { PageHead } from "~/components/PageHead";
import { Tooltip } from "~/components/Tooltip";
import { useKeyboardShortcut } from "~/providers/keyboard-shortcuts";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { BoardsList } from "./components/BoardsList";
@@ -18,15 +16,6 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
const { openModal, modalContentType, isOpen } = useModal();
const { workspace } = useWorkspace();
const { tooltipContent: createModalShortcutTooltipContent } =
useKeyboardShortcut({
type: "PRESS",
stroke: { key: "C" },
action: () => openModal("NEW_BOARD"),
description: t`Create new ${isTemplate ? "template" : "board"}`,
group: "ACTIONS",
});
return (
<>
<PageHead
@@ -50,18 +39,16 @@ export default function BoardsPage({ isTemplate }: { isTemplate?: boolean }) {
{t`Import`}
</Button>
)}
<Tooltip content={createModalShortcutTooltipContent}>
<Button
type="button"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}
>
{t`New`}
</Button>
</Tooltip>
<Button
type="button"
variant="primary"
onClick={() => openModal("NEW_BOARD")}
iconLeft={
<HiOutlinePlusSmall aria-hidden="true" className="h-4 w-4" />
}
>
{t`New`}
</Button>
</div>
</div>

View File

@@ -1,13 +1,11 @@
import type { Locale as DateFnsLocale } from "date-fns";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { format, formatDistanceToNow, isSameYear } from "date-fns";
import { useEffect, useRef, useState } from "react";
import { formatDistanceToNow } from "date-fns";
import { de, enGB, es, fr, it, nl } from "date-fns/locale";
import {
HiOutlineArrowLeft,
HiOutlineArrowRight,
HiOutlineCheckCircle,
HiOutlineClock,
HiOutlinePencil,
HiOutlinePlus,
HiOutlineTag,
@@ -16,20 +14,25 @@ import {
HiOutlineUserPlus,
} from "react-icons/hi2";
import type {
GetCardActivitiesOutput,
GetCardByIdOutput,
} from "@kan/api/types";
import type { GetCardByIdOutput } from "@kan/api/types";
import { authClient } from "@kan/auth/client";
import Avatar from "~/components/Avatar";
import { useLocalisation } from "~/hooks/useLocalisation";
import { api } from "~/utils/api";
import Comment from "./Comment";
type ActivityType =
NonNullable<GetCardByIdOutput>["activities"][number]["type"];
const dateLocaleMap = {
en: enGB,
fr: fr,
de: de,
es: es,
it: it,
nl: nl,
} as const;
const truncate = (value: string | null, maxLength = 50) => {
if (!value) return value;
return value.length > maxLength ? `${value.slice(0, maxLength - 1)}` : value;
@@ -44,9 +47,6 @@ const getActivityText = ({
isSelf,
label,
fromTitle,
toDueDate,
dateLocale,
mergedLabels,
}: {
type: ActivityType;
toTitle: string | null;
@@ -56,45 +56,7 @@ const getActivityText = ({
isSelf: boolean;
label: string | null;
fromTitle?: string | null;
fromDueDate?: Date | null;
toDueDate?: Date | null;
dateLocale: DateFnsLocale;
mergedLabels?: string[];
}) => {
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (
type === "card.updated.label.added" &&
mergedLabels &&
mergedLabels.length > 1
) {
const labelList = mergedLabels.join(", ");
return (
<Trans>
added {mergedLabels.length} labels:{" "}
<TextHighlight>{labelList}</TextHighlight>
</Trans>
);
}
if (
type === "card.updated.label.removed" &&
mergedLabels &&
mergedLabels.length > 1
) {
const labelList = mergedLabels.join(", ");
return (
<Trans>
removed {mergedLabels.length} labels:{" "}
<TextHighlight>{labelList}</TextHighlight>
</Trans>
);
}
const ACTIVITY_TYPE_MAP = {
"card.created": t`created the card`,
"card.updated.title": t`updated the title`,
@@ -112,14 +74,17 @@ const getActivityText = ({
"card.updated.checklist.item.completed": t`completed a checklist item`,
"card.updated.checklist.item.uncompleted": t`marked a checklist item as incomplete`,
"card.updated.checklist.item.deleted": t`deleted a checklist item`,
"card.updated.dueDate.added": t`set the due date`,
"card.updated.dueDate.updated": t`updated the due date`,
"card.updated.dueDate.removed": t`removed the due date`,
} as const;
if (!(type in ACTIVITY_TYPE_MAP)) return null;
const baseText = ACTIVITY_TYPE_MAP[type as keyof typeof ACTIVITY_TYPE_MAP];
const TextHighlight = ({ children }: { children: React.ReactNode }) => (
<span className="font-medium text-light-1000 dark:text-dark-1000">
{children}
</span>
);
if (type === "card.updated.title" && toTitle) {
return (
<Trans>
@@ -244,38 +209,6 @@ const getActivityText = ({
);
}
if (type === "card.updated.dueDate.added" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format(
toDueDate,
showYear ? "do MMM yyyy" : "do MMM",
{ locale: dateLocale },
);
return (
<Trans>
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.dueDate.updated" && toDueDate) {
const showYear = !isSameYear(toDueDate, new Date());
const formattedDate = format(
toDueDate,
showYear ? "do MMM yyyy" : "do MMM",
{ locale: dateLocale },
);
return (
<Trans>
changed the due date to <TextHighlight>{formattedDate}</TextHighlight>
</Trans>
);
}
if (type === "card.updated.dueDate.removed") {
return <Trans>removed the due date</Trans>;
}
return baseText;
};
@@ -296,9 +229,6 @@ const ACTIVITY_ICON_MAP: Partial<Record<ActivityType, React.ReactNode | null>> =
"card.updated.checklist.item.completed": <HiOutlineCheckCircle />,
"card.updated.checklist.item.uncompleted": <HiOutlineCheckCircle />,
"card.updated.checklist.item.deleted": <HiOutlineTrash />,
"card.updated.dueDate.added": <HiOutlineClock />,
"card.updated.dueDate.updated": <HiOutlineClock />,
"card.updated.dueDate.removed": <HiOutlineClock />,
} as const;
const getActivityIcon = (
@@ -316,151 +246,36 @@ const getActivityIcon = (
return ACTIVITY_ICON_MAP[type] ?? null;
};
const ACTIVITIES_PAGE_SIZE = 20;
const ActivityList = ({
activities,
cardPublicId,
isLoading: cardIsLoading,
isLoading,
isAdmin,
isViewOnly,
}: {
activities: NonNullable<GetCardByIdOutput>["activities"];
cardPublicId: string;
isLoading: boolean;
isAdmin?: boolean;
isViewOnly?: boolean;
}) => {
const { dateLocale, locale } = useLocalisation();
const { data: sessionData } = authClient.useSession();
const utils = api.useUtils();
const [allActivities, setAllActivities] = useState<
GetCardActivitiesOutput["activities"]
>([]);
const [hasMore, setHasMore] = useState(true);
const [isLoadingMore, setIsLoadingMore] = useState(false);
const { data } = authClient.useSession();
const { locale } = useLocalisation();
const isFullyExpandedRef = useRef(false);
const lastDataUpdatedAtRef = useRef<number | null>(null);
const {
data: firstPageData,
isFetching: isFetchingFirst,
dataUpdatedAt,
} = api.card.getActivities.useQuery(
{
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
},
{
enabled: !!cardPublicId,
},
);
useEffect(() => {
if (firstPageData && dataUpdatedAt !== lastDataUpdatedAtRef.current) {
lastDataUpdatedAtRef.current = dataUpdatedAt;
if (isFullyExpandedRef.current && firstPageData.hasMore) {
setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore);
const fetchAllRemaining = async () => {
let currentActivities = [...firstPageData.activities];
let currentHasMore = firstPageData.hasMore;
while (currentHasMore) {
const lastActivity =
currentActivities[currentActivities.length - 1];
if (!lastActivity) break;
const nextCursor = new Date(lastActivity.createdAt).toISOString();
const nextPage = await utils.card.getActivities.fetch({
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(
currentActivities.map((a) => a.publicId),
);
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
currentActivities = [...currentActivities, ...newActivities];
currentHasMore = nextPage.hasMore;
} else {
break;
}
}
setAllActivities(currentActivities);
setHasMore(false);
};
fetchAllRemaining();
} else {
setAllActivities(firstPageData.activities);
setHasMore(firstPageData.hasMore);
if (!firstPageData.hasMore) {
isFullyExpandedRef.current = true;
}
}
}
}, [firstPageData, dataUpdatedAt, cardPublicId, utils.card.getActivities]);
const handleLoadMore = async () => {
if (isLoadingMore || !hasMore || allActivities.length === 0) return;
const lastActivity = allActivities[allActivities.length - 1];
if (!lastActivity) return;
setIsLoadingMore(true);
try {
const nextCursor = new Date(lastActivity.createdAt).toISOString();
const nextPage = await utils.card.getActivities.fetch({
cardPublicId,
limit: ACTIVITIES_PAGE_SIZE,
cursor: nextCursor,
});
if (nextPage) {
const existingIds = new Set(allActivities.map((a) => a.publicId));
const newActivities = nextPage.activities.filter(
(a: { publicId: string }) => !existingIds.has(a.publicId),
);
setAllActivities((prev) => [...prev, ...newActivities]);
setHasMore(nextPage.hasMore);
if (!nextPage.hasMore) {
isFullyExpandedRef.current = true;
}
}
} finally {
setIsLoadingMore(false);
}
};
const isFetching = isFetchingFirst || isLoadingMore;
const isLoading =
cardIsLoading || (isFetchingFirst && allActivities.length === 0);
const currentDateLocale = dateLocaleMap[locale] || enGB;
return (
<div className="flex flex-col space-y-4 pt-4">
{allActivities.map((activity, index) => {
{activities.map((activity, index) => {
const activityText = getActivityText({
type: activity.type,
toTitle: activity.toTitle,
fromList: activity.fromList?.name ?? null,
toList: activity.toList?.name ?? null,
memberName: activity.member?.user?.name ?? null,
isSelf: activity.member?.user?.id === sessionData?.user.id,
isSelf: activity.member?.user?.id === data?.user.id,
label: activity.label?.name ?? null,
fromTitle: activity.fromTitle ?? null,
fromDueDate: activity.fromDueDate ?? null,
toDueDate: activity.toDueDate ?? null,
dateLocale: dateLocale,
mergedLabels: (activity as any).mergedLabels,
});
if (activity.type === "card.updated.comment.added")
@@ -475,7 +290,7 @@ const ActivityList = ({
createdAt={activity.createdAt.toISOString()}
comment={activity.comment?.comment}
isEdited={!!activity.comment?.updatedAt}
isAuthor={activity.comment?.createdBy === sessionData?.user.id}
isAuthor={activity.comment?.createdBy === data?.user.id}
isAdmin={isAdmin ?? false}
isViewOnly={!!isViewOnly}
/>
@@ -500,9 +315,11 @@ const ActivityList = ({
)}
isLoading={isLoading}
/>
{index !== allActivities.length - 1 && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
{index !== activities.length - 1 &&
activities[index + 1]?.type !==
"card.updated.comment.added" && (
<div className="absolute bottom-[-14px] left-1/2 top-[30px] w-0.5 -translate-x-1/2 bg-light-600 dark:bg-dark-600" />
)}
</div>
<p className="text-sm">
<span className="font-medium dark:text-dark-1000">{`${activity.user?.name} `}</span>
@@ -513,24 +330,13 @@ const ActivityList = ({
<span className="space-x-1 text-light-900 dark:text-dark-800">
{formatDistanceToNow(new Date(activity.createdAt), {
addSuffix: true,
locale: dateLocale,
locale: currentDateLocale,
})}
</span>
</p>
</div>
);
})}
{hasMore && (
<div className="flex justify-center pt-4">
<button
onClick={handleLoadMore}
disabled={isFetching}
className="text-sm font-medium text-light-900 hover:text-light-1000 disabled:opacity-50 dark:text-dark-800 dark:hover:text-dark-1000"
>
{isFetching ? t`Loading...` : t`Load more activities`}
</button>
</div>
)}
</div>
);
};

View File

@@ -1,463 +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";
import { invalidateCard } from "~/utils/cardInvalidation";
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 invalidateCard(utils, 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>
);
}

View File

@@ -1,175 +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";
import { invalidateCard } from "~/utils/cardInvalidation";
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 invalidateCard(utils, 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>
);
}

View File

@@ -1,14 +1,11 @@
import type { DraggableProvided } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import ContentEditable from "react-contenteditable";
import { HiXMark } from "react-icons/hi2";
import { RiDraggable } from "react-icons/ri";
import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface ChecklistItemRowProps {
item: {
@@ -17,19 +14,13 @@ interface ChecklistItemRowProps {
completed: boolean;
};
cardPublicId: string;
onCreateNewItem?: () => void;
viewOnly?: boolean;
dragHandleProps?: DraggableProvided["dragHandleProps"];
isDragging?: boolean;
}
export default function ChecklistItemRow({
item,
cardPublicId,
onCreateNewItem,
viewOnly = false,
dragHandleProps,
isDragging = false,
}: ChecklistItemRowProps) {
const utils = api.useUtils();
const { showPopup } = usePopup();
@@ -71,7 +62,7 @@ export default function ChecklistItemRow({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});
@@ -99,7 +90,7 @@ export default function ChecklistItemRow({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});
@@ -147,23 +138,7 @@ export default function ChecklistItemRow({
};
return (
<div
className={twMerge(
"group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100",
isDragging && "opacity-80",
)}
>
{!viewOnly && (
<div
{...dragHandleProps}
className="absolute left-0 top-1/2 flex h-[20px] w-[20px] -translate-x-full -translate-y-1/2 cursor-grab items-center justify-center pr-1 opacity-0 transition-opacity group-hover:opacity-75 hover:opacity-100 active:cursor-grabbing"
>
<RiDraggable className="h-4 w-4 text-light-700 dark:text-dark-700" />
</div>
)}
{viewOnly && <div className="w-[20px] flex-shrink-0" />}
<div className="group relative flex items-start gap-3 rounded-md py-2 pl-4 hover:bg-light-100 dark:hover:bg-dark-100">
<label
className={`relative mt-[2px] inline-flex h-[16px] w-[16px] flex-shrink-0 items-center justify-center`}
>
@@ -189,10 +164,7 @@ export default function ChecklistItemRow({
disabled={viewOnly}
onChange={(e) => setTitle(e.target.value)}
// @ts-expect-error - valid event
onBlur={(e: Event) => {
const innerHTML = (e.target as HTMLElement).innerHTML;
commitTitle(innerHTML);
}}
onBlur={(e: Event) => commitTitle(e.target.innerHTML as string)}
className={twMerge(
"m-0 min-h-[20px] w-full p-0 text-sm leading-[20px] text-light-950 outline-none focus-visible:outline-none dark:text-dark-950",
viewOnly && "cursor-default",
@@ -202,9 +174,7 @@ export default function ChecklistItemRow({
if (viewOnly) return;
if (e.key === "Enter") {
e.preventDefault();
const innerHTML = (e.currentTarget as HTMLElement).innerHTML;
commitTitle(innerHTML);
onCreateNewItem?.();
commitTitle(title);
}
if (e.key === "Escape") {
e.preventDefault();

View File

@@ -4,7 +4,6 @@ import { twMerge } from "tailwind-merge";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export default function ChecklistNameInput({
checklistPublicId,
@@ -49,7 +48,7 @@ export default function ChecklistNameInput({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});

View File

@@ -1,13 +1,7 @@
import type { DropResult } from "react-beautiful-dnd";
import { t } from "@lingui/core/macro";
import { DragDropContext, Draggable } from "react-beautiful-dnd";
import { HiPlus, HiXMark } from "react-icons/hi2";
import CircularProgress from "~/components/CircularProgress";
import { StrictModeDroppable as Droppable } from "~/components/StrictModeDroppable";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import ChecklistItemRow from "./ChecklistItemRow";
import ChecklistNameInput from "./ChecklistNameInput";
import NewChecklistItemForm from "./NewChecklistItemForm";
@@ -40,206 +34,109 @@ export default function Checklists({
viewOnly = false,
}: ChecklistsProps) {
const { openModal } = useModal();
const { showPopup } = usePopup();
const utils = api.useUtils();
const reorderItemMutation = api.checklist.updateItem.useMutation({
onMutate: async (vars) => {
await utils.card.byId.cancel({ cardPublicId });
const previous = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (old) => {
if (!old) return old;
const updatedChecklists = old.checklists.map((cl) => {
const itemIndex = cl.items.findIndex(
(item) => item.publicId === vars.checklistItemPublicId,
);
if (itemIndex === -1 || vars.index === undefined) return cl;
const newIndex = vars.index;
const items = Array.from(cl.items);
const [movedItem] = items.splice(itemIndex, 1);
if (!movedItem) return cl;
items.splice(newIndex, 0, movedItem);
return { ...cl, items };
});
return { ...old, checklists: updatedChecklists } as typeof old;
});
return { previous };
},
onError: (_err, _vars, ctx) => {
if (ctx?.previous)
utils.card.byId.setData({ cardPublicId }, ctx.previous);
showPopup({
header: t`Unable to reorder checklist item`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await utils.card.byId.invalidate({ cardPublicId });
},
});
const onDragEnd = (result: DropResult) => {
if (!result.destination) return;
const { source, destination, draggableId } = result;
if (source.droppableId !== destination.droppableId) return;
if (source.index === destination.index) return;
reorderItemMutation.mutate({
checklistItemPublicId: draggableId,
index: destination.index,
});
};
if (checklists.length === 0) return null;
if (!checklists || checklists.length === 0) return null;
return (
<DragDropContext onDragEnd={onDragEnd}>
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;
<div className="border-light-300 pb-4 dark:border-dark-300">
<div>
{checklists.map((checklist) => {
const completedItems = checklist.items.filter(
(item) => item.completed,
);
const progress =
checklist.items.length > 0 && completedItems.length > 0
? (completedItems.length / checklist.items.length) * 100
: 2;
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
{!viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
<div>
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</div>
</div>
)}
{viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
</div>
)}
return (
<div key={checklist.publicId} className="mb-4">
<div className="mb-2 flex items-center font-medium text-light-1000 dark:text-dark-1000">
<div className="min-w-0 flex-1">
<ChecklistNameInput
checklistPublicId={checklist.publicId}
initialName={checklist.name}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
</div>
<Droppable
droppableId={checklist.publicId}
type="CHECKLIST_ITEM"
isDropDisabled={viewOnly}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.droppableProps}
className="ml-1"
>
{checklist.items.map((item, index) => (
<Draggable
key={item.publicId}
draggableId={item.publicId}
index={index}
isDragDisabled={viewOnly}
>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
style={{
...provided.draggableProps.style,
opacity: snapshot.isDragging ? 0.8 : 1,
}}
>
<ChecklistItemRow
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardPublicId}
onCreateNewItem={() =>
setActiveChecklistForm?.(checklist.publicId)
}
viewOnly={viewOnly}
dragHandleProps={provided.dragHandleProps}
isDragging={snapshot.isDragging}
/>
</div>
)}
</Draggable>
))}
{provided.placeholder}
{!viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
<div>
<button
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
onClick={() =>
openModal("DELETE_CHECKLIST", checklist.publicId)
}
>
<HiXMark size={16} />
</button>
<button
onClick={() =>
setActiveChecklistForm?.(checklist.publicId)
}
className="rounded-md p-1 text-light-900 hover:bg-light-100 dark:text-dark-700 dark:hover:bg-dark-100"
>
<HiPlus size={16} />
</button>
</div>
</div>
)}
{viewOnly && (
<div className="ml-2 flex flex-shrink-0 items-center gap-2">
<div className="flex items-center gap-1 rounded-full border-[1px] border-light-300 px-2 py-1 dark:border-dark-300">
<CircularProgress
progress={progress}
size="sm"
className="flex-shrink-0"
/>
<span className="text-[11px] text-light-900 dark:text-dark-700">
{completedItems.length}/{checklist.items.length}
</span>
</div>
)}
</Droppable>
{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>
);
})}
</div>
<div className="ml-1">
{checklist.items.map((item) => (
<ChecklistItemRow
key={item.publicId}
item={{
publicId: item.publicId,
title: item.title,
completed: item.completed,
}}
cardPublicId={cardPublicId}
viewOnly={viewOnly}
/>
))}
</div>
{activeChecklistForm === checklist.publicId && !viewOnly && (
<div className="ml-1">
<NewChecklistItemForm
checklistPublicId={checklist.publicId}
cardPublicId={cardPublicId}
onCancel={() => setActiveChecklistForm?.(null)}
readOnly={viewOnly}
/>
</div>
)}
</div>
);
})}
</div>
</DragDropContext>
</div>
);
}

View File

@@ -11,7 +11,6 @@ import Dropdown from "~/components/Dropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
comment: string;
@@ -56,7 +55,7 @@ const Comment = ({
const updateCommentMutation = api.card.updateComment.useMutation({
onSuccess: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.refetch();
setIsEditing(false);
},
onError: () => {

View File

@@ -4,7 +4,6 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
export function DeleteChecklistConfirmation({
cardPublicId,
@@ -41,7 +40,7 @@ export function DeleteChecklistConfirmation({
},
onSettled: async () => {
closeModal();
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});

View File

@@ -4,7 +4,6 @@ import Button from "~/components/Button";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface DeleteCommentConfirmationProps {
cardPublicId: string;
@@ -48,7 +47,7 @@ export function DeleteCommentConfirmation({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate(queryParams);
},
});

View File

@@ -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>
);
}

View File

@@ -1,142 +0,0 @@
import { t } from "@lingui/core/macro";
import { format } from "date-fns";
import { useEffect, useState } from "react";
import { HiMiniPlus } from "react-icons/hi2";
import DateSelector from "~/components/DateSelector";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface DueDateSelectorProps {
cardPublicId: string;
dueDate: Date | null | undefined;
isLoading?: boolean;
}
export function DueDateSelector({
cardPublicId,
dueDate,
isLoading = false,
}: DueDateSelectorProps) {
const { showPopup } = usePopup();
const utils = api.useUtils();
const [isOpen, setIsOpen] = useState(false);
const [pendingDate, setPendingDate] = useState<Date | null | undefined>(
dueDate,
);
// Sync pendingDate with dueDate when it changes externally
useEffect(() => {
if (!isOpen) {
setPendingDate(dueDate);
}
}, [dueDate, isOpen]);
const updateDueDate = api.card.update.useMutation({
onMutate: async (update) => {
await utils.card.byId.cancel();
const previousCard = utils.card.byId.getData({ cardPublicId });
utils.card.byId.setData({ cardPublicId }, (oldCard) => {
if (!oldCard) return oldCard;
return {
...oldCard,
dueDate:
update.dueDate !== undefined
? (update.dueDate as Date | null)
: oldCard.dueDate,
};
});
return { previousCard };
},
onError: (_error, _update, context) => {
utils.card.byId.setData({ cardPublicId }, context?.previousCard);
showPopup({
header: t`Unable to update due date`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.board.byId.invalidate();
},
});
const handleDateSelect = (date: Date | undefined) => {
// Only update local state, don't fire mutation
setPendingDate(date ?? null);
};
const handleBackdropClick = () => {
// Only fire mutation if date actually changed
const pendingIsNull = pendingDate === null || pendingDate === undefined;
const dueIsNull = dueDate === null || dueDate === undefined;
let dateChanged = false;
if (pendingIsNull && !dueIsNull) {
dateChanged = true;
} else if (!pendingIsNull && dueIsNull) {
dateChanged = true;
} else if (!pendingIsNull && !dueIsNull) {
// Both are non-null at this point
if (pendingDate instanceof Date && dueDate instanceof Date) {
dateChanged = pendingDate.getTime() !== dueDate.getTime();
}
}
// Close popover immediately
setIsOpen(false);
// Fire mutation if date changed (optimistic update will handle UI)
if (dateChanged) {
updateDueDate.mutate({
cardPublicId,
dueDate: pendingDate ?? null,
});
}
};
return (
<div className="relative flex w-full items-center text-left">
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
disabled={isLoading}
className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100"
>
{dueDate ? (
<span>{format(dueDate, "MMM d, yyyy")}</span>
) : (
<>
<HiMiniPlus size={22} className="pr-2" />
{t`Set due date`}
</>
)}
</button>
{isOpen && (
<>
<div className="fixed inset-0 z-10" onClick={handleBackdropClick} />
<div
className="absolute -left-8 top-full z-20 mt-2 rounded-md border border-light-200 bg-light-50 shadow-lg dark:border-dark-200 dark:bg-dark-100"
onClick={(e) => {
e.stopPropagation();
}}
onMouseDown={(e) => {
e.stopPropagation();
}}
>
<DateSelector
selectedDate={pendingDate ?? undefined}
onDateSelect={handleDateSelect}
/>
</div>
</>
)}
</div>
);
}

View File

@@ -6,7 +6,6 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface LabelSelectorProps {
cardPublicId: string;
@@ -75,7 +74,7 @@ export default function LabelSelector({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});

View File

@@ -3,7 +3,6 @@ import { t } from "@lingui/core/macro";
import CheckboxDropdown from "~/components/CheckboxDropdown";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface ListSelectorProps {
cardPublicId: string;
@@ -55,7 +54,7 @@ export default function ListSelector({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});
@@ -79,7 +78,7 @@ export default function ListSelector({
}}
asChild
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedList?.value}
</div>
</CheckboxDropdown>

View File

@@ -7,7 +7,6 @@ import CheckboxDropdown from "~/components/CheckboxDropdown";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface MemberSelectorProps {
cardPublicId: string;
@@ -82,7 +81,7 @@ export default function MemberSelector({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});
@@ -112,7 +111,7 @@ export default function MemberSelector({
createNewItemLabel={t`Invite member`}
asChild
>
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-xs text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
<div className="flex h-full w-full items-center rounded-[5px] border-[1px] border-light-50 py-1 pl-2 text-left text-sm text-neutral-900 hover:border-light-300 hover:bg-light-200 dark:border-dark-50 dark:text-dark-1000 dark:hover:border-dark-200 dark:hover:bg-dark-100">
{selectedMembers.length ? (
<div className="isolate flex justify-end -space-x-1 overflow-hidden">
{selectedMembers.map(({ value, imageUrl }) => (

View File

@@ -10,7 +10,6 @@ import Input from "~/components/Input";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface NewChecklistFormInput {
name: string;
@@ -73,7 +72,7 @@ export function NewChecklistForm({ cardPublicId }: { cardPublicId: string }) {
});
},
onSettled: async (_data, _error, vars) => {
await invalidateCard(utils, vars.cardPublicId);
await utils.card.byId.invalidate({ cardPublicId: vars.cardPublicId });
},
});

View File

@@ -7,7 +7,6 @@ import { generateUID } from "@kan/shared/utils";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
title: string;
@@ -92,7 +91,7 @@ const NewChecklistItemForm = ({
});
},
onSettled: async () => {
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate({ cardPublicId });
},
});

View File

@@ -6,7 +6,6 @@ import { HiOutlineArrowUp } from "react-icons/hi2";
import LoadingSpinner from "~/components/LoadingSpinner";
import { usePopup } from "~/providers/popup";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
interface FormValues {
comment: string;
@@ -35,7 +34,10 @@ const NewCommentForm = ({ cardPublicId }: { cardPublicId: string }) => {
},
onSettled: async () => {
reset();
await invalidateCard(utils, cardPublicId);
await utils.card.byId.invalidate(queryParams);
},
onSuccess: async () => {
await utils.card.byId.refetch();
},
});

View File

@@ -17,18 +17,14 @@ import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import { api } from "~/utils/api";
import { invalidateCard } from "~/utils/cardInvalidation";
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";
import { DeleteCommentConfirmation } from "./components/DeleteCommentConfirmation";
import Dropdown from "./components/Dropdown";
import { DueDateSelector } from "./components/DueDateSelector";
import LabelSelector from "./components/LabelSelector";
import ListSelector from "./components/ListSelector";
import MemberSelector from "./components/MemberSelector";
@@ -109,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 ?? ""}
@@ -126,7 +122,7 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
/>
</div>
{!isTemplate && (
<div className="mb-4 flex w-full flex-row">
<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 ?? ""}
@@ -135,14 +131,6 @@ export function CardRightPanel({ isTemplate }: { isTemplate?: boolean }) {
/>
</div>
)}
<div className="mb-4 flex w-full flex-row">
<p className="my-2 mb-2 w-[100px] text-sm font-medium">{t`Due date`}</p>
<DueDateSelector
cardPublicId={cardId ?? ""}
dueDate={card?.dueDate}
isLoading={!card}
/>
</div>
</div>
);
}
@@ -157,7 +145,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
getModalState,
clearModalState,
isOpen,
modalStates,
} = useModal();
const { showPopup } = usePopup();
const { workspace } = useWorkspace();
@@ -179,6 +166,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
const board = card?.list.board;
const boardId = board?.publicId;
const activities = card?.activities;
const updateCard = api.card.update.useMutation({
onError: () => {
@@ -189,22 +177,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
});
},
onSettled: async () => {
if (cardId) await invalidateCard(utils, cardId);
},
});
const addOrRemoveLabel = api.card.addOrRemoveLabel.useMutation({
onError: () => {
showPopup({
header: t`Unable to add label`,
message: t`Please try again later, or contact customer support.`,
icon: "error",
});
},
onSettled: async () => {
if (cardId) {
await utils.card.byId.invalidate({ cardPublicId: cardId });
}
await utils.card.byId.invalidate({ cardPublicId: cardId });
},
});
@@ -224,24 +197,6 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
});
};
// this adds the new created label to selected labels
useEffect(() => {
const newLabelId = modalStates.NEW_LABEL_CREATED;
if (newLabelId && cardId) {
const isAlreadyAdded = card?.labels.some(
(label) => label.publicId === newLabelId,
);
if (!isAlreadyAdded) {
addOrRemoveLabel.mutate({
cardPublicId: cardId,
labelPublicId: newLabelId,
});
}
clearModalState("NEW_LABEL_CREATED");
}
}, [modalStates.NEW_LABEL_CREATED, card, cardId]);
// Open the new item form after creating a new checklist
useEffect(() => {
if (!card) return;
@@ -253,17 +208,6 @@ 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 (
@@ -271,71 +215,47 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<PageHead
title={t`${card?.title ?? t`Card`} | ${board?.name ?? t`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={`${isTemplate ? "/templates" : "/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]">
@@ -366,21 +286,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`}
@@ -388,6 +293,7 @@ export default function CardPage({ isTemplate }: { isTemplate?: boolean }) {
<div>
<ActivityList
cardPublicId={cardId}
activities={activities ?? []}
isLoading={!card}
isAdmin={workspace.role === "admin"}
/>

View File

@@ -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-light-950 dark: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"

View File

@@ -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" },

View File

@@ -1,14 +1,15 @@
import type { FC, SVGProps } from "react";
import { Trans } from "@lingui/react/macro";
import AirbusLogo from "~/assets/logos/airbus.svg";
import CouchbaseLogo from "~/assets/logos/couchbase.svg";
import DeloitteLogo from "~/assets/logos/deloitte.svg";
import FastCompanyLogo from "~/assets/logos/fast_company.svg";
import LegoLogo from "~/assets/logos/lego.svg";
import LinkedinLogo from "~/assets/logos/linkedin.svg";
import SanaLogo from "~/assets/logos/sana.svg";
import WakamLogo from "~/assets/logos/wakam.svg";
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>>;
@@ -30,31 +31,36 @@ export default function Logos() {
},
{
id: 3,
component: BitwardenLogo as LogoComponent,
alt: "Bitwarden Logo",
},
{
id: 4,
component: CouchbaseLogo as LogoComponent,
alt: "Couchbase Logo",
},
{
id: 4,
id: 5,
component: LegoLogo as LogoComponent,
alt: "Lego Logo",
},
{
id: 5,
id: 6,
component: AirbusLogo as LogoComponent,
alt: "Airbus Logo",
},
{
id: 6,
id: 7,
component: DeloitteLogo as LogoComponent,
alt: "Deloitte Logo",
},
{
id: 7,
id: 8,
component: WakamLogo as LogoComponent,
alt: "Wakam Logo",
},
{
id: 8,
id: 9,
component: LinkedinLogo as LogoComponent,
alt: "Linked In Logo",
},
@@ -76,7 +82,7 @@ export default function Logos() {
<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="flex animate-scroll" style={{ width: "max-content" }}>
<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;

View File

@@ -240,7 +240,7 @@ const Testimonials = () => {
</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">
<div className="grid grid-cols-1 gap-4 md:auto-rows-fr md:grid-cols-3">
{testimonials.map((testimonial, index) => (
<TestimonialCard
key={`testimonial-${index}`}

View File

@@ -176,13 +176,6 @@ export function InviteMemberForm({
};
const handleInviteLinkToggle = async () => {
if (
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
)
return handleUpgrade();
setIsLoadingInviteLink(true);
if (isShareInviteLinkEnabled && workspace.publicId) {
@@ -349,6 +342,11 @@ export function InviteMemberForm({
: t`Create invite link`
}
isChecked={isShareInviteLinkEnabled}
disabled={
env("NEXT_PUBLIC_KAN_ENV") === "cloud" &&
!hasTeamSubscription &&
!hasProSubscription
}
onChange={handleInviteLinkToggle}
/>
<div>
@@ -356,7 +354,7 @@ export function InviteMemberForm({
!hasTeamSubscription &&
!hasProSubscription ? (
<Button type="button" onClick={handleUpgrade}>
{t`Start 14 day free trial`}
{t`Upgrade to Team Plan`}
</Button>
) : (
<Button

View File

@@ -181,7 +181,7 @@ export default function MembersPage() {
<div className="flex items-center gap-3">
{env("NEXT_PUBLIC_KAN_ENV") === "cloud" && (
<>
{!proSubscription && !teamSubscription && (
{!proSubscription && (
<Link
href="/settings/workspace?upgrade=pro"
className="hidden items-center rounded-full border border-emerald-300 bg-emerald-50 px-3 py-1 text-center text-xs text-emerald-400 dark:border-emerald-700 dark:bg-emerald-950 dark:text-emerald-400 lg:flex"

View File

@@ -1,120 +0,0 @@
import Link from "next/link";
import { t } from "@lingui/core/macro";
import { useEffect, useState } from "react";
import Button from "~/components/Button";
import { PageHead } from "~/components/PageHead";
import Layout from "../home/components/Layout";
interface OSSFriend {
name: string;
description: string;
href: string;
}
interface OSSFriendsResponse {
data: OSSFriend[];
}
export default function OSSFriendsView() {
const [ossFriends, setOSSFriends] = useState<OSSFriend[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const fetchOSSFriends = async (): Promise<OSSFriend[]> => {
try {
const response = await fetch("/api/oss-friends");
if (!response.ok) {
throw new Error("Failed to fetch OSS friends");
}
const { data } = (await response.json()) as OSSFriendsResponse;
return data;
} catch (error) {
console.error("Error fetching OSS friends:", error);
return [];
}
};
useEffect(() => {
async function fetchData() {
try {
const data = await fetchOSSFriends();
setOSSFriends(data);
} finally {
setIsLoading(false);
}
}
void fetchData();
}, []);
return (
<Layout>
<PageHead title={`${t`OSS Friends`} | kan.bn`} />
<div className="flex h-full w-full flex-col lg:pt-[5rem]">
<div className="w-full pb-10 pt-32">
<div className="flex flex-col items-center justify-center px-4 pb-10">
<p className="mt-4 text-center text-3xl font-bold text-light-1000 dark:text-dark-1000 lg:text-3xl">
{t`Open Source Friends`}
</p>
<p className="text:md lg:text-md mt-4 max-w-[500px] text-center text-light-950 dark:text-dark-900">
{t`Check out some of our favorite open source projects.`}
</p>
</div>
</div>
<div className="mx-auto w-full max-w-7xl px-4 pb-24">
<div className="grid grid-cols-1 gap-4 md:grid-cols-3">
{isLoading &&
Array.from({ length: 12 }).map((_, idx) => (
<div
key={`oss-friend-skeleton-${idx}`}
className="group relative h-[200px] rounded-2xl border border-light-200 bg-light-50 p-6 dark:border-dark-200 dark:bg-dark-50"
>
<div className="mb-4 flex flex-col gap-3">
<div className="h-5 w-2/3 animate-pulse rounded-sm bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-full animate-pulse rounded-sm bg-light-200 dark:bg-dark-200" />
<div className="h-3 w-5/6 animate-pulse rounded-sm bg-light-200 dark:bg-dark-200" />
</div>
<div className="h-8 w-28 animate-pulse rounded-md bg-light-200 dark:bg-dark-200" />
</div>
))}
{!isLoading &&
ossFriends.map((friend, idx) => {
return (
<div
key={`oss-friend-${idx}`}
className="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"
>
<div className="mb-4 flex flex-col gap-2">
<Link
href={friend.href}
target="_blank"
rel="noopener noreferrer"
>
<h3 className="text-lg font-semibold text-light-1000 dark:text-dark-1000">
{friend.name}
</h3>
</Link>
<p className="text-sm leading-relaxed text-light-900 dark:text-dark-900">
{friend.description}
</p>
</div>
<Button
href={friend.href}
variant="secondary"
rel="noopener noreferrer"
size="sm"
>
{t`Learn more`}
</Button>
</div>
);
})}
</div>
</div>
</div>
</Layout>
);
}

View File

@@ -131,11 +131,11 @@ const Pricing = ({
>
{tier.name}
</h3>
{tier.highlighted && (
{tier.highlighted && frequency?.value === "annually" ? (
<p className="rounded-full bg-light-50 px-2.5 py-1 text-[12px] font-semibold text-dark-500 dark:bg-dark-1000 dark:text-dark-50">
{t`14 day free trial`}
-20%
</p>
)}
) : null}
</div>
<p
className={twMerge(

View File

@@ -9,7 +9,6 @@ import LabelIcon from "~/components/LabelIcon";
import { useModal } from "~/providers/modal";
import { api } from "~/utils/api";
import ActivityList from "~/views/card/components/ActivityList";
import { AttachmentThumbnails } from "~/views/card/components/AttachmentThumbnails";
import Checklists from "~/views/card/components/Checklists";
export function CardModal({
@@ -67,23 +66,22 @@ export function CardModal({
<div className="flex w-full items-center justify-between">
<button
className="absolute right-[2rem] top-[2rem] rounded p-1 hover:bg-light-300 focus:outline-none dark:hover:bg-dark-300"
onClick={(e) => {
onClick={async (e) => {
e.preventDefault();
closeModal();
setTimeout(() => {
void router.replace(
{
pathname: router.pathname,
query: {
...router.query,
workspaceSlug,
boardSlug: [boardSlug],
setTimeout(async () => {
try {
await router.replace(
`/${workspaceSlug}/${boardSlug}`,
undefined,
{
shallow: true,
},
},
undefined,
{ shallow: true },
);
);
} catch (error) {
console.error(error);
}
}, 400);
}}
>
@@ -128,24 +126,11 @@ export function CardModal({
<Editor
content={data.description}
readOnly
workspaceMembers={
data?.list.board.workspace.members ?? []
}
workspaceMembers={data?.list.board.workspace.members ?? []}
/>
</div>
</div>
)}
{data?.attachments &&
data.attachments.length > 0 &&
cardPublicId && (
<div className="mb-10 max-w-2xl">
<AttachmentThumbnails
attachments={data.attachments}
cardPublicId={cardPublicId}
isReadOnly
/>
</div>
)}
{data?.checklists && data.checklists.length > 0 && (
<Checklists
checklists={data.checklists}
@@ -161,6 +146,7 @@ export function CardModal({
{cardPublicId && (
<ActivityList
cardPublicId={cardPublicId}
activities={data?.activities ?? []}
isLoading={isLoading}
isViewOnly={true}
/>

View File

@@ -38,25 +38,12 @@ export default function PublicBoardView() {
? router.query.workspaceSlug[0]
: router.query.workspaceSlug;
const dueDateFilters = formatToArray(router.query.dueDate) as (
| "overdue"
| "today"
| "tomorrow"
| "next-week"
| "next-month"
| "no-due-date"
)[];
const { data, isLoading } = api.board.bySlug.useQuery(
{
boardSlug: boardSlug ?? "",
workspaceSlug: workspaceSlug ?? "",
members: formatToArray(router.query.members),
labels: formatToArray(router.query.labels),
lists: formatToArray(router.query.lists),
...(dueDateFilters.length > 0 && {
dueDateFilters: dueDateFilters,
}),
},
{
enabled: router.isReady && !!boardSlug,
@@ -88,8 +75,7 @@ export default function PublicBoardView() {
);
};
const pathWithoutQuery = router.asPath.split("?")[0];
const splitPath = pathWithoutQuery?.split("/") ?? [];
const splitPath = router.asPath.split("/");
const cardPublicId = splitPath.length > 3 ? splitPath[3] : null;
useEffect(() => {
@@ -144,7 +130,6 @@ export default function PublicBoardView() {
<Filters
labels={data.labels ?? []}
members={[]}
lists={data.allLists ?? []}
isLoading={isLoading}
/>
</div>
@@ -185,37 +170,26 @@ export default function PublicBoardView() {
</span>
</div>
<div className="scrollbar-track-rounded-[4px] scrollbar-thumb-rounded-[4px] scrollbar-w-[8px] z-10 h-full max-h-[calc(100vh-265px)] min-h-[2rem] overflow-y-auto pr-1 scrollbar dark:scrollbar-track-dark-100 dark:scrollbar-thumb-dark-600">
{list.cards.map((card) => {
return (
<Link
key={card.publicId}
href={{
pathname: router.pathname,
query: {
...router.query,
workspaceSlug: data.workspace.slug,
boardSlug: [data.slug, card.publicId],
},
}}
className={`mb-2 flex !cursor-pointer flex-col`}
shallow={true}
onClick={() => {
openModal("CARD");
}}
>
<Card
title={card.title}
labels={card.labels}
checklists={card.checklists ?? []}
members={[]}
description={card.description}
comments={card.comments ?? []}
attachments={card.attachments}
dueDate={card.dueDate ?? null}
/>
</Link>
);
})}
{list.cards.map((card) => (
<Link
key={card.publicId}
href={`/${data.workspace.slug}/${data.slug}/${card.publicId}`}
className={`mb-2 flex !cursor-pointer flex-col`}
shallow={true}
onClick={() => {
openModal("CARD");
}}
>
<Card
title={card.title}
labels={card.labels}
checklists={card.checklists ?? []}
members={[]}
description={card.description}
comments={card.comments ?? []}
/>
</Link>
))}
</div>
</div>
))}

Some files were not shown because too many files have changed in this diff Show More